mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-19 16:30:31 +01:00
Compare commits
7 Commits
feat/cloud
...
fix/span-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94ea514e7c | ||
|
|
b48851e487 | ||
|
|
279a71c5b3 | ||
|
|
7e63e35113 | ||
|
|
d5a50fe456 | ||
|
|
885b41356a | ||
|
|
b653c69e29 |
@@ -80,6 +80,15 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
fineGrainedAuthz := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureUseFineGrainedAuthz, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureUseFineGrainedAuthz.String()),
|
||||
Active: fineGrainedAuthz,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
@@ -10,6 +10,13 @@ export default defineConfig({
|
||||
signoz: {
|
||||
input: {
|
||||
target: '../docs/api/openapi.yml',
|
||||
// Perses' `common.JSONRef` (used by `DashboardGridItem.content`) has a
|
||||
// field tagged `json:"$ref"`, so our spec contains a property literally
|
||||
// named `$ref`.
|
||||
// Orval v8's validator (`@scalar/openapi-parser`) treats every `$ref` key
|
||||
// as a JSON Reference and aborts with `INVALID_REFERENCE` when the value isn't a URI string.
|
||||
// Safe to disable: yes, the spec is generated by `cmd/openapi.go` and gated by backend CI, not hand-edited.
|
||||
unsafeDisableValidation: true,
|
||||
},
|
||||
output: {
|
||||
target: './src/api/generated/services',
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
export interface AlertmanagertypesChannelDTO {
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -144,6 +144,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
loading={loading}
|
||||
notFoundContent={notFoundContent}
|
||||
options={options}
|
||||
optionFilterProp="label"
|
||||
optionRender={(option): JSX.Element => (
|
||||
<Checkbox
|
||||
checked={value.includes(option.value as string)}
|
||||
@@ -162,6 +163,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
return (
|
||||
<Select
|
||||
id={id}
|
||||
showSearch
|
||||
value={value || undefined}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
@@ -170,6 +172,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
loading={loading}
|
||||
notFoundContent={notFoundContent}
|
||||
options={options}
|
||||
optionFilterProp="label"
|
||||
getPopupContainer={getPopupContainer}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
@@ -10,4 +10,5 @@ export enum FeatureKeys {
|
||||
ONBOARDING_V3 = 'onboarding_v3',
|
||||
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
|
||||
USE_JSON_BODY = 'use_json_body',
|
||||
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useQueryClient } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { BellDot, CircleAlert, ExternalLink, Save } from '@signozhq/icons';
|
||||
import { Button, FormInstance, Modal, SelectProps } from 'antd';
|
||||
import { Button, FormInstance, SelectProps } from 'antd';
|
||||
import { ConfirmDialog } from '@signozhq/ui/dialog';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
@@ -162,6 +163,7 @@ function FormAlertRules({
|
||||
const alertTypeFromURL = urlQuery.get(QueryParams.ruleType);
|
||||
|
||||
const [detectionMethod, setDetectionMethod] = useState<string | null>(null);
|
||||
const [isConfirmSaveOpen, setIsConfirmSaveOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEqual(currentQuery.unit, yAxisUnit)) {
|
||||
@@ -577,19 +579,16 @@ function FormAlertRules({
|
||||
});
|
||||
|
||||
// invalidate rule in cache
|
||||
ruleCache.invalidateQueries([
|
||||
await ruleCache.invalidateQueries([
|
||||
REACT_QUERY_KEY.ALERT_RULE_DETAILS,
|
||||
`${ruleId}`,
|
||||
]);
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
setTimeout(() => {
|
||||
urlQuery.delete(QueryParams.compositeQuery);
|
||||
urlQuery.delete(QueryParams.panelTypes);
|
||||
urlQuery.delete(QueryParams.ruleId);
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
safeNavigate(`${ROUTES.LIST_ALL_ALERT}?${urlQuery.toString()}`);
|
||||
}, 2000);
|
||||
urlQuery.delete(QueryParams.compositeQuery);
|
||||
urlQuery.delete(QueryParams.panelTypes);
|
||||
urlQuery.delete(QueryParams.ruleId);
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
safeNavigate(`${ROUTES.LIST_ALL_ALERT}?${urlQuery.toString()}`);
|
||||
} catch (e) {
|
||||
const apiError = convertToApiError(e as AxiosError<RenderErrorResponseDTO>);
|
||||
logData = {
|
||||
@@ -625,24 +624,9 @@ function FormAlertRules({
|
||||
urlQuery,
|
||||
]);
|
||||
|
||||
const onSaveHandler = useCallback(async () => {
|
||||
const content = (
|
||||
<Typography.Text>
|
||||
{' '}
|
||||
{t('confirm_save_content_part1')}{' '}
|
||||
<QueryTypeTag queryType={currentQuery.queryType} />{' '}
|
||||
{t('confirm_save_content_part2')}
|
||||
</Typography.Text>
|
||||
);
|
||||
Modal.confirm({
|
||||
icon: <CircleAlert size="md" />,
|
||||
title: t('confirm_save_title'),
|
||||
centered: true,
|
||||
content,
|
||||
onOk: saveRule,
|
||||
className: 'create-alert-modal',
|
||||
});
|
||||
}, [t, saveRule, currentQuery]);
|
||||
const onSaveHandler = useCallback(() => {
|
||||
setIsConfirmSaveOpen(true);
|
||||
}, []);
|
||||
|
||||
const onTestRuleHandler = useCallback(async () => {
|
||||
if (!isFormValid()) {
|
||||
@@ -988,6 +972,27 @@ function FormAlertRules({
|
||||
</ButtonContainer>
|
||||
</MainFormContainer>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={isConfirmSaveOpen}
|
||||
onOpenChange={setIsConfirmSaveOpen}
|
||||
title={t('confirm_save_title')}
|
||||
titleIcon={<CircleAlert size={14} />}
|
||||
confirmText="OK"
|
||||
confirmColor="primary"
|
||||
onConfirm={async (): Promise<boolean> => {
|
||||
await saveRule();
|
||||
return true;
|
||||
}}
|
||||
onCancel={() => setIsConfirmSaveOpen(false)}
|
||||
width="narrow"
|
||||
>
|
||||
<Typography.Text>
|
||||
{t('confirm_save_content_part1')}{' '}
|
||||
<QueryTypeTag queryType={currentQuery.queryType} />{' '}
|
||||
{t('confirm_save_content_part2')}
|
||||
</Typography.Text>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
.actionContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
display: flex;
|
||||
padding: 8px;
|
||||
height: unset;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
|
||||
:global(.ant-icon-btn) {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
composes: actionBtn;
|
||||
color: var(--danger-background) !important;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.deleteBtn:hover {
|
||||
background-color: color-mix(in srgb, var(--l1-foreground) 12%, transparent);
|
||||
}
|
||||
|
||||
.deleteModal :global(.ant-modal-confirm-body) {
|
||||
align-items: center;
|
||||
}
|
||||
@@ -745,52 +745,6 @@
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 0px;
|
||||
|
||||
.dashboard-action-content {
|
||||
.section-1 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
padding: 8px;
|
||||
height: unset;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
|
||||
.ant-icon-btn {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-2 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
|
||||
.ant-typography {
|
||||
display: flex;
|
||||
padding: 12px 8px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--bg-cherry-400) !important;
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ import {
|
||||
filterDashboards,
|
||||
} from './utils';
|
||||
|
||||
import styles from './DashboardActions.module.scss';
|
||||
import './DashboardList.styles.scss';
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
@@ -436,57 +437,53 @@ function DashboardsList(): JSX.Element {
|
||||
{action && (
|
||||
<Popover
|
||||
content={
|
||||
<div className="dashboard-action-content">
|
||||
<section className="section-1">
|
||||
<Button
|
||||
type="text"
|
||||
className="action-btn"
|
||||
icon={<Expand size={12} />}
|
||||
onClick={onClickHandler}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className="action-btn"
|
||||
icon={<SquareArrowOutUpRight size={12} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
openInNewTab(getLink());
|
||||
}}
|
||||
>
|
||||
Open in New Tab
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className="action-btn"
|
||||
icon={<Link2 size={12} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setCopy(getAbsoluteUrl(getLink()));
|
||||
}}
|
||||
>
|
||||
Copy Link
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className="action-btn"
|
||||
icon={<FileJson size={12} />}
|
||||
onClick={handleJsonExport}
|
||||
>
|
||||
Export JSON
|
||||
</Button>
|
||||
</section>
|
||||
<section className="section-2">
|
||||
<DeleteButton
|
||||
name={dashboard.name}
|
||||
id={dashboard.id}
|
||||
isLocked={dashboard.isLocked}
|
||||
createdBy={dashboard.createdBy}
|
||||
/>
|
||||
</section>
|
||||
<div className={styles.actionContent}>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.actionBtn}
|
||||
icon={<Expand size={12} />}
|
||||
onClick={onClickHandler}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.actionBtn}
|
||||
icon={<SquareArrowOutUpRight size={12} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
openInNewTab(getLink());
|
||||
}}
|
||||
>
|
||||
Open in New Tab
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.actionBtn}
|
||||
icon={<Link2 size={12} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setCopy(getAbsoluteUrl(getLink()));
|
||||
}}
|
||||
>
|
||||
Copy Link
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.actionBtn}
|
||||
icon={<FileJson size={12} />}
|
||||
onClick={handleJsonExport}
|
||||
>
|
||||
Export JSON
|
||||
</Button>
|
||||
<DeleteButton
|
||||
name={dashboard.name}
|
||||
id={dashboard.id}
|
||||
isLocked={dashboard.isLocked}
|
||||
createdBy={dashboard.createdBy}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
placement="bottomRight"
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
.delete-modal {
|
||||
.ant-modal-confirm-body {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background-color: color-mix(in srgb, var(--l1-foreground) 12%, transparent);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { CircleAlert, Trash2 } from '@signozhq/icons';
|
||||
import { Flex, Modal, Tooltip } from 'antd';
|
||||
import { Button, Modal, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -12,10 +12,8 @@ import history from 'lib/history';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import styles from '../DashboardActions.module.scss';
|
||||
import { Data } from '../DashboardsList';
|
||||
import { TableLinkText } from './styles';
|
||||
|
||||
import './DeleteButton.styles.scss';
|
||||
|
||||
interface DeleteButtonProps {
|
||||
createdBy: string;
|
||||
@@ -85,7 +83,7 @@ export function DeleteButton({
|
||||
},
|
||||
},
|
||||
centered: true,
|
||||
className: 'delete-modal',
|
||||
className: styles.deleteModal,
|
||||
});
|
||||
}, [
|
||||
modal,
|
||||
@@ -109,10 +107,16 @@ export function DeleteButton({
|
||||
return '';
|
||||
};
|
||||
|
||||
const isDisabled = isLocked || (user.role === USER_ROLES.VIEWER && !isAuthor);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip placement="left" title={getDeleteTooltipContent()}>
|
||||
<TableLinkText
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.deleteBtn}
|
||||
icon={<Trash2 size={12} />}
|
||||
disabled={isDisabled}
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -120,13 +124,9 @@ export function DeleteButton({
|
||||
openConfirmationDialog();
|
||||
}
|
||||
}}
|
||||
className="delete-btn"
|
||||
disabled={isLocked || (user.role === USER_ROLES.VIEWER && !isAuthor)}
|
||||
>
|
||||
<Flex align="center" justify="center" gap={4}>
|
||||
<Trash2 size={14} /> Delete dashboard
|
||||
</Flex>
|
||||
</TableLinkText>
|
||||
Delete Dashboard
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{contextHolder}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const TableLinkText = styled.span<{ disabled: boolean }>`
|
||||
color: var(--destructive);
|
||||
cursor: ${({ disabled }): string => (disabled ? 'not-allowed' : 'pointer')};
|
||||
${({ disabled }): string => (disabled ? 'opacity: 0.5;' : '')}
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
`;
|
||||
@@ -16,35 +16,31 @@ enum SpanScope {
|
||||
ENTRYPOINT_SPANS = 'entrypoint_spans',
|
||||
}
|
||||
|
||||
interface SpanFilterConfig {
|
||||
key: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface SpanScopeSelectorProps {
|
||||
onChange?: (value: TagFilter) => void;
|
||||
query?: IBuilderQuery;
|
||||
skipQueryBuilderRedirect?: boolean;
|
||||
}
|
||||
|
||||
const SPAN_FILTER_CONFIG: Record<SpanScope, SpanFilterConfig | null> = {
|
||||
const SPAN_FILTER_KEY: Record<SpanScope, string | null> = {
|
||||
[SpanScope.ALL_SPANS]: null,
|
||||
[SpanScope.ROOT_SPANS]: {
|
||||
key: 'isRoot',
|
||||
type: 'spanSearchScope',
|
||||
},
|
||||
[SpanScope.ENTRYPOINT_SPANS]: {
|
||||
key: 'isEntryPoint',
|
||||
type: 'spanSearchScope',
|
||||
},
|
||||
[SpanScope.ROOT_SPANS]: 'isRoot',
|
||||
[SpanScope.ENTRYPOINT_SPANS]: 'isEntryPoint',
|
||||
};
|
||||
|
||||
const createFilterItem = (config: SpanFilterConfig): TagFilterItem => ({
|
||||
const SCOPE_FILTER_KEYS = Object.values(SPAN_FILTER_KEY).filter(
|
||||
(key): key is string => key !== null,
|
||||
);
|
||||
|
||||
const isScopeFilter = (filter: TagFilterItem, key: string): boolean =>
|
||||
filter.key?.key === key && String(filter.value) === 'true';
|
||||
|
||||
const createFilterItem = (key: string): TagFilterItem => ({
|
||||
id: uuid().slice(0, 8),
|
||||
key: {
|
||||
key: config.key,
|
||||
key,
|
||||
dataType: undefined,
|
||||
type: config?.type,
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: 'true',
|
||||
@@ -70,12 +66,7 @@ function SpanScopeSelector({
|
||||
filters: TagFilterItem[] = [],
|
||||
): SpanScope => {
|
||||
const hasFilter = (key: string): boolean =>
|
||||
filters?.some(
|
||||
(filter) =>
|
||||
filter.key?.type === 'spanSearchScope' &&
|
||||
filter.key.key === key &&
|
||||
filter.value === 'true',
|
||||
);
|
||||
filters?.some((filter) => isScopeFilter(filter, key));
|
||||
|
||||
if (hasFilter('isRoot')) {
|
||||
return SpanScope.ROOT_SPANS;
|
||||
@@ -113,28 +104,21 @@ function SpanScopeSelector({
|
||||
|
||||
const nonScopeFilters = currentFilters.filter(
|
||||
(filter) =>
|
||||
!(
|
||||
filter.key?.type === 'spanSearchScope' &&
|
||||
(filter.key.key === 'isRoot' || filter.key.key === 'isEntryPoint')
|
||||
),
|
||||
!SCOPE_FILTER_KEYS.some((scopeKey) => isScopeFilter(filter, scopeKey)),
|
||||
);
|
||||
|
||||
const config = SPAN_FILTER_CONFIG[newScope];
|
||||
const newScopeFilter = config !== null ? [createFilterItem(config)] : [];
|
||||
const scopeKey = SPAN_FILTER_KEY[newScope];
|
||||
const newScopeFilter = scopeKey !== null ? [createFilterItem(scopeKey)] : [];
|
||||
|
||||
return [...nonScopeFilters, ...newScopeFilter];
|
||||
};
|
||||
|
||||
const keysToRemove = Object.values(SPAN_FILTER_CONFIG)
|
||||
.map((config) => config?.key)
|
||||
.filter((key): key is string => typeof key === 'string');
|
||||
|
||||
newQuery.builder.queryData = newQuery.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
filter: {
|
||||
expression: removeKeysFromExpression(
|
||||
item.filter?.expression ?? '',
|
||||
keysToRemove,
|
||||
SCOPE_FILTER_KEYS,
|
||||
),
|
||||
},
|
||||
filters: {
|
||||
|
||||
@@ -20,12 +20,16 @@ import SpanScopeSelector from '../SpanScopeSelector';
|
||||
|
||||
const mockRedirectWithQueryBuilderData = jest.fn();
|
||||
|
||||
const SCOPE_KEYS = ['isRoot', 'isEntryPoint'];
|
||||
const isScopeFilter = (filter: TagFilterItem): boolean =>
|
||||
SCOPE_KEYS.includes(filter.key?.key ?? '') && String(filter.value) === 'true';
|
||||
|
||||
// Helper to create filter items
|
||||
const createSpanScopeFilter = (key: string): TagFilterItem => ({
|
||||
id: 'span-filter',
|
||||
key: {
|
||||
key,
|
||||
type: 'spanSearchScope',
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: 'true',
|
||||
@@ -143,7 +147,6 @@ describe('SpanScopeSelector', () => {
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({
|
||||
key: expectedKey,
|
||||
type: 'spanSearchScope',
|
||||
}),
|
||||
op: '=',
|
||||
value: 'true',
|
||||
@@ -162,11 +165,7 @@ describe('SpanScopeSelector', () => {
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalled();
|
||||
const updatedQuery = mockRedirectWithQueryBuilderData.mock.calls[0][0];
|
||||
const filters = updatedQuery.builder.queryData[0].filters.items;
|
||||
expect(filters).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ type: 'spanSearchScope' }),
|
||||
}),
|
||||
);
|
||||
expect(filters.some(isScopeFilter)).toBe(false);
|
||||
});
|
||||
|
||||
it('should add isRoot filter when selecting ROOT_SPANS', async () => {
|
||||
@@ -206,6 +205,27 @@ describe('SpanScopeSelector', () => {
|
||||
await expect(screen.findByText(expectedText)).resolves.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
// Round-trip from filter.expression can deserialize the value as a boolean
|
||||
// `true` (unquoted in the expression) instead of the string `'true'` produced
|
||||
// by the dropdown. The dropdown must still recognize that as the scope filter.
|
||||
it.each([
|
||||
['Root Spans', 'isRoot'],
|
||||
['Entrypoint Spans', 'isEntryPoint'],
|
||||
])(
|
||||
'should initialize with %s selected when %s = true (boolean value)',
|
||||
async (expectedText, filterKey) => {
|
||||
const booleanScopeFilter: TagFilterItem = {
|
||||
id: 'span-filter',
|
||||
key: { key: filterKey, type: '' },
|
||||
op: '=',
|
||||
value: true as unknown as string,
|
||||
};
|
||||
const queryWithFilter = createQueryWithFilters([booleanScopeFilter]);
|
||||
renderWithContext(queryWithFilter, undefined, defaultQueryBuilderQuery);
|
||||
await expect(screen.findByText(expectedText)).resolves.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('when onChange and query props are provided', () => {
|
||||
@@ -233,9 +253,7 @@ describe('SpanScopeSelector', () => {
|
||||
expect(items).toContainEqual(nonScopeItem);
|
||||
});
|
||||
|
||||
const scopeFiltersInPayload = items.filter(
|
||||
(filter) => filter.key?.type === 'spanSearchScope',
|
||||
);
|
||||
const scopeFiltersInPayload = items.filter(isScopeFilter);
|
||||
|
||||
if (expectedScopeKey) {
|
||||
expect(scopeFiltersInPayload).toHaveLength(1);
|
||||
@@ -434,9 +452,7 @@ describe('SpanScopeSelector', () => {
|
||||
items: [],
|
||||
};
|
||||
// Count non-scope filters
|
||||
const nonScopeFilters = items.filter(
|
||||
(filter) => filter.key?.type !== 'spanSearchScope',
|
||||
);
|
||||
const nonScopeFilters = items.filter((filter) => !isScopeFilter(filter));
|
||||
expect(nonScopeFilters).toHaveLength(1);
|
||||
|
||||
expect(nonScopeFilters).toContainEqual(
|
||||
|
||||
@@ -21,14 +21,13 @@ import {
|
||||
buildRoleUpdatePermission,
|
||||
} from 'hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
|
||||
|
||||
import type { AuthzResources } from '../utils';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
import { RoleType } from 'types/roles';
|
||||
import { handleApiError, toAPIError } from 'utils/errorUtils';
|
||||
|
||||
@@ -54,7 +53,8 @@ function RoleDetailsPage(): JSX.Element {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { activeLicense, isFetchingActiveLicense } = useAppContext();
|
||||
const { isRolesEnabled, isLoading: isRolesGateLoading } =
|
||||
useRolesFeatureGate();
|
||||
|
||||
const authzResources: AuthzResources = permissionsType.data;
|
||||
|
||||
@@ -161,7 +161,7 @@ function RoleDetailsPage(): JSX.Element {
|
||||
},
|
||||
});
|
||||
|
||||
if (isFetchingActiveLicense) {
|
||||
if (isRolesGateLoading) {
|
||||
return (
|
||||
<div className="role-details-page">
|
||||
<Skeleton
|
||||
@@ -173,7 +173,7 @@ function RoleDetailsPage(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
if (activeLicense?.status !== LicenseStatus.VALID) {
|
||||
if (!isRolesEnabled) {
|
||||
return <Redirect to={ROUTES.ROLES_SETTINGS} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import {
|
||||
defaultFeatureFlags,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
invalidLicense,
|
||||
@@ -254,6 +256,34 @@ describe('RoleDetailsPage', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to the roles list when fine-grained authz flag is inactive', async () => {
|
||||
render(
|
||||
<Switch>
|
||||
<Route path="/settings/roles/:roleId">
|
||||
<RoleDetailsPage />
|
||||
</Route>
|
||||
<Route path="/settings/roles" exact>
|
||||
<div data-testid="roles-list-redirect-target" />
|
||||
</Route>
|
||||
</Switch>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
appContextOverrides: {
|
||||
featureFlags: defaultFeatureFlags.map((f) =>
|
||||
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
|
||||
? { ...f, active: false }
|
||||
: f,
|
||||
),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('roles-list-redirect-target'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('permission side panel', () => {
|
||||
beforeEach(() => {
|
||||
// Both hooks mocked so data renders synchronously — no React Query scheduler or MSW round-trip.
|
||||
|
||||
@@ -9,11 +9,10 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { RoleListPermission } from 'hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
import { RoleType } from 'types/roles';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
@@ -32,8 +31,7 @@ interface RolesListingTableProps {
|
||||
function RolesListingTable({
|
||||
searchQuery,
|
||||
}: RolesListingTableProps): JSX.Element {
|
||||
const { activeLicense } = useAppContext();
|
||||
const isValidLicense = activeLicense?.status === LicenseStatus.VALID;
|
||||
const { isRolesEnabled } = useRolesFeatureGate();
|
||||
|
||||
const { permissions: listPerms, isLoading: isAuthZLoading } = useAuthZ([
|
||||
RoleListPermission,
|
||||
@@ -208,11 +206,11 @@ function RolesListingTable({
|
||||
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
|
||||
<div
|
||||
key={role.id}
|
||||
className={`roles-table-row${isValidLicense ? ' roles-table-row--clickable' : ''}`}
|
||||
role={isValidLicense ? 'button' : undefined}
|
||||
tabIndex={isValidLicense ? 0 : undefined}
|
||||
className={`roles-table-row${isRolesEnabled ? ' roles-table-row--clickable' : ''}`}
|
||||
role={isRolesEnabled ? 'button' : undefined}
|
||||
tabIndex={isRolesEnabled ? 0 : undefined}
|
||||
onClick={
|
||||
isValidLicense
|
||||
isRolesEnabled
|
||||
? (): void => {
|
||||
if (role.id) {
|
||||
navigateToRole(role.id, role.name);
|
||||
@@ -221,7 +219,7 @@ function RolesListingTable({
|
||||
: undefined
|
||||
}
|
||||
onKeyDown={
|
||||
isValidLicense
|
||||
isRolesEnabled
|
||||
? (e): void => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && role.id) {
|
||||
navigateToRole(role.id, role.name);
|
||||
|
||||
@@ -4,8 +4,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { RoleCreatePermission } from 'hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
|
||||
|
||||
import CreateRoleModal from './RolesComponents/CreateRoleModal';
|
||||
import RolesListingTable from './RolesComponents/RolesListingTable';
|
||||
@@ -15,8 +14,7 @@ import './RolesSettings.styles.scss';
|
||||
function RolesSettings(): JSX.Element {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const { activeLicense } = useAppContext();
|
||||
const isValidLicense = activeLicense?.status === LicenseStatus.VALID;
|
||||
const { isRolesEnabled } = useRolesFeatureGate();
|
||||
|
||||
return (
|
||||
<div className="roles-settings" data-testid="roles-settings">
|
||||
@@ -42,7 +40,7 @@ function RolesSettings(): JSX.Element {
|
||||
value={searchQuery}
|
||||
onChange={(e): void => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{isValidLicense && (
|
||||
{isRolesEnabled && (
|
||||
<AuthZTooltip checks={[RoleCreatePermission]}>
|
||||
<Button
|
||||
variant="solid"
|
||||
|
||||
@@ -4,7 +4,13 @@ import {
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import {
|
||||
defaultFeatureFlags,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from 'tests/test-utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { invalidLicense, mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
|
||||
@@ -176,6 +182,30 @@ describe('RolesSettings', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('hides the create button and disables row clicks when fine-grained authz flag is inactive', async () => {
|
||||
render(<RolesSettings />, undefined, {
|
||||
appContextOverrides: {
|
||||
featureFlags: defaultFeatureFlags.map((f) =>
|
||||
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
|
||||
? { ...f, active: false }
|
||||
: f,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /custom role/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const rows = document.querySelectorAll('.roles-table-row');
|
||||
rows.forEach((row) => {
|
||||
expect(row).not.toHaveClass('roles-table-row--clickable');
|
||||
expect(row.getAttribute('role')).not.toBe('button');
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the create button and disables row clicks when license is not valid', async () => {
|
||||
render(<RolesSettings />, undefined, {
|
||||
appContextOverrides: { activeLicense: invalidLicense },
|
||||
|
||||
27
frontend/src/hooks/useRolesFeatureGate.ts
Normal file
27
frontend/src/hooks/useRolesFeatureGate.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
|
||||
export const useRolesFeatureGate = (): {
|
||||
isRolesEnabled: boolean;
|
||||
isLoading: boolean;
|
||||
} => {
|
||||
const {
|
||||
activeLicense,
|
||||
featureFlags,
|
||||
isFetchingActiveLicense,
|
||||
isFetchingFeatureFlags,
|
||||
} = useAppContext();
|
||||
|
||||
const isValidLicense = activeLicense?.status === LicenseStatus.VALID;
|
||||
const isFineGrainedAuthzEnabled =
|
||||
featureFlags?.find((f) => f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ)
|
||||
?.active ?? false;
|
||||
|
||||
return {
|
||||
isRolesEnabled: isValidLicense && isFineGrainedAuthzEnabled,
|
||||
isLoading:
|
||||
(isFetchingActiveLicense && !activeLicense) ||
|
||||
(isFetchingFeatureFlags && !featureFlags),
|
||||
};
|
||||
};
|
||||
@@ -105,6 +105,59 @@ jest.mock('react-i18next', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const defaultFeatureFlags = [
|
||||
{ name: FeatureKeys.SSO, active: true, usage: 0, usage_limit: -1, route: '' },
|
||||
{
|
||||
name: FeatureKeys.USE_SPAN_METRICS,
|
||||
active: false,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.GATEWAY,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.PREMIUM_SUPPORT,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.ANOMALY_DETECTION,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.ONBOARDING,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.CHAT_SUPPORT,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.USE_FINE_GRAINED_AUTHZ,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
];
|
||||
|
||||
export function getAppContextMock(
|
||||
role: string,
|
||||
appContextOverrides?: Partial<IAppContext>,
|
||||
@@ -168,57 +221,7 @@ export function getAppContextMock(
|
||||
hasEditPermission: role === USER_ROLES.ADMIN || role === USER_ROLES.EDITOR,
|
||||
isFetchingUser: false,
|
||||
userFetchError: null,
|
||||
featureFlags: [
|
||||
{
|
||||
name: FeatureKeys.SSO,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.USE_SPAN_METRICS,
|
||||
active: false,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.GATEWAY,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.PREMIUM_SUPPORT,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.ANOMALY_DETECTION,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.ONBOARDING,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.CHAT_SUPPORT,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
],
|
||||
featureFlags: defaultFeatureFlags,
|
||||
isFetchingFeatureFlags: false,
|
||||
featureFlagsFetchError: null,
|
||||
hostsData: null,
|
||||
|
||||
@@ -9,7 +9,8 @@ var (
|
||||
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
|
||||
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
|
||||
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
|
||||
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
|
||||
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
|
||||
FeatureUseFineGrainedAuthz = featuretypes.MustNewName("use_fine_grained_authz")
|
||||
)
|
||||
|
||||
func MustNewRegistry() featuretypes.Registry {
|
||||
@@ -70,6 +71,14 @@ func MustNewRegistry() featuretypes.Registry {
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureUseFineGrainedAuthz,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
Stage: featuretypes.StageExperimental,
|
||||
Description: "Controls whether fine-grained authorization is enabled",
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -1784,6 +1784,15 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
fineGrainedAuthz := aH.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureUseFineGrainedAuthz, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureUseFineGrainedAuthz.String()),
|
||||
Active: fineGrainedAuthz,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
@@ -2,9 +2,11 @@ package sqlrulestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
@@ -14,10 +16,14 @@ import (
|
||||
|
||||
type maintenance struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewMaintenanceStore(store sqlstore.SQLStore) ruletypes.MaintenanceStore {
|
||||
return &maintenance{sqlstore: store}
|
||||
func NewMaintenanceStore(store sqlstore.SQLStore, providerSettings factory.ProviderSettings) ruletypes.MaintenanceStore {
|
||||
return &maintenance{
|
||||
sqlstore: store,
|
||||
logger: providerSettings.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string) ([]*ruletypes.PlannedMaintenance, error) {
|
||||
@@ -35,7 +41,11 @@ func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string)
|
||||
|
||||
gettablePlannedMaintenance := make([]*ruletypes.PlannedMaintenance, 0)
|
||||
for _, gettableMaintenancesRule := range gettableMaintenancesRules {
|
||||
gettablePlannedMaintenance = append(gettablePlannedMaintenance, gettableMaintenancesRule.ToPlannedMaintenance())
|
||||
m := gettableMaintenancesRule.ToPlannedMaintenance()
|
||||
gettablePlannedMaintenance = append(gettablePlannedMaintenance, m)
|
||||
if m.HasScheduleRecurrenceBoundsMismatch() {
|
||||
r.logger.WarnContext(ctx, "planned_downtime_recurrence_schedule_mismatch", slog.String("maintenance_id", m.ID.StringValue()))
|
||||
}
|
||||
}
|
||||
|
||||
return gettablePlannedMaintenance, nil
|
||||
|
||||
@@ -44,7 +44,7 @@ func NewFactory(
|
||||
) factory.ProviderFactory[ruler.Ruler, ruler.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config ruler.Config) (ruler.Ruler, error) {
|
||||
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
|
||||
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
|
||||
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore, providerSettings)
|
||||
|
||||
managerOpts := &rules.ManagerOptions{
|
||||
TelemetryStore: telemetryStore,
|
||||
|
||||
@@ -11,9 +11,7 @@ import (
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeInvalidPlannedMaintenancePayload = errors.MustNewCode("invalid_planned_maintenance_payload")
|
||||
)
|
||||
var ErrCodeInvalidPlannedMaintenancePayload = errors.MustNewCode("invalid_planned_maintenance_payload")
|
||||
|
||||
type MaintenanceStatus struct {
|
||||
valuer.String
|
||||
@@ -133,6 +131,26 @@ type PlannedMaintenanceWithRules struct {
|
||||
Rules []*StorablePlannedMaintenanceRule `bun:"rel:has-many,join:id=planned_maintenance_id"`
|
||||
}
|
||||
|
||||
// HasScheduleRecurrenceBoundsMismatch reports whether a recurring maintenance
|
||||
// has different start/end bounds in Schedule and Schedule.Recurrence.
|
||||
//
|
||||
// This is used to detect if there are any entries with recurrence that don't
|
||||
// have the same timestamps stored at the schedule-level.
|
||||
// UI payloads duplicated those values in both places, but direct API users may
|
||||
// have stored bounds that are missing from, or different than, the schedule-level bounds.
|
||||
// We need to observe these before we can safely drop Recurrence.StartTime and
|
||||
// Recurrence.EndTime.
|
||||
func (m *PlannedMaintenance) HasScheduleRecurrenceBoundsMismatch() bool {
|
||||
recurrence := m.Schedule.Recurrence
|
||||
if recurrence == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return !recurrence.StartTime.Equal(m.Schedule.StartTime) ||
|
||||
(recurrence.EndTime == nil && !m.Schedule.EndTime.IsZero()) ||
|
||||
(recurrence.EndTime != nil && !recurrence.EndTime.Equal(m.Schedule.EndTime))
|
||||
}
|
||||
|
||||
func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time) bool {
|
||||
// Check if the alert ID is in the maintenance window
|
||||
found := false
|
||||
@@ -159,42 +177,43 @@ func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
currentTime := now.In(loc)
|
||||
startTime := m.Schedule.StartTime
|
||||
endTime := m.Schedule.EndTime
|
||||
recurrence := m.Schedule.Recurrence
|
||||
|
||||
// fixed schedule
|
||||
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
|
||||
startTime := m.Schedule.StartTime.In(loc)
|
||||
endTime := m.Schedule.EndTime.In(loc)
|
||||
if currentTime.Equal(startTime) || currentTime.Equal(endTime) ||
|
||||
(currentTime.After(startTime) && currentTime.Before(endTime)) {
|
||||
// fixed schedule — only when no recurrence is configured.
|
||||
// When recurrence is set, the recurring check below handles everything;
|
||||
// falling through here would cause the window to match the absolute
|
||||
// StartTime–EndTime range instead of the daily/weekly/monthly pattern.
|
||||
if recurrence == nil && !startTime.IsZero() && !endTime.IsZero() {
|
||||
if now.Equal(startTime) || now.Equal(endTime) ||
|
||||
(now.After(startTime) && now.Before(endTime)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// recurring schedule
|
||||
if m.Schedule.Recurrence != nil {
|
||||
start := m.Schedule.Recurrence.StartTime
|
||||
|
||||
if recurrence != nil {
|
||||
// Make sure the recurrence has started
|
||||
if currentTime.Before(start.In(loc)) {
|
||||
if now.Before(recurrence.StartTime) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if recurrence has expired
|
||||
if m.Schedule.Recurrence.EndTime != nil {
|
||||
endTime := *m.Schedule.Recurrence.EndTime
|
||||
if !endTime.IsZero() && currentTime.After(endTime.In(loc)) {
|
||||
if recurrence.EndTime != nil {
|
||||
if !recurrence.EndTime.IsZero() && now.After(*recurrence.EndTime) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
switch m.Schedule.Recurrence.RepeatType {
|
||||
currentTime := now.In(loc)
|
||||
switch recurrence.RepeatType {
|
||||
case RepeatTypeDaily:
|
||||
return m.checkDaily(currentTime, m.Schedule.Recurrence, loc)
|
||||
return m.checkDaily(currentTime, recurrence, loc)
|
||||
case RepeatTypeWeekly:
|
||||
return m.checkWeekly(currentTime, m.Schedule.Recurrence, loc)
|
||||
return m.checkWeekly(currentTime, recurrence, loc)
|
||||
case RepeatTypeMonthly:
|
||||
return m.checkMonthly(currentTime, m.Schedule.Recurrence, loc)
|
||||
return m.checkMonthly(currentTime, recurrence, loc)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ func timePtr(t time.Time) *time.Time {
|
||||
}
|
||||
|
||||
func TestShouldSkipMaintenance(t *testing.T) {
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
maintenance *PlannedMaintenance
|
||||
@@ -499,7 +498,7 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 04, 1, 12, 10, 0, 0, time.UTC),
|
||||
ts: time.Date(2024, 4, 1, 12, 10, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
@@ -508,14 +507,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
|
||||
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 04, 15, 12, 10, 0, 0, time.UTC),
|
||||
ts: time.Date(2024, 4, 15, 12, 10, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
@@ -524,14 +523,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
|
||||
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 04, 14, 12, 10, 0, 0, time.UTC), // 14th 04 is sunday
|
||||
ts: time.Date(2024, 4, 14, 12, 10, 0, 0, time.UTC), // 14th 04 is sunday
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
@@ -540,14 +539,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
|
||||
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 04, 16, 12, 10, 0, 0, time.UTC), // 16th 04 is tuesday
|
||||
ts: time.Date(2024, 4, 16, 12, 10, 0, 0, time.UTC), // 16th 04 is tuesday
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
@@ -556,14 +555,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
|
||||
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 05, 06, 12, 10, 0, 0, time.UTC),
|
||||
ts: time.Date(2024, 5, 6, 12, 10, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
@@ -572,14 +571,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
|
||||
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 05, 06, 14, 00, 0, 0, time.UTC),
|
||||
ts: time.Date(2024, 5, 6, 14, 0, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
@@ -588,13 +587,13 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 04, 12, 0, 0, 0, time.UTC),
|
||||
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 04, 04, 12, 10, 0, 0, time.UTC),
|
||||
ts: time.Date(2024, 4, 4, 12, 10, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
@@ -603,13 +602,13 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 04, 12, 0, 0, 0, time.UTC),
|
||||
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 04, 04, 14, 10, 0, 0, time.UTC),
|
||||
ts: time.Date(2024, 4, 4, 14, 10, 0, 0, time.UTC),
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
@@ -618,13 +617,52 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 04, 12, 0, 0, 0, time.UTC),
|
||||
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 05, 04, 12, 10, 0, 0, time.UTC),
|
||||
ts: time.Date(2024, 5, 4, 12, 10, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
// The recurrence should govern, when set. Not the fixed range.
|
||||
{
|
||||
name: "recurring-daily-with-fixed-times-outside-daily-window",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
// These fixed fields should be ignored when Recurrence is set.
|
||||
StartTime: time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC),
|
||||
EndTime: time.Date(2026, 4, 30, 18, 0, 0, 0, time.UTC),
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC), // daily at 14:00
|
||||
Duration: valuer.MustParseTextDuration("2h"), // until 16:00
|
||||
RepeatType: RepeatTypeDaily,
|
||||
},
|
||||
},
|
||||
},
|
||||
// 11:00 is inside the fixed range but outside the daily 14:00-16:00 window.
|
||||
// Before the fix this returned true (bug); after fix it returns false.
|
||||
ts: time.Date(2026, 4, 15, 11, 0, 0, 0, time.UTC),
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
name: "recurring-daily-with-fixed-times-inside-daily-window",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC),
|
||||
EndTime: time.Date(2026, 4, 30, 18, 0, 0, 0, time.UTC),
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeDaily,
|
||||
},
|
||||
},
|
||||
},
|
||||
// 15:00 is inside the daily 14:00-16:00 window — should skip.
|
||||
ts: time.Date(2026, 4, 15, 15, 0, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user