mirror of
https://github.com/apache/superset.git
synced 2026-04-30 13:34:20 +00:00
Compare commits
7 Commits
codex/fix-
...
fire-alert
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58a2eb465a | ||
|
|
3c51194bb2 | ||
|
|
19be7020c7 | ||
|
|
7c9794cc2f | ||
|
|
7a6b084ff7 | ||
|
|
17eeeaccac | ||
|
|
4dd1e80f4c |
@@ -184,8 +184,10 @@ services:
|
||||
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
|
||||
# configuring the dev-server to use the host.docker.internal to connect to the backend
|
||||
superset: "http://superset:8088"
|
||||
WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-0.0.0.0}"
|
||||
WEBPACK_DEVSERVER_PORT: "${WEBPACK_DEVSERVER_PORT:-9000}"
|
||||
ports:
|
||||
- "127.0.0.1:9000:9000" # exposing the dynamic webpack dev server
|
||||
- "9000:9000" # exposing the dynamic webpack dev server
|
||||
container_name: superset_node
|
||||
command: ["/app/docker/docker-frontend.sh"]
|
||||
env_file:
|
||||
|
||||
@@ -33,8 +33,11 @@ interface ModalFormFieldProps {
|
||||
hasFeedback?: boolean;
|
||||
}
|
||||
|
||||
const StyledFieldContainer = styled.div<{ bottomSpacing: boolean }>`
|
||||
${({ theme, bottomSpacing }) => css`
|
||||
const StyledFieldContainer = styled.div<{
|
||||
bottomSpacing: boolean;
|
||||
hasError: boolean;
|
||||
}>`
|
||||
${({ theme, bottomSpacing, hasError }) => css`
|
||||
flex: 1;
|
||||
margin-top: 0px;
|
||||
margin-bottom: ${bottomSpacing ? theme.sizeUnit * 4 : 0}px;
|
||||
@@ -48,7 +51,7 @@ const StyledFieldContainer = styled.div<{ bottomSpacing: boolean }>`
|
||||
|
||||
.required {
|
||||
margin-left: ${theme.sizeUnit / 2}px;
|
||||
color: ${theme.colorError};
|
||||
color: ${hasError ? theme.colorError : theme.colorIcon};
|
||||
}
|
||||
|
||||
.helper {
|
||||
@@ -128,8 +131,14 @@ export function ModalFormField({
|
||||
validateStatus,
|
||||
hasFeedback = false,
|
||||
}: ModalFormFieldProps) {
|
||||
const hasError = !!(error || validateStatus === 'error');
|
||||
|
||||
return (
|
||||
<StyledFieldContainer bottomSpacing={bottomSpacing} data-test={testId}>
|
||||
<StyledFieldContainer
|
||||
bottomSpacing={bottomSpacing}
|
||||
hasError={hasError}
|
||||
data-test={testId}
|
||||
>
|
||||
<div className="control-label">
|
||||
{label}
|
||||
{tooltip && <InfoTooltip tooltip={tooltip} />}
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
FeatureFlag,
|
||||
styled,
|
||||
SupersetClient,
|
||||
SupersetTheme,
|
||||
t,
|
||||
VizType,
|
||||
useTheme,
|
||||
@@ -41,7 +40,6 @@ import rison from 'rison';
|
||||
import { useSingleViewResource } from 'src/views/CRUD/hooks';
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import Owner from 'src/types/Owner';
|
||||
// import { Form as AntdForm } from 'src/components/Form';
|
||||
import { propertyComparator } from '@superset-ui/core/components/Select/utils';
|
||||
import {
|
||||
AsyncSelect,
|
||||
@@ -266,11 +264,6 @@ export const StyledInputContainer = styled.div`
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.required {
|
||||
margin-left: ${theme.sizeUnit / 2}px;
|
||||
color: ${theme.colorError};
|
||||
}
|
||||
|
||||
.control-label {
|
||||
margin-bottom: ${theme.sizeUnit * 2}px;
|
||||
color: ${theme.colorText};
|
||||
@@ -404,10 +397,6 @@ const StyledNotificationMethodWrapper = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const inputSpacer = (theme: SupersetTheme) => css`
|
||||
margin-right: ${theme.sizeUnit * 3}px;
|
||||
`;
|
||||
|
||||
type NotificationAddStatus = 'active' | 'disabled' | 'hidden';
|
||||
|
||||
interface NotificationMethodAddProps {
|
||||
@@ -2042,6 +2031,15 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
||||
<ModalFormField
|
||||
label={isReport ? t('Report name') : t('Alert name')}
|
||||
required
|
||||
error={
|
||||
validationStatus[Sections.General]?.hasErrors &&
|
||||
!currentAlert?.name?.trim()
|
||||
? t(
|
||||
'%s name is required',
|
||||
isReport ? t('Report') : t('Alert'),
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
name="name"
|
||||
@@ -2054,7 +2052,17 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</ModalFormField>
|
||||
<ModalFormField label={t('Owners')} required>
|
||||
<ModalFormField
|
||||
label={t('Owners')}
|
||||
required
|
||||
error={
|
||||
validationStatus[Sections.General]?.hasErrors &&
|
||||
(!currentAlert?.owners ||
|
||||
currentAlert.owners.length === 0)
|
||||
? t('Owners are required')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<AsyncSelect
|
||||
ariaLabel={t('Owners')}
|
||||
allowClear
|
||||
@@ -2115,40 +2123,46 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
<StyledInputContainer>
|
||||
<div className="control-label">
|
||||
{t('Database')}
|
||||
<span className="required">*</span>
|
||||
</div>
|
||||
<div className="input-container">
|
||||
<AsyncSelect
|
||||
ariaLabel={t('Database')}
|
||||
name="source"
|
||||
placeholder={t('Select database')}
|
||||
value={
|
||||
currentAlert?.database?.label &&
|
||||
currentAlert?.database?.value
|
||||
? {
|
||||
value: currentAlert.database.value,
|
||||
label: currentAlert.database.label,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
options={loadSourceOptions}
|
||||
onChange={onSourceChange}
|
||||
/>
|
||||
</div>
|
||||
</StyledInputContainer>
|
||||
<StyledInputContainer>
|
||||
<div className="control-label">
|
||||
{t('SQL Query')}
|
||||
<InfoTooltip
|
||||
tooltip={t(
|
||||
'The result of this query must be a value capable of numeric interpretation e.g. 1, 1.0, or "1" (compatible with Python\'s float() function).',
|
||||
)}
|
||||
/>
|
||||
<span className="required">*</span>
|
||||
</div>
|
||||
<ModalFormField
|
||||
label={t('Database')}
|
||||
required
|
||||
error={
|
||||
validationStatus[Sections.Alert]?.hasErrors &&
|
||||
!currentAlert?.database
|
||||
? t('Database is required')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<AsyncSelect
|
||||
ariaLabel={t('Database')}
|
||||
name="source"
|
||||
placeholder={t('Select database')}
|
||||
value={
|
||||
currentAlert?.database?.label &&
|
||||
currentAlert?.database?.value
|
||||
? {
|
||||
value: currentAlert.database.value,
|
||||
label: currentAlert.database.label,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
options={loadSourceOptions}
|
||||
onChange={onSourceChange}
|
||||
/>
|
||||
</ModalFormField>
|
||||
<ModalFormField
|
||||
label={t('SQL Query')}
|
||||
required
|
||||
tooltip={t(
|
||||
'The result of this query must be a value capable of numeric interpretation e.g. 1, 1.0, or "1" (compatible with Python\'s float() function).',
|
||||
)}
|
||||
error={
|
||||
validationStatus[Sections.Alert]?.hasErrors &&
|
||||
!currentAlert?.sql?.length
|
||||
? t('SQL Query is required')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TextAreaControl
|
||||
name="sql"
|
||||
language="sql"
|
||||
@@ -2160,57 +2174,60 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
||||
initialValue={resource?.sql}
|
||||
key={currentAlert?.id}
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
</ModalFormField>
|
||||
<div
|
||||
className="inline-container wrap"
|
||||
css={css`
|
||||
gap: ${theme.sizeUnit}px;
|
||||
`}
|
||||
>
|
||||
<StyledInputContainer css={noMarginBottom}>
|
||||
<div className="control-label" css={inputSpacer}>
|
||||
{t('Trigger Alert If...')}
|
||||
<span className="required">*</span>
|
||||
</div>
|
||||
<div className="input-container">
|
||||
<Select
|
||||
ariaLabel={t('Condition')}
|
||||
onChange={onConditionChange}
|
||||
placeholder={t('Condition')}
|
||||
value={
|
||||
currentAlert?.validator_config_json?.op ||
|
||||
undefined
|
||||
}
|
||||
options={CONDITIONS}
|
||||
/>
|
||||
</div>
|
||||
</StyledInputContainer>
|
||||
<StyledInputContainer css={noMarginBottom}>
|
||||
<div className="control-label">
|
||||
{t('Value')}{' '}
|
||||
{!conditionNotNull && (
|
||||
<span className="required">*</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="input-container">
|
||||
<InputNumber
|
||||
disabled={conditionNotNull}
|
||||
type="number"
|
||||
name="threshold"
|
||||
value={
|
||||
currentAlert?.validator_config_json
|
||||
?.threshold !== undefined &&
|
||||
!conditionNotNull
|
||||
? currentAlert.validator_config_json
|
||||
.threshold
|
||||
: ''
|
||||
}
|
||||
min={0}
|
||||
placeholder={t('Value')}
|
||||
onChange={onThresholdChange}
|
||||
/>
|
||||
</div>
|
||||
</StyledInputContainer>
|
||||
<ModalFormField
|
||||
label={t('Trigger Alert If...')}
|
||||
required
|
||||
error={
|
||||
validationStatus[Sections.Alert]?.hasErrors &&
|
||||
!currentAlert?.validator_config_json?.op
|
||||
? t('Condition is required')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Select
|
||||
ariaLabel={t('Condition')}
|
||||
onChange={onConditionChange}
|
||||
placeholder={t('Condition')}
|
||||
value={
|
||||
currentAlert?.validator_config_json?.op ||
|
||||
undefined
|
||||
}
|
||||
options={CONDITIONS}
|
||||
/>
|
||||
</ModalFormField>
|
||||
<ModalFormField
|
||||
label={t('Value')}
|
||||
required={!conditionNotNull}
|
||||
error={
|
||||
validationStatus[Sections.Alert]?.hasErrors &&
|
||||
!conditionNotNull &&
|
||||
!currentAlert?.validator_config_json?.threshold
|
||||
? t('Value is required')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<InputNumber
|
||||
disabled={conditionNotNull}
|
||||
type="number"
|
||||
name="threshold"
|
||||
value={
|
||||
currentAlert?.validator_config_json
|
||||
?.threshold !== undefined && !conditionNotNull
|
||||
? currentAlert.validator_config_json.threshold
|
||||
: ''
|
||||
}
|
||||
min={0}
|
||||
placeholder={t('Value')}
|
||||
onChange={onThresholdChange}
|
||||
/>
|
||||
</ModalFormField>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@@ -2468,7 +2485,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
||||
<InputNumber
|
||||
type="number"
|
||||
name="custom_width"
|
||||
value={currentAlert?.custom_width || undefined}
|
||||
value={currentAlert?.custom_width || 1600}
|
||||
min={600}
|
||||
max={2400}
|
||||
placeholder={t('Input custom width in pixels')}
|
||||
@@ -2511,66 +2528,66 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
||||
value={currentAlert?.crontab || ''}
|
||||
onChange={newVal => updateAlertState('crontab', newVal)}
|
||||
/>
|
||||
<StyledInputContainer>
|
||||
<div className="control-label">
|
||||
{t('Timezone')} <span className="required">*</span>
|
||||
</div>
|
||||
<ModalFormField
|
||||
label={t('Timezone')}
|
||||
required
|
||||
error={
|
||||
validationStatus[Sections.Schedule]?.hasErrors &&
|
||||
!currentAlert?.timezone
|
||||
? t('Timezone is required')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TimezoneSelector
|
||||
onTimezoneChange={onTimezoneChange}
|
||||
timezone={currentAlert?.timezone}
|
||||
minWidth="100%"
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
<StyledInputContainer>
|
||||
<div className="control-label">
|
||||
{t('Log retention')}
|
||||
<span className="required">*</span>
|
||||
</div>
|
||||
<div className="input-container">
|
||||
<Select
|
||||
ariaLabel={t('Log retention')}
|
||||
placeholder={t('Log retention')}
|
||||
onChange={onLogRetentionChange}
|
||||
value={currentAlert?.log_retention}
|
||||
options={RETENTION_OPTIONS}
|
||||
sortComparator={propertyComparator('value')}
|
||||
/>
|
||||
</div>
|
||||
</StyledInputContainer>
|
||||
<StyledInputContainer css={noMarginBottom}>
|
||||
{isReport ? (
|
||||
<>
|
||||
<div className="control-label">
|
||||
{t('Working timeout')}
|
||||
<span className="required">*</span>
|
||||
</div>
|
||||
<div className="input-container">
|
||||
<NumberInput
|
||||
min={1}
|
||||
name="working_timeout"
|
||||
value={currentAlert?.working_timeout || ''}
|
||||
placeholder={t('Time in seconds')}
|
||||
onChange={onTimeoutVerifyChange}
|
||||
timeUnit={t('seconds')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="control-label">{t('Grace period')}</div>
|
||||
<div className="input-container">
|
||||
<NumberInput
|
||||
min={1}
|
||||
name="grace_period"
|
||||
value={currentAlert?.grace_period || ''}
|
||||
placeholder={t('Time in seconds')}
|
||||
onChange={onTimeoutVerifyChange}
|
||||
timeUnit={t('seconds')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</StyledInputContainer>
|
||||
</ModalFormField>
|
||||
<ModalFormField
|
||||
label={t('Log retention')}
|
||||
required
|
||||
error={
|
||||
validationStatus[Sections.Schedule]?.hasErrors &&
|
||||
!currentAlert?.log_retention
|
||||
? t('Log retention is required')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Select
|
||||
ariaLabel={t('Log retention')}
|
||||
placeholder={t('Log retention')}
|
||||
onChange={onLogRetentionChange}
|
||||
value={currentAlert?.log_retention}
|
||||
options={RETENTION_OPTIONS}
|
||||
sortComparator={propertyComparator('value')}
|
||||
/>
|
||||
</ModalFormField>
|
||||
<ModalFormField
|
||||
label={isReport ? t('Working timeout') : t('Grace period')}
|
||||
required={isReport}
|
||||
error={
|
||||
validationStatus[Sections.Schedule]?.hasErrors &&
|
||||
isReport &&
|
||||
!currentAlert?.working_timeout
|
||||
? t('Working timeout is required')
|
||||
: undefined
|
||||
}
|
||||
bottomSpacing={false}
|
||||
>
|
||||
<NumberInput
|
||||
min={1}
|
||||
name={isReport ? 'working_timeout' : 'grace_period'}
|
||||
value={
|
||||
isReport
|
||||
? currentAlert?.working_timeout || ''
|
||||
: currentAlert?.grace_period || ''
|
||||
}
|
||||
placeholder={t('Time in seconds')}
|
||||
onChange={onTimeoutVerifyChange}
|
||||
timeUnit={t('seconds')}
|
||||
/>
|
||||
</ModalFormField>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { act } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
|
||||
import { useExecuteReportSchedule } from './useExecuteReportSchedule';
|
||||
|
||||
const mockExecuteResponse = {
|
||||
execution_id: 'test-uuid-123',
|
||||
message: 'Report schedule execution started successfully',
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
SupersetClient.configure().init();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
test('successfully executes a report', async () => {
|
||||
const reportId = 123;
|
||||
fetchMock.post(
|
||||
`glob:*/api/v1/report/${reportId}/execute`,
|
||||
mockExecuteResponse,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useExecuteReportSchedule());
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
|
||||
let executeResult: any;
|
||||
await act(async () => {
|
||||
executeResult = await result.current.executeReport(reportId);
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
expect(executeResult).toEqual(mockExecuteResponse);
|
||||
expect(fetchMock.calls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('handles execution errors', async () => {
|
||||
const reportId = 123;
|
||||
const errorMessage = 'Report not found';
|
||||
fetchMock.post(`glob:*/api/v1/report/${reportId}/execute`, {
|
||||
status: 404,
|
||||
body: { message: errorMessage },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExecuteReportSchedule());
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.executeReport(reportId);
|
||||
} catch (error) {
|
||||
// Expected to throw
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBe(errorMessage);
|
||||
});
|
||||
|
||||
test('calls success callback on successful execution', async () => {
|
||||
const reportId = 123;
|
||||
const onSuccess = jest.fn();
|
||||
fetchMock.post(
|
||||
`glob:*/api/v1/report/${reportId}/execute`,
|
||||
mockExecuteResponse,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useExecuteReportSchedule());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.executeReport(reportId, onSuccess);
|
||||
});
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith(mockExecuteResponse);
|
||||
});
|
||||
|
||||
test('calls error callback on failed execution', async () => {
|
||||
const reportId = 123;
|
||||
const onError = jest.fn();
|
||||
const errorMessage = 'Execution failed';
|
||||
fetchMock.post(`glob:*/api/v1/report/${reportId}/execute`, {
|
||||
status: 500,
|
||||
body: { message: errorMessage },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExecuteReportSchedule());
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.executeReport(reportId, undefined, onError);
|
||||
} catch (error) {
|
||||
// Expected to throw
|
||||
}
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(errorMessage);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useCallback } from 'react';
|
||||
import { SupersetClient, t } from '@superset-ui/core';
|
||||
|
||||
interface ExecuteResponse {
|
||||
execution_id: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface UseExecuteReportScheduleState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useExecuteReportSchedule() {
|
||||
const [state, setState] = useState<UseExecuteReportScheduleState>({
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const executeReport = useCallback(
|
||||
async (
|
||||
reportId: number,
|
||||
onSuccess?: (response: ExecuteResponse) => void,
|
||||
onError?: (error: string) => void,
|
||||
) => {
|
||||
setState({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const response = await SupersetClient.post({
|
||||
endpoint: `/api/v1/report/${reportId}/execute`,
|
||||
});
|
||||
|
||||
const result = response.json as ExecuteResponse;
|
||||
setState({ loading: false, error: null });
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
let errorMessage = t('An error occurred while triggering the report');
|
||||
|
||||
if (error && typeof error === 'object' && 'json' in error) {
|
||||
const errorJson = error.json as any;
|
||||
if (errorJson?.message) {
|
||||
errorMessage = errorJson.message;
|
||||
}
|
||||
}
|
||||
|
||||
setState({ loading: false, error: errorMessage });
|
||||
|
||||
if (onError) {
|
||||
onError(errorMessage);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
executeReport,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
};
|
||||
}
|
||||
@@ -59,6 +59,7 @@ import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
|
||||
import Owner from 'src/types/Owner';
|
||||
import AlertReportModal from 'src/features/alerts/AlertReportModal';
|
||||
import { AlertObject, AlertState } from 'src/features/alerts/types';
|
||||
import { useExecuteReportSchedule } from 'src/features/alerts/hooks/useExecuteReportSchedule';
|
||||
import { QueryObjectColumns } from 'src/views/CRUD/types';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
|
||||
@@ -157,12 +158,16 @@ function AlertList({
|
||||
addDangerToast,
|
||||
);
|
||||
|
||||
// Execute hook for Fire Now functionality
|
||||
const { executeReport } = useExecuteReportSchedule();
|
||||
|
||||
const [alertModalOpen, setAlertModalOpen] = useState<boolean>(false);
|
||||
const [currentAlert, setCurrentAlert] = useState<Partial<AlertObject> | null>(
|
||||
null,
|
||||
);
|
||||
const [currentAlertDeleting, setCurrentAlertDeleting] =
|
||||
useState<AlertObject | null>(null);
|
||||
const [executingIds, setExecutingIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// Actions
|
||||
function handleAlertEdit(alert: AlertObject | null) {
|
||||
@@ -246,6 +251,51 @@ function AlertList({
|
||||
[alerts, setResourceCollection, updateResource],
|
||||
);
|
||||
|
||||
const handleExecuteReport = useCallback(
|
||||
async (alert: AlertObject) => {
|
||||
const alertId = alert.id;
|
||||
if (!alertId || executingIds.has(alertId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to executing set
|
||||
setExecutingIds(prev => new Set(prev).add(alertId));
|
||||
|
||||
try {
|
||||
await executeReport(
|
||||
alertId,
|
||||
response => {
|
||||
addSuccessToast(
|
||||
t('%(alertType)s "%(alertName)s" triggered successfully', {
|
||||
alertType: alert.type,
|
||||
alertName: alert.name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
error => {
|
||||
addDangerToast(
|
||||
t('Failed to trigger %(alertType)s "%(alertName)s": %(error)s', {
|
||||
alertType: alert.type,
|
||||
alertName: alert.name,
|
||||
error,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
// Error already handled by onError callback
|
||||
} finally {
|
||||
// Remove from executing set
|
||||
setExecutingIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(alertId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
},
|
||||
[executeReport, executingIds, addSuccessToast, addDangerToast],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -397,6 +447,16 @@ function AlertList({
|
||||
onClick: handleEdit,
|
||||
}
|
||||
: null,
|
||||
allowEdit
|
||||
? {
|
||||
label: 'trigger-now-action',
|
||||
tooltip: t('Trigger Now'),
|
||||
placement: 'bottom',
|
||||
icon: 'ThunderboltOutlined',
|
||||
loading: executingIds.has(original.id),
|
||||
onClick: () => handleExecuteReport(original),
|
||||
}
|
||||
: null,
|
||||
allowEdit && canDelete
|
||||
? {
|
||||
label: 'delete-action',
|
||||
@@ -424,7 +484,14 @@ function AlertList({
|
||||
id: QueryObjectColumns.ChangedBy,
|
||||
},
|
||||
],
|
||||
[canDelete, canEdit, isReportEnabled, toggleActive],
|
||||
[
|
||||
canDelete,
|
||||
canEdit,
|
||||
isReportEnabled,
|
||||
toggleActive,
|
||||
executingIds,
|
||||
handleExecuteReport,
|
||||
],
|
||||
);
|
||||
|
||||
const subMenuButtons: SubMenuProps['buttons'] = [];
|
||||
|
||||
@@ -309,3 +309,15 @@ class ReportScheduleForbiddenError(ForbiddenError):
|
||||
|
||||
class ReportSchedulePruneLogError(CommandException):
|
||||
message = _("An error occurred while pruning logs ")
|
||||
|
||||
|
||||
class ReportScheduleExecuteNowFailedError(CommandException):
|
||||
message = _("Report Schedule execute now failed.")
|
||||
|
||||
|
||||
class ReportScheduleCeleryNotConfiguredError(CommandException):
|
||||
status = 503
|
||||
message = _(
|
||||
"Report Schedule execution requires a Celery backend to be configured. "
|
||||
"Please configure a Celery broker (Redis or RabbitMQ) and worker processes."
|
||||
)
|
||||
|
||||
147
superset/commands/report/execute_now.py
Normal file
147
superset/commands/report/execute_now.py
Normal file
@@ -0,0 +1,147 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.exceptions import CommandException
|
||||
from superset.commands.report.exceptions import (
|
||||
ReportScheduleCeleryNotConfiguredError,
|
||||
ReportScheduleExecuteNowFailedError,
|
||||
ReportScheduleForbiddenError,
|
||||
ReportScheduleNotFoundError,
|
||||
)
|
||||
from superset.daos.report import ReportScheduleDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.reports.models import ReportSchedule
|
||||
from superset.utils.decorators import transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExecuteReportScheduleNowCommand(BaseCommand):
|
||||
"""
|
||||
Execute a report schedule immediately (manual trigger).
|
||||
|
||||
This command validates permissions and triggers immediate execution
|
||||
of a report or alert via Celery task, similar to scheduled execution
|
||||
but without waiting for the cron schedule.
|
||||
"""
|
||||
|
||||
def __init__(self, model_id: int) -> None:
|
||||
self._model_id = model_id
|
||||
self._model: Optional[ReportSchedule] = None
|
||||
|
||||
@transaction()
|
||||
def run(self) -> str:
|
||||
"""
|
||||
Execute the command and return execution UUID for tracking.
|
||||
|
||||
Returns:
|
||||
str: Execution UUID that can be used to track the execution status
|
||||
|
||||
Raises:
|
||||
ReportScheduleNotFoundError: Report schedule not found
|
||||
ReportScheduleForbiddenError: User doesn't have permission to execute
|
||||
ReportScheduleExecuteNowFailedError: Execution failed to start
|
||||
"""
|
||||
try:
|
||||
self.validate()
|
||||
if not self._model:
|
||||
raise ReportScheduleExecuteNowFailedError()
|
||||
|
||||
# Generate execution UUID for tracking
|
||||
execution_id = str(uuid4())
|
||||
|
||||
# Trigger immediate execution via Celery
|
||||
logger.info(
|
||||
"Manually executing report schedule %s (id: %d), execution_id: %s",
|
||||
self._model.name,
|
||||
self._model.id,
|
||||
execution_id,
|
||||
)
|
||||
|
||||
# Import the existing execute task to avoid circular imports
|
||||
from superset.tasks.scheduler import execute
|
||||
|
||||
# Set async options similar to scheduler but for immediate execution
|
||||
async_options: dict[str, Any] = {"task_id": execution_id}
|
||||
if self._model.working_timeout is not None and current_app.config.get(
|
||||
"ALERT_REPORTS_WORKING_TIME_OUT_KILL", True
|
||||
):
|
||||
async_options["time_limit"] = (
|
||||
self._model.working_timeout
|
||||
+ current_app.config.get("ALERT_REPORTS_WORKING_TIME_OUT_LAG", 10)
|
||||
)
|
||||
async_options["soft_time_limit"] = (
|
||||
self._model.working_timeout
|
||||
+ current_app.config.get(
|
||||
"ALERT_REPORTS_WORKING_SOFT_TIME_OUT_LAG", 5
|
||||
)
|
||||
)
|
||||
|
||||
# Execute the task
|
||||
try:
|
||||
execute.apply_async((self._model.id,), **async_options)
|
||||
except Exception as celery_ex:
|
||||
# Check for common Celery configuration issues
|
||||
error_msg = str(celery_ex).lower()
|
||||
if any(
|
||||
keyword in error_msg
|
||||
for keyword in [
|
||||
"no broker",
|
||||
"broker connection",
|
||||
"kombu",
|
||||
"redis",
|
||||
"rabbitmq",
|
||||
"celery",
|
||||
"not registered",
|
||||
"connection refused",
|
||||
]
|
||||
):
|
||||
logger.error("Celery backend not configured: %s", str(celery_ex))
|
||||
raise ReportScheduleCeleryNotConfiguredError() from celery_ex
|
||||
else:
|
||||
logger.error("Celery task execution failed: %s", str(celery_ex))
|
||||
raise ReportScheduleExecuteNowFailedError() from celery_ex
|
||||
|
||||
return execution_id
|
||||
|
||||
except CommandException:
|
||||
raise
|
||||
except Exception as ex:
|
||||
logger.exception(
|
||||
"Unexpected error executing report schedule %d", self._model_id
|
||||
)
|
||||
raise ReportScheduleExecuteNowFailedError() from ex
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate the report schedule exists and user has permission to execute it."""
|
||||
# Validate model exists
|
||||
self._model = ReportScheduleDAO.find_by_id(self._model_id)
|
||||
if not self._model:
|
||||
raise ReportScheduleNotFoundError()
|
||||
|
||||
# Check ownership using the same pattern as delete command
|
||||
try:
|
||||
security_manager.raise_for_ownership(self._model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise ReportScheduleForbiddenError() from ex
|
||||
@@ -29,13 +29,16 @@ from superset.charts.filters import ChartFilter
|
||||
from superset.commands.report.create import CreateReportScheduleCommand
|
||||
from superset.commands.report.delete import DeleteReportScheduleCommand
|
||||
from superset.commands.report.exceptions import (
|
||||
ReportScheduleCeleryNotConfiguredError,
|
||||
ReportScheduleCreateFailedError,
|
||||
ReportScheduleDeleteFailedError,
|
||||
ReportScheduleExecuteNowFailedError,
|
||||
ReportScheduleForbiddenError,
|
||||
ReportScheduleInvalidError,
|
||||
ReportScheduleNotFoundError,
|
||||
ReportScheduleUpdateFailedError,
|
||||
)
|
||||
from superset.commands.report.execute_now import ExecuteReportScheduleNowCommand
|
||||
from superset.commands.report.update import UpdateReportScheduleCommand
|
||||
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
|
||||
from superset.dashboards.filters import DashboardAccessFilter
|
||||
@@ -48,6 +51,7 @@ from superset.reports.schemas import (
|
||||
get_delete_ids_schema,
|
||||
get_slack_channels_schema,
|
||||
openapi_spec_methods_override,
|
||||
ReportScheduleExecuteResponseSchema,
|
||||
ReportSchedulePostSchema,
|
||||
ReportSchedulePutSchema,
|
||||
)
|
||||
@@ -76,6 +80,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
|
||||
RouteMethod.RELATED,
|
||||
"bulk_delete",
|
||||
"slack_channels", # not using RouteMethod since locally defined
|
||||
"execute", # not using RouteMethod since locally defined
|
||||
}
|
||||
class_permission_name = "ReportSchedule"
|
||||
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||
@@ -588,3 +593,77 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
|
||||
except SupersetException as ex:
|
||||
logger.error("Error fetching slack channels %s", str(ex))
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
@expose("/<int:pk>/execute", methods=("POST",))
|
||||
@protect()
|
||||
@safe
|
||||
@permission_name("write")
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.execute",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def execute(self, pk: int) -> Response:
|
||||
"""Execute a report schedule immediately.
|
||||
---
|
||||
post:
|
||||
summary: Execute a report schedule immediately
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: integer
|
||||
name: pk
|
||||
description: The report schedule pk
|
||||
responses:
|
||||
200:
|
||||
description: Report schedule execution started
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
execution_id:
|
||||
type: string
|
||||
description: UUID to track the execution status
|
||||
message:
|
||||
type: string
|
||||
description: Success message
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
try:
|
||||
execution_id = ExecuteReportScheduleNowCommand(pk).run()
|
||||
response_schema = ReportScheduleExecuteResponseSchema()
|
||||
return self.response(
|
||||
200,
|
||||
**response_schema.dump(
|
||||
{
|
||||
"execution_id": execution_id,
|
||||
"message": "Report schedule execution started successfully",
|
||||
}
|
||||
),
|
||||
)
|
||||
except ReportScheduleNotFoundError:
|
||||
return self.response_404()
|
||||
except ReportScheduleForbiddenError:
|
||||
return self.response_403()
|
||||
except ReportScheduleCeleryNotConfiguredError as ex:
|
||||
logger.error(
|
||||
"Celery backend not configured for report schedule execution: %s",
|
||||
str(ex),
|
||||
)
|
||||
return self.response(503, message=str(ex))
|
||||
except ReportScheduleExecuteNowFailedError as ex:
|
||||
logger.error(
|
||||
"Error executing report schedule %s: %s",
|
||||
self.__class__.__name__,
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
@@ -413,3 +413,15 @@ class SlackChannelSchema(Schema):
|
||||
name = fields.String()
|
||||
is_member = fields.Boolean()
|
||||
is_private = fields.Boolean()
|
||||
|
||||
|
||||
class ReportScheduleExecuteResponseSchema(Schema):
|
||||
"""
|
||||
Schema for the response when executing a report schedule immediately.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
unknown = EXCLUDE
|
||||
|
||||
execution_id = fields.String(description="UUID to track the execution status")
|
||||
message = fields.String(description="Success message")
|
||||
|
||||
@@ -2049,3 +2049,94 @@ class TestReportSchedulesApi(SupersetTestCase):
|
||||
)
|
||||
|
||||
assert json.loads(report_schedule.extra_json) == extra_json
|
||||
|
||||
@pytest.mark.usefixtures("create_report_schedules")
|
||||
@patch("superset.tasks.scheduler.execute.apply_async")
|
||||
def test_execute_report_schedule(self, mock_execute):
|
||||
"""
|
||||
ReportSchedule Api: Test execute report schedule
|
||||
"""
|
||||
report_schedule = (
|
||||
db.session.query(ReportSchedule)
|
||||
.filter(ReportSchedule.name == "name1")
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
self.login(ADMIN_USERNAME)
|
||||
uri = f"api/v1/report/{report_schedule.id}/execute"
|
||||
rv = self.client.post(uri)
|
||||
assert rv.status_code == 200
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
assert "execution_id" in data
|
||||
assert "message" in data
|
||||
assert data["message"] == "Report schedule execution started successfully"
|
||||
|
||||
# Verify the task was called
|
||||
mock_execute.assert_called_once()
|
||||
# Verify that the task was called with the correct report_schedule_id and eta
|
||||
call_args = mock_execute.call_args
|
||||
assert call_args[0][0] == (report_schedule.id,)
|
||||
# Check that eta was set for manual execution
|
||||
assert "eta" in call_args[1]
|
||||
assert call_args[1]["eta"] is not None
|
||||
|
||||
@pytest.mark.usefixtures("create_report_schedules")
|
||||
def test_execute_report_schedule_not_found(self):
|
||||
"""
|
||||
ReportSchedule Api: Test execute report schedule not found
|
||||
"""
|
||||
self.login(ADMIN_USERNAME)
|
||||
uri = "api/v1/report/9999999/execute"
|
||||
rv = self.client.post(uri)
|
||||
assert rv.status_code == 404
|
||||
|
||||
@pytest.mark.usefixtures("create_report_schedules")
|
||||
def test_execute_report_schedule_not_owned(self):
|
||||
"""
|
||||
ReportSchedule Api: Test execute report schedule not owned
|
||||
"""
|
||||
report_schedule = (
|
||||
db.session.query(ReportSchedule)
|
||||
.filter(ReportSchedule.name == "name1")
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
self.login(GAMMA_USERNAME)
|
||||
uri = f"api/v1/report/{report_schedule.id}/execute"
|
||||
rv = self.client.post(uri)
|
||||
assert rv.status_code == 403
|
||||
|
||||
def test_execute_report_schedule_disabled(self):
|
||||
"""
|
||||
ReportSchedule Api: Test execute report schedule 404s when feature is disabled
|
||||
"""
|
||||
self.login(ADMIN_USERNAME)
|
||||
with patch("superset.is_feature_enabled", return_value=False):
|
||||
uri = "api/v1/report/1/execute"
|
||||
rv = self.client.post(uri)
|
||||
assert rv.status_code == 404
|
||||
|
||||
@pytest.mark.usefixtures("create_report_schedules")
|
||||
@patch("superset.tasks.scheduler.execute.apply_async")
|
||||
def test_execute_report_schedule_celery_error(self, mock_execute):
|
||||
"""
|
||||
ReportSchedule Api: Test execute report schedule with Celery backend error
|
||||
"""
|
||||
# Simulate Celery backend not configured
|
||||
mock_execute.side_effect = Exception(
|
||||
"kombu.exceptions.ConnectionError: broker connection"
|
||||
)
|
||||
|
||||
report_schedule = (
|
||||
db.session.query(ReportSchedule)
|
||||
.filter(ReportSchedule.name == "name1")
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
self.login(ADMIN_USERNAME)
|
||||
uri = f"api/v1/report/{report_schedule.id}/execute"
|
||||
rv = self.client.post(uri)
|
||||
assert rv.status_code == 503
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
assert "Celery backend" in data["message"]
|
||||
assert "broker" in data["message"].lower()
|
||||
|
||||
Reference in New Issue
Block a user