mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-06 20:20:28 +01:00
Compare commits
100 Commits
asset-migr
...
nv/4172
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55a4471427 | ||
|
|
10f60c011f | ||
|
|
5ad56658bf | ||
|
|
bfb2f5719c | ||
|
|
6fe69b94c9 | ||
|
|
1eb27d0bc4 | ||
|
|
55cce3c708 | ||
|
|
bb725ea4d9 | ||
|
|
65bedcca3f | ||
|
|
1e7ddb0dbe | ||
|
|
f39e2183a3 | ||
|
|
43c9367ab5 | ||
|
|
596cb8adbb | ||
|
|
58a2737717 | ||
|
|
8263aed441 | ||
|
|
d3eb56f3da | ||
|
|
6e0c905977 | ||
|
|
8ee5b5f08e | ||
|
|
1325b0a1b3 | ||
|
|
76dc3b743b | ||
|
|
dfe0a9d147 | ||
|
|
f24c3d8e24 | ||
|
|
7dba4d7b64 | ||
|
|
2299cedeab | ||
|
|
6812d68d4d | ||
|
|
667a1e6d5d | ||
|
|
31306a57d8 | ||
|
|
839b3e29ee | ||
|
|
20ab7d4908 | ||
|
|
d5b72f9f0c | ||
|
|
9a3efa7704 | ||
|
|
fd45b4fad1 | ||
|
|
cea88bf51e | ||
|
|
6ead1cd52a | ||
|
|
7cb700428c | ||
|
|
a2dc410be6 | ||
|
|
66ce48434d | ||
|
|
7d3612c10a | ||
|
|
c82cd32f61 | ||
|
|
a3980e084c | ||
|
|
4319dd9cef | ||
|
|
92a5e9b9c9 | ||
|
|
408a914129 | ||
|
|
0e304b1d40 | ||
|
|
58a9be24d3 | ||
|
|
adf439fcf1 | ||
|
|
a1a54c4bb2 | ||
|
|
3c1961d3fc | ||
|
|
c3efa0660b | ||
|
|
183dd09082 | ||
|
|
a351373c49 | ||
|
|
8e7653b90d | ||
|
|
5c40d6b68b | ||
|
|
31115df41c | ||
|
|
869c3dccb2 | ||
|
|
c5d7a7ef8c | ||
|
|
544b87b254 | ||
|
|
e885fb98e5 | ||
|
|
be227eec43 | ||
|
|
13263c1f25 | ||
|
|
ccbf410d15 | ||
|
|
03b98ff824 | ||
|
|
2cdba0d11c | ||
|
|
84d2885530 | ||
|
|
b82dcc6138 | ||
|
|
a14d5847b9 | ||
|
|
d184746142 | ||
|
|
c335e17e1d | ||
|
|
433dd0b2d0 | ||
|
|
05e97e246a | ||
|
|
bddfe30f6c | ||
|
|
7a01a5250d | ||
|
|
09c98c830d | ||
|
|
0fbb90cc91 | ||
|
|
15f0787610 | ||
|
|
22ebc7732c | ||
|
|
cff18edf6e | ||
|
|
cb49c0bf3b | ||
|
|
1cb6f94d21 | ||
|
|
68155f374b | ||
|
|
696524509f | ||
|
|
705cdab38c | ||
|
|
ae9b881413 | ||
|
|
05f4e15d07 | ||
|
|
1653c6d725 | ||
|
|
070b4b7061 | ||
|
|
7f4c06edd6 | ||
|
|
6bed20b5b9 | ||
|
|
033bd3c9b8 | ||
|
|
d4c9a923fd | ||
|
|
387dcb529f | ||
|
|
7a4da7bcc5 | ||
|
|
b152fae3fa | ||
|
|
2ed766726c | ||
|
|
8767f6a57d | ||
|
|
22d8c7599b | ||
|
|
1019264272 | ||
|
|
c950d7e784 | ||
|
|
1e279e6193 | ||
|
|
d3a278c43e |
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -64,12 +63,12 @@ func NewAnomalyRule(
|
||||
BaseRule: baseRule,
|
||||
}
|
||||
|
||||
switch strings.ToLower(p.RuleCondition.Seasonality) {
|
||||
case "hourly":
|
||||
switch p.RuleCondition.Seasonality {
|
||||
case ruletypes.SeasonalityHourly:
|
||||
t.seasonality = anomaly.SeasonalityHourly
|
||||
case "daily":
|
||||
case ruletypes.SeasonalityDaily:
|
||||
t.seasonality = anomaly.SeasonalityDaily
|
||||
case "weekly":
|
||||
case ruletypes.SeasonalityWeekly:
|
||||
t.seasonality = anomaly.SeasonalityWeekly
|
||||
default:
|
||||
t.seasonality = anomaly.SeasonalityDaily
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
|
||||
}},
|
||||
},
|
||||
SelectedQuery: "A",
|
||||
Seasonality: "daily",
|
||||
Seasonality: ruletypes.SeasonalityDaily,
|
||||
Thresholds: &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{{
|
||||
@@ -170,7 +170,7 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
|
||||
}},
|
||||
},
|
||||
SelectedQuery: "A",
|
||||
Seasonality: "daily",
|
||||
Seasonality: ruletypes.SeasonalityDaily,
|
||||
Thresholds: &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{{
|
||||
|
||||
@@ -83,37 +83,6 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__role-select {
|
||||
width: 100%;
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--l2-background) !important;
|
||||
border-color: var(--border) !important;
|
||||
border-radius: 2px;
|
||||
padding: var(--padding-1) var(--padding-2) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
min-height: 32px;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l1-foreground);
|
||||
line-height: 22px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&:not(.ant-select-disabled):hover .ant-select-selector {
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -61,10 +61,6 @@ function toSaveApiError(err: unknown): APIError {
|
||||
);
|
||||
}
|
||||
|
||||
function areSortedArraysEqual(a: string[], b: string[]): boolean {
|
||||
return JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
|
||||
}
|
||||
|
||||
export interface EditMemberDrawerProps {
|
||||
member: MemberRow | null;
|
||||
open: boolean;
|
||||
@@ -83,7 +79,7 @@ function EditMemberDrawer({
|
||||
const { user: currentUser } = useAppContext();
|
||||
|
||||
const [localDisplayName, setLocalDisplayName] = useState('');
|
||||
const [localRoles, setLocalRoles] = useState<string[]>([]);
|
||||
const [localRole, setLocalRole] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
|
||||
const [isGeneratingLink, setIsGeneratingLink] = useState(false);
|
||||
@@ -133,14 +129,14 @@ function EditMemberDrawer({
|
||||
}, [fetchedUserId, fetchedUserDisplayName, member?.name]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalRoles(fetchedRoleIds);
|
||||
setLocalRole(fetchedRoleIds[0] ?? '');
|
||||
}, [fetchedRoleIds]);
|
||||
|
||||
const isDirty =
|
||||
member !== null &&
|
||||
fetchedUser != null &&
|
||||
(localDisplayName !== fetchedDisplayName ||
|
||||
!areSortedArraysEqual(localRoles, fetchedRoleIds));
|
||||
localRole !== (fetchedRoleIds[0] ?? ''));
|
||||
|
||||
const { mutateAsync: updateMyUser } = useUpdateMyUserV2();
|
||||
const { mutateAsync: updateUser } = useUpdateUser();
|
||||
@@ -224,7 +220,7 @@ function EditMemberDrawer({
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const nameChanged = localDisplayName !== fetchedDisplayName;
|
||||
const rolesChanged = !areSortedArraysEqual(localRoles, fetchedRoleIds);
|
||||
const rolesChanged = localRole !== (fetchedRoleIds[0] ?? '');
|
||||
|
||||
const namePromise = nameChanged
|
||||
? isSelf
|
||||
@@ -237,7 +233,9 @@ function EditMemberDrawer({
|
||||
|
||||
const [nameResult, rolesResult] = await Promise.allSettled([
|
||||
namePromise,
|
||||
rolesChanged ? applyDiff(localRoles, availableRoles) : Promise.resolve([]),
|
||||
rolesChanged
|
||||
? applyDiff([localRole].filter(Boolean), availableRoles)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const errors: SaveError[] = [];
|
||||
@@ -255,7 +253,10 @@ function EditMemberDrawer({
|
||||
context: 'Roles update',
|
||||
apiError: toSaveApiError(rolesResult.reason),
|
||||
onRetry: async (): Promise<void> => {
|
||||
const failures = await applyDiff(localRoles, availableRoles);
|
||||
const failures = await applyDiff(
|
||||
[localRole].filter(Boolean),
|
||||
availableRoles,
|
||||
);
|
||||
setSaveErrors((prev) => {
|
||||
const rest = prev.filter((e) => e.context !== 'Roles update');
|
||||
return [
|
||||
@@ -303,7 +304,7 @@ function EditMemberDrawer({
|
||||
isDirty,
|
||||
isSelf,
|
||||
localDisplayName,
|
||||
localRoles,
|
||||
localRole,
|
||||
fetchedDisplayName,
|
||||
fetchedRoleIds,
|
||||
updateMyUser,
|
||||
@@ -445,15 +446,10 @@ function EditMemberDrawer({
|
||||
>
|
||||
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
|
||||
<div className="edit-member-drawer__disabled-roles">
|
||||
{localRoles.length > 0 ? (
|
||||
localRoles.map((roleId) => {
|
||||
const role = availableRoles.find((r) => r.id === roleId);
|
||||
return (
|
||||
<Badge key={roleId} color="vanilla">
|
||||
{role?.name ?? roleId}
|
||||
</Badge>
|
||||
);
|
||||
})
|
||||
{localRole ? (
|
||||
<Badge color="vanilla">
|
||||
{availableRoles.find((r) => r.id === localRole)?.name ?? localRole}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="edit-member-drawer__email-text">—</span>
|
||||
)}
|
||||
@@ -464,15 +460,14 @@ function EditMemberDrawer({
|
||||
) : (
|
||||
<RolesSelect
|
||||
id="member-role"
|
||||
mode="multiple"
|
||||
roles={availableRoles}
|
||||
loading={rolesLoading}
|
||||
isError={rolesError}
|
||||
error={rolesErrorObj}
|
||||
onRefetch={refetchRoles}
|
||||
value={localRoles}
|
||||
onChange={(roles): void => {
|
||||
setLocalRoles(roles);
|
||||
value={localRole}
|
||||
onChange={(role): void => {
|
||||
setLocalRole(role);
|
||||
setSaveErrors((prev) =>
|
||||
prev.filter(
|
||||
(err) =>
|
||||
@@ -480,8 +475,7 @@ function EditMemberDrawer({
|
||||
),
|
||||
);
|
||||
}}
|
||||
className="edit-member-drawer__role-select"
|
||||
placeholder="Select roles"
|
||||
placeholder="Select role"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
getResetPasswordToken,
|
||||
useDeleteUser,
|
||||
useGetUser,
|
||||
useRemoveUserRoleByUserIDAndRoleID,
|
||||
useSetRoleByUserID,
|
||||
useUpdateMyUserV2,
|
||||
useUpdateUser,
|
||||
@@ -56,7 +55,6 @@ jest.mock('api/generated/services/users', () => ({
|
||||
useUpdateUser: jest.fn(),
|
||||
useUpdateMyUserV2: jest.fn(),
|
||||
useSetRoleByUserID: jest.fn(),
|
||||
useRemoveUserRoleByUserIDAndRoleID: jest.fn(),
|
||||
getResetPasswordToken: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -171,10 +169,6 @@ describe('EditMemberDrawer', () => {
|
||||
mutateAsync: jest.fn().mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
});
|
||||
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
|
||||
mutateAsync: jest.fn().mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
});
|
||||
(useDeleteUser as jest.Mock).mockReturnValue({
|
||||
mutate: mockDeleteMutate,
|
||||
isLoading: false,
|
||||
@@ -248,7 +242,7 @@ describe('EditMemberDrawer', () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls setRole when a new role is added', async () => {
|
||||
it('selecting a different role calls setRole with the new role name', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockSet = jest.fn().mockResolvedValue({});
|
||||
@@ -277,32 +271,30 @@ describe('EditMemberDrawer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('calls removeRole when an existing role is removed', async () => {
|
||||
it('does not call removeRole when the role is changed', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockRemove = jest.fn().mockResolvedValue({});
|
||||
const mockSet = jest.fn().mockResolvedValue({});
|
||||
|
||||
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
|
||||
mutateAsync: mockRemove,
|
||||
(useSetRoleByUserID as jest.Mock).mockReturnValue({
|
||||
mutateAsync: mockSet,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderDrawer({ onComplete });
|
||||
|
||||
// Wait for the signoz-admin tag to appear, then click its remove button
|
||||
const adminTag = await screen.findByTitle('signoz-admin');
|
||||
const removeBtn = adminTag.querySelector(
|
||||
'.ant-select-selection-item-remove',
|
||||
) as Element;
|
||||
await user.click(removeBtn);
|
||||
// Switch from signoz-admin to signoz-viewer using single-select
|
||||
await user.click(screen.getByLabelText('Roles'));
|
||||
await user.click(await screen.findByTitle('signoz-viewer'));
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRemove).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'user-1', roleId: managedRoles[0].id },
|
||||
expect(mockSet).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'user-1' },
|
||||
data: { name: 'signoz-viewer' },
|
||||
});
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -88,3 +88,13 @@
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
|
||||
.roles-single-select {
|
||||
.ant-select-selector {
|
||||
min-height: 32px;
|
||||
background-color: var(--l2-background) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 2px;
|
||||
padding: 2px var(--padding-2) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,10 +158,10 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
return (
|
||||
<Select
|
||||
id={id}
|
||||
value={value}
|
||||
value={value || undefined}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className={cx('roles-select', className)}
|
||||
className={cx('roles-single-select', className)}
|
||||
loading={loading}
|
||||
notFoundContent={notFoundContent}
|
||||
options={options}
|
||||
|
||||
@@ -16,8 +16,8 @@ interface OverviewTabProps {
|
||||
account: ServiceAccountRow;
|
||||
localName: string;
|
||||
onNameChange: (v: string) => void;
|
||||
localRoles: string[];
|
||||
onRolesChange: (v: string[]) => void;
|
||||
localRole: string;
|
||||
onRoleChange: (v: string) => void;
|
||||
isDisabled: boolean;
|
||||
availableRoles: AuthtypesRoleDTO[];
|
||||
rolesLoading?: boolean;
|
||||
@@ -31,8 +31,8 @@ function OverviewTab({
|
||||
account,
|
||||
localName,
|
||||
onNameChange,
|
||||
localRoles,
|
||||
onRolesChange,
|
||||
localRole,
|
||||
onRoleChange,
|
||||
isDisabled,
|
||||
availableRoles,
|
||||
rolesLoading,
|
||||
@@ -96,15 +96,10 @@ function OverviewTab({
|
||||
{isDisabled ? (
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<div className="sa-drawer__disabled-roles">
|
||||
{localRoles.length > 0 ? (
|
||||
localRoles.map((roleId) => {
|
||||
const role = availableRoles.find((r) => r.id === roleId);
|
||||
return (
|
||||
<Badge key={roleId} color="vanilla">
|
||||
{role?.name ?? roleId}
|
||||
</Badge>
|
||||
);
|
||||
})
|
||||
{localRole ? (
|
||||
<Badge color="vanilla">
|
||||
{availableRoles.find((r) => r.id === localRole)?.name ?? localRole}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="sa-drawer__input-text">—</span>
|
||||
)}
|
||||
@@ -114,15 +109,14 @@ function OverviewTab({
|
||||
) : (
|
||||
<RolesSelect
|
||||
id="sa-roles"
|
||||
mode="multiple"
|
||||
roles={availableRoles}
|
||||
loading={rolesLoading}
|
||||
isError={rolesError}
|
||||
error={rolesErrorObj}
|
||||
onRefetch={onRefetchRoles}
|
||||
value={localRoles}
|
||||
onChange={onRolesChange}
|
||||
placeholder="Select roles"
|
||||
value={localRole}
|
||||
onChange={onRoleChange}
|
||||
placeholder="Select role"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -80,7 +80,7 @@ function ServiceAccountDrawer({
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
const [localName, setLocalName] = useState('');
|
||||
const [localRoles, setLocalRoles] = useState<string[]>([]);
|
||||
const [localRole, setLocalRole] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
|
||||
|
||||
@@ -116,7 +116,7 @@ function ServiceAccountDrawer({
|
||||
}, [account?.id, account?.name, setKeysPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalRoles(currentRoles.map((r) => r.id).filter(Boolean) as string[]);
|
||||
setLocalRole(currentRoles[0]?.id ?? '');
|
||||
}, [currentRoles]);
|
||||
|
||||
const isDeleted =
|
||||
@@ -125,8 +125,7 @@ function ServiceAccountDrawer({
|
||||
const isDirty =
|
||||
account !== null &&
|
||||
(localName !== (account.name ?? '') ||
|
||||
JSON.stringify([...localRoles].sort()) !==
|
||||
JSON.stringify([...currentRoles.map((r) => r.id).filter(Boolean)].sort()));
|
||||
localRole !== (currentRoles[0]?.id ?? ''));
|
||||
|
||||
const {
|
||||
roles: availableRoles,
|
||||
@@ -216,7 +215,10 @@ function ServiceAccountDrawer({
|
||||
|
||||
const retryRolesUpdate = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const failures = await applyDiff(localRoles, availableRoles);
|
||||
const failures = await applyDiff(
|
||||
[localRole].filter(Boolean),
|
||||
availableRoles,
|
||||
);
|
||||
if (failures.length === 0) {
|
||||
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Roles update'));
|
||||
} else {
|
||||
@@ -240,7 +242,7 @@ function ServiceAccountDrawer({
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [localRoles, availableRoles, applyDiff, toSaveApiError, makeRoleRetry]);
|
||||
}, [localRole, availableRoles, applyDiff, toSaveApiError, makeRoleRetry]);
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
if (!account || !isDirty) {
|
||||
@@ -259,7 +261,7 @@ function ServiceAccountDrawer({
|
||||
|
||||
const [nameResult, rolesResult] = await Promise.allSettled([
|
||||
namePromise,
|
||||
applyDiff(localRoles, availableRoles),
|
||||
applyDiff([localRole].filter(Boolean), availableRoles),
|
||||
]);
|
||||
|
||||
const errors: SaveError[] = [];
|
||||
@@ -308,7 +310,7 @@ function ServiceAccountDrawer({
|
||||
account,
|
||||
isDirty,
|
||||
localName,
|
||||
localRoles,
|
||||
localRole,
|
||||
availableRoles,
|
||||
updateMutateAsync,
|
||||
applyDiff,
|
||||
@@ -410,8 +412,8 @@ function ServiceAccountDrawer({
|
||||
account={account}
|
||||
localName={localName}
|
||||
onNameChange={handleNameChange}
|
||||
localRoles={localRoles}
|
||||
onRolesChange={setLocalRoles}
|
||||
localRole={localRole}
|
||||
onRoleChange={setLocalRole}
|
||||
isDisabled={isDeleted}
|
||||
availableRoles={availableRoles}
|
||||
rolesLoading={rolesLoading}
|
||||
|
||||
@@ -139,20 +139,20 @@ describe('ServiceAccountDrawer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('changing roles enables Save; clicking Save sends updated roles in payload', async () => {
|
||||
const updateSpy = jest.fn();
|
||||
it('changing roles enables Save; clicking Save sends role add request without delete', async () => {
|
||||
const roleSpy = jest.fn();
|
||||
const deleteSpy = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.put(SA_ENDPOINT, async (req, res, ctx) => {
|
||||
updateSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
|
||||
roleSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => {
|
||||
deleteSpy();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
@@ -167,12 +167,12 @@ describe('ServiceAccountDrawer', () => {
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).not.toHaveBeenCalled();
|
||||
expect(roleSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: '019c24aa-2248-7585-a129-4188b3473c27',
|
||||
}),
|
||||
);
|
||||
expect(deleteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -350,7 +350,7 @@ describe('ServiceAccountDrawer – save-error UX', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('role update failure shows SaveErrorItem with the role name context', async () => {
|
||||
it('role add failure shows SaveErrorItem with the role name context', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
|
||||
@@ -838,22 +838,20 @@ function FormAlertRules({
|
||||
>
|
||||
<div className="overview-header">
|
||||
<div className="alert-type-container">
|
||||
{isNewRule && (
|
||||
<Typography.Title level={5} className="alert-type-title">
|
||||
<BellDot size={14} />
|
||||
<Typography.Title level={5} className="alert-type-title">
|
||||
<BellDot size={14} />
|
||||
|
||||
{alertDef.alertType === AlertTypes.ANOMALY_BASED_ALERT &&
|
||||
'Anomaly Detection Alert'}
|
||||
{alertDef.alertType === AlertTypes.METRICS_BASED_ALERT &&
|
||||
'Metrics Based Alert'}
|
||||
{alertDef.alertType === AlertTypes.LOGS_BASED_ALERT &&
|
||||
'Logs Based Alert'}
|
||||
{alertDef.alertType === AlertTypes.TRACES_BASED_ALERT &&
|
||||
'Traces Based Alert'}
|
||||
{alertDef.alertType === AlertTypes.EXCEPTIONS_BASED_ALERT &&
|
||||
'Exceptions Based Alert'}
|
||||
</Typography.Title>
|
||||
)}
|
||||
{alertDef.alertType === AlertTypes.ANOMALY_BASED_ALERT &&
|
||||
'Anomaly Detection Alert'}
|
||||
{alertDef.alertType === AlertTypes.METRICS_BASED_ALERT &&
|
||||
'Metrics Based Alert'}
|
||||
{alertDef.alertType === AlertTypes.LOGS_BASED_ALERT &&
|
||||
'Logs Based Alert'}
|
||||
{alertDef.alertType === AlertTypes.TRACES_BASED_ALERT &&
|
||||
'Traces Based Alert'}
|
||||
{alertDef.alertType === AlertTypes.EXCEPTIONS_BASED_ALERT &&
|
||||
'Exceptions Based Alert'}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
useGetUser,
|
||||
useRemoveUserRoleByUserIDAndRoleID,
|
||||
useSetRoleByUserID,
|
||||
} from 'api/generated/services/users';
|
||||
import { useGetUser, useSetRoleByUserID } from 'api/generated/services/users';
|
||||
|
||||
export interface MemberRoleUpdateFailure {
|
||||
roleName: string;
|
||||
@@ -43,7 +39,6 @@ export function useMemberRoleManager(
|
||||
);
|
||||
|
||||
const { mutateAsync: setRole } = useSetRoleByUserID();
|
||||
const { mutateAsync: removeRole } = useRemoveUserRoleByUserIDAndRoleID();
|
||||
|
||||
const applyDiff = useCallback(
|
||||
async (
|
||||
@@ -53,25 +48,12 @@ export function useMemberRoleManager(
|
||||
const currentRoleIdSet = new Set(fetchedRoleIds);
|
||||
const desiredRoleIdSet = new Set(localRoleIds.filter(Boolean));
|
||||
|
||||
const toRemove = currentUserRoles.filter((ur) => {
|
||||
const id = ur.role?.id ?? ur.roleId;
|
||||
return id && !desiredRoleIdSet.has(id);
|
||||
});
|
||||
const toAdd = availableRoles.filter(
|
||||
(r) => r.id && desiredRoleIdSet.has(r.id) && !currentRoleIdSet.has(r.id),
|
||||
);
|
||||
|
||||
/// TODO: re-enable deletes once BE for this is streamlined
|
||||
const allOps = [
|
||||
...toRemove.map((ur) => ({
|
||||
roleName: ur.role?.name ?? 'unknown',
|
||||
run: (): ReturnType<typeof removeRole> =>
|
||||
removeRole({
|
||||
pathParams: {
|
||||
id: userId,
|
||||
roleId: ur.role?.id ?? ur.roleId ?? '',
|
||||
},
|
||||
}),
|
||||
})),
|
||||
...toAdd.map((role) => ({
|
||||
roleName: role.name ?? 'unknown',
|
||||
run: (): ReturnType<typeof setRole> =>
|
||||
@@ -94,7 +76,7 @@ export function useMemberRoleManager(
|
||||
|
||||
return failures;
|
||||
},
|
||||
[userId, fetchedRoleIds, currentUserRoles, setRole, removeRole],
|
||||
[userId, fetchedRoleIds, setRole],
|
||||
);
|
||||
|
||||
return { fetchedRoleIds, isLoading, applyDiff };
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getGetServiceAccountRolesQueryKey,
|
||||
useCreateServiceAccountRole,
|
||||
useDeleteServiceAccountRole,
|
||||
useGetServiceAccountRoles,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -36,7 +35,6 @@ export function useServiceAccountRoleManager(
|
||||
|
||||
// the retry for these mutations is safe due to being idempotent on backend
|
||||
const { mutateAsync: createRole } = useCreateServiceAccountRole();
|
||||
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole();
|
||||
|
||||
const invalidateRoles = useCallback(
|
||||
() =>
|
||||
@@ -62,21 +60,13 @@ export function useServiceAccountRoleManager(
|
||||
(r) => r.id && desiredRoleIds.has(r.id) && !currentRoleIds.has(r.id),
|
||||
);
|
||||
|
||||
const removedRoles = currentRoles.filter(
|
||||
(r) => r.id && !desiredRoleIds.has(r.id),
|
||||
);
|
||||
|
||||
// TODO: re-enable deletes once BE for this is streamlined
|
||||
const allOperations = [
|
||||
...addedRoles.map((role) => ({
|
||||
role,
|
||||
run: (): ReturnType<typeof createRole> =>
|
||||
createRole({ pathParams: { id: accountId }, data: { id: role.id } }),
|
||||
})),
|
||||
...removedRoles.map((role) => ({
|
||||
role,
|
||||
run: (): ReturnType<typeof deleteRole> =>
|
||||
deleteRole({ pathParams: { id: accountId, rid: role.id } }),
|
||||
})),
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
@@ -102,7 +92,7 @@ export function useServiceAccountRoleManager(
|
||||
|
||||
return failures;
|
||||
},
|
||||
[accountId, currentRoles, createRole, deleteRole, invalidateRoles],
|
||||
[accountId, currentRoles, createRole, invalidateRoles],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
37
go.mod
37
go.mod
@@ -1,6 +1,6 @@
|
||||
module github.com/SigNoz/signoz
|
||||
|
||||
go 1.25.0
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2
|
||||
@@ -19,6 +19,7 @@ require (
|
||||
github.com/go-co-op/gocron v1.30.1
|
||||
github.com/go-openapi/runtime v0.29.2
|
||||
github.com/go-openapi/strfmt v0.25.0
|
||||
github.com/go-playground/validator/v10 v10.27.0
|
||||
github.com/go-redis/redismock/v9 v9.2.0
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0
|
||||
github.com/gojek/heimdall/v7 v7.0.3
|
||||
@@ -27,8 +28,8 @@ require (
|
||||
github.com/gorilla/handlers v1.5.1
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
||||
github.com/huandu/go-sqlbuilder v1.35.0
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
github.com/huandu/go-sqlbuilder v1.39.1
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12
|
||||
github.com/knadh/koanf v1.5.0
|
||||
github.com/knadh/koanf/v2 v2.3.2
|
||||
@@ -38,6 +39,7 @@ require (
|
||||
github.com/openfga/api/proto v0.0.0-20250909172242-b4b2a12f5c67
|
||||
github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20251027165255-0f8f255e5f6c
|
||||
github.com/opentracing/opentracing-go v1.2.0
|
||||
github.com/perses/perses v0.53.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/prometheus/alertmanager v0.31.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
@@ -75,18 +77,18 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.40.0
|
||||
go.uber.org/multierr v1.11.0
|
||||
go.uber.org/zap v1.27.1
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96
|
||||
golang.org/x/net v0.49.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/text v0.33.0
|
||||
golang.org/x/text v0.34.0
|
||||
gonum.org/v1/gonum v0.17.0
|
||||
google.golang.org/api v0.265.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
k8s.io/apimachinery v0.35.0
|
||||
k8s.io/apimachinery v0.35.2
|
||||
modernc.org/sqlite v1.40.1
|
||||
)
|
||||
|
||||
@@ -125,12 +127,14 @@ require (
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/hashicorp/go-metrics v0.5.4 // indirect
|
||||
github.com/huandu/go-clone v1.7.3 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/muhlemmer/gu v0.3.1 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/perses/common v0.30.2 // indirect
|
||||
github.com/prometheus/client_golang/exp v0.0.0-20260108101519-fb0838f53562 // indirect
|
||||
github.com/redis/go-redis/extra/rediscmd/v9 v9.15.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
@@ -139,6 +143,8 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect
|
||||
github.com/zitadel/oidc/v3 v3.45.4 // indirect
|
||||
github.com/zitadel/schema v1.3.2 // indirect
|
||||
go.opentelemetry.io/collector/client v1.50.0 // indirect
|
||||
go.opentelemetry.io/collector/config/configoptional v1.50.0 // indirect
|
||||
go.opentelemetry.io/collector/config/configretry v1.50.0 // indirect
|
||||
@@ -208,7 +214,7 @@ require (
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/cel-go v0.26.1 // indirect
|
||||
github.com/google/cel-go v0.27.0 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
|
||||
@@ -226,7 +232,7 @@ require (
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/hashicorp/memberlist v0.5.4 // indirect
|
||||
github.com/huandu/xstrings v1.4.0 // indirect
|
||||
github.com/huandu/xstrings v1.5.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -298,7 +304,6 @@ require (
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/viper v1.20.1 // indirect
|
||||
github.com/stoewer/go-strcase v1.3.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/swaggest/openapi-go v0.2.60
|
||||
@@ -376,15 +381,15 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409
|
||||
google.golang.org/grpc v1.78.0 // indirect
|
||||
gopkg.in/telebot.v3 v3.3.8 // indirect
|
||||
k8s.io/client-go v0.35.0 // indirect
|
||||
k8s.io/client-go v0.35.2 // indirect
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||
)
|
||||
|
||||
81
go.sum
81
go.sum
@@ -489,8 +489,8 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=
|
||||
github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
|
||||
github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo=
|
||||
github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw=
|
||||
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
@@ -654,12 +654,15 @@ github.com/hetznercloud/hcloud-go/v2 v2.36.0 h1:HlLL/aaVXUulqe+rsjoJmrxKhPi1MflL
|
||||
github.com/hetznercloud/hcloud-go/v2 v2.36.0/go.mod h1:MnN/QJEa/RYNQiiVoJjNHPntM7Z1wlYPgJ2HA40/cDE=
|
||||
github.com/hjson/hjson-go/v4 v4.0.0 h1:wlm6IYYqHjOdXH1gHev4VoXCaW20HdQAGCxdOEEg2cs=
|
||||
github.com/hjson/hjson-go/v4 v4.0.0/go.mod h1:KaYt3bTw3zhBjYqnXkYywcYctk0A2nxeEFTse3rH13E=
|
||||
github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U=
|
||||
github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs=
|
||||
github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs=
|
||||
github.com/huandu/go-sqlbuilder v1.35.0 h1:ESvxFHN8vxCTudY1Vq63zYpU5yJBESn19sf6k4v2T5Q=
|
||||
github.com/huandu/go-sqlbuilder v1.35.0/go.mod h1:mS0GAtrtW+XL6nM2/gXHRJax2RwSW1TraavWDFAc1JA=
|
||||
github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU=
|
||||
github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ=
|
||||
github.com/huandu/go-clone v1.7.3/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE=
|
||||
github.com/huandu/go-sqlbuilder v1.39.1 h1:uUaj41yLNTQBe7ojNF6Im1RPbHCN4zCjMRySTEC2ooI=
|
||||
github.com/huandu/go-sqlbuilder v1.39.1/go.mod h1:zdONH67liL+/TvoUMwnZP/sUYGSSvHh9psLe/HpXn8E=
|
||||
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
|
||||
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc=
|
||||
github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
@@ -672,8 +675,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
|
||||
@@ -818,6 +821,8 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
|
||||
github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
@@ -827,9 +832,11 @@ github.com/natefinch/wrap v0.2.0 h1:IXzc/pw5KqxJv55gV0lSOcKHYuEZPGbQrOOXr/bamRk=
|
||||
github.com/natefinch/wrap v0.2.0/go.mod h1:6gMHlAl12DwYEfKP3TkuykYUfLSEAvHw67itm4/KAS8=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nexucis/lamenv v0.5.2 h1:tK/u3XGhCq9qIoVNcXsK9LZb8fKopm0A5weqSRvHd7M=
|
||||
github.com/nexucis/lamenv v0.5.2/go.mod h1:HusJm6ltmmT7FMG8A750mOLuME6SHCsr2iFYxp5fFi0=
|
||||
github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
|
||||
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
|
||||
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
|
||||
github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E=
|
||||
github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk=
|
||||
@@ -891,6 +898,10 @@ github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCko
|
||||
github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/perses/common v0.30.2 h1:RAiVxUpX76lTCb4X7pfcXSvYdXQmZwKi4oDKAEO//u0=
|
||||
github.com/perses/common v0.30.2/go.mod h1:DFtur1QPah2/ChXbKKhw7djYdwNgz27s5fPKpiK0Xao=
|
||||
github.com/perses/perses v0.53.1 h1:9VY/6p9QWrZwPSV7qiwTMSOsgcB37Lb1AXKT0ORXc6I=
|
||||
github.com/perses/perses v0.53.1/go.mod h1:ro8fsgBkHYOdrL/MV+fdP9mflKzYCy/+gcbxiaReI/A=
|
||||
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
|
||||
github.com/pierrec/lz4/v4 v4.1.23 h1:oJE7T90aYBGtFNrI8+KbETnPymobAhzRrR8Mu8n1yfU=
|
||||
github.com/pierrec/lz4/v4 v4.1.23/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
@@ -1049,8 +1060,6 @@ github.com/srikanthccv/ClickHouse-go-mock v0.13.0 h1:/b7DQphGkh29ocNtLh4DGmQxQYA
|
||||
github.com/srikanthccv/ClickHouse-go-mock v0.13.0/go.mod h1:LiiyBUdXNwB/1DE9rgK/8q9qjVYsTzg6WXQ/3mU3TeY=
|
||||
github.com/stackitcloud/stackit-sdk-go/core v0.21.1 h1:Y/PcAgM7DPYMNqum0MLv4n1mF9ieuevzcCIZYQfm3Ts=
|
||||
github.com/stackitcloud/stackit-sdk-go/core v0.21.1/go.mod h1:osMglDby4csGZ5sIfhNyYq1bS1TxIdPY88+skE/kkmI=
|
||||
github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=
|
||||
github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
@@ -1150,6 +1159,10 @@ github.com/zeebo/assert v1.3.1 h1:vukIABvugfNMZMQO1ABsyQDJDTVQbn+LWSMy1ol1h6A=
|
||||
github.com/zeebo/assert v1.3.1/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
github.com/zitadel/oidc/v3 v3.45.4 h1:GKyWaPRVQ8sCu9XgJ3NgNGtG52FzwVJpzXjIUG2+YrI=
|
||||
github.com/zitadel/oidc/v3 v3.45.4/go.mod h1:XALmFXS9/kSom9B6uWin1yJ2WTI/E4Ti5aXJdewAVEs=
|
||||
github.com/zitadel/schema v1.3.2 h1:gfJvt7dOMfTmxzhscZ9KkapKo3Nei3B6cAxjav+lyjI=
|
||||
github.com/zitadel/schema v1.3.2/go.mod h1:IZmdfF9Wu62Zu6tJJTH3UsArevs3Y4smfJIj3L8fzxw=
|
||||
go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A=
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
|
||||
go.etcd.io/etcd/client/v2 v2.305.4/go.mod h1:Ud+VUwIi9/uQHOMA+4ekToJ12lTxlv0zB/+DHwTGEbU=
|
||||
@@ -1385,8 +1398,8 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm
|
||||
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -1424,8 +1437,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -1474,8 +1487,8 @@ golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su
|
||||
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -1496,8 +1509,8 @@ golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ
|
||||
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -1598,12 +1611,12 @@ golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -1614,8 +1627,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -1678,8 +1691,8 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools/godoc v0.1.0-deprecated h1:o+aZ1BOj6Hsx/GBdJO/s815sqftjSnrZZwyYTHODvtk=
|
||||
golang.org/x/tools/godoc v0.1.0-deprecated/go.mod h1:qM63CriJ961IHWmnWa9CjZnBndniPt4a3CK0PVB9bIg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -1915,12 +1928,12 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=
|
||||
k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA=
|
||||
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
|
||||
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
|
||||
k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
|
||||
k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
|
||||
k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw=
|
||||
k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60=
|
||||
k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8=
|
||||
k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
|
||||
k8s.io/client-go v0.35.2 h1:YUfPefdGJA4aljDdayAXkc98DnPkIetMl4PrKX97W9o=
|
||||
k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g=
|
||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
|
||||
|
||||
@@ -240,6 +240,26 @@ func (m *MockNotificationManager) DeleteAllRoutePoliciesByName(ctx context.Conte
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockNotificationManager) GetRoutePoliciesByChannel(ctx context.Context, orgID string, channelName string) ([]*alertmanagertypes.RoutePolicy, error) {
|
||||
if orgID == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "orgID cannot be empty")
|
||||
}
|
||||
|
||||
var matched []*alertmanagertypes.RoutePolicy
|
||||
for _, route := range m.routes {
|
||||
if route.OrgID != orgID {
|
||||
continue
|
||||
}
|
||||
for _, ch := range route.Channels {
|
||||
if ch == channelName {
|
||||
matched = append(matched, route)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func (m *MockNotificationManager) Match(ctx context.Context, orgID string, ruleID string, set model.LabelSet) ([]string, error) {
|
||||
key := getKey(orgID, ruleID)
|
||||
if err := m.errors[key]; err != nil {
|
||||
|
||||
@@ -59,6 +59,10 @@ func (m *MockSQLRouteStore) DeleteRouteByName(ctx context.Context, orgID string,
|
||||
return m.routeStore.DeleteRouteByName(ctx, orgID, name)
|
||||
}
|
||||
|
||||
func (m *MockSQLRouteStore) GetAll(ctx context.Context, orgID string) ([]*alertmanagertypes.RoutePolicy, error) {
|
||||
return m.routeStore.GetAll(ctx, orgID)
|
||||
}
|
||||
|
||||
func (m *MockSQLRouteStore) ExpectGetByID(orgID, id string, route *alertmanagertypes.RoutePolicy) {
|
||||
rows := sqlmock.NewRows([]string{"id", "org_id", "name", "expression", "kind", "description", "enabled", "tags", "channels", "created_at", "updated_at", "created_by", "updated_by"})
|
||||
|
||||
|
||||
@@ -83,6 +83,18 @@ func (store *store) GetAllByName(ctx context.Context, orgID string, name string)
|
||||
return routes, nil
|
||||
}
|
||||
|
||||
func (store *store) GetAll(ctx context.Context, orgID string) ([]*routeTypes.RoutePolicy, error) {
|
||||
var routes []*routeTypes.RoutePolicy
|
||||
err := store.sqlstore.BunDBCtx(ctx).NewSelect().Model(&routes).Where("org_id = ?", orgID).Scan(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to fetch routing policies for orgID: %s", orgID)
|
||||
}
|
||||
return routes, nil
|
||||
}
|
||||
|
||||
func (store *store) DeleteRouteByName(ctx context.Context, orgID string, name string) error {
|
||||
_, err := store.sqlstore.BunDBCtx(ctx).NewDelete().Model((*routeTypes.RoutePolicy)(nil)).Where("org_id = ?", orgID).Where("name = ?", name).Exec(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -23,6 +23,10 @@ type NotificationManager interface {
|
||||
DeleteRoutePolicy(ctx context.Context, orgID string, routeID string) error
|
||||
DeleteAllRoutePoliciesByName(ctx context.Context, orgID string, name string) error
|
||||
|
||||
// GetRoutePoliciesByChannel returns all route policies (both rule-based and policy-based)
|
||||
// that reference the given channel name.
|
||||
GetRoutePoliciesByChannel(ctx context.Context, orgID string, channelName string) ([]*alertmanagertypes.RoutePolicy, error)
|
||||
|
||||
// Route matching
|
||||
Match(ctx context.Context, orgID string, ruleID string, set model.LabelSet) ([]string, error)
|
||||
}
|
||||
|
||||
@@ -155,6 +155,28 @@ func (r *provider) GetAllRoutePolicies(ctx context.Context, orgID string) ([]*al
|
||||
return r.routeStore.GetAllByKind(ctx, orgID, alertmanagertypes.PolicyBasedExpression)
|
||||
}
|
||||
|
||||
func (r *provider) GetRoutePoliciesByChannel(ctx context.Context, orgID string, channelName string) ([]*alertmanagertypes.RoutePolicy, error) {
|
||||
if orgID == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "orgID cannot be empty")
|
||||
}
|
||||
|
||||
allRoutes, err := r.routeStore.GetAll(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var matched []*alertmanagertypes.RoutePolicy
|
||||
for _, route := range allRoutes {
|
||||
for _, ch := range route.Channels {
|
||||
if ch == channelName {
|
||||
matched = append(matched, route)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func (r *provider) DeleteRoutePolicy(ctx context.Context, orgID string, routeID string) error {
|
||||
if routeID == "" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "routeID cannot be empty")
|
||||
|
||||
@@ -169,6 +169,21 @@ func (provider *provider) DeleteChannelByID(ctx context.Context, orgID string, c
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if channel is referenced by any route policy (rule-based or policy-based)
|
||||
policies, err := provider.notificationManager.GetRoutePoliciesByChannel(ctx, orgID, channel.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(policies) > 0 {
|
||||
names := make([]string, 0, len(policies))
|
||||
for _, p := range policies {
|
||||
names = append(names, p.Name)
|
||||
}
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"channel %q cannot be deleted because it is used by the following routing policies: %v",
|
||||
channel.Name, names)
|
||||
}
|
||||
|
||||
config, err := provider.configStore.Get(ctx, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -376,7 +376,7 @@ func (module *module) getOrGetSetIdentity(ctx context.Context, serviceAccountID
|
||||
}
|
||||
|
||||
func (module *module) setRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID, role *authtypes.Role) error {
|
||||
serviceAccount, err := module.Get(ctx, orgID, id)
|
||||
serviceAccount, err := module.GetWithRoles(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -386,12 +386,24 @@ func (module *module) setRole(ctx context.Context, orgID valuer.UUID, id valuer.
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.authz.Grant(ctx, orgID, []string{role.Name}, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, id.String(), orgID, nil))
|
||||
err = module.authz.ModifyGrant(ctx, orgID, serviceAccount.RoleNames(), []string{role.Name}, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, id.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.CreateServiceAccountRole(ctx, serviceAccountRole)
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
err = module.store.DeleteServiceAccountRoles(ctx, serviceAccount.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.CreateServiceAccountRole(ctx, serviceAccountRole)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -170,6 +170,21 @@ func (store *store) CreateServiceAccountRole(ctx context.Context, serviceAccount
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) DeleteServiceAccountRoles(ctx context.Context, serviceAccountID valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(new(serviceaccounttypes.ServiceAccountRole)).
|
||||
Where("service_account_id = ?", serviceAccountID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) DeleteServiceAccountRole(ctx context.Context, serviceAccountID valuer.UUID, roleID valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -66,6 +67,8 @@ type Module interface {
|
||||
GetIdentity(context.Context, string) (*authtypes.Identity, error)
|
||||
|
||||
Config() Config
|
||||
|
||||
statsreporter.StatsCollector
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
|
||||
@@ -383,6 +383,11 @@ func (module *setter) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
|
||||
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "cannot self delete")
|
||||
}
|
||||
|
||||
err = user.UpdateStatus(types.UserStatusDeleted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userRoles, err := module.getter.GetRolesByUserID(ctx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -406,6 +411,8 @@ func (module *setter) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
|
||||
return err
|
||||
}
|
||||
|
||||
traitsOrProperties := types.NewTraitsFromUser(user)
|
||||
module.analytics.IdentifyUser(ctx, user.OrgID.String(), user.ID.String(), traitsOrProperties)
|
||||
module.analytics.TrackUser(ctx, user.OrgID.String(), user.ID.String(), "User Deleted", map[string]any{
|
||||
"deleted_by": deletedBy,
|
||||
})
|
||||
@@ -568,8 +575,13 @@ func (module *setter) UpdatePasswordByResetPasswordToken(ctx context.Context, to
|
||||
|
||||
roleNames := roleNamesFromUserRoles(userRoles)
|
||||
|
||||
isPendingInviteUser := user.Status == types.UserStatusPendingInvite
|
||||
// since grant is idempotent, multiple calls won't cause issues in case of retries
|
||||
if user.Status == types.UserStatusPendingInvite {
|
||||
if isPendingInviteUser {
|
||||
if err := user.UpdateStatus(types.UserStatusActive); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = module.authz.Grant(
|
||||
ctx,
|
||||
user.OrgID,
|
||||
@@ -580,15 +592,14 @@ func (module *setter) UpdatePasswordByResetPasswordToken(ctx context.Context, to
|
||||
}
|
||||
|
||||
traitsOrProperties := types.NewTraitsFromUser(user)
|
||||
module.analytics.IdentifyUser(ctx, user.OrgID.String(), user.ID.String(), traitsOrProperties)
|
||||
module.analytics.TrackUser(ctx, user.OrgID.String(), user.ID.String(), "User Activated", traitsOrProperties)
|
||||
}
|
||||
|
||||
return module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if user.Status == types.UserStatusPendingInvite {
|
||||
if err := user.UpdateStatus(types.UserStatusActive); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := module.store.UpdateUser(ctx, user.OrgID, user); err != nil {
|
||||
if isPendingInviteUser {
|
||||
err := module.store.UpdateUser(ctx, user.OrgID, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -817,6 +828,7 @@ func (module *setter) activatePendingUser(ctx context.Context, user *types.User,
|
||||
}
|
||||
|
||||
traitsOrProperties := types.NewTraitsFromUser(user)
|
||||
module.analytics.IdentifyUser(ctx, user.OrgID.String(), user.ID.String(), traitsOrProperties)
|
||||
module.analytics.TrackUser(ctx, user.OrgID.String(), user.ID.String(), "User Activated", traitsOrProperties)
|
||||
|
||||
return nil
|
||||
@@ -866,16 +878,17 @@ func (module *setter) AddUserRole(ctx context.Context, orgID, userID valuer.UUID
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, userRole := range existingUserRoles {
|
||||
if userRole.Role != nil && userRole.Role.Name == roleName {
|
||||
return nil // role already assigned no-op
|
||||
}
|
||||
|
||||
existingRoles := make([]string, len(existingUserRoles))
|
||||
for idx, role := range existingUserRoles {
|
||||
existingRoles[idx] = role.Role.Name
|
||||
}
|
||||
|
||||
// grant via authz (idempotent)
|
||||
if err := module.authz.Grant(
|
||||
if err := module.authz.ModifyGrant(
|
||||
ctx,
|
||||
orgID,
|
||||
existingRoles,
|
||||
[]string{roleName},
|
||||
authtypes.MustNewSubject(authtypes.TypeableUser, existingUser.ID.StringValue(), existingUser.OrgID, nil),
|
||||
); err != nil {
|
||||
@@ -884,7 +897,20 @@ func (module *setter) AddUserRole(ctx context.Context, orgID, userID valuer.UUID
|
||||
|
||||
// create user_role entry
|
||||
userRoles := authtypes.NewUserRoles(userID, foundRoles)
|
||||
if err := module.userRoleStore.CreateUserRoles(ctx, userRoles); err != nil {
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
err = module.userRoleStore.DeleteUserRoles(ctx, existingUser.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := module.userRoleStore.CreateUserRoles(ctx, userRoles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -139,9 +139,6 @@ func WithRuleStateHistoryModule(module rulestatehistory.Module) RuleOption {
|
||||
}
|
||||
|
||||
func NewBaseRule(id string, orgID valuer.UUID, p *ruletypes.PostableRule, opts ...RuleOption) (*BaseRule, error) {
|
||||
if p.RuleCondition == nil || !p.RuleCondition.IsValid() {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid rule condition")
|
||||
}
|
||||
threshold, err := p.RuleCondition.Thresholds.GetRuleThreshold()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -320,6 +320,38 @@ func (m *Manager) Stop(_ context.Context) {
|
||||
m.logger.Info("rule manager stopped")
|
||||
}
|
||||
|
||||
// validateChannels checks that every channel referenced by the rule
|
||||
// exists as a notification channel for the given org.
|
||||
func (m *Manager) validateChannels(ctx context.Context, orgID string, rule *ruletypes.PostableRule) error {
|
||||
channels := rule.Channels()
|
||||
if len(channels) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
orgChannels, err := m.alertmanager.ListChannels(ctx, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
known := make(map[string]struct{}, len(orgChannels))
|
||||
for _, ch := range orgChannels {
|
||||
known[ch.Name] = struct{}{}
|
||||
}
|
||||
|
||||
var unknown []string
|
||||
for _, name := range channels {
|
||||
if _, ok := known[name]; !ok {
|
||||
unknown = append(unknown, name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(unknown) > 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"channels: the following channels do not exist: %v", unknown)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EditRule writes the rule definition to the
|
||||
// datastore and also updates the rule executor
|
||||
func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID) error {
|
||||
@@ -336,7 +368,12 @@ func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := parsedRule.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.validateChannels(ctx, claims.OrgID, &parsedRule); err != nil {
|
||||
return err
|
||||
}
|
||||
existingRule, err := m.ruleStore.GetStoredRule(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -533,7 +570,12 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := parsedRule.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.validateChannels(ctx, claims.OrgID, &parsedRule); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
storedRule := &ruletypes.Rule{
|
||||
Identifiable: types.Identifiable{
|
||||
@@ -920,7 +962,12 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
||||
m.logger.ErrorContext(ctx, "failed to unmarshal patched rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := storedRule.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.validateChannels(ctx, claims.OrgID, &storedRule); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// deploy or un-deploy task according to patched (new) rule state
|
||||
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to sync stored rule state with the task", slog.String("task.name", taskName), errors.Attr(err))
|
||||
@@ -971,6 +1018,12 @@ func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleS
|
||||
if err != nil {
|
||||
return 0, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to unmarshal rule")
|
||||
}
|
||||
if err := parsedRule.Validate(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := m.validateChannels(ctx, orgID.StringValue(), &parsedRule); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !parsedRule.NotificationSettings.UsePolicy {
|
||||
parsedRule.NotificationSettings.GroupBy = append(parsedRule.NotificationSettings.GroupBy, ruletypes.LabelThresholdName)
|
||||
}
|
||||
|
||||
@@ -437,6 +437,7 @@ func New(
|
||||
tokenizer,
|
||||
config,
|
||||
modules.AuthDomain,
|
||||
modules.ServiceAccount,
|
||||
}
|
||||
|
||||
// Initialize stats reporter from the available stats reporter provider factories
|
||||
|
||||
@@ -136,4 +136,5 @@ type RouteStore interface {
|
||||
GetAllByKind(ctx context.Context, orgID string, kind ExpressionKind) ([]*RoutePolicy, error)
|
||||
GetAllByName(ctx context.Context, orgID string, name string) ([]*RoutePolicy, error)
|
||||
DeleteRouteByName(ctx context.Context, orgID string, name string) error
|
||||
GetAll(ctx context.Context, orgID string) ([]*RoutePolicy, error)
|
||||
}
|
||||
|
||||
414
pkg/types/dashboardtypes/dashboard_v2.go
Normal file
414
pkg/types/dashboardtypes/dashboard_v2.go
Normal file
@@ -0,0 +1,414 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/go-playground/validator/v10"
|
||||
v1 "github.com/perses/perses/pkg/model/api/v1"
|
||||
"github.com/perses/perses/pkg/model/api/v1/common"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// StorableDashboardDataV2 wraps v1.Dashboard with additional SigNoz-specific fields.
|
||||
//
|
||||
// The following v1 request fields map to locations inside v1.Dashboard:
|
||||
// - title → Spec.Display.Name (common.Display)
|
||||
// - description → Spec.Display.Description (common.Display)
|
||||
// - tags → Metadata.Tags (v1.Metadata)
|
||||
// - version → Metadata.Version (v1.Metadata)
|
||||
//
|
||||
// Metadata.Name is Perses's unique resource identifier within a project.
|
||||
// We ignore it because SigNoz uses a UUID (StorableDashboardV2.ID) for
|
||||
// dashboard identity instead.
|
||||
//
|
||||
// Metadata.Project is Perses's namespace/grouping mechanism — dashboards,
|
||||
// datasources, and variables all belong to a project for multi-tenancy.
|
||||
// In SigNoz, this role is served by StorableDashboardV2.OrgID.
|
||||
//
|
||||
// CreatedAt/UpdatedAt live in Metadata (Perses's ProjectMetadata),
|
||||
// so TimeAuditable is not used on StorableDashboardV2 or DashboardV2.
|
||||
//
|
||||
// Fields that have no Perses equivalent live on this wrapper:
|
||||
// - image → Image
|
||||
// - locked → Locked
|
||||
// - uploadedGrafana → UploadedGrafana
|
||||
type StorableDashboardDataV2 struct {
|
||||
v1.Dashboard
|
||||
Image string `json:"image,omitempty"`
|
||||
Locked bool `json:"locked"`
|
||||
UploadedGrafana bool `json:"uploadedGrafana,omitempty"`
|
||||
}
|
||||
|
||||
type StorableDashboardV2 struct {
|
||||
bun.BaseModel `bun:"table:dashboard_v2,alias:dashboard_v2"`
|
||||
|
||||
types.Identifiable
|
||||
types.UserAuditable
|
||||
Data StorableDashboardDataV2 `bun:"data,type:text,notnull"`
|
||||
OrgID valuer.UUID `bun:"org_id,notnull"`
|
||||
}
|
||||
|
||||
type DashboardV2 struct {
|
||||
types.UserAuditable
|
||||
|
||||
ID string `json:"id"`
|
||||
Data StorableDashboardDataV2 `json:"data"`
|
||||
OrgID valuer.UUID `json:"org_id"`
|
||||
}
|
||||
|
||||
type (
|
||||
GettableDashboardV2 = DashboardV2
|
||||
|
||||
UpdatableDashboardV2 = StorableDashboardDataV2
|
||||
|
||||
PostableDashboardV2 = StorableDashboardDataV2
|
||||
|
||||
ListableDashboardV2 []*GettableDashboardV2
|
||||
)
|
||||
|
||||
func NewStorableDashboardV2FromDashboardV2(dashboard *DashboardV2) (*StorableDashboardV2, error) {
|
||||
dashboardID, err := valuer.NewUUID(dashboard.ID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid")
|
||||
}
|
||||
|
||||
return &StorableDashboardV2{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: dashboardID,
|
||||
},
|
||||
UserAuditable: types.UserAuditable{
|
||||
CreatedBy: dashboard.CreatedBy,
|
||||
UpdatedBy: dashboard.UpdatedBy,
|
||||
},
|
||||
OrgID: dashboard.OrgID,
|
||||
Data: dashboard.Data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewDashboardV2(orgID valuer.UUID, createdBy string, data StorableDashboardDataV2) *DashboardV2 {
|
||||
currentTime := time.Now()
|
||||
data.Metadata.CreatedAt = currentTime
|
||||
data.Metadata.UpdatedAt = currentTime
|
||||
|
||||
return &DashboardV2{
|
||||
ID: valuer.GenerateUUID().StringValue(),
|
||||
UserAuditable: types.UserAuditable{
|
||||
CreatedBy: createdBy,
|
||||
UpdatedBy: createdBy,
|
||||
},
|
||||
OrgID: orgID,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func NewDashboardV2FromStorableDashboard(storableDashboard *StorableDashboardV2) *DashboardV2 {
|
||||
return &DashboardV2{
|
||||
ID: storableDashboard.ID.StringValue(),
|
||||
UserAuditable: types.UserAuditable{
|
||||
CreatedBy: storableDashboard.CreatedBy,
|
||||
UpdatedBy: storableDashboard.UpdatedBy,
|
||||
},
|
||||
OrgID: storableDashboard.OrgID,
|
||||
Data: storableDashboard.Data,
|
||||
}
|
||||
}
|
||||
|
||||
func NewDashboardsV2FromStorableDashboards(storableDashboards []*StorableDashboardV2) []*DashboardV2 {
|
||||
dashboards := make([]*DashboardV2, len(storableDashboards))
|
||||
for idx, storableDashboard := range storableDashboards {
|
||||
dashboards[idx] = NewDashboardV2FromStorableDashboard(storableDashboard)
|
||||
}
|
||||
return dashboards
|
||||
}
|
||||
|
||||
func NewGettableDashboardsV2FromDashboards(dashboards []*DashboardV2) ([]*GettableDashboardV2, error) {
|
||||
gettableDashboards := make([]*GettableDashboardV2, len(dashboards))
|
||||
for idx, d := range dashboards {
|
||||
gettableDashboard, err := NewGettableDashboardV2FromDashboard(d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gettableDashboards[idx] = gettableDashboard
|
||||
}
|
||||
return gettableDashboards, nil
|
||||
}
|
||||
|
||||
func NewGettableDashboardV2FromDashboard(dashboard *DashboardV2) (*GettableDashboardV2, error) {
|
||||
return &GettableDashboardV2{
|
||||
ID: dashboard.ID,
|
||||
UserAuditable: dashboard.UserAuditable,
|
||||
OrgID: dashboard.OrgID,
|
||||
Data: dashboard.Data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (dashboard *DashboardV2) Update(ctx context.Context, updatableDashboard UpdatableDashboardV2, updatedBy string, diff int) error {
|
||||
if dashboard.Data.Locked {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot update a locked dashboard, please unlock the dashboard to update")
|
||||
}
|
||||
|
||||
if diff > 0 {
|
||||
deleted := 0
|
||||
for key := range dashboard.Data.Spec.Panels {
|
||||
if _, exists := updatableDashboard.Spec.Panels[key]; !exists {
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
if deleted > diff {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "deleting more than %d panel(s) is not supported", diff)
|
||||
}
|
||||
}
|
||||
|
||||
dashboard.UpdatedBy = updatedBy
|
||||
updatableDashboard.Metadata.UpdatedAt = time.Now()
|
||||
dashboard.Data = updatableDashboard
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dashboard *DashboardV2) LockUnlock(lock bool, role types.Role, updatedBy string) error {
|
||||
if dashboard.CreatedBy != updatedBy && role != types.RoleAdmin {
|
||||
return errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "you are not authorized to lock/unlock this dashboard")
|
||||
}
|
||||
dashboard.Data.Locked = lock
|
||||
dashboard.UpdatedBy = updatedBy
|
||||
dashboard.Data.Metadata.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalAndValidateDashboardV2JSON unmarshals the JSON into a StorableDashboardDataV2
|
||||
// (= PostableDashboardV2 = UpdatableDashboardV2) and validates plugin kinds and specs.
|
||||
func UnmarshalAndValidateDashboardV2JSON(data []byte) (*StorableDashboardDataV2, error) {
|
||||
var d StorableDashboardDataV2
|
||||
if err := json.Unmarshal(data, &d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateDashboardV2(d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// Plugin kind → spec type factory. Each value is a pointer to the zero value of the
|
||||
// expected spec struct. validatePluginSpec marshals plugin.Spec back to JSON and
|
||||
// unmarshals into the typed struct to catch field-level errors.
|
||||
var (
|
||||
panelPluginSpecs = map[string]func() any{
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
}
|
||||
queryPluginSpecs = map[string]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
QueryKindComposite: func() any { return new(CompositeQuerySpec) },
|
||||
QueryKindFormula: func() any { return new(FormulaSpec) },
|
||||
QueryKindPromQL: func() any { return new(PromQLQuerySpec) },
|
||||
QueryKindClickHouseSQL: func() any { return new(ClickHouseSQLQuerySpec) },
|
||||
QueryKindTraceOperator: func() any { return new(TraceOperatorSpec) },
|
||||
}
|
||||
variablePluginSpecs = map[string]func() any{
|
||||
VariableKindDynamic: func() any { return new(DynamicVariableSpec) },
|
||||
VariableKindQuery: func() any { return new(QueryVariableSpec) },
|
||||
VariableKindCustom: func() any { return new(CustomVariableSpec) },
|
||||
VariableKindTextbox: func() any { return new(TextboxVariableSpec) },
|
||||
}
|
||||
datasourcePluginSpecs = map[string]func() any{
|
||||
DatasourceKindSigNoz: func() any { return new(struct{}) },
|
||||
}
|
||||
|
||||
// allowedQueryKinds maps each panel plugin kind to the query plugin
|
||||
// kinds it supports. Composite sub-query types are mapped to these
|
||||
// same kind strings via compositeSubQueryTypeToPluginKind.
|
||||
allowedQueryKinds = map[string][]string{
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder},
|
||||
}
|
||||
|
||||
// compositeSubQueryTypeToPluginKind maps CompositeQuery sub-query type
|
||||
// strings to the equivalent top-level query plugin kind for validation.
|
||||
compositeSubQueryTypeToPluginKind = map[string]string{
|
||||
qb.QueryTypeBuilder.StringValue(): QueryKindBuilder,
|
||||
qb.QueryTypeFormula.StringValue(): QueryKindFormula,
|
||||
qb.QueryTypeTraceOperator.StringValue(): QueryKindTraceOperator,
|
||||
qb.QueryTypePromQL.StringValue(): QueryKindPromQL,
|
||||
qb.QueryTypeClickHouseSQL.StringValue(): QueryKindClickHouseSQL,
|
||||
}
|
||||
)
|
||||
|
||||
func validateDashboardV2(d StorableDashboardDataV2) error {
|
||||
// Validate datasource plugins.
|
||||
for name, ds := range d.Spec.Datasources {
|
||||
if err := validatePlugin(ds.Plugin, datasourcePluginSpecs, fmt.Sprintf("spec.datasources.%s.plugin", name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Validate variable plugins (only ListVariables have plugins; TextVariables do not).
|
||||
for i, v := range d.Spec.Variables {
|
||||
plugin, err := extractPluginFromVariable(v)
|
||||
if err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "spec.variables[%d]", i)
|
||||
}
|
||||
if plugin == nil {
|
||||
continue
|
||||
}
|
||||
if err := validatePlugin(*plugin, variablePluginSpecs, fmt.Sprintf("spec.variables[%d].spec.plugin", i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Validate panel and query plugins.
|
||||
for key, panel := range d.Spec.Panels {
|
||||
if panel == nil {
|
||||
continue
|
||||
}
|
||||
path := fmt.Sprintf("spec.panels.%s", key)
|
||||
if err := validatePlugin(panel.Spec.Plugin, panelPluginSpecs, path+".spec.plugin"); err != nil {
|
||||
return err
|
||||
}
|
||||
allowed := allowedQueryKinds[panel.Spec.Plugin.Kind]
|
||||
for qi, query := range panel.Spec.Queries {
|
||||
queryPath := fmt.Sprintf("%s.spec.queries[%d].spec.plugin", path, qi)
|
||||
if err := validatePlugin(query.Spec.Plugin, queryPluginSpecs, queryPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateQueryAllowedForPanel(query.Spec.Plugin, allowed, panel.Spec.Plugin.Kind, queryPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePlugin(plugin common.Plugin, specs map[string]func() any, path string) error {
|
||||
if plugin.Kind == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: plugin kind is required", path)
|
||||
}
|
||||
factory, ok := specs[plugin.Kind]
|
||||
if !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: unknown plugin kind %q", path, plugin.Kind)
|
||||
}
|
||||
if plugin.Spec == nil {
|
||||
return nil
|
||||
}
|
||||
// Re-marshal the spec and unmarshal into the typed struct.
|
||||
specJSON, err := json.Marshal(plugin.Spec)
|
||||
if err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
|
||||
}
|
||||
target := factory()
|
||||
if err := json.Unmarshal(specJSON, target); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
|
||||
}
|
||||
if err := validator.New().Struct(target); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateQueryAllowedForPanel checks that the query plugin kind is permitted
|
||||
// for the given panel. For composite queries it recurses into sub-queries.
|
||||
func validateQueryAllowedForPanel(plugin common.Plugin, allowed []string, panelKind string, path string) error {
|
||||
if !slices.Contains(allowed, plugin.Kind) {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
|
||||
"%s: query kind %q is not supported by panel kind %q", path, plugin.Kind, panelKind)
|
||||
}
|
||||
|
||||
// For composite queries, validate each sub-query type.
|
||||
if plugin.Kind == QueryKindComposite && plugin.Spec != nil {
|
||||
specJSON, err := json.Marshal(plugin.Spec)
|
||||
if err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
|
||||
}
|
||||
var composite struct {
|
||||
Queries []struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"queries"`
|
||||
}
|
||||
if err := json.Unmarshal(specJSON, &composite); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
|
||||
}
|
||||
for si, sub := range composite.Queries {
|
||||
pluginKind, ok := compositeSubQueryTypeToPluginKind[sub.Type]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(allowed, pluginKind) {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
|
||||
"%s.spec.queries[%d]: sub-query type %q is not supported by panel kind %q",
|
||||
path, si, sub.Type, panelKind)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractPluginFromVariable extracts the plugin from a variable.
|
||||
// Returns nil if the variable has no plugin (e.g. TextVariable).
|
||||
func extractPluginFromVariable(v any) (*common.Plugin, error) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
Spec struct {
|
||||
Plugin *common.Plugin `json:"plugin,omitempty"`
|
||||
} `json:"spec"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return raw.Spec.Plugin, nil
|
||||
}
|
||||
|
||||
func NewStatsFromStorableDashboardsV2(dashboards []*StorableDashboardV2) map[string]any {
|
||||
stats := make(map[string]any)
|
||||
stats["dashboard.panels.count"] = int64(0)
|
||||
stats["dashboard.panels.traces.count"] = int64(0)
|
||||
stats["dashboard.panels.metrics.count"] = int64(0)
|
||||
stats["dashboard.panels.logs.count"] = int64(0)
|
||||
for _, dashboard := range dashboards {
|
||||
for _, panel := range dashboard.Data.Spec.Panels {
|
||||
if panel == nil {
|
||||
continue
|
||||
}
|
||||
stats["dashboard.panels.count"] = stats["dashboard.panels.count"].(int64) + 1
|
||||
for _, query := range panel.Spec.Queries {
|
||||
if query.Spec.Plugin.Kind != QueryKindBuilder {
|
||||
continue
|
||||
}
|
||||
spec, ok := query.Spec.Plugin.Spec.(*BuilderQuerySpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch spec.Spec.(type) {
|
||||
case MetricBuilderQuerySpec:
|
||||
stats["dashboard.panels.metrics.count"] = stats["dashboard.panels.metrics.count"].(int64) + 1
|
||||
case LogBuilderQuerySpec:
|
||||
stats["dashboard.panels.logs.count"] = stats["dashboard.panels.logs.count"].(int64) + 1
|
||||
case TraceBuilderQuerySpec:
|
||||
stats["dashboard.panels.traces.count"] = stats["dashboard.panels.traces.count"].(int64) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stats["dashboard.count"] = int64(len(dashboards))
|
||||
return stats
|
||||
}
|
||||
661
pkg/types/dashboardtypes/dashboard_v2_test.go
Normal file
661
pkg/types/dashboardtypes/dashboard_v2_test.go
Normal file
@@ -0,0 +1,661 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateBigExample(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/perses.json")
|
||||
if err != nil {
|
||||
t.Fatalf("reading example file: %v", err)
|
||||
}
|
||||
if _, err := UnmarshalAndValidateDashboardV2JSON(data); err != nil {
|
||||
t.Fatalf("expected valid dashboard, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDashboardWithSections(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/perses_with_sections.json")
|
||||
if err != nil {
|
||||
t.Fatalf("reading example file: %v", err)
|
||||
}
|
||||
if _, err := UnmarshalAndValidateDashboardV2JSON(data); err != nil {
|
||||
t.Fatalf("expected valid dashboard, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateNotAJSON(t *testing.T) {
|
||||
if _, err := UnmarshalAndValidateDashboardV2JSON([]byte("not json")); err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateEmptyObject(t *testing.T) {
|
||||
if _, err := UnmarshalAndValidateDashboardV2JSON([]byte("{}")); err == nil {
|
||||
t.Fatal("expected error for empty object missing kind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEmptySpec(t *testing.T) {
|
||||
// no variables no panels
|
||||
data := []byte(`{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test"},
|
||||
"spec": {}
|
||||
}`)
|
||||
if _, err := UnmarshalAndValidateDashboardV2JSON(data); err != nil {
|
||||
t.Fatalf("expected valid, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOnlyVariables(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test", "project": "signoz"},
|
||||
"spec": {
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "service",
|
||||
"allowAllValue": true,
|
||||
"allowMultiple": false,
|
||||
"plugin": {
|
||||
"kind": "SigNozDynamicVariable",
|
||||
"spec": {"name": "service.name", "source": "Metrics"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "TextVariable",
|
||||
"spec": {
|
||||
"name": "mytext",
|
||||
"value": "default",
|
||||
"plugin": {
|
||||
"kind": "SigNozTextboxVariable",
|
||||
"spec": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"layouts": []
|
||||
}
|
||||
}`)
|
||||
if _, err := UnmarshalAndValidateDashboardV2JSON(data); err != nil {
|
||||
t.Fatalf("expected valid, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateWrongKindAtTop(t *testing.T) {
|
||||
data := []byte(`{"kind": 123}`)
|
||||
if _, err := UnmarshalAndValidateDashboardV2JSON(data); err == nil {
|
||||
t.Fatal("expected error for wrong type on kind field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
wantContain string
|
||||
}{
|
||||
{
|
||||
name: "unknown panel plugin",
|
||||
data: `{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test", "project": "signoz"},
|
||||
"spec": {
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "NonExistentPanel", "spec": {}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}
|
||||
}`,
|
||||
wantContain: "NonExistentPanel",
|
||||
},
|
||||
{
|
||||
name: "unknown query plugin",
|
||||
data: `{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test", "project": "signoz"},
|
||||
"spec": {
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "SigNozTimeSeriesPanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {"kind": "FakeQueryPlugin", "spec": {}}
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}
|
||||
}`,
|
||||
wantContain: "FakeQueryPlugin",
|
||||
},
|
||||
{
|
||||
name: "unknown variable plugin",
|
||||
data: `{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test", "project": "signoz"},
|
||||
"spec": {
|
||||
"variables": [{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "v1",
|
||||
"allowAllValue": false,
|
||||
"allowMultiple": false,
|
||||
"plugin": {"kind": "FakeVariable", "spec": {}}
|
||||
}
|
||||
}],
|
||||
"layouts": []
|
||||
}
|
||||
}`,
|
||||
wantContain: "FakeVariable",
|
||||
},
|
||||
{
|
||||
name: "unknown datasource plugin",
|
||||
data: `{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test", "project": "signoz"},
|
||||
"spec": {
|
||||
"datasources": {
|
||||
"ds1": {
|
||||
"default": true,
|
||||
"plugin": {"kind": "FakeDatasource", "spec": {}}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}
|
||||
}`,
|
||||
wantContain: "FakeDatasource",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantContain)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantContain) {
|
||||
t.Fatalf("error should mention %q, got: %v", tt.wantContain, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateOneInvalidPanel(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test", "project": "signoz"},
|
||||
"spec": {
|
||||
"panels": {
|
||||
"good": {
|
||||
"kind": "Panel",
|
||||
"spec": {"plugin": {"kind": "SigNozNumberPanel", "spec": {}}}
|
||||
},
|
||||
"bad": {
|
||||
"kind": "Panel",
|
||||
"spec": {"plugin": {"kind": "FakePanel", "spec": {}}}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}
|
||||
}`)
|
||||
_, err := UnmarshalAndValidateDashboardV2JSON(data)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid panel plugin kind")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "FakePanel") {
|
||||
t.Fatalf("error should mention FakePanel, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
wantContain string
|
||||
}{
|
||||
{
|
||||
name: "wrong type on panel plugin field",
|
||||
data: `{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test", "project": "signoz"},
|
||||
"spec": {
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozTimeSeriesPanel",
|
||||
"spec": {"visualization": {"fillSpans": "notabool"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}
|
||||
}`,
|
||||
wantContain: "fillSpans",
|
||||
},
|
||||
{
|
||||
name: "wrong type on query plugin field",
|
||||
data: `{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test", "project": "signoz"},
|
||||
"spec": {
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "SigNozTimeSeriesPanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozPromQLQuery",
|
||||
"spec": {"name": "A", "query": 123}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}
|
||||
}`,
|
||||
wantContain: "",
|
||||
},
|
||||
{
|
||||
name: "wrong type on variable plugin field",
|
||||
data: `{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test", "project": "signoz"},
|
||||
"spec": {
|
||||
"variables": [{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "v",
|
||||
"allowAllValue": false,
|
||||
"allowMultiple": false,
|
||||
"plugin": {
|
||||
"kind": "SigNozDynamicVariable",
|
||||
"spec": {"name": 123, "source": "Metrics"}
|
||||
}
|
||||
}
|
||||
}],
|
||||
"layouts": []
|
||||
}
|
||||
}`,
|
||||
wantContain: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if tt.wantContain != "" && !strings.Contains(err.Error(), tt.wantContain) {
|
||||
t.Fatalf("error should mention %q, got: %v", tt.wantContain, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
wantContain string
|
||||
}{
|
||||
{
|
||||
name: "bad time preference",
|
||||
data: `{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test", "project": "signoz"},
|
||||
"spec": {
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozTimeSeriesPanel",
|
||||
"spec": {"visualization": {"timePreference": "last2Hr"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}
|
||||
}`,
|
||||
wantContain: "timePreference",
|
||||
},
|
||||
{
|
||||
name: "bad legend position",
|
||||
data: `{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test", "project": "signoz"},
|
||||
"spec": {
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozBarChartPanel",
|
||||
"spec": {"legend": {"position": "top"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}
|
||||
}`,
|
||||
wantContain: "legend position",
|
||||
},
|
||||
{
|
||||
name: "bad threshold format",
|
||||
data: `{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test", "project": "signoz"},
|
||||
"spec": {
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozNumberPanel",
|
||||
"spec": {"thresholds": [{"value": 100, "operator": ">", "color": "Red", "format": "Color"}]}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}
|
||||
}`,
|
||||
wantContain: "threshold format",
|
||||
},
|
||||
{
|
||||
name: "bad comparison operator",
|
||||
data: `{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test", "project": "signoz"},
|
||||
"spec": {
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozNumberPanel",
|
||||
"spec": {"thresholds": [{"value": 100, "operator": "!=", "color": "Red", "format": "Text"}]}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}
|
||||
}`,
|
||||
wantContain: "comparison operator",
|
||||
},
|
||||
{
|
||||
name: "bad precision",
|
||||
data: `{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test", "project": "signoz"},
|
||||
"spec": {
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozTimeSeriesPanel",
|
||||
"spec": {"formatting": {"decimalPrecision": 9}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}
|
||||
}`,
|
||||
wantContain: "precision",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantContain)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantContain) {
|
||||
t.Fatalf("error should mention %q, got: %v", tt.wantContain, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequiredFields(t *testing.T) {
|
||||
wrapVariable := func(pluginKind, pluginSpec string) string {
|
||||
return `{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test", "project": "signoz"},
|
||||
"spec": {
|
||||
"variables": [{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "v",
|
||||
"allowAllValue": false,
|
||||
"allowMultiple": false,
|
||||
"plugin": {"kind": "` + pluginKind + `", "spec": ` + pluginSpec + `}
|
||||
}
|
||||
}],
|
||||
"layouts": []
|
||||
}
|
||||
}`
|
||||
}
|
||||
wrapPanel := func(panelKind, panelSpec string) string {
|
||||
return `{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test", "project": "signoz"},
|
||||
"spec": {
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "` + panelKind + `", "spec": ` + panelSpec + `}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}
|
||||
}`
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
wantContain string
|
||||
}{
|
||||
{
|
||||
name: "DynamicVariable missing name",
|
||||
data: wrapVariable("SigNozDynamicVariable", `{"source": "Metrics"}`),
|
||||
wantContain: "Name",
|
||||
},
|
||||
{
|
||||
name: "DynamicVariable missing source",
|
||||
data: wrapVariable("SigNozDynamicVariable", `{"name": "http.method"}`),
|
||||
wantContain: "Source",
|
||||
},
|
||||
{
|
||||
name: "QueryVariable missing queryValue",
|
||||
data: wrapVariable("SigNozQueryVariable", `{}`),
|
||||
wantContain: "QueryValue",
|
||||
},
|
||||
{
|
||||
name: "CustomVariable missing customValue",
|
||||
data: wrapVariable("SigNozCustomVariable", `{}`),
|
||||
wantContain: "CustomValue",
|
||||
},
|
||||
{
|
||||
name: "ThresholdWithLabel missing color",
|
||||
data: wrapPanel("SigNozTimeSeriesPanel", `{"thresholds": [{"value": 100, "label": "high", "color": ""}]}`),
|
||||
wantContain: "Color",
|
||||
},
|
||||
{
|
||||
name: "ThresholdWithLabel missing label",
|
||||
data: wrapPanel("SigNozTimeSeriesPanel", `{"thresholds": [{"value": 100, "color": "Red", "label": ""}]}`),
|
||||
wantContain: "Label",
|
||||
},
|
||||
{
|
||||
name: "ComparisonThreshold missing color",
|
||||
data: wrapPanel("SigNozNumberPanel", `{"thresholds": [{"value": 100, "operator": ">", "format": "Text", "color": ""}]}`),
|
||||
wantContain: "Color",
|
||||
},
|
||||
{
|
||||
name: "TableThreshold missing columnName",
|
||||
data: wrapPanel("SigNozTablePanel", `{"thresholds": [{"value": 100, "operator": ">", "format": "Text", "color": "Red", "columnName": ""}]}`),
|
||||
wantContain: "ColumnName",
|
||||
},
|
||||
{
|
||||
name: "LogField missing name",
|
||||
data: wrapPanel("SigNozListPanel", `{"selectedLogFields": [{"name": "", "type": "log", "dataType": "string"}]}`),
|
||||
wantContain: "Name",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantContain)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantContain) {
|
||||
t.Fatalf("error should mention %q, got: %v", tt.wantContain, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeSeriesPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test", "project": "signoz"},
|
||||
"spec": {
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozTimeSeriesPanel",
|
||||
"spec": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}
|
||||
}`)
|
||||
var d StorableDashboardDataV2
|
||||
if err := json.Unmarshal(data, &d); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
specJSON, _ := json.Marshal(d.Spec.Panels["p1"].Spec.Plugin.Spec)
|
||||
var spec TimeSeriesPanelSpec
|
||||
if err := json.Unmarshal(specJSON, &spec); err != nil {
|
||||
t.Fatalf("unmarshal spec failed: %v", err)
|
||||
}
|
||||
|
||||
if spec.Formatting.DecimalPrecision.Value() != 2 {
|
||||
t.Fatalf("expected DecimalPrecision default 2, got %v", spec.Formatting.DecimalPrecision.Value())
|
||||
}
|
||||
if spec.ChartAppearance.LineInterpolation.Value() != "spline" {
|
||||
t.Fatalf("expected LineInterpolation default spline, got %v", spec.ChartAppearance.LineInterpolation.Value())
|
||||
}
|
||||
if spec.ChartAppearance.LineStyle.Value() != "solid" {
|
||||
t.Fatalf("expected LineStyle default solid, got %v", spec.ChartAppearance.LineStyle.Value())
|
||||
}
|
||||
if spec.ChartAppearance.FillMode.Value() != "solid" {
|
||||
t.Fatalf("expected FillMode default solid, got %v", spec.ChartAppearance.FillMode.Value())
|
||||
}
|
||||
if spec.ChartAppearance.SpanGaps.Value() != true {
|
||||
t.Fatalf("expected SpanGaps default true, got %v", spec.ChartAppearance.SpanGaps.Value())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
mkQuery := func(panelKind, queryKind, querySpec string) []byte {
|
||||
return []byte(`{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test", "project": "signoz"},
|
||||
"spec": {
|
||||
"panels": {"p1": {"kind": "Panel", "spec": {
|
||||
"plugin": {"kind": "` + panelKind + `", "spec": {}},
|
||||
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "` + queryKind + `", "spec": ` + querySpec + `}}}]
|
||||
}}},
|
||||
"layouts": []
|
||||
}
|
||||
}`)
|
||||
}
|
||||
mkComposite := func(panelKind, subType, subSpec string) []byte {
|
||||
return []byte(`{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {"name": "test", "project": "signoz"},
|
||||
"spec": {
|
||||
"panels": {"p1": {"kind": "Panel", "spec": {
|
||||
"plugin": {"kind": "` + panelKind + `", "spec": {}},
|
||||
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "SigNozCompositeQuery", "spec": {
|
||||
"queries": [{"type": "` + subType + `", "spec": ` + subSpec + `}]
|
||||
}}}}]
|
||||
}}},
|
||||
"layouts": []
|
||||
}
|
||||
}`)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
wantErr bool
|
||||
}{
|
||||
// Top-level: allowed
|
||||
{"TimeSeries+PromQL", mkQuery("SigNozTimeSeriesPanel", "SigNozPromQLQuery", `{"name":"A","query":"up"}`), false},
|
||||
{"Table+ClickHouse", mkQuery("SigNozTablePanel", "SigNozClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
{"List+Builder", mkQuery("SigNozListPanel", "SigNozBuilderQuery", `{"name":"A","signal":"logs"}`), false},
|
||||
// Top-level: rejected
|
||||
{"Table+PromQL", mkQuery("SigNozTablePanel", "SigNozPromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"List+ClickHouse", mkQuery("SigNozListPanel", "SigNozClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), true},
|
||||
{"List+PromQL", mkQuery("SigNozListPanel", "SigNozPromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"List+Composite", mkQuery("SigNozListPanel", "SigNozCompositeQuery", `{"queries":[]}`), true},
|
||||
{"List+Formula", mkQuery("SigNozListPanel", "SigNozFormula", `{"name":"F1","expression":"A+B"}`), true},
|
||||
// Composite sub-queries
|
||||
{"Table+Composite(promql)", mkComposite("SigNozTablePanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
{"Table+Composite(clickhouse)", mkComposite("SigNozTablePanel", "clickhouse_sql", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
_, err := UnmarshalAndValidateDashboardV2JSON(tc.data)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatalf("%s: expected error, got nil", tc.name)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("%s: expected valid, got: %v", tc.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
541
pkg/types/dashboardtypes/perses_plugin_types.go
Normal file
541
pkg/types/dashboardtypes/perses_plugin_types.go
Normal file
@@ -0,0 +1,541 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// SigNoz variable plugin specs
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type VariablePluginKind = string
|
||||
|
||||
const (
|
||||
VariableKindDynamic VariablePluginKind = "SigNozDynamicVariable"
|
||||
VariableKindQuery VariablePluginKind = "SigNozQueryVariable"
|
||||
VariableKindCustom VariablePluginKind = "SigNozCustomVariable"
|
||||
VariableKindTextbox VariablePluginKind = "SigNozTextboxVariable"
|
||||
)
|
||||
|
||||
type DynamicVariableSpec struct {
|
||||
// Name is the name of the attribute being fetched dynamically from the
|
||||
// source. This could be extended to a richer selector in the future.
|
||||
Name string `json:"name" validate:"required"`
|
||||
Source string `json:"source" validate:"required"`
|
||||
}
|
||||
|
||||
type QueryVariableSpec struct {
|
||||
QueryValue string `json:"queryValue" validate:"required"`
|
||||
}
|
||||
|
||||
type CustomVariableSpec struct {
|
||||
CustomValue string `json:"customValue" validate:"required"`
|
||||
}
|
||||
|
||||
type TextboxVariableSpec struct{}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// SigNoz query plugin specs — aliased from querybuildertypesv5
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type QueryPluginKind = string
|
||||
|
||||
const (
|
||||
QueryKindBuilder QueryPluginKind = "SigNozBuilderQuery"
|
||||
QueryKindComposite QueryPluginKind = "SigNozCompositeQuery"
|
||||
QueryKindFormula QueryPluginKind = "SigNozFormula"
|
||||
QueryKindPromQL QueryPluginKind = "SigNozPromQLQuery"
|
||||
QueryKindClickHouseSQL QueryPluginKind = "SigNozClickHouseSQL"
|
||||
QueryKindTraceOperator QueryPluginKind = "SigNozTraceOperator"
|
||||
)
|
||||
|
||||
type (
|
||||
CompositeQuerySpec = qb.CompositeQuery
|
||||
QueryEnvelope = qb.QueryEnvelope
|
||||
FormulaSpec = qb.QueryBuilderFormula
|
||||
PromQLQuerySpec = qb.PromQuery
|
||||
ClickHouseSQLQuerySpec = qb.ClickHouseQuery
|
||||
TraceOperatorSpec = qb.QueryBuilderTraceOperator
|
||||
)
|
||||
|
||||
// BuilderQuerySpec dispatches to MetricBuilderQuerySpec, LogBuilderQuerySpec,
|
||||
// or TraceBuilderQuerySpec based on the signal field.
|
||||
|
||||
type (
|
||||
MetricBuilderQuerySpec = qb.QueryBuilderQuery[qb.MetricAggregation]
|
||||
LogBuilderQuerySpec = qb.QueryBuilderQuery[qb.LogAggregation]
|
||||
TraceBuilderQuerySpec = qb.QueryBuilderQuery[qb.TraceAggregation]
|
||||
)
|
||||
|
||||
type BuilderQuerySpec struct {
|
||||
Spec any
|
||||
}
|
||||
|
||||
func (b *BuilderQuerySpec) UnmarshalJSON(data []byte) error {
|
||||
var peek struct {
|
||||
Signal string `json:"signal"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &peek); err != nil {
|
||||
return err
|
||||
}
|
||||
switch peek.Signal {
|
||||
case "metrics":
|
||||
var spec MetricBuilderQuerySpec
|
||||
if err := json.Unmarshal(data, &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
b.Spec = spec
|
||||
case "logs":
|
||||
var spec LogBuilderQuerySpec
|
||||
if err := json.Unmarshal(data, &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
b.Spec = spec
|
||||
case "traces":
|
||||
var spec TraceBuilderQuerySpec
|
||||
if err := json.Unmarshal(data, &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
b.Spec = spec
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid signal %q: must be metrics, logs, or traces", peek.Signal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// SigNoz panel plugin specs
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type PanelPluginKind = string
|
||||
|
||||
const (
|
||||
PanelKindTimeSeries PanelPluginKind = "SigNozTimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "SigNozBarChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "SigNozNumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "SigNozPieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "SigNozTablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "SigNozHistogramPanel"
|
||||
PanelKindList PanelPluginKind = "SigNozListPanel"
|
||||
)
|
||||
|
||||
type DatasourcePluginKind = string
|
||||
|
||||
const (
|
||||
DatasourceKindSigNoz DatasourcePluginKind = "SigNozDatasource"
|
||||
)
|
||||
|
||||
type TimeSeriesPanelSpec struct {
|
||||
Visualization TimeSeriesVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
ChartAppearance TimeSeriesChartAppearance `json:"chartAppearance"`
|
||||
Axes Axes `json:"axes"`
|
||||
Legend Legend `json:"legend"`
|
||||
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
type TimeSeriesChartAppearance struct {
|
||||
LineInterpolation LineInterpolation `json:"lineInterpolation"`
|
||||
ShowPoints bool `json:"showPoints"`
|
||||
LineStyle LineStyle `json:"lineStyle"`
|
||||
FillMode FillMode `json:"fillMode"`
|
||||
SpanGaps SpanGaps `json:"spanGaps"`
|
||||
}
|
||||
|
||||
type BarChartPanelSpec struct {
|
||||
Visualization BarChartVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
Axes Axes `json:"axes"`
|
||||
Legend Legend `json:"legend"`
|
||||
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
type NumberPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
Thresholds []ComparisonThreshold `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
type PieChartPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
Legend Legend `json:"legend"`
|
||||
}
|
||||
|
||||
type TablePanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Formatting TableFormatting `json:"formatting"`
|
||||
Thresholds []TableThreshold `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
type HistogramPanelSpec struct {
|
||||
HistogramBuckets HistogramBuckets `json:"histogramBuckets"`
|
||||
Legend Legend `json:"legend"`
|
||||
}
|
||||
|
||||
type HistogramBuckets struct {
|
||||
BucketCount *float64 `json:"bucketCount"`
|
||||
BucketWidth *float64 `json:"bucketWidth"`
|
||||
MergeAllActiveQueries bool `json:"mergeAllActiveQueries"`
|
||||
}
|
||||
|
||||
type ListPanelSpec struct {
|
||||
SelectedLogFields []LogField `json:"selectedLogFields" validate:"dive"`
|
||||
SelectedTracesFields []telemetrytypes.TelemetryFieldKey `json:"selectedTracesFields"`
|
||||
}
|
||||
|
||||
type LogField struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Type string `json:"type"`
|
||||
DataType string `json:"dataType"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Panel common types
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type Axes struct {
|
||||
SoftMin *float64 `json:"softMin"`
|
||||
SoftMax *float64 `json:"softMax"`
|
||||
IsLogScale bool `json:"isLogScale"`
|
||||
}
|
||||
|
||||
type BasicVisualization struct {
|
||||
TimePreference TimePreference `json:"timePreference"`
|
||||
}
|
||||
|
||||
type TimeSeriesVisualization struct {
|
||||
BasicVisualization
|
||||
FillSpans bool `json:"fillSpans"`
|
||||
}
|
||||
|
||||
type BarChartVisualization struct {
|
||||
BasicVisualization
|
||||
FillSpans bool `json:"fillSpans"`
|
||||
StackedBarChart bool `json:"stackedBarChart"`
|
||||
}
|
||||
|
||||
type PanelFormatting struct {
|
||||
Unit string `json:"unit"`
|
||||
DecimalPrecision PrecisionOption `json:"decimalPrecision"`
|
||||
}
|
||||
|
||||
type TableFormatting struct {
|
||||
ColumnUnits map[string]string `json:"columnUnits"`
|
||||
DecimalPrecision PrecisionOption `json:"decimalPrecision"`
|
||||
}
|
||||
|
||||
type Legend struct {
|
||||
Position LegendPosition `json:"position"`
|
||||
CustomColors map[string]string `json:"customColors"`
|
||||
}
|
||||
|
||||
type ThresholdWithLabel struct {
|
||||
Value float64 `json:"value"`
|
||||
Unit string `json:"unit"`
|
||||
Color string `json:"color" validate:"required"`
|
||||
Label string `json:"label" validate:"required"`
|
||||
}
|
||||
|
||||
type ComparisonThreshold struct {
|
||||
Value float64 `json:"value"`
|
||||
Operator ComparisonOperator `json:"operator"`
|
||||
Unit string `json:"unit"`
|
||||
Color string `json:"color" validate:"required"`
|
||||
Format ThresholdFormat `json:"format"`
|
||||
}
|
||||
|
||||
type TableThreshold struct {
|
||||
ComparisonThreshold
|
||||
ColumnName string `json:"columnName" validate:"required"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Constrained scalar types (enum validation via custom UnmarshalJSON)
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// LineInterpolation: "linear" | "spline" | "stepAfter" | "stepBefore". Default is "spline".
|
||||
type LineInterpolation struct {
|
||||
value string
|
||||
}
|
||||
|
||||
const (
|
||||
LineInterpolationLinear = "linear"
|
||||
LineInterpolationSpline = "spline"
|
||||
LineInterpolationStepAfter = "stepAfter"
|
||||
LineInterpolationStepBefore = "stepBefore"
|
||||
)
|
||||
|
||||
func (li LineInterpolation) Value() string {
|
||||
if li.value == "" {
|
||||
return LineInterpolationSpline
|
||||
}
|
||||
return li.value
|
||||
}
|
||||
|
||||
func (li *LineInterpolation) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
switch v {
|
||||
case LineInterpolationLinear, LineInterpolationSpline, LineInterpolationStepAfter, LineInterpolationStepBefore:
|
||||
li.value = v
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid line interpolation %q: must be linear, spline, stepAfter, or stepBefore", v)
|
||||
}
|
||||
}
|
||||
|
||||
func (li LineInterpolation) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(li.Value())
|
||||
}
|
||||
|
||||
// LineStyle: "solid" | "dashed". Default is "solid".
|
||||
type LineStyle struct {
|
||||
value string
|
||||
}
|
||||
|
||||
const (
|
||||
LineStyleSolid = "solid"
|
||||
LineStyleDashed = "dashed"
|
||||
)
|
||||
|
||||
func (ls LineStyle) Value() string {
|
||||
if ls.value == "" {
|
||||
return LineStyleSolid
|
||||
}
|
||||
return ls.value
|
||||
}
|
||||
|
||||
func (ls *LineStyle) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
switch v {
|
||||
case LineStyleSolid, LineStyleDashed:
|
||||
ls.value = v
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid line style %q: must be solid or dashed", v)
|
||||
}
|
||||
}
|
||||
|
||||
func (ls LineStyle) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(ls.Value())
|
||||
}
|
||||
|
||||
// FillMode: "solid" | "gradient" | "none". Default is "solid".
|
||||
type FillMode struct {
|
||||
value string
|
||||
}
|
||||
|
||||
const (
|
||||
FillModeSolid = "solid"
|
||||
FillModeGradient = "gradient"
|
||||
FillModeNone = "none"
|
||||
)
|
||||
|
||||
func (fm FillMode) Value() string {
|
||||
if fm.value == "" {
|
||||
return FillModeSolid
|
||||
}
|
||||
return fm.value
|
||||
}
|
||||
|
||||
func (fm *FillMode) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
switch v {
|
||||
case FillModeSolid, FillModeGradient, FillModeNone:
|
||||
fm.value = v
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid fill mode %q: must be solid, gradient, or none", v)
|
||||
}
|
||||
}
|
||||
|
||||
func (fm FillMode) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(fm.Value())
|
||||
}
|
||||
|
||||
// TimePreference: "globalTime" | "last5Min" | "last15Min" | "last30Min" | "last1Hr" | "last6Hr" | "last1Day" | "last3Days" | "last1Week" | "last1Month".
|
||||
type TimePreference string
|
||||
|
||||
const (
|
||||
TimePreferenceGlobalTime TimePreference = "globalTime"
|
||||
TimePreferenceLast5Min TimePreference = "last5Min"
|
||||
TimePreferenceLast15Min TimePreference = "last15Min"
|
||||
TimePreferenceLast30Min TimePreference = "last30Min"
|
||||
TimePreferenceLast1Hr TimePreference = "last1Hr"
|
||||
TimePreferenceLast6Hr TimePreference = "last6Hr"
|
||||
TimePreferenceLast1Day TimePreference = "last1Day"
|
||||
TimePreferenceLast3Days TimePreference = "last3Days"
|
||||
TimePreferenceLast1Week TimePreference = "last1Week"
|
||||
TimePreferenceLast1Month TimePreference = "last1Month"
|
||||
)
|
||||
|
||||
func (t *TimePreference) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
switch TimePreference(v) {
|
||||
case TimePreferenceGlobalTime, TimePreferenceLast5Min, TimePreferenceLast15Min, TimePreferenceLast30Min, TimePreferenceLast1Hr, TimePreferenceLast6Hr, TimePreferenceLast1Day, TimePreferenceLast3Days, TimePreferenceLast1Week, TimePreferenceLast1Month:
|
||||
*t = TimePreference(v)
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid timePreference %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// LegendPosition: "bottom" | "right".
|
||||
type LegendPosition string
|
||||
|
||||
const (
|
||||
LegendPositionBottom LegendPosition = "bottom"
|
||||
LegendPositionRight LegendPosition = "right"
|
||||
)
|
||||
|
||||
func (l *LegendPosition) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
switch LegendPosition(v) {
|
||||
case LegendPositionBottom, LegendPositionRight:
|
||||
*l = LegendPosition(v)
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid legend position %q: must be bottom or right", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ThresholdFormat: "Text" | "Background".
|
||||
type ThresholdFormat string
|
||||
|
||||
const (
|
||||
ThresholdFormatText ThresholdFormat = "Text"
|
||||
ThresholdFormatBackground ThresholdFormat = "Background"
|
||||
)
|
||||
|
||||
func (f *ThresholdFormat) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
switch ThresholdFormat(v) {
|
||||
case ThresholdFormatText, ThresholdFormatBackground:
|
||||
*f = ThresholdFormat(v)
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid threshold format %q: must be Text or Background", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ComparisonOperator: ">" | "<" | ">=" | "<=" | "=".
|
||||
type ComparisonOperator string
|
||||
|
||||
const (
|
||||
ComparisonOperatorGT ComparisonOperator = ">"
|
||||
ComparisonOperatorLT ComparisonOperator = "<"
|
||||
ComparisonOperatorGTE ComparisonOperator = ">="
|
||||
ComparisonOperatorLTE ComparisonOperator = "<="
|
||||
ComparisonOperatorEQ ComparisonOperator = "="
|
||||
)
|
||||
|
||||
func (o *ComparisonOperator) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
switch ComparisonOperator(v) {
|
||||
case ComparisonOperatorGT, ComparisonOperatorLT, ComparisonOperatorGTE, ComparisonOperatorLTE, ComparisonOperatorEQ:
|
||||
*o = ComparisonOperator(v)
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid comparison operator %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// SpanGaps: bool | number. Default is true.
|
||||
// When true, lines connect across null values. When false, lines break at nulls.
|
||||
// When a number, gaps smaller than that threshold (in seconds) are connected.
|
||||
type SpanGaps struct {
|
||||
value any
|
||||
}
|
||||
|
||||
func (sg SpanGaps) Value() any {
|
||||
if sg.value == nil {
|
||||
return true
|
||||
}
|
||||
return sg.value
|
||||
}
|
||||
|
||||
func (sg *SpanGaps) UnmarshalJSON(data []byte) error {
|
||||
var b bool
|
||||
if err := json.Unmarshal(data, &b); err == nil {
|
||||
sg.value = b
|
||||
return nil
|
||||
}
|
||||
var n float64
|
||||
if err := json.Unmarshal(data, &n); err == nil {
|
||||
if n < 0 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid spanGaps %v: numeric value must be non-negative", n)
|
||||
}
|
||||
sg.value = n
|
||||
return nil
|
||||
}
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid spanGaps: must be a bool or a non-negative number")
|
||||
}
|
||||
|
||||
func (sg SpanGaps) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(sg.Value())
|
||||
}
|
||||
|
||||
// PrecisionOption: 0 | 1 | 2 | 3 | 4 | "full". Default is 2.
|
||||
type PrecisionOption struct {
|
||||
value any
|
||||
}
|
||||
|
||||
const PrecisionOptionFull = "full"
|
||||
|
||||
func (p PrecisionOption) Value() any {
|
||||
if p.value == nil {
|
||||
return 2
|
||||
}
|
||||
return p.value
|
||||
}
|
||||
|
||||
func (p *PrecisionOption) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err == nil {
|
||||
if s != PrecisionOptionFull {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid precision option %q: string value must be %q", s, PrecisionOptionFull)
|
||||
}
|
||||
p.value = s
|
||||
return nil
|
||||
}
|
||||
var n int
|
||||
if err := json.Unmarshal(data, &n); err == nil {
|
||||
switch n {
|
||||
case 0, 1, 2, 3, 4:
|
||||
p.value = n
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid precision option %d: must be 0, 1, 2, 3, or 4", n)
|
||||
}
|
||||
}
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid precision option: must be an int (0-4) or \"full\"")
|
||||
}
|
||||
|
||||
func (p PrecisionOption) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(p.Value())
|
||||
}
|
||||
871
pkg/types/dashboardtypes/testdata/perses.json
vendored
Normal file
871
pkg/types/dashboardtypes/testdata/perses.json
vendored
Normal file
@@ -0,0 +1,871 @@
|
||||
{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {
|
||||
"name": "the-everything-dashboard",
|
||||
"project": "signoz"
|
||||
},
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "The everything dashboard",
|
||||
"description": "Trying to cover as many concepts here as possible"
|
||||
},
|
||||
"duration": "1h",
|
||||
"datasources": {
|
||||
"SigNozDatasource": {
|
||||
"default": true,
|
||||
"plugin": {
|
||||
"kind": "SigNozDatasource",
|
||||
"spec": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "serviceName",
|
||||
"display": {
|
||||
"name": "serviceName"
|
||||
},
|
||||
"allowAllValue": true,
|
||||
"allowMultiple": false,
|
||||
"sort": "none",
|
||||
"plugin": {
|
||||
"kind": "SigNozDynamicVariable",
|
||||
"spec": {
|
||||
"name": "service.name",
|
||||
"source": "Metrics"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "statusCodesFromQuery",
|
||||
"display": {
|
||||
"name": "statusCodesFromQuery"
|
||||
},
|
||||
"allowAllValue": true,
|
||||
"allowMultiple": true,
|
||||
"sort": "alphabetical-asc",
|
||||
"plugin": {
|
||||
"kind": "SigNozQueryVariable",
|
||||
"spec": {
|
||||
"queryValue": "SELECT JSONExtractString(labels, 'http.status_code') AS status_code FROM signoz_metrics.distributed_time_series_v4_1day WHERE status_code != '' GROUP BY status_code"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "limit",
|
||||
"display": {
|
||||
"name": "limit"
|
||||
},
|
||||
"allowAllValue": false,
|
||||
"allowMultiple": false,
|
||||
"sort": "none",
|
||||
"plugin": {
|
||||
"kind": "SigNozCustomVariable",
|
||||
"spec": {
|
||||
"customValue": "1,10,20,40,80,160,200"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "TextVariable",
|
||||
"spec": {
|
||||
"name": "textboxvar",
|
||||
"display": {
|
||||
"name": "textboxvar"
|
||||
},
|
||||
"value": "defaultvaluegoeshere",
|
||||
"plugin": {
|
||||
"kind": "SigNozTextboxVariable",
|
||||
"spec": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"panels": {
|
||||
"24e2697b": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "total resp size",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozTimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"fillSpans": true
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "By",
|
||||
"decimalPrecision": 3
|
||||
},
|
||||
"axes": {
|
||||
"softMax": 800,
|
||||
"isLogScale": true
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"customColors": {
|
||||
"{service.name=\"sampleapp-gateway\"}": "#9ea5f7"
|
||||
}
|
||||
},
|
||||
"thresholds": [
|
||||
{
|
||||
"value": 1024,
|
||||
"unit": "By",
|
||||
"color": "Red",
|
||||
"label": "upper limit"
|
||||
},
|
||||
{
|
||||
"value": 100,
|
||||
"unit": "By",
|
||||
"color": "Orange",
|
||||
"label": "kinda bad"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"links": [
|
||||
{
|
||||
"name": "View service details",
|
||||
"url": "http://localhost:8080/{{_service.name}}?dfddf=%7B%7Blimit%7D%7D"
|
||||
}
|
||||
],
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozBuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "http.server.response.body.size.sum",
|
||||
"reduceTo": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"timeAggregation": "rate"
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "http.response.status_code IN $statusCodesFromQuery"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "service.name",
|
||||
"fieldDataType": "string",
|
||||
"fieldContext": "tag"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"ff2f72f1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "fraction of calls",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozTimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"fillSpans": true
|
||||
},
|
||||
"formatting": {
|
||||
"decimalPrecision": 1
|
||||
},
|
||||
"thresholds": [
|
||||
{
|
||||
"value": 1,
|
||||
"color": "Blue",
|
||||
"label": "max possible"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozCompositeQuery",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"disabled": true,
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "signoz_calls_total",
|
||||
"reduceTo": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"timeAggregation": "rate"
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "service.name IN $serviceName AND http.status_code IN $statusCodesFromQuery"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"signal": "metrics",
|
||||
"disabled": true,
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "signoz_calls_total",
|
||||
"reduceTo": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"timeAggregation": "rate"
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "service.name in $serviceName"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_formula",
|
||||
"spec": {
|
||||
"name": "F1",
|
||||
"expression": "A / B"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"011605e7": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "total resp size"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozBarChartPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"stackedBarChart": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "By"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozBuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "http.server.response.body.size.sum",
|
||||
"reduceTo": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"timeAggregation": "rate"
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "http.response.status_code IN $statusCodesFromQuery"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "service.name",
|
||||
"fieldDataType": "string",
|
||||
"fieldContext": "tag"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"e23516fc": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "num traces for service"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozNumberPanel",
|
||||
"spec": {
|
||||
"formatting": {
|
||||
"unit": "none",
|
||||
"decimalPrecision": 1
|
||||
},
|
||||
"thresholds": [
|
||||
{
|
||||
"value": 1200000,
|
||||
"operator": ">",
|
||||
"unit": "none",
|
||||
"color": "Red",
|
||||
"format": "Text"
|
||||
},
|
||||
{
|
||||
"value": 1200000,
|
||||
"operator": "<=",
|
||||
"unit": "none",
|
||||
"color": "Green",
|
||||
"format": "Text"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozBuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count() "
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "service.name = $serviceName "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"130c8d6b": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "num logs for service"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozNumberPanel",
|
||||
"spec": {
|
||||
"formatting": {
|
||||
"unit": "none",
|
||||
"decimalPrecision": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozBuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count() "
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "service.name = $serviceName "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"246f7c6d": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "num traces for service per resp code"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozPieChartPanel",
|
||||
"spec": {
|
||||
"formatting": {
|
||||
"decimalPrecision": 1
|
||||
},
|
||||
"legend": {
|
||||
"customColors": {
|
||||
"\"201\"": "#2bc051",
|
||||
"\"400\"": "#cc462e",
|
||||
"\"500\"": "#ff0000"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozBuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count() "
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "service.name = $serviceName isEntryPoint = 'true'"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "http.response.status_code",
|
||||
"fieldDataType": "float64",
|
||||
"fieldContext": "tag"
|
||||
}
|
||||
],
|
||||
"legend": "\"{{http.response.status_code}}\""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"21f7d4d0": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "average latency per service"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozTablePanel",
|
||||
"spec": {
|
||||
"formatting": {
|
||||
"columnUnits": {
|
||||
"A": "s"
|
||||
}
|
||||
},
|
||||
"thresholds": [
|
||||
{
|
||||
"value": 1,
|
||||
"operator": ">",
|
||||
"unit": "min",
|
||||
"color": "Red",
|
||||
"format": "Text",
|
||||
"columnName": "A"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozClickHouseSQL",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": "WITH\n __spatial_aggregation_cte AS\n (\n SELECT\n toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts,\n `service.name`,\n le,\n sum(value) / 60 AS value\n FROM signoz_metrics.distributed_samples_v4 AS points\n INNER JOIN\n (\n SELECT\n fingerprint,\n JSONExtractString(labels, 'service.name') AS `service.name`,\n JSONExtractString(labels, 'le') AS le\n FROM signoz_metrics.time_series_v4\n WHERE (metric_name IN ('signoz_latency.bucket')) AND (LOWER(temporality) LIKE LOWER('delta')) AND (__normalized = 0)\n GROUP BY\n fingerprint,\n `service.name`,\n le\n ) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint\n WHERE metric_name IN ('signoz_latency.bucket')\n GROUP BY\n ts,\n `service.name`,\n le\n ),\n __histogramCTE AS\n (\n SELECT\n ts,\n `service.name`,\n histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.9) AS value\n FROM __spatial_aggregation_cte\n GROUP BY\n `service.name`,\n ts\n ORDER BY\n `service.name` ASC,\n ts ASC\n )\nSELECT\n `service.name` AS service,\n avg(value) AS A\nFROM __histogramCTE\nGROUP BY `service.name`"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"ad5fd556": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "logs from service"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozListPanel",
|
||||
"spec": {
|
||||
"selectedLogFields": [
|
||||
{
|
||||
"name": "timestamp",
|
||||
"type": "log",
|
||||
"dataType": ""
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"type": "log",
|
||||
"dataType": ""
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"type": "",
|
||||
"dataType": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "LogQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozBuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count() "
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "service.name = $serviceName"
|
||||
},
|
||||
"groupBy": [],
|
||||
"order": [
|
||||
{
|
||||
"key": {
|
||||
"name": "timestamp"
|
||||
},
|
||||
"direction": "desc"
|
||||
},
|
||||
{
|
||||
"key": {
|
||||
"name": "id"
|
||||
},
|
||||
"direction": "desc"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"f07b59ee": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "response size buckets"
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozHistogramPanel",
|
||||
"spec": {
|
||||
"histogramBuckets": {
|
||||
"bucketCount": 60,
|
||||
"mergeAllActiveQueries": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozBuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "http.server.response.body.size.bucket",
|
||||
"reduceTo": "avg",
|
||||
"spaceAggregation": "p90",
|
||||
"timeAggregation": "rate"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"e1a41831": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "trace operator",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozTimeSeriesPanel",
|
||||
"spec": {
|
||||
"legend": {
|
||||
"position": "right"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozCompositeQuery",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count() "
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "service.name = 'sampleapp-gateway' "
|
||||
},
|
||||
"legend": "Gateway"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"signal": "traces",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count() "
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": "http.response.status_code = 200"
|
||||
},
|
||||
"legend": "$serviceName"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_trace_operator",
|
||||
"spec": {
|
||||
"name": "T1",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count()",
|
||||
"alias": "request_count"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"f0d70491": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "no results in this promql",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozTimeSeriesPanel",
|
||||
"spec": {}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozCompositeQuery",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"type": "promql",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": "sum(rate(flask_exporter_info[5m]))"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "promql",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"query": "sum(increase(flask_exporter_info[5m]))"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"0e6eb4ca": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "no results in this promql",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozTimeSeriesPanel",
|
||||
"spec": {}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozPromQLQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": "sum(rate(flask_exporter_info[5m]))"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/24e2697b"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/ff2f72f1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 6,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/011605e7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 6,
|
||||
"width": 6,
|
||||
"height": 3,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/e23516fc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 9,
|
||||
"width": 6,
|
||||
"height": 3,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/130c8d6b"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 12,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/246f7c6d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 12,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/21f7d4d0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 18,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/ad5fd556"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 18,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/f07b59ee"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 24,
|
||||
"width": 12,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/e1a41831"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 30,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/f0d70491"
|
||||
}
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 30,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/0e6eb4ca"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
167
pkg/types/dashboardtypes/testdata/perses_with_sections.json
vendored
Normal file
167
pkg/types/dashboardtypes/testdata/perses_with_sections.json
vendored
Normal file
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"kind": "Dashboard",
|
||||
"metadata": {
|
||||
"name": "nv-dashboard-with-sections",
|
||||
"project": "signoz"
|
||||
},
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "NV dashboard with sections",
|
||||
"description": ""
|
||||
},
|
||||
"datasources": {
|
||||
"SigNozDatasource": {
|
||||
"default": true,
|
||||
"plugin": {
|
||||
"kind": "SigNozDatasource",
|
||||
"spec": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panels": {
|
||||
"b424e23b": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozNumberPanel",
|
||||
"spec": {
|
||||
"formatting": {
|
||||
"unit": "s",
|
||||
"decimalPrecision": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozBuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "container.cpu.time",
|
||||
"reduceTo": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"timeAggregation": "rate"
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"251df4d5": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "SigNozTimeSeriesPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"fillSpans": false
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "recommendations",
|
||||
"decimalPrecision": 2
|
||||
},
|
||||
"chartAppearance": {
|
||||
"lineInterpolation": "spline",
|
||||
"showPoints": false,
|
||||
"lineStyle": "solid",
|
||||
"fillMode": "none",
|
||||
"spanGaps": true
|
||||
},
|
||||
"legend": {
|
||||
"position": "bottom"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozBuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "app_recommendations_counter",
|
||||
"reduceTo": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"timeAggregation": "rate"
|
||||
}
|
||||
],
|
||||
"filter": {
|
||||
"expression": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"display": {
|
||||
"title": "Bravo",
|
||||
"collapse": {
|
||||
"open": true
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/b424e23b"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"display": {
|
||||
"title": "Alpha",
|
||||
"collapse": {
|
||||
"open": true
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/251df4d5"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ type RuleCondition struct {
|
||||
MatchType MatchType `json:"matchType"`
|
||||
TargetUnit string `json:"targetUnit,omitempty"`
|
||||
Algorithm string `json:"algorithm,omitempty"`
|
||||
Seasonality string `json:"seasonality,omitempty"`
|
||||
Seasonality Seasonality `json:"seasonality,omitzero"`
|
||||
SelectedQuery string `json:"selectedQueryName,omitempty"`
|
||||
RequireMinPoints bool `json:"requireMinPoints,omitempty"`
|
||||
RequiredNumPoints int `json:"requiredNumPoints,omitempty"`
|
||||
@@ -158,10 +158,6 @@ func (rc *RuleCondition) SelectedQueryName() string {
|
||||
return keys[len(keys)-1]
|
||||
}
|
||||
|
||||
func (rc *RuleCondition) IsValid() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// ShouldEval checks if the further series should be evaluated at all for alerts.
|
||||
func (rc *RuleCondition) ShouldEval(series *qbtypes.TimeSeries) bool {
|
||||
return !rc.RequireMinPoints || len(series.Values) >= rc.RequiredNumPoints
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -25,7 +26,8 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultSchemaVersion = "v1"
|
||||
DefaultSchemaVersion = "v1"
|
||||
SchemaVersionV2Alpha1 = "v2alpha1"
|
||||
)
|
||||
|
||||
type RuleDataKind string
|
||||
@@ -39,9 +41,9 @@ type PostableRule struct {
|
||||
AlertName string `json:"alert"`
|
||||
AlertType AlertType `json:"alertType,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
RuleType RuleType `json:"ruleType,omitempty"`
|
||||
EvalWindow valuer.TextDuration `json:"evalWindow,omitempty"`
|
||||
Frequency valuer.TextDuration `json:"frequency,omitempty"`
|
||||
RuleType RuleType `json:"ruleType,omitzero"`
|
||||
EvalWindow valuer.TextDuration `json:"evalWindow,omitzero"`
|
||||
Frequency valuer.TextDuration `json:"frequency,omitzero"`
|
||||
|
||||
RuleCondition *RuleCondition `json:"condition,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
@@ -64,7 +66,7 @@ type PostableRule struct {
|
||||
|
||||
type NotificationSettings struct {
|
||||
GroupBy []string `json:"groupBy,omitempty"`
|
||||
Renotify Renotify `json:"renotify,omitempty"`
|
||||
Renotify Renotify `json:"renotify,omitzero"`
|
||||
UsePolicy bool `json:"usePolicy,omitempty"`
|
||||
// NewGroupEvalDelay is the grace period for new series to be excluded from alerts evaluation
|
||||
NewGroupEvalDelay valuer.TextDuration `json:"newGroupEvalDelay,omitzero"`
|
||||
@@ -93,6 +95,28 @@ func (ns *NotificationSettings) GetAlertManagerNotificationConfig() alertmanager
|
||||
return alertmanagertypes.NewNotificationConfig(ns.GroupBy, renotifyInterval, noDataRenotifyInterval, ns.UsePolicy)
|
||||
}
|
||||
|
||||
// Channels returns all unique channel names referenced by the rule's thresholds.
|
||||
func (r *PostableRule) Channels() []string {
|
||||
if r.RuleCondition == nil || r.RuleCondition.Thresholds == nil {
|
||||
return nil
|
||||
}
|
||||
threshold, err := r.RuleCondition.Thresholds.GetRuleThreshold()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
var channels []string
|
||||
for _, receiver := range threshold.GetRuleReceivers() {
|
||||
for _, ch := range receiver.Channels {
|
||||
if _, ok := seen[ch]; !ok {
|
||||
seen[ch] = struct{}{}
|
||||
channels = append(channels, ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
return channels
|
||||
}
|
||||
|
||||
func (r *PostableRule) GetRuleRouteRequest(ruleID string) ([]*alertmanagertypes.PostableRoutePolicy, error) {
|
||||
threshold, err := r.RuleCondition.Thresholds.GetRuleThreshold()
|
||||
if err != nil {
|
||||
@@ -185,15 +209,19 @@ func (r *PostableRule) processRuleDefaults() {
|
||||
r.SchemaVersion = DefaultSchemaVersion
|
||||
}
|
||||
|
||||
if r.EvalWindow.IsZero() {
|
||||
r.EvalWindow = valuer.MustParseTextDuration("5m")
|
||||
// v2alpha1 uses the Evaluation envelope for window/frequency;
|
||||
// only default top-level fields for v1.
|
||||
if r.SchemaVersion != SchemaVersionV2Alpha1 {
|
||||
if r.EvalWindow.IsZero() {
|
||||
r.EvalWindow = valuer.MustParseTextDuration("5m")
|
||||
}
|
||||
|
||||
if r.Frequency.IsZero() {
|
||||
r.Frequency = valuer.MustParseTextDuration("1m")
|
||||
}
|
||||
}
|
||||
|
||||
if r.Frequency.IsZero() {
|
||||
r.Frequency = valuer.MustParseTextDuration("1m")
|
||||
}
|
||||
|
||||
if r.RuleCondition != nil {
|
||||
if r.RuleCondition != nil && r.RuleCondition.CompositeQuery != nil {
|
||||
switch r.RuleCondition.CompositeQuery.QueryType {
|
||||
case QueryTypeBuilder:
|
||||
if r.RuleType.IsZero() {
|
||||
@@ -259,6 +287,10 @@ func (r *PostableRule) MarshalJSON() ([]byte, error) {
|
||||
aux.SchemaVersion = ""
|
||||
aux.NotificationSettings = nil
|
||||
return json.Marshal(aux)
|
||||
case SchemaVersionV2Alpha1:
|
||||
copyStruct := *r
|
||||
aux := Alias(copyStruct)
|
||||
return json.Marshal(aux)
|
||||
default:
|
||||
copyStruct := *r
|
||||
aux := Alias(copyStruct)
|
||||
@@ -292,23 +324,24 @@ func isValidLabelValue(v string) bool {
|
||||
return utf8.ValidString(v)
|
||||
}
|
||||
|
||||
// validate runs during UnmarshalJSON (read + write path).
|
||||
// Preserves the original pre-existing checks only so that stored rules
|
||||
// continue to load without errors.
|
||||
func (r *PostableRule) validate() error {
|
||||
|
||||
var errs []error
|
||||
|
||||
if r.RuleCondition == nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "rule condition is required")
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition: field is required")
|
||||
}
|
||||
|
||||
if r.Version != "v5" {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "only version v5 is supported, got %q", r.Version))
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "version: only v5 is supported, got %q", r.Version))
|
||||
}
|
||||
|
||||
for k, v := range r.Labels {
|
||||
if !isValidLabelName(k) {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid label name: %s", k))
|
||||
}
|
||||
|
||||
if !isValidLabelValue(v) {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid label value: %s", v))
|
||||
}
|
||||
@@ -321,7 +354,196 @@ func (r *PostableRule) validate() error {
|
||||
}
|
||||
|
||||
errs = append(errs, testTemplateParsing(r)...)
|
||||
return errors.Join(errs...)
|
||||
|
||||
joined := errors.Join(errs...)
|
||||
if joined != nil {
|
||||
return errors.WrapInvalidInputf(joined, errors.CodeInvalidInput, "validation failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate enforces all validation rules. For now, this is invoked on the write path
|
||||
// (create, update, patch, test) before persisting. This is intentionally
|
||||
// not called from UnmarshalJSON so that existing stored rules can always
|
||||
// be loaded regardless of new validation rules.
|
||||
func (r *PostableRule) Validate() error {
|
||||
var errs []error
|
||||
|
||||
if r.AlertName == "" {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "alert: field is required"))
|
||||
}
|
||||
|
||||
if r.RuleCondition == nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition: field is required")
|
||||
}
|
||||
|
||||
if r.Version != "v5" {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "version: only v5 is supported, got %q", r.Version))
|
||||
}
|
||||
|
||||
if r.AlertType != "" {
|
||||
switch r.AlertType {
|
||||
case AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions:
|
||||
default:
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"alertType: unsupported value %q; must be one of %q, %q, %q, %q",
|
||||
r.AlertType, AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions))
|
||||
}
|
||||
}
|
||||
|
||||
if !r.RuleType.IsZero() {
|
||||
if err := r.RuleType.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if r.RuleType == RuleTypeAnomaly && !r.RuleCondition.Seasonality.IsZero() {
|
||||
if err := r.RuleCondition.Seasonality.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if r.RuleCondition.CompositeQuery == nil {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery: field is required"))
|
||||
} else {
|
||||
if len(r.RuleCondition.CompositeQuery.Queries) == 0 {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery.queries: must have at least one query"))
|
||||
} else {
|
||||
cq := &qbtypes.CompositeQuery{Queries: r.RuleCondition.CompositeQuery.Queries}
|
||||
if err := cq.Validate(qbtypes.GetValidationOptions(qbtypes.RequestTypeTimeSeries)...); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if r.RuleCondition.SelectedQuery != "" && r.RuleCondition.CompositeQuery != nil && len(r.RuleCondition.CompositeQuery.Queries) > 0 {
|
||||
found := false
|
||||
for _, query := range r.RuleCondition.CompositeQuery.Queries {
|
||||
if query.GetQueryName() == r.RuleCondition.SelectedQuery {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"condition.selectedQueryName: %q does not match any query in compositeQuery",
|
||||
r.RuleCondition.SelectedQuery))
|
||||
}
|
||||
}
|
||||
|
||||
if r.RuleCondition.RequireMinPoints && r.RuleCondition.RequiredNumPoints <= 0 {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"condition.requiredNumPoints: must be greater than 0 when requireMinPoints is enabled"))
|
||||
}
|
||||
|
||||
errs = append(errs, r.validateSchemaVersion()...)
|
||||
|
||||
for k, v := range r.Labels {
|
||||
if !isValidLabelName(k) {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid label name: %s", k))
|
||||
}
|
||||
if !isValidLabelValue(v) {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid label value: %s", v))
|
||||
}
|
||||
}
|
||||
|
||||
for k := range r.Annotations {
|
||||
if !isValidLabelName(k) {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid annotation name: %s", k))
|
||||
}
|
||||
}
|
||||
|
||||
errs = append(errs, testTemplateParsing(r)...)
|
||||
|
||||
joined := errors.Join(errs...)
|
||||
if joined != nil {
|
||||
return errors.WrapInvalidInputf(joined, errors.CodeInvalidInput, "validation failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PostableRule) validateSchemaVersion() []error {
|
||||
switch r.SchemaVersion {
|
||||
case DefaultSchemaVersion:
|
||||
return r.validateV1()
|
||||
case SchemaVersionV2Alpha1:
|
||||
return r.validateV2Alpha1()
|
||||
default:
|
||||
return []error{errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"schemaVersion: unsupported value %q; must be one of %q, %q",
|
||||
r.SchemaVersion, DefaultSchemaVersion, SchemaVersionV2Alpha1)}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *PostableRule) validateV1() []error {
|
||||
var errs []error
|
||||
|
||||
if r.RuleCondition.Target == nil {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"condition.target: field is required for schemaVersion %q", DefaultSchemaVersion))
|
||||
}
|
||||
if r.RuleCondition.CompareOperator.IsZero() {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"condition.op: field is required for schemaVersion %q", DefaultSchemaVersion))
|
||||
} else if err := r.RuleCondition.CompareOperator.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if r.RuleCondition.MatchType.IsZero() {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"condition.matchType: field is required for schemaVersion %q", DefaultSchemaVersion))
|
||||
} else if err := r.RuleCondition.MatchType.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func (r *PostableRule) validateV2Alpha1() []error {
|
||||
var errs []error
|
||||
|
||||
// TODO(srikanthccv): reject v1-only fields?
|
||||
// if r.RuleCondition.Target != nil {
|
||||
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
// "condition.target: field is not used in schemaVersion %q; set target in condition.thresholds entries instead",
|
||||
// SchemaVersionV2Alpha1))
|
||||
// }
|
||||
// if !r.RuleCondition.CompareOperator.IsZero() {
|
||||
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
// "condition.op: field is not used in schemaVersion %q; set op in condition.thresholds entries instead",
|
||||
// SchemaVersionV2Alpha1))
|
||||
// }
|
||||
// if !r.RuleCondition.MatchType.IsZero() {
|
||||
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
// "condition.matchType: field is not used in schemaVersion %q; set matchType in condition.thresholds entries instead",
|
||||
// SchemaVersionV2Alpha1))
|
||||
// }
|
||||
// if len(r.PreferredChannels) > 0 {
|
||||
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
// "preferredChannels: field is not used in schemaVersion %q; set channels in condition.thresholds entries instead",
|
||||
// SchemaVersionV2Alpha1))
|
||||
// }
|
||||
|
||||
// Require v2alpha1-specific fields
|
||||
if r.RuleCondition.Thresholds == nil {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"condition.thresholds: field is required for schemaVersion %q", SchemaVersionV2Alpha1))
|
||||
}
|
||||
|
||||
if r.Evaluation == nil {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"evaluation: field is required for schemaVersion %q", SchemaVersionV2Alpha1))
|
||||
}
|
||||
if r.NotificationSettings == nil {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"notificationSettings: field is required for schemaVersion %q", SchemaVersionV2Alpha1))
|
||||
} else {
|
||||
if r.NotificationSettings.Renotify.Enabled && !r.NotificationSettings.Renotify.ReNotifyInterval.IsPositive() {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"notificationSettings.renotify.interval: must be a positive duration when renotify is enabled"))
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func testTemplateParsing(rl *PostableRule) (errs []error) {
|
||||
@@ -393,6 +615,10 @@ func (g *GettableRule) MarshalJSON() ([]byte, error) {
|
||||
aux.SchemaVersion = ""
|
||||
aux.NotificationSettings = nil
|
||||
return json.Marshal(aux)
|
||||
case SchemaVersionV2Alpha1:
|
||||
copyStruct := *g
|
||||
aux := Alias(copyStruct)
|
||||
return json.Marshal(aux)
|
||||
default:
|
||||
copyStruct := *g
|
||||
aux := Alias(copyStruct)
|
||||
|
||||
@@ -34,15 +34,15 @@ func TestParseIntoRule(t *testing.T) {
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"builderQueries": {
|
||||
"A": {
|
||||
"expression": "A",
|
||||
"disabled": false,
|
||||
"aggregateAttribute": {
|
||||
"key": "test_metric"
|
||||
}
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 10.0,
|
||||
"matchType": "1",
|
||||
@@ -77,14 +77,15 @@ func TestParseIntoRule(t *testing.T) {
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"builderQueries": {
|
||||
"A": {
|
||||
"disabled": false,
|
||||
"aggregateAttribute": {
|
||||
"key": "test_metric"
|
||||
}
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 5.0,
|
||||
"matchType": "1",
|
||||
@@ -112,12 +113,14 @@ func TestParseIntoRule(t *testing.T) {
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "promql",
|
||||
"promQueries": {
|
||||
"A": {
|
||||
"queries": [{
|
||||
"type": "promql",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": "rate(http_requests_total[5m])",
|
||||
"disabled": false
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 10.0,
|
||||
"matchType": "1",
|
||||
@@ -165,12 +168,13 @@ func TestParseIntoRule(t *testing.T) {
|
||||
|
||||
func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
initRule PostableRule
|
||||
content []byte
|
||||
kind RuleDataKind
|
||||
expectError bool
|
||||
validate func(*testing.T, *PostableRule)
|
||||
name string
|
||||
initRule PostableRule
|
||||
content []byte
|
||||
kind RuleDataKind
|
||||
expectError bool // unmarshal error (read path)
|
||||
expectValidateError bool // Validate() error (write path only)
|
||||
validate func(*testing.T, *PostableRule)
|
||||
}{
|
||||
{
|
||||
name: "schema v1 - threshold name from severity label",
|
||||
@@ -182,13 +186,15 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"builderQueries": {
|
||||
"A": {
|
||||
"aggregateAttribute": {
|
||||
"key": "cpu_usage"
|
||||
}
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "cpu_usage", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
},
|
||||
}],
|
||||
"unit": "percent"
|
||||
},
|
||||
"target": 85.0,
|
||||
@@ -271,13 +277,15 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"builderQueries": {
|
||||
"A": {
|
||||
"aggregateAttribute": {
|
||||
"key": "memory_usage"
|
||||
}
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "memory_usage", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 90.0,
|
||||
"matchType": "1",
|
||||
@@ -312,13 +320,15 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"builderQueries": {
|
||||
"A": {
|
||||
"aggregateAttribute": {
|
||||
"key": "cpu_usage"
|
||||
}
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "cpu_usage", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
},
|
||||
}],
|
||||
"unit": "percent"
|
||||
},
|
||||
"target": 80.0,
|
||||
@@ -394,49 +404,253 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "schema v2 - does not populate thresholds and evaluation",
|
||||
name: "schema v2alpha1 - uses explicit thresholds and evaluation",
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "V2Test",
|
||||
"schemaVersion": "v2",
|
||||
"alert": "V2Alpha1Test",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"ruleType": "threshold_rule",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"builderQueries": {
|
||||
"A": {
|
||||
"aggregateAttribute": {
|
||||
"key": "test_metric"
|
||||
}
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [{
|
||||
"name": "critical",
|
||||
"target": 100.0,
|
||||
"matchType": "1",
|
||||
"op": "1"
|
||||
}]
|
||||
}
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {
|
||||
"evalWindow": "5m",
|
||||
"frequency": "1m"
|
||||
}
|
||||
},
|
||||
"notificationSettings": {
|
||||
"renotify": {
|
||||
"enabled": true,
|
||||
"interval": "4h",
|
||||
"alertStates": ["firing"]
|
||||
}
|
||||
}
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectError: false,
|
||||
validate: func(t *testing.T, rule *PostableRule) {
|
||||
if rule.SchemaVersion != SchemaVersionV2Alpha1 {
|
||||
t.Errorf("Expected schemaVersion %q, got %q", SchemaVersionV2Alpha1, rule.SchemaVersion)
|
||||
}
|
||||
|
||||
if rule.RuleCondition.Thresholds == nil {
|
||||
t.Error("Expected Thresholds to be present for v2alpha1")
|
||||
}
|
||||
if rule.Evaluation == nil {
|
||||
t.Error("Expected Evaluation to be present for v2alpha1")
|
||||
}
|
||||
if rule.NotificationSettings == nil {
|
||||
t.Error("Expected NotificationSettings to be present for v2alpha1")
|
||||
}
|
||||
if rule.RuleType != RuleTypeThreshold {
|
||||
t.Error("Expected RuleType to be auto-detected")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "schema v2alpha1 - rejects v1-only fields with suggestions",
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "MixedFieldsTest",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"ruleType": "threshold_rule",
|
||||
"preferredChannels": ["slack"],
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 100.0,
|
||||
"matchType": "1",
|
||||
"op": "1"
|
||||
}
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectError: false,
|
||||
validate: func(t *testing.T, rule *PostableRule) {
|
||||
if rule.SchemaVersion != "v2" {
|
||||
t.Errorf("Expected schemaVersion 'v2', got '%s'", rule.SchemaVersion)
|
||||
kind: RuleDataKindJson,
|
||||
expectValidateError: true,
|
||||
},
|
||||
{
|
||||
name: "schema v2alpha1 - requires evaluation",
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "MissingEvalTest",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"ruleType": "threshold_rule",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [{
|
||||
"name": "critical",
|
||||
"target": 100.0,
|
||||
"matchType": "1",
|
||||
"op": "1"
|
||||
}]
|
||||
}
|
||||
},
|
||||
"notificationSettings": {
|
||||
"renotify": {
|
||||
"enabled": true,
|
||||
"interval": "4h",
|
||||
"alertStates": ["firing"]
|
||||
}
|
||||
}
|
||||
|
||||
if rule.RuleCondition.Thresholds != nil {
|
||||
t.Error("Expected Thresholds to be nil for v2")
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectValidateError: true,
|
||||
},
|
||||
{
|
||||
name: "schema v2alpha1 - requires notificationSettings",
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "MissingNotifTest",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"ruleType": "threshold_rule",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [{
|
||||
"name": "critical",
|
||||
"target": 100.0,
|
||||
"matchType": "1",
|
||||
"op": "1"
|
||||
}]
|
||||
}
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {
|
||||
"evalWindow": "5m",
|
||||
"frequency": "1m"
|
||||
}
|
||||
}
|
||||
if rule.Evaluation != nil {
|
||||
t.Error("Expected Evaluation to be nil for v2")
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectValidateError: true,
|
||||
},
|
||||
{
|
||||
name: "schema v2alpha1 - requires thresholds for non-promql rules",
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "MissingThresholdsTest",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"ruleType": "threshold_rule",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
}
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {
|
||||
"evalWindow": "5m",
|
||||
"frequency": "1m"
|
||||
}
|
||||
},
|
||||
"notificationSettings": {
|
||||
"renotify": {
|
||||
"enabled": true,
|
||||
"interval": "4h",
|
||||
"alertStates": ["firing"]
|
||||
}
|
||||
}
|
||||
|
||||
if rule.EvalWindow.Duration() != 5*time.Minute {
|
||||
t.Error("Expected default EvalWindow to be applied")
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectValidateError: true,
|
||||
},
|
||||
{
|
||||
name: "unsupported schema version",
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "BadSchemaTest",
|
||||
"schemaVersion": "v3",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 100.0,
|
||||
"matchType": "1",
|
||||
"op": "1"
|
||||
}
|
||||
if rule.RuleType != RuleTypeThreshold {
|
||||
t.Error("Expected RuleType to be auto-detected")
|
||||
}
|
||||
},
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectValidateError: true,
|
||||
},
|
||||
{
|
||||
name: "default schema version - defaults to v1 behavior",
|
||||
@@ -447,13 +661,15 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"builderQueries": {
|
||||
"A": {
|
||||
"aggregateAttribute": {
|
||||
"key": "test_metric"
|
||||
}
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 75.0,
|
||||
"matchType": "1",
|
||||
@@ -480,13 +696,23 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rule := tt.initRule
|
||||
err := json.Unmarshal(tt.content, &rule)
|
||||
if tt.expectError && err == nil {
|
||||
t.Errorf("Expected error but got none")
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected unmarshal error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
if !tt.expectError && err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected unmarshal error: %v", err)
|
||||
return
|
||||
}
|
||||
if tt.validate != nil && err == nil {
|
||||
if tt.expectValidateError {
|
||||
if err := rule.Validate(); err == nil {
|
||||
t.Errorf("Expected Validate() error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.validate != nil {
|
||||
tt.validate(t, &rule)
|
||||
}
|
||||
})
|
||||
@@ -500,15 +726,15 @@ func TestParseIntoRuleThresholdGeneration(t *testing.T) {
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"builderQueries": {
|
||||
"A": {
|
||||
"expression": "A",
|
||||
"disabled": false,
|
||||
"aggregateAttribute": {
|
||||
"key": "response_time"
|
||||
}
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "response_time", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 100.0,
|
||||
"matchType": "1",
|
||||
@@ -571,7 +797,7 @@ func TestParseIntoRuleThresholdGeneration(t *testing.T) {
|
||||
|
||||
func TestParseIntoRuleMultipleThresholds(t *testing.T) {
|
||||
content := []byte(`{
|
||||
"schemaVersion": "v2",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"alert": "MultiThresholdAlert",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
@@ -579,19 +805,16 @@ func TestParseIntoRuleMultipleThresholds(t *testing.T) {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"unit": "%",
|
||||
"builderQueries": {
|
||||
"A": {
|
||||
"expression": "A",
|
||||
"disabled": false,
|
||||
"aggregateAttribute": {
|
||||
"key": "cpu_usage"
|
||||
}
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "cpu_usage", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 90.0,
|
||||
"matchType": "1",
|
||||
"op": "1",
|
||||
"selectedQuery": "A",
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
@@ -616,6 +839,20 @@ func TestParseIntoRuleMultipleThresholds(t *testing.T) {
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {
|
||||
"evalWindow": "5m",
|
||||
"frequency": "1m"
|
||||
}
|
||||
},
|
||||
"notificationSettings": {
|
||||
"renotify": {
|
||||
"enabled": true,
|
||||
"interval": "4h",
|
||||
"alertStates": ["firing"]
|
||||
}
|
||||
}
|
||||
}`)
|
||||
rule := PostableRule{}
|
||||
|
||||
@@ -54,6 +54,29 @@ func (CompareOperator) Enum() []any {
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize returns the canonical (numeric) form of the operator.
|
||||
// This ensures evaluation logic can use simple == checks against the canonical values.
|
||||
func (c CompareOperator) Normalize() CompareOperator {
|
||||
switch c {
|
||||
case ValueIsAbove, ValueIsAboveLiteral, ValueIsAboveSymbol:
|
||||
return ValueIsAbove
|
||||
case ValueIsBelow, ValueIsBelowLiteral, ValueIsBelowSymbol:
|
||||
return ValueIsBelow
|
||||
case ValueIsEq, ValueIsEqLiteral, ValueIsEqLiteralShort, ValueIsEqSymbol:
|
||||
return ValueIsEq
|
||||
case ValueIsNotEq, ValueIsNotEqLiteral, ValueIsNotEqLiteralShort, ValueIsNotEqSymbol:
|
||||
return ValueIsNotEq
|
||||
case ValueAboveOrEq, ValueAboveOrEqLiteral, ValueAboveOrEqLiteralShort, ValueAboveOrEqSymbol:
|
||||
return ValueAboveOrEq
|
||||
case ValueBelowOrEq, ValueBelowOrEqLiteral, ValueBelowOrEqLiteralShort, ValueBelowOrEqSymbol:
|
||||
return ValueBelowOrEq
|
||||
case ValueOutsideBounds, ValueOutsideBoundsLiteral:
|
||||
return ValueOutsideBounds
|
||||
default:
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
func (c CompareOperator) Validate() error {
|
||||
switch c {
|
||||
case ValueIsAbove,
|
||||
@@ -70,10 +93,18 @@ func (c CompareOperator) Validate() error {
|
||||
ValueIsNotEqLiteral,
|
||||
ValueIsNotEqLiteralShort,
|
||||
ValueIsNotEqSymbol,
|
||||
ValueAboveOrEq,
|
||||
ValueAboveOrEqLiteral,
|
||||
ValueAboveOrEqLiteralShort,
|
||||
ValueAboveOrEqSymbol,
|
||||
ValueBelowOrEq,
|
||||
ValueBelowOrEqLiteral,
|
||||
ValueBelowOrEqLiteralShort,
|
||||
ValueBelowOrEqSymbol,
|
||||
ValueOutsideBounds,
|
||||
ValueOutsideBoundsLiteral:
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown comparison operator, known values are: ")
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.op: unsupported value %q; must be one of above, below, equal, not_equal, above_or_equal, below_or_equal, outside_bounds", c.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ type MatchType struct {
|
||||
|
||||
var (
|
||||
AtleastOnce = MatchType{valuer.NewString("1")}
|
||||
AtleastOnceLiteral = MatchType{valuer.NewString("atleast_once")}
|
||||
AtleastOnceLiteral = MatchType{valuer.NewString("at_least_once")}
|
||||
|
||||
AllTheTimes = MatchType{valuer.NewString("2")}
|
||||
AllTheTimesLiteral = MatchType{valuer.NewString("all_the_times")}
|
||||
@@ -38,6 +38,24 @@ func (MatchType) Enum() []any {
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize returns the canonical (numeric) form of the match type.
|
||||
func (m MatchType) Normalize() MatchType {
|
||||
switch m {
|
||||
case AtleastOnce, AtleastOnceLiteral:
|
||||
return AtleastOnce
|
||||
case AllTheTimes, AllTheTimesLiteral:
|
||||
return AllTheTimes
|
||||
case OnAverage, OnAverageLiteral, OnAverageShort:
|
||||
return OnAverage
|
||||
case InTotal, InTotalLiteral, InTotalShort:
|
||||
return InTotal
|
||||
case Last, LastLiteral:
|
||||
return Last
|
||||
default:
|
||||
return m
|
||||
}
|
||||
}
|
||||
|
||||
func (m MatchType) Validate() error {
|
||||
switch m {
|
||||
case
|
||||
@@ -55,6 +73,6 @@ func (m MatchType) Validate() error {
|
||||
LastLiteral:
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown match type operator, known values are")
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.matchType: unsupported value %q; must be one of at_least_once, all_the_times, on_average, in_total, last", m.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,6 @@ func (r RuleType) Validate() error {
|
||||
RuleTypeAnomaly:
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown rule type, known values are")
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "ruleType: unsupported value %q; must be one of threshold_rule, promql_rule, anomaly_rule", r.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
35
pkg/types/ruletypes/seasonality.go
Normal file
35
pkg/types/ruletypes/seasonality.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Seasonality struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
SeasonalityHourly = Seasonality{valuer.NewString("hourly")}
|
||||
SeasonalityDaily = Seasonality{valuer.NewString("daily")}
|
||||
SeasonalityWeekly = Seasonality{valuer.NewString("weekly")}
|
||||
)
|
||||
|
||||
func (Seasonality) Enum() []any {
|
||||
return []any{
|
||||
SeasonalityHourly,
|
||||
SeasonalityDaily,
|
||||
SeasonalityWeekly,
|
||||
}
|
||||
}
|
||||
|
||||
func (s Seasonality) Validate() error {
|
||||
switch s {
|
||||
case SeasonalityHourly, SeasonalityDaily, SeasonalityWeekly:
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"condition.seasonality: unsupported value %q; must be one of hourly, daily, weekly",
|
||||
s.StringValue())
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,9 @@ func (r BasicRuleThresholds) GetRuleReceivers() []RuleReceivers {
|
||||
}
|
||||
|
||||
func (r BasicRuleThresholds) Validate() error {
|
||||
if len(r) == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.thresholds.spec: must have at least one threshold")
|
||||
}
|
||||
var errs []error
|
||||
for _, basicThreshold := range r {
|
||||
if err := basicThreshold.Validate(); err != nil {
|
||||
@@ -189,7 +192,7 @@ func sortThresholds(thresholds []BasicRuleThreshold) {
|
||||
targetI := thresholds[i].target(thresholds[i].TargetUnit) //for sorting we dont need rule unit
|
||||
targetJ := thresholds[j].target(thresholds[j].TargetUnit)
|
||||
|
||||
switch thresholds[i].CompareOperator {
|
||||
switch thresholds[i].CompareOperator.Normalize() {
|
||||
case ValueIsAbove, ValueAboveOrEq, ValueOutsideBounds:
|
||||
// For "above" operations, sort descending (higher values first)
|
||||
return targetI > targetJ
|
||||
@@ -234,16 +237,11 @@ func (b BasicRuleThreshold) Validate() error {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "target value cannot be nil"))
|
||||
}
|
||||
|
||||
switch b.CompareOperator {
|
||||
case ValueIsAbove, ValueIsBelow, ValueIsEq, ValueIsNotEq, ValueAboveOrEq, ValueBelowOrEq, ValueOutsideBounds:
|
||||
default:
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid compare operation: %s", b.CompareOperator.StringValue()))
|
||||
if err := b.CompareOperator.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
switch b.MatchType {
|
||||
case AtleastOnce, AllTheTimes, OnAverage, InTotal, Last:
|
||||
default:
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid match type: %s", b.MatchType.StringValue()))
|
||||
if err := b.MatchType.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
@@ -268,6 +266,33 @@ func PrepareSampleLabelsForRule(seriesLabels []*qbtypes.Label, thresholdName str
|
||||
return lb.Labels()
|
||||
}
|
||||
|
||||
// matchesCompareOp checks if a value matches the compare operator against target.
|
||||
func matchesCompareOp(op CompareOperator, value, target float64) bool {
|
||||
switch op {
|
||||
case ValueIsAbove:
|
||||
return value > target
|
||||
case ValueIsBelow:
|
||||
return value < target
|
||||
case ValueIsEq:
|
||||
return value == target
|
||||
case ValueIsNotEq:
|
||||
return value != target
|
||||
case ValueAboveOrEq:
|
||||
return value >= target
|
||||
case ValueBelowOrEq:
|
||||
return value <= target
|
||||
case ValueOutsideBounds:
|
||||
return math.Abs(value) >= target
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// negatesCompareOp checks if a value does NOT match the compare operator against target.
|
||||
func negatesCompareOp(op CompareOperator, value, target float64) bool {
|
||||
return !matchesCompareOp(op, value, target)
|
||||
}
|
||||
|
||||
func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, target float64) (Sample, bool) {
|
||||
var shouldAlert bool
|
||||
var alertSmpl Sample
|
||||
@@ -278,63 +303,35 @@ func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, ta
|
||||
return alertSmpl, false
|
||||
}
|
||||
|
||||
switch b.MatchType {
|
||||
// Normalize to canonical forms so evaluation uses simple == checks
|
||||
op := b.CompareOperator.Normalize()
|
||||
matchType := b.MatchType.Normalize()
|
||||
|
||||
switch matchType {
|
||||
case AtleastOnce:
|
||||
// If any sample matches the condition, the rule is firing.
|
||||
if b.CompareOperator == ValueIsAbove {
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value > target {
|
||||
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
|
||||
shouldAlert = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if b.CompareOperator == ValueIsBelow {
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value < target {
|
||||
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
|
||||
shouldAlert = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if b.CompareOperator == ValueIsEq {
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value == target {
|
||||
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
|
||||
shouldAlert = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if b.CompareOperator == ValueIsNotEq {
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value != target {
|
||||
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
|
||||
shouldAlert = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if b.CompareOperator == ValueOutsideBounds {
|
||||
for _, smpl := range series.Values {
|
||||
if math.Abs(smpl.Value) >= target {
|
||||
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
|
||||
shouldAlert = true
|
||||
break
|
||||
}
|
||||
for _, smpl := range series.Values {
|
||||
if matchesCompareOp(op, smpl.Value, target) {
|
||||
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
|
||||
shouldAlert = true
|
||||
break
|
||||
}
|
||||
}
|
||||
case AllTheTimes:
|
||||
// If all samples match the condition, the rule is firing.
|
||||
shouldAlert = true
|
||||
alertSmpl = Sample{Point: Point{V: target}, Metric: lbls}
|
||||
if b.CompareOperator == ValueIsAbove {
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value <= target {
|
||||
shouldAlert = false
|
||||
break
|
||||
}
|
||||
for _, smpl := range series.Values {
|
||||
if negatesCompareOp(op, smpl.Value, target) {
|
||||
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
|
||||
shouldAlert = false
|
||||
break
|
||||
}
|
||||
// use min value from the series
|
||||
if shouldAlert {
|
||||
}
|
||||
if shouldAlert {
|
||||
switch op {
|
||||
case ValueIsAbove, ValueAboveOrEq, ValueOutsideBounds:
|
||||
// use min value from the series
|
||||
var minValue = math.Inf(1)
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value < minValue {
|
||||
@@ -342,15 +339,8 @@ func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, ta
|
||||
}
|
||||
}
|
||||
alertSmpl = Sample{Point: Point{V: minValue}, Metric: lbls}
|
||||
}
|
||||
} else if b.CompareOperator == ValueIsBelow {
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value >= target {
|
||||
shouldAlert = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if shouldAlert {
|
||||
case ValueIsBelow, ValueBelowOrEq:
|
||||
// use max value from the series
|
||||
var maxValue = math.Inf(-1)
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value > maxValue {
|
||||
@@ -358,23 +348,8 @@ func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, ta
|
||||
}
|
||||
}
|
||||
alertSmpl = Sample{Point: Point{V: maxValue}, Metric: lbls}
|
||||
}
|
||||
} else if b.CompareOperator == ValueIsEq {
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value != target {
|
||||
shouldAlert = false
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if b.CompareOperator == ValueIsNotEq {
|
||||
for _, smpl := range series.Values {
|
||||
if smpl.Value == target {
|
||||
shouldAlert = false
|
||||
break
|
||||
}
|
||||
}
|
||||
// use any non-inf or nan value from the series
|
||||
if shouldAlert {
|
||||
case ValueIsNotEq:
|
||||
// use any non-inf and non-nan value from the series
|
||||
for _, smpl := range series.Values {
|
||||
if !math.IsInf(smpl.Value, 0) && !math.IsNaN(smpl.Value) {
|
||||
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
|
||||
@@ -382,14 +357,6 @@ func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, ta
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if b.CompareOperator == ValueOutsideBounds {
|
||||
for _, smpl := range series.Values {
|
||||
if math.Abs(smpl.Value) < target {
|
||||
alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lbls}
|
||||
shouldAlert = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
case OnAverage:
|
||||
// If the average of all samples matches the condition, the rule is firing.
|
||||
@@ -403,32 +370,10 @@ func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, ta
|
||||
}
|
||||
avg := sum / count
|
||||
alertSmpl = Sample{Point: Point{V: avg}, Metric: lbls}
|
||||
switch b.CompareOperator {
|
||||
case ValueIsAbove:
|
||||
if avg > target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueIsBelow:
|
||||
if avg < target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueIsEq:
|
||||
if avg == target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueIsNotEq:
|
||||
if avg != target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueOutsideBounds:
|
||||
if math.Abs(avg) >= target {
|
||||
shouldAlert = true
|
||||
}
|
||||
}
|
||||
shouldAlert = matchesCompareOp(op, avg, target)
|
||||
case InTotal:
|
||||
// If the sum of all samples matches the condition, the rule is firing.
|
||||
var sum float64
|
||||
|
||||
for _, smpl := range series.Values {
|
||||
if math.IsNaN(smpl.Value) || math.IsInf(smpl.Value, 0) {
|
||||
continue
|
||||
@@ -436,50 +381,12 @@ func (b BasicRuleThreshold) shouldAlertWithTarget(series *qbtypes.TimeSeries, ta
|
||||
sum += smpl.Value
|
||||
}
|
||||
alertSmpl = Sample{Point: Point{V: sum}, Metric: lbls}
|
||||
switch b.CompareOperator {
|
||||
case ValueIsAbove:
|
||||
if sum > target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueIsBelow:
|
||||
if sum < target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueIsEq:
|
||||
if sum == target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueIsNotEq:
|
||||
if sum != target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueOutsideBounds:
|
||||
if math.Abs(sum) >= target {
|
||||
shouldAlert = true
|
||||
}
|
||||
}
|
||||
shouldAlert = matchesCompareOp(op, sum, target)
|
||||
case Last:
|
||||
// If the last sample matches the condition, the rule is firing.
|
||||
shouldAlert = false
|
||||
alertSmpl = Sample{Point: Point{V: series.Values[len(series.Values)-1].Value}, Metric: lbls}
|
||||
switch b.CompareOperator {
|
||||
case ValueIsAbove:
|
||||
if series.Values[len(series.Values)-1].Value > target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueIsBelow:
|
||||
if series.Values[len(series.Values)-1].Value < target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueIsEq:
|
||||
if series.Values[len(series.Values)-1].Value == target {
|
||||
shouldAlert = true
|
||||
}
|
||||
case ValueIsNotEq:
|
||||
if series.Values[len(series.Values)-1].Value != target {
|
||||
shouldAlert = true
|
||||
}
|
||||
}
|
||||
lastValue := series.Values[len(series.Values)-1].Value
|
||||
alertSmpl = Sample{Point: Point{V: lastValue}, Metric: lbls}
|
||||
shouldAlert = matchesCompareOp(op, lastValue, target)
|
||||
}
|
||||
return alertSmpl, shouldAlert
|
||||
}
|
||||
|
||||
1720
pkg/types/ruletypes/validate_test.go
Normal file
1720
pkg/types/ruletypes/validate_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -243,6 +243,7 @@ type Store interface {
|
||||
|
||||
// Service Account Role
|
||||
CreateServiceAccountRole(context.Context, *ServiceAccountRole) error
|
||||
DeleteServiceAccountRoles(context.Context, valuer.UUID) error
|
||||
DeleteServiceAccountRole(context.Context, valuer.UUID, valuer.UUID) error
|
||||
|
||||
// Service Account Factor API Key
|
||||
|
||||
@@ -133,11 +133,11 @@ def test_get_user_roles(
|
||||
assert "type" in role
|
||||
|
||||
|
||||
def test_assign_additional_role(
|
||||
def test_assign_role_replaces_previous(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""Verify POST /api/v2/users/{id}/roles assigns an additional role."""
|
||||
"""Verify POST /api/v2/users/{id}/roles replaces existing role."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
@@ -166,8 +166,8 @@ def test_assign_additional_role(
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
roles = response.json()["data"]
|
||||
names = {r["name"] for r in roles}
|
||||
assert "signoz-admin" in names
|
||||
assert "signoz-editor" in names
|
||||
assert "signoz-admin" not in names
|
||||
|
||||
|
||||
def test_get_users_by_role(
|
||||
@@ -253,9 +253,7 @@ def test_remove_role(
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
roles_after = response.json()["data"]
|
||||
names = {r["name"] for r in roles_after}
|
||||
assert "signoz-editor" not in names
|
||||
assert "signoz-admin" in names
|
||||
assert len(roles_after) == 0
|
||||
|
||||
|
||||
def test_user_with_roles_reflects_change(
|
||||
@@ -282,8 +280,7 @@ def test_user_with_roles_reflects_change(
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
data = response.json()["data"]
|
||||
role_names = {ur["role"]["name"] for ur in data["userRoles"]}
|
||||
assert "signoz-admin" in role_names
|
||||
assert "signoz-editor" not in role_names
|
||||
assert len(role_names) == 0
|
||||
|
||||
|
||||
def test_admin_cannot_assign_role_to_self(
|
||||
|
||||
@@ -48,7 +48,7 @@ def test_assign_role_to_service_account(
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""POST /{id}/roles assigns a new role, verify via GET."""
|
||||
"""POST /{id}/roles replaces existing role, verify via GET."""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# create service account with viewer role
|
||||
@@ -56,7 +56,7 @@ def test_assign_role_to_service_account(
|
||||
signoz, token, "sa-assign-role", role="signoz-viewer"
|
||||
)
|
||||
|
||||
# assign editor role additionally
|
||||
# assign editor role (replaces viewer)
|
||||
editor_role_id = find_role_by_name(signoz, token, "signoz-editor")
|
||||
assign_resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
@@ -68,7 +68,7 @@ def test_assign_role_to_service_account(
|
||||
)
|
||||
assert assign_resp.status_code == HTTPStatus.NO_CONTENT, assign_resp.text
|
||||
|
||||
# verify both roles are present
|
||||
# verify only editor role is present (viewer was replaced)
|
||||
roles_resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"
|
||||
@@ -78,8 +78,9 @@ def test_assign_role_to_service_account(
|
||||
)
|
||||
assert roles_resp.status_code == HTTPStatus.OK, roles_resp.text
|
||||
role_names = [r["name"] for r in roles_resp.json()["data"]]
|
||||
assert "signoz-viewer" in role_names
|
||||
assert len(role_names) == 1
|
||||
assert "signoz-editor" in role_names
|
||||
assert "signoz-viewer" not in role_names
|
||||
|
||||
|
||||
def test_assign_role_idempotent(
|
||||
@@ -87,7 +88,7 @@ def test_assign_role_idempotent(
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""POST same role twice succeeds (store uses ON CONFLICT DO NOTHING)."""
|
||||
"""POST same role twice succeeds (replace with same role is idempotent)."""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
service_account_id = create_service_account(
|
||||
signoz, token, "sa-role-idempotent", role="signoz-viewer"
|
||||
@@ -118,6 +119,66 @@ def test_assign_role_idempotent(
|
||||
assert role_names.count("signoz-viewer") == 1
|
||||
|
||||
|
||||
def test_assign_role_replaces_access(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""After role replacement, SA loses old permissions and gains new ones."""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# create SA with viewer role and an API key
|
||||
service_account_id, api_key = create_service_account_with_key(
|
||||
signoz, token, "sa-role-replace-access", role="signoz-viewer"
|
||||
)
|
||||
|
||||
# viewer should get 403 on admin-only endpoint
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(SERVICE_ACCOUNT_BASE),
|
||||
headers={"SIGNOZ-API-KEY": api_key},
|
||||
timeout=5,
|
||||
)
|
||||
assert (
|
||||
resp.status_code == HTTPStatus.FORBIDDEN
|
||||
), f"Expected 403 for viewer on admin endpoint, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# assign admin role (replaces viewer)
|
||||
admin_role_id = find_role_by_name(signoz, token, "signoz-admin")
|
||||
assign_resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"
|
||||
),
|
||||
json={"id": admin_role_id},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert assign_resp.status_code == HTTPStatus.NO_CONTENT, assign_resp.text
|
||||
|
||||
# SA should now have admin access
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(SERVICE_ACCOUNT_BASE),
|
||||
headers={"SIGNOZ-API-KEY": api_key},
|
||||
timeout=5,
|
||||
)
|
||||
assert (
|
||||
resp.status_code == HTTPStatus.OK
|
||||
), f"Expected 200 for admin on admin endpoint, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# verify only admin role is present
|
||||
roles_resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert roles_resp.status_code == HTTPStatus.OK, roles_resp.text
|
||||
role_names = [r["name"] for r in roles_resp.json()["data"]]
|
||||
assert len(role_names) == 1
|
||||
assert "signoz-admin" in role_names
|
||||
assert "signoz-viewer" not in role_names
|
||||
|
||||
|
||||
def test_remove_role_from_service_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
|
||||
Reference in New Issue
Block a user