Compare commits

..

1 Commits

Author SHA1 Message Date
Pandey
ef892f3361 fix(ruletypes): tag rule threshold targets as format: double (#12061)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* fix(ruletypes): tag rule threshold targets as format: double

BasicRuleThreshold.{TargetValue,RecoveryTarget} and RuleCondition.Target are
*float64 but emitted a bare 'type: number' (swaggest sets format: double only for
non-pointer floats). Bare number makes oapi-codegen clients generate float32, so a
value like 0.8 loses precision on round-trip. Tag them format:double to match
non-pointer float64 fields (e.g. MetrictypesComparisonSpaceAggregationParam).

* chore(frontend): regenerate API client for rule threshold format: double

Reflects the format: double schema change on the rule threshold/condition
targets in the orval-generated client (oxfmt + oxlint applied).
2026-07-09 22:09:25 +00:00
10 changed files with 22 additions and 263 deletions

View File

@@ -7394,9 +7394,11 @@ components:
op:
$ref: '#/components/schemas/RuletypesCompareOperator'
recoveryTarget:
format: double
nullable: true
type: number
target:
format: double
nullable: true
type: number
targetUnit:
@@ -7680,6 +7682,7 @@ components:
selectedQueryName:
type: string
target:
format: double
nullable: true
type: number
targetUnit:

View File

@@ -8480,10 +8480,12 @@ export interface RuletypesBasicRuleThresholdDTO {
op: RuletypesCompareOperatorDTO;
/**
* @type number,null
* @format double
*/
recoveryTarget?: number | null;
/**
* @type number,null
* @format double
*/
target: number | null;
/**
@@ -8677,6 +8679,7 @@ export interface RuletypesRuleConditionDTO {
selectedQueryName?: string;
/**
* @type number,null
* @format double
*/
target?: number | null;
/**

View File

@@ -1,72 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import TagKeyValueInput from './TagKeyValueInput';
const TID = 'tag-key-value-input';
const startEditingFirstChip = (): HTMLElement => {
fireEvent.doubleClick(screen.getAllByTestId(`${TID}-chip`)[0]);
return screen.getByTestId(`${TID}-edit`);
};
describe('TagKeyValueInput — inline chip edit', () => {
it('shows an error and stays in edit mode when Enter commits an invalid value', () => {
const onTagsChange = jest.fn();
render(<TagKeyValueInput tags={['env:prod']} onTagsChange={onTagsChange} />);
const input = startEditingFirstChip();
fireEvent.change(input, { target: { value: 'novalue' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(screen.getByTestId(`${TID}-error`)).toHaveTextContent(
'key:value format',
);
// Still editing (input present), and no change committed.
expect(screen.getByTestId(`${TID}-edit`)).toBeInTheDocument();
expect(onTagsChange).not.toHaveBeenCalled();
});
it('shows a duplicate error when Enter commits an existing tag', () => {
const onTagsChange = jest.fn();
render(
<TagKeyValueInput
tags={['env:prod', 'team:core']}
onTagsChange={onTagsChange}
/>,
);
const input = startEditingFirstChip();
fireEvent.change(input, { target: { value: 'team:core' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(screen.getByTestId(`${TID}-error`)).toHaveTextContent(
'already exists',
);
expect(onTagsChange).not.toHaveBeenCalled();
});
it('commits a valid edit on Enter', () => {
const onTagsChange = jest.fn();
render(<TagKeyValueInput tags={['env:prod']} onTagsChange={onTagsChange} />);
const input = startEditingFirstChip();
fireEvent.change(input, { target: { value: 'env:staging' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onTagsChange).toHaveBeenCalledWith(['env:staging']);
expect(screen.queryByTestId(`${TID}-error`)).not.toBeInTheDocument();
});
it('reverts silently (no error) when blurring an invalid edit', () => {
const onTagsChange = jest.fn();
render(<TagKeyValueInput tags={['env:prod']} onTagsChange={onTagsChange} />);
const input = startEditingFirstChip();
fireEvent.change(input, { target: { value: 'novalue' } });
fireEvent.blur(input);
expect(screen.queryByTestId(`${TID}-error`)).not.toBeInTheDocument();
expect(screen.queryByTestId(`${TID}-edit`)).not.toBeInTheDocument();
expect(onTagsChange).not.toHaveBeenCalled();
});
});

View File

@@ -82,32 +82,15 @@ function TagKeyValueInput({
const cancelEdit = (): void => {
setEditIndex(-1);
setEditValue('');
setError('');
};
// Commit an inline chip edit. On Enter (`revertOnInvalid` false) an invalid or
// duplicate value shows the error and keeps the edit box open so the user can
// fix it — matching the new-tag input. On blur we revert instead, so a click
// away never strands the user in an un-exitable edit box.
const commitEdit = (revertOnInvalid = false): void => {
const commitEdit = (): void => {
const normalized = parseKeyValueTag(editValue);
if (!normalized) {
if (revertOnInvalid) {
cancelEdit();
return;
}
setError('Tags must be in key:value format (both sides required).');
return;
// Drop into a no-op (revert) on invalid or duplicate edits rather than
// stranding the user in an un-exitable edit box.
if (normalized && !tags.some((t, i) => t === normalized && i !== editIndex)) {
onTagsChange(tags.map((t, i) => (i === editIndex ? normalized : t)));
}
if (tags.some((t, i) => t === normalized && i !== editIndex)) {
if (revertOnInvalid) {
cancelEdit();
return;
}
setError('This tag already exists.');
return;
}
onTagsChange(tags.map((t, i) => (i === editIndex ? normalized : t)));
cancelEdit();
};
@@ -138,14 +121,11 @@ function TagKeyValueInput({
value={editValue}
autoFocus
testId={`${testId}-edit`}
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
setEditValue(e.target.value);
if (error) {
setError('');
}
}}
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setEditValue(e.target.value)
}
onKeyDown={handleEditKeyDown}
onBlur={(): void => commitEdit(true)}
onBlur={commitEdit}
/>
) : (
<TagBadge

View File

@@ -1,65 +0,0 @@
import type {
DashboardtypesDashboardSpecDTO,
DashboardtypesLayoutDTO,
} from 'api/generated/services/sigNoz.schemas';
import { normalizeSpecLayouts } from '../normalizeSpecLayouts';
const gridLayout = (
items: DashboardtypesLayoutDTO['spec']['items'],
): DashboardtypesLayoutDTO => ({
kind: 'Grid' as DashboardtypesLayoutDTO['kind'],
spec: { display: { title: 'S' }, items },
});
// Only `layouts` matters here; the rest of the spec is irrelevant to compaction.
const makeSpec = (
layouts: DashboardtypesLayoutDTO[],
): DashboardtypesDashboardSpecDTO =>
({ layouts }) as DashboardtypesDashboardSpecDTO;
const ref = (id: string): { $ref: string } => ({
$ref: `#/spec/panels/${id}`,
});
describe('normalizeSpecLayouts', () => {
it('pulls a lone panel with a non-zero y up to the top', () => {
const spec = makeSpec([
gridLayout([{ x: 0, y: 10, width: 6, height: 6, content: ref('a') }]),
]);
const out = normalizeSpecLayouts(spec);
expect(out.layouts?.[0].spec.items?.[0]).toMatchObject({ x: 0, y: 0 });
});
it('collapses a vertical gap between stacked panels', () => {
const spec = makeSpec([
gridLayout([
{ x: 0, y: 0, width: 6, height: 6, content: ref('a') },
{ x: 0, y: 20, width: 6, height: 6, content: ref('b') },
]),
]);
const items = normalizeSpecLayouts(spec).layouts?.[0].spec.items ?? [];
expect(items[0]).toMatchObject({ y: 0 });
expect(items[1]).toMatchObject({ y: 6 });
});
it('preserves side-by-side panels and their panel refs', () => {
const spec = makeSpec([
gridLayout([
{ x: 0, y: 5, width: 6, height: 6, content: ref('a') },
{ x: 6, y: 5, width: 6, height: 6, content: ref('b') },
]),
]);
const items = normalizeSpecLayouts(spec).layouts?.[0].spec.items ?? [];
expect(items[0]).toMatchObject({ x: 0, y: 0, content: ref('a') });
expect(items[1]).toMatchObject({ x: 6, y: 0, content: ref('b') });
});
it('leaves empty sections and specs without layouts untouched', () => {
expect(
normalizeSpecLayouts({} as DashboardtypesDashboardSpecDTO).layouts,
).toBeUndefined();
const empty = makeSpec([gridLayout([])]);
expect(normalizeSpecLayouts(empty).layouts?.[0].spec.items).toStrictEqual([]);
});
});

View File

@@ -1,67 +0,0 @@
import type { Layout } from 'react-grid-layout';
// react-grid-layout has no top-level export for these; the built utils are the
// same functions GridLayout uses internally to lay items out on render.
import { compact, correctBounds } from 'react-grid-layout/build/utils';
import type {
DashboardGridItemDTO,
DashboardtypesDashboardSpecDTO,
DashboardtypesLayoutDTO,
} from 'api/generated/services/sigNoz.schemas';
/** Columns in the section grid — mirrors `cols` on SectionGrid's GridLayout. */
const GRID_COLS = 12;
/**
* Compact one Grid section's items exactly as SectionGrid's react-grid-layout
* does on render (12 cols, vertical compaction): pull items up to remove gaps
* and clamp them within bounds. Item→panel refs and sizes are preserved; only
* x/y are normalized. Keyed by array index so a reorder from `compact` maps back
* cleanly.
*/
function compactItems(items: DashboardGridItemDTO[]): DashboardGridItemDTO[] {
if (items.length === 0) {
return items;
}
const layout: Layout[] = items.map((item, index) => ({
i: String(index),
x: item.x ?? 0,
y: item.y ?? 0,
w: item.width ?? 0,
h: item.height ?? 0,
}));
const compacted = compact(
correctBounds(layout, { cols: GRID_COLS }),
'vertical',
GRID_COLS,
);
const byIndex = new Map(compacted.map((entry) => [entry.i, entry]));
return items.map((item, index) => {
const entry = byIndex.get(String(index));
return entry ? { ...item, x: entry.x, y: entry.y } : item;
});
}
/**
* Normalize every Grid section's layout so the stored coordinates match what the
* grid renders. The JSON editor lets a user set arbitrary `x`/`y` (e.g. a lone
* panel at `y: 10`); react-grid-layout would still render it compacted at the
* top, leaving stored and rendered state out of sync. Compacting on save keeps
* them in agreement. Non-Grid layouts and empty sections pass through untouched.
*/
export function normalizeSpecLayouts(
spec: DashboardtypesDashboardSpecDTO,
): DashboardtypesDashboardSpecDTO {
if (!spec.layouts || spec.layouts.length === 0) {
return spec;
}
const layouts: DashboardtypesLayoutDTO[] = spec.layouts.map((layout) => {
if (layout.kind !== 'Grid' || !layout.spec.items) {
return layout;
}
return {
...layout,
spec: { ...layout.spec, items: compactItems(layout.spec.items) },
};
});
return { ...spec, layouts };
}

View File

@@ -10,7 +10,6 @@ import { toAPIError } from 'utils/errorUtils';
import { dashboardToUpdatable } from './dashboardToUpdatable';
import { findPanelLayoutIssues } from './danglingPanels';
import { normalizeSpecLayouts } from './normalizeSpecLayouts';
import { useDashboardStore } from '../../store/useDashboardStore';
export interface JsonValidity {
@@ -159,11 +158,10 @@ export function useJsonEditor({
// The draft only carries name/tags/spec; overlay it on the current dashboard
// so the redacted fields (schemaVersion, image, …) are preserved on save.
const edited = JSON.parse(draft) as Record<string, unknown>;
const merged = { ...dashboard, ...edited };
// Compact each section's layout so hand-edited coordinates (e.g. a lone
// panel left at y: 10) are stored the way the grid actually renders them.
merged.spec = normalizeSpecLayouts(merged.spec);
await updateDashboardV2({ id: dashboardId }, dashboardToUpdatable(merged));
await updateDashboardV2(
{ id: dashboardId },
dashboardToUpdatable({ ...dashboard, ...edited }),
);
toast.success('Dashboard updated');
refetch();
onApplied();

View File

@@ -1,20 +0,0 @@
// react-grid-layout ships these layout helpers without type declarations. They
// are the same functions GridLayout uses internally, so the V2 dashboard reuses
// them to compact a section's items exactly the way they render.
declare module 'react-grid-layout/build/utils' {
import type { Layout } from 'react-grid-layout';
export type CompactType = 'horizontal' | 'vertical' | null;
export function compact(
layout: Layout[],
compactType: CompactType,
cols: number,
allowOverlap?: boolean,
): Layout[];
export function correctBounds(
layout: Layout[],
bounds: { cols: number },
): Layout[];
}

View File

@@ -112,7 +112,7 @@ type AlertCompositeQuery struct {
type RuleCondition struct {
CompositeQuery *AlertCompositeQuery `json:"compositeQuery" required:"true"`
CompareOperator CompareOperator `json:"op,omitzero"`
Target *float64 `json:"target,omitempty"`
Target *float64 `json:"target,omitempty" format:"double"`
AlertOnAbsent bool `json:"alertOnAbsent,omitempty"`
AbsentFor uint64 `json:"absentFor,omitempty"`
MatchType MatchType `json:"matchType,omitzero"`
@@ -187,4 +187,3 @@ func (rc *RuleCondition) String() string {
data, _ := json.Marshal(*rc)
return string(data)
}

View File

@@ -134,9 +134,9 @@ type RuleThreshold interface {
type BasicRuleThreshold struct {
Name string `json:"name" required:"true"`
TargetValue *float64 `json:"target" required:"true"`
TargetValue *float64 `json:"target" required:"true" format:"double"`
TargetUnit string `json:"targetUnit"`
RecoveryTarget *float64 `json:"recoveryTarget"`
RecoveryTarget *float64 `json:"recoveryTarget" format:"double"`
MatchType MatchType `json:"matchType" required:"true"`
CompareOperator CompareOperator `json:"op" required:"true"`
Channels []string `json:"channels"`