mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-12 05:01:04 +01:00
Compare commits
6 Commits
feat/teleg
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d2e6d12e4 | ||
|
|
6b13794728 | ||
|
|
ba3ca5645e | ||
|
|
3d22976c72 | ||
|
|
059801d80d | ||
|
|
c44926b5f8 |
@@ -37,7 +37,6 @@ components:
|
||||
opsgenie: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig'
|
||||
pagerduty: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig'
|
||||
slack: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig'
|
||||
telegram: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfig'
|
||||
webhook: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
@@ -51,7 +50,6 @@ components:
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfig'
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig:
|
||||
properties:
|
||||
@@ -161,18 +159,6 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- telegram
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelTelegramConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig:
|
||||
properties:
|
||||
kind:
|
||||
@@ -325,7 +311,6 @@ components:
|
||||
- jira
|
||||
- jsmops
|
||||
- incidentio
|
||||
- telegram
|
||||
type: string
|
||||
AlertmanagertypesChannelListOrder:
|
||||
enum:
|
||||
@@ -530,29 +515,6 @@ components:
|
||||
- title
|
||||
- value
|
||||
type: object
|
||||
AlertmanagertypesChannelTelegramConfig:
|
||||
properties:
|
||||
apiUrl:
|
||||
type: string
|
||||
botToken:
|
||||
format: password
|
||||
type: string
|
||||
chatId:
|
||||
format: int64
|
||||
type: integer
|
||||
disableNotifications:
|
||||
type: boolean
|
||||
message:
|
||||
type: string
|
||||
messageThreadId:
|
||||
type: integer
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
required:
|
||||
- botToken
|
||||
- chatId
|
||||
type: object
|
||||
AlertmanagertypesChannelWebhookConfig:
|
||||
properties:
|
||||
bearerToken:
|
||||
@@ -7794,6 +7756,8 @@ components:
|
||||
type: object
|
||||
PromotetypesPromotePath:
|
||||
properties:
|
||||
context:
|
||||
type: string
|
||||
indexes:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesWrappedIndex'
|
||||
@@ -7802,6 +7766,12 @@ components:
|
||||
type: string
|
||||
promote:
|
||||
type: boolean
|
||||
signal:
|
||||
type: string
|
||||
required:
|
||||
- signal
|
||||
- context
|
||||
- path
|
||||
type: object
|
||||
PromotetypesWrappedIndex:
|
||||
properties:
|
||||
@@ -13470,110 +13440,6 @@ paths:
|
||||
tags:
|
||||
- llmpricingrules
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/logs/promote_paths:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoints promotes and indexes paths
|
||||
operationId: ListPromotedAndIndexedPaths
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Promote and index paths
|
||||
tags:
|
||||
- logs
|
||||
x-signoz-stability: alpha
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoints promotes and indexes paths
|
||||
operationId: HandlePromoteAndIndexPaths
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Promote and index paths
|
||||
tags:
|
||||
- logs
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/org/preferences:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -13745,6 +13611,152 @@ paths:
|
||||
tags:
|
||||
- preferences
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/promoted_paths:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists the promoted paths of every JSON column, each
|
||||
annotated with its signal and context. The signal, context, promoted and indexes
|
||||
query parameters filter the listing.
|
||||
operationId: ListPromotedPaths
|
||||
parameters:
|
||||
- in: query
|
||||
name: signal
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: context
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: promoted
|
||||
schema:
|
||||
nullable: true
|
||||
type: boolean
|
||||
- in: query
|
||||
name: indexes
|
||||
schema:
|
||||
nullable: true
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List promoted paths
|
||||
tags:
|
||||
- promote
|
||||
x-signoz-stability: alpha
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint promotes paths of JSON columns to their promoted
|
||||
columns. Each path names its promotion target with its signal and context,
|
||||
e.g. traces/attribute.
|
||||
operationId: PromotePaths
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
logs_body:
|
||||
summary: 'Logs body: promote a path and index it'
|
||||
value:
|
||||
- context: body
|
||||
indexes:
|
||||
- fieldDataType: string
|
||||
granularity: 1
|
||||
type: ngrambf_v1(4, 1024, 2, 0)
|
||||
path: user.name
|
||||
promote: true
|
||||
signal: logs
|
||||
traces_attribute:
|
||||
summary: 'Traces attribute: promote a path'
|
||||
value:
|
||||
- context: attribute
|
||||
path: http.method
|
||||
promote: true
|
||||
signal: traces
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Promote paths
|
||||
tags:
|
||||
- promote
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/roles:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -277,7 +277,10 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
|
||||
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
|
||||
|
||||
annotations := r.ExpandAnnotations(expand)
|
||||
annotations := make(ruletypes.Labels, 0, len(r.Annotations().Map()))
|
||||
for name, value := range r.Annotations().Map() {
|
||||
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
|
||||
}
|
||||
if smpl.IsMissing {
|
||||
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
|
||||
lb.Set(ruletypes.NoDataLabel, "true")
|
||||
|
||||
@@ -43,6 +43,11 @@ const mockAliases = [
|
||||
find: /^@signozhq\/ui\/tooltip$/,
|
||||
replacement: `${srcPath}/storybook/mocks/tooltip.mock.tsx`,
|
||||
},
|
||||
{
|
||||
// jest: not replaced, a test asserts on the toast it fires.
|
||||
find: /^@signozhq\/ui\/sonner$/,
|
||||
replacement: `${srcPath}/storybook/mocks/sonner.mock.tsx`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,3 +70,49 @@
|
||||
Object.defineProperty(window, 'Date', { value: FrozenDate, writable: true });
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// Storybook has no microphone. Chromium's recognizer asks for one and for
|
||||
// Google's speech service, and the `not-allowed` or `network` it reports makes
|
||||
// the assistant hide its mic for the rest of the tab. This one starts
|
||||
// listening at once and hears only what a story passes to `hear`.
|
||||
(() => {
|
||||
class StorySpeechRecognition extends EventTarget {
|
||||
static listening = null;
|
||||
lang = 'en-US';
|
||||
continuous = false;
|
||||
interimResults = false;
|
||||
onstart = null;
|
||||
onresult = null;
|
||||
onerror = null;
|
||||
onend = null;
|
||||
|
||||
start() {
|
||||
StorySpeechRecognition.listening = this;
|
||||
setTimeout(() => this.onstart?.());
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (StorySpeechRecognition.listening === this) {
|
||||
StorySpeechRecognition.listening = null;
|
||||
}
|
||||
setTimeout(() => this.onend?.());
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
/** An interim result: the words so far, still listening. */
|
||||
hear(transcript) {
|
||||
const result = Object.assign([{ transcript, confidence: 1 }], {
|
||||
isFinal: false,
|
||||
});
|
||||
this.onresult?.({ resultIndex: 0, results: [result] });
|
||||
}
|
||||
}
|
||||
|
||||
window.SpeechRecognition = StorySpeechRecognition;
|
||||
window.webkitSpeechRecognition = StorySpeechRecognition;
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -122,7 +122,21 @@ const preview: Preview = {
|
||||
['List', 'New', 'Edit'],
|
||||
],
|
||||
'Dashboards',
|
||||
['List', 'Detail', 'Panel Editor', 'Public'],
|
||||
[
|
||||
'List',
|
||||
'Detail',
|
||||
[
|
||||
'Overview',
|
||||
'Settings',
|
||||
'Variables',
|
||||
'New Variable',
|
||||
'Edit Variable',
|
||||
'Authz',
|
||||
],
|
||||
'Panel Editor',
|
||||
['Overview', 'Chart Options', 'Thresholds and Links'],
|
||||
'Public',
|
||||
],
|
||||
'Services',
|
||||
['List', 'Detail', 'Top Level Operations', 'Service Map'],
|
||||
'Logs',
|
||||
|
||||
@@ -23,6 +23,13 @@ const IGNORED_MESSAGES = [
|
||||
// (YouTube embeds, the docs pane) so they hit the real network instead of
|
||||
// an unanswered msw request; the block is the point, not a bug.
|
||||
/violates the following Content Security Policy directive/,
|
||||
// The filter editor's ANTLR parser reports every syntax error through
|
||||
// `console.error` (`line 1:14 missing ...`), so each partial expression
|
||||
// typed into it logs one; the editor shows the same errors on screen.
|
||||
/^line \d+:\d+ /,
|
||||
// Chromium's echo of a 4xx/5xx response, which the `[response]` entry below
|
||||
// already reports with its URL and the time its request left.
|
||||
/^Failed to load resource: the server responded with a status of /,
|
||||
];
|
||||
|
||||
interface CapturedMessage {
|
||||
@@ -86,7 +93,9 @@ const config: TestRunnerConfig = {
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) {
|
||||
messages.push({
|
||||
at: Date.now(),
|
||||
// The browser's clock, when the request left: a busy runner receives
|
||||
// the outgoing story's responses after the next story has started.
|
||||
at: response.request().timing().startTime,
|
||||
text: `[response] ${response.status()} ${response.url()}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,23 +4,15 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
HandleExportRawDataPOSTParams,
|
||||
ListPromotedAndIndexedPaths200,
|
||||
PromotetypesPromotePathDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -28,26 +20,6 @@ import type {
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
const withQueryKey = <T extends object, K>(
|
||||
query: T,
|
||||
queryKey: K,
|
||||
): T & { queryKey: K } => {
|
||||
const result = { queryKey } as T & { queryKey: K };
|
||||
for (const key of Object.keys(query)) {
|
||||
// The explicit queryKey always wins, matching the previous
|
||||
// `{ ...query, queryKey }` spread where it was set last.
|
||||
if (key === 'queryKey') {
|
||||
continue;
|
||||
}
|
||||
Object.defineProperty(result, key, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => (query as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoints allows complex query exporting raw data for traces and logs
|
||||
* @summary Export raw data
|
||||
@@ -149,175 +121,3 @@ export const useHandleExportRawDataPOST = <
|
||||
> => {
|
||||
return useMutation(getHandleExportRawDataPOSTMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoints promotes and indexes paths
|
||||
* @summary Promote and index paths
|
||||
*/
|
||||
export const listPromotedAndIndexedPaths = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListPromotedAndIndexedPaths200>({
|
||||
url: `/api/v1/logs/promote_paths`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListPromotedAndIndexedPathsQueryKey = () => {
|
||||
return [`/api/v1/logs/promote_paths`] as const;
|
||||
};
|
||||
|
||||
export const getListPromotedAndIndexedPathsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListPromotedAndIndexedPathsQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>
|
||||
> = ({ signal }) => listPromotedAndIndexedPaths(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListPromotedAndIndexedPathsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>
|
||||
>;
|
||||
export type ListPromotedAndIndexedPathsQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Promote and index paths
|
||||
*/
|
||||
|
||||
export function useListPromotedAndIndexedPaths<
|
||||
TData = Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListPromotedAndIndexedPathsQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Promote and index paths
|
||||
*/
|
||||
export const invalidateListPromotedAndIndexedPaths = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListPromotedAndIndexedPathsQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoints promotes and indexes paths
|
||||
* @summary Promote and index paths
|
||||
*/
|
||||
export const handlePromoteAndIndexPaths = (
|
||||
promotetypesPromotePathDTONull?: BodyType<
|
||||
PromotetypesPromotePathDTO[] | null
|
||||
> | null,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/logs/promote_paths`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: promotetypesPromotePathDTONull,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getHandlePromoteAndIndexPathsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['handlePromoteAndIndexPaths'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return handlePromoteAndIndexPaths(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type HandlePromoteAndIndexPathsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>
|
||||
>;
|
||||
export type HandlePromoteAndIndexPathsMutationBody =
|
||||
| BodyType<PromotetypesPromotePathDTO[] | null>
|
||||
| undefined;
|
||||
export type HandlePromoteAndIndexPathsMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Promote and index paths
|
||||
*/
|
||||
export const useHandlePromoteAndIndexPaths = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getHandlePromoteAndIndexPathsMutationOptions(options));
|
||||
};
|
||||
|
||||
232
frontend/src/api/generated/services/promote/index.ts
Normal file
232
frontend/src/api/generated/services/promote/index.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
ListPromotedPaths200,
|
||||
ListPromotedPathsParams,
|
||||
PromotetypesPromotePathDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
const withQueryKey = <T extends object, K>(
|
||||
query: T,
|
||||
queryKey: K,
|
||||
): T & { queryKey: K } => {
|
||||
const result = { queryKey } as T & { queryKey: K };
|
||||
for (const key of Object.keys(query)) {
|
||||
// The explicit queryKey always wins, matching the previous
|
||||
// `{ ...query, queryKey }` spread where it was set last.
|
||||
if (key === 'queryKey') {
|
||||
continue;
|
||||
}
|
||||
Object.defineProperty(result, key, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => (query as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists the promoted paths of every JSON column, each annotated with its signal and context. The signal, context, promoted and indexes query parameters filter the listing.
|
||||
* @summary List promoted paths
|
||||
*/
|
||||
export const listPromotedPaths = (
|
||||
params?: ListPromotedPathsParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListPromotedPaths200>({
|
||||
url: `/api/v1/promoted_paths`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListPromotedPathsQueryKey = (
|
||||
params?: ListPromotedPathsParams,
|
||||
) => {
|
||||
return [`/api/v1/promoted_paths`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListPromotedPathsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listPromotedPaths>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListPromotedPathsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listPromotedPaths>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListPromotedPathsQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listPromotedPaths>>
|
||||
> = ({ signal }) => listPromotedPaths(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listPromotedPaths>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListPromotedPathsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listPromotedPaths>>
|
||||
>;
|
||||
export type ListPromotedPathsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List promoted paths
|
||||
*/
|
||||
|
||||
export function useListPromotedPaths<
|
||||
TData = Awaited<ReturnType<typeof listPromotedPaths>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListPromotedPathsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listPromotedPaths>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListPromotedPathsQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List promoted paths
|
||||
*/
|
||||
export const invalidateListPromotedPaths = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListPromotedPathsParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListPromotedPathsQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint promotes paths of JSON columns to their promoted columns. Each path names its promotion target with its signal and context, e.g. traces/attribute.
|
||||
* @summary Promote paths
|
||||
*/
|
||||
export const promotePaths = (
|
||||
promotetypesPromotePathDTONull?: BodyType<
|
||||
PromotetypesPromotePathDTO[] | null
|
||||
> | null,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/promoted_paths`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: promotetypesPromotePathDTONull,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPromotePathsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof promotePaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof promotePaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['promotePaths'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof promotePaths>>,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return promotePaths(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PromotePathsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof promotePaths>>
|
||||
>;
|
||||
export type PromotePathsMutationBody =
|
||||
| BodyType<PromotetypesPromotePathDTO[] | null>
|
||||
| undefined;
|
||||
export type PromotePathsMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Promote paths
|
||||
*/
|
||||
export const usePromotePaths = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof promotePaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof promotePaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPromotePathsMutationOptions(options));
|
||||
};
|
||||
@@ -9519,6 +9519,10 @@ export interface PromotetypesWrappedIndexDTO {
|
||||
}
|
||||
|
||||
export interface PromotetypesPromotePathDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
context: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -9526,11 +9530,15 @@ export interface PromotetypesPromotePathDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
path?: string;
|
||||
path: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
promote?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
signal: string;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5AggregationMetaDTO {
|
||||
@@ -12834,17 +12842,6 @@ export type ListUnmappedLLMModels200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListPromotedAndIndexedPaths200 = {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
data: PromotetypesPromotePathDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListOrgPreferences200 = {
|
||||
/**
|
||||
* @type array
|
||||
@@ -12870,6 +12867,40 @@ export type GetOrgPreference200 = {
|
||||
export type UpdateOrgPreferencePathParameters = {
|
||||
name: string;
|
||||
};
|
||||
export type ListPromotedPathsParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
signal?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
context?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
* @description undefined
|
||||
*/
|
||||
promoted?: boolean | null;
|
||||
/**
|
||||
* @type boolean,null
|
||||
* @description undefined
|
||||
*/
|
||||
indexes?: boolean | null;
|
||||
};
|
||||
|
||||
export type ListPromotedPaths200 = {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
data: PromotetypesPromotePathDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRoles200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
|
||||
import LabelColumn from '../LabelColumn';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Label Column',
|
||||
component: LabelColumn,
|
||||
tags: ['play'],
|
||||
decorators: [withCanvas({ maxWidth: 180 })],
|
||||
args: {
|
||||
labels: ['severity', 'team', 'service', 'environment'],
|
||||
value: {
|
||||
severity: 'critical',
|
||||
team: 'payments',
|
||||
service: 'checkout',
|
||||
environment: 'production',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof LabelColumn>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Labels that fit the column; the rest collapse into a `+N` chip. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Copying one label from its tooltip. */
|
||||
export const LabelCopiedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.hover(
|
||||
await within(canvasElement).findByTestId('label-tag-severity'),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Copy to clipboard' }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Copying the labels that overflow the column from the `+N` chip's tooltip. */
|
||||
export const OverflowLabelsCopiedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.hover(
|
||||
await within(canvasElement).findByTestId('label-overflow-badge'),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Copy to clipboard' }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
import { expect, screen, userEvent, waitFor } from 'storybook/test';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import FieldsSelector from '../FieldsSelector';
|
||||
@@ -56,6 +56,21 @@ export const UnsavedChanges: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Saving: the toast raised once the edited columns are applied. */
|
||||
export const SavedToast: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
const [addField] = await screen.findAllByRole('button', { name: 'Add' });
|
||||
|
||||
await userEvent.click(addField);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Save changes' }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Saved successfully')).toBeVisible(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Empty: the suggestion request succeeds with no columns to add. */
|
||||
export const NoResults: Story = {
|
||||
parameters: {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import HeaderRightSection from '../HeaderRightSection';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Header Right Section',
|
||||
component: HeaderRightSection,
|
||||
tags: ['play'],
|
||||
args: {
|
||||
enableFeedback: true,
|
||||
enableShare: true,
|
||||
enableAnnouncements: false,
|
||||
},
|
||||
} satisfies Meta<typeof HeaderRightSection>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** The top bar's actions: feedback and share. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A feedback note submitted from the popover, which toasts once it is sent. */
|
||||
export const FeedbackSubmittedToast: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Feedback' }),
|
||||
);
|
||||
await userEvent.type(
|
||||
await screen.findByPlaceholderText(/write your feedback here/i),
|
||||
'The new explorer is great',
|
||||
);
|
||||
await userEvent.click(await screen.findByRole('button', { name: /submit/i }));
|
||||
await screen.findByText(/feedback submitted successfully/i);
|
||||
},
|
||||
};
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
filterOptionsBySearch,
|
||||
findOptionLabelText,
|
||||
handleScrollToBottom,
|
||||
findOptionLabel,
|
||||
prioritizeOrAddOptionForMultiSelect,
|
||||
SPACEKEY,
|
||||
} from './utils';
|
||||
@@ -73,6 +74,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
onDropdownVisibleChange,
|
||||
showIncompleteDataMessage = false,
|
||||
showLabels = false,
|
||||
preserveOptionOrder = false,
|
||||
enableRegexOption = false,
|
||||
isDynamicVariable = false,
|
||||
showRetryButton = true,
|
||||
@@ -311,7 +313,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
]);
|
||||
} else {
|
||||
setVisibleOptions(
|
||||
selectedValues.length > 0 && isEmpty(searchText)
|
||||
selectedValues.length > 0 && isEmpty(searchText) && !preserveOptionOrder
|
||||
? prioritizeOrAddOptionForMultiSelect(filteredOptions, selectedValues)
|
||||
: filteredOptions,
|
||||
);
|
||||
@@ -1475,6 +1477,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
const shouldPrioritize =
|
||||
selectedValues.length > 0 &&
|
||||
isEmpty(searchText) &&
|
||||
!preserveOptionOrder &&
|
||||
!(hasSections && (allOptionShown || isAllSelected));
|
||||
|
||||
const processedOptions = shouldPrioritize
|
||||
@@ -1805,6 +1808,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
isDynamicVariable,
|
||||
showRetryButton,
|
||||
waitingMessage,
|
||||
preserveOptionOrder,
|
||||
]);
|
||||
|
||||
// Custom handler for dropdown visibility changes
|
||||
@@ -1892,7 +1896,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
const { label: labelProp, value, closable, onClose } = props;
|
||||
|
||||
const label = showLabels
|
||||
? options.find((option) => option.value === value)?.label || labelProp
|
||||
? findOptionLabel(options, value) || labelProp
|
||||
: labelProp;
|
||||
|
||||
// If the display value is the special ALL value, render the ALL tag
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { findOptionLabel } from '../utils';
|
||||
|
||||
describe('findOptionLabel', () => {
|
||||
const sectioned = [
|
||||
{
|
||||
label: 'Lifecycle',
|
||||
options: [
|
||||
{ label: 'Running', value: 'running' },
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Error status',
|
||||
options: [{ label: 'CrashLoopBackOff', value: 'crashloopbackoff' }],
|
||||
},
|
||||
];
|
||||
|
||||
it('resolves a label nested inside a section', () => {
|
||||
expect(findOptionLabel(sectioned, 'crashloopbackoff')).toBe(
|
||||
'CrashLoopBackOff',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves a label on a flat option list', () => {
|
||||
expect(findOptionLabel([{ label: 'Ready', value: 'ready' }], 'ready')).toBe(
|
||||
'Ready',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined for a value no option declares', () => {
|
||||
expect(findOptionLabel(sectioned, 'nope')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOptionLabel for overflow tags', () => {
|
||||
// The `+N` tooltip and the visible tag both resolve labels this way, so a
|
||||
// sectioned list must not fall back to the raw enum value in either.
|
||||
it('resolves every value in a sectioned list', () => {
|
||||
const sections = [
|
||||
{
|
||||
label: 'Lifecycle',
|
||||
options: [
|
||||
{ label: 'Running', value: 'running' },
|
||||
{ label: 'ContainerCreating', value: 'containercreating' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Error status',
|
||||
options: [
|
||||
{ label: 'OOMKilled', value: 'oomkilled' },
|
||||
{ label: 'NodeAffinity', value: 'nodeaffinity' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const resolved = [
|
||||
'running',
|
||||
'containercreating',
|
||||
'oomkilled',
|
||||
'nodeaffinity',
|
||||
].map((value) => findOptionLabel(sections, value) ?? value);
|
||||
|
||||
expect(resolved).toStrictEqual([
|
||||
'Running',
|
||||
'ContainerCreating',
|
||||
'OOMKilled',
|
||||
'NodeAffinity',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,7 @@ export interface CustomMultiSelectProps extends Omit<
|
||||
maxTagTextLength?: number;
|
||||
showIncompleteDataMessage?: boolean;
|
||||
showLabels?: boolean;
|
||||
preserveOptionOrder?: boolean;
|
||||
enableRegexOption?: boolean;
|
||||
isDynamicVariable?: boolean;
|
||||
showRetryButton?: boolean;
|
||||
|
||||
@@ -69,6 +69,28 @@ export const prioritizeOrAddOptionForSingleSelect = (
|
||||
return [foundOption, ...filteredOptions];
|
||||
};
|
||||
|
||||
/**
|
||||
* Options may be grouped into sections, so resolving a value's label has to look
|
||||
* one level down as well — a sectioned list has no label at the top level.
|
||||
*/
|
||||
export const findOptionLabel = (
|
||||
options: OptionData[],
|
||||
value: string,
|
||||
): string | undefined => {
|
||||
for (const option of options) {
|
||||
if (option.value === value) {
|
||||
return option.label;
|
||||
}
|
||||
|
||||
const nested = option.options?.find((subOption) => subOption.value === value);
|
||||
if (nested) {
|
||||
return nested.label;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const prioritizeOrAddOptionForMultiSelect = (
|
||||
options: OptionData[],
|
||||
values: string[], // Only supports multiple values (string[])
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.overflowValues {
|
||||
max-width: 360px;
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
list-style: disc outside;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import TooltipScrollArea, {
|
||||
TOOLTIP_SCROLL_CONTENT_CLASS,
|
||||
} from 'components/TooltipScrollArea/TooltipScrollArea';
|
||||
|
||||
import styles from '../../VariablesBar.module.scss';
|
||||
import styles from './OverflowValuesTooltip.module.scss';
|
||||
|
||||
interface OverflowValuesTooltipProps {
|
||||
/** The selected values the pill hides behind this `+N`. */
|
||||
@@ -0,0 +1,229 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
/** Suggestions wait on a 300ms debounce and a fetch, past the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/** Throws until `found` holds something, which is what `waitFor` retries on. */
|
||||
const present = <TValue>(
|
||||
found: TValue | null | undefined,
|
||||
what: string,
|
||||
): TValue => {
|
||||
if (found === null || found === undefined) {
|
||||
throw new Error(`${what} not found`);
|
||||
}
|
||||
|
||||
return found;
|
||||
};
|
||||
|
||||
const suggestionList = (canvasElement: HTMLElement): HTMLElement | null =>
|
||||
canvasElement.querySelector<HTMLElement>('.cm-tooltip-autocomplete');
|
||||
|
||||
const suggestionRow = (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): HTMLElement | undefined => {
|
||||
const list = suggestionList(canvasElement);
|
||||
|
||||
return list
|
||||
? within(list)
|
||||
.queryAllByRole('option')
|
||||
.find((option) => option.textContent?.includes(text))
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Ctrl+Space, the editor's own shortcut for asking for suggestions. */
|
||||
const requestSuggestions = (editor: HTMLElement): void => {
|
||||
editor.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: ' ',
|
||||
code: 'Space',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const currentView = (
|
||||
canvasElement: HTMLElement,
|
||||
): { editor: HTMLElement; view: EditorView } => {
|
||||
// An explorer renders one editor per query; the first is the one on screen.
|
||||
const editor = present(
|
||||
canvasElement.querySelector<HTMLElement>(
|
||||
'.code-mirror-where-clause .cm-content',
|
||||
),
|
||||
'filter editor',
|
||||
);
|
||||
|
||||
return {
|
||||
editor,
|
||||
view: present(EditorView.findFromDOM(editor), 'editor view'),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The explorers wrap the filter in `OverlayScrollbar`, which initialises when
|
||||
* the browser is idle. Initialising moves the content, the editor with it, and
|
||||
* focuses the editor again through the DOM, which puts the caret back at the
|
||||
* start and swaps the suggestions for the key list. Throws until every wrapper
|
||||
* around the filter has initialised.
|
||||
*/
|
||||
const assertScrollbarsReady = (editor: HTMLElement): void => {
|
||||
for (
|
||||
let wrapper = editor.closest('.overlay-scrollbar');
|
||||
wrapper;
|
||||
wrapper = wrapper.parentElement?.closest('.overlay-scrollbar') ?? null
|
||||
) {
|
||||
if (!wrapper.hasAttribute('data-overlayscrollbars')) {
|
||||
throw new Error('scrollbars around the filter still initialising');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Waits until `text` shows in the suggestion list, asking for suggestions
|
||||
* whenever the list is shut. Focus and typing only open it once the keys have
|
||||
* loaded, and moving the caret never does.
|
||||
*/
|
||||
const waitForSuggestion = (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<HTMLElement> =>
|
||||
waitFor(
|
||||
() => {
|
||||
const { editor } = currentView(canvasElement);
|
||||
|
||||
if (!suggestionList(canvasElement)) {
|
||||
requestSuggestions(editor);
|
||||
}
|
||||
|
||||
return present(suggestionRow(canvasElement, text), `suggestion "${text}"`);
|
||||
},
|
||||
{ ...untilLoaded, interval: 250 },
|
||||
);
|
||||
|
||||
/**
|
||||
* Focuses the filter once the scrollbars around it have initialised, and waits
|
||||
* for its suggestion list.
|
||||
*/
|
||||
const focusFilter = async (canvasElement: HTMLElement): Promise<EditorView> => {
|
||||
await waitFor(
|
||||
() => {
|
||||
const { editor, view } = currentView(canvasElement);
|
||||
|
||||
assertScrollbarsReady(editor);
|
||||
|
||||
if (!view.hasFocus) {
|
||||
view.focus();
|
||||
}
|
||||
|
||||
if (!suggestionList(canvasElement)) {
|
||||
requestSuggestions(editor);
|
||||
}
|
||||
|
||||
return present(suggestionList(canvasElement), 'suggestion list');
|
||||
},
|
||||
{ ...untilLoaded, interval: 250 },
|
||||
);
|
||||
|
||||
return currentView(canvasElement).view;
|
||||
};
|
||||
|
||||
/** Waits for a row of the suggestion list. */
|
||||
export const findSuggestion = (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<HTMLElement> => waitForSuggestion(canvasElement, text);
|
||||
|
||||
/** Focuses the empty filter: every key, with any recent filters above them. */
|
||||
export const openKeySuggestions = async (
|
||||
canvasElement: HTMLElement,
|
||||
row: string,
|
||||
): Promise<void> => {
|
||||
await focusFilter(canvasElement);
|
||||
await waitForSuggestion(canvasElement, row);
|
||||
};
|
||||
|
||||
/**
|
||||
* Focuses the filter and types onto the end of it one character at a time,
|
||||
* each as the transaction a keystroke makes, leaving the caret at the end so
|
||||
* the suggestion list follows what was typed. Quotes and brackets are not
|
||||
* closed for it: type both.
|
||||
*
|
||||
* `userEvent.type` cannot be used: CodeMirror redraws the line as tokens are
|
||||
* highlighted, which strands the caret `userEvent` tracks.
|
||||
*/
|
||||
export const typeFilter = async (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
const view = await focusFilter(canvasElement);
|
||||
|
||||
for (const character of text) {
|
||||
const at = view.state.doc.length;
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: at, insert: character },
|
||||
selection: { anchor: at + character.length },
|
||||
userEvent: 'input.type',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Types an expression, then steps the caret back inside it, before a closing
|
||||
* bracket or parenthesis, where the suggestions are about what goes in there.
|
||||
*/
|
||||
export const typeFilterWithCaretBack = async (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
stepsBack: number,
|
||||
row: string,
|
||||
): Promise<void> => {
|
||||
await typeFilter(canvasElement, text);
|
||||
|
||||
const { view } = currentView(canvasElement);
|
||||
|
||||
view.dispatch({
|
||||
selection: { anchor: view.state.doc.length - stepsBack },
|
||||
userEvent: 'select',
|
||||
});
|
||||
await waitForSuggestion(canvasElement, row);
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves focus off the filter, which is when the expression is validated and
|
||||
* the error marker can show.
|
||||
*/
|
||||
export const blurFilter = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await userEvent.click(canvasElement.ownerDocument.body);
|
||||
};
|
||||
|
||||
/** Types an expression, leaves the filter and opens its validation errors. */
|
||||
export const showFilterErrors = async (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
await typeFilter(canvasElement, text);
|
||||
await blurFilter(canvasElement);
|
||||
|
||||
const marker = await waitFor(
|
||||
() =>
|
||||
present(
|
||||
canvasElement.querySelector<HTMLElement>('.query-status-container button'),
|
||||
'error marker',
|
||||
),
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.hover(marker);
|
||||
await waitFor(
|
||||
() =>
|
||||
present(
|
||||
canvasElement.ownerDocument.querySelector('.query-validation-error'),
|
||||
'validation error',
|
||||
),
|
||||
untilLoaded,
|
||||
);
|
||||
};
|
||||
@@ -117,3 +117,38 @@ export const Tooltips: TooltipsStory = {
|
||||
msw: { handlers: [attributeValuesHandler(LONG_FILTER_VALUES), ...handlers] },
|
||||
},
|
||||
};
|
||||
|
||||
const [firstQuery] = queryBuilder.currentQuery.builder.queryData;
|
||||
|
||||
/** Interaction: the query picker lists every query the filters can follow. */
|
||||
export const QueryPickerOpen: Story = {
|
||||
parameters: {
|
||||
signoz: {
|
||||
queryBuilder: {
|
||||
...queryBuilder,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{ ...firstQuery, queryName: 'A' },
|
||||
{ ...firstQuery, queryName: 'B' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const trigger = await waitFor(() => {
|
||||
const element = canvasElement.querySelector<HTMLElement>(
|
||||
'[data-slot="combobox-trigger"]',
|
||||
);
|
||||
|
||||
expect(element).toBeInTheDocument();
|
||||
|
||||
return element as HTMLElement;
|
||||
});
|
||||
|
||||
await userEvent.click(trigger);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -233,12 +233,23 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
[effectiveColumns],
|
||||
);
|
||||
|
||||
// A rebuilt column def is a new cell component to React, which remounts every
|
||||
// cell it renders. Read the row callbacks through refs so inline ones do not.
|
||||
const isRowActiveRef = useRef(isRowActive);
|
||||
isRowActiveRef.current = isRowActive;
|
||||
const getRowKeyDataRef = useRef(getRowKeyData);
|
||||
getRowKeyDataRef.current = getRowKeyData;
|
||||
|
||||
const tanstackColumns = useMemo<ColumnDef<TData>[]>(
|
||||
() =>
|
||||
effectiveColumns.map((colDef) =>
|
||||
buildTanstackColumnDef<TData, TItemKey>(colDef, isRowActive, getRowKeyData),
|
||||
buildTanstackColumnDef<TData, TItemKey>(
|
||||
colDef,
|
||||
(row) => isRowActiveRef.current?.(row) ?? false,
|
||||
(index) => getRowKeyDataRef.current(index),
|
||||
),
|
||||
),
|
||||
[effectiveColumns, isRowActive, getRowKeyData],
|
||||
[effectiveColumns],
|
||||
);
|
||||
|
||||
const getRowId = useCallback(
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { UrlUpdateEvent } from 'nuqs/adapters/testing';
|
||||
|
||||
import { renderTanStackTable } from './testUtils';
|
||||
import TanStackTable from '../index';
|
||||
import type { TableColumnDef } from '../types';
|
||||
import {
|
||||
defaultData,
|
||||
renderTanStackTable,
|
||||
renderWithProviders,
|
||||
type TestRow,
|
||||
} from './testUtils';
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => false,
|
||||
@@ -810,6 +818,58 @@ describe('TanStackTableView Integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('cell identity', () => {
|
||||
it('keeps cells mounted across re-renders that pass new inline row callbacks', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMount = jest.fn();
|
||||
|
||||
function MountTracker({ id }: { id: string }): JSX.Element {
|
||||
useEffect(() => onMount(id), [id]);
|
||||
return <span data-testid={`tracked-${id}`}>Row {id}</span>;
|
||||
}
|
||||
|
||||
const columns: TableColumnDef<TestRow>[] = [
|
||||
{
|
||||
id: 'id',
|
||||
header: 'ID',
|
||||
accessorKey: 'id',
|
||||
cell: ({ row }): JSX.Element => <MountTracker id={row.id} />,
|
||||
},
|
||||
];
|
||||
|
||||
function Harness(): JSX.Element {
|
||||
const [, setRenders] = useState(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="rerender"
|
||||
onClick={(): void => setRenders((count) => count + 1)}
|
||||
>
|
||||
Re-render
|
||||
</button>
|
||||
<TanStackTable<TestRow>
|
||||
data={defaultData}
|
||||
columns={columns}
|
||||
getRowKey={(row): string => row.id}
|
||||
isRowActive={(row): boolean => row.id === '1'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
await screen.findByTestId('tracked-2');
|
||||
onMount.mockClear();
|
||||
|
||||
await user.click(screen.getByTestId('rerender'));
|
||||
await user.hover(screen.getByTestId('tracked-2'));
|
||||
|
||||
expect(onMount).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableVirtualScroll', () => {
|
||||
it('throws error when used with onEndReached', () => {
|
||||
expect(() => {
|
||||
|
||||
@@ -143,6 +143,19 @@ export const Pagination: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Interaction: the page size menu lists the row counts a page can show. */
|
||||
export const PageSizeOpen: Story = {
|
||||
args: {
|
||||
pagination: { total: 42, defaultLimit: 10, showTotalCount: true },
|
||||
},
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId('pagination-page-size'),
|
||||
);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** Data: the supported empty result keeps the table structure without a fabricated empty state. */
|
||||
export const Empty: Story = {
|
||||
args: { data: [], testId: 'tanstack-empty-table' },
|
||||
|
||||
@@ -28,6 +28,9 @@ import {
|
||||
useInfraMonitoringOrderBy,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
useInfraMonitoringStatusFilter,
|
||||
useInfraMonitoringPodStatusFilter,
|
||||
useInfraMonitoringNodeReadinessFilter,
|
||||
useInfraMonitoringContainerStatusFilter,
|
||||
} from '../hooks';
|
||||
import {
|
||||
useInfraMonitoringFontSize,
|
||||
@@ -97,6 +100,10 @@ export type K8sBaseListProps<
|
||||
detailsQueryKeyPrefix: string;
|
||||
};
|
||||
|
||||
function emptyToUndefined<T>(values: T[]): T[] | undefined {
|
||||
return values.length > 0 ? values : undefined;
|
||||
}
|
||||
|
||||
export function K8sBaseList<
|
||||
T extends K8sEntityData,
|
||||
TItemKey extends string | SelectedItemParams = string,
|
||||
@@ -120,6 +127,9 @@ export function K8sBaseList<
|
||||
const [groupBy] = useInfraMonitoringGroupBy();
|
||||
const [orderBy] = useInfraMonitoringOrderBy();
|
||||
const [statusFilter] = useInfraMonitoringStatusFilter();
|
||||
const [podStatusFilter] = useInfraMonitoringPodStatusFilter();
|
||||
const [nodeReadinessFilter] = useInfraMonitoringNodeReadinessFilter();
|
||||
const [containerStatusFilter] = useInfraMonitoringContainerStatusFilter();
|
||||
const [selectedItemParams, setSelectedItemParams] =
|
||||
useInfraMonitoringSelectedItemParams();
|
||||
const selectedItem = selectedItemParams.selectedItem;
|
||||
@@ -171,6 +181,9 @@ export function K8sBaseList<
|
||||
JSON.stringify(orderBy),
|
||||
JSON.stringify(groupBy),
|
||||
statusFilter,
|
||||
podStatusFilter.join(),
|
||||
nodeReadinessFilter.join(),
|
||||
containerStatusFilter.join(),
|
||||
...extraQueryKeyParts,
|
||||
);
|
||||
}, [
|
||||
@@ -183,6 +196,9 @@ export function K8sBaseList<
|
||||
orderBy,
|
||||
groupBy,
|
||||
statusFilter,
|
||||
podStatusFilter,
|
||||
nodeReadinessFilter,
|
||||
containerStatusFilter,
|
||||
extraQueryKeyParts,
|
||||
]);
|
||||
|
||||
@@ -203,6 +219,9 @@ export function K8sBaseList<
|
||||
statusFilter === 'active' || statusFilter === 'inactive'
|
||||
? statusFilter
|
||||
: undefined,
|
||||
filterByPodStatus: emptyToUndefined(podStatusFilter),
|
||||
filterByNodeReadiness: emptyToUndefined(nodeReadinessFilter),
|
||||
filterByContainerStatus: emptyToUndefined(containerStatusFilter),
|
||||
},
|
||||
groupBy:
|
||||
groupBy && groupBy.length > 0
|
||||
|
||||
@@ -285,6 +285,9 @@ describe('K8sBaseList', () => {
|
||||
expect(filters.filter).toStrictEqual({
|
||||
expression: '',
|
||||
filterByStatus: undefined,
|
||||
filterByPodStatus: undefined,
|
||||
filterByNodeReadiness: undefined,
|
||||
filterByContainerStatus: undefined,
|
||||
});
|
||||
expect(filters.groupBy).toBeUndefined();
|
||||
expect(filters.orderBy).toBeUndefined();
|
||||
@@ -1668,3 +1671,61 @@ describe('K8sBaseList', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('K8sBaseList status filters', () => {
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
|
||||
>();
|
||||
|
||||
function renderWithParams(queryParams: Record<string, string>): void {
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({ data: [], total: 0, error: null });
|
||||
|
||||
renderComponent<TestItem>({
|
||||
queryParams,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
}
|
||||
|
||||
it('sends the statuses named in the URL', async () => {
|
||||
renderWithParams({ podStatus: 'running,crashloopbackoff' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchListDataMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [filters] = fetchListDataMock.mock.calls[0];
|
||||
expect(filters.filter.filterByPodStatus).toStrictEqual([
|
||||
'running',
|
||||
'crashloopbackoff',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops unknown statuses the API would reject', async () => {
|
||||
renderWithParams({ podStatus: 'running,no_data,bogus' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchListDataMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [filters] = fetchListDataMock.mock.calls[0];
|
||||
expect(filters.filter.filterByPodStatus).toStrictEqual(['running']);
|
||||
});
|
||||
|
||||
it('omits the filter entirely when no status is selected', async () => {
|
||||
renderWithParams({});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchListDataMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [filters] = fetchListDataMock.mock.calls[0];
|
||||
expect(filters.filter.filterByPodStatus).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
.statusFilterGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.statusFilter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.statusFilterLabel {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.statusFilterControl {
|
||||
min-width: 180px;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import {
|
||||
FILTERABLE_CONTAINER_STATUSES,
|
||||
FILTERABLE_NODE_CONDITIONS,
|
||||
FILTERABLE_POD_STATUSES,
|
||||
useInfraMonitoringCategory,
|
||||
useInfraMonitoringContainerStatusFilter,
|
||||
useInfraMonitoringNodeReadinessFilter,
|
||||
useInfraMonitoringPodStatusFilter,
|
||||
} from 'container/InfraMonitoringK8sV2/hooks';
|
||||
|
||||
import StatusMultiSelect from './StatusMultiSelect';
|
||||
import { StatusFilterKind } from '../StatusFilterCells/useStatusFilter';
|
||||
import {
|
||||
CONTAINER_STATUS_FILTER_OPTIONS,
|
||||
NODE_READINESS_FILTER_OPTIONS,
|
||||
POD_STATUS_FILTER_OPTIONS,
|
||||
} from './statusFilterOptions';
|
||||
|
||||
import styles from './EntityStatusFilter.module.scss';
|
||||
|
||||
interface StatusFilterSpec {
|
||||
kind: StatusFilterKind;
|
||||
/** Matches the entity's own status column header, so filter and column agree. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
const POD_STATUS_SPEC: StatusFilterSpec = { kind: 'pod', label: 'Pod Status' };
|
||||
const NODE_READINESS_SPEC: StatusFilterSpec = {
|
||||
kind: 'node',
|
||||
label: 'Node Readiness',
|
||||
};
|
||||
|
||||
/**
|
||||
* Which secondary status filters each list accepts — mirrors the filter structs in
|
||||
* pkg/types/inframonitoringtypes. Volumes take none, so they are absent here.
|
||||
*/
|
||||
const STATUS_FILTERS_BY_ENTITY: Partial<
|
||||
Record<InfraMonitoringEntity, StatusFilterSpec[]>
|
||||
> = {
|
||||
[InfraMonitoringEntity.PODS]: [{ kind: 'pod', label: 'Status' }],
|
||||
[InfraMonitoringEntity.CONTAINERS]: [{ kind: 'container', label: 'Status' }],
|
||||
[InfraMonitoringEntity.NODES]: [NODE_READINESS_SPEC, POD_STATUS_SPEC],
|
||||
[InfraMonitoringEntity.CLUSTERS]: [NODE_READINESS_SPEC, POD_STATUS_SPEC],
|
||||
[InfraMonitoringEntity.NAMESPACES]: [POD_STATUS_SPEC],
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]: [POD_STATUS_SPEC],
|
||||
[InfraMonitoringEntity.DAEMONSETS]: [POD_STATUS_SPEC],
|
||||
[InfraMonitoringEntity.STATEFULSETS]: [POD_STATUS_SPEC],
|
||||
[InfraMonitoringEntity.JOBS]: [POD_STATUS_SPEC],
|
||||
};
|
||||
|
||||
const FILTER_KEY_BY_KIND: Record<StatusFilterKind, string> = {
|
||||
pod: 'pod_status',
|
||||
node: 'node_readiness',
|
||||
container: 'container_status',
|
||||
};
|
||||
|
||||
const OPTIONS_BY_KIND = {
|
||||
pod: POD_STATUS_FILTER_OPTIONS,
|
||||
node: NODE_READINESS_FILTER_OPTIONS,
|
||||
container: CONTAINER_STATUS_FILTER_OPTIONS,
|
||||
};
|
||||
|
||||
const ALL_VALUES_BY_KIND = {
|
||||
pod: FILTERABLE_POD_STATUSES,
|
||||
node: FILTERABLE_NODE_CONDITIONS,
|
||||
container: FILTERABLE_CONTAINER_STATUSES,
|
||||
};
|
||||
|
||||
function EntityStatusFilter(): JSX.Element | null {
|
||||
const [category] = useInfraMonitoringCategory();
|
||||
const [podStatus, setPodStatus] = useInfraMonitoringPodStatusFilter();
|
||||
const [nodeReadiness, setNodeReadiness] =
|
||||
useInfraMonitoringNodeReadinessFilter();
|
||||
const [containerStatus, setContainerStatus] =
|
||||
useInfraMonitoringContainerStatusFilter();
|
||||
|
||||
const specs = STATUS_FILTERS_BY_ENTITY[category as InfraMonitoringEntity];
|
||||
if (!specs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedByKind: Record<StatusFilterKind, string[]> = {
|
||||
pod: podStatus,
|
||||
node: nodeReadiness,
|
||||
container: containerStatus,
|
||||
};
|
||||
|
||||
const setterByKind: Record<StatusFilterKind, (next: string[]) => void> = {
|
||||
pod: (next): void =>
|
||||
void setPodStatus(next.length > 0 ? (next as typeof podStatus) : null),
|
||||
node: (next): void =>
|
||||
void setNodeReadiness(
|
||||
next.length > 0 ? (next as typeof nodeReadiness) : null,
|
||||
),
|
||||
container: (next): void =>
|
||||
void setContainerStatus(
|
||||
next.length > 0 ? (next as typeof containerStatus) : null,
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.statusFilterGroup}>
|
||||
{specs.map((spec) => (
|
||||
<StatusMultiSelect
|
||||
key={spec.kind}
|
||||
label={spec.label}
|
||||
entity={category as InfraMonitoringEntity}
|
||||
filterKey={FILTER_KEY_BY_KIND[spec.kind]}
|
||||
options={OPTIONS_BY_KIND[spec.kind]}
|
||||
allValues={ALL_VALUES_BY_KIND[spec.kind]}
|
||||
selected={selectedByKind[spec.kind]}
|
||||
onChange={setterByKind[spec.kind]}
|
||||
testId={`${spec.kind}-status-filter`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EntityStatusFilter;
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useMemo } from 'react';
|
||||
import { CustomMultiSelect } from 'components/NewSelect';
|
||||
import OverflowValuesTooltip from 'components/OverflowValuesTooltip/OverflowValuesTooltip';
|
||||
import { OptionData } from 'components/NewSelect/types';
|
||||
import { findOptionLabel } from 'components/NewSelect/utils';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { useInfraMonitoringPageListing } from 'container/InfraMonitoringK8sV2/hooks';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
import { logInfraFilterCustomizedEvent } from '../../events';
|
||||
|
||||
import styles from './EntityStatusFilter.module.scss';
|
||||
|
||||
interface StatusMultiSelectProps<T extends string> {
|
||||
label: string;
|
||||
entity: InfraMonitoringEntity;
|
||||
/** Key reported to analytics for this control, e.g. `pod_status`. */
|
||||
filterKey: string;
|
||||
options: OptionData[];
|
||||
allValues: T[];
|
||||
selected: T[];
|
||||
onChange: (next: T[]) => void;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function StatusMultiSelect<T extends string>({
|
||||
label,
|
||||
entity,
|
||||
filterKey,
|
||||
options,
|
||||
allValues,
|
||||
selected,
|
||||
onChange,
|
||||
testId,
|
||||
}: StatusMultiSelectProps<T>): JSX.Element {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
// No selection means no filter, which is every status — so show ALL as checked
|
||||
// rather than an empty control that reads as "nothing matches".
|
||||
const value = useMemo(
|
||||
() => (selected.length > 0 ? selected : allValues),
|
||||
[selected, allValues],
|
||||
);
|
||||
|
||||
const handleChange = (next: string[] | string | undefined): void => {
|
||||
const values = (Array.isArray(next) ? next : [next])
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.filter((item): item is T => allValues.includes(item as T));
|
||||
|
||||
// Selecting everything is the same request as selecting nothing; store the
|
||||
// empty form so the param drops out of the URL.
|
||||
const isAll = values.length === allValues.length;
|
||||
onChange(isAll ? [] : values);
|
||||
|
||||
logInfraFilterCustomizedEvent(
|
||||
entity,
|
||||
'status_filter',
|
||||
currentQuery.builder.queryData[0]?.filter?.expression || '',
|
||||
isAll ? [] : [filterKey],
|
||||
);
|
||||
};
|
||||
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
|
||||
return (
|
||||
<div className={styles.statusFilter}>
|
||||
<span className={styles.statusFilterLabel}>{label}</span>
|
||||
<CustomMultiSelect
|
||||
className={styles.statusFilterControl}
|
||||
data-testid={testId}
|
||||
options={options}
|
||||
value={value}
|
||||
showSearch
|
||||
showLabels
|
||||
preserveOptionOrder
|
||||
maxTagCount={1}
|
||||
maxTagTextLength={12}
|
||||
maxTagPlaceholder={(omitted): JSX.Element => (
|
||||
<OverflowValuesTooltip
|
||||
values={omitted.map((item) => {
|
||||
// The omitted items carry the raw value as their label, since
|
||||
// sections put the real one a level down.
|
||||
const value = String(item.value ?? '');
|
||||
return findOptionLabel(options, value) ?? value;
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
placeholder={label}
|
||||
onChange={(next): void => {
|
||||
handleChange(next);
|
||||
void setCurrentPage(1);
|
||||
}}
|
||||
onClear={(): void => {
|
||||
onChange([]);
|
||||
void setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatusMultiSelect;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { BadgeColor } from '@signozhq/ui/badge';
|
||||
import {
|
||||
InframonitoringtypesContainerStatusDTO,
|
||||
InframonitoringtypesNodeConditionDTO,
|
||||
InframonitoringtypesPodStatusDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { OptionData } from 'components/NewSelect/types';
|
||||
|
||||
import {
|
||||
FILTERABLE_CONTAINER_STATUSES,
|
||||
FILTERABLE_NODE_CONDITIONS,
|
||||
FILTERABLE_POD_STATUSES,
|
||||
} from '../../../hooks';
|
||||
import { POD_STATUS_COLORS, POD_STATUS_LABELS } from '../../../commonUtils';
|
||||
import {
|
||||
CONTAINER_STATUS_COLORS,
|
||||
CONTAINER_STATUS_LABELS,
|
||||
} from '../../../Containers/utils';
|
||||
import { NODE_CONDITION_LABELS } from '../../../Nodes/utils';
|
||||
|
||||
const ERROR_BADGE_COLOR: BadgeColor = 'cherry';
|
||||
|
||||
/**
|
||||
* Splits statuses into the two sections the dropdown renders. The badge colour is
|
||||
* already the severity signal in the table, so reusing it keeps the sections and
|
||||
* the status column from drifting apart.
|
||||
*/
|
||||
function toSectionedOptions<T extends string>(
|
||||
values: T[],
|
||||
labels: Record<T, string>,
|
||||
colors: Record<T, BadgeColor>,
|
||||
): OptionData[] {
|
||||
const toOption = (value: T): OptionData => ({
|
||||
label: labels[value],
|
||||
value,
|
||||
});
|
||||
|
||||
const lifecycle = values.filter(
|
||||
(value) => colors[value] !== ERROR_BADGE_COLOR,
|
||||
);
|
||||
const errors = values.filter((value) => colors[value] === ERROR_BADGE_COLOR);
|
||||
|
||||
return [
|
||||
{ label: 'Lifecycle', options: lifecycle.map(toOption) },
|
||||
{ label: 'Error status', options: errors.map(toOption) },
|
||||
].filter((section) => section.options.length > 0);
|
||||
}
|
||||
|
||||
export const POD_STATUS_FILTER_OPTIONS = toSectionedOptions(
|
||||
FILTERABLE_POD_STATUSES as InframonitoringtypesPodStatusDTO[],
|
||||
POD_STATUS_LABELS,
|
||||
POD_STATUS_COLORS,
|
||||
);
|
||||
|
||||
export const CONTAINER_STATUS_FILTER_OPTIONS = toSectionedOptions(
|
||||
FILTERABLE_CONTAINER_STATUSES as InframonitoringtypesContainerStatusDTO[],
|
||||
CONTAINER_STATUS_LABELS,
|
||||
CONTAINER_STATUS_COLORS,
|
||||
);
|
||||
|
||||
/** Only two values, so sections would add a heading per row. */
|
||||
export const NODE_READINESS_FILTER_OPTIONS: OptionData[] = (
|
||||
FILTERABLE_NODE_CONDITIONS as InframonitoringtypesNodeConditionDTO[]
|
||||
).map((value) => ({ label: NODE_CONDITION_LABELS[value], value }));
|
||||
@@ -0,0 +1,11 @@
|
||||
.statusBadgeButton {
|
||||
display: inline-flex;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
|
||||
// The badge sets its own cursor, so the button's never reaches it.
|
||||
--badge-cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Badge, BadgeColor } from '@signozhq/ui/badge';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
|
||||
import { StatusFilterKind, useStatusFilter } from './useStatusFilter';
|
||||
|
||||
import styles from './ClickableStatusBadge.module.scss';
|
||||
|
||||
interface ClickableStatusBadgeProps {
|
||||
color: BadgeColor;
|
||||
label: string;
|
||||
/** The status this badge stands for, applied as the filter when clicked. */
|
||||
status: string;
|
||||
kind: StatusFilterKind;
|
||||
rowId: string;
|
||||
}
|
||||
|
||||
function ClickableStatusBadge({
|
||||
color,
|
||||
label,
|
||||
status,
|
||||
kind,
|
||||
rowId,
|
||||
}: ClickableStatusBadgeProps): JSX.Element {
|
||||
const { selected, setStatusFilter } = useStatusFilter(kind);
|
||||
|
||||
// Clicking the status the list is already narrowed to has nothing left to
|
||||
// narrow, so it undoes the filter instead — but only when this status is the
|
||||
// whole filter, else it would silently drop the other selected statuses.
|
||||
const isWholeFilter = selected.length === 1 && selected[0] === status;
|
||||
|
||||
return (
|
||||
<TanStackTable.HoverTooltip
|
||||
rowId={rowId}
|
||||
title={isWholeFilter ? `Clear ${label} filter` : `Filter by ${label}`}
|
||||
arrow
|
||||
align="start"
|
||||
delayDuration={300}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.statusBadgeButton}
|
||||
data-testid={`status-badge-${status}`}
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setStatusFilter(isWholeFilter ? [] : [status]);
|
||||
}}
|
||||
>
|
||||
<Badge color={color} variant="outline">
|
||||
{label}
|
||||
</Badge>
|
||||
</button>
|
||||
</TanStackTable.HoverTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClickableStatusBadge;
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
GroupedStatusCounts,
|
||||
StatusCountItem,
|
||||
} from 'container/InfraMonitoringK8sV2/components';
|
||||
|
||||
import { StatusFilterKind, useStatusFilter } from './useStatusFilter';
|
||||
|
||||
interface ClickableStatusCountsProps {
|
||||
items: StatusCountItem[];
|
||||
rowId: string;
|
||||
kind: StatusFilterKind;
|
||||
showZeroValues?: boolean;
|
||||
}
|
||||
|
||||
function ClickableStatusCounts({
|
||||
items,
|
||||
rowId,
|
||||
kind,
|
||||
showZeroValues,
|
||||
}: ClickableStatusCountsProps): JSX.Element {
|
||||
const { setStatusFilter } = useStatusFilter(kind);
|
||||
|
||||
return (
|
||||
<GroupedStatusCounts
|
||||
items={items}
|
||||
rowId={rowId}
|
||||
showZeroValues={showZeroValues}
|
||||
onSelectStatuses={setStatusFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClickableStatusCounts;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { NuqsTestingAdapter, UrlUpdateEvent } from 'nuqs/adapters/testing';
|
||||
|
||||
import ClickableStatusBadge from '../ClickableStatusBadge';
|
||||
|
||||
function renderBadge(
|
||||
searchParams: Record<string, string>,
|
||||
onUrlUpdate: jest.Mock<void, [UrlUpdateEvent]>,
|
||||
): void {
|
||||
render(
|
||||
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
|
||||
<ClickableStatusBadge
|
||||
color="cherry"
|
||||
label="CrashLoopBackOff"
|
||||
status="crashloopbackoff"
|
||||
kind="pod"
|
||||
rowId="row-1"
|
||||
/>
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ClickableStatusBadge', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
beforeEach(() => {
|
||||
onUrlUpdate.mockClear();
|
||||
});
|
||||
|
||||
it('filters by its status when nothing is filtered', async () => {
|
||||
renderBadge({}, onUrlUpdate);
|
||||
|
||||
await userEvent.click(screen.getByTestId('status-badge-crashloopbackoff'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUrlUpdate.mock.calls[0][0].searchParams.get('podStatus')).toBe(
|
||||
'crashloopbackoff',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the filter when its status is the whole filter', async () => {
|
||||
renderBadge({ podStatus: 'crashloopbackoff' }, onUrlUpdate);
|
||||
|
||||
await userEvent.click(screen.getByTestId('status-badge-crashloopbackoff'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
onUrlUpdate.mock.calls[0][0].searchParams.get('podStatus'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('still filters when its status is only part of the filter', async () => {
|
||||
// Clearing here would silently drop oomkilled, which the user also picked.
|
||||
renderBadge({ podStatus: 'crashloopbackoff,oomkilled' }, onUrlUpdate);
|
||||
|
||||
await userEvent.click(screen.getByTestId('status-badge-crashloopbackoff'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUrlUpdate.mock.calls[0][0].searchParams.get('podStatus')).toBe(
|
||||
'crashloopbackoff',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
InframonitoringtypesContainerStatusDTO,
|
||||
InframonitoringtypesNodeConditionDTO,
|
||||
InframonitoringtypesPodStatusDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
useInfraMonitoringContainerStatusFilter,
|
||||
useInfraMonitoringNodeReadinessFilter,
|
||||
useInfraMonitoringPageListing,
|
||||
useInfraMonitoringPodStatusFilter,
|
||||
} from '../../../hooks';
|
||||
|
||||
export type StatusFilterKind = 'pod' | 'node' | 'container';
|
||||
|
||||
interface StatusFilter {
|
||||
selected: string[];
|
||||
/**
|
||||
* Replaces the filter rather than adding to it — a click on a status means
|
||||
* "show me these", not "add these to what I already have". Empty clears it.
|
||||
*/
|
||||
setStatusFilter: (statuses: string[]) => void;
|
||||
}
|
||||
|
||||
export function useStatusFilter(kind: StatusFilterKind): StatusFilter {
|
||||
const [podStatus, setPodStatus] = useInfraMonitoringPodStatusFilter();
|
||||
const [nodeStatus, setNodeStatus] = useInfraMonitoringNodeReadinessFilter();
|
||||
const [containerStatus, setContainerStatus] =
|
||||
useInfraMonitoringContainerStatusFilter();
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
|
||||
const setStatusFilter = useCallback(
|
||||
(statuses: string[]): void => {
|
||||
const next = statuses.length > 0 ? statuses : null;
|
||||
|
||||
if (kind === 'pod') {
|
||||
void setPodStatus(next as InframonitoringtypesPodStatusDTO[] | null);
|
||||
} else if (kind === 'node') {
|
||||
void setNodeStatus(next as InframonitoringtypesNodeConditionDTO[] | null);
|
||||
} else {
|
||||
void setContainerStatus(
|
||||
next as InframonitoringtypesContainerStatusDTO[] | null,
|
||||
);
|
||||
}
|
||||
|
||||
void setCurrentPage(1);
|
||||
},
|
||||
[kind, setPodStatus, setNodeStatus, setContainerStatus, setCurrentPage],
|
||||
);
|
||||
|
||||
let selected: string[] = containerStatus;
|
||||
if (kind === 'pod') {
|
||||
selected = podStatus;
|
||||
} else if (kind === 'node') {
|
||||
selected = nodeStatus;
|
||||
}
|
||||
|
||||
return { selected, setStatusFilter };
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { isCustomTimeRange } from 'store/globalTime';
|
||||
|
||||
export function logInfraFilterCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
source: 'quick_filter' | 'search' | 'host_status_toggle',
|
||||
source: 'quick_filter' | 'search' | 'host_status_toggle' | 'status_filter',
|
||||
expression: string,
|
||||
extraKeys?: string[],
|
||||
): void {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ReactNode } from 'react';
|
||||
import {
|
||||
InframonitoringtypesContainerStatusDTO,
|
||||
InframonitoringtypesNodeConditionDTO,
|
||||
InframonitoringtypesPodStatusDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
@@ -17,6 +22,9 @@ export type K8sBaseFilters = {
|
||||
filter: {
|
||||
expression: string;
|
||||
filterByStatus?: 'active' | 'inactive' | '';
|
||||
filterByPodStatus?: InframonitoringtypesPodStatusDTO[];
|
||||
filterByNodeReadiness?: InframonitoringtypesNodeConditionDTO[];
|
||||
filterByContainerStatus?: InframonitoringtypesContainerStatusDTO[];
|
||||
};
|
||||
groupBy?: Array<{ name: string }>;
|
||||
offset?: number;
|
||||
|
||||
@@ -38,7 +38,11 @@ async function fetchListData(
|
||||
try {
|
||||
const response = await listClusters(
|
||||
{
|
||||
filter: { expression: filters.filter.expression },
|
||||
filter: {
|
||||
expression: filters.filter.expression,
|
||||
filterByPodStatus: filters.filter.filterByPodStatus,
|
||||
filterByNodeReadiness: filters.filter.filterByNodeReadiness,
|
||||
},
|
||||
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
|
||||
offset: filters.offset,
|
||||
limit: filters.limit ?? 10,
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Boxes } from '@signozhq/icons';
|
||||
import { InframonitoringtypesClusterRecordDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
InframonitoringtypesClusterRecordDTO,
|
||||
InframonitoringtypesNodeConditionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { TableColumnDef } from 'components/TanStackTableView';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
|
||||
|
||||
import ColumnHeader from '../Base/ColumnHeader';
|
||||
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
|
||||
import EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { formatBytes, getPodStatusItems } from '../commonUtils';
|
||||
import {
|
||||
GroupedStatusCounts,
|
||||
TextNoData,
|
||||
ValidateColumnValueWrapper,
|
||||
} from '../components';
|
||||
import { TextNoData, ValidateColumnValueWrapper } from '../components';
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
@@ -98,18 +98,21 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupedStatusCounts
|
||||
<ClickableStatusCounts
|
||||
kind="node"
|
||||
rowId={rowId}
|
||||
items={[
|
||||
{
|
||||
value: row.nodeCountsByReadiness.ready,
|
||||
label: 'Ready',
|
||||
color: Color.BG_FOREST_500,
|
||||
statuses: [InframonitoringtypesNodeConditionDTO.ready],
|
||||
},
|
||||
{
|
||||
value: row.nodeCountsByReadiness.notReady,
|
||||
label: 'Not Ready',
|
||||
color: Color.BG_AMBER_500,
|
||||
statuses: [InframonitoringtypesNodeConditionDTO.not_ready],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -135,7 +138,8 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
return (
|
||||
<GroupedStatusCounts
|
||||
<ClickableStatusCounts
|
||||
kind="pod"
|
||||
rowId={rowId}
|
||||
items={getPodStatusItems(row.podCountsByStatus)}
|
||||
/>
|
||||
|
||||
@@ -40,7 +40,10 @@ async function fetchListData(
|
||||
try {
|
||||
const response = await listContainers(
|
||||
{
|
||||
filter: { expression: filters.filter.expression },
|
||||
filter: {
|
||||
expression: filters.filter.expression,
|
||||
filterByContainerStatus: filters.filter.filterByContainerStatus,
|
||||
},
|
||||
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
|
||||
offset: filters.offset,
|
||||
limit: filters.limit ?? 10,
|
||||
|
||||
@@ -9,6 +9,8 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
|
||||
|
||||
import ColumnHeader from '../Base/ColumnHeader';
|
||||
import ClickableStatusBadge from '../Base/components/StatusFilterCells/ClickableStatusBadge';
|
||||
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
|
||||
import EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { formatBytes } from '../commonUtils';
|
||||
@@ -173,7 +175,7 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
|
||||
width: { min: STATUS_COLUMN_WIDTH },
|
||||
enableSort: false,
|
||||
visibilityBehavior: 'hidden-on-expand',
|
||||
cell: ({ row }): React.ReactNode => {
|
||||
cell: ({ row, rowId }): React.ReactNode => {
|
||||
if (
|
||||
!row.status ||
|
||||
row.status === InframonitoringtypesContainerStatusDTO.no_data
|
||||
@@ -182,9 +184,13 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge color={CONTAINER_STATUS_COLORS[row.status]} variant="outline">
|
||||
{CONTAINER_STATUS_LABELS[row.status]}
|
||||
</Badge>
|
||||
<ClickableStatusBadge
|
||||
color={CONTAINER_STATUS_COLORS[row.status]}
|
||||
label={CONTAINER_STATUS_LABELS[row.status]}
|
||||
status={row.status}
|
||||
kind="container"
|
||||
rowId={rowId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -205,7 +211,8 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
return (
|
||||
<GroupedStatusCounts
|
||||
<ClickableStatusCounts
|
||||
kind="container"
|
||||
items={getContainerStatusItems(row.containerCountsByStatus)}
|
||||
rowId={rowId}
|
||||
/>
|
||||
|
||||
@@ -104,6 +104,20 @@ const CONTAINER_ERROR_STATUS_LABELS: Partial<
|
||||
containerCannotRun: 'ContainerCannotRun',
|
||||
};
|
||||
|
||||
const CONTAINER_STATUS_VALUES = new Set<string>(
|
||||
Object.values(InframonitoringtypesContainerStatusDTO),
|
||||
);
|
||||
|
||||
/** See countKeyToPodStatus in commonUtils — the counts DTO uses camelCase keys. */
|
||||
function countKeyToContainerStatus(
|
||||
key: string,
|
||||
): InframonitoringtypesContainerStatusDTO | null {
|
||||
const value = key.toLowerCase();
|
||||
return CONTAINER_STATUS_VALUES.has(value)
|
||||
? (value as InframonitoringtypesContainerStatusDTO)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getContainerStatusItems(
|
||||
counts: InframonitoringtypesContainerCountsByStatusDTO,
|
||||
): StatusCountItem[] {
|
||||
@@ -111,32 +125,67 @@ export function getContainerStatusItems(
|
||||
keyof typeof CONTAINER_ERROR_STATUS_LABELS
|
||||
>;
|
||||
|
||||
const errorBreakdown = errorKeys.map((key) => ({
|
||||
label: CONTAINER_ERROR_STATUS_LABELS[key] as string,
|
||||
value: counts[key],
|
||||
statuses: [countKeyToContainerStatus(key)].filter(
|
||||
(status): status is InframonitoringtypesContainerStatusDTO =>
|
||||
status !== null,
|
||||
),
|
||||
}));
|
||||
|
||||
return [
|
||||
{ value: counts.running, label: 'Running', color: Color.BG_FOREST_500 },
|
||||
{ value: counts.completed, label: 'Completed', color: Color.BG_ROBIN_500 },
|
||||
{
|
||||
value: counts.running,
|
||||
label: 'Running',
|
||||
color: Color.BG_FOREST_500,
|
||||
statuses: [InframonitoringtypesContainerStatusDTO.running],
|
||||
},
|
||||
{
|
||||
value: counts.completed,
|
||||
label: 'Completed',
|
||||
color: Color.BG_ROBIN_500,
|
||||
statuses: [InframonitoringtypesContainerStatusDTO.completed],
|
||||
},
|
||||
{
|
||||
value: counts.waiting + counts.containerCreating,
|
||||
label: 'Waiting',
|
||||
color: Color.BG_AMBER_500,
|
||||
breakdown: [
|
||||
{ label: 'Waiting', value: counts.waiting },
|
||||
{ label: 'ContainerCreating', value: counts.containerCreating },
|
||||
{
|
||||
label: 'Waiting',
|
||||
value: counts.waiting,
|
||||
statuses: [InframonitoringtypesContainerStatusDTO.waiting],
|
||||
},
|
||||
{
|
||||
label: 'ContainerCreating',
|
||||
value: counts.containerCreating,
|
||||
statuses: [InframonitoringtypesContainerStatusDTO.containercreating],
|
||||
},
|
||||
],
|
||||
statuses: [
|
||||
InframonitoringtypesContainerStatusDTO.waiting,
|
||||
InframonitoringtypesContainerStatusDTO.containercreating,
|
||||
],
|
||||
},
|
||||
{
|
||||
value: counts.terminated,
|
||||
label: 'Terminated',
|
||||
color: Color.BG_SIENNA_500,
|
||||
statuses: [InframonitoringtypesContainerStatusDTO.terminated],
|
||||
},
|
||||
{
|
||||
value: counts.unknown,
|
||||
label: 'Unknown',
|
||||
color: Color.BG_SLATE_400,
|
||||
statuses: [InframonitoringtypesContainerStatusDTO.unknown],
|
||||
},
|
||||
{ value: counts.unknown, label: 'Unknown', color: Color.BG_SLATE_400 },
|
||||
{
|
||||
value: errorKeys.reduce((sum, key) => sum + counts[key], 0),
|
||||
label: 'Error Status',
|
||||
color: Color.BG_CHERRY_500,
|
||||
breakdown: errorKeys.map((key) => ({
|
||||
label: CONTAINER_ERROR_STATUS_LABELS[key] as string,
|
||||
value: counts[key],
|
||||
})),
|
||||
breakdown: errorBreakdown,
|
||||
statuses: errorBreakdown.flatMap((item) => item.statuses),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -42,7 +42,10 @@ async function fetchListData(
|
||||
try {
|
||||
const response = await listDaemonSets(
|
||||
{
|
||||
filter: { expression: filters.filter.expression },
|
||||
filter: {
|
||||
expression: filters.filter.expression,
|
||||
filterByPodStatus: filters.filter.filterByPodStatus,
|
||||
},
|
||||
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
|
||||
offset: filters.offset,
|
||||
limit: filters.limit ?? 10,
|
||||
|
||||
@@ -4,6 +4,7 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
|
||||
|
||||
import ColumnHeader from '../Base/ColumnHeader';
|
||||
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
|
||||
import EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
@@ -126,7 +127,8 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
return (
|
||||
<GroupedStatusCounts
|
||||
<ClickableStatusCounts
|
||||
kind="pod"
|
||||
rowId={rowId}
|
||||
items={getPodStatusItems(podCountsByStatus)}
|
||||
/>
|
||||
|
||||
@@ -46,7 +46,10 @@ async function fetchListData(
|
||||
try {
|
||||
const response = await listDeployments(
|
||||
{
|
||||
filter: { expression: filters.filter.expression },
|
||||
filter: {
|
||||
expression: filters.filter.expression,
|
||||
filterByPodStatus: filters.filter.filterByPodStatus,
|
||||
},
|
||||
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
|
||||
offset: filters.offset,
|
||||
limit: filters.limit ?? 10,
|
||||
|
||||
@@ -4,6 +4,7 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
|
||||
|
||||
import ColumnHeader from '../Base/ColumnHeader';
|
||||
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
|
||||
import EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
@@ -124,7 +125,8 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
return (
|
||||
<GroupedStatusCounts
|
||||
<ClickableStatusCounts
|
||||
kind="pod"
|
||||
rowId={rowId}
|
||||
items={getPodStatusItems(podCountsByStatus)}
|
||||
/>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { CategoryIcon } from './Base/categoryIcons';
|
||||
import { K8sDynamicList } from './Base/K8sDynamicList';
|
||||
import EntityStatusFilter from './Base/components/EntityStatusFilter/EntityStatusFilter';
|
||||
import {
|
||||
GetClustersQuickFiltersConfig,
|
||||
GetContainersQuickFiltersConfig,
|
||||
@@ -36,9 +37,12 @@ import {
|
||||
} from './constants';
|
||||
import {
|
||||
useInfraMonitoringCategory,
|
||||
useInfraMonitoringContainerStatusFilter,
|
||||
useInfraMonitoringGroupBy,
|
||||
useInfraMonitoringNodeReadinessFilter,
|
||||
useInfraMonitoringOrderBy,
|
||||
useInfraMonitoringPageListing,
|
||||
useInfraMonitoringPodStatusFilter,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
} from './hooks';
|
||||
|
||||
@@ -60,6 +64,9 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
const [, setOrderBy] = useInfraMonitoringOrderBy();
|
||||
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
const [, setPodStatusFilter] = useInfraMonitoringPodStatusFilter();
|
||||
const [, setNodeReadinessFilter] = useInfraMonitoringNodeReadinessFilter();
|
||||
const [, setContainerStatusFilter] = useInfraMonitoringContainerStatusFilter();
|
||||
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
@@ -219,6 +226,9 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
void setGroupBy(null);
|
||||
void setCurrentPage(null);
|
||||
setSelectedItemParams(null);
|
||||
void setPodStatusFilter(null);
|
||||
void setNodeReadinessFilter(null);
|
||||
void setContainerStatusFilter(null);
|
||||
redirectWithQueryBuilderData({
|
||||
...currentQuery,
|
||||
builder: {
|
||||
@@ -326,7 +336,10 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
showFilters ? styles.listContainerFiltersVisible : ''
|
||||
}`}
|
||||
>
|
||||
<K8sDynamicList controlListPrefix={showFiltersComp} />
|
||||
<K8sDynamicList
|
||||
controlListPrefix={showFiltersComp}
|
||||
leftFilters={<EntityStatusFilter />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,10 @@ async function fetchListData(
|
||||
try {
|
||||
const response = await listJobs(
|
||||
{
|
||||
filter: { expression: filters.filter.expression },
|
||||
filter: {
|
||||
expression: filters.filter.expression,
|
||||
filterByPodStatus: filters.filter.filterByPodStatus,
|
||||
},
|
||||
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
|
||||
offset: filters.offset,
|
||||
limit: filters.limit ?? 10,
|
||||
|
||||
@@ -4,6 +4,7 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
|
||||
|
||||
import ColumnHeader from '../Base/ColumnHeader';
|
||||
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
|
||||
import EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
@@ -118,7 +119,8 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
return (
|
||||
<GroupedStatusCounts
|
||||
<ClickableStatusCounts
|
||||
kind="pod"
|
||||
items={getPodStatusItems(podCountsByStatus)}
|
||||
rowId={rowId}
|
||||
/>
|
||||
|
||||
@@ -44,7 +44,10 @@ async function fetchListData(
|
||||
try {
|
||||
const response = await listNamespaces(
|
||||
{
|
||||
filter: { expression: filters.filter.expression },
|
||||
filter: {
|
||||
expression: filters.filter.expression,
|
||||
filterByPodStatus: filters.filter.filterByPodStatus,
|
||||
},
|
||||
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
|
||||
offset: filters.offset,
|
||||
limit: filters.limit ?? 10,
|
||||
|
||||
@@ -4,14 +4,11 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
|
||||
|
||||
import ColumnHeader from '../Base/ColumnHeader';
|
||||
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
|
||||
import EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { formatBytes, getPodStatusItems } from '../commonUtils';
|
||||
import {
|
||||
GroupedStatusCounts,
|
||||
TextNoData,
|
||||
ValidateColumnValueWrapper,
|
||||
} from '../components';
|
||||
import { TextNoData, ValidateColumnValueWrapper } from '../components';
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
@@ -119,7 +116,8 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
return (
|
||||
<GroupedStatusCounts
|
||||
<ClickableStatusCounts
|
||||
kind="pod"
|
||||
items={getPodStatusItems(row.podCountsByStatus)}
|
||||
rowId={rowId}
|
||||
/>
|
||||
|
||||
@@ -39,7 +39,11 @@ async function fetchListData(
|
||||
try {
|
||||
const response = await listNodes(
|
||||
{
|
||||
filter: { expression: filters.filter.expression },
|
||||
filter: {
|
||||
expression: filters.filter.expression,
|
||||
filterByPodStatus: filters.filter.filterByPodStatus,
|
||||
filterByNodeReadiness: filters.filter.filterByNodeReadiness,
|
||||
},
|
||||
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
|
||||
offset: filters.offset,
|
||||
limit: filters.limit ?? 10,
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Badge, BadgeColor } from '@signozhq/ui/badge';
|
||||
import { InframonitoringtypesNodeRecordDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
InframonitoringtypesNodeConditionDTO,
|
||||
InframonitoringtypesNodeRecordDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
|
||||
|
||||
import ColumnHeader from '../Base/ColumnHeader';
|
||||
import EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import ClickableStatusBadge from '../Base/components/StatusFilterCells/ClickableStatusBadge';
|
||||
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
|
||||
import { formatBytes, getPodStatusItems } from '../commonUtils';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
|
||||
import {
|
||||
GroupedStatusCounts,
|
||||
TextNoData,
|
||||
ValidateColumnValueWrapper,
|
||||
} from '../components';
|
||||
import { TextNoData, ValidateColumnValueWrapper } from '../components';
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import { NODE_CONDITION_COLORS, NODE_CONDITION_LABELS } from './utils';
|
||||
import { Workflow } from '@signozhq/icons';
|
||||
|
||||
export function getK8sNodeRowKey(
|
||||
@@ -31,18 +32,6 @@ export function getK8sNodeItemKey(
|
||||
return node.nodeName;
|
||||
}
|
||||
|
||||
const NODE_CONDITION_COLORS: Record<string, BadgeColor> = {
|
||||
ready: 'forest',
|
||||
not_ready: 'amber',
|
||||
no_data: 'secondary',
|
||||
};
|
||||
|
||||
const NODE_CONDITION_LABEL_MAP: Record<string, string> = {
|
||||
ready: 'Ready',
|
||||
not_ready: 'Not Ready',
|
||||
no_data: 'No Data',
|
||||
};
|
||||
|
||||
export type NodeTableColumnConfig =
|
||||
TableColumnDef<InframonitoringtypesNodeRecordDTO>;
|
||||
export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
|
||||
@@ -107,24 +96,31 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge color={color} variant="outline">
|
||||
{NODE_CONDITION_LABEL_MAP[row.condition] || 'Unknown'}
|
||||
</Badge>
|
||||
<ClickableStatusBadge
|
||||
color={color}
|
||||
label={NODE_CONDITION_LABELS[row.condition] || 'Unknown'}
|
||||
status={row.condition}
|
||||
kind="node"
|
||||
rowId={rowId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupedStatusCounts
|
||||
<ClickableStatusCounts
|
||||
kind="node"
|
||||
items={[
|
||||
{
|
||||
value: row.nodeCountsByReadiness?.ready ?? 0,
|
||||
label: 'Ready',
|
||||
color: Color.BG_FOREST_500,
|
||||
statuses: [InframonitoringtypesNodeConditionDTO.ready],
|
||||
},
|
||||
{
|
||||
value: row.nodeCountsByReadiness?.notReady ?? 0,
|
||||
label: 'Not Ready',
|
||||
color: Color.BG_AMBER_500,
|
||||
statuses: [InframonitoringtypesNodeConditionDTO.not_ready],
|
||||
},
|
||||
]}
|
||||
rowId={rowId}
|
||||
@@ -149,7 +145,8 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
return (
|
||||
<GroupedStatusCounts
|
||||
<ClickableStatusCounts
|
||||
kind="pod"
|
||||
items={getPodStatusItems(row.podCountsByStatus)}
|
||||
rowId={rowId}
|
||||
/>
|
||||
|
||||
20
frontend/src/container/InfraMonitoringK8sV2/Nodes/utils.ts
Normal file
20
frontend/src/container/InfraMonitoringK8sV2/Nodes/utils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { BadgeColor } from '@signozhq/ui/badge';
|
||||
import { InframonitoringtypesNodeConditionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const NODE_CONDITION_COLORS: Record<
|
||||
InframonitoringtypesNodeConditionDTO,
|
||||
BadgeColor
|
||||
> = {
|
||||
[InframonitoringtypesNodeConditionDTO.ready]: 'forest',
|
||||
[InframonitoringtypesNodeConditionDTO.not_ready]: 'amber',
|
||||
[InframonitoringtypesNodeConditionDTO.no_data]: 'secondary',
|
||||
};
|
||||
|
||||
export const NODE_CONDITION_LABELS: Record<
|
||||
InframonitoringtypesNodeConditionDTO,
|
||||
string
|
||||
> = {
|
||||
[InframonitoringtypesNodeConditionDTO.ready]: 'Ready',
|
||||
[InframonitoringtypesNodeConditionDTO.not_ready]: 'Not Ready',
|
||||
[InframonitoringtypesNodeConditionDTO.no_data]: 'No Data',
|
||||
};
|
||||
@@ -36,7 +36,10 @@ async function fetchListData(
|
||||
try {
|
||||
const response = await listPods(
|
||||
{
|
||||
filter: { expression: filters.filter.expression },
|
||||
filter: {
|
||||
expression: filters.filter.expression,
|
||||
filterByPodStatus: filters.filter.filterByPodStatus,
|
||||
},
|
||||
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
|
||||
offset: filters.offset,
|
||||
limit: filters.limit ?? 10,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Container } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import {
|
||||
InframonitoringtypesPodRecordDTO,
|
||||
InframonitoringtypesPodStatusDTO,
|
||||
@@ -10,15 +9,17 @@ import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
|
||||
import ColumnHeader from '../Base/ColumnHeader';
|
||||
import EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import ClickableStatusBadge from '../Base/components/StatusFilterCells/ClickableStatusBadge';
|
||||
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
|
||||
import {
|
||||
formatBytes,
|
||||
getPodStatusItems,
|
||||
POD_STATUS_COLORS,
|
||||
POD_STATUS_LABELS,
|
||||
} from '../commonUtils';
|
||||
import {
|
||||
EntityProgressBar,
|
||||
EntityProgressThresholds,
|
||||
GroupedStatusCounts,
|
||||
TextNoData,
|
||||
ValidateColumnValueWrapper,
|
||||
} from '../components';
|
||||
@@ -100,7 +101,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
width: { min: 250 },
|
||||
enableSort: false,
|
||||
visibilityBehavior: 'hidden-on-expand',
|
||||
cell: ({ row }): React.ReactNode => {
|
||||
cell: ({ row, rowId }): React.ReactNode => {
|
||||
if (!row.podStatus) {
|
||||
return <></>;
|
||||
}
|
||||
@@ -110,11 +111,14 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
}
|
||||
|
||||
const color = POD_STATUS_COLORS[row.podStatus] || POD_STATUS_COLORS.unknown;
|
||||
const label = row.podStatus.charAt(0).toUpperCase() + row.podStatus.slice(1);
|
||||
return (
|
||||
<Badge color={color} variant="outline">
|
||||
{label}
|
||||
</Badge>
|
||||
<ClickableStatusBadge
|
||||
color={color}
|
||||
label={POD_STATUS_LABELS[row.podStatus]}
|
||||
status={row.podStatus}
|
||||
kind="pod"
|
||||
rowId={rowId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -136,7 +140,8 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
return (
|
||||
<GroupedStatusCounts
|
||||
<ClickableStatusCounts
|
||||
kind="pod"
|
||||
items={getPodStatusItems(row.podCountsByStatus)}
|
||||
rowId={rowId}
|
||||
/>
|
||||
|
||||
@@ -42,7 +42,10 @@ async function fetchListData(
|
||||
try {
|
||||
const response = await listStatefulSets(
|
||||
{
|
||||
filter: { expression: filters.filter.expression },
|
||||
filter: {
|
||||
expression: filters.filter.expression,
|
||||
filterByPodStatus: filters.filter.filterByPodStatus,
|
||||
},
|
||||
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
|
||||
offset: filters.offset,
|
||||
limit: filters.limit ?? 10,
|
||||
|
||||
@@ -4,6 +4,7 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
|
||||
|
||||
import ColumnHeader from '../Base/ColumnHeader';
|
||||
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
|
||||
import EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
@@ -127,7 +128,8 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
return (
|
||||
<GroupedStatusCounts
|
||||
<ClickableStatusCounts
|
||||
kind="pod"
|
||||
items={getPodStatusItems(podCountsByStatus)}
|
||||
rowId={rowId}
|
||||
/>
|
||||
|
||||
@@ -69,6 +69,42 @@ function renderPage(
|
||||
}
|
||||
|
||||
describe('InfraMonitoringK8s', () => {
|
||||
describe('when the category changes with a status filter applied', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
beforeEach(async () => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
|
||||
renderPage(
|
||||
{
|
||||
category: K8sCategories.CLUSTERS,
|
||||
podStatus: 'crashloopbackoff',
|
||||
nodeReadiness: 'not_ready',
|
||||
},
|
||||
onUrlUpdateMock,
|
||||
);
|
||||
|
||||
await screen.findByTestId(`category-${K8sCategories.CONTAINERS}`);
|
||||
});
|
||||
|
||||
it('should drop the status filters the new category cannot apply', async () => {
|
||||
fireEvent.click(screen.getByTestId(`category-${K8sCategories.CONTAINERS}`));
|
||||
|
||||
// Containers filter on their own statuses, so a pod status carried over from
|
||||
// clusters would sit in the toolbar looking applied while matching nothing.
|
||||
await waitFor(() => {
|
||||
const categorySwitch = onUrlUpdateMock.mock.calls.find(
|
||||
(call) =>
|
||||
call[0].searchParams.get('category') === K8sCategories.CONTAINERS,
|
||||
);
|
||||
|
||||
expect(categorySwitch).toBeDefined();
|
||||
expect(categorySwitch?.[0].searchParams.get('podStatus')).toBeNull();
|
||||
expect(categorySwitch?.[0].searchParams.get('nodeReadiness')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the category changes from a page other than the first', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
InframonitoringtypesContainerCountsByStatusDTO,
|
||||
InframonitoringtypesContainerStatusDTO,
|
||||
InframonitoringtypesPodCountsByStatusDTO,
|
||||
InframonitoringtypesPodStatusDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { getPodStatusItems } from '../commonUtils';
|
||||
import { getContainerStatusItems } from '../Containers/utils';
|
||||
|
||||
const POD_COUNT_KEYS: Array<keyof InframonitoringtypesPodCountsByStatusDTO> = [
|
||||
'running',
|
||||
'completed',
|
||||
'pending',
|
||||
'unknown',
|
||||
'failed',
|
||||
'crashLoopBackOff',
|
||||
'imagePullBackOff',
|
||||
'errImagePull',
|
||||
'createContainerConfigError',
|
||||
'containerCreating',
|
||||
'oomKilled',
|
||||
'error',
|
||||
'containerCannotRun',
|
||||
'evicted',
|
||||
'nodeAffinity',
|
||||
'nodeLost',
|
||||
'shutdown',
|
||||
'unexpectedAdmissionError',
|
||||
];
|
||||
|
||||
function podCounts(): InframonitoringtypesPodCountsByStatusDTO {
|
||||
return POD_COUNT_KEYS.reduce(
|
||||
(acc, key) => ({ ...acc, [key]: 1 }),
|
||||
{} as InframonitoringtypesPodCountsByStatusDTO,
|
||||
);
|
||||
}
|
||||
|
||||
function containerCounts(): InframonitoringtypesContainerCountsByStatusDTO {
|
||||
return {
|
||||
running: 1,
|
||||
waiting: 1,
|
||||
terminated: 1,
|
||||
completed: 1,
|
||||
unknown: 1,
|
||||
containerCreating: 1,
|
||||
crashLoopBackOff: 1,
|
||||
imagePullBackOff: 1,
|
||||
errImagePull: 1,
|
||||
createContainerConfigError: 1,
|
||||
oomKilled: 1,
|
||||
error: 1,
|
||||
containerCannotRun: 1,
|
||||
} as InframonitoringtypesContainerCountsByStatusDTO;
|
||||
}
|
||||
|
||||
describe('status counts carry filterable statuses', () => {
|
||||
it('maps every pod count bucket to a real status enum member', () => {
|
||||
const items = getPodStatusItems(podCounts());
|
||||
const statuses = items.flatMap((item) => item.statuses ?? []);
|
||||
const valid = Object.values(InframonitoringtypesPodStatusDTO) as string[];
|
||||
|
||||
expect(statuses.length).toBeGreaterThan(0);
|
||||
statuses.forEach((status) => expect(valid).toContain(status));
|
||||
});
|
||||
|
||||
it('gives the pod Error Status chip every error bucket it sums', () => {
|
||||
const items = getPodStatusItems(podCounts());
|
||||
const errorItem = items.find((item) => item.label === 'Error Status');
|
||||
|
||||
// The chip's own value is the sum of its breakdown, so selecting it must
|
||||
// select every status counted in that sum.
|
||||
expect(errorItem?.value).toBe(errorItem?.breakdown?.length);
|
||||
expect(errorItem?.statuses).toHaveLength(errorItem?.breakdown?.length ?? 0);
|
||||
});
|
||||
|
||||
it('maps every container count bucket to a real status enum member', () => {
|
||||
const items = getContainerStatusItems(containerCounts());
|
||||
const statuses = items.flatMap((item) => item.statuses ?? []);
|
||||
const valid = Object.values(
|
||||
InframonitoringtypesContainerStatusDTO,
|
||||
) as string[];
|
||||
|
||||
expect(statuses.length).toBeGreaterThan(0);
|
||||
statuses.forEach((status) => expect(valid).toContain(status));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as inframonitoring from 'api/generated/services/inframonitoring';
|
||||
|
||||
import { entityRegistry } from '../Base/entity.registry';
|
||||
import { K8sCategories } from '../constants';
|
||||
|
||||
jest.mock('api/generated/services/inframonitoring');
|
||||
|
||||
/**
|
||||
* Every list the status filter offers must forward the flag, or the control is a
|
||||
* no-op on that section. Mirrors the filter structs in pkg/types/inframonitoringtypes.
|
||||
*/
|
||||
const EXPECTED_FLAGS: Record<string, string[]> = {
|
||||
[K8sCategories.PODS]: ['filterByPodStatus'],
|
||||
[K8sCategories.CONTAINERS]: ['filterByContainerStatus'],
|
||||
[K8sCategories.NODES]: ['filterByPodStatus', 'filterByNodeReadiness'],
|
||||
[K8sCategories.CLUSTERS]: ['filterByPodStatus', 'filterByNodeReadiness'],
|
||||
[K8sCategories.NAMESPACES]: ['filterByPodStatus'],
|
||||
[K8sCategories.DEPLOYMENTS]: ['filterByPodStatus'],
|
||||
[K8sCategories.DAEMONSETS]: ['filterByPodStatus'],
|
||||
[K8sCategories.STATEFULSETS]: ['filterByPodStatus'],
|
||||
[K8sCategories.JOBS]: ['filterByPodStatus'],
|
||||
};
|
||||
|
||||
const SENTINELS: Record<string, string[]> = {
|
||||
filterByPodStatus: ['crashloopbackoff'],
|
||||
filterByNodeReadiness: ['not_ready'],
|
||||
filterByContainerStatus: ['oomkilled'],
|
||||
};
|
||||
|
||||
describe('status filters reach the list APIs', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Object.values(inframonitoring).forEach((exported) => {
|
||||
if (jest.isMockFunction(exported)) {
|
||||
exported.mockResolvedValue({
|
||||
data: { type: 'list', records: [], total: 0 },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Object.entries(EXPECTED_FLAGS).forEach(([category, flags]) => {
|
||||
it(`forwards ${flags.join(' + ')} for ${category}`, async () => {
|
||||
const config = entityRegistry[category];
|
||||
expect(config).toBeDefined();
|
||||
|
||||
await config.list.fetchListData({
|
||||
filter: {
|
||||
expression: '',
|
||||
...Object.fromEntries(flags.map((flag) => [flag, SENTINELS[flag]])),
|
||||
},
|
||||
start: 1,
|
||||
end: 2,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const call = Object.values(inframonitoring)
|
||||
.filter(jest.isMockFunction)
|
||||
.flatMap((fn) => (fn as jest.Mock).mock.calls)[0];
|
||||
|
||||
expect(call).toBeDefined();
|
||||
flags.forEach((flag) => {
|
||||
expect(call[0].filter[flag]).toStrictEqual(SENTINELS[flag]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,34 @@ export const POD_STATUS_COLORS: Record<
|
||||
[InframonitoringtypesPodStatusDTO.unexpectedadmissionerror]: 'cherry',
|
||||
};
|
||||
|
||||
/** kubectl prints these as single CamelCase words, so the enum value alone is not a usable label. */
|
||||
export const POD_STATUS_LABELS: Record<
|
||||
InframonitoringtypesPodStatusDTO,
|
||||
string
|
||||
> = {
|
||||
[InframonitoringtypesPodStatusDTO.running]: 'Running',
|
||||
[InframonitoringtypesPodStatusDTO.completed]: 'Completed',
|
||||
[InframonitoringtypesPodStatusDTO.pending]: 'Pending',
|
||||
[InframonitoringtypesPodStatusDTO.unknown]: 'Unknown',
|
||||
[InframonitoringtypesPodStatusDTO.no_data]: 'No data',
|
||||
[InframonitoringtypesPodStatusDTO.failed]: 'Failed',
|
||||
[InframonitoringtypesPodStatusDTO.crashloopbackoff]: 'CrashLoopBackOff',
|
||||
[InframonitoringtypesPodStatusDTO.imagepullbackoff]: 'ImagePullBackOff',
|
||||
[InframonitoringtypesPodStatusDTO.errimagepull]: 'ErrImagePull',
|
||||
[InframonitoringtypesPodStatusDTO.createcontainerconfigerror]:
|
||||
'CreateContainerConfigError',
|
||||
[InframonitoringtypesPodStatusDTO.containercreating]: 'ContainerCreating',
|
||||
[InframonitoringtypesPodStatusDTO.oomkilled]: 'OOMKilled',
|
||||
[InframonitoringtypesPodStatusDTO.error]: 'Error',
|
||||
[InframonitoringtypesPodStatusDTO.containercannotrun]: 'ContainerCannotRun',
|
||||
[InframonitoringtypesPodStatusDTO.evicted]: 'Evicted',
|
||||
[InframonitoringtypesPodStatusDTO.nodeaffinity]: 'NodeAffinity',
|
||||
[InframonitoringtypesPodStatusDTO.nodelost]: 'NodeLost',
|
||||
[InframonitoringtypesPodStatusDTO.shutdown]: 'Shutdown',
|
||||
[InframonitoringtypesPodStatusDTO.unexpectedadmissionerror]:
|
||||
'UnexpectedAdmissionError',
|
||||
};
|
||||
|
||||
type PodStatusCategory =
|
||||
| 'running'
|
||||
| 'completed'
|
||||
@@ -105,6 +133,24 @@ const ERROR_STATUS_LABELS: Record<ErrorStatusKey, string> = {
|
||||
unexpectedAdmissionError: 'UnexpectedAdmissionError',
|
||||
};
|
||||
|
||||
const POD_STATUS_VALUES = new Set<string>(
|
||||
Object.values(InframonitoringtypesPodStatusDTO),
|
||||
);
|
||||
|
||||
/**
|
||||
* The count DTO spells each status in camelCase (`crashLoopBackOff`) where the
|
||||
* status enum spells it lowercase (`crashloopbackoff`); anything that does not
|
||||
* land on a real enum member is dropped rather than sent to the API.
|
||||
*/
|
||||
function countKeyToPodStatus(
|
||||
key: string,
|
||||
): InframonitoringtypesPodStatusDTO | null {
|
||||
const value = key.toLowerCase();
|
||||
return POD_STATUS_VALUES.has(value)
|
||||
? (value as InframonitoringtypesPodStatusDTO)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getPodStatusItems(
|
||||
counts: InframonitoringtypesPodCountsByStatusDTO,
|
||||
): StatusCountItem[] {
|
||||
@@ -114,18 +160,42 @@ export function getPodStatusItems(
|
||||
const errorBreakdown = errorKeys.map((key) => ({
|
||||
label: ERROR_STATUS_LABELS[key],
|
||||
value: counts[key],
|
||||
statuses: [countKeyToPodStatus(key)].filter(
|
||||
(status): status is InframonitoringtypesPodStatusDTO => status !== null,
|
||||
),
|
||||
}));
|
||||
|
||||
return [
|
||||
{ value: counts.running, label: 'Running', color: Color.BG_FOREST_500 },
|
||||
{ value: counts.completed, label: 'Completed', color: Color.BG_ROBIN_500 },
|
||||
{ value: counts.pending, label: 'Pending', color: Color.BG_AMBER_500 },
|
||||
{ value: counts.unknown, label: 'Unknown', color: Color.BG_SLATE_400 },
|
||||
{
|
||||
value: counts.running,
|
||||
label: 'Running',
|
||||
color: Color.BG_FOREST_500,
|
||||
statuses: [InframonitoringtypesPodStatusDTO.running],
|
||||
},
|
||||
{
|
||||
value: counts.completed,
|
||||
label: 'Completed',
|
||||
color: Color.BG_ROBIN_500,
|
||||
statuses: [InframonitoringtypesPodStatusDTO.completed],
|
||||
},
|
||||
{
|
||||
value: counts.pending,
|
||||
label: 'Pending',
|
||||
color: Color.BG_AMBER_500,
|
||||
statuses: [InframonitoringtypesPodStatusDTO.pending],
|
||||
},
|
||||
{
|
||||
value: counts.unknown,
|
||||
label: 'Unknown',
|
||||
color: Color.BG_SLATE_400,
|
||||
statuses: [InframonitoringtypesPodStatusDTO.unknown],
|
||||
},
|
||||
{
|
||||
value: errorTotal,
|
||||
label: 'Error Status',
|
||||
color: Color.BG_CHERRY_500,
|
||||
breakdown: errorBreakdown,
|
||||
statuses: errorBreakdown.flatMap((item) => item.statuses),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -45,3 +45,25 @@
|
||||
.tooltipValue {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.itemSelectable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltipRowSelectable {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { MouseEventHandler } from 'react';
|
||||
export interface StatusBreakdownItem {
|
||||
label: string;
|
||||
value: number;
|
||||
/** The status values this row stands for, for click-to-filter. */
|
||||
statuses?: string[];
|
||||
}
|
||||
|
||||
export interface StatusCountItem {
|
||||
@@ -15,15 +17,22 @@ export interface StatusCountItem {
|
||||
label: string;
|
||||
color: string;
|
||||
breakdown?: StatusBreakdownItem[];
|
||||
/** The status values this count stands for, for click-to-filter. */
|
||||
statuses?: string[];
|
||||
}
|
||||
|
||||
interface GroupedStatusCountsProps {
|
||||
items: StatusCountItem[];
|
||||
rowId: string;
|
||||
showZeroValues?: boolean;
|
||||
/** Omit to leave the counts inert, as on entities with no status filter. */
|
||||
onSelectStatuses?: (statuses: string[]) => void;
|
||||
}
|
||||
|
||||
function buildTooltipContent(item: StatusCountItem): React.ReactNode {
|
||||
function buildTooltipContent(
|
||||
item: StatusCountItem,
|
||||
onSelectStatuses?: (statuses: string[]) => void,
|
||||
): React.ReactNode {
|
||||
const onClickHandle: MouseEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -57,14 +66,33 @@ function buildTooltipContent(item: StatusCountItem): React.ReactNode {
|
||||
<Typography.Text className={styles.tooltipHeader}>
|
||||
{item.label}
|
||||
</Typography.Text>
|
||||
{nonZeroBreakdown.map((b) => (
|
||||
<div key={b.label} className={styles.tooltipRow}>
|
||||
<Typography.Text>{b.label}</Typography.Text>
|
||||
<Typography.Text className={styles.tooltipValue}>
|
||||
{b.value}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
{nonZeroBreakdown.map((b) =>
|
||||
onSelectStatuses && b.statuses?.length ? (
|
||||
<button
|
||||
key={b.label}
|
||||
type="button"
|
||||
className={`${styles.tooltipRow} ${styles.tooltipRowSelectable}`}
|
||||
data-testid={`status-breakdown-${b.statuses.join('-')}`}
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelectStatuses(b.statuses as string[]);
|
||||
}}
|
||||
>
|
||||
<Typography.Text>{b.label}</Typography.Text>
|
||||
<Typography.Text className={styles.tooltipValue}>
|
||||
{b.value}
|
||||
</Typography.Text>
|
||||
</button>
|
||||
) : (
|
||||
<div key={b.label} className={styles.tooltipRow}>
|
||||
<Typography.Text>{b.label}</Typography.Text>
|
||||
<Typography.Text className={styles.tooltipValue}>
|
||||
{b.value}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,6 +101,7 @@ export function GroupedStatusCounts({
|
||||
items,
|
||||
rowId,
|
||||
showZeroValues = true,
|
||||
onSelectStatuses,
|
||||
}: GroupedStatusCountsProps): JSX.Element {
|
||||
const visibleItems =
|
||||
showZeroValues === false ? items.filter((item) => item.value > 0) : items;
|
||||
@@ -87,14 +116,30 @@ export function GroupedStatusCounts({
|
||||
<TanStackTable.HoverTooltip
|
||||
key={item.label}
|
||||
rowId={rowId}
|
||||
title={buildTooltipContent(item)}
|
||||
title={buildTooltipContent(item, onSelectStatuses)}
|
||||
arrow
|
||||
align="start"
|
||||
>
|
||||
{item.value ? (
|
||||
<TanStackTable.Text
|
||||
className={styles.item}
|
||||
className={`${styles.item} ${
|
||||
onSelectStatuses && item.statuses?.length ? styles.itemSelectable : ''
|
||||
}`}
|
||||
style={{ '--gsc-color': item.color } as React.CSSProperties}
|
||||
data-testid={
|
||||
item.statuses?.length
|
||||
? `status-count-${item.statuses.join('-')}`
|
||||
: undefined
|
||||
}
|
||||
onClick={
|
||||
onSelectStatuses && item.statuses?.length
|
||||
? (e: React.MouseEvent): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelectStatuses(item.statuses as string[]);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{item.value}
|
||||
</TanStackTable.Text>
|
||||
|
||||
@@ -971,6 +971,9 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
|
||||
EVENTS_FILTERS: 'eventsFilters',
|
||||
HOSTS_FILTERS: 'hostsFilters',
|
||||
STATUS_FILTER: 'statusFilter',
|
||||
POD_STATUS_FILTER: 'podStatus',
|
||||
NODE_READINESS_FILTER: 'nodeReadiness',
|
||||
CONTAINER_STATUS_FILTER: 'containerStatus',
|
||||
CURRENT_PAGE: 'currentPage',
|
||||
PAGE: 'page',
|
||||
PAGE_SIZE: 'pageSize',
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import {
|
||||
createParser,
|
||||
Options,
|
||||
parseAsArrayOf,
|
||||
parseAsInteger,
|
||||
parseAsJson,
|
||||
parseAsString,
|
||||
parseAsStringEnum,
|
||||
useQueryState,
|
||||
useQueryStates,
|
||||
UseQueryStateReturn,
|
||||
} from 'nuqs';
|
||||
import {
|
||||
InframonitoringtypesContainerStatusDTO,
|
||||
InframonitoringtypesNodeConditionDTO,
|
||||
InframonitoringtypesPodStatusDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
@@ -225,3 +232,58 @@ export const useInfraMonitoringStatusFilter = (): UseQueryStateReturn<
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.STATUS_FILTER,
|
||||
parseAsString.withDefault('').withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
/**
|
||||
* The list APIs reject the `no_data` sentinel (see IsFilterable in
|
||||
* pkg/types/inframonitoringtypes), so it is never offered as a filter value.
|
||||
*/
|
||||
function withoutNoData<T extends string>(values: T[]): T[] {
|
||||
return values.filter((value) => value !== 'no_data');
|
||||
}
|
||||
|
||||
export const FILTERABLE_POD_STATUSES = withoutNoData(
|
||||
Object.values(InframonitoringtypesPodStatusDTO),
|
||||
);
|
||||
|
||||
export const FILTERABLE_NODE_CONDITIONS = withoutNoData(
|
||||
Object.values(InframonitoringtypesNodeConditionDTO),
|
||||
);
|
||||
|
||||
export const FILTERABLE_CONTAINER_STATUSES = withoutNoData(
|
||||
Object.values(InframonitoringtypesContainerStatusDTO),
|
||||
);
|
||||
|
||||
const EMPTY_FILTER: [] = [];
|
||||
|
||||
export const useInfraMonitoringPodStatusFilter = (): UseQueryStateReturn<
|
||||
InframonitoringtypesPodStatusDTO[],
|
||||
[]
|
||||
> =>
|
||||
useQueryState(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.POD_STATUS_FILTER,
|
||||
parseAsArrayOf(parseAsStringEnum(FILTERABLE_POD_STATUSES))
|
||||
.withDefault(EMPTY_FILTER)
|
||||
.withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export const useInfraMonitoringNodeReadinessFilter = (): UseQueryStateReturn<
|
||||
InframonitoringtypesNodeConditionDTO[],
|
||||
[]
|
||||
> =>
|
||||
useQueryState(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.NODE_READINESS_FILTER,
|
||||
parseAsArrayOf(parseAsStringEnum(FILTERABLE_NODE_CONDITIONS))
|
||||
.withDefault(EMPTY_FILTER)
|
||||
.withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export const useInfraMonitoringContainerStatusFilter = (): UseQueryStateReturn<
|
||||
InframonitoringtypesContainerStatusDTO[],
|
||||
[]
|
||||
> =>
|
||||
useQueryState(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.CONTAINER_STATUS_FILTER,
|
||||
parseAsArrayOf(parseAsStringEnum(FILTERABLE_CONTAINER_STATUSES))
|
||||
.withDefault(EMPTY_FILTER)
|
||||
.withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { encode } from 'js-base64';
|
||||
import type { Tags } from 'hooks/useResourceAttribute/types';
|
||||
import { fireEvent, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
@@ -31,6 +32,9 @@ import {
|
||||
type ServiceHealth,
|
||||
} from './__story_mockdata__/serviceMap';
|
||||
|
||||
/** The keys are only fetched once the select opens, past the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const GRAPH = 'Service map · graph';
|
||||
const FILTERS = 'Service map · filters';
|
||||
|
||||
@@ -129,3 +133,80 @@ export const serviceMapMocks = defineStoryMocks({
|
||||
],
|
||||
config: (values) => ({ route: serviceMapRoute(values.filters) }),
|
||||
});
|
||||
|
||||
/** Opens the select under a test id; it closes again after every pick. */
|
||||
export const openSelect = async (
|
||||
canvasElement: HTMLElement,
|
||||
testId: string,
|
||||
): Promise<void> => {
|
||||
const select = await within(canvasElement).findByTestId(
|
||||
testId,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(within(select).getByRole('combobox'));
|
||||
};
|
||||
|
||||
export const OPEN_DROPDOWN =
|
||||
'.ant-select-dropdown:not(.ant-select-dropdown-hidden)';
|
||||
|
||||
/** antd keeps a hidden copy of each label for screen readers; the title skips it. */
|
||||
const visibleOption = (title: string): HTMLElement | null =>
|
||||
document.querySelector<HTMLElement>(
|
||||
`${OPEN_DROPDOWN} .ant-select-item-option[title="${title}"]`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Picks the option titled `title` in the open dropdown. `userEvent.click`
|
||||
* moves focus off the select on the way, which closes it before the option
|
||||
* takes the click.
|
||||
*/
|
||||
export const pickOption = async (title: string): Promise<void> => {
|
||||
const option = await waitFor(() => {
|
||||
const match = visibleOption(title);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`option "${title}" not found`);
|
||||
}
|
||||
|
||||
return match;
|
||||
}, untilLoaded);
|
||||
|
||||
await fireEvent.click(option);
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the attribute filter on its next step. Each single-choice pick closes
|
||||
* the dropdown and swaps the select for the next step's, and a click that lands
|
||||
* before the swap opens nothing, so it opens again until `title` shows.
|
||||
*/
|
||||
export const openAttributeFilterOn = (
|
||||
canvasElement: HTMLElement,
|
||||
title: string,
|
||||
): Promise<void> =>
|
||||
waitFor(
|
||||
async () => {
|
||||
if (visibleOption(title)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!document.querySelector(OPEN_DROPDOWN)) {
|
||||
await openSelect(canvasElement, 'resource-attributes-filter');
|
||||
}
|
||||
|
||||
throw new Error(`option "${title}" not shown`);
|
||||
},
|
||||
{ ...untilLoaded, interval: 500 },
|
||||
);
|
||||
|
||||
/** Stages `k8s.cluster.name IN` and leaves the filter open on its values. */
|
||||
export const stageClusterIn = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await openAttributeFilterOn(canvasElement, 'k8s.cluster.name');
|
||||
await pickOption('k8s.cluster.name');
|
||||
await openAttributeFilterOn(canvasElement, 'IN');
|
||||
await pickOption('IN');
|
||||
await openAttributeFilterOn(canvasElement, 'staging-eu');
|
||||
};
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { serviceMapMocks } from './ServiceMap.stories.mocks';
|
||||
import {
|
||||
OPEN_DROPDOWN,
|
||||
openAttributeFilterOn,
|
||||
openSelect,
|
||||
pickOption,
|
||||
serviceMapMocks,
|
||||
stageClusterIn,
|
||||
} from './ServiceMap.stories.mocks';
|
||||
|
||||
import ServiceMapContainer from '../index';
|
||||
|
||||
@@ -97,3 +104,49 @@ export const FilterAttributes: Story = {
|
||||
await screen.findByText('k8s.cluster.name', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** A key staged as a chip, the filter open again on how to match it. */
|
||||
export const FilterOperators: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openAttributeFilterOn(canvasElement, 'k8s.cluster.name');
|
||||
await pickOption('k8s.cluster.name');
|
||||
await openAttributeFilterOn(canvasElement, 'Not IN');
|
||||
},
|
||||
};
|
||||
|
||||
/** A key and `IN` staged, the filter open on the values the key holds. */
|
||||
export const FilterValues: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await stageClusterIn(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/** Two values ticked before the filter is left, which is what applies it. */
|
||||
export const FilterValuesSelected: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await stageClusterIn(canvasElement);
|
||||
await pickOption('prod-us-east');
|
||||
await pickOption('prod-eu-west');
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
document.querySelectorAll('.ant-select-item-option-selected'),
|
||||
).toHaveLength(2),
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The environment selector open on the environments the calls came from. */
|
||||
export const EnvironmentOptions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openSelect(canvasElement, 'resource-environment-filter');
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
document.querySelector(`${OPEN_DROPDOWN} .ant-select-item-option`),
|
||||
).not.toBeNull(),
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
*/
|
||||
|
||||
import { AI_API_PATH, setAIBackendUrl } from 'api/AIAPIInstance';
|
||||
import removeSessionStorage from 'api/browser/sessionstorage/remove';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { rest, type RequestHandler } from 'msw';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
@@ -270,3 +272,58 @@ export const longActionTooltipHandlers: RequestHandler[] = onBothBases(
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/** The recognizer `.storybook/preview-head.html` installs in place of the browser's. */
|
||||
interface StorySpeechRecognition {
|
||||
listening: {
|
||||
hear: (transcript: string) => void;
|
||||
onerror: ((event: { error: string }) => void) | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the composer's mic and waits for the recording to start. The composer
|
||||
* stays disabled until the thread has loaded.
|
||||
*/
|
||||
export const startVoiceInput = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
const mic = await waitFor(
|
||||
async () => {
|
||||
const button = canvas.getByRole('button', { name: /start voice input/i });
|
||||
|
||||
// Depending on its version, Button disables through `disabled` or `aria-disabled`.
|
||||
await expect(button).toBeEnabled();
|
||||
await expect(button).not.toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
return button;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await userEvent.click(mic);
|
||||
await canvas.findByRole('status', { name: /recording voice input/i });
|
||||
};
|
||||
|
||||
/** Speaks into the open recording: the words so far, still listening. */
|
||||
export const speak = (transcript: string): void => {
|
||||
(
|
||||
window as unknown as { webkitSpeechRecognition: StorySpeechRecognition }
|
||||
).webkitSpeechRecognition.listening?.hear(transcript);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears the flag `ChatInput` keeps in sessionStorage after a failed recording,
|
||||
* which hides the mic for the rest of the tab, test-runner's included.
|
||||
*/
|
||||
export const forgetVoiceFailure = (): void => {
|
||||
removeSessionStorage('ai-assistant-voice-unavailable');
|
||||
};
|
||||
|
||||
/** Fails the open recording the way the browser's recognizer reports it. */
|
||||
export const failVoiceInput = (error: 'network' | 'not-allowed'): void => {
|
||||
(
|
||||
window as unknown as { webkitSpeechRecognition: StorySpeechRecognition }
|
||||
).webkitSpeechRecognition.listening?.onerror?.({ error });
|
||||
};
|
||||
|
||||
@@ -9,7 +9,11 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import AIAssistantPage from '../AIAssistantPage';
|
||||
import {
|
||||
aiAssistantMocks,
|
||||
failVoiceInput,
|
||||
forgetVoiceFailure,
|
||||
longActionTooltipHandlers,
|
||||
speak,
|
||||
startVoiceInput,
|
||||
} from './AIAssistantPage.stories.mocks';
|
||||
import type { ThreadPart } from './__story_mockdata__/aiAssistant';
|
||||
|
||||
@@ -38,6 +42,7 @@ const meta = {
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
beforeEach: forgetVoiceFailure,
|
||||
} satisfies Meta<AIAssistantArgs>;
|
||||
|
||||
export default meta;
|
||||
@@ -47,11 +52,15 @@ type Story = StoryObj<AIAssistantArgs>;
|
||||
/** The thread list resolves before the thread does, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/** How long what a click opened has to stay on screen to count as open. */
|
||||
const HOLD_MS = 1_000;
|
||||
|
||||
/**
|
||||
* Click something, and keep clicking until what it opens is on screen. The
|
||||
* message list remounts its items while it measures a freshly loaded thread, so
|
||||
* a single click can land on a row that is about to be replaced, taking the
|
||||
* state it just set with it.
|
||||
* state it just set with it. The replacement can land after that state has
|
||||
* rendered, so it has to still be there `HOLD_MS` later.
|
||||
*/
|
||||
const clickUntil = async (
|
||||
find: () => Promise<HTMLElement>,
|
||||
@@ -60,6 +69,10 @@ const clickUntil = async (
|
||||
await waitFor(async () => {
|
||||
await userEvent.click(await find());
|
||||
await screen.findByText(opens, undefined, { timeout: 1_000 });
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, HOLD_MS);
|
||||
});
|
||||
screen.getByText(opens);
|
||||
}, untilLoaded);
|
||||
};
|
||||
|
||||
@@ -173,6 +186,36 @@ export const ApprovalDiff: Story = {
|
||||
play: openApprovalDiff,
|
||||
};
|
||||
|
||||
/**
|
||||
* Dictating a question: the mic listening, the words heard so far in the
|
||||
* composer, and the controls to discard them or stop and send.
|
||||
*/
|
||||
export const VoiceRecording: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await startVoiceInput(canvasElement);
|
||||
speak('why did checkout p99 jump after the 14:00 deploy');
|
||||
await within(canvasElement).findByDisplayValue(/checkout p99 jump/);
|
||||
},
|
||||
};
|
||||
|
||||
/** The recognizer reporting it cannot reach the speech service. */
|
||||
export const VoiceInputUnavailableToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await startVoiceInput(canvasElement);
|
||||
failVoiceInput('network');
|
||||
await screen.findByText(/voice input unavailable in this browser/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** The browser refusing the microphone. */
|
||||
export const MicrophoneDeniedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await startVoiceInput(canvasElement);
|
||||
failVoiceInput('not-allowed');
|
||||
await screen.findByText(/microphone access denied/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** The comment box a thumbs down opens, which a thumbs up does not. */
|
||||
export const NegativeFeedback: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
@@ -223,6 +266,23 @@ export const AddContext: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** "Copy link" on a conversation row, which toasts once the link is copied. */
|
||||
export const ConversationLinkCopiedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickUntil(async () => {
|
||||
const [actions] = await within(canvasElement).findAllByLabelText(
|
||||
/conversation actions/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
return actions;
|
||||
}, /copy link/i);
|
||||
await userEvent.click(await screen.findByText(/copy link/i));
|
||||
await screen.findByText(/conversation link copied to clipboard/i);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the thread carries, held open at once: the composer's voice and
|
||||
* send buttons, the sidebar's new conversation, the copy chip under each user
|
||||
@@ -251,3 +311,13 @@ export const TooltipsInApprovalDiff: Story = {
|
||||
export const BottomStrip: Story = {
|
||||
args: { bottomStrip: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* The recording controls' tooltips, held open: discard, stop and send, and the
|
||||
* send button beside them, which waits for words.
|
||||
*/
|
||||
export const TooltipsInVoiceRecording: Story = {
|
||||
args: { tooltipsOpen: true, contents: BRIEF },
|
||||
play: async ({ canvasElement }): Promise<void> =>
|
||||
startVoiceInput(canvasElement),
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
@@ -33,6 +34,9 @@ const STORY_RELATIVE_TIME = '6h';
|
||||
const RULE = 'Alert overview · rule';
|
||||
const PREVIEW = 'Alert overview · preview';
|
||||
|
||||
const TEST_NOTIFICATIONS = ['sent', 'no-alerts'] as const;
|
||||
type TestNotification = (typeof TEST_NOTIFICATIONS)[number];
|
||||
|
||||
export const alertOverviewMocks = defineStoryMocks({
|
||||
controls: {
|
||||
alertSchema: choiceControl<AlertSchema>('Alert schema', {
|
||||
@@ -66,6 +70,13 @@ export const alertOverviewMocks = defineStoryMocks({
|
||||
value: 3,
|
||||
max: 6,
|
||||
}),
|
||||
testNotification: choiceControl<TestNotification>('Test notification', {
|
||||
group: 'Alert overview · rule',
|
||||
description:
|
||||
'What the test run finds. `sent` raises the success toast, `no-alerts` the error toast saying the condition was not met.',
|
||||
options: TEST_NOTIFICATIONS,
|
||||
value: 'sent',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
@@ -96,7 +107,10 @@ export const alertOverviewMocks = defineStoryMocks({
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: { alertCount: 2, message: 'Rule tested against the last 6 hours' },
|
||||
data: {
|
||||
alertCount: values.testNotification === 'no-alerts' ? 0 : 2,
|
||||
message: 'Rule tested against the last 6 hours',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -146,3 +160,21 @@ export const alertOverviewMocks = defineStoryMocks({
|
||||
route: `/alerts/overview?ruleId=${STORY_RULE_ID}&relativeTime=${STORY_RELATIVE_TIME}`,
|
||||
}),
|
||||
});
|
||||
|
||||
export const clickFooterButton = async (
|
||||
canvasElement: HTMLElement,
|
||||
testId: string,
|
||||
): Promise<void> => {
|
||||
// The footer stays disabled, and remounts, until the rule has loaded.
|
||||
const button = await waitFor(
|
||||
() => {
|
||||
const element = within(canvasElement).getByTestId(testId);
|
||||
|
||||
expect(element).toBeEnabled();
|
||||
|
||||
return element;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await userEvent.click(button);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { alertOverviewMocks } from './AlertOverview.stories.mocks';
|
||||
import {
|
||||
alertOverviewMocks,
|
||||
clickFooterButton,
|
||||
} from './AlertOverview.stories.mocks';
|
||||
|
||||
import AlertList from '../../index';
|
||||
|
||||
@@ -75,3 +78,33 @@ export const AlertActionsMenu: Story = {
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
};
|
||||
|
||||
/** The rule saved unchanged from the footer: the toast raised once the PUT answers. */
|
||||
export const AlertRuleUpdatedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickFooterButton(canvasElement, 'save-alert-rule-button');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/alert rule updated successfully/i)).toBeVisible(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Test Notification from the footer. */
|
||||
export const TestNotificationSentToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await clickFooterButton(canvasElement, 'test-notification-button');
|
||||
if (args.testNotification === 'sent') {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/test notification sent successfully/i),
|
||||
).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Test Notification finding nothing to alert on: the error toast. */
|
||||
export const TestNotificationNoAlertsToast: Story = {
|
||||
args: { testNotification: 'no-alerts' },
|
||||
play: TestNotificationSentToast.play,
|
||||
};
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import { rest, type ResponseComposition, type RestContext } from 'msw';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
@@ -19,6 +20,34 @@ import {
|
||||
import { AlertListTabs } from '../../types';
|
||||
|
||||
const LIST = 'Alert rules · list';
|
||||
const ACTIONS = 'Alert rules · row actions';
|
||||
|
||||
const ACTION_STATES = ['success', 'loading', 'error'] as const;
|
||||
type ActionState = (typeof ACTION_STATES)[number];
|
||||
|
||||
const actionDescription = (call: string): string =>
|
||||
`How the ${call} behind the row action answers. \`loading\` never answers and keeps the promise toast pending, \`error\` settles it with the failure.`;
|
||||
|
||||
const actionResponse = (
|
||||
state: ActionState,
|
||||
res: ResponseComposition,
|
||||
ctx: RestContext,
|
||||
body: () => unknown,
|
||||
): ReturnType<ResponseComposition> => {
|
||||
if (state === 'loading') {
|
||||
return res(ctx.delay('infinite'));
|
||||
}
|
||||
|
||||
return state === 'error'
|
||||
? res(
|
||||
ctx.status(500),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: { code: 'internal', message: 'The server could not finish' },
|
||||
}),
|
||||
)
|
||||
: res(ctx.status(200), ctx.json(body()));
|
||||
};
|
||||
|
||||
export const alertRulesMocks = defineStoryMocks({
|
||||
controls: {
|
||||
@@ -41,6 +70,24 @@ export const alertRulesMocks = defineStoryMocks({
|
||||
options: RULE_STATE_CHOICES,
|
||||
value: 'mixed',
|
||||
}),
|
||||
toggleState: choiceControl<ActionState>('Enable or disable', {
|
||||
group: ACTIONS,
|
||||
description: actionDescription('PATCH'),
|
||||
options: ACTION_STATES,
|
||||
value: 'success',
|
||||
}),
|
||||
cloneState: choiceControl<ActionState>('Clone', {
|
||||
group: ACTIONS,
|
||||
description: actionDescription('POST'),
|
||||
options: ACTION_STATES,
|
||||
value: 'success',
|
||||
}),
|
||||
deleteState: choiceControl<ActionState>('Delete', {
|
||||
group: ACTIONS,
|
||||
description: actionDescription('DELETE'),
|
||||
options: ACTION_STATES,
|
||||
value: 'success',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
@@ -52,6 +99,47 @@ export const alertRulesMocks = defineStoryMocks({
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.patch('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
|
||||
actionResponse(values.toggleState, res, ctx, () => ({
|
||||
status: 'success',
|
||||
data: null,
|
||||
})),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v2/rules', (_req, res, ctx) =>
|
||||
actionResponse(values.cloneState, res, ctx, () => ({
|
||||
status: 'success',
|
||||
data: alertRulesResponse(1, {
|
||||
severity: values.ruleSeverity,
|
||||
state: values.ruleState,
|
||||
}).data?.[0],
|
||||
})),
|
||||
),
|
||||
|
||||
rest.delete('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
|
||||
actionResponse(values.deleteState, res, ctx, () => ({
|
||||
status: 'success',
|
||||
data: null,
|
||||
})),
|
||||
),
|
||||
],
|
||||
config: () => ({ route: `/alerts?tab=${AlertListTabs.ALERT_RULES}` }),
|
||||
});
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
export const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
export const runRowAction = async (
|
||||
canvasElement: HTMLElement,
|
||||
name: RegExp,
|
||||
): Promise<void> => {
|
||||
const [actions] = await within(canvasElement).findAllByTestId(
|
||||
'alert-actions',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(actions);
|
||||
await userEvent.click(await screen.findByRole('menuitem', { name }));
|
||||
};
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { alertRulesMocks } from './AlertRules.stories.mocks';
|
||||
import {
|
||||
alertRulesMocks,
|
||||
runRowAction,
|
||||
untilLoaded,
|
||||
} from './AlertRules.stories.mocks';
|
||||
|
||||
import AlertList from '../../index';
|
||||
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
|
||||
@@ -31,9 +35,6 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<AlertRulesArgs>;
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* Every alert rule the org has configured, with the state each one evaluated to
|
||||
* on its last run and the severity it fires at.
|
||||
@@ -149,3 +150,95 @@ export const BottomStripPaginated: Story = {
|
||||
signoz: { route: '/alerts?tab=AlertRules&page=2&limit=10' },
|
||||
},
|
||||
};
|
||||
|
||||
/** Enable or Disable from the row menu: the promise toast, pending then settled. */
|
||||
export const ToggleAlertToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await runRowAction(canvasElement, /^(enable|disable)$/i);
|
||||
if (args.toggleState === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/alert (enabled|disabled)/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** The PATCH never answers: the "Enabling or Disabling alert..." toast stays pending. */
|
||||
export const ToggleAlertLoadingToast: Story = {
|
||||
args: { toggleState: 'loading' },
|
||||
play: ToggleAlertToast.play,
|
||||
};
|
||||
|
||||
/** The PATCH failed: the promise toast settles with the server message. */
|
||||
export const ToggleAlertErrorToast: Story = {
|
||||
args: { toggleState: 'error' },
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: ToggleAlertToast.play,
|
||||
};
|
||||
|
||||
/** Clone from the row menu. */
|
||||
export const CloneAlertToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await runRowAction(canvasElement, /^clone$/i);
|
||||
if (args.cloneState === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/alert cloned successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** The POST never answers: "Cloning alert..." stays pending. */
|
||||
export const CloneAlertLoadingToast: Story = {
|
||||
args: { cloneState: 'loading' },
|
||||
play: CloneAlertToast.play,
|
||||
};
|
||||
|
||||
/** The POST failed. */
|
||||
export const CloneAlertErrorToast: Story = {
|
||||
args: { cloneState: 'error' },
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: CloneAlertToast.play,
|
||||
};
|
||||
|
||||
/** Delete from the row menu. */
|
||||
export const DeleteAlertToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await runRowAction(canvasElement, /^delete$/i);
|
||||
if (args.deleteState === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/alert deleted successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** The DELETE never answers: "Deleting alert..." stays pending. */
|
||||
export const DeleteAlertLoadingToast: Story = {
|
||||
args: { deleteState: 'loading' },
|
||||
play: DeleteAlertToast.play,
|
||||
};
|
||||
|
||||
/** The DELETE failed. */
|
||||
export const DeleteAlertErrorToast: Story = {
|
||||
args: { deleteState: 'error' },
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: DeleteAlertToast.play,
|
||||
};
|
||||
|
||||
/** The copy button in a label badge's tooltip, clicked. */
|
||||
export const LabelCopiedToast: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: async (): Promise<void> => {
|
||||
const [copy] = await screen.findAllByRole(
|
||||
'button',
|
||||
{ name: 'Copy to clipboard' },
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(copy);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import { userEvent, within } from 'storybook/test';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
@@ -11,8 +12,9 @@ import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import {
|
||||
DOWNTIME_KINDS,
|
||||
DOWNTIME_MAX,
|
||||
downtimeSchedulesResponse,
|
||||
type DowntimeKind,
|
||||
downtimeSchedulesResponse,
|
||||
FIRST_DOWNTIME_NAME,
|
||||
} from './__story_mockdata__/plannedDowntime';
|
||||
|
||||
import {
|
||||
@@ -101,3 +103,28 @@ export const plannedDowntimeMocks = defineStoryMocks({
|
||||
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.PLANNED_DOWNTIME}`,
|
||||
}),
|
||||
});
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
export const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
export const clickFirstRowAction = async (
|
||||
canvasElement: HTMLElement,
|
||||
action: 'edit' | 'delete',
|
||||
): Promise<void> => {
|
||||
const icon = (
|
||||
await within(canvasElement).findByText(
|
||||
FIRST_DOWNTIME_NAME,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
)
|
||||
)
|
||||
.closest('.header-content')
|
||||
// The row action holds edit then delete, neither of them labelled.
|
||||
?.querySelectorAll('.action-btn svg')[action === 'edit' ? 0 : 1];
|
||||
|
||||
if (!icon) {
|
||||
throw new Error(`Downtime ${action} action did not render`);
|
||||
}
|
||||
|
||||
await userEvent.click(icon);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,11 @@ import { screen, userEvent, within } from 'storybook/test';
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { plannedDowntimeMocks } from './PlannedDowntime.stories.mocks';
|
||||
import {
|
||||
clickFirstRowAction,
|
||||
plannedDowntimeMocks,
|
||||
untilLoaded,
|
||||
} from './PlannedDowntime.stories.mocks';
|
||||
import { FIRST_DOWNTIME_NAME } from './__story_mockdata__/plannedDowntime';
|
||||
|
||||
import AlertList from '../../index';
|
||||
@@ -31,9 +35,6 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<PlannedDowntimeArgs>;
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* The windows where alerting is held back: what is running now, what is
|
||||
* scheduled, and which rules each one silences.
|
||||
@@ -82,27 +83,36 @@ export const NewDowntime: Story = {
|
||||
/** The deletion confirmation opened from the first schedule's real row action. */
|
||||
export const DeleteDowntimeConfirm: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const action = (
|
||||
await within(canvasElement).findByText(
|
||||
FIRST_DOWNTIME_NAME,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
)
|
||||
)
|
||||
.closest('.header-content')
|
||||
// The row action holds edit then delete, neither of them labelled.
|
||||
?.querySelectorAll('.action-btn svg')[1];
|
||||
|
||||
if (!action) {
|
||||
throw new Error('Downtime delete action did not render');
|
||||
}
|
||||
|
||||
await userEvent.click(action);
|
||||
await clickFirstRowAction(canvasElement, 'delete');
|
||||
// The modal titles itself and its confirm button the same.
|
||||
await screen.findByRole('button', { name: 'Delete Schedule' });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The first schedule opened for editing: the specific rules it silences sit
|
||||
* above the rule picker, each with its own remove button.
|
||||
*/
|
||||
export const EditDowntime: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickFirstRowAction(canvasElement, 'edit');
|
||||
await screen.findByText(/edit planned downtime/i);
|
||||
await screen.findByText(/^Postgres connections/);
|
||||
},
|
||||
};
|
||||
|
||||
/** The new-downtime form set to repeat weekly, which adds the days and duration. */
|
||||
export const NewDowntimeRecurring: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await NewDowntime.play?.({ canvasElement } as never);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Repeats every' }),
|
||||
);
|
||||
await userEvent.click(await screen.findByText('Weekly'));
|
||||
await screen.findByText('Duration');
|
||||
},
|
||||
};
|
||||
|
||||
/** A client-side search with no matching downtime schedule. */
|
||||
export const SearchNoResults: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
@@ -26,6 +27,18 @@ const REQUEST = 'Routing policies · requests';
|
||||
const REQUEST_STATES = ['loaded', 'error'] as const;
|
||||
type RequestState = (typeof REQUEST_STATES)[number];
|
||||
|
||||
const MUTATION_STATES = ['success', 'error'] as const;
|
||||
type MutationState = (typeof MUTATION_STATES)[number];
|
||||
|
||||
const MUTATION = 'Routing policies · mutations';
|
||||
|
||||
const mutationError = (
|
||||
message: string,
|
||||
): { status: string; error: { code: string; message: string } } => ({
|
||||
status: 'error',
|
||||
error: { code: 'invalid_input', message },
|
||||
});
|
||||
|
||||
export const routingPoliciesMocks = defineStoryMocks({
|
||||
controls: {
|
||||
policies: countControl('Routing policies', {
|
||||
@@ -51,6 +64,27 @@ export const routingPoliciesMocks = defineStoryMocks({
|
||||
options: REQUEST_STATES,
|
||||
value: 'loaded',
|
||||
}),
|
||||
createState: choiceControl<MutationState>('Create policy', {
|
||||
group: MUTATION,
|
||||
description:
|
||||
'How the POST behind "Save Routing Policy" answers. `error` raises the error toast.',
|
||||
options: MUTATION_STATES,
|
||||
value: 'success',
|
||||
}),
|
||||
updateState: choiceControl<MutationState>('Update policy', {
|
||||
group: MUTATION,
|
||||
description:
|
||||
'How the PUT behind "Save Routing Policy" answers when editing. `error` raises the error toast.',
|
||||
options: MUTATION_STATES,
|
||||
value: 'success',
|
||||
}),
|
||||
deleteState: choiceControl<MutationState>('Delete policy', {
|
||||
group: MUTATION,
|
||||
description:
|
||||
'How the DELETE behind the confirmation answers. `error` raises the error toast.',
|
||||
options: MUTATION_STATES,
|
||||
value: 'success',
|
||||
}),
|
||||
},
|
||||
handlers: (values, _response) => [
|
||||
rest.get('http://localhost/api/v1/route_policies', (_req, res, ctx) =>
|
||||
@@ -64,15 +98,24 @@ export const routingPoliciesMocks = defineStoryMocks({
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v1/route_policies', (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ status: 'success', data: null })),
|
||||
values.createState === 'error'
|
||||
? res(
|
||||
ctx.status(400),
|
||||
ctx.json(mutationError('Policy name already exists')),
|
||||
)
|
||||
: res(ctx.status(201), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v1/route_policies/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
values.updateState === 'error'
|
||||
? res(ctx.status(500), ctx.json(mutationError('Could not update')))
|
||||
: res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.delete('http://localhost/api/v1/route_policies/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
values.deleteState === 'error'
|
||||
? res(ctx.status(500), ctx.json(mutationError('Could not delete')))
|
||||
: res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/channels', (_req, res, ctx) =>
|
||||
@@ -85,3 +128,40 @@ export const routingPoliciesMocks = defineStoryMocks({
|
||||
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.ROUTING_POLICIES}`,
|
||||
}),
|
||||
});
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
export const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
export const fillNewPolicy = async (): Promise<void> => {
|
||||
await userEvent.type(
|
||||
await screen.findByPlaceholderText('e.g. Base routing policy...'),
|
||||
'Toast policy',
|
||||
);
|
||||
await userEvent.type(
|
||||
await screen.findByPlaceholderText(/e\.g\. service\.name/),
|
||||
'severity = "critical"',
|
||||
);
|
||||
await userEvent.click(await screen.findByRole('combobox'));
|
||||
await userEvent.click(await screen.findByTitle('ops-slack'));
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Save Routing Policy' }),
|
||||
);
|
||||
};
|
||||
|
||||
export const openEditPolicy = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await userEvent.click(
|
||||
(
|
||||
await within(canvasElement).findAllByTestId(
|
||||
'edit-routing-policy',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
)
|
||||
)[0],
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Save Routing Policy' }),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { routingPoliciesMocks } from './RoutingPolicies.stories.mocks';
|
||||
import {
|
||||
fillNewPolicy,
|
||||
openEditPolicy,
|
||||
routingPoliciesMocks,
|
||||
untilLoaded,
|
||||
} from './RoutingPolicies.stories.mocks';
|
||||
import { FIRST_POLICY_NAME } from './__story_mockdata__/routingPolicies';
|
||||
|
||||
import AlertList from '../../index';
|
||||
@@ -31,9 +36,6 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<RoutingPoliciesArgs>;
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* The rules that decide which channel an alert reaches, matched on the labels
|
||||
* the alert carries.
|
||||
@@ -137,3 +139,74 @@ export const FormValidationError: Story = {
|
||||
await screen.findByText('Please provide a name for the routing policy');
|
||||
},
|
||||
};
|
||||
|
||||
const confirmDelete = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
await DeletePolicyConfirm.play?.({ canvasElement } as never);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Delete Routing Policy' }),
|
||||
);
|
||||
};
|
||||
|
||||
/** A new policy saved: the toast raised once the POST answers. */
|
||||
export const CreatePolicyToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await NewPolicy.play?.({ canvasElement } as never);
|
||||
await fillNewPolicy();
|
||||
if (args.createState === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/routing policy created successfully/i),
|
||||
).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** The POST failed: the error toast carries the server message. */
|
||||
export const CreatePolicyErrorToast: Story = {
|
||||
args: { createState: 'error' },
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: CreatePolicyToast.play,
|
||||
};
|
||||
|
||||
/** An existing policy saved unchanged from its edit form. */
|
||||
export const UpdatePolicyToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await openEditPolicy(canvasElement);
|
||||
if (args.updateState === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/routing policy updated successfully/i),
|
||||
).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** The PUT failed. */
|
||||
export const UpdatePolicyErrorToast: Story = {
|
||||
args: { updateState: 'error' },
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: UpdatePolicyToast.play,
|
||||
};
|
||||
|
||||
/** The first policy deleted from its confirmation. */
|
||||
export const DeletePolicyToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await confirmDelete(canvasElement);
|
||||
if (args.deleteState === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/routing policy deleted successfully/i),
|
||||
).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** The DELETE failed. */
|
||||
export const DeletePolicyErrorToast: Story = {
|
||||
args: { deleteState: 'error' },
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: DeletePolicyToast.play,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import {
|
||||
expect,
|
||||
fireEvent,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -148,3 +155,23 @@ export const Tooltips: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
parameters: { msw: { handlers: [overflowingLabels] } },
|
||||
};
|
||||
|
||||
/** The copy button in the overflow chip's tooltip, clicked. */
|
||||
export const OverflowLabelsCopiedToast: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
parameters: { msw: { handlers: [overflowingLabels] } },
|
||||
play: async (): Promise<void> => {
|
||||
// The overflow tooltips render after the label ones, and theirs come last.
|
||||
await screen.findAllByText(/^ruleId: /, undefined, { timeout: 15_000 });
|
||||
const copyButtons = screen.getAllByRole('button', {
|
||||
name: 'Copy to clipboard',
|
||||
});
|
||||
|
||||
// A pointerdown re-renders the held-open tooltip under the pointer, so the
|
||||
// click is dispatched alone.
|
||||
await fireEvent.click(copyButtons[copyButtons.length - 1]);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,9 +6,19 @@
|
||||
import { rest } from 'msw';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { countControl, toggleControl } from '@/storybook/controls/controls';
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import {
|
||||
RESPONSE_STATES,
|
||||
type ResponseState,
|
||||
respondWith,
|
||||
} from '@/storybook/runtime/responseState';
|
||||
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
|
||||
import {
|
||||
@@ -23,6 +33,9 @@ import {
|
||||
type ListErrorsBody,
|
||||
} from './__story_mockdata__/exceptions';
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const LIST = 'Exceptions · list';
|
||||
const FILTERS = 'Exceptions · filters';
|
||||
|
||||
@@ -42,6 +55,13 @@ export const exceptionsMocks = defineStoryMocks({
|
||||
value: 6,
|
||||
max: EXCEPTION_QUICK_FILTER_CAP,
|
||||
}),
|
||||
filterKeys: choiceControl<ResponseState>('Filter keys', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
'How `/autocomplete/attribute_keys` answers when the resource filter opens, apart from the page-wide Data control.',
|
||||
options: RESPONSE_STATES,
|
||||
value: 'loaded',
|
||||
}),
|
||||
filterPanel: toggleControl('Quick filters panel', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
@@ -92,7 +112,7 @@ export const exceptionsMocks = defineStoryMocks({
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_keys',
|
||||
response.json((req) =>
|
||||
respondWith(values.filterKeys, (req) =>
|
||||
exceptionAttributeKeysResponse(req.url.searchParams.get('searchText')),
|
||||
),
|
||||
),
|
||||
@@ -111,3 +131,61 @@ export const exceptionsMocks = defineStoryMocks({
|
||||
set(LOCALSTORAGE.SHOW_EXCEPTIONS_QUICK_FILTERS, String(values.filterPanel));
|
||||
},
|
||||
});
|
||||
|
||||
export const openResourceFilter = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<HTMLElement> => {
|
||||
const filter = await within(canvasElement).findByTestId(
|
||||
'qb-search-select',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(within(filter).getByRole('combobox'));
|
||||
|
||||
return filter;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clicks the visible row whose label is `text`. The dropdown renders in the
|
||||
* body, a key row carries its type beside the label, and antd keeps a hidden
|
||||
* copy of each label for screen readers that takes no clicks.
|
||||
*
|
||||
* The key list renders twice after the filter opens, since a second key
|
||||
* request empties it until it answers. The click happens in the same task as
|
||||
* the lookup: `fireEvent` from `storybook/test` dispatches a tick later, which
|
||||
* can land on a row already removed and never reach React.
|
||||
*/
|
||||
export const pickSuggestion = async (text: string): Promise<void> => {
|
||||
await waitFor(() => {
|
||||
const match = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'.query-builder-search.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
|
||||
),
|
||||
).find((option) =>
|
||||
Array.from(option.querySelectorAll('*')).some(
|
||||
(node) => node.children.length === 0 && node.textContent === text,
|
||||
),
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`suggestion "${text}" not found`);
|
||||
}
|
||||
|
||||
// `userEvent.click` moves focus off the search input on the way, which closes
|
||||
// the dropdown before the row takes the click.
|
||||
match.click();
|
||||
}, untilLoaded);
|
||||
};
|
||||
|
||||
export const commitFilter = async (
|
||||
key: string,
|
||||
operator: string,
|
||||
value: string,
|
||||
): Promise<void> => {
|
||||
await pickSuggestion(key);
|
||||
await screen.findByText('Operator for', { exact: false }, untilLoaded);
|
||||
await pickSuggestion(operator);
|
||||
await screen.findByText('Value(s) for', { exact: false }, untilLoaded);
|
||||
await pickSuggestion(value);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,12 @@ import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { exceptionsMocks } from './AllErrors.stories.mocks';
|
||||
import {
|
||||
commitFilter,
|
||||
exceptionsMocks,
|
||||
openResourceFilter,
|
||||
pickSuggestion,
|
||||
} from './AllErrors.stories.mocks';
|
||||
import AllErrors from '../index';
|
||||
|
||||
type AllErrorsArgs = PageStoryArgs<typeof exceptionsMocks>;
|
||||
@@ -133,3 +138,108 @@ export const QuickFiltersSettingsWithBanner: Story = {
|
||||
export const BottomStrip: Story = {
|
||||
args: { bottomStrip: true },
|
||||
};
|
||||
|
||||
/** The resource filter opened: every key the exceptions can be narrowed by. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await screen.findByText('Suggested Filters', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** The key list grown past its first rows with the Show all shortcut. */
|
||||
export const FilterAllKeys: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await screen.findByText('Show all filter items', undefined, untilLoaded);
|
||||
await userEvent.keyboard('{Control>}/{/Control}');
|
||||
await screen.findByText('cloud.region', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** A partial key: the typed text as a free search, then the keys that match. */
|
||||
export const FilterPartialKey: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const filter = await openResourceFilter(canvasElement);
|
||||
|
||||
await userEvent.type(within(filter).getByRole('combobox'), 'serv');
|
||||
await screen.findByText('service.namespace', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** A key picked: the operators the exceptions page allows for it. */
|
||||
export const FilterOperatorSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await pickSuggestion('service.name');
|
||||
await screen.findByText('Operator for', { exact: false }, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** A key and an operator picked: the values the key holds. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await pickSuggestion('service.name');
|
||||
await pickSuggestion('=');
|
||||
await screen.findByText('Value(s) for', { exact: false }, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** Two conditions committed as chips, with the dropdown closed again. */
|
||||
export const FilterChips: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await commitFilter('service.name', '=', 'checkout');
|
||||
await commitFilter('deployment.environment', '!=', 'staging');
|
||||
await userEvent.click(canvasElement.ownerDocument.body);
|
||||
await within(canvasElement).findByText(
|
||||
'deployment.environment != staging',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A committed chip clicked to change it: its text goes back into the input,
|
||||
* with the dropdown shut until the input is typed into.
|
||||
*/
|
||||
export const FilterEditChip: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await commitFilter('service.name', '=', 'checkout');
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
'service.name = checkout',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
// The select remounts whenever its chips change, so it is looked up again.
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
within(within(canvasElement).getByTestId('qb-search-select')).getByRole(
|
||||
'combobox',
|
||||
),
|
||||
).toHaveValue('service.name = checkout'),
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The key list while its request is still in flight. */
|
||||
export const FilterKeysLoading: Story = {
|
||||
args: { filterKeys: 'loading' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
document.querySelector('.query-builder-search .ant-spin'),
|
||||
).not.toBeNull(),
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { rest } from 'msw';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
@@ -76,6 +77,9 @@ const routeFor = (mode: AlertMode): string => {
|
||||
|
||||
const FORM = 'Create alert · form';
|
||||
|
||||
const TEST_NOTIFICATIONS = ['sent', 'no-alerts'] as const;
|
||||
type TestNotification = (typeof TEST_NOTIFICATIONS)[number];
|
||||
|
||||
export const createAlertMocks = defineStoryMocks({
|
||||
controls: {
|
||||
alertMode: choiceControl<AlertMode>('Alert being created', {
|
||||
@@ -96,6 +100,13 @@ export const createAlertMocks = defineStoryMocks({
|
||||
value: 3,
|
||||
max: 6,
|
||||
}),
|
||||
testNotification: choiceControl<TestNotification>('Test notification', {
|
||||
group: 'Create alert · form',
|
||||
description:
|
||||
'What the test run finds. `sent` raises the success toast, `no-alerts` the error toast saying the condition was not met.',
|
||||
options: TEST_NOTIFICATIONS,
|
||||
value: 'sent',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.post('http://localhost/api/v2/rules', (_req, res, ctx) =>
|
||||
@@ -107,7 +118,10 @@ export const createAlertMocks = defineStoryMocks({
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: { alertCount: 2, message: 'Rule tested against the last 6 hours' },
|
||||
data: {
|
||||
alertCount: values.testNotification === 'no-alerts' ? 0 : 2,
|
||||
message: 'Rule tested against the last 6 hours',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -155,3 +169,35 @@ export const createAlertMocks = defineStoryMocks({
|
||||
],
|
||||
config: (values) => ({ route: routeFor(values.alertMode) }),
|
||||
});
|
||||
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
export const fillValidAlert = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.type(
|
||||
await canvas.findByTestId('alert-name-input', undefined, untilLoaded),
|
||||
'Toast alert',
|
||||
);
|
||||
const channels = await canvas.findByTestId(
|
||||
'threshold-notification-channel-select',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(within(channels).getByRole('combobox'));
|
||||
await userEvent.click(await screen.findByTitle('ops-slack'));
|
||||
await userEvent.keyboard('{Escape}');
|
||||
};
|
||||
|
||||
export const clickFooterButton = async (
|
||||
canvasElement: HTMLElement,
|
||||
testId: string,
|
||||
): Promise<void> => {
|
||||
const button = await within(canvasElement).findByTestId(testId);
|
||||
|
||||
await waitFor(() => expect(button).toBeEnabled());
|
||||
await userEvent.click(button);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { createAlertMocks } from './CreateAlert.stories.mocks';
|
||||
import {
|
||||
clickFooterButton,
|
||||
createAlertMocks,
|
||||
fillValidAlert,
|
||||
} from './CreateAlert.stories.mocks';
|
||||
|
||||
import CreateAlertPage from '../index';
|
||||
|
||||
@@ -87,3 +91,35 @@ export const Tooltips: Story = {
|
||||
await screen.findByText('Example:', undefined, { timeout: 15_000 });
|
||||
},
|
||||
};
|
||||
|
||||
/** A valid rule saved from the footer: the toast raised once the POST answers. */
|
||||
export const AlertRuleCreatedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await fillValidAlert(canvasElement);
|
||||
await clickFooterButton(canvasElement, 'save-alert-rule-button');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/alert rule created successfully/i)).toBeVisible(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Test Notification on a rule whose condition fires: the success toast. */
|
||||
export const TestNotificationSentToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await fillValidAlert(canvasElement);
|
||||
await clickFooterButton(canvasElement, 'test-notification-button');
|
||||
if (args.testNotification === 'sent') {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/test notification sent successfully/i),
|
||||
).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Test Notification on a rule that finds nothing to alert on: the error toast. */
|
||||
export const TestNotificationNoAlertsToast: Story = {
|
||||
args: { testNotification: 'no-alerts' },
|
||||
play: TestNotificationSentToast.play,
|
||||
};
|
||||
|
||||
@@ -67,16 +67,6 @@
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
// The values behind a pill's `+N`, one bullet each, width-capped so a long value
|
||||
// wraps instead of stretching the tooltip off-screen.
|
||||
.overflowValues {
|
||||
max-width: 360px;
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
list-style: disc outside;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
// Shared by the Text and value selectors: strips the antd control chrome so the
|
||||
// selector blends into the variable pill.
|
||||
.control {
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { VariableSelection } from '../../selectionTypes';
|
||||
import { dynamicVariableOptions } from '../../utils/dynamicVariableOptions';
|
||||
import { areSelectionsEqual } from '../../utils/resolveVariableSelection';
|
||||
import { selectionFromCommittedValues } from '../../utils/selectionUtils';
|
||||
import OverflowValuesTooltip from './OverflowValuesTooltip';
|
||||
import OverflowValuesTooltip from 'components/OverflowValuesTooltip/OverflowValuesTooltip';
|
||||
import styles from '../../VariablesBar.module.scss';
|
||||
|
||||
interface ValueSelectorProps {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { rest } from 'msw';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import { choiceControl, toggleControl } from '@/storybook/controls/controls';
|
||||
@@ -23,6 +24,11 @@ import {
|
||||
NEW_PANEL_ID,
|
||||
newPanelSearch,
|
||||
} from '../../DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import {
|
||||
API_RESULTS,
|
||||
settle,
|
||||
type ApiResult,
|
||||
} from '../../stories/__story_mockdata__/settled';
|
||||
import {
|
||||
currentDashboardDocument,
|
||||
PANEL_IDS,
|
||||
@@ -86,6 +92,13 @@ export const panelEditorMocks = defineStoryMocks({
|
||||
'A locked dashboard is read-only, so the editor loads but Save is refused with the reason.',
|
||||
value: false,
|
||||
}),
|
||||
panelSave: choiceControl<ApiResult>('Panel save', {
|
||||
group: PANEL,
|
||||
description:
|
||||
'How the JSON Patch behind Save changes answers. `success` raises the "Panel saved" toast, `loading` never answers and leaves the button spinning.',
|
||||
options: API_RESULTS,
|
||||
value: 'success',
|
||||
}),
|
||||
noData: toggleControl('Preview returns nothing', {
|
||||
group: DATA,
|
||||
description: 'The preview query answers with an empty result.',
|
||||
@@ -109,18 +122,14 @@ export const panelEditorMocks = defineStoryMocks({
|
||||
|
||||
// Saving the panel is a JSON Patch whose response replaces the cache, so
|
||||
// the ops are applied to the story's document and the edit stays.
|
||||
rest.patch(
|
||||
'http://localhost/api/v2/dashboards/:id',
|
||||
async (req, res, ctx) => {
|
||||
rest.patch('http://localhost/api/v2/dashboards/:id', (req, res, ctx) =>
|
||||
settle(values.panelSave, req, res, ctx, async () => {
|
||||
const ops = (await req.json()) as Parameters<
|
||||
typeof patchDashboardDocument
|
||||
>[1];
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(patchDashboardDocument(document, ops)),
|
||||
);
|
||||
},
|
||||
return patchDashboardDocument(document, ops);
|
||||
}),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
@@ -203,3 +212,85 @@ export const panelEditorMocks = defineStoryMocks({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const savePanel = async (): Promise<void> => {
|
||||
const save = await screen.findByTestId(
|
||||
'panel-editor-v2-save',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(save).toBeEnabled(), { timeout: 10000 });
|
||||
await userEvent.click(save);
|
||||
};
|
||||
|
||||
// The editor resolves its panel from the dashboard document before it renders.
|
||||
const PAGE_LOAD = { timeout: 10000 };
|
||||
|
||||
/**
|
||||
* Opens a display-options section and scrolls it to the top of the pane, where
|
||||
* a screenshot of the viewport can see it. Returns the section to query in.
|
||||
*/
|
||||
export const expandSection = async (title: string): Promise<HTMLElement> => {
|
||||
// The toggle's test id is the title, lowercased, with whitespace as dashes.
|
||||
const toggle = await screen.findByTestId(
|
||||
`config-section-${title.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
{},
|
||||
PAGE_LOAD,
|
||||
);
|
||||
|
||||
if (toggle.getAttribute('aria-expanded') !== 'true') {
|
||||
await userEvent.click(toggle);
|
||||
}
|
||||
toggle.scrollIntoView({ block: 'start' });
|
||||
|
||||
return toggle.closest('section') ?? document.body;
|
||||
};
|
||||
|
||||
/** Picks one tile of a radio-tile control by its value. */
|
||||
export const pickSegment = async (
|
||||
testId: string,
|
||||
value: string,
|
||||
): Promise<void> => {
|
||||
const radio = (): HTMLElement =>
|
||||
within(screen.getByTestId(`${testId}-${value}`)).getByRole('radio');
|
||||
|
||||
// A pick made while the editor still seeds its draft from the panel is
|
||||
// dropped, so it is made again until it holds.
|
||||
await waitFor(async () => {
|
||||
if (!(radio() as HTMLInputElement).checked) {
|
||||
await userEvent.click(radio());
|
||||
}
|
||||
await expect(radio()).toBeChecked();
|
||||
}, PAGE_LOAD);
|
||||
};
|
||||
|
||||
/**
|
||||
* The pane's pickers are antd Selects: the element carrying the test id does
|
||||
* nothing on click, the combobox inside it is what opens the list.
|
||||
*/
|
||||
export const openConfigSelect = async (testId: string): Promise<void> => {
|
||||
const select = await screen.findByTestId(testId, {}, PAGE_LOAD);
|
||||
|
||||
await userEvent.click(within(select).getByRole('combobox'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens Thresholds and adds one row through the section's own button, whose
|
||||
* test id depends on the panel kind.
|
||||
*/
|
||||
export const addThreshold = async (
|
||||
testId = 'panel-editor-v2-add-threshold',
|
||||
): Promise<void> => {
|
||||
const section = await expandSection('Thresholds');
|
||||
|
||||
await userEvent.click(within(section).getByTestId(testId));
|
||||
};
|
||||
|
||||
export const openLinkDialog = async (): Promise<void> => {
|
||||
const section = await expandSection('Context Links');
|
||||
|
||||
// The header's quick add carries the same name, so the body button goes by id.
|
||||
await userEvent.click(within(section).getByTestId('panel-editor-v2-add-link'));
|
||||
await screen.findByTestId('context-link-dialog');
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { screen, userEvent, within } from 'storybook/test';
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { panelEditorMocks } from './PanelEditorPage.stories.mocks';
|
||||
import { panelEditorMocks, savePanel } from './PanelEditorPage.stories.mocks';
|
||||
|
||||
import PanelEditorPage from '../PanelEditorPage';
|
||||
|
||||
@@ -22,8 +22,8 @@ const pageStory = storyMocks(panelEditorMocks, { layout: 'app' });
|
||||
* Route: `/dashboard/:dashboardId/panel/:panelId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Panel Editor',
|
||||
tags: ['play'],
|
||||
title: 'Pages/Dashboards/Panel Editor/Overview',
|
||||
tags: ['authz', 'play'],
|
||||
// The page is wrapped in `withAuthZPage`, which types its props as an index
|
||||
// signature; the story's args are what the controls resolve to.
|
||||
component: PanelEditorPage as ComponentType<PanelEditorArgs>,
|
||||
@@ -70,11 +70,39 @@ export const NewPanel: Story = {
|
||||
args: { panel: 'new' },
|
||||
};
|
||||
|
||||
/** A number panel: one value, formatted, with comparison thresholds. */
|
||||
export const NumberPanel: Story = {
|
||||
args: { panel: 'p99-latency' },
|
||||
};
|
||||
|
||||
/** A bar chart, whose Visualization section offers stacking. */
|
||||
export const BarChartPanel: Story = {
|
||||
args: { panel: 'errors-by-status' },
|
||||
};
|
||||
|
||||
/** A pie chart, with a legend per slice and no axes. */
|
||||
export const PieChartPanel: Story = {
|
||||
args: { panel: 'traffic-share' },
|
||||
};
|
||||
|
||||
/** A list panel, where the config pane is the column editor. */
|
||||
export const ListPanel: Story = {
|
||||
args: { panel: 'recent-logs' },
|
||||
};
|
||||
|
||||
/** Interaction: the list panel's add column menu, searching field keys. */
|
||||
export const ListPanelAddColumnOpen: Story = {
|
||||
args: { panel: 'recent-logs' },
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('list-columns-add', undefined, {
|
||||
timeout: 10_000,
|
||||
}),
|
||||
);
|
||||
await screen.findAllByTestId('list-columns-suggestion');
|
||||
},
|
||||
};
|
||||
|
||||
/** A table panel, with its column units and thresholds. */
|
||||
export const TablePanel: Story = {
|
||||
args: { panel: 'top-endpoints' },
|
||||
@@ -201,3 +229,23 @@ export const ConfigChanged: Story = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Save changes on a saved panel: the toast raised once the patch answers,
|
||||
* before the editor hands back to the dashboard. Set Panel save to `loading`
|
||||
* to see the button spin instead.
|
||||
*/
|
||||
export const PanelSavedToast: Story = {
|
||||
play: async ({ args }): Promise<void> => {
|
||||
await savePanel();
|
||||
if (args.panelSave === 'success') {
|
||||
await screen.findByText('Panel saved');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Save changes on a new text panel, the editor kind with no query to bake in. */
|
||||
export const TextPanelSavedToast: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'text' },
|
||||
play: PanelSavedToast.play,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { expect, screen, userEvent, waitFor } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
expandSection,
|
||||
openConfigSelect,
|
||||
panelEditorMocks,
|
||||
pickSegment,
|
||||
} from '../PanelEditorPage.stories.mocks';
|
||||
|
||||
import PanelEditorPage from '../../PanelEditorPage';
|
||||
|
||||
type PanelEditorArgs = PageStoryArgs<typeof panelEditorMocks>;
|
||||
|
||||
const pageStory = storyMocks(panelEditorMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The panel editor's display options, one section open at a time: what each
|
||||
* panel kind lets a user tune about how its data is drawn, and the preview
|
||||
* following along.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId/panel/:panelId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Panel Editor/Chart Options',
|
||||
tags: ['authz', 'play'],
|
||||
component: PanelEditorPage as ComponentType<PanelEditorArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD_PANEL_EDITOR} component={PanelEditorPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<PanelEditorArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<PanelEditorArgs>;
|
||||
|
||||
/** The time range the panel reads, open on its choices. */
|
||||
export const TimePreferenceOpen: Story = {
|
||||
play: async () => {
|
||||
await openConfigSelect('panel-editor-v2-time-preference');
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** Formatting & Units: the unit and decimals the values are shown with. */
|
||||
export const Formatting: Story = {
|
||||
play: async () => {
|
||||
await expandSection('Formatting & Units');
|
||||
await screen.findByTestId('panel-editor-v2-decimals');
|
||||
},
|
||||
};
|
||||
|
||||
/** The unit picker, open on its categories. */
|
||||
export const UnitPickerOpen: Story = {
|
||||
play: async (context) => {
|
||||
await Formatting.play?.(context);
|
||||
await openConfigSelect('panel-editor-v2-unit');
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** Axes: soft bounds for the y axis and its scale. */
|
||||
export const Axes: Story = {
|
||||
play: async () => {
|
||||
await expandSection('Axes');
|
||||
await screen.findByTestId('panel-editor-v2-log-scale');
|
||||
},
|
||||
};
|
||||
|
||||
/** The y axis on a log scale, which the preview redraws with. */
|
||||
export const AxesLogScale: Story = {
|
||||
play: async (context) => {
|
||||
await Axes.play?.(context);
|
||||
await pickSegment('panel-editor-v2-log-scale', 'log');
|
||||
},
|
||||
};
|
||||
|
||||
/** Legend: its position, and a color per series the query returned. */
|
||||
export const Legend: Story = {
|
||||
play: async () => {
|
||||
await expandSection('Legend');
|
||||
await screen.findByTestId('panel-editor-v2-legend-position');
|
||||
},
|
||||
};
|
||||
|
||||
/** The legend moved to the right of the preview. */
|
||||
export const LegendRight: Story = {
|
||||
play: async (context) => {
|
||||
await Legend.play?.(context);
|
||||
await pickSegment('panel-editor-v2-legend-position', 'right');
|
||||
},
|
||||
};
|
||||
|
||||
/** Chart appearance: line style, interpolation, fill and points. */
|
||||
export const ChartAppearance: Story = {
|
||||
play: async () => {
|
||||
await expandSection('Chart appearance');
|
||||
await screen.findByTestId('panel-editor-v2-line-style');
|
||||
},
|
||||
};
|
||||
|
||||
/** Dashed lines under a gradient fill, with the points drawn. */
|
||||
export const ChartAppearanceDashedGradient: Story = {
|
||||
play: async (context) => {
|
||||
await ChartAppearance.play?.(context);
|
||||
await pickSegment('panel-editor-v2-line-style', 'dashed');
|
||||
await pickSegment('panel-editor-v2-fill-mode', 'gradient');
|
||||
const points = screen.getByTestId('panel-editor-v2-show-points');
|
||||
|
||||
await userEvent.click(points);
|
||||
await waitFor(() => expect(points).toHaveAttribute('aria-checked', 'true'));
|
||||
},
|
||||
};
|
||||
|
||||
/** An area chart's appearance, which adds the fill opacity. */
|
||||
export const AreaChartAppearance: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'area-chart' },
|
||||
play: async () => {
|
||||
await expandSection('Chart appearance');
|
||||
await screen.findByTestId('panel-editor-v2-fill-opacity');
|
||||
},
|
||||
};
|
||||
|
||||
/** A bar chart with its series stacked on each other. */
|
||||
export const BarStacked: Story = {
|
||||
args: { panel: 'errors-by-status' },
|
||||
play: async () => {
|
||||
await expandSection('Visualization');
|
||||
await pickSegment('panel-editor-v2-stacked-bar-chart', 'stacked');
|
||||
},
|
||||
};
|
||||
|
||||
/** A table's formatting: a unit per value column. */
|
||||
export const TableColumnUnits: Story = {
|
||||
args: { panel: 'top-endpoints' },
|
||||
play: async () => {
|
||||
await expandSection('Formatting & Units');
|
||||
await screen.findByTestId('panel-editor-v2-decimals');
|
||||
},
|
||||
};
|
||||
|
||||
/** A pie chart's legend, colored per slice. */
|
||||
export const PieLegend: Story = {
|
||||
args: { panel: 'traffic-share' },
|
||||
play: async () => {
|
||||
await expandSection('Legend');
|
||||
await screen.findByTestId('panel-editor-v2-legend-position');
|
||||
},
|
||||
};
|
||||
|
||||
/** A histogram's buckets: how many, how wide, and whether queries merge. */
|
||||
export const HistogramBuckets: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'histogram' },
|
||||
play: async () => {
|
||||
await expandSection('Buckets');
|
||||
await screen.findByTestId('panel-editor-v2-bucket-count');
|
||||
},
|
||||
};
|
||||
|
||||
/** A text panel's appearance: alignment and background. */
|
||||
export const TextAppearance: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'text' },
|
||||
play: async () => {
|
||||
await expandSection('Panel appearance');
|
||||
await screen.findByTestId('text-layout-align');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { expect, screen, userEvent, waitFor } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
addThreshold,
|
||||
openLinkDialog,
|
||||
panelEditorMocks,
|
||||
} from '../PanelEditorPage.stories.mocks';
|
||||
|
||||
import PanelEditorPage from '../../PanelEditorPage';
|
||||
|
||||
type PanelEditorArgs = PageStoryArgs<typeof panelEditorMocks>;
|
||||
|
||||
const pageStory = storyMocks(panelEditorMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The panel editor's Thresholds and Context Links sections. A threshold row
|
||||
* takes the shape of its panel kind: a labelled line on a chart, a comparison
|
||||
* on a number, a column rule on a table. A context link opens a dialog whose
|
||||
* URL can carry the dashboard's variables.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId/panel/:panelId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Panel Editor/Thresholds and Links',
|
||||
tags: ['authz', 'play'],
|
||||
component: PanelEditorPage as ComponentType<PanelEditorArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD_PANEL_EDITOR} component={PanelEditorPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<PanelEditorArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<PanelEditorArgs>;
|
||||
|
||||
/** A time series threshold: a value, a color and a label drawn on the chart. */
|
||||
export const Threshold: Story = {
|
||||
play: async () => {
|
||||
await addThreshold();
|
||||
await screen.findByTestId('threshold-value-0');
|
||||
},
|
||||
};
|
||||
|
||||
/** The threshold's custom color picker, open beside the preset swatches. */
|
||||
export const ThresholdColorOpen: Story = {
|
||||
play: async (context) => {
|
||||
await Threshold.play?.(context);
|
||||
await userEvent.click(screen.getByTestId('threshold-color-0-custom'));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
document.querySelector('.ant-color-picker-inner-content'),
|
||||
).toBeVisible(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** A number panel's threshold, which compares the value and recolors it. */
|
||||
export const NumberThreshold: Story = {
|
||||
args: { panel: 'p99-latency' },
|
||||
play: async () => {
|
||||
await addThreshold('panel-editor-v2-add-comparison-threshold');
|
||||
await screen.findByTestId('comparison-threshold-operator-0');
|
||||
},
|
||||
};
|
||||
|
||||
/** A table's threshold, which picks the column it colors. */
|
||||
export const TableThreshold: Story = {
|
||||
args: { panel: 'top-endpoints' },
|
||||
play: async () => {
|
||||
await addThreshold('panel-editor-v2-add-table-threshold');
|
||||
await screen.findByTestId('table-threshold-column-0');
|
||||
},
|
||||
};
|
||||
|
||||
/** The add-link dialog, empty, with Save waiting for a URL. */
|
||||
export const ContextLinkDialog: Story = {
|
||||
play: async () => {
|
||||
await openLinkDialog();
|
||||
},
|
||||
};
|
||||
|
||||
/** The URL field focused, offering the variables it can interpolate. */
|
||||
export const ContextLinkVariables: Story = {
|
||||
play: async () => {
|
||||
await openLinkDialog();
|
||||
await userEvent.click(screen.getByTestId('context-link-url'));
|
||||
await screen.findByTestId('context-link-variable-service');
|
||||
},
|
||||
};
|
||||
|
||||
/** A URL with query parameters, split into rows that can be edited one by one. */
|
||||
export const ContextLinkParams: Story = {
|
||||
play: async () => {
|
||||
await openLinkDialog();
|
||||
await userEvent.type(
|
||||
screen.getByTestId('context-link-label'),
|
||||
'Open service traces',
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('context-link-url'));
|
||||
await userEvent.paste(
|
||||
'/traces-explorer?service={{service}}&env={{environment}}',
|
||||
);
|
||||
// Leaving the URL closes the variables list that covers the rows.
|
||||
await userEvent.click(screen.getByTestId('context-link-label'));
|
||||
await screen.findByTestId('context-link-param-key-1');
|
||||
},
|
||||
};
|
||||
|
||||
/** A URL the link cannot open, which the field flags and Save refuses. */
|
||||
export const ContextLinkInvalidUrl: Story = {
|
||||
play: async () => {
|
||||
await openLinkDialog();
|
||||
await userEvent.click(screen.getByTestId('context-link-url'));
|
||||
await userEvent.paste('traces for {{service}}');
|
||||
await screen.findByTestId('context-link-url-error');
|
||||
},
|
||||
};
|
||||
|
||||
/** A saved link, listed in the section with its edit and remove actions. */
|
||||
export const ContextLinkSaved: Story = {
|
||||
play: async (context) => {
|
||||
await ContextLinkParams.play?.(context);
|
||||
await userEvent.click(screen.getByTestId('context-link-save'));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('context-link-dialog')).toBeNull(),
|
||||
);
|
||||
await screen.findByTestId('context-link-item-0');
|
||||
},
|
||||
};
|
||||
@@ -7,19 +7,26 @@ import { rest } from 'msw';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import type { GetPublicDashboard200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useDashboardPreferencesStore } from 'hooks/dashboard/useDashboardPreference';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
multiChoiceControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import {
|
||||
fieldKeysResponse,
|
||||
fieldValuesResponse,
|
||||
} from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import { queryRangeV5ScalarResponse } from '@/storybook/msw/__story_mockdata__/queryRange';
|
||||
|
||||
import {
|
||||
currentDashboardDocument,
|
||||
cyclicVariablesDashboardResponse,
|
||||
patchDashboardDocument,
|
||||
PANEL_IDS,
|
||||
seedDashboardDocument,
|
||||
@@ -29,8 +36,14 @@ import {
|
||||
type VariableKind,
|
||||
} from './__story_mockdata__/dashboard';
|
||||
import {
|
||||
API_RESULTS,
|
||||
settle,
|
||||
type ApiResult,
|
||||
} from './__story_mockdata__/settled';
|
||||
import {
|
||||
attributeValues,
|
||||
emptyPanelResponse,
|
||||
NAMESPACE_VALUES,
|
||||
VARIABLE_ATTRIBUTES,
|
||||
panelResponse,
|
||||
serviceVariableValues,
|
||||
} from './__story_mockdata__/panelData';
|
||||
@@ -44,6 +57,7 @@ import {
|
||||
const LAYOUT = 'Dashboard · layout';
|
||||
const DATA = 'Dashboard · panels';
|
||||
const SHARING = 'Dashboard · sharing';
|
||||
const REQUESTS = 'Dashboard · requests';
|
||||
|
||||
export const dashboardRoute = (): string =>
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: STORY_DASHBOARD_ID });
|
||||
@@ -65,6 +79,14 @@ const NOT_FOUND = {
|
||||
|
||||
const ok = { status: 'success', data: null };
|
||||
|
||||
// The editor rewrites any `Syntax error:` into its own hint, so the ClickHouse
|
||||
// wording only shows through the variable bar.
|
||||
const VARIABLE_QUERY_ERROR = {
|
||||
status: 'error',
|
||||
error:
|
||||
"Code: 62. DB::Exception: Syntax error: failed at position 58 ('$environment'). (SYNTAX_ERROR)",
|
||||
};
|
||||
|
||||
const publicMeta = (): GetPublicDashboard200 => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
@@ -137,6 +159,12 @@ export const dashboardMocks = defineStoryMocks({
|
||||
value: 4,
|
||||
max: 12,
|
||||
}),
|
||||
variableQueryFails: toggleControl('Variable query fails', {
|
||||
group: DATA,
|
||||
description:
|
||||
"The query-backed `service` variable answers with a ClickHouse syntax error, which the variable bar and the editor's Test Run both report.",
|
||||
value: false,
|
||||
}),
|
||||
noData: toggleControl('Panels return nothing', {
|
||||
group: DATA,
|
||||
description:
|
||||
@@ -149,6 +177,44 @@ export const dashboardMocks = defineStoryMocks({
|
||||
'Answers the dashboard document with a 404, which is the page-level failure the shell renders around.',
|
||||
value: false,
|
||||
}),
|
||||
dashboardPatch: choiceControl<ApiResult>('Dashboard edit', {
|
||||
group: REQUESTS,
|
||||
description:
|
||||
'How the JSON Patch behind every spec edit answers: rename, settings, variables, clone panel or section and the variable review. `loading` never answers, `error` answers 500.',
|
||||
options: API_RESULTS,
|
||||
value: 'success',
|
||||
}),
|
||||
dashboardUpdate: choiceControl<ApiResult>('Dashboard replace', {
|
||||
group: REQUESTS,
|
||||
description: "How the PUT behind the JSON editor's Apply changes answers.",
|
||||
options: API_RESULTS,
|
||||
value: 'success',
|
||||
}),
|
||||
dashboardClone: choiceControl<ApiResult>('Dashboard clone', {
|
||||
group: REQUESTS,
|
||||
description: 'How the endpoint behind Actions, Clone dashboard answers.',
|
||||
options: API_RESULTS,
|
||||
value: 'success',
|
||||
}),
|
||||
dashboardDelete: choiceControl<ApiResult>('Dashboard delete', {
|
||||
group: REQUESTS,
|
||||
description: 'How the endpoint behind Actions, Delete dashboard answers.',
|
||||
options: API_RESULTS,
|
||||
value: 'success',
|
||||
}),
|
||||
publicWrite: choiceControl<ApiResult>('Public link change', {
|
||||
group: REQUESTS,
|
||||
description: 'How publish, update and unpublish on the Publish tab answer.',
|
||||
options: API_RESULTS,
|
||||
value: 'success',
|
||||
}),
|
||||
substituteVars: choiceControl<ApiResult>('Variable substitution', {
|
||||
group: REQUESTS,
|
||||
description:
|
||||
'How the endpoint that resolves variables before Create Alerts answers.',
|
||||
options: API_RESULTS,
|
||||
value: 'success',
|
||||
}),
|
||||
published: toggleControl('Published publicly', {
|
||||
group: SHARING,
|
||||
description:
|
||||
@@ -177,26 +243,37 @@ export const dashboardMocks = defineStoryMocks({
|
||||
// Every spec edit travels as a JSON Patch, and its response is what
|
||||
// replaces the cache, so the ops are applied to the story's document
|
||||
// rather than answered away.
|
||||
rest.patch(
|
||||
'http://localhost/api/v2/dashboards/:id',
|
||||
async (req, res, ctx) => {
|
||||
rest.patch('http://localhost/api/v2/dashboards/:id', (req, res, ctx) =>
|
||||
settle(values.dashboardPatch, req, res, ctx, async () => {
|
||||
const ops = (await req.json()) as Parameters<
|
||||
typeof patchDashboardDocument
|
||||
>[1];
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(patchDashboardDocument(document, ops)),
|
||||
);
|
||||
},
|
||||
return patchDashboardDocument(document, ops);
|
||||
}),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v2/dashboards/:id/clone', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(currentDashboardDocument(document))),
|
||||
rest.put('http://localhost/api/v2/dashboards/:id', (req, res, ctx) =>
|
||||
settle(values.dashboardUpdate, req, res, ctx, () =>
|
||||
currentDashboardDocument(document),
|
||||
),
|
||||
),
|
||||
|
||||
rest.delete('http://localhost/api/v2/dashboards/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(ok)),
|
||||
rest.post('http://localhost/api/v2/dashboards/:id/clone', (req, res, ctx) =>
|
||||
settle(values.dashboardClone, req, res, ctx, () =>
|
||||
currentDashboardDocument(document),
|
||||
),
|
||||
),
|
||||
|
||||
rest.delete('http://localhost/api/v2/dashboards/:id', (req, res, ctx) =>
|
||||
settle(values.dashboardDelete, req, res, ctx, () => ok),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v5/substitute_vars', (req, res, ctx) =>
|
||||
settle(values.substituteVars, req, res, ctx, async () => ({
|
||||
status: 'success',
|
||||
data: await req.json(),
|
||||
})),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v2/dashboards/:id/lock', (_req, res, ctx) =>
|
||||
@@ -228,18 +305,35 @@ export const dashboardMocks = defineStoryMocks({
|
||||
// The variable bar resolves before the panels and stays laid out while
|
||||
// they load or fail, so its two endpoints answer on their own rather
|
||||
// than through the Data control.
|
||||
rest.post('http://localhost/api/v2/variables/query', (_req, res, ctx) =>
|
||||
values.variableQueryFails
|
||||
? rest.post('http://localhost/api/v2/variables/query', (_req, res, ctx) =>
|
||||
res(ctx.status(400), ctx.json(VARIABLE_QUERY_ERROR)),
|
||||
)
|
||||
: rest.post('http://localhost/api/v2/variables/query', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
variableValues: serviceVariableValues(values.variableValues),
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: { variableValues: serviceVariableValues(values.variableValues) },
|
||||
}),
|
||||
ctx.json(
|
||||
fieldValuesResponse(attributeValues(req.url.searchParams.get('name'))),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/fields/values', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(fieldValuesResponse(NAMESPACE_VALUES))),
|
||||
// The dynamic variable editor lists the attributes a variable can read.
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/keys',
|
||||
response.json(() => fieldKeysResponse(VARIABLE_ATTRIBUTES)),
|
||||
),
|
||||
|
||||
// The header reads the public link on every load, so it answers even while
|
||||
@@ -250,18 +344,17 @@ export const dashboardMocks = defineStoryMocks({
|
||||
: res(ctx.status(404), ctx.json(NOT_PUBLIC)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v1/dashboards/:id/public',
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(publicMeta())),
|
||||
rest.post('http://localhost/api/v1/dashboards/:id/public', (req, res, ctx) =>
|
||||
settle(values.publicWrite, req, res, ctx, publicMeta),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v1/dashboards/:id/public', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(publicMeta())),
|
||||
rest.put('http://localhost/api/v1/dashboards/:id/public', (req, res, ctx) =>
|
||||
settle(values.publicWrite, req, res, ctx, publicMeta),
|
||||
),
|
||||
|
||||
rest.delete(
|
||||
'http://localhost/api/v1/dashboards/:id/public',
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(ok)),
|
||||
(req, res, ctx) => settle(values.publicWrite, req, res, ctx, () => ok),
|
||||
),
|
||||
];
|
||||
},
|
||||
@@ -273,6 +366,9 @@ export const dashboardMocks = defineStoryMocks({
|
||||
variables: values.variables,
|
||||
locked: values.locked,
|
||||
});
|
||||
// The sync mode persists per dashboard, so one story's pick would open the
|
||||
// next one on it.
|
||||
useDashboardPreferencesStore.setState({ preferences: {} });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -331,3 +427,218 @@ export const metricsListHandler = rest.get(
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: { metrics: [] } })),
|
||||
);
|
||||
|
||||
export const cyclicVariablesDashboardHandler = rest.get(
|
||||
'http://localhost/api/v2/dashboards/:id',
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(cyclicVariablesDashboardResponse())),
|
||||
);
|
||||
|
||||
// The dashboard document loads before the toolbar renders.
|
||||
export const PAGE_LOAD = { timeout: 10000 };
|
||||
|
||||
export const clickAction = async (
|
||||
canvasElement: HTMLElement,
|
||||
label: string,
|
||||
): Promise<void> => {
|
||||
// The dropdown trigger's Slot merge drops the button's own test id.
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: 'Actions' },
|
||||
PAGE_LOAD,
|
||||
),
|
||||
);
|
||||
const item = await screen.findByText(label);
|
||||
const menuItem = item.closest('[role="menuitem"]') ?? item;
|
||||
|
||||
await waitFor(() => {
|
||||
expect(menuItem).not.toHaveAttribute('aria-disabled', 'true');
|
||||
expect(menuItem).not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
await userEvent.click(menuItem);
|
||||
};
|
||||
|
||||
export const clickPanelAction = async (
|
||||
canvasElement: HTMLElement,
|
||||
panelId: string,
|
||||
label: string,
|
||||
): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
`panel-actions-${panelId}`,
|
||||
{},
|
||||
PAGE_LOAD,
|
||||
),
|
||||
);
|
||||
await userEvent.click(await screen.findByText(label));
|
||||
};
|
||||
|
||||
export const clickSectionAction = async (
|
||||
canvasElement: HTMLElement,
|
||||
label: string,
|
||||
): Promise<void> => {
|
||||
const [firstSection] = await within(canvasElement).findAllByRole(
|
||||
'button',
|
||||
{ name: 'Section actions' },
|
||||
PAGE_LOAD,
|
||||
);
|
||||
await userEvent.click(firstSection);
|
||||
await userEvent.click(await screen.findByText(label));
|
||||
};
|
||||
|
||||
export const expectToast = (text: string | RegExp): Promise<HTMLElement> =>
|
||||
screen.findByText(text, undefined, PAGE_LOAD);
|
||||
|
||||
export const openJsonEditor = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId('edit-json', {}, PAGE_LOAD),
|
||||
);
|
||||
await screen.findByTestId('json-editor-copy', {}, PAGE_LOAD);
|
||||
};
|
||||
|
||||
export const confirmImpact = async (): Promise<void> => {
|
||||
await userEvent.click(await screen.findByTestId('variable-impact-confirm'));
|
||||
};
|
||||
|
||||
/**
|
||||
* A denied control stays on screen, disabled. Buttons say so through
|
||||
* `disabled` or `aria-disabled` depending on the Button, menu rows through
|
||||
* `data-disabled`.
|
||||
*/
|
||||
const isDisabled = (element: HTMLElement): boolean =>
|
||||
element.hasAttribute('disabled') ||
|
||||
element.getAttribute('aria-disabled') === 'true' ||
|
||||
element.hasAttribute('data-disabled');
|
||||
|
||||
export const expectDisabled = (
|
||||
element: HTMLElement,
|
||||
disabled = true,
|
||||
): Promise<void> =>
|
||||
waitFor(() => expect(isDisabled(element)).toBe(disabled), PAGE_LOAD);
|
||||
|
||||
export const toolbarButton = (
|
||||
canvasElement: HTMLElement,
|
||||
name: string,
|
||||
): Promise<HTMLElement> =>
|
||||
within(canvasElement).findByRole('button', { name }, PAGE_LOAD);
|
||||
|
||||
export const openActionsMenu = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
// The dropdown trigger's Slot merge drops the button's own test id.
|
||||
await userEvent.click(await toolbarButton(canvasElement, 'Actions'));
|
||||
await screen.findByText('Clone dashboard');
|
||||
};
|
||||
|
||||
// Not by test id: the Actions menu only carries them on some Dropdown versions.
|
||||
export const menuItem = (name: string): HTMLElement =>
|
||||
screen.getByRole('menuitem', { name });
|
||||
|
||||
export type SettingsTab = 'Overview' | 'Variables' | 'Publish';
|
||||
|
||||
export type VariableType = 'dynamic' | 'textbox' | 'custom' | 'query';
|
||||
|
||||
type RowAction = 'edit' | 'delete' | 'apply-all';
|
||||
|
||||
/** Opens the settings drawer from the toolbar and returns the tab's panel. */
|
||||
export const openSettings = async (
|
||||
canvasElement: HTMLElement,
|
||||
tab: SettingsTab = 'Overview',
|
||||
): Promise<HTMLElement> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId('show-drawer', {}, PAGE_LOAD),
|
||||
);
|
||||
|
||||
if (tab !== 'Overview') {
|
||||
await userEvent.click(await screen.findByRole('tab', { name: tab }));
|
||||
}
|
||||
|
||||
return screen.findByRole('tabpanel');
|
||||
};
|
||||
|
||||
export const variableRow = (name: string): Promise<HTMLElement> =>
|
||||
screen.findByTestId(`variable-row-${name}`, {}, PAGE_LOAD);
|
||||
|
||||
/** Row actions render once the edit permission resolves. */
|
||||
export const clickRowAction = async (
|
||||
name: string,
|
||||
action: RowAction,
|
||||
): Promise<void> => {
|
||||
const row = await variableRow(name);
|
||||
|
||||
await userEvent.click(
|
||||
action === 'apply-all'
|
||||
? // The tooltip trigger's Slot merge drops this button's test id.
|
||||
await within(row).findByRole('button', { name: 'Apply to all' }, PAGE_LOAD)
|
||||
: await within(row).findByTestId(
|
||||
`variable-${action}-${name}`,
|
||||
{},
|
||||
PAGE_LOAD,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/** A new variable's editor, which opens on the Dynamic type. */
|
||||
export const openNewVariable = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
const panel = await openSettings(canvasElement, 'Variables');
|
||||
const add = await within(panel).findByTestId('add-variable', {}, PAGE_LOAD);
|
||||
|
||||
// Disabled until its permission check resolves: through `disabled` or
|
||||
// `aria-disabled`, depending on the Button.
|
||||
await waitFor(() => expect(add).toBeEnabled(), PAGE_LOAD);
|
||||
await waitFor(
|
||||
() => expect(add).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
PAGE_LOAD,
|
||||
);
|
||||
await userEvent.click(add);
|
||||
await screen.findByText('Variable Type');
|
||||
};
|
||||
|
||||
export const openVariableEditor = async (
|
||||
canvasElement: HTMLElement,
|
||||
name: string,
|
||||
): Promise<void> => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await clickRowAction(name, 'edit');
|
||||
await screen.findByText('Variable Type');
|
||||
};
|
||||
|
||||
/**
|
||||
* A value in the editor's preview. The toolbar's selector for the same variable
|
||||
* resolves to its first value too, whenever its own query returns.
|
||||
*/
|
||||
export const findPreviewValue = async (value: string): Promise<HTMLElement> => {
|
||||
const label = await screen.findByText('Preview of Values');
|
||||
|
||||
return within(label.parentElement as HTMLElement).findByText(
|
||||
value,
|
||||
undefined,
|
||||
PAGE_LOAD,
|
||||
);
|
||||
};
|
||||
|
||||
export const pickVariableType = async (type: VariableType): Promise<void> => {
|
||||
await userEvent.click(await screen.findByTestId(`variable-type-${type}`));
|
||||
};
|
||||
|
||||
export const typeVariableName = async (name: string): Promise<void> => {
|
||||
const input = await screen.findByTestId('variable-name');
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, name);
|
||||
};
|
||||
|
||||
/**
|
||||
* The editor's pickers are antd Selects: the element carrying the test id does
|
||||
* nothing on click, the combobox inside it is what opens the list.
|
||||
*/
|
||||
export const openVariableSelect = async (testId: string): Promise<void> => {
|
||||
const select = await screen.findByTestId(testId);
|
||||
|
||||
await userEvent.click(within(select).getByRole('combobox'));
|
||||
};
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import * as monaco from 'monaco-editor';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
clickAction,
|
||||
clickPanelAction,
|
||||
clickSectionAction,
|
||||
dashboardMocks,
|
||||
desyncedDashboardHandler,
|
||||
expectToast,
|
||||
metricsListHandler,
|
||||
openJsonEditor,
|
||||
PAGE_LOAD,
|
||||
tooltipDashboardHandler,
|
||||
tooltipRoute,
|
||||
warnedPanelQueryHandler,
|
||||
@@ -30,8 +37,8 @@ const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail',
|
||||
tags: ['role-gated', 'play'],
|
||||
title: 'Pages/Dashboards/Detail/Overview',
|
||||
tags: ['authz', 'play'],
|
||||
// The page is wrapped in `withAuthZPage`, which types its props as an index
|
||||
// signature; the story's args are what the controls resolve to.
|
||||
component: DashboardPage as ComponentType<DashboardArgs>,
|
||||
@@ -159,53 +166,6 @@ export const TooltipsInJsonDrawer: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Overview tab of dashboard settings, where Cross-Panel Sync explains what
|
||||
* syncing the crosshair does and links out to the docs.
|
||||
*/
|
||||
export const TooltipsInSettings: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('show-drawer', {}, { timeout: 10000 }),
|
||||
);
|
||||
await screen.findByText('Sync Mode');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Variables tab of dashboard settings, where a dynamic variable's Apply to
|
||||
* all says whether it is already a filter on every panel. The row keeps its
|
||||
* actions invisible until it is hovered, which the story does first.
|
||||
*/
|
||||
export const TooltipsInVariableSettings: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('show-drawer', {}, { timeout: 10000 }),
|
||||
);
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Variables' }));
|
||||
|
||||
// The tooltip trigger's Slot merge drops the button's own test id.
|
||||
await userEvent.hover(
|
||||
await screen.findByRole(
|
||||
'button',
|
||||
{ name: 'Apply to all' },
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByText(
|
||||
'Add this variable as a filter to every panel',
|
||||
undefined,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A panel expanded into view mode, whose header carries the full panel name its
|
||||
* title truncates, over the dashboard's own tooltips behind the dialog.
|
||||
@@ -390,6 +350,194 @@ export const NewPanelPickerFromSection: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The panel menu's move-to-section submenu, open on the sections it can go to. */
|
||||
export const PanelMoveToSectionSubmenu: Story = {
|
||||
play: async (context) => {
|
||||
await PanelActionsMenu.play?.(context);
|
||||
await userEvent.hover(await screen.findByText('Move to section'));
|
||||
// The submenu lists the sections the panel is not already in.
|
||||
await waitFor(() => expect(screen.getAllByRole('menu')).toHaveLength(2), {
|
||||
timeout: 10000,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Actions, Clone dashboard: the toast announcing the copy, raised once the
|
||||
* clone endpoint answers. Set Dashboard clone to `error` to see the error
|
||||
* modal instead.
|
||||
*/
|
||||
export const DashboardClonedToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await clickAction(canvasElement, 'Clone dashboard');
|
||||
if (args.dashboardClone === 'success') {
|
||||
await expectToast('Dashboard cloned');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The title edited inline: the toast announcing the new name, raised once the
|
||||
* patch answers.
|
||||
*/
|
||||
export const DashboardRenamedToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await clickAction(canvasElement, 'Rename');
|
||||
const input = await screen.findByTestId('dashboard-title-input');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'Service overview (renamed){enter}');
|
||||
if (args.dashboardPatch === 'success') {
|
||||
await expectToast('Dashboard renamed successfully');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Actions, Delete dashboard, confirmed: the toast announcing the deletion,
|
||||
* raised once the delete endpoint answers and before the page leaves for the
|
||||
* list.
|
||||
*/
|
||||
export const DashboardDeletedToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await clickAction(canvasElement, 'Delete dashboard');
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Delete' }));
|
||||
if (args.dashboardDelete === 'success') {
|
||||
await expectToast('Dashboard deleted successfully');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** A panel's Clone: the promise toast, loading until the patch answers. */
|
||||
export const PanelClonedToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await clickPanelAction(canvasElement, 'p99-latency', 'Clone');
|
||||
if (args.dashboardPatch === 'success') {
|
||||
await expectToast('Panel cloned');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** A panel's Clone whose patch is refused. */
|
||||
export const PanelCloneFailedToast: Story = {
|
||||
args: { dashboardPatch: 'error' },
|
||||
// The refused patch is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async (context) => {
|
||||
await PanelClonedToast.play?.(context);
|
||||
await expectToast('Failed to clone panel');
|
||||
},
|
||||
};
|
||||
|
||||
/** A panel's Clone whose patch never answers, so the toast stays on loading. */
|
||||
export const PanelCloneLoadingToast: Story = {
|
||||
args: { dashboardPatch: 'loading' },
|
||||
play: async (context) => {
|
||||
await PanelClonedToast.play?.(context);
|
||||
await expectToast('Cloning panel…');
|
||||
},
|
||||
};
|
||||
|
||||
/** A section's Clone section: the promise toast, loading until the patch answers. */
|
||||
export const SectionClonedToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await clickSectionAction(canvasElement, 'Clone section');
|
||||
if (args.dashboardPatch === 'success') {
|
||||
await expectToast('Section cloned');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** A section's Clone section whose patch is refused. */
|
||||
export const SectionCloneFailedToast: Story = {
|
||||
args: { dashboardPatch: 'error' },
|
||||
// The refused patch is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async (context) => {
|
||||
await SectionClonedToast.play?.(context);
|
||||
await expectToast('Failed to clone section');
|
||||
},
|
||||
};
|
||||
|
||||
/** A section's Clone section whose patch never answers, so the toast stays on loading. */
|
||||
export const SectionCloneLoadingToast: Story = {
|
||||
args: { dashboardPatch: 'loading' },
|
||||
play: async (context) => {
|
||||
await SectionClonedToast.play?.(context);
|
||||
await expectToast('Cloning section…');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A panel's Create Alerts when the substitute-variables call is refused: the
|
||||
* dashboard has variables selected, so the query is resolved before the alert
|
||||
* page opens.
|
||||
*/
|
||||
export const PanelCreateAlertErrorToast: Story = {
|
||||
args: { substituteVars: 'error' },
|
||||
// The refused request is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickPanelAction(canvasElement, 'p99-latency', 'Create Alerts');
|
||||
await expectToast('Failed to create alert from panel');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The View modal on a time series panel, Save in its series manager: the toast
|
||||
* confirming the legend state was stored.
|
||||
*/
|
||||
export const ChartManagerSavedToast: Story = {
|
||||
parameters: { msw: { handlers: [metricsListHandler] } },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickPanelAction(canvasElement, 'request-rate', 'View');
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Save' }, PAGE_LOAD),
|
||||
);
|
||||
await expectToast('The updated graphs & legends are saved');
|
||||
},
|
||||
};
|
||||
|
||||
/** The JSON editor's Copy: the toast confirming the clipboard write. */
|
||||
export const JsonCopiedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openJsonEditor(canvasElement);
|
||||
await userEvent.click(screen.getByTestId('json-editor-copy'));
|
||||
await expectToast('JSON copied to clipboard');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The JSON editor with a whitespace edit, applied: the toast announcing the
|
||||
* update, raised once the PUT answers.
|
||||
*/
|
||||
export const JsonAppliedToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await openJsonEditor(canvasElement);
|
||||
await waitFor(
|
||||
() => {
|
||||
if (monaco.editor.getModels().length === 0) {
|
||||
throw new Error('Monaco has not mounted');
|
||||
}
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
// Typing into Monaco's hidden textarea never reaches its model in this runner.
|
||||
monaco.editor
|
||||
.getModels()[0]
|
||||
.applyEdits([{ range: new monaco.Range(1, 1, 1, 1), text: ' ' }]);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('json-editor-apply')).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
),
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('json-editor-apply'));
|
||||
if (args.dashboardUpdate === 'success') {
|
||||
await expectToast('Dashboard updated');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A dashboard id nobody has, which is what a deleted or mistyped link opens on.
|
||||
*
|
||||
|
||||
@@ -336,6 +336,28 @@ const VARIABLES: Record<VariableKind, DashboardtypesVariableDTO> = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `service` already filters on `$environment`, so an `environment` that reads
|
||||
* `$service` back closes the loop. An imported dashboard can arrive like this;
|
||||
* the editor only refuses it on save.
|
||||
*/
|
||||
const CYCLIC_ENVIRONMENT: DashboardtypesVariableDTO = {
|
||||
kind: ListVariableKind.ListVariable,
|
||||
spec: {
|
||||
name: 'environment',
|
||||
display: { name: 'environment' },
|
||||
allowMultiple: false,
|
||||
allowAllValue: false,
|
||||
plugin: {
|
||||
kind: QueryVariableKind['signoz/QueryVariable'],
|
||||
spec: {
|
||||
queryValue:
|
||||
'SELECT DISTINCT env FROM signoz_metrics WHERE service_name IN $service',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export interface PanelQueryShape {
|
||||
requestType: string;
|
||||
metricName?: string;
|
||||
@@ -451,3 +473,18 @@ export const patchDashboardDocument = (
|
||||
|
||||
return envelope(document);
|
||||
};
|
||||
|
||||
export const cyclicVariablesDashboardResponse = (): GetDashboardV2200 => {
|
||||
const response = dashboardResponse({
|
||||
panels: PANEL_IDS.length,
|
||||
sectioned: true,
|
||||
variables: VARIABLE_KINDS,
|
||||
locked: false,
|
||||
});
|
||||
|
||||
response.data.spec.variables = response.data.spec.variables.map((variable) =>
|
||||
variable === VARIABLES.custom ? CYCLIC_ENVIRONMENT : variable,
|
||||
);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -173,3 +173,20 @@ export const NAMESPACE_VALUES = [
|
||||
'payments-prod',
|
||||
'platform-prod',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The attributes the dynamic variable editor offers a variable to read, with
|
||||
* the values each one resolves to.
|
||||
*/
|
||||
const ATTRIBUTE_VALUES: Record<string, readonly string[]> = {
|
||||
'k8s.namespace.name': NAMESPACE_VALUES,
|
||||
'k8s.cluster.name': ['prod-us-east-1', 'prod-eu-west-1'],
|
||||
'service.name': SERVICES,
|
||||
'deployment.environment': ['production', 'staging', 'development'],
|
||||
'host.name': ['ip-10-0-1-12', 'ip-10-0-1-37', 'ip-10-0-2-4'],
|
||||
};
|
||||
|
||||
export const VARIABLE_ATTRIBUTES = Object.keys(ATTRIBUTE_VALUES);
|
||||
|
||||
export const attributeValues = (name: string | null): readonly string[] =>
|
||||
ATTRIBUTE_VALUES[name ?? ''] ?? NAMESPACE_VALUES;
|
||||
|
||||
46
frontend/src/pages/DashboardPage/stories/__story_mockdata__/settled.ts
generated
Normal file
46
frontend/src/pages/DashboardPage/stories/__story_mockdata__/settled.ts
generated
Normal file
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
DefaultBodyType,
|
||||
MockedResponse,
|
||||
PathParams,
|
||||
ResponseComposition,
|
||||
RestContext,
|
||||
RestRequest,
|
||||
} from 'msw';
|
||||
|
||||
export const API_RESULTS = ['success', 'loading', 'error'] as const;
|
||||
|
||||
export type ApiResult = (typeof API_RESULTS)[number];
|
||||
|
||||
const SERVER_ERROR = {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: 'Something went wrong on the server',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* How a mutation endpoint answers for a result: never, with a 500, or with
|
||||
* whatever `ok` builds from the request.
|
||||
*/
|
||||
export const settle = async (
|
||||
result: ApiResult,
|
||||
req: RestRequest<DefaultBodyType, PathParams<string>>,
|
||||
res: ResponseComposition,
|
||||
ctx: RestContext,
|
||||
ok: (
|
||||
req: RestRequest<DefaultBodyType, PathParams<string>>,
|
||||
) => object | Promise<object>,
|
||||
): Promise<MockedResponse> => {
|
||||
if (result === 'loading') {
|
||||
return res(ctx.delay('infinite'));
|
||||
}
|
||||
|
||||
if (result === 'error') {
|
||||
return res(ctx.status(500), ctx.json(SERVER_ERROR));
|
||||
}
|
||||
|
||||
return res(ctx.status(200), ctx.json(await ok(req)));
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
dashboardMocks,
|
||||
expectDisabled,
|
||||
menuItem,
|
||||
openActionsMenu,
|
||||
toolbarButton,
|
||||
} from '../DashboardPage.stories.mocks';
|
||||
|
||||
import DashboardPage from '../../DashboardPage';
|
||||
|
||||
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The dashboard with one permission missing at a time. Every story here is an
|
||||
* admin denied exactly what its name says. `read` decides whether the page
|
||||
* renders at all; `update` is every edit, the settings drawer included, so
|
||||
* Configure is as far as a user without it gets.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail/Authz',
|
||||
tags: ['authz', 'play'],
|
||||
component: DashboardPage as ComponentType<DashboardArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DashboardArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DashboardArgs>;
|
||||
|
||||
const READ = 'read:dashboard';
|
||||
const UPDATE = 'update:dashboard';
|
||||
const DELETE = 'delete:dashboard';
|
||||
const CREATE = 'create:dashboard';
|
||||
|
||||
/** The page refuses to render: `read` is the one check it mounts on. */
|
||||
export const NoRead: Story = {
|
||||
args: { revoked: [READ] },
|
||||
play: async () => {
|
||||
await screen.findByText('Uh-oh! You are not authorized', undefined, {
|
||||
timeout: 10000,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The panels still render, and Configure and New Panel stay in the toolbar,
|
||||
* disabled.
|
||||
*/
|
||||
export const NoUpdate: Story = {
|
||||
args: { revoked: [UPDATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expectDisabled(await toolbarButton(canvasElement, 'Configure'));
|
||||
await expectDisabled(await toolbarButton(canvasElement, 'New Panel'));
|
||||
},
|
||||
};
|
||||
|
||||
/** The same toolbar with its tooltips open, each naming what is missing. */
|
||||
export const TooltipsWithoutUpdate: Story = {
|
||||
args: { revoked: [UPDATE], tooltipsOpen: true },
|
||||
play: NoUpdate.play,
|
||||
};
|
||||
|
||||
/**
|
||||
* A locked dashboard the user could not have edited anyway: the tooltips name
|
||||
* the permission rather than the lock.
|
||||
*/
|
||||
export const TooltipsWithoutUpdateWhenLocked: Story = {
|
||||
args: { revoked: [UPDATE], locked: true, tooltipsOpen: true },
|
||||
play: NoUpdate.play,
|
||||
};
|
||||
|
||||
/** The Actions menu: rename, lock and new section locked; clone and delete open. */
|
||||
export const NoUpdateActionsMenu: Story = {
|
||||
args: { revoked: [UPDATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openActionsMenu(canvasElement);
|
||||
await expectDisabled(menuItem('Rename'));
|
||||
await expectDisabled(menuItem('Lock dashboard'));
|
||||
await expectDisabled(menuItem('New section'));
|
||||
await expectDisabled(menuItem('Clone dashboard'), false);
|
||||
await expectDisabled(menuItem('Delete dashboard'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/** A panel's menu: view and download stay, every edit is locked. */
|
||||
export const NoUpdatePanelMenu: Story = {
|
||||
args: { revoked: [UPDATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'panel-actions-p99-latency',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
const menu = await screen.findByRole('menu');
|
||||
|
||||
await expectDisabled(
|
||||
within(menu).getByRole('menuitem', { name: /Edit panel/ }),
|
||||
);
|
||||
await expectDisabled(
|
||||
within(menu).getByRole('menuitem', { name: /Delete panel/ }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The JSON editor opens read-only, and Apply says why it is locked. */
|
||||
export const NoUpdateJsonEditor: Story = {
|
||||
args: { revoked: [UPDATE], tooltipsOpen: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'edit-json',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await expectDisabled(await screen.findByTestId('json-editor-apply'));
|
||||
},
|
||||
};
|
||||
|
||||
/** A blank dashboard whose add-panel prompts are locked. */
|
||||
export const NoUpdateEmpty: Story = {
|
||||
args: { revoked: [UPDATE], panels: 0, sectioned: false },
|
||||
play: async () => {
|
||||
await expectDisabled(
|
||||
await screen.findByTestId('add-panel', {}, { timeout: 10000 }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Delete dashboard locked in the Actions menu; every edit still works. */
|
||||
export const NoDelete: Story = {
|
||||
args: { revoked: [DELETE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openActionsMenu(canvasElement);
|
||||
await expectDisabled(menuItem('Delete dashboard'));
|
||||
await expectDisabled(menuItem('Rename'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/** Cloning is a create, so the copy is locked while the original stays editable. */
|
||||
export const NoCreate: Story = {
|
||||
args: { revoked: [CREATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openActionsMenu(canvasElement);
|
||||
await expectDisabled(menuItem('Clone dashboard'));
|
||||
await expectDisabled(menuItem('Rename'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/** Everything but reading: the Actions menu with only full screen left. */
|
||||
export const ReadOnly: Story = {
|
||||
args: { revoked: [UPDATE, DELETE, CREATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openActionsMenu(canvasElement);
|
||||
await expectDisabled(menuItem('Clone dashboard'));
|
||||
await expectDisabled(menuItem('Delete dashboard'));
|
||||
await expectDisabled(menuItem('Full screen'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The page holds on its spinner until the permission check answers. The
|
||||
* spinner's `Loading dashboard...` tip never shows: antd only renders a tip
|
||||
* around nested content.
|
||||
*/
|
||||
export const CheckLoading: Story = {
|
||||
args: { authzState: 'loading' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await waitFor(() =>
|
||||
expect(canvasElement.querySelector('.ant-spin')).not.toBeNull(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission check failing rather than denying: the page renders, and
|
||||
* every edit stays locked as if `update` were denied.
|
||||
*/
|
||||
export const CheckFailed: Story = {
|
||||
args: { authzState: 'error' },
|
||||
// The mocked check intentionally fails; the resulting console error is the
|
||||
// point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expectDisabled(await toolbarButton(canvasElement, 'Configure'));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { expect, screen, userEvent, waitFor } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
cyclicVariablesDashboardHandler,
|
||||
dashboardMocks,
|
||||
findPreviewValue,
|
||||
openVariableEditor,
|
||||
openVariableSelect,
|
||||
} from '../DashboardPage.stories.mocks';
|
||||
|
||||
import DashboardPage from '../../DashboardPage';
|
||||
|
||||
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The variable editor on each of the dashboard's variables, reached from a
|
||||
* row's edit action in the Variables tab: what every type saved, the values it
|
||||
* resolves to, and the query failures and dependency loops it reports.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail/Edit Variable',
|
||||
tags: ['authz', 'play'],
|
||||
component: DashboardPage as ComponentType<DashboardArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DashboardArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DashboardArgs>;
|
||||
|
||||
/**
|
||||
* `$environment`: a comma-separated list with one value by default. The preview
|
||||
* stays empty until the options are edited.
|
||||
*/
|
||||
export const Custom: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'environment');
|
||||
await screen.findByDisplayValue('production,staging,development');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `$service`: a ClickHouse query that reads `$environment`, run once on open so
|
||||
* the preview and default value have its values.
|
||||
*/
|
||||
export const Query: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'service');
|
||||
await findPreviewValue('inventory');
|
||||
},
|
||||
};
|
||||
|
||||
/** `$service` with its query failing, which the preview reports in its place. */
|
||||
export const QueryFailed: Story = {
|
||||
args: { variableQueryFails: true },
|
||||
// The failed variable query is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'service');
|
||||
await screen.findByText(
|
||||
'Please make sure query is valid and dependent variables are selected',
|
||||
undefined,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The sort picker, open over the previewed values it reorders. */
|
||||
export const SortOpen: Story = {
|
||||
play: async (context) => {
|
||||
await Query.play?.(context);
|
||||
await openVariableSelect('variable-sort-select');
|
||||
await screen.findByTitle('Alphabetical (descending)');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `$namespace`: a dynamic variable reading `k8s.namespace.name` from metrics,
|
||||
* with the panels it could be applied to.
|
||||
*/
|
||||
export const Dynamic: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'namespace');
|
||||
await findPreviewValue('payments-prod');
|
||||
},
|
||||
};
|
||||
|
||||
/** `$owner`: a Textbox variable with its default value. */
|
||||
export const Textbox: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'owner');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('variable-text-input')).toHaveValue(
|
||||
'platform-team',
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `$environment` and `$service` reading each other, as an imported dashboard
|
||||
* can arrive: saving either one is refused and names the loop.
|
||||
*
|
||||
* The document is answered by the story, so the Panels, Sections, Variables and
|
||||
* Locked controls do not reach it.
|
||||
*/
|
||||
export const CircularDependency: Story = {
|
||||
parameters: { msw: { handlers: [cyclicVariablesDashboardHandler] } },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'service');
|
||||
await userEvent.click(screen.getByTestId('variable-save'));
|
||||
await screen.findByText(/circular dependency detected/);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
dashboardMocks,
|
||||
findPreviewValue,
|
||||
openNewVariable,
|
||||
openVariableSelect,
|
||||
pickVariableType,
|
||||
typeVariableName,
|
||||
} from '../DashboardPage.stories.mocks';
|
||||
|
||||
import DashboardPage from '../../DashboardPage';
|
||||
|
||||
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The variable editor on a new variable, reached from Add variable in the
|
||||
* Variables tab: each type's own fields, the pickers they open, and the name
|
||||
* and attribute checks that keep Save disabled.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail/New Variable',
|
||||
tags: ['authz', 'play'],
|
||||
component: DashboardPage as ComponentType<DashboardArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DashboardArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DashboardArgs>;
|
||||
|
||||
/** The editor as it opens, on the Dynamic type with no field picked. */
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/** The dynamic variable's field picker, open on the attributes it can read. */
|
||||
export const FieldPickerOpen: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await openVariableSelect('variable-field-select');
|
||||
await screen.findByRole(
|
||||
'option',
|
||||
{ name: 'k8s.cluster.name' },
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The telemetry source the field is read from, open. */
|
||||
export const SignalPickerOpen: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await openVariableSelect('variable-signal-select');
|
||||
await screen.findByTitle('Traces');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A field picked: the name follows it until typed over, and the values it
|
||||
* resolves to fill the preview and the default value.
|
||||
*/
|
||||
export const DynamicFieldPicked: Story = {
|
||||
play: async (context) => {
|
||||
await FieldPickerOpen.play?.(context);
|
||||
await userEvent.click(
|
||||
screen.getByRole('option', { name: 'k8s.cluster.name' }),
|
||||
);
|
||||
await findPreviewValue('prod-us-east-1');
|
||||
},
|
||||
};
|
||||
|
||||
/** A field another dynamic variable already reads, which Save refuses. */
|
||||
export const AttributeTaken: Story = {
|
||||
play: async (context) => {
|
||||
await FieldPickerOpen.play?.(context);
|
||||
await userEvent.click(
|
||||
screen.getByRole('option', { name: 'k8s.namespace.name' }),
|
||||
);
|
||||
await screen.findByText('A variable with this attribute key already exists');
|
||||
},
|
||||
};
|
||||
|
||||
/** The panels the new filter can be added to, open. */
|
||||
export const ApplyToPanelsOpen: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await openVariableSelect('variable-apply-panels');
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** A name typed and cleared again. */
|
||||
export const NameRequired: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await userEvent.type(screen.getByTestId('variable-name'), 'x{Backspace}');
|
||||
await screen.findByText('Variable name is required');
|
||||
},
|
||||
};
|
||||
|
||||
/** A name another variable on the dashboard has. */
|
||||
export const NameTaken: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await typeVariableName('environment');
|
||||
await screen.findByText('Variable name already exists');
|
||||
},
|
||||
};
|
||||
|
||||
/** A name with a space in it, which a `$name` reference could not spell. */
|
||||
export const NameWithSpaces: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await typeVariableName('build id');
|
||||
await screen.findByText('Variable name cannot contain whitespaces');
|
||||
},
|
||||
};
|
||||
|
||||
/** A Textbox variable: a free-text value with an optional default. */
|
||||
export const Textbox: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await typeVariableName('region');
|
||||
await pickVariableType('textbox');
|
||||
await userEvent.type(
|
||||
await screen.findByTestId('variable-text-input'),
|
||||
'us-east-1',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A Custom variable with its options typed in, previewed as they are parsed,
|
||||
* and multiple values on, which adds the ALL option toggle.
|
||||
*/
|
||||
export const Custom: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await typeVariableName('region');
|
||||
await pickVariableType('custom');
|
||||
await userEvent.type(
|
||||
await screen.findByTestId('variable-custom-input'),
|
||||
'us-east-1,eu-west-1,ap-south-1',
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('variable-multi-switch'));
|
||||
await screen.findByText('Include an option for ALL values');
|
||||
},
|
||||
};
|
||||
|
||||
/** The Custom variable's default value picker, open on the parsed options. */
|
||||
export const CustomDefaultOpen: Story = {
|
||||
play: async (context) => {
|
||||
await Custom.play?.(context);
|
||||
await openVariableSelect('variable-default-select');
|
||||
await screen.findByRole('option', { name: 'eu-west-1' });
|
||||
},
|
||||
};
|
||||
|
||||
/** A Query variable before its query is written: Test Run waits for one. */
|
||||
export const Query: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await pickVariableType('query');
|
||||
await screen.findByText('Test Run Query');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { dashboardMocks, openSettings } from '../DashboardPage.stories.mocks';
|
||||
|
||||
import DashboardPage from '../../DashboardPage';
|
||||
|
||||
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The dashboard's settings drawer on its Overview and Publish tabs: the name,
|
||||
* icon, description and tags, the cross-panel sync mode, and the public link.
|
||||
* The Publish tab only exists on cloud and enterprise licenses.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail/Settings',
|
||||
tags: ['authz', 'play'],
|
||||
component: DashboardPage as ComponentType<DashboardArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DashboardArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DashboardArgs>;
|
||||
|
||||
/** The drawer as Configure opens it, on the Overview tab. */
|
||||
export const Overview: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement);
|
||||
await screen.findByText('Sync Mode');
|
||||
},
|
||||
};
|
||||
|
||||
/** Two fields edited, so the footer counts them and offers to save or discard. */
|
||||
export const OverviewUnsavedChanges: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement);
|
||||
await userEvent.type(await screen.findByTestId('dashboard-name'), ' (v2)');
|
||||
await userEvent.type(
|
||||
screen.getByTestId('dashboard-desc'),
|
||||
' Owned by the platform team.',
|
||||
);
|
||||
await screen.findByText('2 unsaved changes');
|
||||
},
|
||||
};
|
||||
|
||||
/** The icon picker beside the name, open on the system icons. */
|
||||
export const OverviewIconPicker: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const panel = await openSettings(canvasElement);
|
||||
|
||||
await userEvent.click(within(panel).getAllByRole('combobox')[0]);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync set to Tooltip, which adds the choice between every series and only the
|
||||
* ones that share the group-by.
|
||||
*/
|
||||
export const OverviewTooltipSync: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement);
|
||||
await userEvent.click(await screen.findByRole('radio', { name: 'Tooltip' }));
|
||||
await screen.findByText('Synced Tooltip Series');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Overview tab's own tooltip: Cross-Panel Sync explains what syncing does
|
||||
* and links out to the docs.
|
||||
*/
|
||||
export const TooltipsInOverview: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: Overview.play,
|
||||
};
|
||||
|
||||
/** The Publish tab of a dashboard with a public link. */
|
||||
export const Publish: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Publish');
|
||||
await screen.findByText('Default time range');
|
||||
},
|
||||
};
|
||||
|
||||
/** The Publish tab with its default time range select open. */
|
||||
export const PublishTimeRangeOpen: Story = {
|
||||
play: async (context) => {
|
||||
await Publish.play?.(context);
|
||||
await userEvent.click(
|
||||
within(screen.getByRole('tabpanel')).getByRole('combobox'),
|
||||
);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** The Publish tab of a dashboard nobody published: no link, one Publish button. */
|
||||
export const PublishPrivate: Story = {
|
||||
args: { published: false },
|
||||
// The 404 an unpublished dashboard answers with is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Publish');
|
||||
await screen.findByText('This dashboard is private');
|
||||
},
|
||||
};
|
||||
|
||||
/** The name edited and saved: the toast announcing the update, raised once the patch answers. */
|
||||
export const OverviewSavedToast: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openSettings(canvasElement);
|
||||
await userEvent.type(await screen.findByTestId('dashboard-name'), ' (v2)');
|
||||
await userEvent.click(await screen.findByTestId('save-dashboard-config'));
|
||||
if (args.dashboardPatch === 'success') {
|
||||
await screen.findByText('Dashboard updated');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Publish Dashboard on a private dashboard: the toast announcing the new link. */
|
||||
export const PublishedToast: Story = {
|
||||
args: { published: false },
|
||||
// The 404 an unpublished dashboard answers with is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openSettings(canvasElement, 'Publish');
|
||||
await userEvent.click(await screen.findByTestId('public-dashboard-publish'));
|
||||
if (args.publicWrite === 'success') {
|
||||
await screen.findByText('Dashboard published successfully');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Update Dashboard on a published dashboard: the toast confirming the new settings. */
|
||||
export const PublicUpdatedToast: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openSettings(canvasElement, 'Publish');
|
||||
await userEvent.click(await screen.findByTestId('public-dashboard-update'));
|
||||
if (args.publicWrite === 'success') {
|
||||
await screen.findByText('Public dashboard updated successfully');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Unpublish Dashboard: the toast confirming the link is gone. */
|
||||
export const UnpublishedToast: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openSettings(canvasElement, 'Publish');
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('public-dashboard-unpublish'),
|
||||
);
|
||||
if (args.publicWrite === 'success') {
|
||||
await screen.findByText('Dashboard unpublished successfully');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** The copy button beside the public link: the toast confirming the copy. */
|
||||
export const PublicUrlCopiedToast: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Publish');
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Copy link' }),
|
||||
);
|
||||
await screen.findByText('Copied public dashboard URL successfully');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
clickRowAction,
|
||||
confirmImpact,
|
||||
dashboardMocks,
|
||||
openSettings,
|
||||
openVariableEditor,
|
||||
typeVariableName,
|
||||
variableRow,
|
||||
} from '../DashboardPage.stories.mocks';
|
||||
|
||||
import DashboardPage from '../../DashboardPage';
|
||||
|
||||
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The Variables tab of dashboard settings: the variables in the order the bar
|
||||
* shows them, and what deleting, renaming or applying one does to the panels
|
||||
* that read it. Every panel filters on `$environment`, and `$service` reads it
|
||||
* too; nothing reads `$namespace` or `$owner`.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail/Variables',
|
||||
tags: ['authz', 'play'],
|
||||
component: DashboardPage as ComponentType<DashboardArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DashboardArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DashboardArgs>;
|
||||
|
||||
/** One variable of each type, listed by name and description. */
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await variableRow('owner');
|
||||
},
|
||||
};
|
||||
|
||||
/** A dashboard without variables, where the tab is its add-variable prompt. */
|
||||
export const Empty: Story = {
|
||||
args: { variables: [] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await screen.findByText('No variables yet');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A dynamic variable's Apply to all says whether it is already a filter on
|
||||
* every panel. The row keeps its actions invisible until it is hovered, which
|
||||
* the story does first.
|
||||
*/
|
||||
export const TooltipsInVariables: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
|
||||
// The tooltip trigger's Slot merge drops the button's own test id.
|
||||
await userEvent.hover(
|
||||
await screen.findByRole(
|
||||
'button',
|
||||
{ name: 'Apply to all' },
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByText(
|
||||
'Add this variable as a filter to every panel',
|
||||
undefined,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Deleting a variable nothing reads, which the row confirms inline. */
|
||||
export const DeleteConfirm: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await clickRowAction('owner', 'delete');
|
||||
await screen.findByText('Delete?');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Deleting `$environment`, which the panels and `$service` read, so each usage
|
||||
* is listed for review before anything is removed.
|
||||
*/
|
||||
export const DeleteReferenced: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await clickRowAction('environment', 'delete');
|
||||
await screen.findByText('Delete $environment');
|
||||
},
|
||||
};
|
||||
|
||||
/** Apply to all on `$namespace`: the filter it would add to every panel query. */
|
||||
export const ApplyToAll: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await clickRowAction('namespace', 'apply-all');
|
||||
await screen.findByText('Apply $namespace to panels');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Renaming `$environment` to `$env`: saving the editor holds the rename until
|
||||
* the rewritten queries are reviewed.
|
||||
*/
|
||||
export const RenameReferenced: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'environment');
|
||||
await typeVariableName('env');
|
||||
await userEvent.click(screen.getByTestId('variable-save'));
|
||||
await screen.findByText('Rename $environment');
|
||||
},
|
||||
};
|
||||
|
||||
/** Deleting a variable nothing reads, confirmed: the toast raised once the patch answers. */
|
||||
export const VariablesUpdatedToast: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await clickRowAction('owner', 'delete');
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Confirm delete' }),
|
||||
);
|
||||
if (args.dashboardPatch === 'success') {
|
||||
await screen.findByText('Variables updated');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Deleting `$environment` after reviewing its usages: the toast raised once the patch answers. */
|
||||
export const VariableDeletedToast: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await clickRowAction('environment', 'delete');
|
||||
await screen.findByText('Delete $environment');
|
||||
await confirmImpact();
|
||||
if (args.dashboardPatch === 'success') {
|
||||
await screen.findByText('Deleted $environment');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Deleting `$environment` whose patch is refused. */
|
||||
export const VariableDeleteFailedToast: Story = {
|
||||
args: { dashboardPatch: 'error' },
|
||||
// The refused patch is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async (context) => {
|
||||
await VariableDeletedToast.play?.(context);
|
||||
await screen.findByText('Could not delete the variable');
|
||||
},
|
||||
};
|
||||
|
||||
/** Renaming `$environment` to `$env` after reviewing the rewritten queries. */
|
||||
export const VariableRenamedToast: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openVariableEditor(canvasElement, 'environment');
|
||||
await typeVariableName('env');
|
||||
await userEvent.click(screen.getByTestId('variable-save'));
|
||||
await screen.findByText('Rename $environment');
|
||||
await confirmImpact();
|
||||
if (args.dashboardPatch === 'success') {
|
||||
await screen.findByText('Renamed to $env');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Renaming `$environment` whose patch is refused. */
|
||||
export const VariableRenameFailedToast: Story = {
|
||||
args: { dashboardPatch: 'error' },
|
||||
// The refused patch is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async (context) => {
|
||||
await VariableRenamedToast.play?.(context);
|
||||
await screen.findByText('Could not rename the variable');
|
||||
},
|
||||
};
|
||||
|
||||
/** Apply to all on `$namespace`, confirmed after the review. */
|
||||
export const VariableAppliedToast: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await clickRowAction('namespace', 'apply-all');
|
||||
await screen.findByText('Apply $namespace to panels');
|
||||
await confirmImpact();
|
||||
if (args.dashboardPatch === 'success') {
|
||||
await screen.findByText('Applied $namespace to panels');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Apply to all on `$namespace` whose patch is refused. */
|
||||
export const VariableApplyFailedToast: Story = {
|
||||
args: { dashboardPatch: 'error' },
|
||||
// The refused patch is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async (context) => {
|
||||
await VariableAppliedToast.play?.(context);
|
||||
await screen.findByText('Could not apply the variable to panels');
|
||||
},
|
||||
};
|
||||
@@ -3,9 +3,23 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
rest,
|
||||
type ResponseResolver,
|
||||
type RestContext,
|
||||
type RestRequest,
|
||||
} from 'msw';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
expect,
|
||||
mocked,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'storybook/test';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
@@ -21,14 +35,20 @@ import {
|
||||
dashboardViewsResponse,
|
||||
dashboardsListResponse,
|
||||
orgUsersResponse,
|
||||
PIN_WRITES,
|
||||
recentDashboardIds,
|
||||
ROW_MARKERS,
|
||||
savedView,
|
||||
seedPinnedDashboards,
|
||||
setDashboardPinned,
|
||||
STORY_USER_EMAIL,
|
||||
TEMPLATE_REQUESTS,
|
||||
TOOLTIP_TAGS,
|
||||
WRITE_STATES,
|
||||
type PinWrite,
|
||||
type RowMarker,
|
||||
type TemplateRequest,
|
||||
type WriteState,
|
||||
} from './__story_mockdata__/dashboardsList';
|
||||
import { useDashboardViewsStore } from '../store/useDashboardViewsStore';
|
||||
import {
|
||||
@@ -40,6 +60,7 @@ import { builtinViewQuery } from '../utils/views';
|
||||
|
||||
const LIST = 'Dashboards · list';
|
||||
const VIEWS = 'Dashboards · views';
|
||||
const WRITES = 'Dashboards · writes';
|
||||
|
||||
const VIEWS_OPTIONS = [
|
||||
BuiltinViewId.All,
|
||||
@@ -71,6 +92,42 @@ const writtenDashboard = (): GetDashboardV2200 =>
|
||||
|
||||
const ok = { status: 'success', data: null };
|
||||
|
||||
const serverError = {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: 'Something went wrong',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
};
|
||||
|
||||
const pinLimitError = {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'already_exists',
|
||||
message: 'Pin limit reached',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
};
|
||||
|
||||
/** Answers a write per its control: the `success` resolver, never, or a 500. */
|
||||
const gated =
|
||||
(
|
||||
state: WriteState,
|
||||
success: ResponseResolver<RestRequest, RestContext>,
|
||||
): ResponseResolver<RestRequest, RestContext> =>
|
||||
(req, res, ctx) => {
|
||||
if (state === 'loading') {
|
||||
return res(ctx.delay('infinite'));
|
||||
}
|
||||
|
||||
return state === 'error'
|
||||
? res(ctx.status(500), ctx.json(serverError))
|
||||
: success(req, res, ctx);
|
||||
};
|
||||
|
||||
/**
|
||||
* `formatQueryErrorMessage` strips the `invalid filter query:` prefix and turns
|
||||
* the backticks into quotes, so the message carries both to show it doing it.
|
||||
@@ -174,6 +231,48 @@ export const dashboardsListMocks = defineStoryMocks({
|
||||
'Answers the list with a 400 and a parse error, which is the Invalid query state: the backend message replaces the generic one and Retry is gone.',
|
||||
value: false,
|
||||
}),
|
||||
rowWrite: choiceControl<WriteState>('Row action', {
|
||||
group: WRITES,
|
||||
description:
|
||||
'How rename, tags, duplicate, lock and delete answer. `success` raises their toast, `loading` never answers, `error` opens the error modal instead.',
|
||||
options: WRITE_STATES,
|
||||
value: 'success',
|
||||
}),
|
||||
pinWrite: choiceControl<PinWrite>('Pin', {
|
||||
group: WRITES,
|
||||
description:
|
||||
'How pinning answers. `limit` refuses the pin with a 409 and raises the 10-pin toast, `error` fails both pin and unpin with a toast.',
|
||||
options: PIN_WRITES,
|
||||
value: 'success',
|
||||
}),
|
||||
createWrite: choiceControl<WriteState>('Create dashboard', {
|
||||
group: WRITES,
|
||||
description:
|
||||
'How the POST behind Create and Import answers. `error` raises a failure toast beside the error modal.',
|
||||
options: WRITE_STATES,
|
||||
value: 'success',
|
||||
}),
|
||||
migrationWrite: choiceControl<WriteState>('Retry migration', {
|
||||
group: WRITES,
|
||||
description:
|
||||
'How the legacy dashboard migration answers. `success` raises the migrated toast, `error` the failure toast.',
|
||||
options: WRITE_STATES,
|
||||
value: 'success',
|
||||
}),
|
||||
viewWrite: choiceControl<WriteState>('Saved view writes', {
|
||||
group: WRITES,
|
||||
description:
|
||||
'How saving, renaming and deleting a saved view answer. `error` raises the failure toast.',
|
||||
options: WRITE_STATES,
|
||||
value: 'success',
|
||||
}),
|
||||
templateRequest: choiceControl<TemplateRequest>('Template request', {
|
||||
group: WRITES,
|
||||
description:
|
||||
'How the analytics event behind "Request a new template" answers: `error` returns a failed response, `rejected` throws.',
|
||||
options: TEMPLATE_REQUESTS,
|
||||
value: 'success',
|
||||
}),
|
||||
view: choiceControl<ViewOption>('Active view', {
|
||||
group: VIEWS,
|
||||
description:
|
||||
@@ -237,6 +336,12 @@ export const dashboardsListMocks = defineStoryMocks({
|
||||
rest.put(
|
||||
'http://localhost/api/v2/users/me/dashboards/:id/pins',
|
||||
(req, res, ctx) => {
|
||||
if (values.pinWrite !== 'success') {
|
||||
return values.pinWrite === 'limit'
|
||||
? res(ctx.status(409), ctx.json(pinLimitError))
|
||||
: res(ctx.status(500), ctx.json(serverError));
|
||||
}
|
||||
|
||||
setDashboardPinned(String(req.params.id), true);
|
||||
|
||||
return res(ctx.status(200), ctx.json(ok));
|
||||
@@ -246,44 +351,75 @@ export const dashboardsListMocks = defineStoryMocks({
|
||||
rest.delete(
|
||||
'http://localhost/api/v2/users/me/dashboards/:id/pins',
|
||||
(req, res, ctx) => {
|
||||
if (values.pinWrite === 'error') {
|
||||
return res(ctx.status(500), ctx.json(serverError));
|
||||
}
|
||||
|
||||
setDashboardPinned(String(req.params.id), false);
|
||||
|
||||
return res(ctx.status(200), ctx.json(ok));
|
||||
},
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v2/dashboards', (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json(writtenDashboard())),
|
||||
rest.post(
|
||||
'http://localhost/api/v2/dashboards',
|
||||
gated(values.createWrite, (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json(writtenDashboard())),
|
||||
),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v2/dashboards/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(writtenDashboard())),
|
||||
rest.put(
|
||||
'http://localhost/api/v2/dashboards/:id',
|
||||
gated(values.rowWrite, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(writtenDashboard())),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v2/dashboards/:id/clone', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(writtenDashboard())),
|
||||
rest.patch(
|
||||
'http://localhost/api/v2/dashboards/:id',
|
||||
gated(values.rowWrite, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(writtenDashboard())),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/dashboards/:id/clone',
|
||||
gated(values.rowWrite, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(writtenDashboard())),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/dashboards/:id/migrate',
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(writtenDashboard())),
|
||||
gated(values.migrationWrite, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(writtenDashboard())),
|
||||
),
|
||||
),
|
||||
|
||||
rest.delete('http://localhost/api/v2/dashboards/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(ok)),
|
||||
rest.delete(
|
||||
'http://localhost/api/v2/dashboards/:id',
|
||||
gated(values.rowWrite, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(ok)),
|
||||
),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v2/dashboards/:id/lock', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(ok)),
|
||||
rest.put(
|
||||
'http://localhost/api/v2/dashboards/:id/lock',
|
||||
gated(values.rowWrite, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(ok)),
|
||||
),
|
||||
),
|
||||
|
||||
rest.delete('http://localhost/api/v2/dashboards/:id/lock', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(ok)),
|
||||
rest.delete(
|
||||
'http://localhost/api/v2/dashboards/:id/lock',
|
||||
gated(values.rowWrite, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(ok)),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/dashboard_views',
|
||||
async (req, res, ctx) => {
|
||||
gated(values.viewWrite, async (req, res, ctx) => {
|
||||
const body = (await req.json()) as { name: string };
|
||||
|
||||
return res(
|
||||
@@ -298,12 +434,12 @@ export const dashboardsListMocks = defineStoryMocks({
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
rest.put(
|
||||
'http://localhost/api/v2/dashboard_views/:id',
|
||||
async (req, res, ctx) => {
|
||||
gated(values.viewWrite, async (req, res, ctx) => {
|
||||
const body = (await req.json()) as { name: string; data: unknown };
|
||||
|
||||
return res(
|
||||
@@ -318,15 +454,37 @@ export const dashboardsListMocks = defineStoryMocks({
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
rest.delete('http://localhost/api/v2/dashboard_views/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(ok)),
|
||||
rest.delete(
|
||||
'http://localhost/api/v2/dashboard_views/:id',
|
||||
gated(values.viewWrite, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(ok)),
|
||||
),
|
||||
),
|
||||
],
|
||||
config: (values) => ({ route: listRoute(values.view) }),
|
||||
effect: (values) => {
|
||||
mocked(logEvent).mockImplementation(async () => {
|
||||
if (values.templateRequest === 'rejected') {
|
||||
throw new Error('Event rejected');
|
||||
}
|
||||
|
||||
return values.templateRequest === 'error'
|
||||
? {
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
error: 'Event rejected',
|
||||
message: null,
|
||||
}
|
||||
: {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'success',
|
||||
payload: { status: 'success', data: '' },
|
||||
};
|
||||
});
|
||||
seedPinnedDashboards(
|
||||
values.markers.includes('pinned')
|
||||
? Array.from({ length: PINNED_COUNT }, (_unused, index) =>
|
||||
@@ -342,3 +500,105 @@ export const dashboardsListMocks = defineStoryMocks({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Opens the actions menu of the row at `index`. */
|
||||
export const openRowActions = async (
|
||||
canvasElement: HTMLElement,
|
||||
index: number,
|
||||
): Promise<void> => {
|
||||
// The icon-only trigger carries no accessible name.
|
||||
const triggers = await within(canvasElement).findAllByTestId(
|
||||
'dashboard-action-icon',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
await userEvent.click(triggers[index]);
|
||||
await screen.findByText('Rename');
|
||||
};
|
||||
|
||||
/** Picks a row action, retrying while its permission check still disables it. */
|
||||
export const pickRowAction = async (label: string | RegExp): Promise<void> => {
|
||||
await waitFor(
|
||||
async () => {
|
||||
await userEvent.click(screen.getByText(label));
|
||||
await screen.findByRole('dialog', {}, { timeout: 500 });
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
};
|
||||
|
||||
/** Picks a row action once its permission check has enabled it. */
|
||||
export const clickRowAction = async (testId: string): Promise<void> => {
|
||||
const item = await screen.findByTestId(testId);
|
||||
|
||||
await waitFor(
|
||||
async () => {
|
||||
await expect(item).toBeEnabled();
|
||||
await expect(item).not.toHaveAttribute('data-disabled');
|
||||
await expect(item).not.toHaveAttribute('aria-disabled', 'true');
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await userEvent.click(item);
|
||||
};
|
||||
|
||||
export const TOAST_TIMEOUT = 10000;
|
||||
|
||||
export const openLegacyDialog = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'dashboard-title-4',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
await screen.findByTestId('legacy-dashboard-id');
|
||||
};
|
||||
|
||||
export const retryMigration = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await openLegacyDialog(canvasElement);
|
||||
const retry = await screen.findByTestId('legacy-dashboard-retry-migration');
|
||||
|
||||
await waitFor(() => expect(retry).toBeEnabled(), { timeout: TOAST_TIMEOUT });
|
||||
await userEvent.click(retry);
|
||||
};
|
||||
|
||||
export const uploadDashboardJson = async (json: object): Promise<void> => {
|
||||
await userEvent.click(await screen.findByText('Import JSON'));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
const input = dialog.querySelector<HTMLInputElement>('input[type="file"]');
|
||||
|
||||
if (!input) {
|
||||
throw new Error('The import dialog has no file input');
|
||||
}
|
||||
await userEvent.upload(
|
||||
input,
|
||||
new File([JSON.stringify(json)], 'dashboard.json', {
|
||||
type: 'application/json',
|
||||
}),
|
||||
);
|
||||
await userEvent.click(await screen.findByTestId('import-json-submit'));
|
||||
};
|
||||
|
||||
export const requestTemplate = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'new-dashboard-cta',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
await userEvent.click(await screen.findByText('From a template'));
|
||||
await userEvent.type(
|
||||
await screen.findByTestId('request-dashboard-name'),
|
||||
'Redis overview',
|
||||
);
|
||||
await userEvent.click(await screen.findByTestId('request-dashboard-submit'));
|
||||
};
|
||||
|
||||
@@ -5,8 +5,16 @@ import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
clickRowAction,
|
||||
dashboardsListMocks,
|
||||
openLegacyDialog,
|
||||
openRowActions,
|
||||
overflowingRows,
|
||||
pickRowAction,
|
||||
requestTemplate,
|
||||
retryMigration,
|
||||
TOAST_TIMEOUT,
|
||||
uploadDashboardJson,
|
||||
} from './DashboardsListPage.stories.mocks';
|
||||
import { BuiltinViewId } from '../types';
|
||||
|
||||
@@ -114,6 +122,415 @@ export const Tooltips: Story = {
|
||||
parameters: { msw: { handlers: [overflowingRows] } },
|
||||
};
|
||||
|
||||
/** The first row's actions menu, open over the list. */
|
||||
export const RowActionsMenu: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openRowActions(canvasElement, 0);
|
||||
},
|
||||
};
|
||||
|
||||
/** The rename dialog, opened from the menu of the second row (the first is locked). */
|
||||
export const RenameDashboardDialog: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openRowActions(canvasElement, 1);
|
||||
await pickRowAction('Rename');
|
||||
await screen.findByRole('dialog', { name: 'Rename dashboard' });
|
||||
},
|
||||
};
|
||||
|
||||
/** The tags dialog, opened from the menu of the second row (the first is locked). */
|
||||
export const EditTagsDialog: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openRowActions(canvasElement, 1);
|
||||
await pickRowAction(/^(Edit|Add) Tags$/);
|
||||
await screen.findByRole('dialog', { name: /^(Edit|Add) tags$/ });
|
||||
},
|
||||
};
|
||||
|
||||
/** The popover that names the current filters as a new saved view. */
|
||||
export const SaveViewPopover: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: 'Save current filters as a view' },
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByText('Save as view');
|
||||
},
|
||||
};
|
||||
|
||||
/** The renamed toast, raised once the PATCH answers. */
|
||||
export const DashboardRenamedToast: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openRowActions(canvasElement, 1);
|
||||
await pickRowAction('Rename');
|
||||
const input = await screen.findByTestId('rename-dashboard-input');
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'API latency overview');
|
||||
await userEvent.click(await screen.findByTestId('rename-dashboard-submit'));
|
||||
if (args.rowWrite === 'success') {
|
||||
await screen.findByText('Dashboard renamed', {}, { timeout: TOAST_TIMEOUT });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** The tags toast, raised once the PATCH answers. */
|
||||
export const TagsUpdatedToast: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openRowActions(canvasElement, 1);
|
||||
await pickRowAction(/^(Edit|Add) Tags$/);
|
||||
const input = await screen.findByPlaceholderText('key:value (press Enter)');
|
||||
|
||||
await userEvent.type(input, 'owner:sre{Enter}');
|
||||
await userEvent.click(await screen.findByTestId('edit-tags-submit'));
|
||||
if (args.rowWrite === 'success') {
|
||||
await screen.findByText('Tags updated', {}, { timeout: TOAST_TIMEOUT });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Duplicate from the row menu: the toast names the source dashboard. */
|
||||
export const DashboardDuplicatedToast: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openRowActions(canvasElement, 1);
|
||||
await clickRowAction('dashboard-action-duplicate');
|
||||
if (args.rowWrite === 'success') {
|
||||
await screen.findByText(
|
||||
'Duplicated "API latency and errors"',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Lock from the row menu of an unlocked dashboard. */
|
||||
export const DashboardLockedToast: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openRowActions(canvasElement, 1);
|
||||
await clickRowAction('dashboard-action-lock');
|
||||
if (args.rowWrite === 'success') {
|
||||
await screen.findByText('Dashboard locked', {}, { timeout: TOAST_TIMEOUT });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Unlock from the row menu of the first row, which is locked. */
|
||||
export const DashboardUnlockedToast: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openRowActions(canvasElement, 0);
|
||||
await clickRowAction('dashboard-action-lock');
|
||||
if (args.rowWrite === 'success') {
|
||||
await screen.findByText(
|
||||
'Dashboard unlocked',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Delete confirmed in the dialog the row menu opens. */
|
||||
export const DashboardDeletedToast: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openRowActions(canvasElement, 1);
|
||||
await clickRowAction('dashboard-action-delete');
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
|
||||
await userEvent.click(
|
||||
await within(dialog).findByRole('button', { name: 'Delete' }),
|
||||
);
|
||||
if (args.rowWrite === 'success') {
|
||||
await screen.findByText(
|
||||
'Dashboard deleted successfully',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Pinning past the limit: the backend refuses with a 409. */
|
||||
export const PinLimitToast: Story = {
|
||||
args: { pinWrite: 'limit' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'dashboard-pin-3',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
await screen.findByText(
|
||||
/You can pin up to 10 dashboards/,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
},
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** A pin the backend failed to write. */
|
||||
export const PinFailedToast: Story = {
|
||||
args: { pinWrite: 'error' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'dashboard-pin-3',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
await screen.findByText(
|
||||
'Failed to pin dashboard.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
},
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** An unpin the backend failed to write, from the first (pinned) row. */
|
||||
export const UnpinFailedToast: Story = {
|
||||
args: { pinWrite: 'error' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'dashboard-pin-0',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
await screen.findByText(
|
||||
'Failed to unpin dashboard.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
},
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** The id copied from the legacy dashboard dialog. */
|
||||
export const LegacyDashboardIdCopiedToast: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openLegacyDialog(canvasElement);
|
||||
await userEvent.click(await screen.findByTestId('legacy-dashboard-copy-id'));
|
||||
await screen.findByText(
|
||||
'Dashboard ID copied',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Retry migration of the legacy dashboard succeeding. */
|
||||
export const DashboardMigratedToast: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await retryMigration(canvasElement);
|
||||
if (args.migrationWrite === 'success') {
|
||||
await screen.findByText(
|
||||
'Dashboard migrated to the new experience',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Retry migration of the legacy dashboard refused by the backend. */
|
||||
export const DashboardMigrationFailedToast: Story = {
|
||||
args: { migrationWrite: 'error' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await retryMigration(canvasElement);
|
||||
await screen.findByText(
|
||||
'Something went wrong',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
},
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** The Blank tab's create failing: the toast raised beside the error modal. */
|
||||
export const CreateDashboardFailedToast: Story = {
|
||||
args: { createWrite: 'error' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'new-dashboard-cta',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
await userEvent.click(await screen.findByTestId('create-dashboard-submit'));
|
||||
await screen.findAllByText(
|
||||
/Something went wrong/,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
},
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
const IMPORTED_SPEC = { layouts: [], panels: {}, variables: [] };
|
||||
|
||||
/** A dashboard file whose `image` is neither an asset path nor base64. */
|
||||
export const ImportJsonInvalidImageToast: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'new-dashboard-cta',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
await uploadDashboardJson({
|
||||
schemaVersion: 'v6',
|
||||
image: 'https://example.com/logo.png',
|
||||
spec: { display: { name: 'Imported' }, ...IMPORTED_SPEC },
|
||||
});
|
||||
await screen.findByText(
|
||||
/Dashboard "image" must be/,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** A valid dashboard file the create endpoint refuses. */
|
||||
export const ImportJsonCreateFailedToast: Story = {
|
||||
args: { createWrite: 'error' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'new-dashboard-cta',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
await uploadDashboardJson({
|
||||
schemaVersion: 'v6',
|
||||
spec: { display: { name: 'Imported' }, ...IMPORTED_SPEC },
|
||||
});
|
||||
await screen.findAllByText(
|
||||
/Something went wrong/,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
},
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** A template request the analytics endpoint accepted. */
|
||||
export const TemplateRequestSubmittedToast: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await requestTemplate(canvasElement);
|
||||
await screen.findByText(
|
||||
'Dashboard request submitted',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** A template request the analytics endpoint answered with a failure. */
|
||||
export const TemplateRequestFailedToast: Story = {
|
||||
args: { templateRequest: 'error' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await requestTemplate(canvasElement);
|
||||
await screen.findByText('Event rejected', {}, { timeout: TOAST_TIMEOUT });
|
||||
},
|
||||
};
|
||||
|
||||
/** A template request that threw before an answer came back. */
|
||||
export const TemplateRequestRejectedToast: Story = {
|
||||
args: { templateRequest: 'rejected' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await requestTemplate(canvasElement);
|
||||
await screen.findByText(
|
||||
'Something went wrong',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Saving the current filters as a view, refused by the backend. */
|
||||
export const SaveViewFailedToast: Story = {
|
||||
args: { viewWrite: 'error' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: 'Save current filters as a view' },
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
await userEvent.type(await screen.findByTestId('save-view-name'), 'On-call');
|
||||
await userEvent.click(await screen.findByTestId('save-view-confirm'));
|
||||
await screen.findByText(
|
||||
'Failed to save view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
},
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** Renaming a saved view, refused by the backend. */
|
||||
export const RenameViewFailedToast: Story = {
|
||||
args: { viewWrite: 'error' },
|
||||
play: async ({ canvasElement }) => {
|
||||
const [rename] = await within(canvasElement).findAllByRole(
|
||||
'button',
|
||||
{ name: 'Rename view' },
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
|
||||
// The actions only take pointer events while the row is hovered by CSS.
|
||||
await userEvent.click(rename, { pointerEventsCheck: 0 });
|
||||
const input = await screen.findByTestId('rename-view-name');
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'Renamed view');
|
||||
await userEvent.click(await screen.findByTestId('rename-view-confirm'));
|
||||
await screen.findByText(
|
||||
'Failed to update view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
},
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** Deleting a saved view, refused by the backend. */
|
||||
export const DeleteViewFailedToast: Story = {
|
||||
args: { viewWrite: 'error' },
|
||||
play: async ({ canvasElement }) => {
|
||||
const [remove] = await within(canvasElement).findAllByRole(
|
||||
'button',
|
||||
{ name: 'Delete view' },
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
|
||||
await userEvent.click(remove, { pointerEventsCheck: 0 });
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
|
||||
await userEvent.click(
|
||||
await within(dialog).findByRole('button', { name: 'Delete' }),
|
||||
);
|
||||
await screen.findByText(
|
||||
'Failed to delete view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
},
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* The query the backend refused: the parse error it returned replaces the
|
||||
* generic failure copy, and there is nothing to retry.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user