mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-03 15:40:34 +01:00
Compare commits
21 Commits
feat/add-r
...
fix/ui-mig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d48dbec881 | ||
|
|
a81f66b995 | ||
|
|
af72a4118b | ||
|
|
dc6a1d0a4e | ||
|
|
2e60ab0f81 | ||
|
|
29ed44a8ce | ||
|
|
76ee298605 | ||
|
|
218f8269dd | ||
|
|
e8effa5b3f | ||
|
|
ca5ff7f617 | ||
|
|
a487b311bc | ||
|
|
6473066193 | ||
|
|
fb0d34ae35 | ||
|
|
ba684acba3 | ||
|
|
184724003a | ||
|
|
a4d3f10da8 | ||
|
|
a71ac2ada6 | ||
|
|
0963ff08cd | ||
|
|
e43aeb8e24 | ||
|
|
9074208b09 | ||
|
|
571e23910e |
19
.github/CODEOWNERS
vendored
19
.github/CODEOWNERS
vendored
@@ -169,3 +169,22 @@ go.mod @therealpandey
|
||||
## Dashboard V2
|
||||
/frontend/src/pages/DashboardPageV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/DashboardsListPageV2/ @SigNoz/pulse-frontend
|
||||
|
||||
## Infrastructure Monitoring
|
||||
/frontend/src/pages/InfrastructureMonitoring/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/InfraMonitoringHosts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/InfraMonitoringK8s/ @SigNoz/pulse-frontend
|
||||
|
||||
## Alerts
|
||||
/frontend/src/pages/AlertList/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/AlertDetails/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/CreateAlert/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/EditRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AlertHistory/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertRule/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/EditAlertV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/FormAlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
|
||||
|
||||
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -52,6 +52,7 @@ jobs:
|
||||
- rootuser
|
||||
- serviceaccount
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
|
||||
@@ -64,16 +64,16 @@ web:
|
||||
settings:
|
||||
posthog:
|
||||
# Whether to enable PostHog in web.
|
||||
enabled: true
|
||||
enabled: false
|
||||
appcues:
|
||||
# Whether to enable Appcues in web.
|
||||
enabled: true
|
||||
enabled: false
|
||||
sentry:
|
||||
# Whether to enable Sentry in web.
|
||||
enabled: true
|
||||
enabled: false
|
||||
pylon:
|
||||
# Whether to enable Pylon in web.
|
||||
enabled: true
|
||||
enabled: false
|
||||
|
||||
##################### Cache #####################
|
||||
cache:
|
||||
|
||||
@@ -6572,6 +6572,15 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
SpantypesOtelSpanRef:
|
||||
properties:
|
||||
refType:
|
||||
type: string
|
||||
spanId:
|
||||
type: string
|
||||
traceId:
|
||||
type: string
|
||||
type: object
|
||||
SpantypesPostableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -6835,6 +6844,10 @@ components:
|
||||
type: string
|
||||
parent_span_id:
|
||||
type: string
|
||||
references:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesOtelSpanRef'
|
||||
type: array
|
||||
resource:
|
||||
additionalProperties:
|
||||
type: string
|
||||
@@ -6860,6 +6873,8 @@ components:
|
||||
type: string
|
||||
trace_state:
|
||||
type: string
|
||||
required:
|
||||
- references
|
||||
type: object
|
||||
TagtypesPostableTag:
|
||||
properties:
|
||||
|
||||
@@ -7768,6 +7768,21 @@ export interface SpantypesGettableTraceAggregationsDTO {
|
||||
aggregations: SpantypesSpanAggregationResultDTO[];
|
||||
}
|
||||
|
||||
export interface SpantypesOtelSpanRefDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
refType?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
spanId?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -7862,6 +7877,10 @@ export interface SpantypesWaterfallSpanDTO {
|
||||
* @type string
|
||||
*/
|
||||
parent_span_id?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
references: SpantypesOtelSpanRefDTO[];
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
|
||||
@@ -120,7 +120,8 @@ export const interceptorRejected = async (
|
||||
!(
|
||||
response.config.url === '/sessions' && response.config.method === 'delete'
|
||||
) &&
|
||||
response.config.url !== '/authz/check'
|
||||
response.config.url !== '/authz/check' &&
|
||||
response.config.url !== '/api/v2/reset_password_tokens/verify'
|
||||
) {
|
||||
try {
|
||||
const accessToken = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#prefix__clip0_4062_7291)" stroke-width="1.167" stroke-linecap="round" stroke-linejoin="round"><path d="M7 12.833A5.833 5.833 0 107 1.167a5.833 5.833 0 000 11.666z" fill="#E5484D" stroke="#E5484D"/><path d="M8.75 5.25l-3.5 3.5M5.25 5.25l3.5 3.5" stroke="#121317"/></g><defs><clipPath id="prefix__clip0_4062_7291"><path fill="#fff" d="M0 0h14v14H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 467 B |
@@ -359,8 +359,7 @@ function CustomTimePickerPopoverContent({
|
||||
<Clock
|
||||
color={Color.BG_ROBIN_400}
|
||||
className="timezone-container__clock-icon"
|
||||
height={12}
|
||||
width={12}
|
||||
size={14}
|
||||
/>
|
||||
|
||||
<span className="timezone__name">{timezone.name}</span>
|
||||
|
||||
@@ -27,13 +27,15 @@ function SortableField({
|
||||
field,
|
||||
onRemove,
|
||||
allowDrag,
|
||||
isRequired,
|
||||
}: {
|
||||
field: TelemetryFieldKey;
|
||||
onRemove: (field: TelemetryFieldKey) => void;
|
||||
allowDrag: boolean;
|
||||
isRequired: boolean;
|
||||
}): JSX.Element {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: field.name });
|
||||
useSortable({ id: field.key as string });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -53,15 +55,17 @@ function SortableField({
|
||||
{allowDrag && <GripVertical size={14} />}
|
||||
<span className={styles.fieldKey}>{field.name}</span>
|
||||
</div>
|
||||
<Button
|
||||
className={cx(styles.removeBtn, 'periscope-btn')}
|
||||
variant="outlined"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
onClick={(): void => onRemove(field)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
{!isRequired && (
|
||||
<Button
|
||||
className={cx(styles.removeBtn, 'periscope-btn')}
|
||||
variant="outlined"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
onClick={(): void => onRemove(field)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +75,7 @@ interface AddedFieldsProps {
|
||||
fields: TelemetryFieldKey[];
|
||||
onFieldsChange: (fields: TelemetryFieldKey[]) => void;
|
||||
maxFields?: number;
|
||||
requiredFields?: readonly string[];
|
||||
}
|
||||
|
||||
function AddedFields({
|
||||
@@ -78,14 +83,18 @@ function AddedFields({
|
||||
fields,
|
||||
onFieldsChange,
|
||||
maxFields,
|
||||
requiredFields = [],
|
||||
}: AddedFieldsProps): JSX.Element {
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
// Contract: caller (FieldsSelector) normalizes `fields` so every entry has
|
||||
// `.key` populated. AddedFields reads it directly.
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent): void => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = fields.findIndex((f) => f.name === active.id);
|
||||
const newIndex = fields.findIndex((f) => f.name === over.id);
|
||||
const oldIndex = fields.findIndex((f) => f.key === active.id);
|
||||
const newIndex = fields.findIndex((f) => f.key === over.id);
|
||||
onFieldsChange(arrayMove(fields, oldIndex, newIndex));
|
||||
}
|
||||
};
|
||||
@@ -99,7 +108,7 @@ function AddedFields({
|
||||
);
|
||||
|
||||
const handleRemove = (field: TelemetryFieldKey): void => {
|
||||
onFieldsChange(fields.filter((f) => f.name !== field.name));
|
||||
onFieldsChange(fields.filter((f) => f.key !== field.key));
|
||||
};
|
||||
|
||||
const allowDrag = inputValue.length === 0;
|
||||
@@ -125,16 +134,17 @@ function AddedFields({
|
||||
<div className={styles.noValues}>No values found</div>
|
||||
) : (
|
||||
<SortableContext
|
||||
items={fields.map((f) => f.name)}
|
||||
items={fields.map((f) => f.key as string)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
disabled={!allowDrag}
|
||||
>
|
||||
{filteredFields.map((field) => (
|
||||
<SortableField
|
||||
key={field.name}
|
||||
key={field.key}
|
||||
field={field}
|
||||
onRemove={handleRemove}
|
||||
allowDrag={allowDrag}
|
||||
isRequired={requiredFields.includes(field.key as string)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
@@ -76,11 +76,8 @@
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
// Ant Skeleton.Input rendered inside the loading state — override its
|
||||
// hard-coded width.
|
||||
:global(.ant-skeleton-input) {
|
||||
width: 300px;
|
||||
margin: 8px 12px;
|
||||
width: 50% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +92,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
font-size: 13px;
|
||||
@@ -132,7 +130,7 @@
|
||||
}
|
||||
|
||||
.isDragDisabled {
|
||||
padding: 6px 12px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.otherFieldItem {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Input } from '@signozhq/ui/input';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -26,33 +27,36 @@ interface FieldsSelectorProps {
|
||||
onClose: () => void;
|
||||
signal: DataSource;
|
||||
maxFields?: number;
|
||||
requiredFields?: readonly string[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
defaultPosition?: { x: number; y: number };
|
||||
}
|
||||
|
||||
function FieldsSelector({
|
||||
isOpen,
|
||||
type FieldsSelectorContentProps = Omit<FieldsSelectorProps, 'isOpen'>;
|
||||
|
||||
// Inner component: holds all hooks + UI. Gets mounted/unmounted via the
|
||||
// outer gate so opening always seeds a fresh draft from `fields`.
|
||||
// Assumes `fields` arrives normalized (key populated) — see outer gate.
|
||||
function FieldsSelectorContent({
|
||||
title,
|
||||
fields,
|
||||
onFieldsChange,
|
||||
onClose,
|
||||
signal,
|
||||
maxFields,
|
||||
requiredFields,
|
||||
width = DEFAULT_PANEL_WIDTH,
|
||||
height,
|
||||
defaultPosition,
|
||||
}: FieldsSelectorProps): JSX.Element | null {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
}: FieldsSelectorContentProps): JSX.Element {
|
||||
const resolvedHeight =
|
||||
height ?? window.innerHeight - DEFAULT_PANEL_HEIGHT_OFFSET;
|
||||
const resolvedPosition = defaultPosition ?? {
|
||||
x: window.innerWidth - width - DEFAULT_PANEL_RIGHT_INSET,
|
||||
y: DEFAULT_PANEL_TOP_INSET,
|
||||
};
|
||||
|
||||
const [draftFields, setDraftFields] = useState<TelemetryFieldKey[]>(fields);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [debouncedInputValue, setDebouncedInputValue] = useState('');
|
||||
@@ -72,15 +76,17 @@ function FieldsSelector({
|
||||
|
||||
const handleAdd = useCallback(
|
||||
(field: TelemetryFieldKey): void => {
|
||||
if (maxFields !== undefined && draftFields.length >= maxFields) {
|
||||
return;
|
||||
}
|
||||
if (draftFields.some((f) => f.name === field.name)) {
|
||||
return;
|
||||
}
|
||||
setDraftFields((prev) => [...prev, field]);
|
||||
setDraftFields((prev) => {
|
||||
if (maxFields !== undefined && prev.length >= maxFields) {
|
||||
return prev;
|
||||
}
|
||||
if (prev.some((f) => f.key === field.key)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, field];
|
||||
});
|
||||
},
|
||||
[draftFields, maxFields],
|
||||
[maxFields],
|
||||
);
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
@@ -99,7 +105,7 @@ function FieldsSelector({
|
||||
() =>
|
||||
!(
|
||||
draftFields.length === fields.length &&
|
||||
draftFields.every((f, i) => f.name === fields[i]?.name)
|
||||
draftFields.every((f, i) => f.key === fields[i]?.key)
|
||||
),
|
||||
[draftFields, fields],
|
||||
);
|
||||
@@ -138,6 +144,7 @@ function FieldsSelector({
|
||||
fields={draftFields}
|
||||
onFieldsChange={setDraftFields}
|
||||
maxFields={maxFields}
|
||||
requiredFields={requiredFields}
|
||||
/>
|
||||
|
||||
<OtherFields
|
||||
@@ -173,4 +180,27 @@ function FieldsSelector({
|
||||
);
|
||||
}
|
||||
|
||||
// Outer gate: normalizes `fields` once (populates `key` so downstream code
|
||||
// can read it directly) and decides whether the inner component renders.
|
||||
// When isOpen flips false→true, the inner remounts → draft state seeds fresh.
|
||||
function FieldsSelector({
|
||||
isOpen,
|
||||
fields,
|
||||
...rest
|
||||
}: FieldsSelectorProps): JSX.Element | null {
|
||||
const normalizedFields = useMemo<TelemetryFieldKey[]>(
|
||||
() =>
|
||||
fields.map((f) => ({
|
||||
...f,
|
||||
key: f.key ?? buildCompositeKey(f.name, f.fieldContext),
|
||||
})),
|
||||
[fields],
|
||||
);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
return <FieldsSelectorContent {...rest} fields={normalizedFields} />;
|
||||
}
|
||||
|
||||
export default FieldsSelector;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import {
|
||||
FieldContext,
|
||||
@@ -47,15 +48,22 @@ function OtherFields({
|
||||
|
||||
const otherFields: TelemetryFieldKey[] = useMemo(() => {
|
||||
const suggestions = Object.values(data?.data.data.keys || {}).flat();
|
||||
const addedNames = new Set(addedFields.map((f) => f.name));
|
||||
return suggestions
|
||||
.filter((attr) => !addedNames.has(attr.name))
|
||||
.map((attr) => ({
|
||||
// Normalize: synthesize `key` once so downstream reads can trust it.
|
||||
const normalizedSuggestions: TelemetryFieldKey[] = suggestions.map(
|
||||
(attr) => ({
|
||||
...attr,
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext as string),
|
||||
signal: attr.signal as SignalType,
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
fieldDataType: attr.fieldDataType as FieldDataType,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
const addedIds = new Set(
|
||||
addedFields.map((f) => f.key ?? buildCompositeKey(f.name, f.fieldContext)),
|
||||
);
|
||||
return normalizedSuggestions.filter(
|
||||
(attr) => !addedIds.has(attr.key as string),
|
||||
);
|
||||
}, [data, addedFields]);
|
||||
|
||||
if (isFetching) {
|
||||
@@ -64,8 +72,13 @@ function OtherFields({
|
||||
<div className={styles.sectionHeader}>OTHER FIELDS</div>
|
||||
<div className={styles.otherList}>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Skeleton.Input active size="small" key={i} />
|
||||
<div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={i}
|
||||
className={cx(styles.fieldItem, styles.otherFieldItem)}
|
||||
>
|
||||
<Skeleton.Input active size="small" block />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,7 +96,7 @@ function OtherFields({
|
||||
) : (
|
||||
otherFields.map((attr) => (
|
||||
<div
|
||||
key={attr.name}
|
||||
key={attr.key}
|
||||
className={cx(styles.fieldItem, styles.otherFieldItem)}
|
||||
>
|
||||
<span className={styles.fieldKey}>{attr.name}</span>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import AddedFields from '../AddedFields';
|
||||
|
||||
// AddedFields assumes the caller has populated `key` (the parent
|
||||
// FieldsSelector does this via its normalization useMemo). Tests pre-populate
|
||||
// it directly.
|
||||
const makeField = (name: string, fieldContext = 'log'): TelemetryFieldKey => ({
|
||||
name,
|
||||
signal: 'logs',
|
||||
fieldContext: fieldContext as TelemetryFieldKey['fieldContext'],
|
||||
fieldDataType: 'string',
|
||||
key: `${fieldContext}.${name}`,
|
||||
});
|
||||
|
||||
describe('AddedFields — requiredFields', () => {
|
||||
it('renders a Remove button for every field when no requiredFields are passed', () => {
|
||||
const fields = [makeField('a'), makeField('b'), makeField('c')];
|
||||
|
||||
render(
|
||||
<AddedFields inputValue="" fields={fields} onFieldsChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('hides the Remove button for fields whose composite key is in requiredFields', () => {
|
||||
const fields = [makeField('a'), makeField('b'), makeField('c')];
|
||||
|
||||
render(
|
||||
<AddedFields
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['log.a', 'log.c']}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Only 'b' is removable.
|
||||
const removeButtons = screen.getAllByRole('button', { name: /remove/i });
|
||||
expect(removeButtons).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still renders the field name for required fields', () => {
|
||||
const fields = [makeField('a'), makeField('b')];
|
||||
|
||||
render(
|
||||
<AddedFields
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['log.a']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('a')).toBeInTheDocument();
|
||||
expect(screen.getByText('b')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('locks ONLY the canonical variant — a same-name field from another context stays removable', () => {
|
||||
// Two `body` fields with different contexts. requiredFields holds the
|
||||
// canonical composite key only, so the attribute variant is deletable.
|
||||
const fields = [makeField('body', 'log'), makeField('body', 'attribute')];
|
||||
|
||||
render(
|
||||
<AddedFields
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['log.body']}
|
||||
/>,
|
||||
);
|
||||
|
||||
// One Remove button: the attribute variant. log variant is locked.
|
||||
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not lock anything when a bare name is passed (composite key required)', () => {
|
||||
// Bare `body` no longer matches — matching is composite-key only now.
|
||||
const fields = [makeField('body', 'log'), makeField('body', 'attribute')];
|
||||
|
||||
render(
|
||||
<AddedFields
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['body']}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Neither variant locked → both removable.
|
||||
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('treats requiredFields as exact composite-key match (substring does not lock)', () => {
|
||||
const fields = [makeField('body'), makeField('body_extra')];
|
||||
|
||||
render(
|
||||
<AddedFields
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['log.body']}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 'log.body' locked, 'log.body_extra' removable.
|
||||
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { Dot } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
import { Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { AIAssistantEvents } from 'container/AIAssistant/events';
|
||||
@@ -109,7 +110,7 @@ function HeaderRightSection({
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<TooltipSimple title="Noz">
|
||||
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
|
||||
import { useLogsTableColumns } from '../useLogsTableColumns';
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
useTimezone: (): { formatTimezoneAdjustedTimestamp: jest.Mock } => ({
|
||||
formatTimezoneAdjustedTimestamp: jest.fn(() => 'TS'),
|
||||
}),
|
||||
}));
|
||||
|
||||
const field = (name: string, type = ''): IField => ({
|
||||
name,
|
||||
type,
|
||||
dataType: 'string',
|
||||
});
|
||||
|
||||
describe('useLogsTableColumns — selectColumns-order respected', () => {
|
||||
it('prepends stateIndicator and renders user fields in array order', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [field('c'), field('a'), field('b')],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('slots body and timestamp at their position in the fields array', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [
|
||||
field('service.name'),
|
||||
field('body', 'log'),
|
||||
field('request.id'),
|
||||
field('timestamp', 'log'),
|
||||
],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
// body/timestamp appear where the caller placed them, keyed by their
|
||||
// composite IDs ('log.*'); contextless user fields collapse to bare name.
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'service.name',
|
||||
'log.body',
|
||||
'request.id',
|
||||
'log.timestamp',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders a same-name field from another context as a DISTINCT column (no collision)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [field('body', 'log'), field('body', 'attribute')],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
const byId = new Map(result.current.map((c) => [c.id, c]));
|
||||
// Attribute variant is its own column, not a duplicate 'log.body'.
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'log.body',
|
||||
'attribute.body',
|
||||
]);
|
||||
expect(byId.get('log.body')?.enableRemove).toBe(false);
|
||||
expect(byId.get('attribute.body')?.enableRemove).toBe(true);
|
||||
});
|
||||
|
||||
it('applies the same distinct-column treatment to timestamp variants', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [field('timestamp', 'log'), field('timestamp', 'attribute')],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
const byId = new Map(result.current.map((c) => [c.id, c]));
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'log.timestamp',
|
||||
'attribute.timestamp',
|
||||
]);
|
||||
expect(byId.get('log.timestamp')?.enableRemove).toBe(false);
|
||||
expect(byId.get('attribute.timestamp')?.enableRemove).toBe(true);
|
||||
});
|
||||
|
||||
it('skips the synthetic "id" field name', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [field('id'), field('a'), field('b')],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the special body/timestamp coldefs (canBeHidden=false), not the generic user field def', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [
|
||||
field('body', 'log'),
|
||||
field('timestamp', 'log'),
|
||||
field('user_field'),
|
||||
],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
const byId = new Map(result.current.map((c) => [c.id, c]));
|
||||
// body + timestamp are locked from the table-X removal pathway.
|
||||
expect(byId.get('log.body')?.canBeHidden).toBe(false);
|
||||
expect(byId.get('log.body')?.enableRemove).toBe(false);
|
||||
expect(byId.get('log.timestamp')?.canBeHidden).toBe(false);
|
||||
expect(byId.get('log.timestamp')?.enableRemove).toBe(false);
|
||||
// User-added fields stay removable. User field has type='' so composite
|
||||
// collapses to bare name.
|
||||
expect(byId.get('user_field')?.enableRemove).toBe(true);
|
||||
});
|
||||
|
||||
it('renders only the stateIndicator when fields is empty', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual(['state-indicator']);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getSanitizedLogBody,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
@@ -18,13 +19,11 @@ import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
|
||||
type UseLogsTableColumnsProps = {
|
||||
fields: IField[];
|
||||
fontSize: FontSize;
|
||||
appendTo?: 'center' | 'end';
|
||||
};
|
||||
|
||||
export function useLogsTableColumns({
|
||||
fields,
|
||||
fontSize,
|
||||
appendTo = 'center',
|
||||
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
@@ -47,74 +46,74 @@ export function useLogsTableColumns({
|
||||
),
|
||||
};
|
||||
|
||||
const fieldColumns: TableColumnDef<ILog>[] = fields
|
||||
.filter((f): boolean => !['id', 'body', 'timestamp'].includes(f.name))
|
||||
.map(
|
||||
(f): TableColumnDef<ILog> => ({
|
||||
id: f.name,
|
||||
header: f.name,
|
||||
accessorFn: (log): unknown => FlatLogData(log)[f.name],
|
||||
enableRemove: true,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
|
||||
),
|
||||
}),
|
||||
);
|
||||
const timestampCol: TableColumnDef<ILog> = {
|
||||
id: buildCompositeKey('timestamp', 'log'),
|
||||
header: 'Timestamp',
|
||||
accessorFn: (log): unknown => log.timestamp,
|
||||
canBeHidden: false,
|
||||
enableRemove: false,
|
||||
width: { default: 170, min: 170 },
|
||||
cell: ({ value }): ReactElement => {
|
||||
const ts = value as string | number;
|
||||
const formatted =
|
||||
typeof ts === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
ts / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
return <TanStackTable.Text>{formatted}</TanStackTable.Text>;
|
||||
},
|
||||
};
|
||||
|
||||
const timestampCol: TableColumnDef<ILog> | null = fields.some(
|
||||
(f) => f.name === 'timestamp',
|
||||
)
|
||||
? {
|
||||
id: 'timestamp',
|
||||
header: 'Timestamp',
|
||||
accessorFn: (log): unknown => log.timestamp,
|
||||
canBeHidden: false,
|
||||
enableRemove: false,
|
||||
width: { default: 170, min: 170 },
|
||||
cell: ({ value }): ReactElement => {
|
||||
const ts = value as string | number;
|
||||
const formatted =
|
||||
typeof ts === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
ts / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
return <TanStackTable.Text>{formatted}</TanStackTable.Text>;
|
||||
},
|
||||
const bodyCol: TableColumnDef<ILog> = {
|
||||
id: buildCompositeKey('body', 'log'),
|
||||
header: 'Body',
|
||||
accessorFn: (log): string => getBodyDisplayString(log.body),
|
||||
canBeHidden: false,
|
||||
enableRemove: false,
|
||||
width: { default: '100%', min: 300 },
|
||||
cell: ({ value, isActive }): ReactElement => (
|
||||
<TanStackTable.Text
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getSanitizedLogBody(value as string, {
|
||||
shouldEscapeHtml: true,
|
||||
}),
|
||||
}}
|
||||
data-active={isActive}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
const makeUserFieldCol = (f: IField): TableColumnDef<ILog> => ({
|
||||
id: buildCompositeKey(f.name, f.type),
|
||||
header: f.name,
|
||||
accessorFn: (log): unknown => FlatLogData(log)[f.name],
|
||||
enableRemove: true,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
|
||||
),
|
||||
});
|
||||
|
||||
// Match body/timestamp by composite key, not bare name — else a variant
|
||||
// like `attribute.body` collapses onto `log.body`, duplicating the column.
|
||||
const fieldCols = fields
|
||||
.map((f): TableColumnDef<ILog> | null => {
|
||||
if (f.name === 'id') {
|
||||
return null;
|
||||
}
|
||||
: null;
|
||||
|
||||
const bodyCol: TableColumnDef<ILog> | null = fields.some(
|
||||
(f) => f.name === 'body',
|
||||
)
|
||||
? {
|
||||
id: 'body',
|
||||
header: 'Body',
|
||||
accessorFn: (log): string => getBodyDisplayString(log.body),
|
||||
canBeHidden: false,
|
||||
enableRemove: false,
|
||||
width: { default: '100%', min: 300 },
|
||||
cell: ({ value, isActive }): ReactElement => (
|
||||
<TanStackTable.Text
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getSanitizedLogBody(value as string, {
|
||||
shouldEscapeHtml: true,
|
||||
}),
|
||||
}}
|
||||
data-active={isActive}
|
||||
/>
|
||||
),
|
||||
const compositeKey = buildCompositeKey(f.name, f.type);
|
||||
if (compositeKey === timestampCol.id) {
|
||||
return timestampCol;
|
||||
}
|
||||
: null;
|
||||
if (compositeKey === bodyCol.id) {
|
||||
return bodyCol;
|
||||
}
|
||||
return makeUserFieldCol(f);
|
||||
})
|
||||
.filter((c): c is TableColumnDef<ILog> => c !== null);
|
||||
|
||||
return [
|
||||
stateIndicatorCol,
|
||||
...(timestampCol ? [timestampCol] : []),
|
||||
...(appendTo === 'center' ? fieldColumns : []),
|
||||
...(bodyCol ? [bodyCol] : []),
|
||||
...(appendTo === 'end' ? fieldColumns : []),
|
||||
];
|
||||
}, [fields, appendTo, fontSize, formatTimezoneAdjustedTimestamp]);
|
||||
return [stateIndicatorCol, ...fieldCols];
|
||||
}, [fields, fontSize, formatTimezoneAdjustedTimestamp]);
|
||||
}
|
||||
|
||||
@@ -256,6 +256,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
.edit-columns-container {
|
||||
padding: 12px;
|
||||
|
||||
.edit-columns-btn {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
padding: 4px 0px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: none !important;
|
||||
|
||||
.edit-columns-text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-columns-btn:hover {
|
||||
background-color: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
.add-new-column-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, InputNumber, Popover, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import cx from 'classnames';
|
||||
import { LogViewMode } from 'container/LogsTable';
|
||||
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
@@ -14,7 +11,6 @@ import {
|
||||
Minus,
|
||||
Plus,
|
||||
SlidersVertical,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import './LogsFormatOptionsMenu.styles.scss';
|
||||
@@ -23,14 +19,21 @@ interface LogsFormatOptionsMenuProps {
|
||||
items: any;
|
||||
selectedOptionFormat: any;
|
||||
config: OptionsMenuConfig;
|
||||
onOpenColumns?: () => void;
|
||||
}
|
||||
|
||||
interface OptionsMenuContentProps extends LogsFormatOptionsMenuProps {
|
||||
closePopover: () => void;
|
||||
}
|
||||
|
||||
function OptionsMenu({
|
||||
items,
|
||||
selectedOptionFormat,
|
||||
config,
|
||||
}: LogsFormatOptionsMenuProps): JSX.Element {
|
||||
const { maxLines, format, addColumn, fontSize } = config;
|
||||
onOpenColumns,
|
||||
closePopover,
|
||||
}: OptionsMenuContentProps): JSX.Element {
|
||||
const { maxLines, format, fontSize } = config;
|
||||
const [selectedItem, setSelectedItem] = useState(selectedOptionFormat);
|
||||
const maxLinesNumber = (maxLines?.value as number) || 1;
|
||||
const [maxLinesPerRow, setMaxLinesPerRow] = useState<number>(maxLinesNumber);
|
||||
@@ -40,13 +43,6 @@ function OptionsMenu({
|
||||
const [isFontSizeOptionsOpen, setIsFontSizeOptionsOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const [showAddNewColumnContainer, setShowAddNewColumnContainer] =
|
||||
useState(false);
|
||||
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const initialMouseEnterRef = useRef<boolean>(false);
|
||||
|
||||
const onChange = useCallback(
|
||||
(key: LogViewMode) => {
|
||||
if (!format) {
|
||||
@@ -61,7 +57,6 @@ function OptionsMenu({
|
||||
const handleMenuItemClick = (key: LogViewMode): void => {
|
||||
setSelectedItem(key);
|
||||
onChange(key);
|
||||
setShowAddNewColumnContainer(false);
|
||||
};
|
||||
|
||||
const incrementMaxLinesPerRow = (): void => {
|
||||
@@ -76,20 +71,6 @@ function OptionsMenu({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchValueChange = useDebouncedFn((event): void => {
|
||||
// @ts-expect-error
|
||||
const value = event?.target?.value || '';
|
||||
|
||||
if (addColumn && addColumn?.onSearch) {
|
||||
addColumn?.onSearch(value);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const handleToggleAddNewColumn = (): void => {
|
||||
addColumn?.onSearch?.('');
|
||||
setShowAddNewColumnContainer(!showAddNewColumnContainer);
|
||||
};
|
||||
|
||||
const handleLinesPerRowChange = (maxLinesPerRow: number | null): void => {
|
||||
if (
|
||||
maxLinesPerRow &&
|
||||
@@ -100,6 +81,11 @@ function OptionsMenu({
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditColumns = (): void => {
|
||||
onOpenColumns?.();
|
||||
closePopover();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (maxLinesPerRow && config && config.maxLines?.onChange) {
|
||||
config.maxLines.onChange(maxLinesPerRow);
|
||||
@@ -112,110 +98,10 @@ function OptionsMenu({
|
||||
}
|
||||
}, [fontSizeValue]);
|
||||
|
||||
function handleColumnSelection(
|
||||
currentIndex: number,
|
||||
optionsData: DefaultOptionType[],
|
||||
): void {
|
||||
const currentItem = optionsData[currentIndex];
|
||||
const itemLength = optionsData.length;
|
||||
if (addColumn && addColumn?.onSelect) {
|
||||
addColumn?.onSelect(selectedValue, {
|
||||
label: currentItem.label,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
// if the last element is selected then select the previous one
|
||||
if (currentIndex === itemLength - 1) {
|
||||
// there should be more than 1 element in the list
|
||||
if (currentIndex - 1 >= 0) {
|
||||
const prevValue = optionsData[currentIndex - 1]?.value || null;
|
||||
setSelectedValue(prevValue as string | null);
|
||||
} else {
|
||||
// if there is only one element then just select and do nothing
|
||||
setSelectedValue(null);
|
||||
}
|
||||
} else {
|
||||
// selecting any random element from the list except the last one
|
||||
const nextIndex = currentIndex + 1;
|
||||
|
||||
const nextValue = optionsData[nextIndex]?.value || null;
|
||||
|
||||
setSelectedValue(nextValue as string | null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (!selectedValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optionsData = addColumn?.options || [];
|
||||
|
||||
const currentIndex = optionsData.findIndex(
|
||||
(item) => item?.value === selectedValue,
|
||||
);
|
||||
|
||||
const itemLength = optionsData.length;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp': {
|
||||
const newValue = optionsData[Math.max(0, currentIndex - 1)]?.value;
|
||||
|
||||
setSelectedValue(newValue as string | null);
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
case 'ArrowDown': {
|
||||
const newValue =
|
||||
optionsData[Math.min(itemLength - 1, currentIndex + 1)]?.value;
|
||||
|
||||
setSelectedValue(newValue as string | null);
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
handleColumnSelection(currentIndex, optionsData);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll the selected item into view
|
||||
const listNode = listRef.current;
|
||||
if (listNode && selectedValue) {
|
||||
const optionsData = addColumn?.options || [];
|
||||
const currentIndex = optionsData.findIndex(
|
||||
(item) => item?.value === selectedValue,
|
||||
);
|
||||
const itemNode = listNode.children[currentIndex] as HTMLElement;
|
||||
if (itemNode) {
|
||||
itemNode.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedValue]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return (): void => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [selectedValue]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'nested-menu-container',
|
||||
showAddNewColumnContainer ? 'active' : '',
|
||||
)}
|
||||
className="nested-menu-container"
|
||||
onClick={(event): void => {
|
||||
// this is to restrict click events to propogate to parent
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
@@ -269,71 +155,7 @@ function OptionsMenu({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showAddNewColumnContainer && (
|
||||
<div className="add-new-column-container">
|
||||
<div className="add-new-column-header">
|
||||
<div className="title">
|
||||
<div className="periscope-btn ghost" onClick={handleToggleAddNewColumn}>
|
||||
<ChevronLeft
|
||||
size={14}
|
||||
className="back-icon"
|
||||
onClick={handleToggleAddNewColumn}
|
||||
/>
|
||||
</div>
|
||||
Add New Column
|
||||
</div>
|
||||
|
||||
<Input
|
||||
tabIndex={0}
|
||||
type="text"
|
||||
autoFocus
|
||||
onFocus={addColumn?.onFocus}
|
||||
onChange={handleSearchValueChange}
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="add-new-column-content">
|
||||
{addColumn?.isFetching && (
|
||||
<div className="loading-container"> Loading ... </div>
|
||||
)}
|
||||
|
||||
<div className="column-format-new-options" ref={listRef}>
|
||||
{addColumn?.options?.map(({ label, value }, index) => (
|
||||
<div
|
||||
className={cx('column-name', value === selectedValue && 'selected')}
|
||||
key={value}
|
||||
onMouseEnter={(): void => {
|
||||
if (!initialMouseEnterRef.current) {
|
||||
setSelectedValue(value as string | null);
|
||||
}
|
||||
|
||||
initialMouseEnterRef.current = true;
|
||||
}}
|
||||
onMouseMove={(): void => {
|
||||
// this is added to handle the mouse move explicit event and not the re-rendered on mouse enter event
|
||||
setSelectedValue(value as string | null);
|
||||
}}
|
||||
onClick={(eve): void => {
|
||||
eve.stopPropagation();
|
||||
handleColumnSelection(index, addColumn?.options || []);
|
||||
}}
|
||||
>
|
||||
<div className="name">
|
||||
<Tooltip placement="left" title={label}>
|
||||
{label}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFontSizeOptionsOpen && !showAddNewColumnContainer && (
|
||||
) : (
|
||||
<div>
|
||||
<div className="font-size-container">
|
||||
<div className="title">Font Size</div>
|
||||
@@ -373,72 +195,52 @@ function OptionsMenu({
|
||||
|
||||
{selectedItem && (
|
||||
<>
|
||||
<>
|
||||
<div className="horizontal-line" />
|
||||
<div className="max-lines-per-row">
|
||||
<div className="title"> max lines per row </div>
|
||||
<div className="raw-format max-lines-per-row-input">
|
||||
<button
|
||||
type="button"
|
||||
className="periscope-btn"
|
||||
onClick={decrementMaxLinesPerRow}
|
||||
>
|
||||
{' '}
|
||||
<Minus size={12} />{' '}
|
||||
</button>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={maxLinesPerRow}
|
||||
onChange={handleLinesPerRowChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="periscope-btn"
|
||||
onClick={incrementMaxLinesPerRow}
|
||||
>
|
||||
{' '}
|
||||
<Plus size={12} />{' '}
|
||||
</button>
|
||||
</div>
|
||||
<div className="horizontal-line" />
|
||||
<div className="max-lines-per-row">
|
||||
<div className="title"> max lines per row </div>
|
||||
<div className="raw-format max-lines-per-row-input">
|
||||
<button
|
||||
type="button"
|
||||
className="periscope-btn"
|
||||
onClick={decrementMaxLinesPerRow}
|
||||
>
|
||||
{' '}
|
||||
<Minus size={12} />{' '}
|
||||
</button>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={maxLinesPerRow}
|
||||
onChange={handleLinesPerRowChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="periscope-btn"
|
||||
onClick={incrementMaxLinesPerRow}
|
||||
>
|
||||
{' '}
|
||||
<Plus size={12} />{' '}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="selected-item-content-container active">
|
||||
{!showAddNewColumnContainer && <div className="horizontal-line" />}
|
||||
|
||||
<div className="item-content">
|
||||
{!showAddNewColumnContainer && (
|
||||
<div className="title">
|
||||
columns
|
||||
<Plus size={14} onClick={handleToggleAddNewColumn} />{' '}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="column-format">
|
||||
{addColumn?.value?.map(({ name }) => (
|
||||
<div className="column-name" key={name}>
|
||||
<div className="name">
|
||||
<Tooltip placement="left" title={name}>
|
||||
{name}
|
||||
</Tooltip>
|
||||
</div>
|
||||
{addColumn?.value?.length > 1 && (
|
||||
<X
|
||||
className="delete-btn"
|
||||
size={14}
|
||||
onClick={(): void => addColumn.onRemove(name)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{addColumn && addColumn?.value?.length === 0 && (
|
||||
<div className="column-name no-columns-selected">
|
||||
No columns selected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedItem === 'table' && onOpenColumns && (
|
||||
<>
|
||||
<div className="horizontal-line" />
|
||||
<div className="edit-columns-container">
|
||||
<Button
|
||||
className="edit-columns-btn"
|
||||
type="text"
|
||||
onClick={handleEditColumns}
|
||||
data-testid="periscope-btn-edit-columns"
|
||||
>
|
||||
<Typography.Text className="edit-columns-text">
|
||||
Edit columns
|
||||
</Typography.Text>
|
||||
<ChevronRight size={14} className="icon" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -452,6 +254,7 @@ function LogsFormatOptionsMenu({
|
||||
items,
|
||||
selectedOptionFormat,
|
||||
config,
|
||||
onOpenColumns,
|
||||
}: LogsFormatOptionsMenuProps): JSX.Element {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
return (
|
||||
@@ -461,6 +264,8 @@ function LogsFormatOptionsMenu({
|
||||
items={items}
|
||||
selectedOptionFormat={selectedOptionFormat}
|
||||
config={config}
|
||||
onOpenColumns={onOpenColumns}
|
||||
closePopover={(): void => setIsPopoverOpen(false)}
|
||||
/>
|
||||
}
|
||||
trigger="click"
|
||||
|
||||
@@ -155,4 +155,66 @@ describe('LogsFormatOptionsMenu (unit)', () => {
|
||||
expect(fontSizeOnChange).toHaveBeenCalledWith(FontSize.MEDIUM);
|
||||
});
|
||||
});
|
||||
|
||||
function renderWithOnOpen(
|
||||
onOpenColumns?: jest.Mock,
|
||||
selectedOptionFormat: 'table' | 'raw' | 'list' = 'table',
|
||||
): { getByTestId: ReturnType<typeof render>['getByTestId'] } {
|
||||
const items = [
|
||||
{ key: 'raw', label: 'Raw', data: { title: 'max lines per row' } },
|
||||
{ key: 'list', label: 'Default' },
|
||||
{ key: 'table', label: 'Column', data: { title: 'columns' } },
|
||||
];
|
||||
|
||||
const { getByTestId } = render(
|
||||
<LogsFormatOptionsMenu
|
||||
items={items}
|
||||
selectedOptionFormat={selectedOptionFormat}
|
||||
config={{
|
||||
format: { value: selectedOptionFormat, onChange: jest.fn() },
|
||||
maxLines: { value: 1, onChange: jest.fn() },
|
||||
fontSize: { value: FontSize.SMALL, onChange: jest.fn() },
|
||||
}}
|
||||
onOpenColumns={onOpenColumns}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(getByTestId('periscope-btn-format-options'));
|
||||
return { getByTestId };
|
||||
}
|
||||
|
||||
it('renders "Edit columns" row when format=table and onOpenColumns provided', () => {
|
||||
const onOpenColumns = jest.fn();
|
||||
const { getByTestId } = renderWithOnOpen(onOpenColumns, 'table');
|
||||
|
||||
expect(getByTestId('periscope-btn-edit-columns')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render "Edit columns" row when onOpenColumns is not provided', () => {
|
||||
renderWithOnOpen(undefined, 'table');
|
||||
|
||||
expect(
|
||||
document.querySelector('[data-testid="periscope-btn-edit-columns"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not render "Edit columns" row when format is not table', () => {
|
||||
renderWithOnOpen(jest.fn(), 'raw');
|
||||
|
||||
expect(
|
||||
document.querySelector('[data-testid="periscope-btn-edit-columns"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('fires onOpenColumns and closes the popover when "Edit columns" is clicked', async () => {
|
||||
const onOpenColumns = jest.fn();
|
||||
const { getByTestId } = renderWithOnOpen(onOpenColumns, 'table');
|
||||
|
||||
fireEvent.click(getByTestId('periscope-btn-edit-columns'));
|
||||
|
||||
expect(onOpenColumns).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
// Popover content unmounts on close.
|
||||
expect(document.querySelector('.menu-container')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,6 +109,16 @@ $custom-border-color: #2c3044;
|
||||
color: color-mix(in srgb, var(--l2-foreground) 45%, transparent);
|
||||
}
|
||||
|
||||
.ant-select-clear {
|
||||
background-color: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
// Customize tags in multiselect (dark mode by default)
|
||||
.ant-select-selection-item {
|
||||
background-color: var(--l1-border);
|
||||
@@ -392,7 +402,9 @@ $custom-border-color: #2c3044;
|
||||
// Custom dropdown styles for multi-select
|
||||
.custom-multiselect-dropdown {
|
||||
padding: 8px 0 0 0;
|
||||
max-height: 350px;
|
||||
// Tall enough to hold the react-virtuoso list (<=300px) + header/footer
|
||||
// so only the list scrolls (avoids a second scrollbar on this container).
|
||||
max-height: 410px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
@@ -483,8 +495,12 @@ $custom-border-color: #2c3044;
|
||||
.option-checkbox {
|
||||
width: 100%;
|
||||
|
||||
> span:not(.ant-checkbox) {
|
||||
width: 100%;
|
||||
// @signozhq/ui Checkbox renders children inside a <label> that is
|
||||
// content-sized by default. Make it fill the row (min-width: 0 lets it
|
||||
// shrink) so the option text below can truncate instead of overflowing.
|
||||
> label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.all-option-text {
|
||||
@@ -501,7 +517,12 @@ $custom-border-color: #2c3044;
|
||||
width: 100%;
|
||||
|
||||
.option-label-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.option-badge {
|
||||
@@ -514,26 +535,30 @@ $custom-border-color: #2c3044;
|
||||
}
|
||||
}
|
||||
|
||||
.only-btn {
|
||||
display: none;
|
||||
}
|
||||
// Size the buttons to the row's resting content height (20px) and fully
|
||||
// override antd's default 32px Button box, so revealing them on hover
|
||||
// never changes the row height.
|
||||
.only-btn,
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 18px;
|
||||
min-height: 0;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
.only-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
.toggle-btn:hover {
|
||||
background-color: unset;
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.option-content:hover {
|
||||
.only-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
}
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
@@ -548,9 +573,6 @@ $custom-border-color: #2c3044;
|
||||
.option-checkbox:hover {
|
||||
.toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
}
|
||||
.option-badge {
|
||||
display: none;
|
||||
|
||||
2
frontend/src/components/Noz/Noz.constants.ts
Normal file
2
frontend/src/components/Noz/Noz.constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Shared hover copy for every Noz entry point (header, floating trigger, sidebar). */
|
||||
export const NOZ_TOOLTIP_TITLE = 'Noz, your AI teammate';
|
||||
@@ -14,9 +14,7 @@ export function ShiftHoldOverlayController({
|
||||
|
||||
const actions = createShortcutActions({
|
||||
navigate: noop,
|
||||
// Overlay is read-only — actions never fire — so we only need a no-op
|
||||
// that satisfies the typed signature.
|
||||
handleThemeChange: (): void => undefined,
|
||||
handleThemeChange: noop,
|
||||
});
|
||||
|
||||
const visible = useShiftHoldOverlay({
|
||||
|
||||
@@ -72,6 +72,7 @@ function TanStackTableInner<TData>(
|
||||
data,
|
||||
columns,
|
||||
columnStorageKey,
|
||||
respectColumnOrder = true,
|
||||
columnSizing: columnSizingProp,
|
||||
onColumnSizingChange,
|
||||
onColumnOrderChange,
|
||||
@@ -175,6 +176,7 @@ function TanStackTableInner<TData>(
|
||||
storageKey: columnStorageKey,
|
||||
columns,
|
||||
isGrouped,
|
||||
respectColumnOrder,
|
||||
});
|
||||
|
||||
// Use store values when columnStorageKey is provided, otherwise fall back to props/defaults
|
||||
@@ -206,6 +208,7 @@ function TanStackTableInner<TData>(
|
||||
handleRemoveColumn,
|
||||
} = useColumnHandlers({
|
||||
columnStorageKey,
|
||||
respectColumnOrder,
|
||||
effectiveSizing,
|
||||
storeSetSizing,
|
||||
storeSetOrder,
|
||||
|
||||
@@ -24,6 +24,8 @@ jest.mock('../TanStackTable.module.scss', () => ({
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom doesn't include ResizeObserver — must direct-assign rather than
|
||||
// spyOn (spyOn requires the property to already exist).
|
||||
window.ResizeObserver = jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
@@ -867,4 +869,110 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasSingleColumn — gates the Remove popover per-column', () => {
|
||||
const hasSingleColumnFlagPresent = (): boolean =>
|
||||
Boolean(document.querySelector('th[data-single-column="true"]'));
|
||||
|
||||
it('is true when only one non-pinned column exists', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
data: [{ id: '1', name: 'Item 1', value: 100 }],
|
||||
columns: [
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(hasSingleColumnFlagPresent()).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when multiple non-pinned columns exist (all removable)', async () => {
|
||||
renderTanStackTable({});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 3 default columns (id/name/value), none pinned, none non-removable
|
||||
// → table is not single-column.
|
||||
expect(hasSingleColumnFlagPresent()).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when removable + non-removable mix exists (the body/timestamp case)', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
data: [{ id: '1', name: 'Item 1', value: 100 }],
|
||||
columns: [
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Timestamp',
|
||||
accessorKey: 'name',
|
||||
enableRemove: false,
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
{
|
||||
id: 'value',
|
||||
header: 'Body',
|
||||
accessorKey: 'value',
|
||||
enableRemove: false,
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
{
|
||||
id: 'id',
|
||||
header: 'User',
|
||||
accessorKey: 'id',
|
||||
enableRemove: true,
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(hasSingleColumnFlagPresent()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not count pinned columns toward the total', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
data: [{ id: '1', name: 'Item 1', value: 100 }],
|
||||
columns: [
|
||||
{
|
||||
id: 'stateIndicator',
|
||||
header: '',
|
||||
pin: 'left',
|
||||
accessorKey: 'id',
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 1 pinned + 1 non-pinned → only the non-pinned counts → single-column.
|
||||
expect(hasSingleColumnFlagPresent()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,6 +168,53 @@ describe('useColumnState', () => {
|
||||
'a',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores stored columnOrder when respectColumnOrder=false', () => {
|
||||
const columns = [col('a'), col('b'), col('c')];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({
|
||||
storageKey: TEST_KEY,
|
||||
columns,
|
||||
respectColumnOrder: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// Falls through to natural columns-array order; stored order is ignored.
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toStrictEqual([
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('honors stored columnOrder when respectColumnOrder=true (default)', () => {
|
||||
const columns = [col('a'), col('b'), col('c')];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({
|
||||
storageKey: TEST_KEY,
|
||||
columns,
|
||||
respectColumnOrder: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toStrictEqual([
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions', () => {
|
||||
|
||||
@@ -152,6 +152,8 @@ export type TanStackTableProps<TData> = {
|
||||
columns: TableColumnDef<TData>[];
|
||||
/** Storage key for column state persistence (visibility, sizing, ordering). When set, enables unified column management. */
|
||||
columnStorageKey?: string;
|
||||
/** When false, the table renders columns in their natural array order and the persisted columnOrder slot is ignored on read and skipped on write. Use when an external source (e.g. preferences.selectColumns) is the canonical order. Default true. */
|
||||
respectColumnOrder?: boolean;
|
||||
columnSizing?: ColumnSizingState;
|
||||
onColumnSizingChange?: Dispatch<SetStateAction<ColumnSizingState>>;
|
||||
onColumnOrderChange?: (cols: TableColumnDef<TData>[]) => void;
|
||||
|
||||
@@ -7,6 +7,8 @@ import { TableColumnDef } from './types';
|
||||
export interface UseColumnHandlersOptions<TData> {
|
||||
/** Storage key for persisting column state (enables store mode) */
|
||||
columnStorageKey?: string;
|
||||
/** When false, drag-reorder skips the persisted columnOrder write — order flows back via onColumnOrderChange only. */
|
||||
respectColumnOrder?: boolean;
|
||||
effectiveSizing: ColumnSizingState;
|
||||
storeSetSizing: (sizing: ColumnSizingState) => void;
|
||||
storeSetOrder: (columns: TableColumnDef<TData>[]) => void;
|
||||
@@ -28,6 +30,7 @@ export interface UseColumnHandlersResult<TData> {
|
||||
*/
|
||||
export function useColumnHandlers<TData>({
|
||||
columnStorageKey,
|
||||
respectColumnOrder = true,
|
||||
effectiveSizing,
|
||||
storeSetSizing,
|
||||
storeSetOrder,
|
||||
@@ -50,12 +53,12 @@ export function useColumnHandlers<TData>({
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(cols: TableColumnDef<TData>[]) => {
|
||||
if (columnStorageKey) {
|
||||
if (columnStorageKey && respectColumnOrder) {
|
||||
storeSetOrder(cols);
|
||||
}
|
||||
onColumnOrderChange?.(cols);
|
||||
},
|
||||
[columnStorageKey, storeSetOrder, onColumnOrderChange],
|
||||
[columnStorageKey, respectColumnOrder, storeSetOrder, onColumnOrderChange],
|
||||
);
|
||||
|
||||
const handleRemoveColumn = useCallback(
|
||||
|
||||
@@ -20,6 +20,7 @@ type UseColumnStateOptions<TData> = {
|
||||
storageKey?: string;
|
||||
columns: TableColumnDef<TData>[];
|
||||
isGrouped?: boolean;
|
||||
respectColumnOrder?: boolean;
|
||||
};
|
||||
|
||||
type UseColumnStateResult<TData> = {
|
||||
@@ -40,6 +41,7 @@ export function useColumnState<TData>({
|
||||
storageKey,
|
||||
columns,
|
||||
isGrouped = false,
|
||||
respectColumnOrder = true,
|
||||
}: UseColumnStateOptions<TData>): UseColumnStateResult<TData> {
|
||||
useEffect(() => {
|
||||
if (storageKey) {
|
||||
@@ -130,7 +132,7 @@ export function useColumnState<TData>({
|
||||
}, [hiddenColumnIds, columns, isGrouped]);
|
||||
|
||||
const sortedColumns = useMemo((): TableColumnDef<TData>[] => {
|
||||
if (columnOrder.length === 0) {
|
||||
if (!respectColumnOrder || columnOrder.length === 0) {
|
||||
return columns;
|
||||
}
|
||||
|
||||
@@ -144,7 +146,7 @@ export function useColumnState<TData>({
|
||||
});
|
||||
|
||||
return [...pinned, ...sortedRest];
|
||||
}, [columns, columnOrder]);
|
||||
}, [columns, columnOrder, respectColumnOrder]);
|
||||
|
||||
const hideColumn = useCallback(
|
||||
(columnId: string) => {
|
||||
|
||||
@@ -116,35 +116,17 @@ jest.mock('hooks/useNotifications', (): unknown => ({
|
||||
}));
|
||||
|
||||
// mock theme hook
|
||||
//
|
||||
// We spread jest.requireActual so additions to hooks/useDarkMode (new hooks,
|
||||
// re-exports, contexts) keep working in this test without needing the mock to
|
||||
// re-enumerate every export. We only override the hooks the palette actually
|
||||
// calls.
|
||||
jest.mock('hooks/useDarkMode', (): unknown => {
|
||||
const actual = jest.requireActual('hooks/useDarkMode');
|
||||
const useThemeModeMock = (): {
|
||||
jest.mock('hooks/useDarkMode', (): unknown => ({
|
||||
useThemeMode: (): {
|
||||
setAutoSwitch: jest.Mock;
|
||||
setTheme: jest.Mock;
|
||||
toggleTheme: jest.Mock;
|
||||
theme: string;
|
||||
autoSwitch: boolean;
|
||||
} => ({
|
||||
setAutoSwitch: jest.fn(),
|
||||
setTheme: jest.fn(),
|
||||
toggleTheme: jest.fn(),
|
||||
theme: 'dark',
|
||||
autoSwitch: false,
|
||||
});
|
||||
return {
|
||||
...actual,
|
||||
__esModule: true,
|
||||
default: useThemeModeMock,
|
||||
useThemeMode: useThemeModeMock,
|
||||
useIsDarkMode: (): boolean => true,
|
||||
useSystemTheme: (): 'dark' | 'light' => 'dark',
|
||||
};
|
||||
});
|
||||
}),
|
||||
}));
|
||||
|
||||
// mock updateUserPreference API and react-query mutation
|
||||
jest.mock('api/v1/user/preferences/name/update', (): jest.Mock => jest.fn());
|
||||
|
||||
@@ -21,8 +21,6 @@ import {
|
||||
useAIAssistantStore,
|
||||
} from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useThemeMode } from 'hooks/useDarkMode';
|
||||
import { ThemeMode } from 'hooks/useDarkMode/constant';
|
||||
import { useThemeSelection } from 'hooks/useDarkMode/useThemeSelection';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import history from 'lib/history';
|
||||
import { ROLES as UserRole } from 'types/roles';
|
||||
@@ -50,8 +48,7 @@ export function CmdKPalette({
|
||||
}): JSX.Element | null {
|
||||
const { open, setOpen } = useCmdK();
|
||||
|
||||
const { theme } = useThemeMode();
|
||||
const selectTheme = useThemeSelection();
|
||||
const { setAutoSwitch, setTheme, theme } = useThemeMode();
|
||||
const location = useLocation();
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
@@ -84,12 +81,14 @@ export function CmdKPalette({
|
||||
|
||||
useEffect(cmdKEffect, [setOpen]);
|
||||
|
||||
function handleThemeChange(value: ThemeMode): void {
|
||||
function handleThemeChange(value: string): void {
|
||||
logEvent('Account Settings: Theme Changed', { theme: value });
|
||||
// Close the palette inside the same flushSync batch as the theme change
|
||||
// so its dismissal is part of the captured "new" frame of the wipe;
|
||||
// otherwise the dialog would be visible in both snapshots and flicker.
|
||||
selectTheme(value, () => setOpen(false));
|
||||
if (value === 'auto') {
|
||||
setAutoSwitch(true);
|
||||
} else {
|
||||
setAutoSwitch(false);
|
||||
setTheme(value);
|
||||
}
|
||||
}
|
||||
|
||||
function onClickHandler(key: string): void {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { GlobalShortcutsName } from 'constants/shortcuts/globalShortcuts';
|
||||
import { THEME_MODE, ThemeMode } from 'hooks/useDarkMode/constant';
|
||||
import { THEME_MODE } from 'hooks/useDarkMode/constant';
|
||||
import {
|
||||
BarChart,
|
||||
BellDot,
|
||||
@@ -34,7 +34,7 @@ export type CmdAction = {
|
||||
|
||||
type ActionDeps = {
|
||||
navigate: (path: string) => void;
|
||||
handleThemeChange: (mode: ThemeMode) => void;
|
||||
handleThemeChange: (mode: string) => void;
|
||||
/**
|
||||
* Provided only when the AI Assistant feature is available for the current
|
||||
* tenant. When present, the palette surfaces an "Open AI Assistant" entry
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
|
||||
import { AIAssistantEvents, AIAssistantOpenSource } from '../events';
|
||||
import { normalizePage } from '../hooks/useAIAssistantAnalyticsContext';
|
||||
@@ -42,16 +43,15 @@ export default function AIAssistantTrigger(): JSX.Element | null {
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipSimple title="Noz">
|
||||
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={`${styles.trigger} noz-wave`}
|
||||
onClick={handleOpen}
|
||||
aria-label="Open Noz"
|
||||
>
|
||||
<Noz size={24} />
|
||||
</Button>
|
||||
prefix={<Noz size={24} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,8 +67,8 @@
|
||||
gap: 4px;
|
||||
|
||||
&--success {
|
||||
background: color-mix(in srgb, var(--success-background) 10%, transparent);
|
||||
color: var(--success-foreground);
|
||||
background: color-mix(in srgb, var(--text-forest-500) 10%, transparent);
|
||||
color: var(--text-forest-400);
|
||||
}
|
||||
|
||||
&--error {
|
||||
|
||||
@@ -196,7 +196,11 @@ function Footer(): JSX.Element {
|
||||
</Button>
|
||||
);
|
||||
if (alertValidationMessage) {
|
||||
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
|
||||
button = (
|
||||
<Tooltip title={alertValidationMessage}>
|
||||
<span>{button}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return button;
|
||||
}, [
|
||||
@@ -224,7 +228,11 @@ function Footer(): JSX.Element {
|
||||
</Button>
|
||||
);
|
||||
if (alertValidationMessage) {
|
||||
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
|
||||
button = (
|
||||
<Tooltip title={alertValidationMessage}>
|
||||
<span>{button}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return button;
|
||||
}, [
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
font-size: 10px;
|
||||
color: var(--l2-foreground);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,18 +47,10 @@
|
||||
}
|
||||
|
||||
.ant-tabs-tab-active {
|
||||
.overview-btn {
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.variables-btn {
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.overview-btn,
|
||||
.variables-btn,
|
||||
.public-dashboard-btn {
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
color: var(--primary-background);
|
||||
background: var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,15 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
|
||||
.sidenav-beta-tag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.variable-type-btn + .variable-type-btn {
|
||||
@@ -177,6 +186,7 @@
|
||||
|
||||
.multiple-values-section {
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0;
|
||||
|
||||
.typography-variables {
|
||||
@@ -193,6 +203,7 @@
|
||||
|
||||
.all-option-section {
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0;
|
||||
|
||||
.typography-variables {
|
||||
|
||||
@@ -518,7 +518,6 @@ function VariableItem({
|
||||
size={14}
|
||||
style={{
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
marginTop: 1,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -614,7 +613,6 @@ function VariableItem({
|
||||
size={14}
|
||||
style={{
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
marginTop: 1,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
}
|
||||
.ant-btn-default {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
.ant-tabs-tab-active {
|
||||
|
||||
@@ -2,12 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import FieldsSelector from 'components/FieldsSelector';
|
||||
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
|
||||
import { MAX_LOGS_LIST_SIZE } from 'constants/liveTail';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import GoToTop from 'container/GoToTop';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { LOGS_REQUIRED_COLUMNS } from 'container/OptionsMenu/constants';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
@@ -56,6 +58,8 @@ function LiveLogsContainer({
|
||||
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
|
||||
});
|
||||
|
||||
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
|
||||
|
||||
const formatItems = [
|
||||
{
|
||||
key: 'raw',
|
||||
@@ -238,6 +242,7 @@ function LiveLogsContainer({
|
||||
items={formatItems}
|
||||
selectedOptionFormat={options.format}
|
||||
config={config}
|
||||
onOpenColumns={(): void => setIsFieldsSelectorOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -261,6 +266,17 @@ function LiveLogsContainer({
|
||||
</div>
|
||||
|
||||
<GoToTop />
|
||||
{config.fieldsSelector && (
|
||||
<FieldsSelector
|
||||
isOpen={isFieldsSelectorOpen}
|
||||
title="Edit columns"
|
||||
fields={config.fieldsSelector.value}
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.LOGS}
|
||||
requiredFields={LOGS_REQUIRED_COLUMNS}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@ function LiveLogsList({
|
||||
const logsColumns = useLogsTableColumns({
|
||||
fields: selectedFields,
|
||||
fontSize: options.fontSize,
|
||||
appendTo: 'end',
|
||||
});
|
||||
|
||||
const makeOnLogCopy = useCallback(
|
||||
@@ -198,6 +197,7 @@ function LiveLogsList({
|
||||
ref={ref as React.Ref<TanStackTableHandle>}
|
||||
columns={logsColumns}
|
||||
columnStorageKey={LOCALSTORAGE.LOGS_LIST_COLUMNS}
|
||||
respectColumnOrder={false}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
plainTextCellLineClamp={options.maxLines}
|
||||
cellTypographySize={options.fontSize}
|
||||
|
||||
@@ -105,7 +105,6 @@ function LogsExplorerList({
|
||||
const logsColumns = useLogsTableColumns({
|
||||
fields: selectedFields,
|
||||
fontSize: options.fontSize,
|
||||
appendTo: 'end',
|
||||
});
|
||||
|
||||
const makeOnLogCopy = useCallback(
|
||||
@@ -204,6 +203,7 @@ function LogsExplorerList({
|
||||
ref={ref as React.Ref<TanStackTableHandle>}
|
||||
columns={logsColumns}
|
||||
columnStorageKey={LOCALSTORAGE.LOGS_LIST_COLUMNS}
|
||||
respectColumnOrder={false}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
plainTextCellLineClamp={options.maxLines}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import FieldsSelector from 'components/FieldsSelector';
|
||||
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { LOGS_REQUIRED_COLUMNS } from 'container/OptionsMenu/constants';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import QueryStatus from './QueryStatus';
|
||||
|
||||
function LogsActionsContainer({
|
||||
listQuery,
|
||||
selectedPanelType,
|
||||
@@ -18,10 +19,6 @@ function LogsActionsContainer({
|
||||
handleToggleFrequencyChart,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
isFetching,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
}: {
|
||||
listQuery: any;
|
||||
selectedPanelType: PANEL_TYPES;
|
||||
@@ -29,10 +26,6 @@ function LogsActionsContainer({
|
||||
handleToggleFrequencyChart: () => void;
|
||||
orderBy: string;
|
||||
setOrderBy: (value: string) => void;
|
||||
isFetching: boolean;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isSuccess: boolean;
|
||||
}): JSX.Element {
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
@@ -40,6 +33,8 @@ function LogsActionsContainer({
|
||||
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
|
||||
});
|
||||
|
||||
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
|
||||
|
||||
const formatItems = [
|
||||
{
|
||||
key: 'raw',
|
||||
@@ -102,23 +97,24 @@ function LogsActionsContainer({
|
||||
items={formatItems}
|
||||
selectedOptionFormat={options.format}
|
||||
config={config}
|
||||
onOpenColumns={(): void => setIsFieldsSelectorOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(selectedPanelType === PANEL_TYPES.TIME_SERIES ||
|
||||
selectedPanelType === PANEL_TYPES.TABLE) && (
|
||||
<div className="query-stats">
|
||||
<QueryStatus
|
||||
loading={isLoading || isFetching}
|
||||
error={isError}
|
||||
success={isSuccess}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{config.fieldsSelector && (
|
||||
<FieldsSelector
|
||||
isOpen={isFieldsSelectorOpen}
|
||||
title="Edit columns"
|
||||
fields={config.fieldsSelector.value}
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.LOGS}
|
||||
requiredFields={LOGS_REQUIRED_COLUMNS}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,40 +155,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.query-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
align-self: flex-end;
|
||||
|
||||
.rows {
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: 0.36px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.time {
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: 0.36px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
border: none;
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
.query-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { LoaderCircle, CircleCheck } from '@signozhq/icons';
|
||||
import { Spin } from 'antd';
|
||||
|
||||
import solidXCircleUrl from '@/assets/Icons/solid-x-circle.svg';
|
||||
|
||||
import './QueryStatus.styles.scss';
|
||||
|
||||
interface IQueryStatusProps {
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export default function QueryStatus(
|
||||
props: IQueryStatusProps,
|
||||
): React.ReactElement {
|
||||
const { loading, error, success } = props;
|
||||
|
||||
const content = useMemo((): React.ReactElement => {
|
||||
if (loading) {
|
||||
return (
|
||||
<Spin
|
||||
spinning
|
||||
size="small"
|
||||
indicator={<LoaderCircle className="animate-spin" size="md" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<img
|
||||
src={solidXCircleUrl}
|
||||
alt="header"
|
||||
className="error"
|
||||
style={{ height: '14px', width: '14px' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (success) {
|
||||
return (
|
||||
<CircleCheck className="success" size={14} fill={Color.BG_ROBIN_500} />
|
||||
);
|
||||
}
|
||||
return <div />;
|
||||
}, [error, loading, success]);
|
||||
return <div className="query-status">{content}</div>;
|
||||
}
|
||||
@@ -160,7 +160,7 @@ function LogsExplorerViewsContainer({
|
||||
'custom',
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching, isError, isSuccess, error } =
|
||||
const { data, isLoading, isFetching, isError, error } =
|
||||
useGetExplorerQueryRange(
|
||||
requestData,
|
||||
selectedPanelType,
|
||||
@@ -437,10 +437,6 @@ function LogsExplorerViewsContainer({
|
||||
handleToggleFrequencyChart={handleToggleFrequencyChart}
|
||||
orderBy={orderBy}
|
||||
setOrderBy={setOrderBy}
|
||||
isFetching={isFetching}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
isSuccess={isSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ function AllAttributes({
|
||||
);
|
||||
|
||||
const attributes = useMemo(
|
||||
() => attributesData?.data.attributes ?? [],
|
||||
() => attributesData?.data?.attributes ?? [],
|
||||
[attributesData],
|
||||
);
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ function MetricDetails({
|
||||
);
|
||||
|
||||
const metadata = useMemo(() => {
|
||||
if (!metricMetadataResponse) {
|
||||
if (!metricMetadataResponse?.data) {
|
||||
return null;
|
||||
}
|
||||
const { type, description, unit, temporality, isMonotonic } =
|
||||
|
||||
@@ -12,8 +12,6 @@ import APIError from 'types/api/error';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
const toggleThemeFunction = jest.fn();
|
||||
const setThemeFunction = jest.fn();
|
||||
const setAutoSwitchFunction = jest.fn();
|
||||
const logEventFunction = jest.fn();
|
||||
const copyToClipboardFn = jest.fn();
|
||||
const editUserFn = jest.fn();
|
||||
@@ -58,11 +56,9 @@ jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: jest.fn(() => true),
|
||||
useSystemTheme: jest.fn(() => 'dark'),
|
||||
default: jest.fn(() => ({
|
||||
theme: 'dark',
|
||||
setTheme: setThemeFunction,
|
||||
toggleTheme: toggleThemeFunction,
|
||||
autoSwitch: false,
|
||||
setAutoSwitch: setAutoSwitchFunction,
|
||||
setAutoSwitch: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -138,8 +134,7 @@ describe('MySettings Flows', () => {
|
||||
fireEvent.click(lightOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setAutoSwitchFunction).toHaveBeenCalledWith(false);
|
||||
expect(setThemeFunction).toHaveBeenCalledWith('light');
|
||||
expect(toggleThemeFunction).toHaveBeenCalled();
|
||||
expect(logEventFunction).toHaveBeenCalledWith(
|
||||
'Account Settings: Theme Changed',
|
||||
{
|
||||
@@ -147,10 +142,6 @@ describe('MySettings Flows', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Lock in that the new selectTheme flow does not call toggleTheme;
|
||||
// otherwise we'd double-flip on top of the explicit setTheme call.
|
||||
expect(toggleThemeFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
import { AxiosError } from 'axios';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import useThemeMode, { useIsDarkMode, useSystemTheme } from 'hooks/useDarkMode';
|
||||
import { THEME_MODE, ThemeMode } from 'hooks/useDarkMode/constant';
|
||||
import { useThemeSelection } from 'hooks/useDarkMode/useThemeSelection';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { MonitorCog, Moon, Sun } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
@@ -26,10 +24,9 @@ import './MySettings.styles.scss';
|
||||
function MySettings(): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
||||
const { autoSwitch } = useThemeMode();
|
||||
const { toggleTheme, autoSwitch, setAutoSwitch } = useThemeMode();
|
||||
const systemTheme = useSystemTheme();
|
||||
const { notifications } = useNotifications();
|
||||
const selectTheme = useThemeSelection();
|
||||
|
||||
const [sideNavPinned, setSideNavPinned] = useState(false);
|
||||
|
||||
@@ -62,7 +59,7 @@ function MySettings(): JSX.Element {
|
||||
<Moon data-testid="dark-theme-icon" size={12} /> Dark{' '}
|
||||
</div>
|
||||
),
|
||||
value: THEME_MODE.DARK,
|
||||
value: 'dark',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
@@ -71,7 +68,7 @@ function MySettings(): JSX.Element {
|
||||
<Badge color="robin">Beta</Badge>
|
||||
</div>
|
||||
),
|
||||
value: THEME_MODE.LIGHT,
|
||||
value: 'light',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
@@ -79,31 +76,46 @@ function MySettings(): JSX.Element {
|
||||
<MonitorCog size={12} data-testid="auto-theme-icon" /> System{' '}
|
||||
</div>
|
||||
),
|
||||
value: THEME_MODE.SYSTEM,
|
||||
value: 'auto',
|
||||
},
|
||||
];
|
||||
|
||||
const [theme, setTheme] = useState(() => {
|
||||
if (autoSwitch) {
|
||||
return THEME_MODE.SYSTEM;
|
||||
return 'auto';
|
||||
}
|
||||
return isDarkMode ? THEME_MODE.DARK : THEME_MODE.LIGHT;
|
||||
return isDarkMode ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
const handleThemeChange = (value: string): void => {
|
||||
// ToggleGroupSimple items above are all THEME_MODE values, so narrowing
|
||||
// the string here is safe.
|
||||
const mode = value as ThemeMode;
|
||||
logEvent('Account Settings: Theme Changed', { theme: mode });
|
||||
selectTheme(mode);
|
||||
logEvent('Account Settings: Theme Changed', {
|
||||
theme: value,
|
||||
});
|
||||
setTheme(value);
|
||||
|
||||
if (value === 'auto') {
|
||||
setAutoSwitch(true);
|
||||
} else {
|
||||
setAutoSwitch(false);
|
||||
// Only toggle if the current theme is different from the target
|
||||
const targetIsDark = value === 'dark';
|
||||
if (targetIsDark !== isDarkMode) {
|
||||
toggleTheme();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (autoSwitch) {
|
||||
setTheme(THEME_MODE.SYSTEM);
|
||||
setTheme('auto');
|
||||
return;
|
||||
}
|
||||
setTheme(isDarkMode ? THEME_MODE.DARK : THEME_MODE.LIGHT);
|
||||
|
||||
if (isDarkMode) {
|
||||
setTheme('dark');
|
||||
} else {
|
||||
setTheme('light');
|
||||
}
|
||||
}, [autoSwitch, isDarkMode]);
|
||||
|
||||
const handleSideNavPinnedChange = (checked: boolean): void => {
|
||||
|
||||
@@ -67,13 +67,23 @@ describe('ensureLogsRequiredColumns', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not duplicate if a required column appears twice in the input', () => {
|
||||
// Tolerant of malformed input — invariant only adds *missing* required
|
||||
// columns; it does not deduplicate existing entries (that's a separate
|
||||
// concern, not its job).
|
||||
it('collapses composite-key duplicates in the input', () => {
|
||||
// Two identical `body` entries → deduped to one, then timestamp prepended.
|
||||
const input = [BODY, BODY, ATTR_A];
|
||||
const result = ensureLogsRequiredColumns(input);
|
||||
expect(result.filter((c) => c.name === 'timestamp')).toHaveLength(1);
|
||||
expect(result[0]).toStrictEqual(TIMESTAMP);
|
||||
expect(result).toStrictEqual([TIMESTAMP, BODY, ATTR_A]);
|
||||
expect(result.filter((c) => c.name === 'body')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps same-name fields with different contexts as distinct columns', () => {
|
||||
// Different composite keys → both legitimate, neither deduped.
|
||||
const ATTR_BODY: TelemetryFieldKey = {
|
||||
name: 'body',
|
||||
signal: 'logs',
|
||||
fieldContext: 'attribute',
|
||||
fieldDataType: 'string',
|
||||
};
|
||||
const input = [TIMESTAMP, BODY, ATTR_BODY];
|
||||
expect(ensureLogsRequiredColumns(input)).toStrictEqual(input);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,4 +232,159 @@ describe('useOptionsMenu', () => {
|
||||
expect(optionNames).toContain('level');
|
||||
expect(optionNames).toContain('timestamp');
|
||||
});
|
||||
|
||||
describe('reorderSelectColumns / handleRemoveSelectedColumn — composite ID lookup', () => {
|
||||
const baseFormatting = {
|
||||
format: 'table',
|
||||
maxLines: 1,
|
||||
fontSize: 'small',
|
||||
};
|
||||
|
||||
// Two entries share the same `name` but differ in `fieldContext` — the
|
||||
// exact case composite IDs are meant to disambiguate.
|
||||
const seedColumns = [
|
||||
{
|
||||
name: 'body',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: 'string',
|
||||
signal: 'logs',
|
||||
},
|
||||
{
|
||||
name: 'service.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
signal: 'logs',
|
||||
},
|
||||
{
|
||||
name: 'service.name',
|
||||
fieldContext: 'attribute',
|
||||
fieldDataType: 'string',
|
||||
signal: 'logs',
|
||||
},
|
||||
{
|
||||
name: 'timestamp',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: '',
|
||||
signal: 'logs',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: { data: { data: { keys: {} } } },
|
||||
isFetching: false,
|
||||
});
|
||||
(usePreferenceContext as jest.Mock).mockReturnValue({
|
||||
traces: {
|
||||
preferences: { columns: [], formatting: baseFormatting },
|
||||
updateColumns: mockUpdateColumns,
|
||||
updateFormatting: mockUpdateFormatting,
|
||||
},
|
||||
logs: {
|
||||
preferences: { columns: seedColumns, formatting: baseFormatting },
|
||||
updateColumns: mockUpdateColumns,
|
||||
updateFormatting: mockUpdateFormatting,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('reorders by composite IDs — preserves both same-name variants distinctly', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useOptionsMenu({
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
);
|
||||
|
||||
// New order: [attribute.service.name, log.body, resource.service.name, log.timestamp]
|
||||
result.current.config.addColumn?.onReorder([
|
||||
'attribute.service.name',
|
||||
'log.body',
|
||||
'resource.service.name',
|
||||
'log.timestamp',
|
||||
]);
|
||||
|
||||
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
|
||||
const reordered = mockUpdateColumns.mock.calls[0][0];
|
||||
expect(
|
||||
reordered.map(
|
||||
(c: { name: string; fieldContext: string }) =>
|
||||
`${c.fieldContext}.${c.name}`,
|
||||
),
|
||||
).toStrictEqual([
|
||||
'attribute.service.name',
|
||||
'log.body',
|
||||
'resource.service.name',
|
||||
'log.timestamp',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reorder ignores ids that are not columns (e.g. state-indicator) and skips unknown ids', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useOptionsMenu({
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.config.addColumn?.onReorder([
|
||||
'state-indicator',
|
||||
'log.timestamp',
|
||||
'unknown.composite',
|
||||
'log.body',
|
||||
'resource.service.name',
|
||||
'attribute.service.name',
|
||||
]);
|
||||
|
||||
const reordered = mockUpdateColumns.mock.calls[0][0];
|
||||
// state-indicator + unknown.composite filtered; rest preserved in given order.
|
||||
expect(
|
||||
reordered.map(
|
||||
(c: { name: string; fieldContext: string }) =>
|
||||
`${c.fieldContext}.${c.name}`,
|
||||
),
|
||||
).toStrictEqual([
|
||||
'log.timestamp',
|
||||
'log.body',
|
||||
'resource.service.name',
|
||||
'attribute.service.name',
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes only the variant whose composite ID matches', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useOptionsMenu({
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
);
|
||||
|
||||
// Removing 'resource.service.name' should drop ONLY the resource variant.
|
||||
result.current.config.addColumn?.onRemove('resource.service.name');
|
||||
|
||||
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
|
||||
const remaining = mockUpdateColumns.mock.calls[0][0];
|
||||
expect(
|
||||
remaining.map(
|
||||
(c: { name: string; fieldContext: string }) =>
|
||||
`${c.fieldContext}.${c.name}`,
|
||||
),
|
||||
).toStrictEqual(['log.body', 'attribute.service.name', 'log.timestamp']);
|
||||
});
|
||||
|
||||
it('removing by a non-matching composite ID is a no-op (filter returns the full list)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useOptionsMenu({
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.config.addColumn?.onRemove('unknown.composite');
|
||||
|
||||
const remaining = mockUpdateColumns.mock.calls[0][0];
|
||||
// No entries match → full list preserved.
|
||||
expect(remaining).toHaveLength(seedColumns.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { FontSize, OptionsQuery } from './types';
|
||||
import { buildCompositeKey } from './utils';
|
||||
|
||||
export const URL_OPTIONS = 'options';
|
||||
|
||||
@@ -35,22 +36,48 @@ export const defaultLogsSelectedColumns: TelemetryFieldKey[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const LOGS_REQUIRED_COLUMNS = ['timestamp', 'body'] as const;
|
||||
// Names that must always be present in logs selectColumns (writer invariant).
|
||||
const LOGS_REQUIRED_COLUMN_NAMES = defaultLogsSelectedColumns.map(
|
||||
(c) => c.name,
|
||||
);
|
||||
|
||||
/**
|
||||
* Always-on invariant: every logs selectColumns array must contain `body` and
|
||||
* `timestamp`. Applied at both loader and writer boundaries so the picker, the
|
||||
* table, and persisted state can never diverge into a "missing required
|
||||
* column" state.
|
||||
*/
|
||||
// Composite keys (not bare names) so the picker locks ONLY the canonical
|
||||
// `log.body`/`log.timestamp` — a same-name variant like `attribute.body` stays
|
||||
// removable.
|
||||
export const LOGS_REQUIRED_COLUMNS = defaultLogsSelectedColumns.map((c) =>
|
||||
buildCompositeKey(c.name, c.fieldContext),
|
||||
);
|
||||
|
||||
// Drop composite-key duplicates (never legitimate — they only come from
|
||||
// corrupted state). Returns the same array reference when nothing to dedupe.
|
||||
export function dedupeColumnsByCompositeKey(
|
||||
columns: TelemetryFieldKey[],
|
||||
): TelemetryFieldKey[] {
|
||||
const seen = new Set<string>();
|
||||
let hasDuplicate = false;
|
||||
const deduped = columns.filter((c) => {
|
||||
const key = buildCompositeKey(c.name, c.fieldContext);
|
||||
if (seen.has(key)) {
|
||||
hasDuplicate = true;
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
return hasDuplicate ? deduped : columns;
|
||||
}
|
||||
|
||||
// Logs selectColumns invariant: no composite-key duplicates, and body +
|
||||
// timestamp always present. Applied at loader + writer boundaries.
|
||||
export function ensureLogsRequiredColumns(
|
||||
columns: TelemetryFieldKey[],
|
||||
): TelemetryFieldKey[] {
|
||||
const missing = LOGS_REQUIRED_COLUMNS.filter(
|
||||
(name) => !columns.some((c) => c.name === name),
|
||||
const deduped = dedupeColumnsByCompositeKey(columns);
|
||||
const missing = LOGS_REQUIRED_COLUMN_NAMES.filter(
|
||||
(name) => !deduped.some((c) => c.name === name),
|
||||
);
|
||||
if (missing.length === 0) {
|
||||
return columns;
|
||||
return deduped;
|
||||
}
|
||||
const defaultsByName = new Map(
|
||||
defaultLogsSelectedColumns.map((c) => [c.name, c]),
|
||||
@@ -58,7 +85,7 @@ export function ensureLogsRequiredColumns(
|
||||
const prepended = missing
|
||||
.map((name) => defaultsByName.get(name))
|
||||
.filter((c): c is TelemetryFieldKey => c !== undefined);
|
||||
return [...prepended, ...columns];
|
||||
return [...prepended, ...deduped];
|
||||
}
|
||||
|
||||
export const defaultTraceSelectedColumns: TelemetryFieldKey[] = [
|
||||
|
||||
@@ -42,4 +42,8 @@ export type OptionsMenuConfig = {
|
||||
onRemove: (key: string) => void;
|
||||
onReorder: (orderedIds: string[]) => void;
|
||||
};
|
||||
fieldsSelector?: {
|
||||
value: TelemetryFieldKey[];
|
||||
onFieldsChange: (next: TelemetryFieldKey[]) => void;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
OptionsMenuConfig,
|
||||
OptionsQuery,
|
||||
} from './types';
|
||||
import { getOptionsFromKeys } from './utils';
|
||||
import { buildCompositeKey, getOptionsFromKeys } from './utils';
|
||||
|
||||
interface UseOptionsMenuProps {
|
||||
storageKey?: string;
|
||||
@@ -281,7 +281,7 @@ const useOptionsMenu = ({
|
||||
const handleRemoveSelectedColumn = useCallback(
|
||||
(columnKey: string) => {
|
||||
const newSelectedColumns = preferences?.columns?.filter(
|
||||
({ name }) => name !== columnKey,
|
||||
(f) => buildCompositeKey(f.name, f.fieldContext) !== columnKey,
|
||||
);
|
||||
|
||||
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {
|
||||
@@ -363,9 +363,11 @@ const useOptionsMenu = ({
|
||||
const reorderSelectColumns = useCallback(
|
||||
(orderedIds: string[]): void => {
|
||||
const current = preferences?.columns ?? [];
|
||||
const byName = new Map(current.map((f) => [f.name, f]));
|
||||
const byCompositeKey = new Map(
|
||||
current.map((f) => [buildCompositeKey(f.name, f.fieldContext), f]),
|
||||
);
|
||||
const reordered = orderedIds
|
||||
.map((id) => byName.get(id))
|
||||
.map((id) => byCompositeKey.get(id))
|
||||
.filter((f): f is TelemetryFieldKey => f !== undefined);
|
||||
updateColumns(reordered);
|
||||
},
|
||||
@@ -396,6 +398,10 @@ const useOptionsMenu = ({
|
||||
onSearch: handleSearchAttribute,
|
||||
onReorder: reorderSelectColumns,
|
||||
},
|
||||
fieldsSelector: {
|
||||
value: preferences?.columns ?? [],
|
||||
onFieldsChange: updateColumns,
|
||||
},
|
||||
format: {
|
||||
value: preferences?.formatting?.format || defaultOptionsQuery.format,
|
||||
onChange: handleFormatChange,
|
||||
@@ -417,6 +423,7 @@ const useOptionsMenu = ({
|
||||
handleRemoveSelectedColumn,
|
||||
handleSearchAttribute,
|
||||
reorderSelectColumns,
|
||||
updateColumns,
|
||||
handleFormatChange,
|
||||
handleMaxLinesChange,
|
||||
handleFontSizeChange,
|
||||
|
||||
@@ -14,3 +14,9 @@ export const getOptionsFromKeys = (
|
||||
({ value }) => !selectedKeys.find((key) => key === value),
|
||||
);
|
||||
};
|
||||
|
||||
// Composite identity for a column. Disambiguates same-name fields across
|
||||
// different fieldContexts (e.g. resource.service.name vs attribute.service.name).
|
||||
// Falls back to bare name when context is missing.
|
||||
export const buildCompositeKey = (name: string, context?: string): string =>
|
||||
context ? `${context}.${name}` : name;
|
||||
|
||||
@@ -103,7 +103,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
|
||||
return {
|
||||
...rest,
|
||||
...(domainToAdminEmail && { domainToAdminEmail }),
|
||||
domainToAdminEmail: domainToAdminEmail ?? {},
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
@@ -129,7 +129,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
|
||||
return {
|
||||
...rest,
|
||||
...(groupMappings && { groupMappings }),
|
||||
groupMappings: groupMappings ?? {},
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { AuthtypesAuthNProviderDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
convertDomainMappingsToList,
|
||||
convertDomainMappingsToRecord,
|
||||
convertGroupMappingsToList,
|
||||
convertGroupMappingsToRecord,
|
||||
prepareInitialValues,
|
||||
} from './CreateEdit.utils';
|
||||
|
||||
describe('convertGroupMappingsToRecord', () => {
|
||||
it('returns undefined for an empty list', () => {
|
||||
expect(convertGroupMappingsToRecord([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when input is undefined', () => {
|
||||
expect(convertGroupMappingsToRecord(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('converts entries to a Record', () => {
|
||||
expect(
|
||||
convertGroupMappingsToRecord([
|
||||
{ groupName: 'admins', role: 'ADMIN' },
|
||||
{ groupName: 'viewers', role: 'VIEWER' },
|
||||
]),
|
||||
).toStrictEqual({ admins: 'ADMIN', viewers: 'VIEWER' });
|
||||
});
|
||||
|
||||
it('skips entries with missing groupName or role', () => {
|
||||
expect(
|
||||
convertGroupMappingsToRecord([
|
||||
{ groupName: 'admins', role: 'ADMIN' },
|
||||
{ groupName: '', role: 'VIEWER' },
|
||||
{ role: 'EDITOR' },
|
||||
]),
|
||||
).toStrictEqual({ admins: 'ADMIN' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertDomainMappingsToRecord', () => {
|
||||
it('returns undefined for an empty list', () => {
|
||||
expect(convertDomainMappingsToRecord([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when input is undefined', () => {
|
||||
expect(convertDomainMappingsToRecord(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('converts entries to a Record', () => {
|
||||
expect(
|
||||
convertDomainMappingsToRecord([
|
||||
{ domain: 'example.com', adminEmail: 'admin@example.com' },
|
||||
{ domain: 'corp.io', adminEmail: 'it@corp.io' },
|
||||
]),
|
||||
).toStrictEqual({
|
||||
'example.com': 'admin@example.com',
|
||||
'corp.io': 'it@corp.io',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip fidelity', () => {
|
||||
it('Record → list → Record preserves group mappings', () => {
|
||||
const original = { admins: 'ADMIN', devs: 'EDITOR', viewers: 'VIEWER' };
|
||||
expect(
|
||||
convertGroupMappingsToRecord(convertGroupMappingsToList(original)),
|
||||
).toStrictEqual(original);
|
||||
});
|
||||
|
||||
it('Record → list → Record preserves domain mappings', () => {
|
||||
const original = {
|
||||
'example.com': 'admin@example.com',
|
||||
'corp.io': 'it@corp.io',
|
||||
};
|
||||
expect(
|
||||
convertDomainMappingsToRecord(convertDomainMappingsToList(original)),
|
||||
).toStrictEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareInitialValues', () => {
|
||||
it('returns empty defaults when no record is provided', () => {
|
||||
expect(prepareInitialValues(undefined)).toStrictEqual({
|
||||
name: '',
|
||||
ssoEnabled: false,
|
||||
ssoType: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates groupMappings Record into groupMappingsList for the form', () => {
|
||||
const result = prepareInitialValues({
|
||||
id: 'domain-1',
|
||||
name: 'example.com',
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.saml,
|
||||
roleMapping: {
|
||||
defaultRole: 'VIEWER',
|
||||
useRoleAttribute: false,
|
||||
groupMappings: { admins: 'ADMIN', viewers: 'VIEWER' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.roleMapping?.groupMappingsList).toStrictEqual([
|
||||
{ groupName: 'admins', role: 'ADMIN' },
|
||||
{ groupName: 'viewers', role: 'VIEWER' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('hydrates domainToAdminEmail Record into domainToAdminEmailList for the form', () => {
|
||||
const result = prepareInitialValues({
|
||||
id: 'domain-1',
|
||||
name: 'example.com',
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.google_auth,
|
||||
googleAuthConfig: {
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
domainToAdminEmail: { 'example.com': 'admin@example.com' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.googleAuthConfig?.domainToAdminEmailList).toStrictEqual([
|
||||
{ domain: 'example.com', adminEmail: 'admin@example.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sets groupMappingsList to empty array when roleMapping has no groupMappings', () => {
|
||||
const result = prepareInitialValues({
|
||||
id: 'domain-1',
|
||||
name: 'example.com',
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.oidc,
|
||||
roleMapping: { defaultRole: 'VIEWER', useRoleAttribute: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.roleMapping?.groupMappingsList).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import CreateEdit from '../CreateEdit/CreateEdit';
|
||||
import {
|
||||
AUTH_DOMAINS_UPDATE_ENDPOINT,
|
||||
mockDomainWithRoleMapping,
|
||||
mockGoogleAuthWithWorkspaceGroups,
|
||||
mockUpdateSuccessResponse,
|
||||
} from './mocks';
|
||||
|
||||
// The real @signozhq/ui/button has internal effects that prevent form.validateFields()
|
||||
// from resolving inside act(). Mirror the pattern from SSOEnforcementToggle.test.tsx
|
||||
// which mocks @signozhq/ui/switch for the same reason.
|
||||
jest.mock('@signozhq/ui/button', () => ({
|
||||
...jest.requireActual('@signozhq/ui/button'),
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
loading,
|
||||
disabled,
|
||||
'aria-label': ariaLabel,
|
||||
prefix,
|
||||
suffix,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
'aria-label'?: string;
|
||||
prefix?: React.ReactNode;
|
||||
suffix?: React.ReactNode;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{prefix}
|
||||
{children}
|
||||
{suffix}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('CreateEdit — save payload correctness', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('sends groupMappings: {} when all group mappings are deleted', async () => {
|
||||
let capturedPayload: unknown = null;
|
||||
|
||||
server.use(
|
||||
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
|
||||
capturedPayload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockDomainWithRoleMapping}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open the Role Mapping collapse (Ant Design Collapse responds to click events)
|
||||
fireEvent.click(screen.getByText(/role mapping \(advanced\)/i));
|
||||
|
||||
// Wait for the 3 group mapping rows to appear
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /remove mapping/i }),
|
||||
).toHaveLength(3),
|
||||
);
|
||||
|
||||
// Delete each row; re-query after each removal
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', { name: /remove mapping/i })[0],
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /remove mapping/i }),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', { name: /remove mapping/i })[0],
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /remove mapping/i }),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', { name: /remove mapping/i })[0],
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryAllByRole('button', { name: /remove mapping/i }),
|
||||
).toHaveLength(0),
|
||||
);
|
||||
|
||||
// Submit — MSW intercepts the PUT request
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(capturedPayload).not.toBeNull());
|
||||
|
||||
expect(capturedPayload).toMatchObject({
|
||||
config: expect.objectContaining({
|
||||
roleMapping: expect.objectContaining({ groupMappings: {} }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('sends domainToAdminEmail: {} when all domain mappings are deleted', async () => {
|
||||
let capturedPayload: unknown = null;
|
||||
|
||||
server.use(
|
||||
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
|
||||
capturedPayload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockGoogleAuthWithWorkspaceGroups}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open the Google Workspace Groups collapse
|
||||
fireEvent.click(screen.getByText(/google workspace groups/i));
|
||||
|
||||
// Wait for the single domain mapping row
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /remove mapping/i }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// Delete the row
|
||||
fireEvent.click(screen.getByRole('button', { name: /remove mapping/i }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryAllByRole('button', { name: /remove mapping/i }),
|
||||
).toHaveLength(0),
|
||||
);
|
||||
|
||||
// Submit
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(capturedPayload).not.toBeNull());
|
||||
|
||||
expect(capturedPayload).toMatchObject({
|
||||
config: expect.objectContaining({
|
||||
googleAuthConfig: expect.objectContaining({
|
||||
domainToAdminEmail: {},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -72,8 +72,20 @@
|
||||
.alert-rule-scope {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.ant-radio-wrapper {
|
||||
color: var(--l1-foreground);
|
||||
// `.createForm label` styles field labels (font-weight 500, 14px,
|
||||
// 6px bottom padding). Those bleed into the @signozhq/ui RadioGroup
|
||||
// option labels, making them bold and vertically misaligned with the
|
||||
// radio control. Reset them back to plain option-text styling.
|
||||
label {
|
||||
padding: 0;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
// Loosen the design-system default (grid gap 0.5rem) between options.
|
||||
.silence-alerts-radio-group {
|
||||
margin-top: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,10 +156,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.ant-btn {
|
||||
margin-top: 8px;
|
||||
}
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.schedule-created-at {
|
||||
|
||||
@@ -54,8 +54,7 @@ import {
|
||||
} from './PlannedDowntimeutils';
|
||||
|
||||
import './PlannedDowntime.styles.scss';
|
||||
import { RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { RadioGroup } from '@signozhq/ui/radio-group';
|
||||
import { RadioGroupItem, RadioGroup } from '@signozhq/ui/radio-group';
|
||||
|
||||
dayjs.locale('en');
|
||||
dayjs.extend(utc);
|
||||
@@ -471,7 +470,7 @@ export function PlannedDowntimeForm(
|
||||
initialValue="specific"
|
||||
className="alert-rule-scope"
|
||||
>
|
||||
<RadioGroup>
|
||||
<RadioGroup className="silence-alerts-radio-group">
|
||||
<RadioGroupItem value="all">All alert rules</RadioGroupItem>
|
||||
<RadioGroupItem value="specific">Specific alert rules</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
|
||||
@@ -233,8 +233,10 @@
|
||||
background: var(--l1-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
padding: 0px;
|
||||
gap: 0;
|
||||
margin: 4px;
|
||||
|
||||
.ant-typography {
|
||||
.qb-tag-text {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px !important;
|
||||
@@ -244,7 +246,7 @@
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
> button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -259,26 +261,26 @@
|
||||
&.resource {
|
||||
border: 1px solid color-mix(in srgb, var(--bg-aqua-400) 13%, transparent);
|
||||
|
||||
.ant-typography {
|
||||
.qb-tag-text {
|
||||
color: var(--bg-aqua-400);
|
||||
background: color-mix(in srgb, var(--bg-aqua-400) 6%, transparent);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
> button {
|
||||
background: color-mix(in srgb, var(--bg-aqua-400) 6%, transparent);
|
||||
}
|
||||
}
|
||||
&.tag {
|
||||
border: 1px solid color-mix(in srgb, var(--bg-sienna-400) 20%, transparent);
|
||||
|
||||
.ant-typography {
|
||||
.qb-tag-text {
|
||||
color: var(--bg-sienna-400);
|
||||
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
> button {
|
||||
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
|
||||
}
|
||||
}
|
||||
@@ -286,13 +288,13 @@
|
||||
&.scope {
|
||||
border: 1px solid color-mix(in srgb, var(--bg-robin-400) 20%, transparent);
|
||||
|
||||
.ant-typography {
|
||||
.qb-tag-text {
|
||||
color: var(--bg-robin-400);
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
> button {
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -966,6 +966,7 @@ function QueryBuilderSearchV2(
|
||||
>
|
||||
<Tooltip title={chipValue}>
|
||||
<TypographyText
|
||||
className="qb-tag-text"
|
||||
$isInNin={isInNin}
|
||||
$isEnabled={!!searchValue}
|
||||
onClick={(): void => {
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
color: var(--semantic-primary-foreground);
|
||||
|
||||
&--error {
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
|
||||
.reset-password-header-title {
|
||||
|
||||
67
frontend/src/container/ResetPassword/TokenError.tsx
Normal file
67
frontend/src/container/ResetPassword/TokenError.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { CircleAlert } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import AuthPageContainer from 'components/AuthPageContainer';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import './ResetPassword.styles.scss';
|
||||
|
||||
interface TokenErrorContent {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
function getErrorContent(error?: APIError): TokenErrorContent {
|
||||
const code = error?.getErrorCode();
|
||||
|
||||
if (code === 'reset_password_token_expired') {
|
||||
return {
|
||||
title: 'Reset Password token is expired',
|
||||
subtitle:
|
||||
'Password reset links are single-use and expire after a set period. Please request a new password reset link.',
|
||||
};
|
||||
}
|
||||
|
||||
if (code === 'reset_password_token_not_found') {
|
||||
return {
|
||||
title: 'Invalid Reset Link',
|
||||
subtitle:
|
||||
'This reset password link is invalid or has already been used. Please request a new password reset link.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Reset Link Unavailable',
|
||||
subtitle:
|
||||
'We could not validate your reset password link. Please request a new one.',
|
||||
};
|
||||
}
|
||||
|
||||
interface TokenErrorProps {
|
||||
error?: APIError;
|
||||
}
|
||||
|
||||
function TokenError({ error }: TokenErrorProps): JSX.Element {
|
||||
const { title, subtitle } = getErrorContent(error);
|
||||
|
||||
return (
|
||||
<AuthPageContainer>
|
||||
<div className="reset-password-card reset-password-card--centered">
|
||||
<div className="reset-password-header">
|
||||
<div className="reset-password-header-icon reset-password-header-icon--error">
|
||||
<CircleAlert size={32} />
|
||||
</div>
|
||||
<Typography.Title level={4} className="reset-password-header-title">
|
||||
{title}
|
||||
</Typography.Title>
|
||||
<Typography.Text className="reset-password-header-subtitle">
|
||||
{subtitle}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{error && <AuthError error={error} />}
|
||||
</div>
|
||||
</AuthPageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenError;
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Logout } from 'api/utils';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
@@ -17,10 +16,6 @@ jest.mock('lib/history', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('api/utils', () => ({
|
||||
Logout: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockSuccessNotification = jest.fn();
|
||||
const mockErrorNotification = jest.fn();
|
||||
|
||||
@@ -70,17 +65,6 @@ describe('ResetPassword Component', () => {
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/signoz 1\.0\.0/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to login when token is missing', () => {
|
||||
window.history.pushState({}, '', '/password-reset');
|
||||
|
||||
render(<ResetPassword version="1.0.0" />, undefined, {
|
||||
initialRoute: '/password-reset',
|
||||
});
|
||||
|
||||
expect(Logout).toHaveBeenCalled();
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Validation', () => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-use';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { Form, Input as AntdInput } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Logout } from 'api/utils';
|
||||
import resetPasswordApi from 'api/v1/factor_password/resetPassword';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import AuthPageContainer from 'components/AuthPageContainer';
|
||||
@@ -38,13 +37,6 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
Logout();
|
||||
history.push(ROUTES.LOGIN);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const handleFormSubmit: () => Promise<void> = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
@@ -35,10 +35,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.ant-btn {
|
||||
margin-top: 8px;
|
||||
}
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.routing-policies-table {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Pin, PinOff } from '@signozhq/icons';
|
||||
|
||||
import { SidebarItem } from '../sideNav.types';
|
||||
|
||||
import './NavItem.styles.scss';
|
||||
import './NavItem.styles.scss';
|
||||
|
||||
export default function NavItem({
|
||||
@@ -27,7 +26,7 @@ export default function NavItem({
|
||||
showIcon?: boolean;
|
||||
dataTestId?: string;
|
||||
}): JSX.Element {
|
||||
const { label, icon, isBeta, isNew, isEarlyAccess } = item;
|
||||
const { label, icon, isBeta, isNew, isEarlyAccess, tooltip } = item;
|
||||
|
||||
const handleTogglePinClick = (
|
||||
event: React.MouseEvent<SVGSVGElement, MouseEvent>,
|
||||
@@ -36,7 +35,7 @@ export default function NavItem({
|
||||
onTogglePin?.(item);
|
||||
};
|
||||
|
||||
return (
|
||||
const navItem = (
|
||||
<div
|
||||
className={cx(
|
||||
'nav-item',
|
||||
@@ -107,6 +106,15 @@ export default function NavItem({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Only non-pinnable items set `tooltip`; it would nest with the pin tooltip.
|
||||
return tooltip ? (
|
||||
<Tooltip title={tooltip} placement="right">
|
||||
{navItem}
|
||||
</Tooltip>
|
||||
) : (
|
||||
navItem
|
||||
);
|
||||
}
|
||||
|
||||
NavItem.defaultProps = {
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from './sideNav.types';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
|
||||
export const getStartedMenuItem = {
|
||||
key: ROUTES.GET_STARTED,
|
||||
@@ -97,6 +98,7 @@ export const aiAssistantMenuItem = {
|
||||
icon: <Noz size={16} />,
|
||||
itemKey: 'ai-assistant',
|
||||
isEarlyAccess: true,
|
||||
tooltip: NOZ_TOOLTIP_TITLE,
|
||||
};
|
||||
|
||||
export const shortcutMenuItem = {
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface SidebarItem {
|
||||
isBeta?: boolean;
|
||||
isNew?: boolean;
|
||||
isEarlyAccess?: boolean;
|
||||
/** Hover copy for the whole item row (e.g. Noz's early-access tagline). */
|
||||
tooltip?: ReactNode;
|
||||
isPinned?: boolean;
|
||||
children?: SidebarItem[];
|
||||
isExternal?: boolean;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 80%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
|
||||
);
|
||||
) !important;
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 0;
|
||||
@@ -34,9 +34,9 @@
|
||||
}
|
||||
|
||||
.refresh-interval-text {
|
||||
padding: 12px 14px 8px 14px;
|
||||
padding: 12px 14px 8px 14px !important;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px; /* 163.636% */
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.3rem;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.optionsTrigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { memo } from 'react';
|
||||
import { OptionFormatTypes } from 'constants/optionsFormatTypes';
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings } from '@signozhq/icons';
|
||||
import FieldsSelector from 'components/FieldsSelector';
|
||||
import Controls, { ControlsProps } from 'container/Controls';
|
||||
import OptionsMenu from 'container/OptionsMenu';
|
||||
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { Container } from './styles';
|
||||
import styles from './Controls.module.scss';
|
||||
|
||||
function TraceExplorerControls({
|
||||
isLoading,
|
||||
@@ -14,6 +16,9 @@ function TraceExplorerControls({
|
||||
config,
|
||||
showSizeChanger = true,
|
||||
}: TraceExplorerControlsProps): JSX.Element | null {
|
||||
const { t } = useTranslation(['trace']);
|
||||
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
|
||||
|
||||
const {
|
||||
pagination,
|
||||
handleCountItemsPerPageChange,
|
||||
@@ -22,12 +27,25 @@ function TraceExplorerControls({
|
||||
} = useQueryPagination(totalCount, perPageOptions);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{config && (
|
||||
<OptionsMenu
|
||||
selectedOptionFormat={OptionFormatTypes.LIST} // Defaulting it to List view as options are shown only in the List view tab
|
||||
config={{ addColumn: config?.addColumn }}
|
||||
/>
|
||||
<div className={styles.container}>
|
||||
{config?.fieldsSelector && (
|
||||
<>
|
||||
<div
|
||||
className={styles.optionsTrigger}
|
||||
onClick={(): void => setIsFieldsSelectorOpen(true)}
|
||||
>
|
||||
{t('options_menu.options')}
|
||||
<Settings size="md" />
|
||||
</div>
|
||||
<FieldsSelector
|
||||
isOpen={isFieldsSelectorOpen}
|
||||
title="Edit columns"
|
||||
fields={config.fieldsSelector.value}
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Controls
|
||||
@@ -41,7 +59,7 @@ function TraceExplorerControls({
|
||||
handleNavigatePrevious={handleNavigatePrevious}
|
||||
showSizeChanger={showSizeChanger}
|
||||
/>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.3rem;
|
||||
margin: 8px 0;
|
||||
`;
|
||||
@@ -207,8 +207,9 @@ function ListView({
|
||||
const reordered = [...columns];
|
||||
const [moved] = reordered.splice(fromIndex, 1);
|
||||
reordered.splice(toIndex, 0, moved);
|
||||
// `key` is the composite (fieldContext.name) — disambiguates same-name fields.
|
||||
const orderedIds = reordered
|
||||
.map((c) => String(('dataIndex' in c && c.dataIndex) || c.key || ''))
|
||||
.map((c) => String(c.key || ('dataIndex' in c && c.dataIndex) || ''))
|
||||
.filter(Boolean);
|
||||
config?.addColumn?.onReorder(orderedIds);
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { formUrlParams } from 'container/TraceDetail/utils';
|
||||
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
@@ -83,12 +84,11 @@ export const getListColumns = (
|
||||
const columns: ColumnsType<RowData> =
|
||||
selectedColumns.map((props) => {
|
||||
const name = props?.name || (props as any)?.key;
|
||||
const fieldDataType = props?.fieldDataType || (props as any)?.dataType;
|
||||
const fieldContext = props?.fieldContext || (props as any)?.type;
|
||||
return {
|
||||
title: name,
|
||||
dataIndex: name,
|
||||
key: `${name}-${fieldDataType}-${fieldContext}`,
|
||||
key: buildCompositeKey(name, fieldContext),
|
||||
width: 145,
|
||||
render: (value, item): JSX.Element => {
|
||||
if (value === '') {
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useThemeSelection } from '../useThemeSelection';
|
||||
|
||||
const setThemeMock = jest.fn();
|
||||
const setAutoSwitchMock = jest.fn();
|
||||
let themeValue = 'dark';
|
||||
let systemThemeValue: 'dark' | 'light' = 'light';
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
__esModule: true,
|
||||
default: (): {
|
||||
theme: string;
|
||||
setTheme: jest.Mock;
|
||||
setAutoSwitch: jest.Mock;
|
||||
toggleTheme: jest.Mock;
|
||||
autoSwitch: boolean;
|
||||
} => ({
|
||||
theme: themeValue,
|
||||
setTheme: setThemeMock,
|
||||
setAutoSwitch: setAutoSwitchMock,
|
||||
toggleTheme: jest.fn(),
|
||||
autoSwitch: false,
|
||||
}),
|
||||
useThemeMode: (): {
|
||||
theme: string;
|
||||
setTheme: jest.Mock;
|
||||
setAutoSwitch: jest.Mock;
|
||||
toggleTheme: jest.Mock;
|
||||
autoSwitch: boolean;
|
||||
} => ({
|
||||
theme: themeValue,
|
||||
setTheme: setThemeMock,
|
||||
setAutoSwitch: setAutoSwitchMock,
|
||||
toggleTheme: jest.fn(),
|
||||
autoSwitch: false,
|
||||
}),
|
||||
useSystemTheme: (): 'dark' | 'light' => systemThemeValue,
|
||||
useIsDarkMode: (): boolean => themeValue === 'dark',
|
||||
}));
|
||||
|
||||
const canAnimateMock = jest.fn();
|
||||
const runTransitionMock = jest.fn();
|
||||
|
||||
jest.mock('utils/themeTransition', () => ({
|
||||
__esModule: true,
|
||||
canAnimateThemeTransition: (): boolean => canAnimateMock(),
|
||||
runThemeTransition: (cb: () => void): void => runTransitionMock(cb),
|
||||
}));
|
||||
|
||||
describe('useThemeSelection', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
themeValue = 'dark';
|
||||
systemThemeValue = 'light';
|
||||
canAnimateMock.mockReturnValue(false);
|
||||
// Default behaviour: invoke the applyChange callback synchronously.
|
||||
runTransitionMock.mockImplementation((cb: () => void) => cb());
|
||||
});
|
||||
|
||||
it('applies an explicit light theme without auto-switch', () => {
|
||||
themeValue = 'dark';
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('light'));
|
||||
|
||||
expect(setAutoSwitchMock).toHaveBeenCalledWith(false);
|
||||
expect(setThemeMock).toHaveBeenCalledWith('light');
|
||||
});
|
||||
|
||||
it('applies an explicit dark theme without auto-switch', () => {
|
||||
themeValue = 'light';
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('dark'));
|
||||
|
||||
expect(setAutoSwitchMock).toHaveBeenCalledWith(false);
|
||||
expect(setThemeMock).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
it('SYSTEM with a light system preference resolves to setTheme("light") + auto on', () => {
|
||||
themeValue = 'dark';
|
||||
systemThemeValue = 'light';
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('auto'));
|
||||
|
||||
expect(setAutoSwitchMock).toHaveBeenCalledWith(true);
|
||||
// Explicit resolved value is what keeps the wipe snapshot accurate;
|
||||
// see the comment in useThemeSelection for the failure mode.
|
||||
expect(setThemeMock).toHaveBeenCalledWith('light');
|
||||
});
|
||||
|
||||
it('SYSTEM with a dark system preference resolves to setTheme("dark") + auto on', () => {
|
||||
themeValue = 'light';
|
||||
systemThemeValue = 'dark';
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('auto'));
|
||||
|
||||
expect(setAutoSwitchMock).toHaveBeenCalledWith(true);
|
||||
expect(setThemeMock).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
it('invokes onApplied inside the same batch, after the state mutations', () => {
|
||||
themeValue = 'dark';
|
||||
const onApplied = jest.fn();
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('light', onApplied));
|
||||
|
||||
expect(onApplied).toHaveBeenCalledTimes(1);
|
||||
expect(setThemeMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
onApplied.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('routes through runThemeTransition when the dark↔light state actually flips', () => {
|
||||
themeValue = 'dark';
|
||||
canAnimateMock.mockReturnValue(true);
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('light'));
|
||||
|
||||
expect(runTransitionMock).toHaveBeenCalledTimes(1);
|
||||
expect(setThemeMock).toHaveBeenCalledWith('light');
|
||||
});
|
||||
|
||||
it('skips runThemeTransition when no dark↔light flip happens', () => {
|
||||
themeValue = 'dark';
|
||||
canAnimateMock.mockReturnValue(true);
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('dark'));
|
||||
|
||||
expect(runTransitionMock).not.toHaveBeenCalled();
|
||||
// applyChange still ran inline.
|
||||
expect(setThemeMock).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
it('skips runThemeTransition when SYSTEM resolves to the currently-rendered theme', () => {
|
||||
themeValue = 'dark';
|
||||
systemThemeValue = 'dark';
|
||||
canAnimateMock.mockReturnValue(true);
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('auto'));
|
||||
|
||||
expect(runTransitionMock).not.toHaveBeenCalled();
|
||||
expect(setAutoSwitchMock).toHaveBeenCalledWith(true);
|
||||
expect(setThemeMock).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
it('skips runThemeTransition when capability check is false even if the theme flips', () => {
|
||||
themeValue = 'dark';
|
||||
canAnimateMock.mockReturnValue(false);
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('light'));
|
||||
|
||||
expect(runTransitionMock).not.toHaveBeenCalled();
|
||||
expect(setThemeMock).toHaveBeenCalledWith('light');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,4 @@ export const THEME_MODE = {
|
||||
LIGHT: 'light',
|
||||
DARK: 'dark',
|
||||
SYSTEM: 'auto',
|
||||
} as const;
|
||||
|
||||
export type ThemeMode = (typeof THEME_MODE)[keyof typeof THEME_MODE];
|
||||
};
|
||||
|
||||
@@ -18,13 +18,7 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import { THEME_MODE } from './constant';
|
||||
|
||||
export const ThemeContext = createContext<{
|
||||
theme: string;
|
||||
toggleTheme: () => void;
|
||||
autoSwitch: boolean;
|
||||
setAutoSwitch: Dispatch<SetStateAction<boolean>>;
|
||||
setTheme: Dispatch<SetStateAction<string>>;
|
||||
}>({
|
||||
export const ThemeContext = createContext({
|
||||
theme: THEME_MODE.DARK,
|
||||
toggleTheme: (): void => {},
|
||||
autoSwitch: false,
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
canAnimateThemeTransition,
|
||||
runThemeTransition,
|
||||
} from 'utils/themeTransition';
|
||||
|
||||
import useThemeMode, { useSystemTheme } from './index';
|
||||
import { THEME_MODE, ThemeMode } from './constant';
|
||||
|
||||
type SelectTheme = (value: ThemeMode, onApplied?: () => void) => void;
|
||||
|
||||
// Centralises the "apply a theme selection" flow used by MySettings and the
|
||||
// command palette: figures out whether the visible (dark↔light) theme is
|
||||
// actually flipping, applies the state change, and — when capable — wraps the
|
||||
// change in a left→right view-transition wipe.
|
||||
//
|
||||
// `value` is one of THEME_MODE.{LIGHT,DARK,SYSTEM}; `onApplied` runs inside the
|
||||
// same flushSync batch as the theme change (useful for, e.g., closing the
|
||||
// command palette so its dismissal is part of the captured "new" snapshot).
|
||||
export function useThemeSelection(): SelectTheme {
|
||||
const { theme, setTheme, setAutoSwitch } = useThemeMode();
|
||||
const systemTheme = useSystemTheme();
|
||||
|
||||
return useCallback<SelectTheme>(
|
||||
(value, onApplied) => {
|
||||
const currentIsDark = theme === THEME_MODE.DARK;
|
||||
|
||||
// When switching to SYSTEM, the visible theme flips iff the OS preference
|
||||
// differs from what we're currently rendering. For explicit LIGHT/DARK,
|
||||
// resolvedTargetIsDark is just (value === DARK).
|
||||
const resolvedTargetIsDark =
|
||||
value === THEME_MODE.SYSTEM
|
||||
? systemTheme === THEME_MODE.DARK
|
||||
: value === THEME_MODE.DARK;
|
||||
const isSystem = value === THEME_MODE.SYSTEM;
|
||||
|
||||
// Always push the resolved LIGHT/DARK through setTheme synchronously so
|
||||
// the View Transition snapshot reflects the new theme. If we relied on
|
||||
// ThemeProvider's effect (setAutoSwitch → re-render → effect →
|
||||
// setThemeState), the flip wouldn't be guaranteed to run inside this
|
||||
// flushSync batch and the wipe would capture old → old, then snap.
|
||||
const resolvedTheme = resolvedTargetIsDark
|
||||
? THEME_MODE.DARK
|
||||
: THEME_MODE.LIGHT;
|
||||
|
||||
// runThemeTransition needs a zero-arg callback, so this closure is
|
||||
// unavoidable. It allocates once per selection — cheap enough that
|
||||
// micro-optimising it would just obscure the flow.
|
||||
const apply = (): void => {
|
||||
setAutoSwitch(isSystem);
|
||||
setTheme(resolvedTheme);
|
||||
onApplied?.();
|
||||
};
|
||||
|
||||
const willFlipDarkMode = resolvedTargetIsDark !== currentIsDark;
|
||||
if (!willFlipDarkMode || !canAnimateThemeTransition()) {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
|
||||
runThemeTransition(apply);
|
||||
},
|
||||
[theme, systemTheme, setTheme, setAutoSwitch],
|
||||
);
|
||||
}
|
||||
|
||||
export default useThemeSelection;
|
||||
@@ -9,7 +9,7 @@
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
padding: 0 8px;
|
||||
padding-left: 16px;
|
||||
margin-bottom: 0px;
|
||||
|
||||
&::before {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Logout } from 'api/utils';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { createErrorResponse, rest, server } from 'mocks-server/server';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ResetPassword from '../index';
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
push: jest.fn(),
|
||||
location: { search: '' },
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('api/utils', () => ({
|
||||
Logout: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const VERIFY_TOKEN_ENDPOINT = '*/api/v2/reset_password_tokens/verify';
|
||||
const VERSION_ENDPOINT = '*/version';
|
||||
|
||||
const mockHistoryPush = history.push as jest.MockedFunction<
|
||||
typeof history.push
|
||||
>;
|
||||
|
||||
const successVerifyResponse = {
|
||||
data: { id: 'token-id', token: 'valid-token' },
|
||||
};
|
||||
|
||||
const successVersionResponse = {
|
||||
version: '0.0.1',
|
||||
ee: 'Y',
|
||||
setupCompleted: true,
|
||||
};
|
||||
|
||||
describe('ResetPassword Page', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(successVersionResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('Token validation on page load', () => {
|
||||
it('shows spinner then form when token is valid', async () => {
|
||||
server.use(
|
||||
rest.post(VERIFY_TOKEN_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.delay(50), ctx.status(200), ctx.json(successVerifyResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
window.history.pushState({}, '', '/password-reset?token=valid-token');
|
||||
render(<ResetPassword />, undefined, {
|
||||
initialRoute: '/password-reset?token=valid-token',
|
||||
});
|
||||
|
||||
// Loading state: spinner visible, form and error absent
|
||||
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('password')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/reset password token is expired/i),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// After verification resolves: form is shown
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('password')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('confirmPassword')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Invalid Reset Link" when token is not found (404)', async () => {
|
||||
server.use(
|
||||
rest.post(
|
||||
VERIFY_TOKEN_ENDPOINT,
|
||||
createErrorResponse(
|
||||
404,
|
||||
'reset_password_token_not_found',
|
||||
'reset password token does not exist',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
window.history.pushState({}, '', '/password-reset?token=invalid-token');
|
||||
render(<ResetPassword />, undefined, {
|
||||
initialRoute: '/password-reset?token=invalid-token',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid reset link/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(/invalid or has already been used/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/reset password token does not exist/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "token is expired" when token is expired (401) without redirecting to login', async () => {
|
||||
server.use(
|
||||
rest.post(
|
||||
VERIFY_TOKEN_ENDPOINT,
|
||||
createErrorResponse(
|
||||
401,
|
||||
'reset_password_token_expired',
|
||||
'reset password token has expired',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
window.history.pushState({}, '', '/password-reset?token=expired-token');
|
||||
render(<ResetPassword />, undefined, {
|
||||
initialRoute: '/password-reset?token=expired-token',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/reset password token is expired/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(/single-use and expire after a set period/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/reset password token has expired/i),
|
||||
).toBeInTheDocument();
|
||||
// 401 from this endpoint must NOT trigger logout/redirect
|
||||
expect(mockHistoryPush).not.toHaveBeenCalledWith(ROUTES.LOGIN);
|
||||
expect(Logout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redirects to login when no token is in the URL', async () => {
|
||||
window.history.pushState({}, '', '/password-reset');
|
||||
render(<ResetPassword />, undefined, {
|
||||
initialRoute: '/password-reset',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
|
||||
});
|
||||
|
||||
expect(Logout).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useLocation } from 'react-use';
|
||||
import { AxiosError } from 'axios';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import getUserVersion from 'api/v1/version/get';
|
||||
import { verifyResetPasswordToken } from 'api/generated/services/users';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Logout } from 'api/utils';
|
||||
import Spinner from 'components/Spinner';
|
||||
import ResetPasswordContainer from 'container/ResetPassword';
|
||||
import TokenError from 'container/ResetPassword/TokenError';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -10,24 +19,65 @@ import APIError from 'types/api/error';
|
||||
function ResetPassword(): JSX.Element {
|
||||
const { user, isLoggedIn } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { search } = useLocation();
|
||||
const params = new URLSearchParams(search || '');
|
||||
const token = params.get('token') || '';
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
void Logout();
|
||||
history.push(ROUTES.LOGIN);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const {
|
||||
data: versionData,
|
||||
isLoading: isVersionLoading,
|
||||
error: versionError,
|
||||
} = useQuery({
|
||||
queryFn: getUserVersion,
|
||||
queryKey: ['getUserVersion', user?.accessJwt],
|
||||
enabled: !isLoggedIn,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
}, [error, showErrorModal]);
|
||||
const {
|
||||
isLoading: isVerifying,
|
||||
isError: isTokenError,
|
||||
error: tokenError,
|
||||
} = useQuery<
|
||||
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
|
||||
AxiosError<RenderErrorResponseDTO>
|
||||
>({
|
||||
queryFn: () => verifyResetPasswordToken({ token }),
|
||||
queryKey: ['verifyResetPasswordToken', token],
|
||||
enabled: !!token,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
const tokenApiError = useMemo(
|
||||
() => convertToApiError(tokenError),
|
||||
[tokenError],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (versionError) {
|
||||
showErrorModal(versionError as APIError);
|
||||
}
|
||||
}, [versionError, showErrorModal]);
|
||||
|
||||
if (!token) {
|
||||
return <Spinner tip="Loading..." />;
|
||||
}
|
||||
|
||||
return <ResetPasswordContainer version={data?.data.version || ''} />;
|
||||
if (isVersionLoading || isVerifying) {
|
||||
return <Spinner tip="Validating your reset password token..." />;
|
||||
}
|
||||
|
||||
if (isTokenError) {
|
||||
return <TokenError error={tokenApiError} />;
|
||||
}
|
||||
|
||||
return <ResetPasswordContainer version={versionData?.data.version || ''} />;
|
||||
}
|
||||
|
||||
export default ResetPassword;
|
||||
|
||||
@@ -22,6 +22,7 @@ import styles from './AnalyticsPanel.module.scss';
|
||||
interface AnalyticsPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onTabChange: (tab: string) => void;
|
||||
}
|
||||
|
||||
const PANEL_WIDTH = 350;
|
||||
@@ -32,6 +33,7 @@ const PANEL_MARGIN_BOTTOM = 50;
|
||||
function AnalyticsPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
onTabChange,
|
||||
}: AnalyticsPanelProps): JSX.Element | null {
|
||||
const aggregations = useTraceStore((s) => s.aggregations);
|
||||
const colorByFieldName = useTraceStore((s) => s.colorByField.name);
|
||||
@@ -118,7 +120,7 @@ function AnalyticsPanel({
|
||||
/>
|
||||
|
||||
<div className={styles.body}>
|
||||
<TabsRoot defaultValue="exec-time">
|
||||
<TabsRoot defaultValue="exec-time" onValueChange={onTabChange}>
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="exec-time" variant="secondary">
|
||||
% exec time
|
||||
|
||||
@@ -31,7 +31,12 @@ import Events from 'container/SpanDetailsDrawer/Events/Events';
|
||||
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
TraceDetailEventKeys,
|
||||
TraceDetailEvents,
|
||||
} from 'pages/TraceDetailsV3/events';
|
||||
import { useMigratePinnedAttributes } from 'pages/TraceDetailsV3/hooks/useMigratePinnedAttributes';
|
||||
import { useTraceDetailLogEvent } from 'pages/TraceDetailsV3/hooks/useTraceDetailLogEvent';
|
||||
import {
|
||||
getSpanAttribute,
|
||||
getSpanDisplayData,
|
||||
@@ -86,6 +91,16 @@ function SpanDetailsContent({
|
||||
}): JSX.Element {
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
const spanAttributeActions = useSpanAttributeActions();
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', selectedSpan.trace_id);
|
||||
const handleTabChange = useCallback(
|
||||
(tab: string): void => {
|
||||
logTraceEvent(TraceDetailEvents.SpanPanelTabChanged, {
|
||||
[TraceDetailEventKeys.Tab]: tab,
|
||||
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
|
||||
});
|
||||
},
|
||||
[logTraceEvent, selectedSpan.span_id],
|
||||
);
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
|
||||
@@ -376,7 +391,7 @@ function SpanDetailsContent({
|
||||
|
||||
<div className={styles.tabsSection}>
|
||||
{/* Step 9: ContentTabs */}
|
||||
<TabsRoot defaultValue="overview">
|
||||
<TabsRoot defaultValue="overview" onValueChange={handleTabChange}>
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="overview" variant="secondary">
|
||||
<Bookmark size={14} /> Overview
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
@@ -29,6 +29,8 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
@@ -90,11 +92,35 @@ function TraceDetailsHeader({
|
||||
const previewFields = useTraceStore((s) => s.previewFields);
|
||||
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceID || '');
|
||||
const pageLoadedAtRef = useRef(Date.now());
|
||||
|
||||
const handleSwitchToOldView = useCallback((): void => {
|
||||
logTraceEvent(TraceDetailEvents.ViewSwitched, {
|
||||
[TraceDetailEventKeys.From]: 'v3',
|
||||
[TraceDetailEventKeys.To]: 'v2',
|
||||
[TraceDetailEventKeys.DwellMs]: Date.now() - pageLoadedAtRef.current,
|
||||
});
|
||||
setLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW, 'true');
|
||||
const oldUrl = `/trace-old/${traceID}${window.location.search}`;
|
||||
history.replace(oldUrl);
|
||||
}, [traceID]);
|
||||
}, [traceID, logTraceEvent]);
|
||||
|
||||
const handleToggleAnalytics = useCallback((): void => {
|
||||
logTraceEvent(TraceDetailEvents.AnalyticsPanelToggled, {
|
||||
[TraceDetailEventKeys.Open]: !isAnalyticsOpen,
|
||||
});
|
||||
setIsAnalyticsOpen((prev) => !prev);
|
||||
}, [logTraceEvent, isAnalyticsOpen]);
|
||||
|
||||
const handleAnalyticsTabChange = useCallback(
|
||||
(tab: string): void => {
|
||||
logTraceEvent(TraceDetailEvents.AnalyticsTabChanged, {
|
||||
[TraceDetailEventKeys.Tab]: tab,
|
||||
});
|
||||
},
|
||||
[logTraceEvent],
|
||||
);
|
||||
|
||||
const handlePreviousBtnClick = useCallback((): void => {
|
||||
if (hasInAppHistory()) {
|
||||
@@ -167,7 +193,7 @@ function TraceDetailsHeader({
|
||||
size="icon"
|
||||
color="secondary"
|
||||
aria-label="Analytics"
|
||||
onClick={(): void => setIsAnalyticsOpen((prev) => !prev)}
|
||||
onClick={handleToggleAnalytics}
|
||||
>
|
||||
<ChartPie size={14} />
|
||||
</Button>
|
||||
@@ -245,6 +271,7 @@ function TraceDetailsHeader({
|
||||
<AnalyticsPanel
|
||||
isOpen={isAnalyticsOpen}
|
||||
onClose={(): void => setIsAnalyticsOpen(false)}
|
||||
onTabChange={handleAnalyticsTabChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
align-items: center;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
@@ -126,16 +127,13 @@
|
||||
padding: 6px 8px;
|
||||
background: var(--l3-background);
|
||||
|
||||
:global(.ant-input-prefix) {
|
||||
height: 18px;
|
||||
margin-inline-end: 6px;
|
||||
height: 18px;
|
||||
margin-inline-end: 6px;
|
||||
|
||||
svg {
|
||||
opacity: 0.4;
|
||||
}
|
||||
svg {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&,
|
||||
input {
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
|
||||
38
frontend/src/pages/TraceDetailsV3/events.ts
Normal file
38
frontend/src/pages/TraceDetailsV3/events.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export enum TraceDetailEvents {
|
||||
DataLoaded = 'Trace Detail: Data loaded',
|
||||
ViewSwitched = 'Trace Detail: View switched',
|
||||
FlameGraphToggled = 'Trace Detail: Flame graph toggled',
|
||||
WaterfallToggled = 'Trace Detail: Waterfall toggled',
|
||||
AnalyticsPanelToggled = 'Trace Detail: Analytics panel toggled',
|
||||
AnalyticsTabChanged = 'Trace Detail: Analytics tab changed',
|
||||
SpanPanelTabChanged = 'Trace Detail: Span panel tab changed',
|
||||
}
|
||||
|
||||
export enum TraceDetailEventKeys {
|
||||
// Injected on every event by useTraceDetailLogEvent
|
||||
View = 'view',
|
||||
TraceId = 'traceId',
|
||||
// Data loaded — trace shape
|
||||
TotalSpansCount = 'totalSpansCount',
|
||||
NumServices = 'numServices',
|
||||
TraceDurationMs = 'traceDurationMs',
|
||||
HadErrors = 'hadErrors',
|
||||
FlamegraphSampled = 'flamegraphSampled',
|
||||
// Data loaded — persisted settings
|
||||
SpanPanelVariant = 'spanPanelVariant',
|
||||
ColorByField = 'colorByField',
|
||||
PreviewFieldsCount = 'previewFieldsCount',
|
||||
EntryPreferOldView = 'entryPreferOldView',
|
||||
// View switched
|
||||
From = 'from',
|
||||
To = 'to',
|
||||
DwellMs = 'dwellMs',
|
||||
// Toggles / tabs
|
||||
Expanded = 'expanded',
|
||||
Open = 'open',
|
||||
Tab = 'tab',
|
||||
// Span panel tab changed
|
||||
SpanId = 'spanId',
|
||||
}
|
||||
|
||||
export type TraceDetailView = 'v2' | 'v3';
|
||||
@@ -0,0 +1,88 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { TraceDetailEvents } from '../../events';
|
||||
import { useTraceDetailLogEvent } from '../useTraceDetailLogEvent';
|
||||
|
||||
const logEventMock = jest.fn();
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]): void => logEventMock(...args),
|
||||
}));
|
||||
|
||||
describe('useTraceDetailLogEvent', () => {
|
||||
beforeEach(() => {
|
||||
logEventMock.mockClear();
|
||||
});
|
||||
|
||||
it('injects view and traceId on every event', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTraceDetailLogEvent('v3', 'trace-123'),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(TraceDetailEvents.DataLoaded, { totalSpansCount: 42 });
|
||||
});
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(logEventMock).toHaveBeenCalledWith(TraceDetailEvents.DataLoaded, {
|
||||
view: 'v3',
|
||||
traceId: 'trace-123',
|
||||
totalSpansCount: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('injects view and traceId even when no attributes are passed', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTraceDetailLogEvent('v2', 'trace-456'),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(TraceDetailEvents.ViewSwitched);
|
||||
});
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(TraceDetailEvents.ViewSwitched, {
|
||||
view: 'v2',
|
||||
traceId: 'trace-456',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a stable callback identity and emits the latest traceId', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ traceId }) => useTraceDetailLogEvent('v3', traceId),
|
||||
{ initialProps: { traceId: 'trace-1' } },
|
||||
);
|
||||
|
||||
const firstIdentity = result.current;
|
||||
rerender({ traceId: 'trace-2' });
|
||||
|
||||
expect(result.current).toBe(firstIdentity);
|
||||
|
||||
act(() => {
|
||||
result.current(TraceDetailEvents.SpanPanelTabChanged, { spanId: 's1' });
|
||||
});
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
TraceDetailEvents.SpanPanelTabChanged,
|
||||
{
|
||||
view: 'v3',
|
||||
traceId: 'trace-2',
|
||||
spanId: 's1',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('never throws if logEvent throws (analytics must not break the UI)', () => {
|
||||
logEventMock.mockImplementationOnce(() => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useTraceDetailLogEvent('v3', 'trace-123'),
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current(TraceDetailEvents.DataLoaded);
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
import {
|
||||
TraceDetailEventKeys,
|
||||
TraceDetailEvents,
|
||||
TraceDetailView,
|
||||
} from '../events';
|
||||
|
||||
export type TraceDetailLogEvent = (
|
||||
event: TraceDetailEvents,
|
||||
attributes?: Record<string, unknown>,
|
||||
) => void;
|
||||
|
||||
export function useTraceDetailLogEvent(
|
||||
view: TraceDetailView,
|
||||
traceId: string,
|
||||
): TraceDetailLogEvent {
|
||||
const contextRef = useRef({ view, traceId });
|
||||
contextRef.current = { view, traceId };
|
||||
|
||||
return useCallback(
|
||||
(
|
||||
event: TraceDetailEvents,
|
||||
attributes: Record<string, unknown> = {},
|
||||
): void => {
|
||||
try {
|
||||
void logEvent(event, {
|
||||
[TraceDetailEventKeys.View]: contextRef.current.view,
|
||||
[TraceDetailEventKeys.TraceId]: contextRef.current.traceId,
|
||||
...attributes,
|
||||
});
|
||||
} catch {
|
||||
// No-op. Logging must never throw into the UI.
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { COLOR_BY_FIELDS } from './constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
|
||||
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
|
||||
import TraceStoreSync from './stores/TraceStoreSync';
|
||||
import { useTraceStore } from './stores/traceStore';
|
||||
import { AGGREGATIONS } from './utils/aggregations';
|
||||
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
|
||||
@@ -56,6 +59,14 @@ function TraceDetailsV3(): JSX.Element {
|
||||
const selectedSpanId = urlQuery.get('spanId') || undefined;
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
||||
// Tracks which traceId the load event already fired for, so navigating
|
||||
// between traces (the route component stays mounted) re-fires it once each.
|
||||
const dataLoadedFiredForRef = useRef('');
|
||||
const colorByField = useTraceStore((s) => s.colorByField);
|
||||
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
|
||||
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
|
||||
|
||||
const handleSpanDetailsClose = useCallback((): void => {
|
||||
urlQuery.delete('spanId');
|
||||
safeNavigate({ search: urlQuery.toString() });
|
||||
@@ -154,6 +165,46 @@ function TraceDetailsV3(): JSX.Element {
|
||||
allSpansRef.current = allSpans;
|
||||
}, [allSpans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!traceId ||
|
||||
dataLoadedFiredForRef.current === traceId ||
|
||||
!userPrefsReady
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const payload = traceData?.payload;
|
||||
if (!payload?.spans?.length) {
|
||||
return;
|
||||
}
|
||||
dataLoadedFiredForRef.current = traceId;
|
||||
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
[TraceDetailEventKeys.NumServices]: numServices,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
payload.endTimestampMillis - payload.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
|
||||
[TraceDetailEventKeys.FlamegraphSampled]:
|
||||
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
|
||||
[TraceDetailEventKeys.SpanPanelVariant]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
|
||||
SpanDetailVariant.DOCKED_RIGHT,
|
||||
[TraceDetailEventKeys.ColorByField]: colorByField.name,
|
||||
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
|
||||
[TraceDetailEventKeys.EntryPreferOldView]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW) === 'true',
|
||||
});
|
||||
}, [
|
||||
traceId,
|
||||
userPrefsReady,
|
||||
traceData,
|
||||
totalSpansCount,
|
||||
colorByField,
|
||||
previewFieldsCount,
|
||||
logTraceEvent,
|
||||
]);
|
||||
|
||||
// Frontend mode: expand all parents by default when full data arrives
|
||||
useEffect(() => {
|
||||
if (isFullDataLoaded && allSpans.length > 0) {
|
||||
@@ -233,6 +284,12 @@ function TraceDetailsV3(): JSX.Element {
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
|
||||
|
||||
const handleCollapseChange = (key: string): void => {
|
||||
logTraceEvent(
|
||||
key === 'flame'
|
||||
? TraceDetailEvents.FlameGraphToggled
|
||||
: TraceDetailEvents.WaterfallToggled,
|
||||
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
|
||||
);
|
||||
setActiveKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ChevronDown, Copy } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { JsonView } from 'periscope/components/JsonView';
|
||||
import { PrettyView } from 'periscope/components/PrettyView';
|
||||
import { PrettyViewProps } from 'periscope/components/PrettyView';
|
||||
@@ -12,6 +13,8 @@ import './DataViewer.styles.scss';
|
||||
|
||||
type ViewMode = 'pretty' | 'json';
|
||||
|
||||
const VIEW_MODE_CHANGED_EVENT = 'Data Viewer: View mode changed';
|
||||
|
||||
const VIEW_MODE_OPTIONS: { label: string; value: ViewMode }[] = [
|
||||
{ label: 'Pretty', value: 'pretty' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
@@ -34,6 +37,20 @@ function DataViewer({
|
||||
|
||||
const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]);
|
||||
|
||||
const handleViewModeChange = (value: string): void => {
|
||||
const next = value as ViewMode;
|
||||
setViewMode(next);
|
||||
try {
|
||||
logEvent(VIEW_MODE_CHANGED_EVENT, {
|
||||
viewMode: next,
|
||||
path: window.location.pathname,
|
||||
drawerKey,
|
||||
});
|
||||
} catch {
|
||||
// No op
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (): void => {
|
||||
const text = JSON.stringify(data, null, 2);
|
||||
setCopy(text);
|
||||
@@ -56,7 +73,7 @@ function DataViewer({
|
||||
{
|
||||
type: 'radio-group',
|
||||
value: viewMode,
|
||||
onChange: (value): void => setViewMode(value as ViewMode),
|
||||
onChange: handleViewModeChange,
|
||||
children: VIEW_MODE_OPTIONS.map((opt) => ({
|
||||
type: 'radio',
|
||||
key: opt.value,
|
||||
|
||||
@@ -827,22 +827,3 @@ body.ai-assistant-panel-open {
|
||||
--input-focus-outline-width: 0;
|
||||
--radius-2: 4px;
|
||||
}
|
||||
|
||||
// Scoped to .theme-wipe-active (toggled on <html> in runThemeTransition) so
|
||||
// these overrides don't leak into any unrelated view transitions added later.
|
||||
// We disable the default UA crossfade so the JS-driven clip-path wipe is the
|
||||
// only visible effect, and stack the new snapshot above the old.
|
||||
html.theme-wipe-active {
|
||||
&::view-transition-old(root),
|
||||
&::view-transition-new(root) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
&::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&::view-transition-new(root) {
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
extractQueryPairs,
|
||||
getCurrentQueryPair,
|
||||
getCurrentValueIndexAtCursor,
|
||||
getQueryContextAtCursor,
|
||||
} from '../queryContextUtils';
|
||||
|
||||
describe('extractQueryPairs', () => {
|
||||
@@ -608,3 +609,42 @@ describe('getCurrentQueryPair', () => {
|
||||
expect(result).toStrictEqual(queryPairs[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQueryContextAtCursor - trailing dot in key/value', () => {
|
||||
// A token ending with "." (e.g. "service.") is lexed as FREETEXT rather than
|
||||
// KEY, which previously collapsed the context to "nothing" and made the
|
||||
// suggestion dropdown render empty (appearing closed). These cases lock in
|
||||
// that a partial key/value still resolves to the correct context.
|
||||
it('keeps key context while typing a key that ends with a dot', () => {
|
||||
['service.', 'k8s.', 'attribute.', 'k8s.pod.'].forEach((q) => {
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
expect(ctx.isInKey).toBe(true);
|
||||
expect(ctx.isInValue).toBe(false);
|
||||
expect(ctx.isInOperator).toBe(false);
|
||||
expect(ctx.keyToken).toBe(q);
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a new key after a conjunction as key context', () => {
|
||||
const q = 'a = b AND k8s.';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
expect(ctx.isInKey).toBe(true);
|
||||
expect(ctx.isInValue).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps value context while typing a value that ends with a dot', () => {
|
||||
const q = 'service.name = foo.';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
expect(ctx.isInValue).toBe(true);
|
||||
expect(ctx.isInKey).toBe(false);
|
||||
expect(ctx.keyToken).toBe('service.name');
|
||||
expect(ctx.operatorToken).toBe('=');
|
||||
});
|
||||
|
||||
it('still resolves a complete dotted key to key context', () => {
|
||||
const q = 'k8s.namespace';
|
||||
const ctx = getQueryContextAtCursor(q, q.length);
|
||||
expect(ctx.isInKey).toBe(true);
|
||||
expect(ctx.keyToken).toBe('k8s.namespace');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import {
|
||||
canAnimateThemeTransition,
|
||||
runThemeTransition,
|
||||
THEME_WIPE_ACTIVE_CLASS,
|
||||
} from '../themeTransition';
|
||||
|
||||
type StartVT = (cb: () => void) => {
|
||||
ready: Promise<void>;
|
||||
finished: Promise<void>;
|
||||
};
|
||||
|
||||
const installStartViewTransition = (impl?: StartVT): jest.Mock => {
|
||||
const defaultImpl: StartVT = (cb) => {
|
||||
cb();
|
||||
return { ready: Promise.resolve(), finished: Promise.resolve() };
|
||||
};
|
||||
const fn = jest.fn(impl ?? defaultImpl);
|
||||
Object.defineProperty(document, 'startViewTransition', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: fn,
|
||||
});
|
||||
return fn;
|
||||
};
|
||||
|
||||
const removeStartViewTransition = (): void => {
|
||||
Object.defineProperty(document, 'startViewTransition', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const setReducedMotion = (matches: boolean): void => {
|
||||
(window.matchMedia as jest.Mock) = jest
|
||||
.fn()
|
||||
.mockImplementation((query: string) => ({
|
||||
matches: query === '(prefers-reduced-motion: reduce)' ? matches : false,
|
||||
media: query,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
};
|
||||
|
||||
describe('canAnimateThemeTransition', () => {
|
||||
afterEach(() => {
|
||||
removeStartViewTransition();
|
||||
});
|
||||
|
||||
it('returns false when document.startViewTransition is unavailable', () => {
|
||||
removeStartViewTransition();
|
||||
setReducedMotion(false);
|
||||
expect(canAnimateThemeTransition()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when prefers-reduced-motion is reduce', () => {
|
||||
installStartViewTransition();
|
||||
setReducedMotion(true);
|
||||
expect(canAnimateThemeTransition()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when API is supported and motion is allowed', () => {
|
||||
installStartViewTransition();
|
||||
setReducedMotion(false);
|
||||
expect(canAnimateThemeTransition()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runThemeTransition', () => {
|
||||
afterEach(() => {
|
||||
removeStartViewTransition();
|
||||
document.documentElement.classList.remove(THEME_WIPE_ACTIVE_CLASS);
|
||||
});
|
||||
|
||||
it('falls back to running applyChange directly when API is missing', () => {
|
||||
removeStartViewTransition();
|
||||
const applyChange = jest.fn();
|
||||
runThemeTransition(applyChange);
|
||||
expect(applyChange).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('invokes startViewTransition and runs applyChange inside its callback', () => {
|
||||
const startVT = installStartViewTransition();
|
||||
const applyChange = jest.fn();
|
||||
runThemeTransition(applyChange);
|
||||
expect(startVT).toHaveBeenCalledTimes(1);
|
||||
expect(applyChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('toggles the wipe-active class on <html> for the lifetime of the transition', async () => {
|
||||
let resolveFinished: () => void = (): void => {};
|
||||
installStartViewTransition((cb) => {
|
||||
cb();
|
||||
return {
|
||||
ready: Promise.resolve(),
|
||||
finished: new Promise<void>((resolve) => {
|
||||
resolveFinished = resolve;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
runThemeTransition(() => undefined);
|
||||
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(true);
|
||||
|
||||
resolveFinished();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the wipe-active class through overlapping transitions', async () => {
|
||||
let resolveA: () => void = (): void => {};
|
||||
let resolveB: () => void = (): void => {};
|
||||
let callIndex = 0;
|
||||
installStartViewTransition((cb) => {
|
||||
cb();
|
||||
callIndex += 1;
|
||||
if (callIndex === 1) {
|
||||
return {
|
||||
ready: Promise.resolve(),
|
||||
finished: new Promise<void>((resolve) => {
|
||||
resolveA = resolve;
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: Promise.resolve(),
|
||||
finished: new Promise<void>((resolve) => {
|
||||
resolveB = resolve;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
runThemeTransition(() => undefined);
|
||||
runThemeTransition(() => undefined);
|
||||
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(true);
|
||||
|
||||
// First transition finishes — class must stay because B is still in flight.
|
||||
resolveA();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(true);
|
||||
|
||||
resolveB();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to applyChange and releases the class when startViewTransition throws before its callback runs', () => {
|
||||
installStartViewTransition(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const applyChange = jest.fn();
|
||||
runThemeTransition(applyChange);
|
||||
expect(applyChange).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not double-invoke applyChange when startViewTransition throws after its callback runs', () => {
|
||||
installStartViewTransition((cb) => {
|
||||
cb();
|
||||
throw new Error('post-cb');
|
||||
});
|
||||
const applyChange = jest.fn();
|
||||
runThemeTransition(applyChange);
|
||||
expect(applyChange).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -766,6 +766,58 @@ export function getQueryContextAtCursor(
|
||||
}
|
||||
}
|
||||
|
||||
// A token that ends with a '.' (e.g. "service." or "k8s.pod.") is lexed as
|
||||
// FREETEXT, not KEY, because the KEY rule requires a character after the dot.
|
||||
// While the user is mid-typing such a key/value the token stays FREETEXT, which
|
||||
// is otherwise unclassified and would collapse the context to "nothing" — making
|
||||
// the suggestion dropdown render empty (and appear closed). Classify the partial
|
||||
// token by the slot it occupies so suggestions keep flowing until the next
|
||||
// character turns it back into a valid KEY token.
|
||||
const partialToken = exactToken ?? lastTokenBeforeCursor;
|
||||
if (
|
||||
partialToken &&
|
||||
partialToken.channel === 0 &&
|
||||
partialToken.type === FilterQueryLexer.FREETEXT
|
||||
) {
|
||||
// Closest meaningful token before the partial one determines the slot:
|
||||
// right after an operator we are typing a value, otherwise a key.
|
||||
const prevMeaningful =
|
||||
allTokens
|
||||
.filter(
|
||||
(t) =>
|
||||
t.channel === 0 &&
|
||||
t.type !== FilterQueryLexer.EOF &&
|
||||
t.stop < partialToken.start,
|
||||
)
|
||||
.pop() || null;
|
||||
|
||||
const prevContext = prevMeaningful
|
||||
? determineTokenContext(prevMeaningful, input)
|
||||
: null;
|
||||
const isValuePosition = !!prevContext?.isInOperator;
|
||||
|
||||
return {
|
||||
tokenType: partialToken.type,
|
||||
text: partialToken.text,
|
||||
start: partialToken.start,
|
||||
stop: partialToken.stop,
|
||||
currentToken: partialToken.text,
|
||||
isInKey: !isValuePosition,
|
||||
isInNegation: false,
|
||||
isInOperator: false,
|
||||
isInValue: isValuePosition,
|
||||
isInFunction: false,
|
||||
isInConjunction: false,
|
||||
isInParenthesis: false,
|
||||
isInBracketList: false,
|
||||
keyToken: isValuePosition ? currentPair?.key || '' : partialToken.text,
|
||||
operatorToken: isValuePosition ? currentPair?.operator || '' : undefined,
|
||||
valueToken: isValuePosition ? partialToken.text : undefined,
|
||||
queryPairs,
|
||||
currentPair,
|
||||
};
|
||||
}
|
||||
|
||||
// If we don't have tokens yet, return default context
|
||||
if (!previousToken && !nextToken && !exactToken && !lastTokenBeforeCursor) {
|
||||
return {
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { flushSync } from 'react-dom';
|
||||
|
||||
const WIPE_DURATION_MS = 400;
|
||||
const WIPE_EASING = 'ease-out';
|
||||
|
||||
// Toggled on <html> for the duration of the wipe so the CSS overrides
|
||||
// (animation: none on ::view-transition-{old,new}(root)) don't leak into
|
||||
// any future, unrelated view transitions in the app.
|
||||
export const THEME_WIPE_ACTIVE_CLASS = 'theme-wipe-active';
|
||||
|
||||
type ViewTransition = {
|
||||
ready: Promise<void>;
|
||||
finished: Promise<void>;
|
||||
};
|
||||
type DocumentWithVT = Document & {
|
||||
startViewTransition?: (callback: () => void) => ViewTransition;
|
||||
};
|
||||
|
||||
// Rapid theme switches cancel the in-flight transition and immediately start a
|
||||
// new one; if we removed the class on the first transition's settled promise,
|
||||
// we'd strip the CSS override mid-way through the next wipe and the user
|
||||
// would briefly see the UA crossfade. Refcount so the class only comes off
|
||||
// once every transition we started has settled.
|
||||
let wipeActiveRefCount = 0;
|
||||
const acquireWipeClass = (root: HTMLElement): void => {
|
||||
wipeActiveRefCount += 1;
|
||||
root.classList.add(THEME_WIPE_ACTIVE_CLASS);
|
||||
};
|
||||
const releaseWipeClass = (root: HTMLElement): void => {
|
||||
wipeActiveRefCount = Math.max(0, wipeActiveRefCount - 1);
|
||||
if (wipeActiveRefCount === 0) {
|
||||
root.classList.remove(THEME_WIPE_ACTIVE_CLASS);
|
||||
}
|
||||
};
|
||||
|
||||
// Identity of the transition we most recently started. Used to skip the
|
||||
// .animate() call on a stale transition whose .ready resolved after a newer
|
||||
// transition has already taken over the ::view-transition-new pseudo-element.
|
||||
let currentTransition: ViewTransition | null = null;
|
||||
|
||||
export function canAnimateThemeTransition(): boolean {
|
||||
const doc = document as DocumentWithVT;
|
||||
if (typeof doc.startViewTransition !== 'function') {
|
||||
return false;
|
||||
}
|
||||
return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
// Runs `applyChange` inside a View Transition and wipes the new theme in from
|
||||
// left to right via a polygon clip-path on ::view-transition-new(root).
|
||||
// Callers should gate on canAnimateThemeTransition() first; this is a safe
|
||||
// no-animation fallback otherwise.
|
||||
export function runThemeTransition(applyChange: () => void): void {
|
||||
const doc = document as DocumentWithVT;
|
||||
if (!doc.startViewTransition) {
|
||||
applyChange();
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
acquireWipeClass(root);
|
||||
|
||||
// Some Chromium versions throw if startViewTransition is called while
|
||||
// another transition is in setup. Track whether the callback ran so we
|
||||
// don't double-apply if the throw happens mid-callback.
|
||||
let applied = false;
|
||||
let transition: ViewTransition;
|
||||
try {
|
||||
transition = doc.startViewTransition(() => {
|
||||
applied = true;
|
||||
flushSync(applyChange);
|
||||
});
|
||||
} catch {
|
||||
releaseWipeClass(root);
|
||||
if (!applied) {
|
||||
applyChange();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
currentTransition = transition;
|
||||
|
||||
const from = 'polygon(0 0, 0 0, 0 100%, 0 100%)';
|
||||
const to = 'polygon(0 0, 100% 0, 100% 100%, 0 100%)';
|
||||
|
||||
transition.ready
|
||||
.then(() => {
|
||||
// If a newer transition has superseded this one between
|
||||
// startViewTransition() and `ready` resolving, the browser has
|
||||
// already cancelled our pseudo-element. Calling .animate() on it now
|
||||
// would race with the newer transition's own animation.
|
||||
if (currentTransition !== transition) {
|
||||
return;
|
||||
}
|
||||
root.animate(
|
||||
{ clipPath: [from, to] },
|
||||
{
|
||||
duration: WIPE_DURATION_MS,
|
||||
easing: WIPE_EASING,
|
||||
pseudoElement: '::view-transition-new(root)',
|
||||
},
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// Transition cancelled — applyChange has already run.
|
||||
});
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (currentTransition === transition) {
|
||||
currentTransition = null;
|
||||
}
|
||||
releaseWipeClass(root);
|
||||
};
|
||||
transition.finished.then(cleanup).catch(cleanup);
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary
|
||||
events, status_message, status_code_string, kind_string, parent_span_id,
|
||||
flags, is_remote, trace_state, status_code,
|
||||
db_name, db_operation, http_method, http_url, http_host,
|
||||
external_http_method, external_http_url, response_status_code
|
||||
external_http_method, external_http_url, response_status_code, links as references
|
||||
FROM %s.%s
|
||||
WHERE trace_id=? AND ts_bucket_start>=? AND ts_bucket_start<=?
|
||||
ORDER BY timestamp ASC, name ASC`,
|
||||
@@ -130,7 +130,7 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
|
||||
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
|
||||
"flags", "is_remote", "trace_state", "status_code",
|
||||
"db_name", "db_operation", "http_method", "http_url", "http_host",
|
||||
"external_http_method", "external_http_url", "response_status_code",
|
||||
"external_http_method", "external_http_url", "response_status_code", "links as references",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
ids := make([]any, len(spanIDs))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user