mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-23 20:30:31 +01:00
Compare commits
2 Commits
chore/base
...
platform-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
065c483db2 | ||
|
|
61a0ba4e54 |
@@ -92,7 +92,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
|
||||
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
|
||||
},
|
||||
func(ctx context.Context, sqlstore sqlstore.SQLStore, _ licensing.Licensing, _ dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
|
||||
func(ctx context.Context, sqlstore sqlstore.SQLStore, _ licensing.Licensing, _ ...authz.RegisterTypeable) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
|
||||
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -137,12 +137,12 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
|
||||
return authNs, nil
|
||||
},
|
||||
func(ctx context.Context, sqlstore sqlstore.SQLStore, licensing licensing.Licensing, dashboardModule dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
|
||||
func(ctx context.Context, sqlstore sqlstore.SQLStore, licensing licensing.Licensing, registry ...authz.RegisterTypeable) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
|
||||
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, dashboardModule), nil
|
||||
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, registry...), nil
|
||||
},
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
|
||||
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)
|
||||
|
||||
@@ -6,20 +6,15 @@ import { PayloadProps, Props } from 'types/api/thirdPartyApis/listOverview';
|
||||
|
||||
const listOverview = async (
|
||||
props: Props,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
const { start, end, show_ip: showIp, filter } = props;
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/third-party-apis/overview/list`,
|
||||
{
|
||||
start,
|
||||
end,
|
||||
show_ip: showIp,
|
||||
filter,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
const response = await axios.post(`/third-party-apis/overview/list`, {
|
||||
start,
|
||||
end,
|
||||
show_ip: showIp,
|
||||
filter,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 240px;
|
||||
width: 100%;
|
||||
padding: 24px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.text {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.subText {
|
||||
color: var(--foreground);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Typography } from 'antd';
|
||||
import eyesEmojiUrl from 'assets/Images/eyesEmoji.svg';
|
||||
|
||||
import styles from './QueryCancelledPlaceholder.module.scss';
|
||||
|
||||
interface QueryCancelledPlaceholderProps {
|
||||
subText?: string;
|
||||
}
|
||||
|
||||
function QueryCancelledPlaceholder({
|
||||
subText,
|
||||
}: QueryCancelledPlaceholderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.placeholder}>
|
||||
<img className={styles.emoji} src={eyesEmojiUrl} alt="eyes emoji" />
|
||||
<Typography className={styles.text}>
|
||||
Query cancelled.
|
||||
<span className={styles.subText}>
|
||||
{' '}
|
||||
{subText || 'Click "Run Query" to load data.'}
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
QueryCancelledPlaceholder.defaultProps = {
|
||||
subText: undefined,
|
||||
};
|
||||
|
||||
export default QueryCancelledPlaceholder;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './QueryCancelledPlaceholder';
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* Maximum number of retries for a failed react-query request before giving up.
|
||||
* Used as the upper bound in the default `retry` predicate:
|
||||
* `return failureCount < MAX_QUERY_RETRIES;`
|
||||
*
|
||||
* This retries up to 3 times (4 attempts total including the initial request).
|
||||
*/
|
||||
export const MAX_QUERY_RETRIES = 3;
|
||||
@@ -25,8 +25,7 @@ export const REACT_QUERY_KEY = {
|
||||
ALERT_RULE_TIMELINE_GRAPH: 'ALERT_RULE_TIMELINE_GRAPH',
|
||||
GET_CONSUMER_LAG_DETAILS: 'GET_CONSUMER_LAG_DETAILS',
|
||||
TOGGLE_ALERT_STATE: 'TOGGLE_ALERT_STATE',
|
||||
GET_ALL_ALERTS: 'GET_ALL_ALERTS',
|
||||
ALERT_RULES_CHART_PREVIEW: 'ALERT_RULES_CHART_PREVIEW',
|
||||
GET_ALL_ALLERTS: 'GET_ALL_ALLERTS',
|
||||
REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE',
|
||||
DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE',
|
||||
GET_HOST_LIST: 'GET_HOST_LIST',
|
||||
|
||||
@@ -21,7 +21,6 @@ import { FilterConfirmProps } from 'antd/lib/table/interface';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import getAll from 'api/errors/getAll';
|
||||
import getErrorCounts from 'api/errors/getErrorCounts';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -37,7 +36,6 @@ import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import history from 'lib/history';
|
||||
import { isUndefined } from 'lodash-es';
|
||||
import { useAllErrorsQueryState } from 'pages/AllErrors/QueryStateContext';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
@@ -123,13 +121,7 @@ function AllErrors(): JSX.Element {
|
||||
const { queries } = useResourceAttribute();
|
||||
const compositeData = useGetCompositeQueryParam();
|
||||
|
||||
const setIsFetching = useAllErrorsQueryState((s) => s.setIsFetching);
|
||||
const isCancelled = useAllErrorsQueryState((s) => s.isCancelled);
|
||||
|
||||
const [
|
||||
{ isLoading, isFetching: isErrorsFetching, data },
|
||||
errorCountResponse,
|
||||
] = useQueries([
|
||||
const [{ isLoading, data }, errorCountResponse] = useQueries([
|
||||
{
|
||||
queryKey: ['getAllErrors', updatedPath, maxTime, minTime, compositeData],
|
||||
queryFn: (): Promise<SuccessResponse<PayloadProps> | ErrorResponse> =>
|
||||
@@ -170,12 +162,6 @@ function AllErrors(): JSX.Element {
|
||||
enabled: !loading,
|
||||
},
|
||||
]);
|
||||
|
||||
const isFetching = isErrorsFetching || errorCountResponse.isFetching;
|
||||
useEffect(() => {
|
||||
setIsFetching(isFetching);
|
||||
}, [isFetching, setIsFetching]);
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -487,12 +473,6 @@ function AllErrors(): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [errorCountResponse.data?.payload]);
|
||||
|
||||
if (isCancelled && !data?.payload?.length) {
|
||||
return (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load exceptions.' />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Spin, Table } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import emptyStateUrl from 'assets/Icons/emptyState.svg';
|
||||
import cx from 'classnames';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
@@ -27,6 +23,8 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
|
||||
|
||||
import { ApiMonitoringHardcodedAttributeKeys } from '../../constants';
|
||||
import { DEFAULT_PARAMS, useApiMonitoringParams } from '../../queryParams';
|
||||
import { columnsConfig, formatDataForTable } from '../../utils';
|
||||
@@ -42,7 +40,6 @@ function DomainList(): JSX.Element {
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { currentQuery, handleRunQuery } = useQueryBuilder();
|
||||
const query = useMemo(() => currentQuery?.builder?.queryData[0] || null, [
|
||||
currentQuery,
|
||||
@@ -56,19 +53,6 @@ function DomainList(): JSX.Element {
|
||||
|
||||
const compositeData = useGetCompositeQueryParam();
|
||||
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
queryClient.cancelQueries([REACT_QUERY_KEY.GET_DOMAINS_LIST]);
|
||||
setIsCancelled(true);
|
||||
}, [queryClient]);
|
||||
|
||||
const handleStageAndRunQuery = useCallback(() => {
|
||||
setIsCancelled(false);
|
||||
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_DOMAINS_LIST]);
|
||||
handleRunQuery();
|
||||
}, [queryClient, handleRunQuery]);
|
||||
|
||||
const { data, isLoading, isFetching } = useListOverview({
|
||||
start: minTime,
|
||||
end: maxTime,
|
||||
@@ -121,13 +105,6 @@ function DomainList(): JSX.Element {
|
||||
[data],
|
||||
);
|
||||
|
||||
// Auto-reset cancelled state when a new fetch starts
|
||||
useEffect(() => {
|
||||
if (isFetching) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [isFetching]);
|
||||
|
||||
// Open drawer if selectedDomain is set in URL
|
||||
useEffect(() => {
|
||||
if (selectedDomain && formattedDataForTable?.length > 0) {
|
||||
@@ -142,13 +119,7 @@ function DomainList(): JSX.Element {
|
||||
<section className={cx('api-module-right-section')}>
|
||||
<Toolbar
|
||||
showAutoRefresh={false}
|
||||
rightActions={
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={handleStageAndRunQuery}
|
||||
isLoadingQueries={isFetching}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
}
|
||||
rightActions={<RightToolbarActions onStageRunQuery={handleRunQuery} />}
|
||||
/>
|
||||
<div className={cx('api-monitoring-list-header')}>
|
||||
<QuerySearch
|
||||
@@ -159,44 +130,38 @@ function DomainList(): JSX.Element {
|
||||
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
|
||||
/>
|
||||
</div>
|
||||
{isCancelled && formattedDataForTable.length === 0 && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load API monitoring data.' />
|
||||
)}
|
||||
{!isCancelled &&
|
||||
!isFetching &&
|
||||
!isLoading &&
|
||||
formattedDataForTable.length === 0 && (
|
||||
<div className="no-filtered-domains-message-container">
|
||||
<div className="no-filtered-domains-message-content">
|
||||
<img
|
||||
src={emptyStateUrl}
|
||||
alt="thinking-emoji"
|
||||
className="empty-state-svg"
|
||||
/>
|
||||
{!isFetching && !isLoading && formattedDataForTable.length === 0 && (
|
||||
<div className="no-filtered-domains-message-container">
|
||||
<div className="no-filtered-domains-message-content">
|
||||
<img
|
||||
src={emptyStateUrl}
|
||||
alt="thinking-emoji"
|
||||
className="empty-state-svg"
|
||||
/>
|
||||
|
||||
<div className="no-filtered-domains-message">
|
||||
<div className="no-domain-title">
|
||||
No External API calls detected with applied filters.
|
||||
</div>
|
||||
<div className="no-domain-subtitle">
|
||||
Ensure all HTTP client spans are being sent with kind as{' '}
|
||||
<span className="attribute">Client</span> and url set in{' '}
|
||||
<span className="attribute">url.full</span> or{' '}
|
||||
<span className="attribute">http.url</span> attribute.
|
||||
</div>
|
||||
<a
|
||||
href={DOCLINKS.EXTERNAL_API_MONITORING}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="external-api-doc-link"
|
||||
>
|
||||
Learn how External API monitoring works in SigNoz{' '}
|
||||
<MoveUpRight size={14} />
|
||||
</a>
|
||||
<div className="no-filtered-domains-message">
|
||||
<div className="no-domain-title">
|
||||
No External API calls detected with applied filters.
|
||||
</div>
|
||||
<div className="no-domain-subtitle">
|
||||
Ensure all HTTP client spans are being sent with kind as{' '}
|
||||
<span className="attribute">Client</span> and url set in{' '}
|
||||
<span className="attribute">url.full</span> or{' '}
|
||||
<span className="attribute">http.url</span> attribute.
|
||||
</div>
|
||||
<a
|
||||
href={DOCLINKS.EXTERNAL_API_MONITORING}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="external-api-doc-link"
|
||||
>
|
||||
Learn how External API monitoring works in SigNoz{' '}
|
||||
<MoveUpRight size={14} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(isFetching || isLoading || formattedDataForTable.length > 0) && (
|
||||
<Table
|
||||
className="api-monitoring-domain-list-table"
|
||||
|
||||
@@ -18,16 +18,9 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
export interface ChartPreviewProps {
|
||||
alertDef: AlertDef;
|
||||
source?: YAxisSource;
|
||||
isCancelled?: boolean;
|
||||
onFetchingStateChange?: (isFetching: boolean) => void;
|
||||
}
|
||||
|
||||
function ChartPreview({
|
||||
alertDef,
|
||||
source,
|
||||
isCancelled = false,
|
||||
onFetchingStateChange,
|
||||
}: ChartPreviewProps): JSX.Element {
|
||||
function ChartPreview({ alertDef, source }: ChartPreviewProps): JSX.Element {
|
||||
const { currentQuery, panelType, stagedQuery } = useQueryBuilder();
|
||||
const {
|
||||
alertType,
|
||||
@@ -95,8 +88,6 @@ function ChartPreview({
|
||||
graphType={panelType || PANEL_TYPES.TIME_SERIES}
|
||||
setQueryStatus={setQueryStatus}
|
||||
additionalThresholds={thresholdState.thresholds}
|
||||
isCancelled={isCancelled}
|
||||
onFetchingStateChange={onFetchingStateChange}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -111,8 +102,6 @@ function ChartPreview({
|
||||
graphType={panelType || PANEL_TYPES.TIME_SERIES}
|
||||
setQueryStatus={setQueryStatus}
|
||||
additionalThresholds={thresholdState.thresholds}
|
||||
isCancelled={isCancelled}
|
||||
onFetchingStateChange={onFetchingStateChange}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import QuerySectionComponent from 'container/FormAlertRules/QuerySection';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { getMetricNameFromQueryData } from 'hooks/useGetYAxisUnit';
|
||||
@@ -64,24 +62,7 @@ function QuerySection(): JSX.Element {
|
||||
return currentQueryKey !== stagedQueryKey;
|
||||
}, [currentQuery, alertType, thresholdState, stagedQuery]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingQueries) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [isLoadingQueries]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
queryClient.cancelQueries([REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW]);
|
||||
setIsCancelled(true);
|
||||
}, [queryClient]);
|
||||
|
||||
const runQueryHandler = useCallback(() => {
|
||||
setIsCancelled(false);
|
||||
queryClient.invalidateQueries([REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW]);
|
||||
// Reset the source param when the query is changed
|
||||
// Then manually run the query
|
||||
if (source === YAxisSource.DASHBOARDS && didQueryChange) {
|
||||
@@ -95,7 +76,6 @@ function QuerySection(): JSX.Element {
|
||||
currentQuery,
|
||||
didQueryChange,
|
||||
handleRunQuery,
|
||||
queryClient,
|
||||
redirectWithQueryBuilderData,
|
||||
source,
|
||||
]);
|
||||
@@ -126,12 +106,7 @@ function QuerySection(): JSX.Element {
|
||||
return (
|
||||
<div className="query-section">
|
||||
<Stepper stepNumber={1} label="Define the query" />
|
||||
<ChartPreview
|
||||
alertDef={alertDef}
|
||||
source={source}
|
||||
isCancelled={isCancelled}
|
||||
onFetchingStateChange={setIsLoadingQueries}
|
||||
/>
|
||||
<ChartPreview alertDef={alertDef} source={source} />
|
||||
<div className="query-section-tabs">
|
||||
<div className="query-section-query-actions">
|
||||
{tabs.map((tab) => (
|
||||
@@ -155,8 +130,6 @@ function QuerySection(): JSX.Element {
|
||||
setQueryCategory={onQueryCategoryChange}
|
||||
alertType={alertType}
|
||||
runQuery={runQueryHandler}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
alertDef={alertDef}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
key={currentQuery.queryType}
|
||||
|
||||
@@ -4,14 +4,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import Spinner from 'components/Spinner';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import AnomalyAlertEvaluationView from 'container/AnomalyAlertEvaluationView';
|
||||
import { INITIAL_CRITICAL_THRESHOLD } from 'container/CreateAlertV2/context/constants';
|
||||
import { Threshold } from 'container/CreateAlertV2/context/types';
|
||||
@@ -71,8 +69,6 @@ export interface ChartPreviewProps {
|
||||
setQueryStatus?: (status: string) => void;
|
||||
showSideLegend?: boolean;
|
||||
additionalThresholds?: Threshold[];
|
||||
isCancelled?: boolean;
|
||||
onFetchingStateChange?: (isFetching: boolean) => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
@@ -90,8 +86,6 @@ function ChartPreview({
|
||||
setQueryStatus,
|
||||
showSideLegend = false,
|
||||
additionalThresholds,
|
||||
isCancelled = false,
|
||||
onFetchingStateChange,
|
||||
}: ChartPreviewProps): JSX.Element | null {
|
||||
const { t } = useTranslation('alerts');
|
||||
const dispatch = useDispatch();
|
||||
@@ -191,7 +185,7 @@ function ChartPreview({
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW,
|
||||
'chartPreview',
|
||||
userQueryKey || JSON.stringify(query),
|
||||
selectedInterval,
|
||||
minTime,
|
||||
@@ -199,14 +193,9 @@ function ChartPreview({
|
||||
alertDef?.ruleType,
|
||||
],
|
||||
enabled: canQuery,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onFetchingStateChange?.(queryResponse.isFetching);
|
||||
}, [queryResponse.isFetching, onFetchingStateChange]);
|
||||
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect((): void => {
|
||||
@@ -345,16 +334,11 @@ function ChartPreview({
|
||||
|
||||
const chartData = getUPlotChartData(queryResponse?.data?.payload);
|
||||
|
||||
const hasResultData = !!queryResponse?.data?.payload?.data?.result?.length;
|
||||
|
||||
const isAnomalyDetectionAlert =
|
||||
alertDef?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
|
||||
|
||||
const chartDataAvailable =
|
||||
chartData &&
|
||||
hasResultData &&
|
||||
!queryResponse.isLoading &&
|
||||
(!queryResponse.isError || isCancelled);
|
||||
chartData && !queryResponse.isError && !queryResponse.isLoading;
|
||||
|
||||
const isAnomalyDetectionEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
|
||||
@@ -375,14 +359,10 @@ function ChartPreview({
|
||||
{queryResponse.isLoading && (
|
||||
<Spinner size="large" tip="Loading..." height="100%" />
|
||||
)}
|
||||
{(queryResponse?.isError || queryResponse?.error) && !isCancelled && (
|
||||
{(queryResponse?.isError || queryResponse?.error) && (
|
||||
<ErrorInPlace error={queryResponse.error as APIError} />
|
||||
)}
|
||||
|
||||
{isCancelled && !queryResponse.isLoading && !hasResultData && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load the chart preview.' />
|
||||
)}
|
||||
|
||||
{chartDataAvailable && !isAnomalyDetectionAlert && (
|
||||
<GridPanelSwitch
|
||||
options={options}
|
||||
@@ -423,8 +403,6 @@ ChartPreview.defaultProps = {
|
||||
setQueryStatus: (): void => {},
|
||||
showSideLegend: false,
|
||||
additionalThresholds: undefined,
|
||||
isCancelled: false,
|
||||
onFetchingStateChange: undefined,
|
||||
};
|
||||
|
||||
export default ChartPreview;
|
||||
|
||||
@@ -35,8 +35,6 @@ function QuerySection({
|
||||
setQueryCategory,
|
||||
alertType,
|
||||
runQuery,
|
||||
isLoadingQueries,
|
||||
handleCancelQuery,
|
||||
alertDef,
|
||||
panelType,
|
||||
ruleId,
|
||||
@@ -228,8 +226,6 @@ function QuerySection({
|
||||
queryType: queryCategory,
|
||||
});
|
||||
}}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
@@ -249,11 +245,7 @@ function QuerySection({
|
||||
onChange={handleQueryCategoryChange}
|
||||
tabBarExtraContent={
|
||||
<span style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||
<RunQueryBtn
|
||||
onStageRunQuery={runQuery}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
/>
|
||||
<RunQueryBtn onStageRunQuery={runQuery} />
|
||||
</span>
|
||||
}
|
||||
items={items}
|
||||
@@ -295,8 +287,6 @@ interface QuerySectionProps {
|
||||
setQueryCategory: (n: EQueryType) => void;
|
||||
alertType: AlertTypes;
|
||||
runQuery: VoidFunction;
|
||||
isLoadingQueries: boolean;
|
||||
handleCancelQuery: () => void;
|
||||
alertDef: AlertDef;
|
||||
panelType: PANEL_TYPES;
|
||||
ruleId: string;
|
||||
|
||||
@@ -136,19 +136,6 @@ function FormAlertRules({
|
||||
|
||||
// use query client
|
||||
const ruleCache = useQueryClient();
|
||||
const [isChartQueryCancelled, setIsChartQueryCancelled] = useState(false);
|
||||
const [isLoadingAlertQuery, setIsLoadingAlertQuery] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingAlertQuery) {
|
||||
setIsChartQueryCancelled(false);
|
||||
}
|
||||
}, [isLoadingAlertQuery]);
|
||||
|
||||
const handleCancelAlertQuery = useCallback(() => {
|
||||
ruleCache.cancelQueries(REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW);
|
||||
setIsChartQueryCancelled(true);
|
||||
}, [ruleCache]);
|
||||
|
||||
const isNewRule = !ruleId || isEmpty(ruleId);
|
||||
|
||||
@@ -715,8 +702,6 @@ function FormAlertRules({
|
||||
yAxisUnit={yAxisUnit || ''}
|
||||
graphType={panelType || PANEL_TYPES.TIME_SERIES}
|
||||
setQueryStatus={setQueryStatus}
|
||||
isCancelled={isChartQueryCancelled}
|
||||
onFetchingStateChange={setIsLoadingAlertQuery}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -735,8 +720,6 @@ function FormAlertRules({
|
||||
yAxisUnit={yAxisUnit || ''}
|
||||
graphType={panelType || PANEL_TYPES.TIME_SERIES}
|
||||
setQueryStatus={setQueryStatus}
|
||||
isCancelled={isChartQueryCancelled}
|
||||
onFetchingStateChange={setIsLoadingAlertQuery}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -919,15 +902,7 @@ function FormAlertRules({
|
||||
queryCategory={currentQuery.queryType}
|
||||
setQueryCategory={onQueryCategoryChange}
|
||||
alertType={alertType || AlertTypes.METRICS_BASED_ALERT}
|
||||
runQuery={(): void => {
|
||||
setIsChartQueryCancelled(false);
|
||||
ruleCache.invalidateQueries([
|
||||
REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW,
|
||||
]);
|
||||
handleRunQuery();
|
||||
}}
|
||||
isLoadingQueries={isLoadingAlertQuery}
|
||||
handleCancelQuery={handleCancelAlertQuery}
|
||||
runQuery={(): void => handleRunQuery()}
|
||||
alertDef={alertDef}
|
||||
panelType={panelType || PANEL_TYPES.TIME_SERIES}
|
||||
key={currentQuery.queryType}
|
||||
|
||||
@@ -6,7 +6,6 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux'; // old code, TODO: fix this correctly
|
||||
import {
|
||||
@@ -19,7 +18,6 @@ import cx from 'classnames';
|
||||
import { ToggleGraphProps } from 'components/Graph/types';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import Spinner from 'components/Spinner';
|
||||
import TimePreference from 'components/TimePreferenceDropDown';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
@@ -88,7 +86,6 @@ function FullView({
|
||||
|
||||
const fullViewRef = useRef<HTMLDivElement>(null);
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentGraphRef(fullViewRef);
|
||||
@@ -206,8 +203,8 @@ function FullView({
|
||||
});
|
||||
}, [selectedPanelType]);
|
||||
|
||||
const queryRangeKey = useMemo(
|
||||
() => [
|
||||
const response = useGetQueryRange(requestData, ENTITY_VERSION_V5, {
|
||||
queryKey: [
|
||||
widget?.query,
|
||||
selectedPanelType,
|
||||
requestData,
|
||||
@@ -215,28 +212,10 @@ function FullView({
|
||||
minTime,
|
||||
maxTime,
|
||||
],
|
||||
[widget?.query, selectedPanelType, requestData, version, minTime, maxTime],
|
||||
);
|
||||
|
||||
const response = useGetQueryRange(requestData, ENTITY_VERSION_V5, {
|
||||
queryKey: queryRangeKey,
|
||||
enabled: !isDependedDataLoaded,
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (response.isFetching) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [response.isFetching]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
queryClient.cancelQueries(queryRangeKey);
|
||||
setIsCancelled(true);
|
||||
}, [queryClient, queryRangeKey]);
|
||||
|
||||
const onDragSelect = useCallback((start: number, end: number): void => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
@@ -375,8 +354,6 @@ function FullView({
|
||||
onStageRunQuery={(): void => {
|
||||
handleRunQuery();
|
||||
}}
|
||||
isLoadingQueries={response.isFetching}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -409,27 +386,23 @@ function FullView({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isCancelled ? (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to reload the widget.' />
|
||||
) : (
|
||||
<PanelWrapper
|
||||
panelMode={PanelMode.STANDALONE_VIEW}
|
||||
queryResponse={response}
|
||||
widget={widget}
|
||||
setRequestData={setRequestData}
|
||||
isFullViewMode
|
||||
onToggleModelHandler={onToggleModelHandler}
|
||||
setGraphVisibility={setGraphsVisibilityStates}
|
||||
graphVisibility={graphsVisibilityStates}
|
||||
onDragSelect={customOnDragSelect ?? onDragSelect}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
searchTerm={searchTerm}
|
||||
onClickHandler={onClickHandler}
|
||||
enableDrillDown={enableDrillDown}
|
||||
selectedGraph={selectedPanelType}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>
|
||||
)}
|
||||
<PanelWrapper
|
||||
panelMode={PanelMode.STANDALONE_VIEW}
|
||||
queryResponse={response}
|
||||
widget={widget}
|
||||
setRequestData={setRequestData}
|
||||
isFullViewMode
|
||||
onToggleModelHandler={onToggleModelHandler}
|
||||
setGraphVisibility={setGraphsVisibilityStates}
|
||||
graphVisibility={graphsVisibilityStates}
|
||||
onDragSelect={customOnDragSelect ?? onDragSelect}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
searchTerm={searchTerm}
|
||||
onClickHandler={onClickHandler}
|
||||
enableDrillDown={enableDrillDown}
|
||||
selectedGraph={selectedPanelType}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -8,7 +7,6 @@ import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
@@ -39,20 +37,6 @@ function Explorer(): JSX.Element {
|
||||
currentQuery,
|
||||
} = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingQueries) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [isLoadingQueries]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
queryClient.cancelQueries([REACT_QUERY_KEY.GET_QUERY_RANGE]);
|
||||
setIsCancelled(true);
|
||||
}, [queryClient]);
|
||||
|
||||
const [showQuickFilters, setShowQuickFilters] = useState(true);
|
||||
|
||||
@@ -171,11 +155,7 @@ function Explorer(): JSX.Element {
|
||||
|
||||
<div className="explore-header-right-actions">
|
||||
<DateTimeSelector showAutoRefresh />
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
<RightToolbarActions onStageRunQuery={(): void => handleRunQuery()} />
|
||||
</div>
|
||||
</div>
|
||||
<QueryBuilderV2
|
||||
@@ -191,10 +171,7 @@ function Explorer(): JSX.Element {
|
||||
/>
|
||||
|
||||
<div className="explore-content">
|
||||
<TimeSeries
|
||||
onFetchingStateChange={setIsLoadingQueries}
|
||||
isCancelled={isCancelled}
|
||||
/>
|
||||
<TimeSeries />
|
||||
</div>
|
||||
</div>
|
||||
<ExplorerOptionWrapper
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Button } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { QueryBuilder } from 'container/QueryBuilder';
|
||||
import { ButtonWrapper } from 'container/TracesExplorer/QuerySection/styles';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { MeterExplorerEventKeys, MeterExplorerEvents } from '../events';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
return (
|
||||
<div className="query-section">
|
||||
<QueryBuilder
|
||||
panelType={panelTypes}
|
||||
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
|
||||
version="v4"
|
||||
actions={
|
||||
<ButtonWrapper>
|
||||
<Button
|
||||
onClick={(): void => {
|
||||
handleRunQuery();
|
||||
logEvent(MeterExplorerEvents.QueryBuilderQueryChanged, {
|
||||
[MeterExplorerEventKeys.Tab]: 'explorer',
|
||||
});
|
||||
}}
|
||||
type="primary"
|
||||
>
|
||||
Run Query
|
||||
</Button>
|
||||
</ButtonWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuerySection;
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { isAxiosError } from 'axios';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
|
||||
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
|
||||
@@ -23,15 +21,7 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
interface TimeSeriesProps {
|
||||
onFetchingStateChange?: (isFetching: boolean) => void;
|
||||
isCancelled?: boolean;
|
||||
}
|
||||
|
||||
function TimeSeries({
|
||||
onFetchingStateChange,
|
||||
isCancelled = false,
|
||||
}: TimeSeriesProps): JSX.Element {
|
||||
function TimeSeries(): JSX.Element {
|
||||
const { stagedQuery, currentQuery } = useQueryBuilder();
|
||||
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit('');
|
||||
|
||||
@@ -77,11 +67,7 @@ function TimeSeries({
|
||||
minTime,
|
||||
index,
|
||||
],
|
||||
queryFn: ({
|
||||
signal,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
}): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
GetMetricQueryRange(
|
||||
{
|
||||
query: payload,
|
||||
@@ -93,15 +79,9 @@ function TimeSeries({
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
undefined,
|
||||
signal,
|
||||
),
|
||||
enabled: !!payload,
|
||||
retry: (failureCount: number, error: unknown): boolean => {
|
||||
if (isAxiosError(error) && error.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
|
||||
retry: (failureCount: number, error: Error): boolean => {
|
||||
let status: number | undefined;
|
||||
|
||||
if (error instanceof APIError) {
|
||||
@@ -114,7 +94,7 @@ function TimeSeries({
|
||||
return false;
|
||||
}
|
||||
|
||||
return failureCount < MAX_QUERY_RETRIES;
|
||||
return failureCount < 3;
|
||||
},
|
||||
onError: (error: APIError): void => {
|
||||
showErrorModal(error);
|
||||
@@ -122,11 +102,6 @@ function TimeSeries({
|
||||
})),
|
||||
);
|
||||
|
||||
const isFetching = queries.some((q) => q.isFetching);
|
||||
useEffect(() => {
|
||||
onFetchingStateChange?.(isFetching);
|
||||
}, [isFetching, onFetchingStateChange]);
|
||||
|
||||
const data = useMemo(() => queries.map(({ data }) => data) ?? [], [queries]);
|
||||
|
||||
const responseData = useMemo(
|
||||
@@ -147,11 +122,7 @@ function TimeSeries({
|
||||
<BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
|
||||
<div className="time-series-container">
|
||||
{!hasMetricSelected && <EmptyMetricsSearch />}
|
||||
{isCancelled && hasMetricSelected && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load metrics.' />
|
||||
)}
|
||||
{!isCancelled &&
|
||||
hasMetricSelected &&
|
||||
{hasMetricSelected &&
|
||||
responseData.map((datapoint, index) => (
|
||||
<div
|
||||
className="time-series-view-panel"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Switch, Tooltip } from 'antd';
|
||||
@@ -7,7 +6,6 @@ import logEvent from 'api/common/logEvent';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
@@ -56,21 +54,6 @@ function Explorer(): JSX.Element {
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingQueries) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [isLoadingQueries]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
queryClient.cancelQueries([REACT_QUERY_KEY.GET_QUERY_RANGE]);
|
||||
setIsCancelled(true);
|
||||
}, [queryClient]);
|
||||
|
||||
const metricNames = useMemo(() => {
|
||||
const currentMetricNames: string[] = [];
|
||||
stagedQuery?.builder.queryData.forEach((query) => {
|
||||
@@ -324,11 +307,7 @@ function Explorer(): JSX.Element {
|
||||
<div className="explore-header-right-actions">
|
||||
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
|
||||
<DateTimeSelector showAutoRefresh />
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
<RightToolbarActions onStageRunQuery={(): void => handleRunQuery()} />
|
||||
</div>
|
||||
</div>
|
||||
<QueryBuilderV2
|
||||
@@ -340,7 +319,6 @@ function Explorer(): JSX.Element {
|
||||
/>
|
||||
<div className="explore-content">
|
||||
<TimeSeries
|
||||
onFetchingStateChange={setIsLoadingQueries}
|
||||
showOneChartPerQuery={showOneChartPerQuery}
|
||||
setWarning={setWarning}
|
||||
areAllMetricUnitsSame={areAllMetricUnitsSame}
|
||||
@@ -353,7 +331,6 @@ function Explorer(): JSX.Element {
|
||||
yAxisUnit={yAxisUnit}
|
||||
setYAxisUnit={setYAxisUnit}
|
||||
showYAxisUnitSelector={showYAxisUnitSelector}
|
||||
isCancelled={isCancelled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useIsFetching, useQueryClient } from 'react-query';
|
||||
import { Button } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { QueryBuilder } from 'container/QueryBuilder';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { ButtonWrapper } from 'container/TracesExplorer/QuerySection/styles';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -14,16 +11,9 @@ import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
const isLoadingQueries = useIsFetching([REACT_QUERY_KEY.GET_QUERY_RANGE]) > 0;
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
queryClient.cancelQueries([REACT_QUERY_KEY.GET_QUERY_RANGE]);
|
||||
}, [queryClient]);
|
||||
|
||||
return (
|
||||
<div className="query-section">
|
||||
<QueryBuilder
|
||||
@@ -32,16 +22,17 @@ function QuerySection(): JSX.Element {
|
||||
version="v4"
|
||||
actions={
|
||||
<ButtonWrapper>
|
||||
<RunQueryBtn
|
||||
onStageRunQuery={(): void => {
|
||||
<Button
|
||||
onClick={(): void => {
|
||||
handleRunQuery();
|
||||
logEvent(MetricsExplorerEvents.QueryBuilderQueryChanged, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'explorer',
|
||||
});
|
||||
}}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
type="primary"
|
||||
>
|
||||
Run Query
|
||||
</Button>
|
||||
</ButtonWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useQueries, useQueryClient } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -11,12 +11,10 @@ import {
|
||||
} from 'api/generated/services/metrics';
|
||||
import { isAxiosError } from 'axios';
|
||||
import classNames from 'classnames';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
@@ -38,7 +36,6 @@ import {
|
||||
} from './utils';
|
||||
|
||||
function TimeSeries({
|
||||
onFetchingStateChange,
|
||||
showOneChartPerQuery,
|
||||
setWarning,
|
||||
isMetricUnitsLoading,
|
||||
@@ -49,7 +46,6 @@ function TimeSeries({
|
||||
setYAxisUnit,
|
||||
showYAxisUnitSelector,
|
||||
metrics,
|
||||
isCancelled = false,
|
||||
}: TimeSeriesProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery } = useQueryBuilder();
|
||||
|
||||
@@ -102,11 +98,7 @@ function TimeSeries({
|
||||
minTime,
|
||||
index,
|
||||
],
|
||||
queryFn: ({
|
||||
signal,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
}): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
GetMetricQueryRange(
|
||||
{
|
||||
query: payload,
|
||||
@@ -119,15 +111,9 @@ function TimeSeries({
|
||||
},
|
||||
// ENTITY_VERSION_V4,
|
||||
ENTITY_VERSION_V5,
|
||||
undefined,
|
||||
signal,
|
||||
),
|
||||
enabled: !!payload,
|
||||
retry: (failureCount: number, error: unknown): boolean => {
|
||||
if (isAxiosError(error) && error.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
|
||||
retry: (failureCount: number, error: Error): boolean => {
|
||||
let status: number | undefined;
|
||||
|
||||
if (error instanceof APIError) {
|
||||
@@ -140,16 +126,11 @@ function TimeSeries({
|
||||
return false;
|
||||
}
|
||||
|
||||
return failureCount < MAX_QUERY_RETRIES;
|
||||
return failureCount < 3;
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
const isFetching = queries.some((q) => q.isFetching);
|
||||
useEffect(() => {
|
||||
onFetchingStateChange?.(isFetching);
|
||||
}, [isFetching, onFetchingStateChange]);
|
||||
|
||||
const data = useMemo(() => queries.map(({ data }) => data) ?? [], [queries]);
|
||||
|
||||
const responseData = useMemo(
|
||||
@@ -250,11 +231,7 @@ function TimeSeries({
|
||||
})}
|
||||
>
|
||||
{metricNames.length === 0 && <EmptyMetricsSearch />}
|
||||
{isCancelled && metricNames.length > 0 && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load metrics.' />
|
||||
)}
|
||||
{!isCancelled &&
|
||||
metricNames.length > 0 &&
|
||||
{metricNames.length > 0 &&
|
||||
responseData.map((datapoint, index) => {
|
||||
const isQueryDataItem = index < metricNames.length;
|
||||
const metricName = isQueryDataItem ? metricNames[index] : undefined;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { MetricsexplorertypesMetricMetadataDTO } from 'api/generated/services/si
|
||||
import { Warning } from 'types/api';
|
||||
|
||||
export interface TimeSeriesProps {
|
||||
onFetchingStateChange?: (isFetching: boolean) => void;
|
||||
showOneChartPerQuery: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
areAllMetricUnitsSame: boolean;
|
||||
@@ -16,5 +15,4 @@ export interface TimeSeriesProps {
|
||||
yAxisUnit: string | undefined;
|
||||
setYAxisUnit: (unit: string) => void;
|
||||
showYAxisUnitSelector: boolean;
|
||||
isCancelled?: boolean;
|
||||
}
|
||||
|
||||
@@ -4,25 +4,9 @@
|
||||
|
||||
.inspect-metrics-fallback {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
|
||||
.inspect-metrics-fallback-header-placeholder {
|
||||
// Reserve the same vertical space the GraphView header occupies
|
||||
// (antd middle button height) so swapping chart ↔ fallback causes
|
||||
// no layout shift.
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inspect-metrics-fallback-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 520px;
|
||||
}
|
||||
}
|
||||
|
||||
.inspect-metrics-title {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Drawer, Empty, Skeleton, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetMetricMetadata } from 'api/generated/services/metrics';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
@@ -112,28 +109,6 @@ function Inspect({
|
||||
reset,
|
||||
} = useInspectMetrics(appliedMetricName);
|
||||
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
// Auto-reset isCancelled only on the rising edge of a new fetch
|
||||
// (transition from not-loading → loading). Watching `isLoading` directly
|
||||
// races with the cancel flow — when the user cancels mid-fetch, loading
|
||||
// is still true in the render right after setIsCancelled(true), which
|
||||
// would immediately reset it.
|
||||
const wasLoadingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const nowLoading = isInspectMetricsLoading || isInspectMetricsRefetching;
|
||||
if (!wasLoadingRef.current && nowLoading) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
wasLoadingRef.current = nowLoading;
|
||||
}, [isInspectMetricsLoading, isInspectMetricsRefetching]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const handleCancelInspectQuery = useCallback(() => {
|
||||
queryClient.cancelQueries(REACT_QUERY_KEY.GET_INSPECT_METRICS_DETAILS);
|
||||
setIsCancelled(true);
|
||||
}, [queryClient]);
|
||||
|
||||
const handleDispatchMetricInspectionOptions = useCallback(
|
||||
(action: MetricInspectionAction): void => {
|
||||
dispatchMetricInspectionOptions(action);
|
||||
@@ -192,66 +167,96 @@ function Inspect({
|
||||
setExpandedViewOptions(null);
|
||||
}, [inspectionStep]);
|
||||
|
||||
const chartArea = useMemo(() => {
|
||||
const renderFallback = (testId: string, body: JSX.Element): JSX.Element => (
|
||||
<div data-testid={testId} className="inspect-metrics-fallback">
|
||||
<div className="inspect-metrics-fallback-header-placeholder" />
|
||||
<div className="inspect-metrics-fallback-body">{body}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Cancelled state takes precedence over any react-query state — ensures
|
||||
// the placeholder shows immediately on cancel, regardless of whether
|
||||
// isLoading/isRefetching has settled yet.
|
||||
if (isCancelled) {
|
||||
return renderFallback(
|
||||
'inspect-metrics-cancelled',
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to see inspect results.' />,
|
||||
const content = useMemo(() => {
|
||||
if (isInspectMetricsLoading && !isInspectMetricsRefetching) {
|
||||
return (
|
||||
<div
|
||||
data-testid="inspect-metrics-loading"
|
||||
className="inspect-metrics-fallback"
|
||||
>
|
||||
<Skeleton active />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isInspectMetricsLoading && !isInspectMetricsRefetching) {
|
||||
return renderFallback('inspect-metrics-loading', <Skeleton active />);
|
||||
}
|
||||
|
||||
if (isInspectMetricsError) {
|
||||
return renderFallback(
|
||||
'inspect-metrics-error',
|
||||
<Empty description="Error loading inspect metrics." />,
|
||||
const errorMessage = 'Error loading inspect metrics.';
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="inspect-metrics-error"
|
||||
className="inspect-metrics-fallback"
|
||||
>
|
||||
<Empty description={errorMessage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!inspectMetricsTimeSeries.length) {
|
||||
return renderFallback(
|
||||
'inspect-metrics-empty',
|
||||
<Empty description="No time series found for this metric to inspect." />,
|
||||
return (
|
||||
<div
|
||||
data-testid="inspect-metrics-empty"
|
||||
className="inspect-metrics-fallback"
|
||||
>
|
||||
<Empty description="No time series found for this metric to inspect." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GraphView
|
||||
inspectMetricsTimeSeries={aggregatedTimeSeries}
|
||||
formattedInspectMetricsTimeSeries={formattedInspectMetricsTimeSeries}
|
||||
resetInspection={resetInspection}
|
||||
metricName={appliedMetricName}
|
||||
metricUnit={selectedMetricUnit}
|
||||
metricType={selectedMetricType}
|
||||
spaceAggregationSeriesMap={spaceAggregationSeriesMap}
|
||||
inspectionStep={inspectionStep}
|
||||
setPopoverOptions={setPopoverOptions}
|
||||
setShowExpandedView={setShowExpandedView}
|
||||
showExpandedView={showExpandedView}
|
||||
setExpandedViewOptions={setExpandedViewOptions}
|
||||
popoverOptions={popoverOptions}
|
||||
metricInspectionAppliedOptions={metricInspectionOptions.appliedOptions}
|
||||
isInspectMetricsRefetching={isInspectMetricsRefetching}
|
||||
/>
|
||||
<div className="inspect-metrics-content">
|
||||
<div className="inspect-metrics-content-first-col">
|
||||
<GraphView
|
||||
inspectMetricsTimeSeries={aggregatedTimeSeries}
|
||||
formattedInspectMetricsTimeSeries={formattedInspectMetricsTimeSeries}
|
||||
resetInspection={resetInspection}
|
||||
metricName={appliedMetricName}
|
||||
metricUnit={selectedMetricUnit}
|
||||
metricType={selectedMetricType}
|
||||
spaceAggregationSeriesMap={spaceAggregationSeriesMap}
|
||||
inspectionStep={inspectionStep}
|
||||
setPopoverOptions={setPopoverOptions}
|
||||
setShowExpandedView={setShowExpandedView}
|
||||
showExpandedView={showExpandedView}
|
||||
setExpandedViewOptions={setExpandedViewOptions}
|
||||
popoverOptions={popoverOptions}
|
||||
metricInspectionAppliedOptions={metricInspectionOptions.appliedOptions}
|
||||
isInspectMetricsRefetching={isInspectMetricsRefetching}
|
||||
/>
|
||||
<QueryBuilder
|
||||
currentMetricName={currentMetricName}
|
||||
setCurrentMetricName={setCurrentMetricName}
|
||||
setAppliedMetricName={setAppliedMetricName}
|
||||
spaceAggregationLabels={spaceAggregationLabels}
|
||||
currentMetricInspectionOptions={metricInspectionOptions.currentOptions}
|
||||
dispatchMetricInspectionOptions={handleDispatchMetricInspectionOptions}
|
||||
inspectionStep={inspectionStep}
|
||||
inspectMetricsTimeSeries={inspectMetricsTimeSeries}
|
||||
currentQuery={currentQueryData}
|
||||
setCurrentQuery={setCurrentQueryData}
|
||||
/>
|
||||
</div>
|
||||
<div className="inspect-metrics-content-second-col">
|
||||
<Stepper
|
||||
inspectionStep={inspectionStep}
|
||||
resetInspection={resetInspection}
|
||||
/>
|
||||
{showExpandedView && (
|
||||
<ExpandedView
|
||||
options={expandedViewOptions}
|
||||
spaceAggregationSeriesMap={spaceAggregationSeriesMap}
|
||||
step={inspectionStep}
|
||||
metricInspectionAppliedOptions={metricInspectionOptions.appliedOptions}
|
||||
timeAggregatedSeriesMap={timeAggregatedSeriesMap}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [
|
||||
isInspectMetricsLoading,
|
||||
isInspectMetricsRefetching,
|
||||
isInspectMetricsError,
|
||||
isCancelled,
|
||||
inspectMetricsTimeSeries,
|
||||
aggregatedTimeSeries,
|
||||
formattedInspectMetricsTimeSeries,
|
||||
@@ -307,46 +312,7 @@ function Inspect({
|
||||
className="inspect-metrics-modal"
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="inspect-metrics-content">
|
||||
<div className="inspect-metrics-content-first-col">
|
||||
{chartArea}
|
||||
<QueryBuilder
|
||||
currentMetricName={currentMetricName}
|
||||
setCurrentMetricName={setCurrentMetricName}
|
||||
setAppliedMetricName={setAppliedMetricName}
|
||||
spaceAggregationLabels={spaceAggregationLabels}
|
||||
currentMetricInspectionOptions={metricInspectionOptions.currentOptions}
|
||||
dispatchMetricInspectionOptions={handleDispatchMetricInspectionOptions}
|
||||
inspectionStep={inspectionStep}
|
||||
inspectMetricsTimeSeries={inspectMetricsTimeSeries}
|
||||
currentQuery={currentQueryData}
|
||||
setCurrentQuery={setCurrentQueryData}
|
||||
isLoadingQueries={isInspectMetricsLoading || isInspectMetricsRefetching}
|
||||
handleCancelQuery={handleCancelInspectQuery}
|
||||
onRunQuery={(): void => {
|
||||
setIsCancelled(false);
|
||||
queryClient.invalidateQueries([
|
||||
REACT_QUERY_KEY.GET_INSPECT_METRICS_DETAILS,
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="inspect-metrics-content-second-col">
|
||||
<Stepper
|
||||
inspectionStep={inspectionStep}
|
||||
resetInspection={resetInspection}
|
||||
/>
|
||||
{showExpandedView && (
|
||||
<ExpandedView
|
||||
options={expandedViewOptions}
|
||||
spaceAggregationSeriesMap={spaceAggregationSeriesMap}
|
||||
step={inspectionStep}
|
||||
metricInspectionAppliedOptions={metricInspectionOptions.appliedOptions}
|
||||
timeAggregatedSeriesMap={timeAggregatedSeriesMap}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{content}
|
||||
</Drawer>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -20,22 +20,13 @@ function QueryBuilder({
|
||||
inspectMetricsTimeSeries,
|
||||
currentQuery,
|
||||
setCurrentQuery,
|
||||
isLoadingQueries,
|
||||
handleCancelQuery,
|
||||
onRunQuery,
|
||||
}: QueryBuilderProps): JSX.Element {
|
||||
const applyInspectionOptions = useCallback(() => {
|
||||
onRunQuery?.();
|
||||
setAppliedMetricName(currentMetricName ?? '');
|
||||
dispatchMetricInspectionOptions({
|
||||
type: 'APPLY_METRIC_INSPECTION_OPTIONS',
|
||||
});
|
||||
}, [
|
||||
currentMetricName,
|
||||
setAppliedMetricName,
|
||||
dispatchMetricInspectionOptions,
|
||||
onRunQuery,
|
||||
]);
|
||||
}, [currentMetricName, setAppliedMetricName, dispatchMetricInspectionOptions]);
|
||||
|
||||
return (
|
||||
<div className="inspect-metrics-query-builder">
|
||||
@@ -48,11 +39,7 @@ function QueryBuilder({
|
||||
>
|
||||
Query Builder
|
||||
</Button>
|
||||
<RunQueryBtn
|
||||
onStageRunQuery={applyInspectionOptions}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
/>
|
||||
<RunQueryBtn onStageRunQuery={applyInspectionOptions} />
|
||||
</div>
|
||||
<Card className="inspect-metrics-query-builder-content">
|
||||
<MetricNameSearch
|
||||
|
||||
@@ -103,8 +103,6 @@ describe('QueryBuilder', () => {
|
||||
filterExpression: '',
|
||||
} as any,
|
||||
setCurrentQuery: jest.fn(),
|
||||
isLoadingQueries: false,
|
||||
handleCancelQuery: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -65,9 +65,6 @@ export interface QueryBuilderProps {
|
||||
inspectMetricsTimeSeries: InspectMetricsSeries[];
|
||||
currentQuery: IBuilderQuery;
|
||||
setCurrentQuery: (query: IBuilderQuery) => void;
|
||||
isLoadingQueries: boolean;
|
||||
handleCancelQuery: () => void;
|
||||
onRunQuery?: () => void;
|
||||
}
|
||||
|
||||
export interface MetricNameSearchProps {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { inspectMetrics } from 'api/generated/services/metrics';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
@@ -110,7 +107,7 @@ export function useInspectMetrics(
|
||||
isRefetching: isInspectMetricsRefetching,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_INSPECT_METRICS_DETAILS,
|
||||
'inspectMetrics',
|
||||
metricName,
|
||||
start,
|
||||
end,
|
||||
@@ -130,12 +127,6 @@ export function useInspectMetrics(
|
||||
),
|
||||
enabled: !!metricName,
|
||||
keepPreviousData: true,
|
||||
retry: (failureCount: number, error: Error): boolean => {
|
||||
if (isAxiosError(error) && error.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
return failureCount < MAX_QUERY_RETRIES;
|
||||
},
|
||||
});
|
||||
|
||||
const inspectMetricsData = useMemo(
|
||||
|
||||
@@ -12,8 +12,6 @@ function MetricsSearch({
|
||||
currentQueryFilterExpression,
|
||||
setCurrentQueryFilterExpression,
|
||||
isLoading,
|
||||
handleCancelQuery,
|
||||
onRunQuery,
|
||||
}: MetricsSearchProps): JSX.Element {
|
||||
const handleOnChange = useCallback(
|
||||
(expression: string): void => {
|
||||
@@ -24,8 +22,7 @@ function MetricsSearch({
|
||||
|
||||
const handleStageAndRunQuery = useCallback(() => {
|
||||
onChange(currentQueryFilterExpression);
|
||||
onRunQuery?.();
|
||||
}, [currentQueryFilterExpression, onChange, onRunQuery]);
|
||||
}, [currentQueryFilterExpression, onChange]);
|
||||
|
||||
const handleRunQuery = useCallback(
|
||||
(expression: string): void => {
|
||||
@@ -56,7 +53,6 @@ function MetricsSearch({
|
||||
<RunQueryBtn
|
||||
onStageRunQuery={handleStageAndRunQuery}
|
||||
isLoadingQueries={isLoading}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
<div className="metrics-search-options">
|
||||
<DateTimeSelectionV2
|
||||
|
||||
@@ -4,7 +4,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux'; // old code, TODO: fix this correctly
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
Querybuildertypesv5OrderByDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import eyesEmojiUrl from 'assets/Images/eyesEmoji.svg';
|
||||
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { usePageSize } from 'container/InfraMonitoringK8s/utils';
|
||||
@@ -106,8 +104,6 @@ function Summary(): JSX.Element {
|
||||
setCurrentQueryFilterExpression,
|
||||
] = useState<string>(appliedFilterExpression);
|
||||
|
||||
const [isCancelled, setIsCancelled] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQueryFilterExpression(appliedFilterExpression);
|
||||
}, [appliedFilterExpression]);
|
||||
@@ -168,7 +164,6 @@ function Summary(): JSX.Element {
|
||||
isLoading: isGetMetricsStatsLoading,
|
||||
isError: isGetMetricsStatsError,
|
||||
error: metricsStatsError,
|
||||
reset: resetMetricsStats,
|
||||
} = useGetMetricsStats();
|
||||
|
||||
const {
|
||||
@@ -177,7 +172,6 @@ function Summary(): JSX.Element {
|
||||
isLoading: isGetMetricsTreemapLoading,
|
||||
isError: isGetMetricsTreemapError,
|
||||
error: metricsTreemapError,
|
||||
reset: resetMetricsTreemap,
|
||||
} = useGetMetricsTreemap();
|
||||
|
||||
const metricsStatsApiError = useMemo(
|
||||
@@ -202,40 +196,6 @@ function Summary(): JSX.Element {
|
||||
});
|
||||
}, [metricsTreemapQuery, getMetricsTreemap]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
resetMetricsStats();
|
||||
resetMetricsTreemap();
|
||||
setCurrentQueryFilterExpression(appliedFilterExpression);
|
||||
setIsCancelled(true);
|
||||
}, [
|
||||
resetMetricsStats,
|
||||
resetMetricsTreemap,
|
||||
setCurrentQueryFilterExpression,
|
||||
appliedFilterExpression,
|
||||
]);
|
||||
|
||||
const handleRunQuery = useCallback(() => {
|
||||
setIsCancelled(false);
|
||||
getMetricsStats({
|
||||
data: {
|
||||
...metricsListQuery,
|
||||
filter: { expression: currentQueryFilterExpression },
|
||||
},
|
||||
});
|
||||
getMetricsTreemap({
|
||||
data: {
|
||||
...metricsTreemapQuery,
|
||||
filter: { expression: currentQueryFilterExpression },
|
||||
},
|
||||
});
|
||||
}, [
|
||||
getMetricsStats,
|
||||
getMetricsTreemap,
|
||||
metricsListQuery,
|
||||
metricsTreemapQuery,
|
||||
currentQueryFilterExpression,
|
||||
]);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(expression: string) => {
|
||||
const newFilters: TagFilter = {
|
||||
@@ -370,19 +330,11 @@ function Summary(): JSX.Element {
|
||||
!isGetMetricsTreemapLoading &&
|
||||
!isGetMetricsTreemapError;
|
||||
|
||||
const isLoadingQueries =
|
||||
isGetMetricsStatsLoading || isGetMetricsTreemapLoading;
|
||||
|
||||
const showFullScreenLoading =
|
||||
isLoadingQueries &&
|
||||
(isGetMetricsStatsLoading || isGetMetricsTreemapLoading) &&
|
||||
formattedMetricsData.length === 0 &&
|
||||
!treeMapData?.data[heatmapView]?.length;
|
||||
|
||||
const showNoMetrics =
|
||||
isMetricsListDataEmpty &&
|
||||
isMetricsTreeMapDataEmpty &&
|
||||
!appliedFilterExpression;
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className="metrics-explorer-summary-tab">
|
||||
@@ -391,26 +343,13 @@ function Summary(): JSX.Element {
|
||||
onChange={handleFilterChange}
|
||||
currentQueryFilterExpression={currentQueryFilterExpression}
|
||||
setCurrentQueryFilterExpression={setCurrentQueryFilterExpression}
|
||||
isLoading={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
onRunQuery={handleRunQuery}
|
||||
isLoading={isGetMetricsStatsLoading || isGetMetricsTreemapLoading}
|
||||
/>
|
||||
{showFullScreenLoading ? (
|
||||
<MetricsLoading />
|
||||
) : isCancelled ? (
|
||||
<div className="no-logs-container">
|
||||
<div className="no-logs-container-content">
|
||||
<img className="eyes-emoji" src={eyesEmojiUrl} alt="eyes emoji" />
|
||||
<Typography className="no-logs-text">
|
||||
Query cancelled.
|
||||
<span className="sub-text">
|
||||
{' '}
|
||||
Click "Run Query" to load metrics.
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
) : showNoMetrics ? (
|
||||
) : isMetricsListDataEmpty &&
|
||||
isMetricsTreeMapDataEmpty &&
|
||||
!appliedFilterExpression ? (
|
||||
<NoLogs dataSource={DataSource.METRICS} />
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -33,8 +33,6 @@ export interface MetricsSearchProps {
|
||||
currentQueryFilterExpression: string;
|
||||
setCurrentQueryFilterExpression: (expression: string) => void;
|
||||
isLoading: boolean;
|
||||
handleCancelQuery: () => void;
|
||||
onRunQuery: () => void;
|
||||
}
|
||||
|
||||
export interface MetricsTreemapProps {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Tabs, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -24,8 +25,8 @@ import PromQLQueryContainer from './QueryBuilder/promQL';
|
||||
import './QuerySection.styles.scss';
|
||||
function QuerySection({
|
||||
selectedGraph,
|
||||
queryRangeKey,
|
||||
isLoadingQueries,
|
||||
handleCancelQuery,
|
||||
selectedWidget,
|
||||
dashboardVersion,
|
||||
dashboardId,
|
||||
@@ -178,7 +179,7 @@ function QuerySection({
|
||||
label="Stage & Run Query"
|
||||
onStageRunQuery={handleRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
queryRangeKey={queryRangeKey}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
@@ -190,8 +191,8 @@ function QuerySection({
|
||||
|
||||
interface QueryProps {
|
||||
selectedGraph: PANEL_TYPES;
|
||||
isLoadingQueries: boolean;
|
||||
handleCancelQuery: () => void;
|
||||
queryRangeKey?: QueryKey;
|
||||
isLoadingQueries?: boolean;
|
||||
selectedWidget: Widgets;
|
||||
dashboardVersion?: string;
|
||||
dashboardId?: string;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { Card } from 'container/GridCardLayout/styles';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
@@ -23,7 +22,6 @@ function WidgetGraph({
|
||||
selectedWidget,
|
||||
isLoadingPanelData,
|
||||
enableDrillDown = false,
|
||||
isCancelled = false,
|
||||
}: WidgetGraphContainerProps): JSX.Element {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
@@ -48,24 +46,20 @@ function WidgetGraph({
|
||||
</div>
|
||||
<DateTimeSelectionV2 showAutoRefresh={false} hideShareModal />
|
||||
</div>
|
||||
{!isCancelled && queryResponse.error && (
|
||||
{queryResponse.error && (
|
||||
<AlertIconContainer color="red" title={queryResponse.error.message}>
|
||||
<InfoCircleOutlined />
|
||||
</AlertIconContainer>
|
||||
)}
|
||||
|
||||
{isCancelled ? (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to reload the chart.' />
|
||||
) : (
|
||||
<WidgetGraphComponent
|
||||
isLoadingPanelData={isLoadingPanelData}
|
||||
selectedGraph={selectedGraph}
|
||||
queryResponse={queryResponse}
|
||||
setRequestData={setRequestData}
|
||||
selectedWidget={selectedWidget}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
)}
|
||||
<WidgetGraphComponent
|
||||
isLoadingPanelData={isLoadingPanelData}
|
||||
selectedGraph={selectedGraph}
|
||||
queryResponse={queryResponse}
|
||||
setRequestData={setRequestData}
|
||||
selectedWidget={selectedWidget}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { memo, useEffect } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
@@ -34,7 +34,6 @@ function LeftContainer({
|
||||
isNewPanel = false,
|
||||
}: WidgetGraphProps): JSX.Element {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { selectedTime: globalSelectedInterval, minTime, maxTime } = useSelector<
|
||||
AppState,
|
||||
@@ -50,25 +49,12 @@ function LeftContainer({
|
||||
],
|
||||
[globalSelectedInterval, requestData, minTime, maxTime],
|
||||
);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
queryClient.cancelQueries(queryRangeKey);
|
||||
setIsCancelled(true);
|
||||
}, [queryClient, queryRangeKey]);
|
||||
|
||||
const queryResponse = useGetQueryRange(requestData, ENTITY_VERSION_V5, {
|
||||
enabled: !!stagedQuery,
|
||||
queryKey: queryRangeKey,
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (queryResponse.isFetching) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [queryResponse.isFetching]);
|
||||
|
||||
// Update parent component with query response for legend colors
|
||||
useEffect(() => {
|
||||
if (setQueryResponse) {
|
||||
@@ -85,13 +71,12 @@ function LeftContainer({
|
||||
selectedWidget={selectedWidget}
|
||||
isLoadingPanelData={isLoadingPanelData}
|
||||
enableDrillDown={enableDrillDown}
|
||||
isCancelled={isCancelled}
|
||||
/>
|
||||
<QueryContainer className="query-section-left-container">
|
||||
<QuerySection
|
||||
selectedGraph={selectedGraph}
|
||||
queryRangeKey={queryRangeKey}
|
||||
isLoadingQueries={queryResponse.isFetching}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
selectedWidget={selectedWidget}
|
||||
dashboardVersion={ENTITY_VERSION_V5}
|
||||
dashboardId={dashboardData?.id}
|
||||
|
||||
@@ -50,5 +50,4 @@ export type WidgetGraphContainerProps = {
|
||||
selectedWidget: Widgets;
|
||||
isLoadingPanelData: boolean;
|
||||
enableDrillDown?: boolean;
|
||||
isCancelled?: boolean;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { QueryKey, useIsFetching, useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
@@ -10,23 +12,14 @@ import {
|
||||
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
|
||||
|
||||
import './RunQueryBtn.scss';
|
||||
|
||||
type RunQueryBtnProps = {
|
||||
interface RunQueryBtnProps {
|
||||
className?: string;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
} & (
|
||||
| {
|
||||
onStageRunQuery: () => void;
|
||||
handleCancelQuery: () => void;
|
||||
isLoadingQueries: boolean;
|
||||
}
|
||||
| {
|
||||
onStageRunQuery?: never;
|
||||
handleCancelQuery?: never;
|
||||
isLoadingQueries?: never;
|
||||
}
|
||||
);
|
||||
isLoadingQueries?: boolean;
|
||||
handleCancelQuery?: () => void;
|
||||
onStageRunQuery?: () => void;
|
||||
queryRangeKey?: QueryKey;
|
||||
}
|
||||
|
||||
function RunQueryBtn({
|
||||
className,
|
||||
@@ -34,10 +27,26 @@ function RunQueryBtn({
|
||||
isLoadingQueries,
|
||||
handleCancelQuery,
|
||||
onStageRunQuery,
|
||||
disabled,
|
||||
queryRangeKey,
|
||||
}: RunQueryBtnProps): JSX.Element {
|
||||
const isMac = getUserOperatingSystem() === UserOperatingSystem.MACOS;
|
||||
const isLoading = isLoadingQueries ?? false;
|
||||
const queryClient = useQueryClient();
|
||||
const isKeyFetchingCount = useIsFetching(
|
||||
queryRangeKey as QueryKey | undefined,
|
||||
);
|
||||
const isLoading =
|
||||
typeof isLoadingQueries === 'boolean'
|
||||
? isLoadingQueries
|
||||
: isKeyFetchingCount > 0;
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
if (handleCancelQuery) {
|
||||
return handleCancelQuery();
|
||||
}
|
||||
if (queryRangeKey) {
|
||||
queryClient.cancelQueries(queryRangeKey);
|
||||
}
|
||||
}, [handleCancelQuery, queryClient, queryRangeKey]);
|
||||
|
||||
return isLoading ? (
|
||||
<Button
|
||||
@@ -45,7 +54,7 @@ function RunQueryBtn({
|
||||
type="button"
|
||||
prefix={<Loader2 size={14} className="loading-icon animate-spin" />}
|
||||
className={cx('cancel-query-btn', className)}
|
||||
onClick={handleCancelQuery}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -54,7 +63,7 @@ function RunQueryBtn({
|
||||
color="primary"
|
||||
type="button"
|
||||
className={cx('run-query-btn', className)}
|
||||
disabled={disabled}
|
||||
disabled={isLoading || !onStageRunQuery}
|
||||
onClick={onStageRunQuery}
|
||||
prefix={<Play size={14} />}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
// frontend/src/container/QueryBuilder/components/RunQueryBtn/__tests__/RunQueryBtn.test.tsx
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import RunQueryBtn from '../RunQueryBtn';
|
||||
|
||||
jest.mock('react-query', () => {
|
||||
const actual = jest.requireActual('react-query');
|
||||
return {
|
||||
...actual,
|
||||
useIsFetching: jest.fn(),
|
||||
useQueryClient: jest.fn(),
|
||||
};
|
||||
});
|
||||
import { useIsFetching, useQueryClient } from 'react-query';
|
||||
|
||||
// Mock OS util
|
||||
jest.mock('utils/getUserOS', () => ({
|
||||
getUserOperatingSystem: jest.fn(),
|
||||
@@ -16,60 +26,79 @@ describe('RunQueryBtn', () => {
|
||||
(getUserOperatingSystem as jest.Mock).mockReturnValue(
|
||||
UserOperatingSystem.MACOS,
|
||||
);
|
||||
(useIsFetching as jest.Mock).mockReturnValue(0);
|
||||
(useQueryClient as jest.Mock).mockReturnValue({
|
||||
cancelQueries: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
test('renders run state and triggers on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
test('uses isLoadingQueries prop over useIsFetching', () => {
|
||||
// Simulate fetching but prop forces not loading
|
||||
(useIsFetching as jest.Mock).mockReturnValue(1);
|
||||
const onRun = jest.fn();
|
||||
render(<RunQueryBtn onStageRunQuery={onRun} isLoadingQueries={false} />);
|
||||
// Should show "Run Query" (not cancel)
|
||||
const runBtn = screen.getByRole('button', { name: /run query/i });
|
||||
expect(runBtn).toBeInTheDocument();
|
||||
expect(runBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
test('fallback cancel: uses handleCancelQuery when no key provided', () => {
|
||||
(useIsFetching as jest.Mock).mockReturnValue(0);
|
||||
const cancelQueries = jest.fn();
|
||||
(useQueryClient as jest.Mock).mockReturnValue({ cancelQueries });
|
||||
|
||||
const onCancel = jest.fn();
|
||||
render(
|
||||
<RunQueryBtn
|
||||
onStageRunQuery={onRun}
|
||||
handleCancelQuery={onCancel}
|
||||
isLoadingQueries={false}
|
||||
/>,
|
||||
);
|
||||
render(<RunQueryBtn isLoadingQueries handleCancelQuery={onCancel} />);
|
||||
|
||||
const cancelBtn = screen.getByRole('button', { name: /cancel/i });
|
||||
fireEvent.click(cancelBtn);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(cancelQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('renders run state and triggers on click', () => {
|
||||
const onRun = jest.fn();
|
||||
render(<RunQueryBtn onStageRunQuery={onRun} />);
|
||||
const btn = screen.getByRole('button', { name: /run query/i });
|
||||
expect(btn).toBeEnabled();
|
||||
await user.click(btn);
|
||||
fireEvent.click(btn);
|
||||
expect(onRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('shows cancel state and calls handleCancelQuery', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRun = jest.fn();
|
||||
const onCancel = jest.fn();
|
||||
render(
|
||||
<RunQueryBtn
|
||||
onStageRunQuery={onRun}
|
||||
handleCancelQuery={onCancel}
|
||||
isLoadingQueries
|
||||
/>,
|
||||
);
|
||||
const cancel = screen.getByRole('button', { name: /cancel/i });
|
||||
await user.click(cancel);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('disabled when disabled prop is true', () => {
|
||||
render(<RunQueryBtn disabled />);
|
||||
test('disabled when onStageRunQuery is undefined', () => {
|
||||
render(<RunQueryBtn />);
|
||||
expect(screen.getByRole('button', { name: /run query/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('disabled when no props provided', () => {
|
||||
render(<RunQueryBtn />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /run query/i }),
|
||||
).toBeInTheDocument();
|
||||
test('shows cancel state and calls handleCancelQuery', () => {
|
||||
const onCancel = jest.fn();
|
||||
render(<RunQueryBtn isLoadingQueries handleCancelQuery={onCancel} />);
|
||||
const cancel = screen.getByRole('button', { name: /cancel/i });
|
||||
fireEvent.click(cancel);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('derives loading from queryKey via useIsFetching and cancels via queryClient', () => {
|
||||
(useIsFetching as jest.Mock).mockReturnValue(1);
|
||||
const cancelQueries = jest.fn();
|
||||
(useQueryClient as jest.Mock).mockReturnValue({ cancelQueries });
|
||||
|
||||
const queryKey = ['GET_QUERY_RANGE', '1h', { some: 'req' }, 1, 2];
|
||||
render(<RunQueryBtn queryRangeKey={queryKey} />);
|
||||
|
||||
// Button switches to cancel state
|
||||
const cancelBtn = screen.getByRole('button', { name: /cancel/i });
|
||||
expect(cancelBtn).toBeInTheDocument();
|
||||
|
||||
// Clicking cancel calls cancelQueries with the key
|
||||
fireEvent.click(cancelBtn);
|
||||
expect(cancelQueries).toHaveBeenCalledWith(queryKey);
|
||||
});
|
||||
|
||||
test('shows Command + CornerDownLeft on mac', () => {
|
||||
const { container } = render(
|
||||
<RunQueryBtn
|
||||
onStageRunQuery={jest.fn()}
|
||||
handleCancelQuery={jest.fn()}
|
||||
isLoadingQueries={false}
|
||||
/>,
|
||||
<RunQueryBtn onStageRunQuery={(): void => {}} />,
|
||||
);
|
||||
expect(container.querySelector('.lucide-command')).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -82,11 +111,7 @@ describe('RunQueryBtn', () => {
|
||||
UserOperatingSystem.WINDOWS,
|
||||
);
|
||||
const { container } = render(
|
||||
<RunQueryBtn
|
||||
onStageRunQuery={jest.fn()}
|
||||
handleCancelQuery={jest.fn()}
|
||||
isLoadingQueries={false}
|
||||
/>,
|
||||
<RunQueryBtn onStageRunQuery={(): void => {}} />,
|
||||
);
|
||||
expect(container.querySelector('.lucide-chevron-up')).toBeInTheDocument();
|
||||
expect(container.querySelector('.lucide-command')).not.toBeInTheDocument();
|
||||
@@ -96,14 +121,8 @@ describe('RunQueryBtn', () => {
|
||||
});
|
||||
|
||||
test('renders custom label when provided', () => {
|
||||
render(
|
||||
<RunQueryBtn
|
||||
onStageRunQuery={jest.fn()}
|
||||
handleCancelQuery={jest.fn()}
|
||||
isLoadingQueries={false}
|
||||
label="Stage & Run Query"
|
||||
/>,
|
||||
);
|
||||
const onRun = jest.fn();
|
||||
render(<RunQueryBtn onStageRunQuery={onRun} label="Stage & Run Query" />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /stage & run query/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { MutableRefObject, useEffect } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
|
||||
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
|
||||
@@ -8,19 +9,23 @@ import './ToolbarActions.styles.scss';
|
||||
|
||||
interface RightToolbarActionsProps {
|
||||
onStageRunQuery: () => void;
|
||||
isLoadingQueries: boolean;
|
||||
handleCancelQuery: () => void;
|
||||
isLoadingQueries?: boolean;
|
||||
listQueryKeyRef?: MutableRefObject<any>;
|
||||
chartQueryKeyRef?: MutableRefObject<any>;
|
||||
showLiveLogs?: boolean;
|
||||
}
|
||||
|
||||
export default function RightToolbarActions({
|
||||
onStageRunQuery,
|
||||
isLoadingQueries,
|
||||
handleCancelQuery,
|
||||
listQueryKeyRef,
|
||||
chartQueryKeyRef,
|
||||
showLiveLogs,
|
||||
}: RightToolbarActionsProps): JSX.Element {
|
||||
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (showLiveLogs) {
|
||||
return;
|
||||
@@ -37,11 +42,20 @@ export default function RightToolbarActions({
|
||||
if (showLiveLogs) {
|
||||
return (
|
||||
<div className="right-toolbar-actions-container">
|
||||
<RunQueryBtn disabled />
|
||||
<RunQueryBtn />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleCancelQuery = (): void => {
|
||||
if (listQueryKeyRef?.current) {
|
||||
queryClient.cancelQueries(listQueryKeyRef.current);
|
||||
}
|
||||
if (chartQueryKeyRef?.current) {
|
||||
queryClient.cancelQueries(chartQueryKeyRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="right-toolbar-actions-container">
|
||||
<RunQueryBtn
|
||||
@@ -54,5 +68,8 @@ export default function RightToolbarActions({
|
||||
}
|
||||
|
||||
RightToolbarActions.defaultProps = {
|
||||
isLoadingQueries: false,
|
||||
listQueryKeyRef: null,
|
||||
chartQueryKeyRef: null,
|
||||
showLiveLogs: false,
|
||||
};
|
||||
|
||||
@@ -92,12 +92,7 @@ describe('ToolbarActions', () => {
|
||||
const onStageRunQuery = jest.fn();
|
||||
const { queryByText } = render(
|
||||
<MockQueryClientProvider>
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
isLoadingQueries={false}
|
||||
handleCancelQuery={jest.fn()}
|
||||
/>
|
||||
,
|
||||
<RightToolbarActions onStageRunQuery={onStageRunQuery} />,
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getViewQuery } from '../drilldownUtils';
|
||||
import { AggregateData } from '../useAggregateDrilldown';
|
||||
import useBaseDrilldownNavigate, {
|
||||
buildDrilldownUrl,
|
||||
getRoute,
|
||||
} from '../useBaseDrilldownNavigate';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({ safeNavigate: mockSafeNavigate }),
|
||||
}));
|
||||
|
||||
jest.mock('../drilldownUtils', () => ({
|
||||
...jest.requireActual('../drilldownUtils'),
|
||||
getViewQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetViewQuery = getViewQuery as jest.Mock;
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_QUERY: Query = {
|
||||
id: 'q1',
|
||||
queryType: 'builder' as any,
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'A',
|
||||
dataSource: 'metrics' as any,
|
||||
groupBy: [],
|
||||
expression: '',
|
||||
disabled: false,
|
||||
functions: [],
|
||||
legend: '',
|
||||
having: [],
|
||||
limit: null,
|
||||
stepInterval: undefined,
|
||||
orderBy: [],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
};
|
||||
|
||||
const MOCK_VIEW_QUERY: Query = {
|
||||
...MOCK_QUERY,
|
||||
builder: {
|
||||
...MOCK_QUERY.builder,
|
||||
queryData: [
|
||||
{
|
||||
...MOCK_QUERY.builder.queryData[0],
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_AGGREGATE_DATA: AggregateData = {
|
||||
queryName: 'A',
|
||||
filters: [{ filterKey: 'service_name', filterValue: 'auth', operator: '=' }],
|
||||
timeRange: { startTime: 1000000, endTime: 2000000 },
|
||||
};
|
||||
|
||||
// ─── getRoute ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getRoute', () => {
|
||||
it.each([
|
||||
['view_logs', ROUTES.LOGS_EXPLORER],
|
||||
['view_metrics', ROUTES.METRICS_EXPLORER],
|
||||
['view_traces', ROUTES.TRACES_EXPLORER],
|
||||
])('maps %s to the correct explorer route', (key, expected) => {
|
||||
expect(getRoute(key)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns empty string for an unknown key', () => {
|
||||
expect(getRoute('view_dashboard')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildDrilldownUrl ────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildDrilldownUrl', () => {
|
||||
beforeEach(() => {
|
||||
mockGetViewQuery.mockReturnValue(MOCK_VIEW_QUERY);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns null for an unknown drilldown key', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_dashboard');
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when getViewQuery returns null', () => {
|
||||
mockGetViewQuery.mockReturnValue(null);
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a URL starting with the logs explorer route for view_logs', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
|
||||
expect(url).not.toBeNull();
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
});
|
||||
|
||||
it('returns a URL starting with the traces explorer route for view_traces', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_traces');
|
||||
expect(url).toContain(ROUTES.TRACES_EXPLORER);
|
||||
});
|
||||
|
||||
it('includes compositeQuery param in the URL', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
|
||||
expect(url).toContain('compositeQuery=');
|
||||
});
|
||||
|
||||
it('includes startTime and endTime when aggregateData has a timeRange', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
|
||||
expect(url).toContain('startTime=1000000');
|
||||
expect(url).toContain('endTime=2000000');
|
||||
});
|
||||
|
||||
it('omits startTime and endTime when aggregateData has no timeRange', () => {
|
||||
const { timeRange: _, ...withoutTimeRange } = MOCK_AGGREGATE_DATA;
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, withoutTimeRange, 'view_logs');
|
||||
expect(url).not.toContain('startTime=');
|
||||
expect(url).not.toContain('endTime=');
|
||||
});
|
||||
|
||||
it('includes summaryFilters param for view_metrics', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_metrics');
|
||||
expect(url).toContain(ROUTES.METRICS_EXPLORER);
|
||||
expect(url).toContain('summaryFilters=');
|
||||
});
|
||||
|
||||
it('does not include summaryFilters param for non-metrics routes', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
|
||||
expect(url).not.toContain('summaryFilters=');
|
||||
});
|
||||
|
||||
it('handles null aggregateData by passing empty filters and empty queryName', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, null, 'view_logs');
|
||||
expect(url).not.toBeNull();
|
||||
expect(mockGetViewQuery).toHaveBeenCalledWith(MOCK_QUERY, [], 'view_logs', '');
|
||||
});
|
||||
|
||||
it('passes aggregateData filters and queryName to getViewQuery', () => {
|
||||
buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
|
||||
expect(mockGetViewQuery).toHaveBeenCalledWith(
|
||||
MOCK_QUERY,
|
||||
MOCK_AGGREGATE_DATA.filters,
|
||||
'view_logs',
|
||||
MOCK_AGGREGATE_DATA.queryName,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── useBaseDrilldownNavigate ─────────────────────────────────────────────────
|
||||
|
||||
describe('useBaseDrilldownNavigate', () => {
|
||||
beforeEach(() => {
|
||||
mockGetViewQuery.mockReturnValue(MOCK_VIEW_QUERY);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls safeNavigate with the built URL on a valid key', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: MOCK_AGGREGATE_DATA,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current('view_logs');
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||
const [url] = mockSafeNavigate.mock.calls[0];
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(url).toContain('compositeQuery=');
|
||||
});
|
||||
|
||||
it('opens the explorer in a new tab', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: MOCK_AGGREGATE_DATA,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current('view_traces');
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ newTab: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('calls callback after successful navigation', () => {
|
||||
const callback = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: MOCK_AGGREGATE_DATA,
|
||||
callback,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current('view_logs');
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call safeNavigate for an unknown key', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: MOCK_AGGREGATE_DATA,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current('view_dashboard');
|
||||
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still calls callback when the key is unknown', () => {
|
||||
const callback = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: MOCK_AGGREGATE_DATA,
|
||||
callback,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current('view_dashboard');
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still calls callback when getViewQuery returns null', () => {
|
||||
mockGetViewQuery.mockReturnValue(null);
|
||||
const callback = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: MOCK_AGGREGATE_DATA,
|
||||
callback,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current('view_logs');
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles null aggregateData without throwing', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: null,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() => result.current('view_logs')).not.toThrow();
|
||||
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -166,7 +166,7 @@ export const getAggregateColumnHeader = (
|
||||
};
|
||||
};
|
||||
|
||||
export const getFiltersFromMetric = (metric: any): FilterData[] =>
|
||||
const getFiltersFromMetric = (metric: any): FilterData[] =>
|
||||
Object.keys(metric).map((key) => ({
|
||||
filterKey: key,
|
||||
filterValue: metric[key],
|
||||
|
||||
@@ -2,10 +2,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { LinkOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
|
||||
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
|
||||
import useContextVariables from 'hooks/dashboard/useContextVariables';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { ContextLinksData } from 'types/api/dashboard/getAll';
|
||||
@@ -13,10 +17,9 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { ContextMenuItem } from './contextConfig';
|
||||
import { getDataLinks } from './dataLinksUtils';
|
||||
import { getAggregateColumnHeader } from './drilldownUtils';
|
||||
import { getAggregateColumnHeader, getViewQuery } from './drilldownUtils';
|
||||
import { getBaseContextConfig } from './menuOptions';
|
||||
import { AggregateData } from './useAggregateDrilldown';
|
||||
import useBaseDrilldownNavigate from './useBaseDrilldownNavigate';
|
||||
|
||||
interface UseBaseAggregateOptionsProps {
|
||||
query: Query;
|
||||
@@ -34,6 +37,19 @@ interface BaseAggregateOptionsConfig {
|
||||
items?: ContextMenuItem;
|
||||
}
|
||||
|
||||
const getRoute = (key: string): string => {
|
||||
switch (key) {
|
||||
case 'view_logs':
|
||||
return ROUTES.LOGS_EXPLORER;
|
||||
case 'view_metrics':
|
||||
return ROUTES.METRICS_EXPLORER;
|
||||
case 'view_traces':
|
||||
return ROUTES.TRACES_EXPLORER;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const useBaseAggregateOptions = ({
|
||||
query,
|
||||
onClose,
|
||||
@@ -46,8 +62,10 @@ const useBaseAggregateOptions = ({
|
||||
baseAggregateOptionsConfig: BaseAggregateOptionsConfig;
|
||||
} => {
|
||||
const [resolvedQuery, setResolvedQuery] = useState<Query>(query);
|
||||
const { getUpdatedQuery, isLoading: isResolveQueryLoading } =
|
||||
useUpdatedQuery();
|
||||
const {
|
||||
getUpdatedQuery,
|
||||
isLoading: isResolveQueryLoading,
|
||||
} = useUpdatedQuery();
|
||||
const { dashboardData } = useDashboardStore();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -69,6 +87,8 @@ const useBaseAggregateOptions = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, aggregateData, panelType]);
|
||||
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
// Use the new useContextVariables hook
|
||||
const { processedVariables } = useContextVariables({
|
||||
maxValues: 2,
|
||||
@@ -102,16 +122,50 @@ const useBaseAggregateOptions = ({
|
||||
{label}
|
||||
</ContextMenu.Item>
|
||||
));
|
||||
} catch {
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}, [contextLinks, processedVariables, onClose, aggregateData, query]);
|
||||
|
||||
const handleBaseDrilldown = useBaseDrilldownNavigate({
|
||||
resolvedQuery,
|
||||
aggregateData,
|
||||
callback: onClose,
|
||||
});
|
||||
const handleBaseDrilldown = useCallback(
|
||||
(key: string): void => {
|
||||
const route = getRoute(key);
|
||||
const timeRange = aggregateData?.timeRange;
|
||||
const filtersToAdd = aggregateData?.filters || [];
|
||||
const viewQuery = getViewQuery(
|
||||
resolvedQuery,
|
||||
filtersToAdd,
|
||||
key,
|
||||
aggregateData?.queryName || '',
|
||||
);
|
||||
|
||||
let queryParams = {
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange?.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange?.endTime.toString(),
|
||||
}),
|
||||
} as Record<string, string>;
|
||||
|
||||
if (route === ROUTES.METRICS_EXPLORER) {
|
||||
queryParams = {
|
||||
...queryParams,
|
||||
[QueryParams.summaryFilters]: JSON.stringify(
|
||||
viewQuery?.builder.queryData[0].filters,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (route) {
|
||||
safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
|
||||
newTab: true,
|
||||
});
|
||||
}
|
||||
|
||||
onClose();
|
||||
},
|
||||
[resolvedQuery, safeNavigate, onClose, aggregateData],
|
||||
);
|
||||
|
||||
const { pathname } = useLocation();
|
||||
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getViewQuery } from './drilldownUtils';
|
||||
import { AggregateData } from './useAggregateDrilldown';
|
||||
|
||||
type DrilldownKey = 'view_logs' | 'view_metrics' | 'view_traces';
|
||||
|
||||
const DRILLDOWN_ROUTE_MAP: Record<DrilldownKey, string> = {
|
||||
view_logs: ROUTES.LOGS_EXPLORER,
|
||||
view_metrics: ROUTES.METRICS_EXPLORER,
|
||||
view_traces: ROUTES.TRACES_EXPLORER,
|
||||
};
|
||||
|
||||
const getRoute = (key: string): string =>
|
||||
DRILLDOWN_ROUTE_MAP[key as DrilldownKey] ?? '';
|
||||
|
||||
interface UseBaseDrilldownNavigateProps {
|
||||
resolvedQuery: Query;
|
||||
aggregateData: AggregateData | null;
|
||||
callback?: () => void;
|
||||
}
|
||||
|
||||
const useBaseDrilldownNavigate = ({
|
||||
resolvedQuery,
|
||||
aggregateData,
|
||||
callback,
|
||||
}: UseBaseDrilldownNavigateProps): ((key: string) => void) => {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
return useCallback(
|
||||
(key: string): void => {
|
||||
const route = getRoute(key);
|
||||
const viewQuery = getViewQuery(
|
||||
resolvedQuery,
|
||||
aggregateData?.filters ?? [],
|
||||
key,
|
||||
aggregateData?.queryName ?? '',
|
||||
);
|
||||
|
||||
if (!viewQuery || !route) {
|
||||
callback?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const timeRange = aggregateData?.timeRange;
|
||||
let queryParams: Record<string, string> = {
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange.endTime.toString(),
|
||||
}),
|
||||
};
|
||||
|
||||
if (route === ROUTES.METRICS_EXPLORER) {
|
||||
queryParams = {
|
||||
...queryParams,
|
||||
[QueryParams.summaryFilters]: JSON.stringify(
|
||||
viewQuery.builder.queryData[0].filters,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
|
||||
newTab: true,
|
||||
});
|
||||
|
||||
callback?.();
|
||||
},
|
||||
[resolvedQuery, safeNavigate, callback, aggregateData],
|
||||
);
|
||||
};
|
||||
|
||||
export function buildDrilldownUrl(
|
||||
resolvedQuery: Query,
|
||||
aggregateData: AggregateData | null,
|
||||
key: string,
|
||||
): string | null {
|
||||
const route = getRoute(key);
|
||||
const viewQuery = getViewQuery(
|
||||
resolvedQuery,
|
||||
aggregateData?.filters ?? [],
|
||||
key,
|
||||
aggregateData?.queryName ?? '',
|
||||
);
|
||||
|
||||
if (!viewQuery || !route) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeRange = aggregateData?.timeRange;
|
||||
let queryParams: Record<string, string> = {
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange.endTime.toString(),
|
||||
}),
|
||||
};
|
||||
|
||||
if (route === ROUTES.METRICS_EXPLORER) {
|
||||
queryParams = {
|
||||
...queryParams,
|
||||
[QueryParams.summaryFilters]: JSON.stringify(
|
||||
viewQuery.builder.queryData[0].filters,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return `${route}?${createQueryParams(queryParams)}`;
|
||||
}
|
||||
|
||||
export { getRoute };
|
||||
export default useBaseDrilldownNavigate;
|
||||
@@ -2,7 +2,6 @@ import { useMemo } from 'react';
|
||||
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { updateBarStepInterval } from 'container/GridCardLayout/utils';
|
||||
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
|
||||
@@ -133,10 +132,6 @@ export const useGetQueryRange: UseGetQueryRange = (
|
||||
return options.retry;
|
||||
}
|
||||
return (failureCount: number, error: Error): boolean => {
|
||||
if (isAxiosError(error) && error.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let status: number | undefined;
|
||||
|
||||
if (error instanceof APIError) {
|
||||
@@ -149,7 +144,7 @@ export const useGetQueryRange: UseGetQueryRange = (
|
||||
return false;
|
||||
}
|
||||
|
||||
return failureCount < MAX_QUERY_RETRIES;
|
||||
return failureCount < 3;
|
||||
};
|
||||
}, [options?.retry]);
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import listOverview from 'api/thirdPartyApis/listOverview';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -22,21 +20,12 @@ export const useListOverview = (
|
||||
showIp,
|
||||
filter.expression,
|
||||
],
|
||||
queryFn: ({ signal }) =>
|
||||
listOverview(
|
||||
{
|
||||
start,
|
||||
end,
|
||||
show_ip: showIp,
|
||||
filter,
|
||||
},
|
||||
signal,
|
||||
),
|
||||
retry: (failureCount, error): boolean => {
|
||||
if (isAxiosError(error) && error.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
return failureCount < MAX_QUERY_RETRIES;
|
||||
},
|
||||
queryFn: () =>
|
||||
listOverview({
|
||||
start,
|
||||
end,
|
||||
show_ip: showIp,
|
||||
filter,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface AllErrorsQueryState {
|
||||
isFetching: boolean;
|
||||
isCancelled: boolean;
|
||||
setIsFetching: (isFetching: boolean) => void;
|
||||
setIsCancelled: (isCancelled: boolean) => void;
|
||||
}
|
||||
|
||||
export const useAllErrorsQueryState = create<AllErrorsQueryState>((set) => ({
|
||||
isFetching: false,
|
||||
isCancelled: false,
|
||||
setIsFetching: (isFetching): void => {
|
||||
set((state) => ({
|
||||
isFetching,
|
||||
// Auto-reset cancelled when a new fetch starts
|
||||
isCancelled: isFetching ? false : state.isCancelled,
|
||||
}));
|
||||
},
|
||||
setIsCancelled: (isCancelled): void => {
|
||||
set({ isCancelled });
|
||||
},
|
||||
}));
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { FilterOutlined } from '@ant-design/icons';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
@@ -20,22 +19,12 @@ import history from 'lib/history';
|
||||
import { isNull } from 'lodash-es';
|
||||
|
||||
import { routes } from './config';
|
||||
import { useAllErrorsQueryState } from './QueryStateContext';
|
||||
|
||||
import './AllErrors.styles.scss';
|
||||
|
||||
function AllErrors(): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const isLoadingQueries = useAllErrorsQueryState((s) => s.isFetching);
|
||||
const setIsCancelled = useAllErrorsQueryState((s) => s.setIsCancelled);
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
queryClient.cancelQueries(['getAllErrors']);
|
||||
queryClient.cancelQueries(['getErrorCounts']);
|
||||
setIsCancelled(true);
|
||||
}, [queryClient, setIsCancelled]);
|
||||
|
||||
const [showFilters, setShowFilters] = useState<boolean>(() => {
|
||||
const localStorageValue = getLocalStorageKey(
|
||||
@@ -88,11 +77,7 @@ function AllErrors(): JSX.Element {
|
||||
}
|
||||
rightActions={
|
||||
<div className="right-toolbar-actions-container">
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={handleRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
<RightToolbarActions onStageRunQuery={handleRunQuery} />
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
@@ -76,27 +74,6 @@ function LogsExplorer(): JSX.Element {
|
||||
const chartQueryKeyRef = useRef<any>();
|
||||
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingQueries) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [isLoadingQueries]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
if (listQueryKeyRef.current) {
|
||||
queryClient.cancelQueries(listQueryKeyRef.current);
|
||||
}
|
||||
if (chartQueryKeyRef.current) {
|
||||
queryClient.cancelQueries(chartQueryKeyRef.current);
|
||||
}
|
||||
setIsCancelled(true);
|
||||
// Reset loading state — the views container unmounts when cancelled, so
|
||||
// no child will call setIsLoadingQueries(false) otherwise.
|
||||
setIsLoadingQueries(false);
|
||||
}, [queryClient]);
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>(undefined);
|
||||
|
||||
@@ -319,12 +296,10 @@ function LogsExplorer(): JSX.Element {
|
||||
}
|
||||
rightActions={
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => {
|
||||
setIsCancelled(false);
|
||||
handleRunQuery();
|
||||
}}
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
listQueryKeyRef={listQueryKeyRef}
|
||||
chartQueryKeyRef={chartQueryKeyRef}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
showLiveLogs={showLiveLogs}
|
||||
/>
|
||||
}
|
||||
@@ -340,18 +315,14 @@ function LogsExplorer(): JSX.Element {
|
||||
</ExplorerCard>
|
||||
</div>
|
||||
<div className="logs-explorer-views">
|
||||
{isCancelled ? (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load logs.' />
|
||||
) : (
|
||||
<LogsExplorerViewsContainer
|
||||
listQueryKeyRef={listQueryKeyRef}
|
||||
chartQueryKeyRef={chartQueryKeyRef}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
setWarning={setWarning}
|
||||
showLiveLogs={showLiveLogs}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
)}
|
||||
<LogsExplorerViewsContainer
|
||||
listQueryKeyRef={listQueryKeyRef}
|
||||
chartQueryKeyRef={chartQueryKeyRef}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
setWarning={setWarning}
|
||||
showLiveLogs={showLiveLogs}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
@@ -73,29 +71,11 @@ function TracesExplorer(): JSX.Element {
|
||||
});
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const listQueryKeyRef = useRef<any>();
|
||||
|
||||
// Get panel type from URL
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingQueries) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [isLoadingQueries]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
if (listQueryKeyRef.current) {
|
||||
queryClient.cancelQueries(listQueryKeyRef.current);
|
||||
}
|
||||
setIsCancelled(true);
|
||||
// Reset loading state — the active view unmounts when cancelled, so no
|
||||
// child will call setIsLoadingQueries(false) otherwise.
|
||||
setIsLoadingQueries(false);
|
||||
}, [queryClient]);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
|
||||
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
@@ -230,12 +210,9 @@ function TracesExplorer(): JSX.Element {
|
||||
}
|
||||
rightActions={
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => {
|
||||
setIsCancelled(false);
|
||||
handleRunQuery();
|
||||
}}
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
listQueryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -247,11 +224,7 @@ function TracesExplorer(): JSX.Element {
|
||||
</ExplorerCard>
|
||||
|
||||
<div className="traces-explorer-views">
|
||||
{isCancelled && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load traces.' />
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.LIST && (
|
||||
{selectedView === ExplorerViews.LIST && (
|
||||
<div className="trace-explorer-list-view">
|
||||
<ListView
|
||||
isFilterApplied={isFilterApplied}
|
||||
@@ -262,7 +235,7 @@ function TracesExplorer(): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TRACE && (
|
||||
{selectedView === ExplorerViews.TRACE && (
|
||||
<div className="trace-explorer-traces-view">
|
||||
<TracesView
|
||||
isFilterApplied={isFilterApplied}
|
||||
@@ -273,7 +246,7 @@ function TracesExplorer(): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TIMESERIES && (
|
||||
{selectedView === ExplorerViews.TIMESERIES && (
|
||||
<div className="trace-explorer-time-series-view">
|
||||
<TimeSeriesView
|
||||
dataSource={DataSource.TRACES}
|
||||
@@ -285,7 +258,7 @@ function TracesExplorer(): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TABLE && (
|
||||
{selectedView === ExplorerViews.TABLE && (
|
||||
<div className="trace-explorer-table-view">
|
||||
<TableView
|
||||
setWarning={setWarning}
|
||||
|
||||
1
go.mod
1
go.mod
@@ -66,7 +66,6 @@ require (
|
||||
github.com/uptrace/bun/dialect/pgdialect v1.2.9
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.2.9
|
||||
github.com/uptrace/bun/extra/bunotel v1.2.9
|
||||
github.com/yuin/goldmark v1.7.16
|
||||
go.opentelemetry.io/collector/confmap v1.51.0
|
||||
go.opentelemetry.io/collector/otelcol v0.144.0
|
||||
go.opentelemetry.io/collector/pdata v1.51.0
|
||||
|
||||
2
go.sum
2
go.sum
@@ -1153,8 +1153,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zeebo/assert v1.3.1 h1:vukIABvugfNMZMQO1ABsyQDJDTVQbn+LWSMy1ol1h6A=
|
||||
|
||||
@@ -10,8 +10,34 @@ import (
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
var (
|
||||
serviceAccountAdminRoles = []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}
|
||||
serviceAccountReadRoles = []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
authtypes.SigNozEditorRoleName,
|
||||
authtypes.SigNozViewerRoleName,
|
||||
}
|
||||
)
|
||||
|
||||
func serviceAccountCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]authtypes.Selector, error) {
|
||||
return []authtypes.Selector{
|
||||
authtypes.MustNewSelector(authtypes.TypeMetaResources, authtypes.WildCardSelectorString),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func serviceAccountInstanceSelectorCallback(req *http.Request, _ authtypes.Claims) ([]authtypes.Selector, error) {
|
||||
id := mux.Vars(req)["id"]
|
||||
return []authtypes.Selector{
|
||||
authtypes.MustNewSelector(authtypes.TypeMetaResource, id),
|
||||
authtypes.MustNewSelector(authtypes.TypeMetaResource, authtypes.WildCardSelectorString),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Create), handler.OpenAPIDef{
|
||||
// POST /api/v1/service_accounts — Create (admin only)
|
||||
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Create, authtypes.RelationCreate, serviceaccounttypes.TypeableMetaResourcesServiceAccounts, serviceAccountCollectionSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Create service account",
|
||||
@@ -28,7 +54,8 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.List), handler.OpenAPIDef{
|
||||
// GET /api/v1/service_accounts — List (admin, editor, viewer)
|
||||
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.Check(provider.serviceAccountHandler.List, authtypes.RelationList, serviceaccounttypes.TypeableMetaResourcesServiceAccounts, serviceAccountCollectionSelectorCallback, serviceAccountReadRoles), handler.OpenAPIDef{
|
||||
ID: "ListServiceAccounts",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "List service accounts",
|
||||
@@ -40,11 +67,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// GET /api/v1/service_accounts/me — GetMe (open access, self)
|
||||
if err := router.Handle("/api/v1/service_accounts/me", handler.New(provider.authZ.OpenAccess(provider.serviceAccountHandler.GetMe), handler.OpenAPIDef{
|
||||
ID: "GetMyServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
@@ -62,7 +90,8 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Get), handler.OpenAPIDef{
|
||||
// GET /api/v1/service_accounts/{id} — Get (admin, editor, viewer)
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Get, authtypes.RelationRead, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountReadRoles), handler.OpenAPIDef{
|
||||
ID: "GetServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Gets a service account",
|
||||
@@ -74,12 +103,13 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.GetRoles), handler.OpenAPIDef{
|
||||
// GET /api/v1/service_accounts/{id}/roles — GetRoles (admin, editor, viewer)
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.Check(provider.serviceAccountHandler.GetRoles, authtypes.RelationRead, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountReadRoles), handler.OpenAPIDef{
|
||||
ID: "GetServiceAccountRoles",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Gets service account roles",
|
||||
@@ -91,12 +121,13 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.SetRole), handler.OpenAPIDef{
|
||||
// POST /api/v1/service_accounts/{id}/roles — SetRole (admin only)
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.Check(provider.serviceAccountHandler.SetRole, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccountRole",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Create service account role",
|
||||
@@ -113,7 +144,8 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.DeleteRole), handler.OpenAPIDef{
|
||||
// DELETE /api/v1/service_accounts/{id}/roles/{rid} — DeleteRole (admin only)
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.DeleteRole, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
|
||||
ID: "DeleteServiceAccountRole",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Delete service account role",
|
||||
@@ -130,6 +162,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// PUT /api/v1/service_accounts/me — UpdateMe (open access, self)
|
||||
if err := router.Handle("/api/v1/service_accounts/me", handler.New(provider.authZ.OpenAccess(provider.serviceAccountHandler.UpdateMe), handler.OpenAPIDef{
|
||||
ID: "UpdateMyServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
@@ -147,7 +180,8 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Update), handler.OpenAPIDef{
|
||||
// PUT /api/v1/service_accounts/{id} — Update (admin only)
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Update, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
|
||||
ID: "UpdateServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Updates a service account",
|
||||
@@ -164,7 +198,8 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Delete), handler.OpenAPIDef{
|
||||
// DELETE /api/v1/service_accounts/{id} — Delete (admin only)
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Delete, authtypes.RelationDelete, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
|
||||
ID: "DeleteServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Deletes a service account",
|
||||
@@ -181,7 +216,8 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.CreateFactorAPIKey), handler.OpenAPIDef{
|
||||
// POST /api/v1/service_accounts/{id}/keys — CreateFactorAPIKey (admin only)
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.Check(provider.serviceAccountHandler.CreateFactorAPIKey, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccountKey",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Create a service account key",
|
||||
@@ -198,7 +234,8 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.ListFactorAPIKey), handler.OpenAPIDef{
|
||||
// GET /api/v1/service_accounts/{id}/keys — ListFactorAPIKey (admin, editor, viewer)
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.Check(provider.serviceAccountHandler.ListFactorAPIKey, authtypes.RelationRead, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountReadRoles), handler.OpenAPIDef{
|
||||
ID: "ListServiceAccountKeys",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "List service account keys",
|
||||
@@ -210,12 +247,13 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.UpdateFactorAPIKey), handler.OpenAPIDef{
|
||||
// PUT /api/v1/service_accounts/{id}/keys/{fid} — UpdateFactorAPIKey (admin only)
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.UpdateFactorAPIKey, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
|
||||
ID: "UpdateServiceAccountKey",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Updates a service account key",
|
||||
@@ -232,7 +270,8 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.RevokeFactorAPIKey), handler.OpenAPIDef{
|
||||
// DELETE /api/v1/service_accounts/{id}/keys/{fid} — RevokeFactorAPIKey (admin only)
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.RevokeFactorAPIKey, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
|
||||
ID: "RevokeServiceAccountKey",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Revoke a service account key",
|
||||
|
||||
@@ -376,6 +376,21 @@ func (module *module) getOrGetSetIdentity(ctx context.Context, serviceAccountID
|
||||
}
|
||||
|
||||
func (module *module) setRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID, role *authtypes.Role) error {
|
||||
// Verify the caller holds the role they are trying to assign. This prevents
|
||||
// privilege escalation — a caller cannot grant a role they don't have.
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
roleSelector := []authtypes.Selector{
|
||||
authtypes.MustNewSelector(authtypes.TypeRole, role.Name),
|
||||
}
|
||||
err = module.authz.CheckWithTupleCreation(ctx, claims, orgID, authtypes.RelationAssignee, authtypes.TypeableRole, roleSelector, roleSelector)
|
||||
if err != nil {
|
||||
return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "caller does not hold the role %q being assigned", role.Name)
|
||||
}
|
||||
|
||||
serviceAccount, err := module.GetWithRoles(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -430,3 +445,4 @@ func apiKeyCacheKey(apiKey string) string {
|
||||
func identityCacheKey(serviceAccountID valuer.UUID) string {
|
||||
return "identity::" + serviceAccountID.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package implserviceaccount
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// typeableRegistry is a standalone RegisterTypeable implementation that can be
|
||||
// used independently of the module lifecycle. This is needed because authz
|
||||
// initialization requires RegisterTypeable entries before the service account
|
||||
// module is created (the module depends on authz).
|
||||
type typeableRegistry struct{}
|
||||
|
||||
func NewTypeableRegistry() authz.RegisterTypeable {
|
||||
return &typeableRegistry{}
|
||||
}
|
||||
|
||||
func (registry *typeableRegistry) MustGetTypeables() []authtypes.Typeable {
|
||||
return []authtypes.Typeable{
|
||||
serviceaccounttypes.TypeableMetaResourceServiceAccount,
|
||||
serviceaccounttypes.TypeableMetaResourcesServiceAccounts,
|
||||
}
|
||||
}
|
||||
|
||||
func (registry *typeableRegistry) MustGetManagedRoleTransactions() map[string][]*authtypes.Transaction {
|
||||
return map[string][]*authtypes.Transaction{
|
||||
authtypes.SigNozAdminRoleName: {
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Relation: authtypes.RelationCreate,
|
||||
Object: *authtypes.MustNewObject(
|
||||
authtypes.Resource{
|
||||
Type: authtypes.TypeMetaResources,
|
||||
Name: serviceaccounttypes.TypeableMetaResourcesServiceAccounts.Name(),
|
||||
},
|
||||
authtypes.MustNewSelector(authtypes.TypeMetaResources, "*"),
|
||||
),
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Relation: authtypes.RelationList,
|
||||
Object: *authtypes.MustNewObject(
|
||||
authtypes.Resource{
|
||||
Type: authtypes.TypeMetaResources,
|
||||
Name: serviceaccounttypes.TypeableMetaResourcesServiceAccounts.Name(),
|
||||
},
|
||||
authtypes.MustNewSelector(authtypes.TypeMetaResources, "*"),
|
||||
),
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Relation: authtypes.RelationRead,
|
||||
Object: *authtypes.MustNewObject(
|
||||
authtypes.Resource{
|
||||
Type: authtypes.TypeMetaResource,
|
||||
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
|
||||
},
|
||||
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
|
||||
),
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Relation: authtypes.RelationUpdate,
|
||||
Object: *authtypes.MustNewObject(
|
||||
authtypes.Resource{
|
||||
Type: authtypes.TypeMetaResource,
|
||||
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
|
||||
},
|
||||
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
|
||||
),
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Relation: authtypes.RelationDelete,
|
||||
Object: *authtypes.MustNewObject(
|
||||
authtypes.Resource{
|
||||
Type: authtypes.TypeMetaResource,
|
||||
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
|
||||
},
|
||||
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
|
||||
),
|
||||
},
|
||||
},
|
||||
authtypes.SigNozEditorRoleName: {
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Relation: authtypes.RelationList,
|
||||
Object: *authtypes.MustNewObject(
|
||||
authtypes.Resource{
|
||||
Type: authtypes.TypeMetaResources,
|
||||
Name: serviceaccounttypes.TypeableMetaResourcesServiceAccounts.Name(),
|
||||
},
|
||||
authtypes.MustNewSelector(authtypes.TypeMetaResources, "*"),
|
||||
),
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Relation: authtypes.RelationRead,
|
||||
Object: *authtypes.MustNewObject(
|
||||
authtypes.Resource{
|
||||
Type: authtypes.TypeMetaResource,
|
||||
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
|
||||
},
|
||||
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
|
||||
),
|
||||
},
|
||||
},
|
||||
authtypes.SigNozViewerRoleName: {
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Relation: authtypes.RelationList,
|
||||
Object: *authtypes.MustNewObject(
|
||||
authtypes.Resource{
|
||||
Type: authtypes.TypeMetaResources,
|
||||
Name: serviceaccounttypes.TypeableMetaResourcesServiceAccounts.Name(),
|
||||
},
|
||||
authtypes.MustNewSelector(authtypes.TypeMetaResources, "*"),
|
||||
),
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Relation: authtypes.RelationRead,
|
||||
Object: *authtypes.MustNewObject(
|
||||
authtypes.Resource{
|
||||
Type: authtypes.TypeMetaResource,
|
||||
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
|
||||
},
|
||||
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ func New(
|
||||
sqlstoreProviderFactories factory.NamedMap[factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config]],
|
||||
telemetrystoreProviderFactories factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]],
|
||||
authNsCallback func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error),
|
||||
authzCallback func(context.Context, sqlstore.SQLStore, licensing.Licensing, dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
|
||||
authzCallback func(context.Context, sqlstore.SQLStore, licensing.Licensing, ...authz.RegisterTypeable) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
|
||||
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing) dashboard.Module,
|
||||
gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config],
|
||||
auditorProviderFactories func(licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]],
|
||||
@@ -328,8 +328,11 @@ func New(
|
||||
// Initialize dashboard module (needed for authz registry)
|
||||
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing)
|
||||
|
||||
// Initialize service account typeable registry (decoupled from module lifecycle for DI)
|
||||
serviceAccountRegistry := implserviceaccount.NewTypeableRegistry()
|
||||
|
||||
// Initialize authz
|
||||
authzProviderFactory, err := authzCallback(ctx, sqlstore, licensing, dashboard)
|
||||
authzProviderFactory, err := authzCallback(ctx, sqlstore, licensing, dashboard, serviceAccountRegistry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,755 +0,0 @@
|
||||
package blockkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/templatingtypes"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
extensionast "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// Extender is a goldmark.Extender that registers the Block Kit node renderer
|
||||
// together with the GFM extensions it relies on (tables, strikethrough, task
|
||||
// lists).
|
||||
var Extender goldmark.Extender = &extender{}
|
||||
|
||||
type extender struct{}
|
||||
|
||||
func (e *extender) Extend(m goldmark.Markdown) {
|
||||
extension.Table.Extend(m)
|
||||
extension.Strikethrough.Extend(m)
|
||||
extension.TaskList.Extend(m)
|
||||
m.Renderer().AddOptions(
|
||||
renderer.WithNodeRenderers(util.Prioritized(newRenderer(), 1)),
|
||||
)
|
||||
}
|
||||
|
||||
// listFrame tracks state for a single level of list nesting.
|
||||
type listFrame struct {
|
||||
style string // "bullet" or "ordered"
|
||||
indent int
|
||||
itemCount int
|
||||
}
|
||||
|
||||
// listContext holds all state while processing a list tree.
|
||||
type listContext struct {
|
||||
result []templatingtypes.RichTextList
|
||||
stack []listFrame
|
||||
current *templatingtypes.RichTextList
|
||||
currentItemInlines []interface{}
|
||||
}
|
||||
|
||||
// tableContext holds state while processing a table.
|
||||
type tableContext struct {
|
||||
rows [][]templatingtypes.TableCell
|
||||
currentRow []templatingtypes.TableCell
|
||||
currentCellInlines []interface{}
|
||||
isHeader bool
|
||||
}
|
||||
|
||||
// nodeRenderer converts Markdown AST to Slack Block Kit JSON.
|
||||
type nodeRenderer struct {
|
||||
blocks []interface{}
|
||||
mrkdwn strings.Builder
|
||||
// holds active styles for the current rich text element
|
||||
styleStack []templatingtypes.RichTextStyle
|
||||
// holds the current list context while processing a list tree.
|
||||
listCtx *listContext
|
||||
// holds the current table context while processing a table.
|
||||
tableCtx *tableContext
|
||||
// stores the current blockquote depth while processing a blockquote.
|
||||
// so blockquote with nested list can be rendered correctly.
|
||||
blockquoteDepth int
|
||||
}
|
||||
|
||||
func newRenderer() renderer.NodeRenderer {
|
||||
return &nodeRenderer{}
|
||||
}
|
||||
|
||||
// RegisterFuncs registers node rendering functions.
|
||||
func (r *nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
// Blocks
|
||||
reg.Register(ast.KindDocument, r.renderDocument)
|
||||
reg.Register(ast.KindHeading, r.renderHeading)
|
||||
reg.Register(ast.KindParagraph, r.renderParagraph)
|
||||
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
|
||||
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
|
||||
reg.Register(ast.KindBlockquote, r.renderBlockquote)
|
||||
reg.Register(ast.KindList, r.renderList)
|
||||
reg.Register(ast.KindListItem, r.renderListItem)
|
||||
reg.Register(ast.KindImage, r.renderImage)
|
||||
|
||||
// Inlines
|
||||
reg.Register(ast.KindText, r.renderText)
|
||||
reg.Register(ast.KindEmphasis, r.renderEmphasis)
|
||||
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
|
||||
reg.Register(ast.KindLink, r.renderLink)
|
||||
|
||||
// Extensions
|
||||
reg.Register(extensionast.KindStrikethrough, r.renderStrikethrough)
|
||||
reg.Register(extensionast.KindTable, r.renderTable)
|
||||
reg.Register(extensionast.KindTableHeader, r.renderTableHeader)
|
||||
reg.Register(extensionast.KindTableRow, r.renderTableRow)
|
||||
reg.Register(extensionast.KindTableCell, r.renderTableCell)
|
||||
reg.Register(extensionast.KindTaskCheckBox, r.renderTaskCheckBox)
|
||||
}
|
||||
|
||||
// inRichTextMode returns true when we're inside a list or table context
|
||||
// in slack blockkit list and table items are rendered as rich_text elements
|
||||
// if more cases are found in future those needs to be added here.
|
||||
func (r *nodeRenderer) inRichTextMode() bool {
|
||||
return r.listCtx != nil || r.tableCtx != nil
|
||||
}
|
||||
|
||||
// currentStyle merges the stored style stack into templatingtypes.RichTextStyle
|
||||
// which can be applied on rich text elements.
|
||||
func (r *nodeRenderer) currentStyle() *templatingtypes.RichTextStyle {
|
||||
s := templatingtypes.RichTextStyle{}
|
||||
for _, f := range r.styleStack {
|
||||
s.Bold = s.Bold || f.Bold
|
||||
s.Italic = s.Italic || f.Italic
|
||||
s.Strike = s.Strike || f.Strike
|
||||
s.Code = s.Code || f.Code
|
||||
}
|
||||
if s == (templatingtypes.RichTextStyle{}) {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// flushMrkdwn collects markdown text and adds it as a templatingtypes.SectionBlock with mrkdwn text
|
||||
// whenever starting a new block we flush markdown to render it as a separate block.
|
||||
func (r *nodeRenderer) flushMrkdwn() {
|
||||
text := strings.TrimSpace(r.mrkdwn.String())
|
||||
if text != "" {
|
||||
r.blocks = append(r.blocks, templatingtypes.SectionBlock{
|
||||
Type: "section",
|
||||
Text: &templatingtypes.TextObject{
|
||||
Type: "mrkdwn",
|
||||
Text: text,
|
||||
},
|
||||
})
|
||||
}
|
||||
r.mrkdwn.Reset()
|
||||
}
|
||||
|
||||
// addInline adds an inline element to the appropriate context.
|
||||
func (r *nodeRenderer) addInline(el interface{}) {
|
||||
if r.listCtx != nil {
|
||||
r.listCtx.currentItemInlines = append(r.listCtx.currentItemInlines, el)
|
||||
} else if r.tableCtx != nil {
|
||||
r.tableCtx.currentCellInlines = append(r.tableCtx.currentCellInlines, el)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Document ---
|
||||
|
||||
func (r *nodeRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.blocks = nil
|
||||
r.mrkdwn.Reset()
|
||||
r.styleStack = nil
|
||||
r.listCtx = nil
|
||||
r.tableCtx = nil
|
||||
r.blockquoteDepth = 0
|
||||
} else {
|
||||
// on exiting the document node write the json for the collected blocks.
|
||||
r.flushMrkdwn()
|
||||
var data []byte
|
||||
var err error
|
||||
if len(r.blocks) > 0 {
|
||||
data, err = json.Marshal(r.blocks)
|
||||
if err != nil {
|
||||
return ast.WalkStop, err
|
||||
}
|
||||
} else {
|
||||
// if no blocks are collected, write an empty array.
|
||||
data = []byte("[]")
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
if err != nil {
|
||||
return ast.WalkStop, err
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Heading ---
|
||||
|
||||
func (r *nodeRenderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.mrkdwn.WriteString("*")
|
||||
} else {
|
||||
r.mrkdwn.WriteString("*\n")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Paragraph ---
|
||||
|
||||
func (r *nodeRenderer) renderParagraph(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
if r.mrkdwn.Len() > 0 {
|
||||
text := r.mrkdwn.String()
|
||||
if !strings.HasSuffix(text, "\n") {
|
||||
r.mrkdwn.WriteString("\n")
|
||||
}
|
||||
}
|
||||
// handling of nested blockquotes
|
||||
if r.blockquoteDepth > 0 {
|
||||
r.mrkdwn.WriteString(strings.Repeat("> ", r.blockquoteDepth))
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- ThematicBreak ---
|
||||
|
||||
func (r *nodeRenderer) renderThematicBreak(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.flushMrkdwn()
|
||||
r.blocks = append(r.blocks, templatingtypes.DividerBlock{Type: "divider"})
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- CodeBlock (indented) ---
|
||||
|
||||
func (r *nodeRenderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
r.flushMrkdwn()
|
||||
|
||||
var buf bytes.Buffer
|
||||
lines := node.Lines()
|
||||
for i := 0; i < lines.Len(); i++ {
|
||||
line := lines.At(i)
|
||||
buf.Write(line.Value(source))
|
||||
}
|
||||
|
||||
text := buf.String()
|
||||
// Remove trailing newline
|
||||
text = strings.TrimRight(text, "\n")
|
||||
// Slack API rejects empty text in rich_text_preformatted elements
|
||||
if text == "" {
|
||||
text = " "
|
||||
}
|
||||
|
||||
elements := []interface{}{
|
||||
templatingtypes.RichTextInline{Type: "text", Text: text},
|
||||
}
|
||||
|
||||
r.blocks = append(r.blocks, templatingtypes.RichTextBlock{
|
||||
Type: "rich_text",
|
||||
Elements: []interface{}{
|
||||
templatingtypes.RichTextPreformatted{
|
||||
Type: "rich_text_preformatted",
|
||||
Elements: elements,
|
||||
Border: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- FencedCodeBlock ---
|
||||
|
||||
func (r *nodeRenderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
r.flushMrkdwn()
|
||||
|
||||
n := node.(*ast.FencedCodeBlock)
|
||||
var buf bytes.Buffer
|
||||
lines := node.Lines()
|
||||
for i := 0; i < lines.Len(); i++ {
|
||||
line := lines.At(i)
|
||||
buf.Write(line.Value(source))
|
||||
}
|
||||
|
||||
text := buf.String()
|
||||
text = strings.TrimRight(text, "\n")
|
||||
// Slack API rejects empty text in rich_text_preformatted elements
|
||||
if text == "" {
|
||||
text = " "
|
||||
}
|
||||
|
||||
elements := []interface{}{
|
||||
templatingtypes.RichTextInline{Type: "text", Text: text},
|
||||
}
|
||||
|
||||
// If language is specified, collect it.
|
||||
var language string
|
||||
lang := n.Language(source)
|
||||
if len(lang) > 0 {
|
||||
language = string(lang)
|
||||
}
|
||||
// Add the preformatted block to the blocks slice with the collected language.
|
||||
r.blocks = append(r.blocks, templatingtypes.RichTextBlock{
|
||||
Type: "rich_text",
|
||||
Elements: []interface{}{
|
||||
templatingtypes.RichTextPreformatted{
|
||||
Type: "rich_text_preformatted",
|
||||
Elements: elements,
|
||||
Border: 0,
|
||||
Language: language,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// --- Blockquote ---
|
||||
|
||||
func (r *nodeRenderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.blockquoteDepth++
|
||||
} else {
|
||||
r.blockquoteDepth--
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- List ---
|
||||
|
||||
func (r *nodeRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
list := node.(*ast.List)
|
||||
|
||||
if entering {
|
||||
style := "bullet"
|
||||
if list.IsOrdered() {
|
||||
style = "ordered"
|
||||
}
|
||||
|
||||
if r.listCtx == nil {
|
||||
// Top-level list: flush mrkdwn and create context
|
||||
r.flushMrkdwn()
|
||||
r.listCtx = &listContext{}
|
||||
} else {
|
||||
// Nested list: check if we already have some collected list items that needs to be flushed.
|
||||
// in slack blockkit, list items with different levels of indentation are added as different rich_text_list blocks.
|
||||
if len(r.listCtx.currentItemInlines) > 0 {
|
||||
sec := templatingtypes.RichTextBlock{
|
||||
Type: "rich_text_section",
|
||||
Elements: r.listCtx.currentItemInlines,
|
||||
}
|
||||
if r.listCtx.current != nil {
|
||||
r.listCtx.current.Elements = append(r.listCtx.current.Elements, sec)
|
||||
}
|
||||
r.listCtx.currentItemInlines = nil
|
||||
// Increment parent's itemCount
|
||||
if len(r.listCtx.stack) > 0 {
|
||||
r.listCtx.stack[len(r.listCtx.stack)-1].itemCount++
|
||||
}
|
||||
}
|
||||
// Finalize current list to result only if items were collected
|
||||
if r.listCtx.current != nil && len(r.listCtx.current.Elements) > 0 {
|
||||
r.listCtx.result = append(r.listCtx.result, *r.listCtx.current)
|
||||
}
|
||||
}
|
||||
|
||||
// the stack accumulated till this level derives hte indentation
|
||||
// the stack get's collected as we go in more nested levels of list
|
||||
// and as we get our of the nesting we remove the items from the slack
|
||||
indent := len(r.listCtx.stack)
|
||||
r.listCtx.stack = append(r.listCtx.stack, listFrame{
|
||||
style: style,
|
||||
indent: indent,
|
||||
itemCount: 0,
|
||||
})
|
||||
|
||||
newList := &templatingtypes.RichTextList{
|
||||
Type: "rich_text_list",
|
||||
Style: style,
|
||||
Indent: indent,
|
||||
Border: 0,
|
||||
Elements: []interface{}{},
|
||||
}
|
||||
|
||||
// Handle ordered list with start > 1
|
||||
if list.IsOrdered() && list.Start > 1 {
|
||||
newList.Offset = list.Start - 1
|
||||
}
|
||||
|
||||
r.listCtx.current = newList
|
||||
|
||||
} else {
|
||||
// Leaving list: finalize current list
|
||||
if r.listCtx.current != nil && len(r.listCtx.current.Elements) > 0 {
|
||||
r.listCtx.result = append(r.listCtx.result, *r.listCtx.current)
|
||||
}
|
||||
|
||||
// Pop stack to so upcoming indentations can be handled correctly.
|
||||
r.listCtx.stack = r.listCtx.stack[:len(r.listCtx.stack)-1]
|
||||
|
||||
if len(r.listCtx.stack) > 0 {
|
||||
// Resume parent: start a new list segment at parent indent/style
|
||||
parent := &r.listCtx.stack[len(r.listCtx.stack)-1]
|
||||
newList := &templatingtypes.RichTextList{
|
||||
Type: "rich_text_list",
|
||||
Style: parent.style,
|
||||
Indent: parent.indent,
|
||||
Border: 0,
|
||||
Elements: []interface{}{},
|
||||
}
|
||||
// Set offset for ordered parent continuation
|
||||
if parent.style == "ordered" && parent.itemCount > 0 {
|
||||
newList.Offset = parent.itemCount
|
||||
}
|
||||
r.listCtx.current = newList
|
||||
} else {
|
||||
// Top-level list is done since all stack are popped: build templatingtypes.RichTextBlock if non-empty
|
||||
if len(r.listCtx.result) > 0 {
|
||||
elements := make([]interface{}, len(r.listCtx.result))
|
||||
for i, l := range r.listCtx.result {
|
||||
elements[i] = l
|
||||
}
|
||||
r.blocks = append(r.blocks, templatingtypes.RichTextBlock{
|
||||
Type: "rich_text",
|
||||
Elements: elements,
|
||||
})
|
||||
}
|
||||
r.listCtx = nil
|
||||
}
|
||||
}
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- ListItem ---
|
||||
|
||||
func (r *nodeRenderer) renderListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.listCtx.currentItemInlines = nil
|
||||
} else {
|
||||
// Only add if there are inlines (might be empty after nested list consumed them)
|
||||
if len(r.listCtx.currentItemInlines) > 0 {
|
||||
sec := templatingtypes.RichTextBlock{
|
||||
Type: "rich_text_section",
|
||||
Elements: r.listCtx.currentItemInlines,
|
||||
}
|
||||
if r.listCtx.current != nil {
|
||||
r.listCtx.current.Elements = append(r.listCtx.current.Elements, sec)
|
||||
}
|
||||
r.listCtx.currentItemInlines = nil
|
||||
// Increment parent frame's itemCount
|
||||
if len(r.listCtx.stack) > 0 {
|
||||
r.listCtx.stack[len(r.listCtx.stack)-1].itemCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Table ---
|
||||
// when table is encountered, we flush the markdown and create a table context.
|
||||
// when header row is encountered, we set the isHeader flag to true
|
||||
// when each row ends in renderTableRow we add that row to rows array of table context.
|
||||
// when table cell is encountered, we apply header related styles to the collected inline items,
|
||||
// all inline items are parsed as separate AST items like list item, links, text, etc. are collected
|
||||
// using the addInline function and wrapped in a rich_text_section block.
|
||||
|
||||
func (r *nodeRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.flushMrkdwn()
|
||||
r.tableCtx = &tableContext{}
|
||||
} else {
|
||||
// Pad short rows to match header column count for valid Block Kit payload
|
||||
// without this slack blockkit attachment is invalid and the API fails
|
||||
rows := r.tableCtx.rows
|
||||
if len(rows) > 0 {
|
||||
maxCols := len(rows[0])
|
||||
for i, row := range rows {
|
||||
for len(row) < maxCols {
|
||||
emptySec := templatingtypes.RichTextBlock{
|
||||
Type: "rich_text_section",
|
||||
Elements: []interface{}{templatingtypes.RichTextInline{Type: "text", Text: " "}},
|
||||
}
|
||||
row = append(row, templatingtypes.TableCell{
|
||||
Type: "rich_text",
|
||||
Elements: []interface{}{emptySec},
|
||||
})
|
||||
}
|
||||
rows[i] = row
|
||||
}
|
||||
}
|
||||
r.blocks = append(r.blocks, templatingtypes.TableBlock{
|
||||
Type: "table",
|
||||
Rows: rows,
|
||||
})
|
||||
r.tableCtx = nil
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderTableHeader(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.tableCtx.isHeader = true
|
||||
r.tableCtx.currentRow = nil
|
||||
} else {
|
||||
r.tableCtx.rows = append(r.tableCtx.rows, r.tableCtx.currentRow)
|
||||
r.tableCtx.currentRow = nil
|
||||
r.tableCtx.isHeader = false
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderTableRow(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.tableCtx.currentRow = nil
|
||||
} else {
|
||||
r.tableCtx.rows = append(r.tableCtx.rows, r.tableCtx.currentRow)
|
||||
r.tableCtx.currentRow = nil
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderTableCell(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.tableCtx.currentCellInlines = nil
|
||||
} else {
|
||||
// If header, make text bold for the collected inline items.
|
||||
if r.tableCtx.isHeader {
|
||||
for i, el := range r.tableCtx.currentCellInlines {
|
||||
if inline, ok := el.(templatingtypes.RichTextInline); ok {
|
||||
if inline.Style == nil {
|
||||
inline.Style = &templatingtypes.RichTextStyle{Bold: true}
|
||||
} else {
|
||||
inline.Style.Bold = true
|
||||
}
|
||||
r.tableCtx.currentCellInlines[i] = inline
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ensure cell has at least one element for valid Block Kit payload
|
||||
if len(r.tableCtx.currentCellInlines) == 0 {
|
||||
r.tableCtx.currentCellInlines = []interface{}{
|
||||
templatingtypes.RichTextInline{Type: "text", Text: " "},
|
||||
}
|
||||
}
|
||||
// All inline items that are collected for a table cell are wrapped in a rich_text_section block.
|
||||
sec := templatingtypes.RichTextBlock{
|
||||
Type: "rich_text_section",
|
||||
Elements: r.tableCtx.currentCellInlines,
|
||||
}
|
||||
// The rich_text_section block is wrapped in a rich_text block.
|
||||
cell := templatingtypes.TableCell{
|
||||
Type: "rich_text",
|
||||
Elements: []interface{}{sec},
|
||||
}
|
||||
r.tableCtx.currentRow = append(r.tableCtx.currentRow, cell)
|
||||
r.tableCtx.currentCellInlines = nil
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- TaskCheckBox ---
|
||||
|
||||
func (r *nodeRenderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*extensionast.TaskCheckBox)
|
||||
text := "[ ] "
|
||||
if n.IsChecked {
|
||||
text = "[x] "
|
||||
}
|
||||
if r.inRichTextMode() {
|
||||
r.addInline(templatingtypes.RichTextInline{Type: "text", Text: text})
|
||||
} else {
|
||||
r.mrkdwn.WriteString(text)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Inline: Text ---
|
||||
|
||||
func (r *nodeRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.Text)
|
||||
value := string(n.Segment.Value(source))
|
||||
|
||||
if r.inRichTextMode() {
|
||||
r.addInline(templatingtypes.RichTextInline{
|
||||
Type: "text",
|
||||
Text: value,
|
||||
Style: r.currentStyle(),
|
||||
})
|
||||
if n.HardLineBreak() || n.SoftLineBreak() {
|
||||
r.addInline(templatingtypes.RichTextInline{Type: "text", Text: "\n"})
|
||||
}
|
||||
} else {
|
||||
r.mrkdwn.WriteString(value)
|
||||
if n.HardLineBreak() || n.SoftLineBreak() {
|
||||
r.mrkdwn.WriteString("\n")
|
||||
if r.blockquoteDepth > 0 {
|
||||
r.mrkdwn.WriteString(strings.Repeat("> ", r.blockquoteDepth))
|
||||
}
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Inline: Emphasis ---
|
||||
|
||||
func (r *nodeRenderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Emphasis)
|
||||
if r.inRichTextMode() {
|
||||
if entering {
|
||||
s := templatingtypes.RichTextStyle{}
|
||||
if n.Level == 1 {
|
||||
s.Italic = true
|
||||
} else {
|
||||
s.Bold = true
|
||||
}
|
||||
r.styleStack = append(r.styleStack, s)
|
||||
} else {
|
||||
// the collected style gets used by the rich text element using currentStyle()
|
||||
// so we remove this style from the stack.
|
||||
if len(r.styleStack) > 0 {
|
||||
r.styleStack = r.styleStack[:len(r.styleStack)-1]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if n.Level == 1 {
|
||||
r.mrkdwn.WriteString("_")
|
||||
} else {
|
||||
r.mrkdwn.WriteString("*")
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Inline: Strikethrough ---
|
||||
|
||||
func (r *nodeRenderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if r.inRichTextMode() {
|
||||
if entering {
|
||||
r.styleStack = append(r.styleStack, templatingtypes.RichTextStyle{Strike: true})
|
||||
} else {
|
||||
// the collected style gets used by the rich text element using currentStyle()
|
||||
// so we remove this style from the stack.
|
||||
if len(r.styleStack) > 0 {
|
||||
r.styleStack = r.styleStack[:len(r.styleStack)-1]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
r.mrkdwn.WriteString("~")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Inline: CodeSpan ---
|
||||
|
||||
func (r *nodeRenderer) renderCodeSpan(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
if r.inRichTextMode() {
|
||||
// Collect all child text
|
||||
var buf bytes.Buffer
|
||||
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if t, ok := c.(*ast.Text); ok {
|
||||
v := t.Segment.Value(source)
|
||||
if bytes.HasSuffix(v, []byte("\n")) {
|
||||
buf.Write(v[:len(v)-1])
|
||||
buf.WriteByte(' ')
|
||||
} else {
|
||||
buf.Write(v)
|
||||
}
|
||||
} else if s, ok := c.(*ast.String); ok {
|
||||
buf.Write(s.Value)
|
||||
}
|
||||
}
|
||||
style := r.currentStyle()
|
||||
if style == nil {
|
||||
style = &templatingtypes.RichTextStyle{Code: true}
|
||||
} else {
|
||||
style.Code = true
|
||||
}
|
||||
r.addInline(templatingtypes.RichTextInline{
|
||||
Type: "text",
|
||||
Text: buf.String(),
|
||||
Style: style,
|
||||
})
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
// mrkdwn mode
|
||||
r.mrkdwn.WriteByte('`')
|
||||
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if t, ok := c.(*ast.Text); ok {
|
||||
v := t.Segment.Value(source)
|
||||
if bytes.HasSuffix(v, []byte("\n")) {
|
||||
r.mrkdwn.Write(v[:len(v)-1])
|
||||
r.mrkdwn.WriteByte(' ')
|
||||
} else {
|
||||
r.mrkdwn.Write(v)
|
||||
}
|
||||
} else if s, ok := c.(*ast.String); ok {
|
||||
r.mrkdwn.Write(s.Value)
|
||||
}
|
||||
}
|
||||
r.mrkdwn.WriteByte('`')
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// --- Inline: Link ---
|
||||
|
||||
func (r *nodeRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Link)
|
||||
if r.inRichTextMode() {
|
||||
if entering {
|
||||
// Walk the entire subtree to collect text from all descendants,
|
||||
// including nested inline nodes like emphasis, strong, code spans, etc.
|
||||
var buf bytes.Buffer
|
||||
_ = ast.Walk(node, func(child ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering || child == node {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
if t, ok := child.(*ast.Text); ok {
|
||||
buf.Write(t.Segment.Value(source))
|
||||
} else if s, ok := child.(*ast.String); ok {
|
||||
buf.Write(s.Value)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
// Once we've collected the text for the link (given it was present)
|
||||
// let's add the link to the rich text block.
|
||||
r.addInline(templatingtypes.RichTextLink{
|
||||
Type: "link",
|
||||
URL: string(n.Destination),
|
||||
Text: buf.String(),
|
||||
Style: r.currentStyle(),
|
||||
})
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
} else {
|
||||
if entering {
|
||||
r.mrkdwn.WriteString("<")
|
||||
r.mrkdwn.Write(n.Destination)
|
||||
r.mrkdwn.WriteString("|")
|
||||
} else {
|
||||
r.mrkdwn.WriteString(">")
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Image (skip) ---
|
||||
|
||||
func (r *nodeRenderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
@@ -1,542 +0,0 @@
|
||||
package blockkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
func jsonEqual(a, b string) bool {
|
||||
var va, vb interface{}
|
||||
if err := json.Unmarshal([]byte(a), &va); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal([]byte(b), &vb); err != nil {
|
||||
return false
|
||||
}
|
||||
ja, _ := json.Marshal(va)
|
||||
jb, _ := json.Marshal(vb)
|
||||
return string(ja) == string(jb)
|
||||
}
|
||||
|
||||
func prettyJSON(s string) string {
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
return s
|
||||
}
|
||||
b, _ := json.MarshalIndent(v, "", " ")
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestRenderer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
markdown: "",
|
||||
expected: `[]`,
|
||||
},
|
||||
{
|
||||
name: "simple paragraph",
|
||||
markdown: "Hello world",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "Hello world" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "heading",
|
||||
markdown: "# My Heading",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "*My Heading*" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "multiple paragraphs",
|
||||
markdown: "First paragraph\n\nSecond paragraph",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "First paragraph\nSecond paragraph" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "todo list ",
|
||||
markdown: "- [ ] item 1\n- [x] item 2",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"border": 0,
|
||||
"elements": [
|
||||
{ "elements": [ { "text": "[ ] ", "type": "text" }, { "text": "item 1", "type": "text" } ], "type": "rich_text_section" },
|
||||
{ "elements": [ { "text": "[x] ", "type": "text" }, { "text": "item 2", "type": "text" } ], "type": "rich_text_section" }
|
||||
],
|
||||
"indent": 0,
|
||||
"style": "bullet",
|
||||
"type": "rich_text_list"
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "thematic break between paragraphs",
|
||||
markdown: "Before\n\n---\n\nAfter",
|
||||
expected: `[
|
||||
{ "type": "section", "text": { "type": "mrkdwn", "text": "Before" } },
|
||||
{ "type": "divider" },
|
||||
{ "type": "section", "text": { "type": "mrkdwn", "text": "After" } }
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "fenced code block with language",
|
||||
markdown: "```go\nfmt.Println(\"hello\")\n```",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_preformatted",
|
||||
"border": 0,
|
||||
"language": "go",
|
||||
"elements": [
|
||||
{ "type": "text", "text": "fmt.Println(\"hello\")" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "indented code block",
|
||||
markdown: " code line 1\n code line 2",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_preformatted",
|
||||
"border": 0,
|
||||
"elements": [
|
||||
{ "type": "text", "text": "code line 1\ncode line 2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "empty fenced code block",
|
||||
markdown: "```\n```",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_preformatted",
|
||||
"border": 0,
|
||||
"elements": [
|
||||
{ "type": "text", "text": " " }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "simple bullet list",
|
||||
markdown: "- item 1\n- item 2\n- item 3",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 1" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 2" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 3" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "simple ordered list",
|
||||
markdown: "1. first\n2. second\n3. third",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "first" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "second" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "third" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "nested bullet list (2 levels)",
|
||||
markdown: "- item 1\n- item 2\n - sub a\n - sub b\n- item 3",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 1" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 2" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 1, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "sub a" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "sub b" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 3" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "nested ordered list with offset",
|
||||
markdown: "1. first\n 1. nested-a\n 2. nested-b\n2. second\n3. third",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "first" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 1, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "nested-a" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "nested-b" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 1,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "second" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "third" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "mixed ordered/bullet nesting",
|
||||
markdown: "1. ordered\n - bullet child\n2. ordered again",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "ordered" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 1, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "bullet child" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 1,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "ordered again" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "list items with bold/italic/link/code",
|
||||
markdown: "- **bold item**\n- _italic item_\n- [link](http://example.com)\n- `code item`",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "bold item", "style": { "bold": true } }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "italic item", "style": { "italic": true } }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "link", "url": "http://example.com", "text": "link" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "code item", "style": { "code": true } }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "table with header and body",
|
||||
markdown: "| Name | Age |\n|------|-----|\n| Alice | 30 |",
|
||||
expected: `[
|
||||
{
|
||||
"type": "table",
|
||||
"rows": [
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Name", "style": { "bold": true } }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Age", "style": { "bold": true } }] }
|
||||
]}
|
||||
],
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Alice" }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "30" }] }
|
||||
]}
|
||||
]
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "blockquote",
|
||||
markdown: "> quoted text",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "> quoted text" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "blockquote with nested list",
|
||||
markdown: "> item 1\n> > item 2\n> > item 3",
|
||||
expected: `[
|
||||
{
|
||||
"text": {
|
||||
"text": "> item 1\n> > item 2\n> > item 3",
|
||||
"type": "mrkdwn"
|
||||
},
|
||||
"type": "section"
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "inline formatting in paragraph",
|
||||
markdown: "This is **bold** and _italic_ and ~strike~ and `code`",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "This is *bold* and _italic_ and ~strike~ and ` + "`code`" + `" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "link in paragraph",
|
||||
markdown: "Visit [Google](http://google.com)",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "Visit <http://google.com|Google>" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "image is skipped",
|
||||
markdown: "",
|
||||
// For image skip the block and return empty array
|
||||
expected: `[]`,
|
||||
},
|
||||
{
|
||||
name: "paragraph then list then paragraph",
|
||||
markdown: "Before\n\n- item\n\nAfter",
|
||||
expected: `[
|
||||
{ "type": "section", "text": { "type": "mrkdwn", "text": "Before" } },
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "type": "section", "text": { "type": "mrkdwn", "text": "After" } }
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "ordered list with start > 1",
|
||||
markdown: "5. fifth\n6. sixth",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 4,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "fifth" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "sixth" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "deeply nested ordered list (3 levels) with offsets",
|
||||
markdown: "1. Some things\n\t1. are best left\n2. to the fate\n\t1. of the world\n\t\t1. and then\n\t\t2. this is how\n3. it turns out to be",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Some things" }] }] },
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 1, "border": 0,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "are best left" }] }] },
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 1,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "to the fate" }] }] },
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 1, "border": 0,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "of the world" }] }] },
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 2, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "and then" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "this is how" }] }
|
||||
]
|
||||
},
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 2,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "it turns out to be" }] }] }
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "link with bold label in list item",
|
||||
markdown: "- [**docs**](http://example.com)",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "link", "url": "http://example.com", "text": "docs" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "table with empty cell",
|
||||
markdown: "| A | B |\n|---|---|\n| 1 | |",
|
||||
expected: `[
|
||||
{
|
||||
"type": "table",
|
||||
"rows": [
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "A", "style": { "bold": true } }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "B", "style": { "bold": true } }] }
|
||||
]}
|
||||
],
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "1" }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": " " }] }
|
||||
]}
|
||||
]
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "table with missing column in row",
|
||||
markdown: "| A | B |\n|---|---|\n| 1 |",
|
||||
expected: `[
|
||||
{
|
||||
"type": "table",
|
||||
"rows": [
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "A", "style": { "bold": true } }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "B", "style": { "bold": true } }] }
|
||||
]}
|
||||
],
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "1" }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": " " }] }
|
||||
]}
|
||||
]
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
md := goldmark.New(
|
||||
goldmark.WithExtensions(Extender),
|
||||
)
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
|
||||
t.Fatalf("convert error: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !jsonEqual(got, tt.expected) {
|
||||
t.Errorf("JSON mismatch\n\nMarkdown:\n%s\n\nExpected:\n%s\n\nGot:\n%s",
|
||||
tt.markdown, prettyJSON(tt.expected), prettyJSON(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// Package blockkit provides a goldmark extension that renders markdown as
|
||||
// Slack Block Kit JSON (an array of blocks suitable for the Slack API's
|
||||
// `blocks` payload field).
|
||||
//
|
||||
// The current Slack notifier uses the sibling mrkdwn package instead, because
|
||||
// Block Kit's hard limits (one table per message, 50 blocks per message,
|
||||
// 12k-character total text) would silently truncate or reject notifications
|
||||
// with rich bodies. This package is kept in tree for future use.
|
||||
package blockkit
|
||||
@@ -1,47 +0,0 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestConcurrentRender exercises every render entry point from many
|
||||
// goroutines. Run with `go test -race` to catch shared-state regressions
|
||||
// in the HTML, Block Kit or mrkdwn paths.
|
||||
func TestConcurrentRender(t *testing.T) {
|
||||
const goroutines = 32
|
||||
const iterations = 20
|
||||
|
||||
markdowns := []string{
|
||||
"# Heading\n\nparagraph **bold** *em* `code`",
|
||||
"- item 1\n- item 2\n - nested\n- item 3",
|
||||
"| a | b |\n| - | - |\n| 1 | 2 |\n| 3 | 4 |",
|
||||
"> quote\n>> nested quote",
|
||||
"```go\npackage main\n```",
|
||||
"[link](https://example.com) and ",
|
||||
}
|
||||
|
||||
renderers := map[string]func(string) (string, error){
|
||||
"html": RenderHTML,
|
||||
"blockkit": RenderSlackBlockKit,
|
||||
"mrkdwn": RenderSlackMrkdwn,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for name, render := range renderers {
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func(name string, render func(string) (string, error), g int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
md := markdowns[(g+i)%len(markdowns)]
|
||||
if _, err := render(md); err != nil {
|
||||
t.Errorf("%s render failed: %v", name, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}(name, render, g)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// Package markdownrenderer renders markdown to the formats that alert
|
||||
// notifications are delivered in: HTML, Slack Block Kit JSON, and Slack mrkdwn.
|
||||
package markdownrenderer
|
||||
@@ -1,166 +0,0 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var testMarkdown = `# 🔥 FIRING: High CPU Usage on api-gateway
|
||||
|
||||
https://signoz.example.com/alerts/123
|
||||
https://runbooks.example.com/cpu-high
|
||||
|
||||
## Alert Details
|
||||
|
||||
**Status:** **FIRING** | *api-gateway* service is experiencing high CPU usage. ~~resolved~~ previously.
|
||||
|
||||
Alert triggered because ` + "`cpu_usage_percent`" + ` exceeded threshold ` + "`90`" + `.
|
||||
|
||||
[View Alert in SigNoz](https://signoz.example.com/alerts/123) | [View Logs](https://signoz.example.com/logs?service=api-gateway) | [View Traces](https://signoz.example.com/traces?service=api-gateway)
|
||||
|
||||

|
||||
|
||||
## Alert Labels
|
||||
|
||||
| Label | Value |
|
||||
| -------- | ----------- |
|
||||
| service | api-gateway |
|
||||
| instance | pod-5a8b3c |
|
||||
| severity | critical |
|
||||
| region | us-east-1 |
|
||||
|
||||
## Remediation Steps
|
||||
|
||||
1. Check current CPU usage on the pod
|
||||
2. Review recent deployments for regressions
|
||||
3. Scale horizontally if load-related
|
||||
1. Increase replica count
|
||||
2. Verify HPA configuration
|
||||
|
||||
## Affected Services
|
||||
|
||||
* api-gateway
|
||||
* auth-service
|
||||
* payment-service
|
||||
* payment-processor
|
||||
* payment-validator
|
||||
|
||||
## Incident Checklist
|
||||
|
||||
- [x] Alert acknowledged
|
||||
- [x] On-call notified
|
||||
- [ ] Root cause identified
|
||||
- [ ] Fix deployed
|
||||
|
||||
## Alert Rule Description
|
||||
|
||||
> This alert fires when CPU usage exceeds 90% for more than 5 minutes on any pod in the api-gateway service.
|
||||
>
|
||||
>> For capacity planning guidelines, see the infrastructure runbook section on horizontal pod autoscaling.
|
||||
|
||||
## Triggered Query
|
||||
` + "```promql\navg(rate(container_cpu_usage_seconds_total{service=\"api-gateway\"}[5m])) by (pod) > 0.9\n```" + `
|
||||
|
||||
## Inline Details
|
||||
|
||||
This alert was generated by SigNoz using ` + "`alertmanager`" + ` rules engine.
|
||||
`
|
||||
|
||||
func TestRenderHTML_Composite(t *testing.T) {
|
||||
html, err := RenderHTML(testMarkdown)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := "<h1>🔥 FIRING: High CPU Usage on api-gateway</h1>\n" +
|
||||
"<p><a href=\"https://signoz.example.com/alerts/123\">https://signoz.example.com/alerts/123</a>\n<a href=\"https://runbooks.example.com/cpu-high\">https://runbooks.example.com/cpu-high</a></p>\n" +
|
||||
"<h2>Alert Details</h2>\n" +
|
||||
"<p><strong>Status:</strong> <strong>FIRING</strong> | <em>api-gateway</em> service is experiencing high CPU usage. <del>resolved</del> previously.</p>\n" +
|
||||
"<p>Alert triggered because <code>cpu_usage_percent</code> exceeded threshold <code>90</code>.</p>\n" +
|
||||
"<p><a href=\"https://signoz.example.com/alerts/123\">View Alert in SigNoz</a> | <a href=\"https://signoz.example.com/logs?service=api-gateway\">View Logs</a> | <a href=\"https://signoz.example.com/traces?service=api-gateway\">View Traces</a></p>\n" +
|
||||
"<p><img src=\"https://signoz.example.com/badges/critical.svg\" alt=\"critical\" title=\"Critical Alert\"></p>\n" +
|
||||
"<h2>Alert Labels</h2>\n" +
|
||||
"<table>\n<thead>\n<tr>\n<th>Label</th>\n<th>Value</th>\n</tr>\n</thead>\n" +
|
||||
"<tbody>\n<tr>\n<td>service</td>\n<td>api-gateway</td>\n</tr>\n" +
|
||||
"<tr>\n<td>instance</td>\n<td>pod-5a8b3c</td>\n</tr>\n" +
|
||||
"<tr>\n<td>severity</td>\n<td>critical</td>\n</tr>\n" +
|
||||
"<tr>\n<td>region</td>\n<td>us-east-1</td>\n</tr>\n</tbody>\n</table>\n" +
|
||||
"<h2>Remediation Steps</h2>\n" +
|
||||
"<ol>\n<li>Check current CPU usage on the pod</li>\n<li>Review recent deployments for regressions</li>\n<li>Scale horizontally if load-related\n" +
|
||||
"<ol>\n<li>Increase replica count</li>\n<li>Verify HPA configuration</li>\n</ol>\n</li>\n</ol>\n" +
|
||||
"<h2>Affected Services</h2>\n" +
|
||||
"<ul>\n<li>api-gateway</li>\n<li>auth-service</li>\n<li>payment-service\n" +
|
||||
"<ul>\n<li>payment-processor</li>\n<li>payment-validator</li>\n</ul>\n</li>\n</ul>\n" +
|
||||
"<h2>Incident Checklist</h2>\n" +
|
||||
"<ul>\n<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> Alert acknowledged</li>\n" +
|
||||
"<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> On-call notified</li>\n" +
|
||||
"<li><input disabled=\"\" type=\"checkbox\"> Root cause identified</li>\n" +
|
||||
"<li><input disabled=\"\" type=\"checkbox\"> Fix deployed</li>\n</ul>\n" +
|
||||
"<h2>Alert Rule Description</h2>\n" +
|
||||
"<blockquote>\n<p>This alert fires when CPU usage exceeds 90% for more than 5 minutes on any pod in the api-gateway service.</p>\n" +
|
||||
"<blockquote>\n<p>For capacity planning guidelines, see the infrastructure runbook section on horizontal pod autoscaling.</p>\n</blockquote>\n</blockquote>\n" +
|
||||
"<h2>Triggered Query</h2>\n" +
|
||||
"<pre><code class=\"language-promql\">avg(rate(container_cpu_usage_seconds_total{service="api-gateway"}[5m])) by (pod) > 0.9\n</code></pre>\n" +
|
||||
"<h2>Inline Details</h2>\n" +
|
||||
"<p>This alert was generated by SigNoz using <code>alertmanager</code> rules engine.</p>\n"
|
||||
|
||||
assert.Equal(t, expected, html)
|
||||
}
|
||||
|
||||
func TestRenderHTML_InlineFormatting(t *testing.T) {
|
||||
input := `# 🔥 FIRING: High CPU on api-gateway
|
||||
## Alert Status
|
||||
|
||||
**FIRING** alert for *api-gateway* service — ~~resolved~~ previously.
|
||||
|
||||
Metric ` + "`cpu_usage_percent`" + ` exceeded threshold. [View in SigNoz](https://signoz.example.com/alerts/123)
|
||||
|
||||
`
|
||||
|
||||
html, err := RenderHTML(input)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := "<h1>🔥 FIRING: High CPU on api-gateway</h1>\n<h2>Alert Status</h2>\n" +
|
||||
"<p><strong>FIRING</strong> alert for <em>api-gateway</em> service — <del>resolved</del> previously.</p>\n" +
|
||||
"<p>Metric <code>cpu_usage_percent</code> exceeded threshold. <a href=\"https://signoz.example.com/alerts/123\">View in SigNoz</a></p>\n" +
|
||||
"<p><img src=\"https://signoz.example.com/badges/critical.svg\" alt=\"critical\" title=\"Critical Alert\"></p>\n"
|
||||
|
||||
assert.Equal(t, expected, html)
|
||||
}
|
||||
|
||||
func TestRenderHTML_BlockElements(t *testing.T) {
|
||||
input := `1. Check CPU usage on the pod
|
||||
2. Review recent deployments
|
||||
3. Scale horizontally if needed
|
||||
|
||||
* api-gateway
|
||||
* auth-service
|
||||
* payment-service
|
||||
|
||||
- [x] Alert acknowledged
|
||||
- [ ] Root cause identified
|
||||
|
||||
> This alert fires when CPU usage exceeds 90% for more than 5 minutes.
|
||||
|
||||
| Label | Value |
|
||||
| -------- | ----------- |
|
||||
| service | api-gateway |
|
||||
| severity | <no value> |
|
||||
|
||||
` + "```promql\navg(rate(container_cpu_usage_seconds_total{service=\"api-gateway\"}[5m])) by (pod) > 0.9\n```"
|
||||
|
||||
html, err := RenderHTML(input)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := "<ol>\n<li>Check CPU usage on the pod</li>\n<li>Review recent deployments</li>\n<li>Scale horizontally if needed</li>\n</ol>\n" +
|
||||
"<ul>\n<li>api-gateway</li>\n<li>auth-service</li>\n<li>payment-service</li>\n</ul>\n" +
|
||||
"<ul>\n<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> Alert acknowledged</li>\n" +
|
||||
"<li><input disabled=\"\" type=\"checkbox\"> Root cause identified</li>\n</ul>\n" +
|
||||
"<blockquote>\n<p>This alert fires when CPU usage exceeds 90% for more than 5 minutes.</p>\n</blockquote>\n" +
|
||||
"<table>\n<thead>\n<tr>\n<th>Label</th>\n<th>Value</th>\n</tr>\n</thead>\n" +
|
||||
"<tbody>\n<tr>\n<td>service</td>\n<td>api-gateway</td>\n</tr>\n" +
|
||||
"<tr>\n<td>severity</td>\n<td><no value></td>\n</tr>\n</tbody>\n</table>\n" +
|
||||
"<pre><code class=\"language-promql\">avg(rate(container_cpu_usage_seconds_total{service="api-gateway"}[5m])) by (pod) > 0.9\n</code></pre>\n"
|
||||
|
||||
assert.Equal(t, expected, html)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/blockkit"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/mrkdwn"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
)
|
||||
|
||||
// htmlRenderer is built once and shared. Goldmark's built-in HTML path and
|
||||
// the novalue extension carry no mutable state, so a single goldmark.Markdown
|
||||
// is safe to Convert concurrently.
|
||||
var htmlRenderer = goldmark.New(goldmark.WithExtensions(extension.GFM, escapeNoValue))
|
||||
|
||||
// The Slack renderers hold per-document state on the node renderer (list
|
||||
// context, table context, style stack, blockquote/list prefixes). Two
|
||||
// goroutines calling Convert on the same goldmark.Markdown would corrupt
|
||||
// that state. A sync.Pool gives each concurrent caller its own instance
|
||||
// while still amortising the cost of building the pipeline.
|
||||
var (
|
||||
blockkitPool = sync.Pool{
|
||||
New: func() any {
|
||||
return goldmark.New(goldmark.WithExtensions(blockkit.Extender))
|
||||
},
|
||||
}
|
||||
mrkdwnPool = sync.Pool{
|
||||
New: func() any {
|
||||
return goldmark.New(goldmark.WithExtensions(mrkdwn.Extender))
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// RenderHTML converts markdown to HTML.
|
||||
func RenderHTML(markdown string) (string, error) {
|
||||
return render(htmlRenderer, markdown, "HTML")
|
||||
}
|
||||
|
||||
// RenderSlackBlockKit converts markdown to a Slack Block Kit JSON array.
|
||||
func RenderSlackBlockKit(markdown string) (string, error) {
|
||||
md := blockkitPool.Get().(goldmark.Markdown)
|
||||
defer blockkitPool.Put(md)
|
||||
return render(md, markdown, "Slack Block Kit")
|
||||
}
|
||||
|
||||
// RenderSlackMrkdwn converts markdown to Slack's mrkdwn format.
|
||||
func RenderSlackMrkdwn(markdown string) (string, error) {
|
||||
md := mrkdwnPool.Get().(goldmark.Markdown)
|
||||
defer mrkdwnPool.Put(md)
|
||||
return render(md, markdown, "Slack mrkdwn")
|
||||
}
|
||||
|
||||
func render(md goldmark.Markdown, markdown string, format string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(markdown), &buf); err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "failed to convert markdown to %s", format)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// Package mrkdwn provides a goldmark extension that renders markdown as
|
||||
// Slack's "mrkdwn" text format (the legacy text field used in attachments
|
||||
// and message payloads).
|
||||
package mrkdwn
|
||||
@@ -1,404 +0,0 @@
|
||||
package mrkdwn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
extensionast "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// Extender is a goldmark.Extender that registers the Slack mrkdwn node
|
||||
// renderer together with the GFM extensions it relies on (tables,
|
||||
// strikethrough).
|
||||
var Extender goldmark.Extender = &extender{}
|
||||
|
||||
type extender struct{}
|
||||
|
||||
func (e *extender) Extend(m goldmark.Markdown) {
|
||||
extension.Table.Extend(m)
|
||||
extension.Strikethrough.Extend(m)
|
||||
m.Renderer().AddOptions(
|
||||
renderer.WithNodeRenderers(util.Prioritized(newRenderer(), 1)),
|
||||
)
|
||||
}
|
||||
|
||||
// nodeRenderer renders nodes as Slack mrkdwn.
|
||||
type nodeRenderer struct {
|
||||
prefixes []string
|
||||
}
|
||||
|
||||
func newRenderer() renderer.NodeRenderer {
|
||||
return &nodeRenderer{}
|
||||
}
|
||||
|
||||
// RegisterFuncs implements NodeRenderer.RegisterFuncs.
|
||||
func (r *nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
// Blocks
|
||||
reg.Register(ast.KindDocument, r.renderDocument)
|
||||
reg.Register(ast.KindHeading, r.renderHeading)
|
||||
reg.Register(ast.KindBlockquote, r.renderBlockquote)
|
||||
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindFencedCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindList, r.renderList)
|
||||
reg.Register(ast.KindListItem, r.renderListItem)
|
||||
reg.Register(ast.KindParagraph, r.renderParagraph)
|
||||
reg.Register(ast.KindTextBlock, r.renderTextBlock)
|
||||
reg.Register(ast.KindRawHTML, r.renderRawHTML)
|
||||
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
|
||||
|
||||
// Inlines
|
||||
reg.Register(ast.KindAutoLink, r.renderAutoLink)
|
||||
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
|
||||
reg.Register(ast.KindEmphasis, r.renderEmphasis)
|
||||
reg.Register(ast.KindImage, r.renderImage)
|
||||
reg.Register(ast.KindLink, r.renderLink)
|
||||
reg.Register(ast.KindText, r.renderText)
|
||||
|
||||
// Extensions
|
||||
reg.Register(extensionast.KindStrikethrough, r.renderStrikethrough)
|
||||
reg.Register(extensionast.KindTable, r.renderTable)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) writePrefix(w util.BufWriter) {
|
||||
for _, p := range r.prefixes {
|
||||
_, _ = w.WriteString(p)
|
||||
}
|
||||
}
|
||||
|
||||
// writeLineSeparator writes a newline followed by the current prefix.
|
||||
// Used for tight separations (e.g., between list items or text blocks).
|
||||
func (r *nodeRenderer) writeLineSeparator(w util.BufWriter) {
|
||||
_ = w.WriteByte('\n')
|
||||
r.writePrefix(w)
|
||||
}
|
||||
|
||||
// writeBlockSeparator writes a blank line separator between block-level elements,
|
||||
// respecting any active prefixes for proper nesting (e.g., inside blockquotes).
|
||||
func (r *nodeRenderer) writeBlockSeparator(w util.BufWriter) {
|
||||
r.writeLineSeparator(w)
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
|
||||
// separateFromPrevious writes a block separator if the node has a previous sibling.
|
||||
func (r *nodeRenderer) separateFromPrevious(w util.BufWriter, n ast.Node) {
|
||||
if n.PreviousSibling() != nil {
|
||||
r.writeBlockSeparator(w)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
// The renderer is pooled, so wipe any prefix stack left over from a
|
||||
// prior document (e.g. one that errored mid-walk and left push/pop
|
||||
// unbalanced) before starting a fresh convert.
|
||||
r.prefixes = r.prefixes[:0]
|
||||
} else {
|
||||
_, _ = w.WriteString("\n\n")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, node)
|
||||
}
|
||||
_, _ = w.WriteString("*")
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderBlockquote(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
r.prefixes = append(r.prefixes, "> ")
|
||||
_, _ = w.WriteString("> ")
|
||||
} else {
|
||||
r.prefixes = r.prefixes[:len(r.prefixes)-1]
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
// start code block and write code line by line
|
||||
_, _ = w.WriteString("```\n")
|
||||
l := n.Lines().Len()
|
||||
for i := 0; i < l; i++ {
|
||||
line := n.Lines().At(i)
|
||||
v := line.Value(source)
|
||||
_, _ = w.Write(v)
|
||||
}
|
||||
} else {
|
||||
_, _ = w.WriteString("```")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
if node.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
// another line break if not a nested list item and starting another block
|
||||
if node.Parent() == nil || node.Parent().Kind() != ast.KindListItem {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderListItem(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
if n.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
parent := n.Parent().(*ast.List)
|
||||
// compute and write the prefix based on list type and index
|
||||
var prefixStr string
|
||||
if parent.IsOrdered() {
|
||||
index := parent.Start
|
||||
for c := parent.FirstChild(); c != nil && c != n; c = c.NextSibling() {
|
||||
index++
|
||||
}
|
||||
prefixStr = fmt.Sprintf("%d. ", index)
|
||||
} else {
|
||||
prefixStr = "• "
|
||||
}
|
||||
_, _ = w.WriteString(prefixStr)
|
||||
r.prefixes = append(r.prefixes, "\t") // add tab for nested list items
|
||||
} else {
|
||||
r.prefixes = r.prefixes[:len(r.prefixes)-1]
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderParagraph(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderTextBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering && n.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderRawHTML(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
n := n.(*ast.RawHTML)
|
||||
l := n.Segments.Len()
|
||||
for i := 0; i < l; i++ {
|
||||
segment := n.Segments.At(i)
|
||||
_, _ = w.Write(segment.Value(source))
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderThematicBreak(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
_, _ = w.WriteString("---")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.AutoLink)
|
||||
url := string(n.URL(source))
|
||||
label := string(n.Label(source))
|
||||
|
||||
if n.AutoLinkType == ast.AutoLinkEmail && !strings.HasPrefix(strings.ToLower(url), "mailto:") {
|
||||
url = "mailto:" + url
|
||||
}
|
||||
|
||||
if url == label {
|
||||
_, _ = fmt.Fprintf(w, "<%s>", url)
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(w, "<%s|%s>", url, label)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
_ = w.WriteByte('`')
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
segment := c.(*ast.Text).Segment
|
||||
value := segment.Value(source)
|
||||
if bytes.HasSuffix(value, []byte("\n")) { // replace newline with space
|
||||
_, _ = w.Write(value[:len(value)-1])
|
||||
_ = w.WriteByte(' ')
|
||||
} else {
|
||||
_, _ = w.Write(value)
|
||||
}
|
||||
}
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
_ = w.WriteByte('`')
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Emphasis)
|
||||
mark := "_"
|
||||
if n.Level == 2 {
|
||||
mark = "*"
|
||||
}
|
||||
_, _ = w.WriteString(mark)
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Link)
|
||||
if entering {
|
||||
_, _ = w.WriteString("<")
|
||||
_, _ = w.Write(util.URLEscape(n.Destination, true))
|
||||
_, _ = w.WriteString("|")
|
||||
} else {
|
||||
_, _ = w.WriteString(">")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.Image)
|
||||
_, _ = w.WriteString("<")
|
||||
_, _ = w.Write(util.URLEscape(n.Destination, true))
|
||||
_, _ = w.WriteString("|")
|
||||
|
||||
// Write the alt text directly
|
||||
var altBuf bytes.Buffer
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if textNode, ok := c.(*ast.Text); ok {
|
||||
altBuf.Write(textNode.Segment.Value(source))
|
||||
}
|
||||
}
|
||||
_, _ = w.Write(altBuf.Bytes())
|
||||
|
||||
_, _ = w.WriteString(">")
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.Text)
|
||||
segment := n.Segment
|
||||
value := segment.Value(source)
|
||||
_, _ = w.Write(value)
|
||||
if n.HardLineBreak() || n.SoftLineBreak() {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
_, _ = w.WriteString("~")
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
r.separateFromPrevious(w, node)
|
||||
|
||||
// Collect cells and max widths
|
||||
var rows [][]string
|
||||
var colWidths []int
|
||||
|
||||
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if c.Kind() == extensionast.KindTableHeader || c.Kind() == extensionast.KindTableRow {
|
||||
var row []string
|
||||
colIdx := 0
|
||||
for cc := c.FirstChild(); cc != nil; cc = cc.NextSibling() {
|
||||
if cc.Kind() == extensionast.KindTableCell {
|
||||
cellText := extractPlainText(cc, source)
|
||||
row = append(row, cellText)
|
||||
runeLen := utf8.RuneCountInString(cellText)
|
||||
if colIdx >= len(colWidths) {
|
||||
colWidths = append(colWidths, runeLen)
|
||||
} else if runeLen > colWidths[colIdx] {
|
||||
colWidths[colIdx] = runeLen
|
||||
}
|
||||
colIdx++
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
|
||||
// writing table in code block
|
||||
_, _ = w.WriteString("```\n")
|
||||
for i, row := range rows {
|
||||
for colIdx, cellText := range row {
|
||||
width := 0
|
||||
if colIdx < len(colWidths) {
|
||||
width = colWidths[colIdx]
|
||||
}
|
||||
runeLen := utf8.RuneCountInString(cellText)
|
||||
padding := max(0, width-runeLen)
|
||||
|
||||
_, _ = w.WriteString(cellText)
|
||||
_, _ = w.WriteString(strings.Repeat(" ", padding))
|
||||
if colIdx < len(row)-1 {
|
||||
_, _ = w.WriteString(" | ")
|
||||
}
|
||||
}
|
||||
_ = w.WriteByte('\n')
|
||||
|
||||
// Print separator after header
|
||||
if i == 0 {
|
||||
for colIdx := range row {
|
||||
width := 0
|
||||
if colIdx < len(colWidths) {
|
||||
width = colWidths[colIdx]
|
||||
}
|
||||
_, _ = w.WriteString(strings.Repeat("-", width))
|
||||
if colIdx < len(row)-1 {
|
||||
_, _ = w.WriteString("-|-")
|
||||
}
|
||||
}
|
||||
_ = w.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
_, _ = w.WriteString("```")
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// extractPlainText extracts all the text content from the given node.
|
||||
func extractPlainText(n ast.Node, source []byte) string {
|
||||
var buf bytes.Buffer
|
||||
_ = ast.Walk(n, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
if textNode, ok := node.(*ast.Text); ok {
|
||||
buf.Write(textNode.Segment.Value(source))
|
||||
} else if strNode, ok := node.(*ast.String); ok {
|
||||
buf.Write(strNode.Value)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
return strings.TrimSpace(buf.String())
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package mrkdwn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
func TestRenderer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Heading with Thematic Break",
|
||||
markdown: "# Title 1\n# Hello World\n---\nthis is sometext",
|
||||
expected: "*Title 1*\n\n*Hello World*\n\n---\n\nthis is sometext\n\n",
|
||||
},
|
||||
{
|
||||
name: "Blockquote",
|
||||
markdown: "> This is a quote\n> It continues",
|
||||
expected: "> This is a quote\n> It continues\n\n",
|
||||
},
|
||||
{
|
||||
name: "Fenced Code Block",
|
||||
markdown: "```go\npackage main\nfunc main() {}\n```",
|
||||
expected: "```\npackage main\nfunc main() {}\n```\n\n",
|
||||
},
|
||||
{
|
||||
name: "Unordered List",
|
||||
markdown: "- item 1\n- item 2\n- item 3",
|
||||
expected: "• item 1\n• item 2\n• item 3\n\n",
|
||||
},
|
||||
{
|
||||
name: "nested unordered list",
|
||||
markdown: "- item 1\n- item 2\n\t- item 2.1\n\t\t- item 2.1.1\n\t\t- item 2.1.2\n\t- item 2.2\n- item 3",
|
||||
expected: "• item 1\n• item 2\n\t• item 2.1\n\t\t• item 2.1.1\n\t\t• item 2.1.2\n\t• item 2.2\n• item 3\n\n",
|
||||
},
|
||||
{
|
||||
name: "Ordered List",
|
||||
markdown: "1. item 1\n2. item 2\n3. item 3",
|
||||
expected: "1. item 1\n2. item 2\n3. item 3\n\n",
|
||||
},
|
||||
{
|
||||
name: "nested ordered list",
|
||||
markdown: "1. item 1\n2. item 2\n\t1. item 2.1\n\t\t1. item 2.1.1\n\t\t2. item 2.1.2\n\t2. item 2.2\n\t3. item 2.3\n3. item 3\n4. item 4",
|
||||
expected: "1. item 1\n2. item 2\n\t1. item 2.1\n\t\t1. item 2.1.1\n\t\t2. item 2.1.2\n\t2. item 2.2\n\t3. item 2.3\n3. item 3\n4. item 4\n\n",
|
||||
},
|
||||
{
|
||||
name: "Links and AutoLinks",
|
||||
markdown: "This is a [link](https://example.com) and an autolink <https://test.com>",
|
||||
expected: "This is a <https://example.com|link> and an autolink <https://test.com>\n\n",
|
||||
},
|
||||
{
|
||||
name: "Images",
|
||||
markdown: "An image ",
|
||||
expected: "An image <https://example.com/image.png|alt text>\n\n",
|
||||
},
|
||||
{
|
||||
name: "Emphasis",
|
||||
markdown: "This is **bold** and *italic* and __bold__ and _italic_",
|
||||
expected: "This is *bold* and _italic_ and *bold* and _italic_\n\n",
|
||||
},
|
||||
{
|
||||
name: "Strikethrough",
|
||||
markdown: "This is ~~strike~~",
|
||||
expected: "This is ~strike~\n\n",
|
||||
},
|
||||
{
|
||||
name: "Code Span",
|
||||
markdown: "This is `inline code` embedded.",
|
||||
expected: "This is `inline code` embedded.\n\n",
|
||||
},
|
||||
{
|
||||
name: "Table",
|
||||
markdown: "Col 1 | Col 2 | Col 3\n--- | --- | ---\nVal 1 | Long Value 2 | 3\nShort | V | 1000",
|
||||
expected: "```\nCol 1 | Col 2 | Col 3\n------|--------------|------\nVal 1 | Long Value 2 | 3 \nShort | V | 1000 \n```\n\n",
|
||||
},
|
||||
{
|
||||
name: "Mixed Nested Lists",
|
||||
markdown: "1. first\n\t- nested bullet\n\t- another bullet\n2. second",
|
||||
expected: "1. first\n\t• nested bullet\n\t• another bullet\n2. second\n\n",
|
||||
},
|
||||
{
|
||||
name: "Email AutoLink",
|
||||
markdown: "<user@example.com>",
|
||||
expected: "<mailto:user@example.com|user@example.com>\n\n",
|
||||
},
|
||||
{
|
||||
name: "No value string parsed as is",
|
||||
markdown: "Service: <no value>",
|
||||
expected: "Service: <no value>\n\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
md := goldmark.New(goldmark.WithExtensions(Extender))
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
|
||||
t.Fatalf("failed to convert: %v", err)
|
||||
}
|
||||
|
||||
// Do exact string matching
|
||||
actual := buf.String()
|
||||
if actual != tt.expected {
|
||||
t.Errorf("\nExpected:\n%q\nGot:\n%q\nRaw Expected:\n%s\nRaw Got:\n%s",
|
||||
tt.expected, actual, tt.expected, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"github.com/yuin/goldmark"
|
||||
gast "github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// Go text/template writes the literal string "<no value>" when a referenced
|
||||
// key is missing. The default goldmark HTML path treats it as raw HTML and
|
||||
// drops it, hiding the missing value. This renderer escapes it so operators
|
||||
// can see which variable was unset in the rendered notification.
|
||||
type noValueHTMLRenderer struct {
|
||||
html.Config
|
||||
}
|
||||
|
||||
func newNoValueHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
|
||||
r := &noValueHTMLRenderer{Config: html.NewConfig()}
|
||||
for _, opt := range opts {
|
||||
opt.SetHTMLOption(&r.Config)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *noValueHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
reg.Register(gast.KindRawHTML, r.renderRawHTML)
|
||||
}
|
||||
|
||||
func (r *noValueHTMLRenderer) renderRawHTML(w util.BufWriter, source []byte, node gast.Node, entering bool) (gast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return gast.WalkSkipChildren, nil
|
||||
}
|
||||
n := node.(*gast.RawHTML)
|
||||
if r.Unsafe {
|
||||
for i := 0; i < n.Segments.Len(); i++ {
|
||||
segment := n.Segments.At(i)
|
||||
_, _ = w.Write(segment.Value(source))
|
||||
}
|
||||
return gast.WalkSkipChildren, nil
|
||||
}
|
||||
if string(n.Segments.Value(source)) == "<no value>" {
|
||||
_, _ = w.WriteString("<no value>")
|
||||
return gast.WalkSkipChildren, nil
|
||||
}
|
||||
_, _ = w.WriteString("<!-- raw HTML omitted -->")
|
||||
return gast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
type escapeNoValueExtension struct{}
|
||||
|
||||
// escapeNoValue renders the literal `<no value>` produced by Go's text/template
|
||||
// as visible escaped text instead of dropping it as raw HTML.
|
||||
var escapeNoValue = &escapeNoValueExtension{}
|
||||
|
||||
func (e *escapeNoValueExtension) Extend(m goldmark.Markdown) {
|
||||
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
||||
util.Prioritized(newNoValueHTMLRenderer(), 500),
|
||||
))
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
)
|
||||
|
||||
func TestEscapeNoValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "plain text",
|
||||
markdown: "Service: <no value>",
|
||||
expected: "<p>Service: <no value></p>\n",
|
||||
},
|
||||
{
|
||||
name: "inside strong",
|
||||
markdown: "Service: **<no value>**",
|
||||
expected: "<p>Service: <strong><no value></strong></p>\n",
|
||||
},
|
||||
{
|
||||
name: "inside emphasis",
|
||||
markdown: "Service: *<no value>*",
|
||||
expected: "<p>Service: <em><no value></em></p>\n",
|
||||
},
|
||||
{
|
||||
name: "inside strikethrough",
|
||||
markdown: "Service: ~~<no value>~~",
|
||||
expected: "<p>Service: <del><no value></del></p>\n",
|
||||
},
|
||||
{
|
||||
name: "real html still omitted",
|
||||
markdown: "hello <div>world</div>",
|
||||
expected: "<p>hello <!-- raw HTML omitted -->world<!-- raw HTML omitted --></p>\n",
|
||||
},
|
||||
{
|
||||
name: "inside heading",
|
||||
markdown: "# Title <no value>",
|
||||
expected: "<h1>Title <no value></h1>\n",
|
||||
},
|
||||
{
|
||||
name: "inside list item",
|
||||
markdown: "- item <no value>",
|
||||
expected: "<ul>\n<li>item <no value></li>\n</ul>\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gm := goldmark.New(goldmark.WithExtensions(escapeNoValue, extension.Strikethrough))
|
||||
var buf bytes.Buffer
|
||||
if err := gm.Convert([]byte(tt.markdown), &buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if buf.String() != tt.expected {
|
||||
t.Errorf("expected:\n%s\ngot:\n%s", tt.expected, buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func jsonEqual(a, b string) bool {
|
||||
var va, vb any
|
||||
if err := json.Unmarshal([]byte(a), &va); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal([]byte(b), &vb); err != nil {
|
||||
return false
|
||||
}
|
||||
ja, _ := json.Marshal(va)
|
||||
jb, _ := json.Marshal(vb)
|
||||
return string(ja) == string(jb)
|
||||
}
|
||||
|
||||
func prettyJSON(s string) string {
|
||||
var v any
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
return s
|
||||
}
|
||||
b, _ := json.MarshalIndent(v, "", " ")
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestRenderSlackBlockKit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple paragraph",
|
||||
markdown: "Hello world",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "Hello world" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "alert-themed with heading, list, and code block",
|
||||
markdown: `# Alert Triggered
|
||||
|
||||
- Service: **checkout-api**
|
||||
- Status: _critical_
|
||||
|
||||
` + "```" + `
|
||||
error: connection timeout after 30s
|
||||
` + "```",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "*Alert Triggered*" }
|
||||
},
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [
|
||||
{ "type": "text", "text": "Service: " },
|
||||
{ "type": "text", "text": "checkout-api", "style": { "bold": true } }
|
||||
]},
|
||||
{ "type": "rich_text_section", "elements": [
|
||||
{ "type": "text", "text": "Status: " },
|
||||
{ "type": "text", "text": "critical", "style": { "italic": true } }
|
||||
]}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_preformatted",
|
||||
"border": 0,
|
||||
"elements": [
|
||||
{ "type": "text", "text": "error: connection timeout after 30s" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := RenderSlackBlockKit(tt.markdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Render error: %v", err)
|
||||
}
|
||||
|
||||
if !json.Valid([]byte(got)) {
|
||||
t.Fatalf("output is not valid JSON:\n%s", got)
|
||||
}
|
||||
|
||||
if !jsonEqual(got, tt.expected) {
|
||||
t.Errorf("JSON mismatch\n\nMarkdown:\n%s\n\nExpected:\n%s\n\nGot:\n%s",
|
||||
tt.markdown, prettyJSON(tt.expected), prettyJSON(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSlackMrkdwn(t *testing.T) {
|
||||
markdown := `# Alert Triggered
|
||||
|
||||
- Service: **checkout-api**
|
||||
- Status: _critical_
|
||||
- Dashboard: [View Dashboard](https://example.com/dashboard)
|
||||
|
||||
| Metric | Value | Threshold |
|
||||
| --- | --- | --- |
|
||||
| Latency | 250ms | 100ms |
|
||||
| Error Rate | 5.2% | 1% |
|
||||
|
||||
` + "```" + `
|
||||
error: connection timeout after 30s
|
||||
` + "```"
|
||||
|
||||
expected := "*Alert Triggered*\n\n" +
|
||||
"• Service: *checkout-api*\n" +
|
||||
"• Status: _critical_\n" +
|
||||
"• Dashboard: <https://example.com/dashboard|View Dashboard>\n\n" +
|
||||
"```\nMetric | Value | Threshold\n-----------|-------|----------\nLatency | 250ms | 100ms \nError Rate | 5.2% | 1% \n```\n\n" +
|
||||
"```\nerror: connection timeout after 30s\n```\n\n"
|
||||
|
||||
got, err := RenderSlackMrkdwn(markdown)
|
||||
if err != nil {
|
||||
t.Fatalf("Render error: %v", err)
|
||||
}
|
||||
|
||||
if got != expected {
|
||||
t.Errorf("mrkdwn mismatch\n\nExpected:\n%q\n\nGot:\n%q", expected, got)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,11 @@ var (
|
||||
errInvalidServiceAccountName = errors.New(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "name must start with a lowercase letter (a-z), contain only lowercase letters, numbers (0-9), and hyphens (-), and be at most 50 characters long")
|
||||
)
|
||||
|
||||
var (
|
||||
TypeableMetaResourceServiceAccount = authtypes.MustNewTypeableMetaResource(authtypes.MustNewName("service-account"))
|
||||
TypeableMetaResourcesServiceAccounts = authtypes.MustNewTypeableMetaResources(authtypes.MustNewName("service-accounts"))
|
||||
)
|
||||
|
||||
var (
|
||||
ServiceAccountStatusActive = ServiceAccountStatus{valuer.NewString("active")}
|
||||
ServiceAccountStatusDeleted = ServiceAccountStatus{valuer.NewString("deleted")}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
// Package templatingtypes holds the data types produced by the markdown
|
||||
// renderers under pkg/templating (e.g. Slack Block Kit JSON DTOs).
|
||||
package templatingtypes
|
||||
|
||||
// SectionBlock represents a Slack section block with mrkdwn text.
|
||||
type SectionBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text *TextObject `json:"text"`
|
||||
}
|
||||
|
||||
// DividerBlock represents a Slack divider block.
|
||||
type DividerBlock struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// RichTextBlock is a container for rich text elements (lists, code blocks,
|
||||
// table and cell blocks).
|
||||
type RichTextBlock struct {
|
||||
Type string `json:"type"`
|
||||
Elements []any `json:"elements"`
|
||||
}
|
||||
|
||||
// TableBlock represents a Slack table rendered as a rich_text block with
|
||||
// preformatted text.
|
||||
type TableBlock struct {
|
||||
Type string `json:"type"`
|
||||
Rows [][]TableCell `json:"rows"`
|
||||
}
|
||||
|
||||
// TableCell is a cell in a TableBlock.
|
||||
type TableCell struct {
|
||||
Type string `json:"type"`
|
||||
Elements []any `json:"elements"`
|
||||
}
|
||||
|
||||
// TextObject is the text field inside a SectionBlock.
|
||||
type TextObject struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// RichTextList represents an ordered or unordered list.
|
||||
type RichTextList struct {
|
||||
Type string `json:"type"`
|
||||
Style string `json:"style"`
|
||||
Indent int `json:"indent"`
|
||||
Border int `json:"border"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
Elements []any `json:"elements"`
|
||||
}
|
||||
|
||||
// RichTextPreformatted represents a code block.
|
||||
type RichTextPreformatted struct {
|
||||
Type string `json:"type"`
|
||||
Elements []any `json:"elements"`
|
||||
Border int `json:"border"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// RichTextInline represents inline text with optional styling, e.g. text
|
||||
// inside a list or a table cell.
|
||||
type RichTextInline struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Style *RichTextStyle `json:"style,omitempty"`
|
||||
}
|
||||
|
||||
// RichTextLink represents a link inside rich text, e.g. a link inside a list
|
||||
// or a table cell.
|
||||
type RichTextLink struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Style *RichTextStyle `json:"style,omitempty"`
|
||||
}
|
||||
|
||||
// RichTextStyle toggles inline styling on a rich-text element.
|
||||
type RichTextStyle struct {
|
||||
Bold bool `json:"bold,omitempty"`
|
||||
Italic bool `json:"italic,omitempty"`
|
||||
Strike bool `json:"strike,omitempty"`
|
||||
Code bool `json:"code,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user