Compare commits

...

23 Commits

Author SHA1 Message Date
Ashwin Bhatkal
ff5280b077 chore(notification-channels): remove the v1 channel client and the old list 2026-09-24 08:10:21 +05:30
Ashwin Bhatkal
8d704c4bd2 feat(authz): gate the notification channel screens on the new permissions 2026-09-24 08:10:12 +05:30
Ashwin Bhatkal
36581e6f31 feat(authz): add notification channel permission helpers 2026-09-24 08:10:11 +05:30
Ashwin Bhatkal
e1c000389a feat(authz): expose notification channels as a fine-grained resource 2026-09-24 08:10:11 +05:30
Ashwin Bhatkal
9531dec1dd test(notification-channels): move the channel story mocks onto the v2 API 2026-09-24 08:09:48 +05:30
Ashwin Bhatkal
eeadaeb38a test(notification-channels): point the channel e2e specs at the v2 screen 2026-09-24 08:09:48 +05:30
Ashwin Bhatkal
9942f29c03 refactor(notification-channels): read channel pickers from the v2 list API 2026-09-24 08:09:48 +05:30
Ashwin Bhatkal
ba4fe13f15 fix(notification-channels): keep the alerts page mounted when opening a channel 2026-09-24 08:09:48 +05:30
Ashwin Bhatkal
fdd6884549 feat(notification-channels): rebuild the channels list on the shared table 2026-09-24 08:09:48 +05:30
Ashwin Bhatkal
25f8460667 feat(notification-channels): add a read-only JSON view for a channel 2026-09-24 08:09:48 +05:30
Ashwin Bhatkal
f391704916 refactor(notification-channels): move the channel form onto the v2 schemas and API 2026-09-24 08:09:48 +05:30
Abhi kumar
2c09fedde1 fix(dashboard): panel UX fixes — bar gap, tooltip date, palette, legend search (#12959)
Some checks are pending
build-staging / staging (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

Four independent panel-UX fixes from the dashboards epic, one commit
each.

- **Bar gap.** The gap between bars was wider than half a bar. The bar
width factor goes 0.6 → 0.85, leaving just enough to separate them.
Shared by bar and histogram panels.
- **Tooltip date.** A tooltip on a point from today now shows only the
time; the date still appears for any other day.
- **Red in the palette.** Roughly one series in six was being coloured
red — 18 of 117 entries in `chartcolors`, 15 of 74 in `lightModeColor` —
which spends the one colour that should mean "something is wrong". Each
red entry is rotated onto a free hue with its original lightness
preserved and saturation clamped, and its key renamed to match. Entries
are replaced, never removed: `generateColor` indexes by `hash %
Object.keys(...).length`, so changing the count would recolour every
existing chart. Pinks and magentas are left alone.
- **Bottom legend search.** A bottom legend that overflows the rows the
panel reserves for it now gets a search box and a "Showing N of M
series" readout, on a single row above the series. Whether that row
exists is the chart layout's call, since it is the layout that reserves
the height — a row nobody reserved would eat a row of series. One
`LegendToolbar` serves both placements, with position driving the layout
only, so the right column is unchanged. The readout counts the rows the
search left listed, against the whole series set.

#### Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/3959
Closes https://github.com/SigNoz/engineering-pod/issues/3967
Closes https://github.com/SigNoz/engineering-pod/issues/3976
Closes https://github.com/SigNoz/engineering-pod/issues/3977

#### Additional Information

- The palette replacements are computed (even spread across the non-red
arc), not hand-designed — worth a visual pass across light and dark
before merging.
- Two tests asserted a palette hex for a given series label and are
updated to the new value.
- The search row's 24px height and 4px gap are pinned as
`LEGEND_TOOLBAR_HEIGHT` / `LEGEND_TOOLBAR_GAP` beside the existing row
constants; they must match the stylesheet or the reserved rectangle
clips a row.

<img width="1611" height="634" alt="image"
src="https://github.com/user-attachments/assets/2c21a458-e142-4e4f-baff-5bbcbb072140"
/>
2026-09-23 18:58:55 +00:00
Nityananda Gohain
5a1be60745 fix(ai-o11y): scope overview dashboard to gen_ai spans and move message attributes (#12967)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- Top span names and Cost by service use `builder_ai_query`, so non-AI
spans no longer show up.
- LLM cost/token panels filter on `gen_ai.request.model EXISTS`; with
variables on "All" they scanned every span.
- Default span mappers now move (not copy) vendor message keys into
`gen_ai.input.messages` / `gen_ai.output.messages`, as documented.
- Bumped versions: dashboard to 3, `gen_ai.llm` mapper to 3,
`gen_ai.agent` mapper to 2.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/engineering-pod/issues/6107
2026-09-23 14:56:04 +00:00
Gaurav Tewari
cad93a8063 chore: remove beta tag class (#12965)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Removed beta tag class from AI o11y . 
since it remove margin and breaks alignment. don't see any use of it .
it also shifts the pin icon a bit

before - 
<img width="149" height="160" alt="image"
src="https://github.com/user-attachments/assets/26770802-b5b0-452b-b66a-fecda0d49c38"
/>
 
 
<img width="331" height="152" alt="image"
src="https://github.com/user-attachments/assets/fba73ebe-2655-4de5-8488-eb6bb0bfebca"
/>


 now - 
 
<img width="158" height="192" alt="image"
src="https://github.com/user-attachments/assets/fb0ca483-e8db-4ff0-bb36-e0186c2e9458"
/>

<img width="272" height="63" alt="image"
src="https://github.com/user-attachments/assets/68486c3f-a704-4077-82e5-089452abf773"
/>

 



it was added for long text .
https://github.com/SigNoz/signoz/pull/5801/changes#r1736497688
for now in side nav we don't have any long text. and maybe we could have
handled this better
 

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

<!--Please delete paragraphs that you did not use before submitting.-->

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-23 11:45:33 +00:00
Abhi kumar
aed096bf27 refactor(query-builder): compose the panel-type field map instead of listing it (#12781)
#### Description

`panelTypeDataSourceFormValuesMap` — the map deciding which builder
fields survive a panel-type switch — spelled out all 21 panel-type ×
data-source combinations as literal field lists, 435 lines of them.

They reduce to seven distinct sets:

- logs and traces carry **identical** fields for every panel type
- metrics adds its two aggregation steps (`timeAggregation`,
`spaceAggregation`)
- every panel type is one of four query shapes: series, scalar table,
single value, raw rows

Much of the apparent variation was ordering noise — a bar chart and a
table on logs have the *same* field set, listed in a different order.

Composed from those rules it's 84 lines, and the policy is legible at a
glance: charts, table and pie share a surface; table and pie differ only
by `reduceTo` on metrics; a single value has nothing to group, limit or
order; raw rows carry no aggregation. Two asymmetries that were buried
in the literals are now called out where they're decided, rather than
silently reproduced.

**No behaviour change.** Adding a panel type becomes one line — "which
shape is it?"

#### Additional Information

- Equivalence was checked cell by cell against the previous literal
table before it was removed; all 21 cells matched as sets. The old table
is in git history at `main:frontend/src/lib/query/panelQuery.ts` if you
want to re-run that comparison.
- The specs pin the **rules**, not the values, so they fail when a rule
changes — the moment to stop and decide — rather than whenever a field
moves. Two are worth reading:
- *"gives bar / histogram / table / pie the same non-metrics fields as a
time series"* states the hazard composing introduces: the aggregating
types share one field list, so an edit meant for charts reaches table
and pie too. A failure there names the reason.
- *"gives every cell its own array instance"* — the `QueryBuilder`
provider does `propsRequired?.push('dataSource')` on the list it reads
from this map, so cells sharing an instance would leak fields into each
other. My first draft shared one array across 10 cells; this test is
what guards it.
- Order is not asserted anywhere: `handleQueryChange` and the provider
both assign each field independently via `set()`, so sequence carries no
meaning.
- Three consumers, all exercised: `handleQueryChange` (dashboards v2's
kind switcher and V1's `PanelTypeSelector`) and the shared
`QueryBuilder` provider every explorer uses. Verified with `tsgo`,
`oxlint`, and the `lib` / `providers` / `WidgetCard` / Logs+Traces
explorer / `DashboardPage` suites: 243 suites, 2056 tests.
- Pre-existing and deliberately left alone: that `push` mutates module
state, so the arrays grow by one `'dataSource'` entry on every
query-builder change. Harmless today only because the assignment is
idempotent; `[...propsRequired, 'dataSource']` would fix it, but that's
the provider's bug, not this map's.
2026-09-23 11:24:51 +00:00
Nityananda Gohain
6b66ab64c8 fix: add ai-o11y quick filter migration (#12964)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
Added migration to update old instances where quick filters for ai-o11y
is not present.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
part of https://github.com/SigNoz/engineering-pod/issues/6107
2026-09-23 11:15:42 +00:00
Naman Verma
362d3a4fdf fix: backfill notification channel tuples (#12960)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
cacheci / tests (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Same migration logic as number 128. Needed for enterprise servers as
these tuples decide whether a role may create, list, read, update or
delete channels at all. An existing org with zero channels still needs
them, otherwise its admin can't create the first one or even list the
empty page.
2026-09-23 09:48:11 +00:00
Jugal Kishore
ff80758e1c feat(onboarding): add Cursor, Cline, vLLM, SGLang, Karpenter datasources (#12944)
#### Description

- Adds 20 data sources to the onboarding picker: Cursor, Antigravity
CLI, Qwen Code, DeepSeek Harness, Meta Muse Code, Meta Muse Spark,
OpenAI Agents SDK, Cline, E2B Sandbox, Daytona Sandbox, Vercel Sandbox,
Modal, vLLM, SGLang, Karpenter, Podman, EMQX, AWS RDS Aurora, GCP Cloud
Storage, and AWS Lambda MicroVMs.
- Go now asks for an instrumentation method (SDK / compile-time `otelc`
/ eBPF) before the environment question, so the new zero-code guides are
reachable. The card links to the new comparison overview.
- The Lambda → Traces question now asks how the function is packaged
instead of which runtime it uses, and gains a Container Image option.
Layers do not attach to container images, so runtime alone did not pick
the right guide.
- GCP Cloud Storage points at `/integrations/gcp?service=cloudstorage`
rather than a doc, matching the Cloud SQL and Memorystore cards.
`cloudstorage` is a first-class cloud integration with its own
dashboard, so the in-app flow is the one users want.
- New logos: `antigravity`, `aurora`, `cline`, `cursor`, `daytona`,
`e2b`, `emqx`, `karpenter`, `meta`, `modal`, `podman`, `sglang`, `vllm`.
Qwen Code, OpenAI Agents SDK and Lambda MicroVMs reuse existing marks.
- Recolours 15 pre-existing logos that were pure white or near-black and
disappeared against one of the two card backgrounds (`--l2-background`
is `#121317` dark, `#F9F9FB` light): `anthropic-api-monitoring`,
`clickhouse`, `confluent-kafka`, `datadog`, `deno`, `document-load`,
`from-log-file`, `haystack`, `kafka`, `langchain`, `ollama`, `openai`,
`openrouter`, `vercel`, `zap`. Brand hue kept where the brand has one,
adjusted to clear 3:1 against both; black-or-white marks use a neutral
grey.

#### Issues closed by this PR

Closes SigNoz/signoz.io#4205
Closes SigNoz/signoz.io#4197
Closes SigNoz/signoz.io#4178
Closes SigNoz/signoz.io#4171
Closes SigNoz/signoz.io#4167
Closes SigNoz/signoz.io#4153
Closes SigNoz/signoz.io#4144
Closes SigNoz/signoz.io#4133
Closes SigNoz/signoz.io#4131
Closes SigNoz/signoz.io#4129
Closes SigNoz/signoz.io#4125
Closes SigNoz/signoz.io#4108
Closes SigNoz/signoz.io#4092
Closes SigNoz/signoz.io#4088
Closes SigNoz/signoz.io#4082
Closes SigNoz/signoz.io#4074
Closes SigNoz/signoz.io#4063
Closes SigNoz/signoz.io#4032
Closes SigNoz/signoz.io#4024
2026-09-23 07:54:02 +00:00
Vinicius Lourenço
f9d425bf5c fix(storybook): remove timing races from story tests (#12954)
#### Description

A few tweaks to improve the resiliency of the storybook tests under CI
stress.
2026-09-23 07:46:27 +00:00
Abhi kumar
7ce73f3470 fix(dashboard): don't re-run an errored panel query on scroll back into view (#12958)
#### Description

- Lazy-loaded panels toggle `enabled` on viewport visibility.
react-query treats a key with no data as stale regardless of
`staleTime`, so an errored panel refetched (with retries on 5xx) every
time it scrolled back into view.
- `useGetQueryRangeV5` now keeps an errored key enabled, so only a key
change (time, variables, query) or the Retry button re-runs it. A new
key still stays gated while off-screen.
- Adds a `useGetQueryRangeV5` test suite covering the gating paths.
2026-09-23 07:04:51 +00:00
Nityananda Gohain
5aca8b0d3c chore: remove ai-o11y ff (#12947)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
Remove ai-o11y FF and enable it by default

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/engineering-pod/issues/6107
2026-09-23 06:39:16 +00:00
Gaurav Tewari
f1c9e0f1d0 feat(llm-observability): add ai o11y analytics events (#12952)
#### Description

- Renames AI Observability explorer events from `Traces Explorer: *` to
`AI Observability Explorer: *`, so they no longer mix with the regular
Traces Explorer events.
- Adds page-visit events for Overview, Attribute Mapping and Model
Pricing.
- Adds action events: attribute mapping saved and test run; model cost
saved and deleted; unpriced model mapped.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-23 05:05:06 +00:00
Gaurav Tewari
10c0af327b fix: failing e2e for llm (#12953)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Attribute-mapping e2e now adds a condition key when creating its
group.
- Since #12809 the backend rejects groups without conditions (`400
condition must list at least one attribute or resource substring`), so
the spec failed on save.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

- Follow-up: we should add check on frontend as well for #12809 ( we
have already decided to add this later )

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-23 04:43:36 +00:00
231 changed files with 5769 additions and 6448 deletions

View File

@@ -93,17 +93,18 @@ func runGenerateAuthz(_ context.Context) error {
registry := coretypes.NewRegistry()
allowedResources := map[string]bool{
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceDashboard).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceLicense).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSubscription).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMeterMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceQuickFilter).String(): true,
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceDashboard).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceLicense).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSubscription).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMeterMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceQuickFilter).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceNotificationChannel).String(): true,
}
allowedTypes := map[string]bool{}

View File

@@ -80,15 +80,6 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
aiObservability := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),
Active: aiObservability,
Usage: 0,
UsageLimit: -1,
Route: "",
})
metricsReduction := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableMetricsReduction, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureEnableMetricsReduction.String()),

View File

@@ -1,6 +1,7 @@
import type { Preview } from '@storybook/react-vite';
import type { SetupWorker } from 'msw';
import { setupWorker } from 'msw';
import { configure } from 'storybook/test';
import { settleForCapture } from '../src/storybook/visual/settleForCapture';
import PageDocs from '../src/storybook/docs/PageDocs';
@@ -83,6 +84,10 @@ const translationsReady = i18n.loadNamespaces(
),
);
// testing-library's 1s default for `findBy*` and `waitFor` is shorter than a
// popup or a query takes to land on a loaded CI runner.
configure({ asyncUtilTimeout: 10_000 });
const preview: Preview = {
parameters: {
layout: 'fullscreen',

View File

@@ -30,6 +30,8 @@ interface CapturedMessage {
text: string;
}
const PREPARE_TIMEOUT_MS = 180_000;
const messagesByPage = new WeakMap<Page, CapturedMessage[]>();
/**
@@ -52,6 +54,15 @@ const config: TestRunnerConfig = {
// msw logs every mocked request at `log`; keep it out of the failure dump
// unless the job is re-run with debug logging (GitHub sets RUNNER_DEBUG=1).
logLevel: process.env.RUNNER_DEBUG === '1' ? 'info' : 'warn',
// The runner's default prepare, minus Playwright's 30s navigation timeout: on
// a cold dev server the first load compiles the whole preview, which outlasts
// it on a shared CI runner.
async prepare({ page }): Promise<void> {
await page.goto(new URL('iframe.html', process.env.TARGET_URL).toString(), {
waitUntil: 'load',
timeout: PREPARE_TIMEOUT_MS,
});
},
async preVisit(page): Promise<void> {
const existing = messagesByPage.get(page);
if (existing) {

View File

@@ -151,5 +151,37 @@
"slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)",
"api_key_required": "API Key is mandatory",
"to_required": "To field is mandatory",
"channel_name_required": "Channel name is mandatory"
}
"channel_name_required": "Channel name is mandatory",
"page_title_view": "View Notification Channel",
"pager_details_invalid_json": "Enter valid JSON, or clear the box to send no details",
"routing_key_required": "Routing Key is mandatory for creating pagerduty channel",
"field_slack_title_link": "Title link",
"field_slack_color": "Color",
"help_slack_color": "good, warning, danger, or a hex value like #439FE0. Templates are allowed.",
"placeholder_slack_color": "{{ if eq .Status \"firing\" }}danger{{ else }}good{{ end }}",
"field_slack_pretext": "Pretext",
"help_slack_pretext": "Shown above the attachment block",
"field_slack_fallback": "Fallback text",
"help_slack_fallback": "Plain text shown where the attachment cannot render, such as push notifications",
"field_slack_footer": "Footer",
"field_slack_fields": "Fields",
"help_slack_fields": "Extra entries rendered as a table inside the attachment",
"placeholder_slack_field_title": "Title",
"placeholder_slack_field_value": "Value",
"field_slack_field_short": "Short",
"add_slack_field": "Add field",
"remove_slack_field": "Remove field",
"field_slack_actions": "Actions",
"help_slack_actions": "Buttons rendered under the attachment. A button with a URL links out.",
"placeholder_slack_action_text": "Button text",
"placeholder_slack_action_url": "https://runbook.example.com",
"placeholder_slack_action_type": "button",
"placeholder_slack_action_name": "Name (Slack app callbacks)",
"placeholder_slack_action_value": "Value (Slack app callbacks)",
"placeholder_slack_action_style": "Style: default, primary or danger",
"placeholder_slack_action_confirm": "Confirmation prompt (optional)",
"add_slack_action": "Add action",
"remove_slack_action": "Remove action",
"field_webhook_bearer_token": "Bearer token (optional)",
"help_webhook_bearer_token": "Sent as an Authorization: Bearer header. Leave the username and password empty when using it."
}

View File

@@ -8,7 +8,6 @@ import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useIsAIObservabilityEnabled } from 'hooks/useIsAIObservabilityEnabled';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
@@ -44,7 +43,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const isAdmin = user.role === USER_ROLES.ADMIN;
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
const mapRoutes = useMemo(
() =>
new Map(
@@ -135,14 +133,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
return <Redirect to={ROUTES.HOME} />;
}
if (
(pathname.startsWith(`${ROUTES.AI_OBSERVABILITY_BASE}/`) ||
pathname === ROUTES.AI_OBSERVABILITY_BASE) &&
!isAIObservabilityEnabled
) {
return <Redirect to={ROUTES.HOME} />;
}
// Check for workspace access restriction (cloud only)
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;

View File

@@ -1462,10 +1462,9 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
});
it('should redirect VIEWER from /alerts/channels/new (ADMIN only)', async () => {
// After moving channels under /alerts, CHANNELS_NEW ('/alerts/channels/new')
// is an exact, ADMIN-only route with no overlapping non-exact ALL_CHANNELS
// route to match last, so a VIEWER is now correctly redirected.
it('lets a VIEWER reach /alerts/channels/new, which authz then gates', () => {
// CHANNELS_NEW runs on fine-grained authz, so the router no longer decides
// on the role: the page's own guard denies when `create` is not granted.
renderPrivateRoute({
initialRoute: ROUTES.CHANNELS_NEW,
appContext: {
@@ -1474,7 +1473,7 @@ describe('PrivateRoute', () => {
},
});
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
assertStaysOnRoute(ROUTES.CHANNELS_NEW);
});
it('should allow EDITOR to access /get-started-with-signoz-cloud route', () => {
@@ -1558,6 +1557,11 @@ describe('PrivateRoute', () => {
keyof typeof routeWithInitialAuthZSupport,
AuthzRouteCase
> = {
CHANNELS_NEW: { path: ROUTES.CHANNELS_NEW, deniedRoles: DENIED_ROLES },
CHANNELS_EDIT: {
path: ROUTES.CHANNELS_EDIT.replace(':channelId', 'channel-id-1'),
deniedRoles: DENIED_ROLES,
},
ALL_DASHBOARD: { path: ROUTES.ALL_DASHBOARD, deniedRoles: DENIED_ROLES },
DASHBOARD: {
path: ROUTES.DASHBOARD.replace(':dashboardId', 'dashboard-id-1'),

View File

@@ -0,0 +1,28 @@
import ROUTES from 'constants/routes';
import routes from '../routes';
/**
* The channel form renders inside the alerts page, which owns the tab strip.
* Pointing these routes at the standalone pages tears that page down on every
* open and rebuilds it on the way back, which reads as a full reload.
*/
describe('channel routes', () => {
const findRoute = (key: string): (typeof routes)[number] | undefined =>
routes.find((route) => route.key === key);
it('mounts the same component as the alerts list', () => {
const list = findRoute('LIST_ALL_ALERT');
const create = findRoute('CHANNELS_NEW');
const edit = findRoute('CHANNELS_EDIT');
expect(list?.component).toBeDefined();
expect(create?.component).toBe(list?.component);
expect(edit?.component).toBe(list?.component);
});
it('keeps the channel paths under /alerts', () => {
expect(ROUTES.CHANNELS_NEW.startsWith('/alerts')).toBe(true);
expect(ROUTES.CHANNELS_EDIT.startsWith('/alerts')).toBe(true);
});
});

View File

@@ -7,8 +7,6 @@ import {
AlertOverview,
AllErrors,
ApiMonitoring,
ChannelsEdit,
ChannelsNew,
CreateNewAlerts,
DashboardPage,
DashboardPanelEditorPage,
@@ -243,14 +241,14 @@ const routes: AppRoutes[] = [
{
path: ROUTES.CHANNELS_NEW,
exact: true,
component: ChannelsNew,
component: ListAllALertsPage,
isPrivate: true,
key: 'CHANNELS_NEW',
},
{
path: ROUTES.CHANNELS_EDIT,
exact: true,
component: ChannelsEdit,
component: ListAllALertsPage,
isPrivate: true,
key: 'CHANNELS_EDIT',
},

View File

@@ -1,40 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createEmail';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
email_configs: [
{
send_resolved: props.send_resolved,
to: props.to,
html: props.html,
headers: props.headers,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -1,40 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createMsTeams';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
msteamsv2_configs: [
{
send_resolved: props.send_resolved,
webhook_url: props.webhook_url,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -1,43 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createOpsgenie';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
opsgenie_configs: [
{
api_key: props.api_key,
description: props.description,
priority: props.priority,
message: props.message,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -1,48 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createPager';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
pagerduty_configs: [
{
send_resolved: props.send_resolved,
routing_key: props.routing_key,
client: props.client,
client_url: props.client_url,
description: props.description,
severity: props.severity,
class: props.class,
component: props.component,
group: props.group,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -1,41 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createSlack';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
slack_configs: [
{
send_resolved: props.send_resolved,
api_url: props.api_url,
channel: props.channel,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -1,59 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createWebhook';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
let httpConfig = {};
const username = props.username ? props.username.trim() : '';
const password = props.password ? props.password.trim() : '';
if (username !== '' && password !== '') {
httpConfig = {
basic_auth: {
username,
password,
},
};
} else if (username === '' && password !== '') {
httpConfig = {
authorization: {
type: 'Bearer',
credentials: password,
},
};
}
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
webhook_configs: [
{
send_resolved: props.send_resolved,
url: props.api_url,
http_config: httpConfig,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -1,30 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/delete';
/**
* @deprecated Use the generated `useDeleteChannelByID` hook (or `deleteChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const deleteChannel = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.delete<PayloadProps>(`/channels/${props.id}`);
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default deleteChannel;

View File

@@ -1,40 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editEmail';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editEmail = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
email_configs: [
{
send_resolved: props.send_resolved,
to: props.to,
html: props.html,
headers: props.headers,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editEmail;

View File

@@ -1,40 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editMsTeams';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editMsTeams = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
msteamsv2_configs: [
{
send_resolved: props.send_resolved,
webhook_url: props.webhook_url,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editMsTeams;

View File

@@ -1,44 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponse, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editOpsgenie';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editOpsgenie = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
opsgenie_configs: [
{
send_resolved: props.send_resolved,
api_key: props.api_key,
description: props.description,
priority: props.priority,
message: props.message,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editOpsgenie;

View File

@@ -1,48 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editPager';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editPager = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
pagerduty_configs: [
{
send_resolved: props.send_resolved,
routing_key: props.routing_key,
client: props.client,
client_url: props.client_url,
description: props.description,
severity: props.severity,
class: props.class,
component: props.component,
group: props.group,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editPager;

View File

@@ -1,41 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editSlack';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editSlack = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
slack_configs: [
{
send_resolved: props.send_resolved,
api_url: props.api_url,
channel: props.channel,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editSlack;

View File

@@ -1,59 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editWebhook';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editWebhook = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
let httpConfig = {};
const username = props.username ? props.username.trim() : '';
const password = props.password ? props.password.trim() : '';
if (username !== '' && password !== '') {
httpConfig = {
basic_auth: {
username,
password,
},
};
} else if (username === '' && password !== '') {
httpConfig = {
authorization: {
type: 'Bearer',
credentials: password,
},
};
}
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
webhook_configs: [
{
send_resolved: props.send_resolved,
url: props.api_url,
http_config: httpConfig,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editWebhook;

View File

@@ -1,29 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/get';
import { Channels } from 'types/api/channels/getAll';
/**
* @deprecated Use the generated `useGetChannelByID` hook (or `getChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const get = async (props: Props): Promise<SuccessResponseV2<Channels>> => {
try {
const response = await axios.get<PayloadProps>(`/channels/${props.id}`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default get;

View File

@@ -1,28 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { Channels, PayloadProps } from 'types/api/channels/getAll';
/**
* @deprecated Use the generated `useListChannels` hook (or `listChannels` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const getAll = async (): Promise<SuccessResponseV2<Channels[]>> => {
try {
const response = await axios.get<PayloadProps>('/channels');
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default getAll;

View File

@@ -1,33 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createEmail';
const testEmail = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
email_configs: [
{
send_resolved: true,
to: props.to,
html: props.html,
headers: props.headers,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testEmail;

View File

@@ -1,33 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createMsTeams';
const testMsTeams = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
msteamsv2_configs: [
{
send_resolved: true,
webhook_url: props.webhook_url,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testMsTeams;

View File

@@ -1,36 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createOpsgenie';
const testOpsgenie = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
opsgenie_configs: [
{
api_key: props.api_key,
description: props.description,
priority: props.priority,
message: props.message,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testOpsgenie;

View File

@@ -1,41 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createPager';
const testPager = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
pagerduty_configs: [
{
send_resolved: true,
routing_key: props.routing_key,
client: props.client,
client_url: props.client_url,
description: props.description,
severity: props.severity,
class: props.class,
component: props.component,
group: props.group,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testPager;

View File

@@ -1,34 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createSlack';
const testSlack = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
slack_configs: [
{
send_resolved: true,
api_url: props.api_url,
channel: props.channel,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testSlack;

View File

@@ -1,52 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createWebhook';
const testWebhook = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
let httpConfig = {};
const username = props.username ? props.username.trim() : '';
const password = props.password ? props.password.trim() : '';
if (username !== '' && password !== '') {
httpConfig = {
basic_auth: {
username,
password,
},
};
} else if (username === '' && password !== '') {
httpConfig = {
authorization: {
type: 'Bearer',
credentials: password,
},
};
}
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
webhook_configs: [
{
send_resolved: true,
url: props.api_url,
http_config: httpConfig,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testWebhook;

View File

@@ -1,6 +1,6 @@
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 92.2 65" style="enable-background:new 0 0 92.2 65;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st0{fill:#7A8291;}
</style>
<metadata>
<sfw xmlns="ns_sfw;">

Before

Width:  |  Height:  |  Size: 714 B

After

Width:  |  Height:  |  Size: 714 B

View File

@@ -0,0 +1,3 @@
<svg role="img" viewBox="0 0 16 15" xmlns="http://www.w3.org/2000/svg">
<path d="M14.0777 13.984C14.945 14.6345 16.2458 14.2008 15.0533 13.0084C11.476 9.53949 12.2349 0 7.79033 0C3.34579 0 4.10461 9.53949 0.527295 13.0084C-0.773543 14.3092 0.635692 14.6345 1.50293 13.984C4.86344 11.7076 4.64663 7.69664 7.79033 7.69664C10.934 7.69664 10.7172 11.7076 14.0777 13.984Z" fill="#4285F4" />
</svg>

After

Width:  |  Height:  |  Size: 394 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><g fill="none" fill-rule="evenodd"><path fill="#c925d1" d="M0 0h64v64H0z"/><path fill="#fff" d="M36.213 16.011h-2.016v-2.005h2.016V12h2.017v2.006h2.016v2.005H38.23v2.006h-2.017zm9.074 8.023h-2.016v-2.006h2.016v-2.005h2.016v2.005h2.017v2.006h-2.017v2.006h-2.016v-2.006m-3.434 22.64c-1.495-3.292-4.482-6.263-7.792-7.75 3.31-1.487 6.297-4.459 7.792-7.75 1.494 3.291 4.482 6.263 7.791 7.75-3.31 1.487-6.297 4.458-7.79 7.75m12.139-8.753c-5.202 0-11.13-5.898-11.13-11.071 0-.555-.452-1.003-1.009-1.003s-1.008.448-1.008 1.003c0 5.173-5.93 11.071-11.13 11.071-.558 0-1.009.448-1.009 1.003s.45 1.003 1.008 1.003c5.202 0 11.13 5.898 11.13 11.07 0 .554.452 1.003 1.01 1.003.556 0 1.007-.45 1.007-1.003 0-5.172 5.93-11.07 11.13-11.07.558 0 1.009-.45 1.009-1.003 0-.555-.45-1.003-1.008-1.003m-31.39-19.904c6.85 0 10.587 1.988 10.587 3.008s-3.737 3.009-10.586 3.009-10.587-1.988-10.587-3.009 3.737-3.008 10.587-3.008m-.35 15.15c-5.012 0-8.62-1.063-10.236-2.19v-7.113c2.367 1.433 6.487 2.176 10.587 2.176s8.22-.743 10.586-2.176v6.484c-1.016 1.406-5.247 2.819-10.936 2.819M33.19 45.975c0 1.435-4.126 3.52-10.59 3.52-6.46 0-10.583-2.085-10.583-3.52v-4.407c2.258 1.354 6.014 2.192 10.276 2.192 2.942 0 5.786-.413 8.01-1.166l-.651-1.898c-2.019.682-4.633 1.058-7.359 1.058-5.39 0-9.252-1.402-10.276-2.76v-5.707c2.411 1.176 6.113 1.885 10.237 1.885 3.92 0 8.34-.725 10.936-2.269v2.162h2.016v-14.04c0-3.292-6.34-5.014-12.602-5.014S10 17.733 10 21.025v24.95c0 3.59 6.49 5.526 12.598 5.526 6.112 0 12.607-1.937 12.607-5.526v-2.887h-2.016z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,6 +1,6 @@
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 50.6 50.6" style="enable-background:new 0 0 50.6 50.6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st0{fill:#7A8291;}
</style>
<metadata>
<sfw xmlns="ns_sfw;">

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m23.365 13.556-1.442-2.895V8.994c0-2.764-2.218-5.002-4.954-5.002h-2.464c.178-.367.276-.779.276-1.213A2.77 2.77 0 0 0 12.018 0a2.77 2.77 0 0 0-2.763 2.779c0 .434.098.846.276 1.213H7.067c-2.736 0-4.954 2.238-4.954 5.002v1.667L.64 13.549c-.149.29-.149.636 0 .927l1.472 2.855v1.667C2.113 21.762 4.33 24 7.067 24h9.902c2.736 0 4.954-2.238 4.954-5.002V17.33l1.44-2.865c.143-.286.143-.622.002-.91m-12.854 2.36a2.27 2.27 0 0 1-2.261 2.273 2.27 2.27 0 0 1-2.261-2.273v-4.042A2.27 2.27 0 0 1 8.249 9.6a2.267 2.267 0 0 1 2.262 2.274zm7.285 0a2.27 2.27 0 0 1-2.26 2.273 2.27 2.27 0 0 1-2.262-2.273v-4.042A2.267 2.267 0 0 1 15.535 9.6a2.267 2.267 0 0 1 2.261 2.274z" fill="#7A8291" />
</svg>

After

Width:  |  Height:  |  Size: 761 B

View File

@@ -1,6 +1,6 @@
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 43 43" style="enable-background:new 0 0 43 43;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#0074A2;}
</style>
<metadata>
<sfw xmlns="ns_sfw;">

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,3 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23" fill="#7A8291" />
</svg>

After

Width:  |  Height:  |  Size: 453 B

View File

@@ -3,7 +3,7 @@
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 800.5 907.77" style="enable-background:new 0 0 800.5 907.77;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st0{fill:#8B5CC7;}
</style>
<path class="st0" d="M303.36,238.61c31.36-21.37,71.76-12.97,65-6.53c-12.89,12.28,4.26,8.65,6.11,31.31
c1.36,16.69-4.09,25.88-8.78,31.11c-9.79,1.28-21.69,3.67-36.02,8.33c-8.48,2.76-15.85,5.82-22.31,8.9

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,10 @@
<svg role="img" viewBox="0 0 275 287" xmlns="http://www.w3.org/2000/svg">
<path d="M14.5584 193.736H114.275V227.925H14.5584V193.736Z" fill="#7A8291" />
<path d="M148.464 74.076H262.426V108.265H148.464V74.076Z" fill="#7A8291" />
<path d="M88.6338 84.6127L173.246 0L197.422 24.175L112.809 108.788L88.6338 84.6127Z" fill="#7A8291" />
<path d="M89.157 170.084L24.175 105.102L0 129.277L64.9819 194.259L89.157 170.084Z" fill="#7A8291" />
<path d="M174.629 217.911L106.133 286.407L81.9577 262.232L150.454 193.736L174.629 217.911Z" fill="#7A8291" />
<path d="M174.106 132.44L250.66 208.994L274.835 184.819L198.281 108.265L174.106 132.44Z" fill="#7A8291" />
<path d="M88.6338 48.434V131.057H54.4451L54.4451 48.434H88.6338Z" fill="#7A8291" />
<path d="M208.294 168.094V270.66H174.106V168.094H208.294Z" fill="#7A8291" />
</svg>

After

Width:  |  Height:  |  Size: 825 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#fff" viewBox="0 0 24 24"><title>Deno</title><path d="M1.105 18.02A11.9 11.9 0 0 1 0 12.985q0-.698.078-1.376a12 12 0 0 1 .231-1.34A12 12 0 0 1 4.025 4.02a12 12 0 0 1 5.46-2.771 12 12 0 0 1 3.428-.23c1.452.112 2.825.477 4.077 1.05a12 12 0 0 1 2.78 1.774 12.02 12.02 0 0 1 4.053 7.078A12 12 0 0 1 24 12.985q0 .454-.036.914a12 12 0 0 1-.728 3.305 12 12 0 0 1-2.38 3.875c-1.33 1.357-3.02 1.962-4.43 1.936a4.4 4.4 0 0 1-2.724-1.024c-.99-.853-1.391-1.83-1.53-2.919a5 5 0 0 1 .128-1.518c.105-.38.37-1.116.76-1.437-.455-.197-1.04-.624-1.226-.829-.045-.05-.04-.13 0-.183a.155.155 0 0 1 .177-.053c.392.134.869.267 1.372.35.66.111 1.484.25 2.317.292 2.03.1 4.153-.813 4.812-2.627s.403-3.609-1.96-4.685-3.454-2.356-5.363-3.128c-1.247-.505-2.636-.205-4.06.582-3.838 2.121-7.277 8.822-5.69 15.032a.191.191 0 0 1-.315.19 12 12 0 0 1-1.25-1.634 12 12 0 0 1-.769-1.404M11.57 6.087c.649-.051 1.214.501 1.31 1.236.13.979-.228 1.99-1.41 2.013-1.01.02-1.315-.997-1.248-1.614.066-.616.574-1.575 1.35-1.635"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="#7A8291" viewBox="0 0 24 24"><title>Deno</title><path d="M1.105 18.02A11.9 11.9 0 0 1 0 12.985q0-.698.078-1.376a12 12 0 0 1 .231-1.34A12 12 0 0 1 4.025 4.02a12 12 0 0 1 5.46-2.771 12 12 0 0 1 3.428-.23c1.452.112 2.825.477 4.077 1.05a12 12 0 0 1 2.78 1.774 12.02 12.02 0 0 1 4.053 7.078A12 12 0 0 1 24 12.985q0 .454-.036.914a12 12 0 0 1-.728 3.305 12 12 0 0 1-2.38 3.875c-1.33 1.357-3.02 1.962-4.43 1.936a4.4 4.4 0 0 1-2.724-1.024c-.99-.853-1.391-1.83-1.53-2.919a5 5 0 0 1 .128-1.518c.105-.38.37-1.116.76-1.437-.455-.197-1.04-.624-1.226-.829-.045-.05-.04-.13 0-.183a.155.155 0 0 1 .177-.053c.392.134.869.267 1.372.35.66.111 1.484.25 2.317.292 2.03.1 4.153-.813 4.812-2.627s.403-3.609-1.96-4.685-3.454-2.356-5.363-3.128c-1.247-.505-2.636-.205-4.06.582-3.838 2.121-7.277 8.822-5.69 15.032a.191.191 0 0 1-.315.19 12 12 0 0 1-1.25-1.634 12 12 0 0 1-.769-1.404M11.57 6.087c.649-.051 1.214.501 1.31 1.236.13.979-.228 1.99-1.41 2.013-1.01.02-1.315-.997-1.248-1.614.066-.616.574-1.575 1.35-1.635"/></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" fill="#FFF" viewBox="0 0 50 50"><path d="M7 2v46h36V14.594l-.281-.313-12-12L30.406 2Zm2 2h20v12h12v30H9Zm22 1.438L39.563 14H31ZM15 22v2h20v-2Zm0 6v2h16v-2Zm0 6v2h20v-2Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" fill="#7A8291" viewBox="0 0 50 50"><path d="M7 2v46h36V14.594l-.281-.313-12-12L30.406 2Zm2 2h20v12h12v30H9Zm22 1.438L39.563 14H31ZM15 22v2h20v-2Zm0 6v2h16v-2Zm0 6v2h20v-2Z"/></svg>

Before

Width:  |  Height:  |  Size: 242 B

After

Width:  |  Height:  |  Size: 245 B

View File

@@ -0,0 +1,3 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M9 6h10v12H9a4 4 0 0 1-4-4v-4a4 4 0 0 1 4-4zm1.2 4.6h8.8v.9h-8.8zm0 3.6h8.8v.9h-8.8z" fill="#7A8291" fill-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 213 B

View File

@@ -0,0 +1,5 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M6.5 5.5h13a1.5 1.5 0 0 1 0 3h-13a1.5 1.5 0 0 1 0-3z" fill="#00B173" />
<path d="M4 10.5h10a1.5 1.5 0 0 1 0 3H4a1.5 1.5 0 0 1 0-3z" fill="#00B173" />
<path d="M6.5 15.5h13a1.5 1.5 0 0 1 0 3h-13a1.5 1.5 0 0 1 0-3z" fill="#00B173" />
</svg>

After

Width:  |  Height:  |  Size: 323 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 115.28 122.88"><path d="M25.38 57h64.88V37.34H69.59c-2.17 0-5.19-1.17-6.62-2.6s-2.3-4.01-2.3-6.17V7.64H8.15c-.18 0-.32.09-.41.18-.15.1-.19.23-.19.42v106.45c0 .14.09.32.18.41.09.14.28.18.41.18h81.51c.18 0 .17-.09.27-.18.14-.09.33-.28.33-.41v-11.16H25.38c-4.14 0-7.56-3.4-7.56-7.56V64.55c0-4.15 3.4-7.55 7.56-7.55m4.11 11.38h7.43v18.15h11.63v5.92H29.49zm20.4 12.05c0-3.93 1.09-6.99 3.28-9.17 2.19-2.19 5.24-3.28 9.15-3.28 4.01 0 7.09 1.08 9.26 3.22 2.17 2.15 3.25 5.16 3.25 9.04 0 2.81-.47 5.11-1.42 6.91q-1.425 2.7-4.11 4.2t-6.69 1.5c-2.71 0-4.96-.43-6.74-1.29-1.78-.87-3.22-2.23-4.32-4.11-1.11-1.87-1.66-4.21-1.66-7.02m7.42.01c0 2.43.45 4.17 1.36 5.23s2.14 1.59 3.7 1.59c1.6 0 2.84-.52 3.71-1.56.88-1.04 1.32-2.9 1.32-5.6 0-2.26-.46-3.92-1.37-4.96-.92-1.05-2.16-1.57-3.73-1.57-1.5 0-2.71.53-3.62 1.59-.91 1.08-1.37 2.83-1.37 5.28m33.11 3.3v-5.01h11.49v10.23c-2.2 1.5-4.15 2.53-5.83 3.07-1.69.54-3.7.81-6.02.81-2.86 0-5.19-.49-6.99-1.46s-3.19-2.42-4.18-4.35c-.99-1.92-1.48-4.13-1.48-6.63 0-2.63.54-4.91 1.62-6.85s2.67-3.41 4.76-4.42c1.63-.78 3.83-1.17 6.58-1.17 2.66 0 4.64.24 5.96.72s2.41 1.23 3.28 2.24 1.52 2.3 1.96 3.85l-7.16 1.29c-.3-.91-.8-1.61-1.5-2.09-.71-.49-1.6-.73-2.7-.73-1.62 0-2.92.57-3.89 1.7s-1.45 2.92-1.45 5.37c0 2.6.49 4.46 1.47 5.57.97 1.11 2.34 1.68 4.09 1.68q1.245 0 2.37-.36c.75-.24 1.61-.65 2.59-1.22v-2.25h-4.97zM97.79 57h9.93c4.16 0 7.56 3.41 7.56 7.56v31.42c0 4.15-3.41 7.56-7.56 7.56h-9.93v13.55c0 1.61-.65 3.04-1.7 4.1a5.74 5.74 0 0 1-4.1 1.7H5.81a5.74 5.74 0 0 1-4.1-1.7 5.74 5.74 0 0 1-1.7-4.1V5.85c0-1.61.65-3.04 1.7-4.1a5.8 5.8 0 0 1 4.1-1.7h58.72c.13-.05.27-.05.41-.05.64 0 1.29.28 1.75.69h.09c.09.05.14.09.23.18L97 31.23c.51.51.88 1.2.88 1.98 0 .23-.05.41-.09.65zM67.52 27.97V8.94l21.43 21.7H70.19c-.74 0-1.38-.32-1.89-.78-.46-.46-.78-1.15-.78-1.89" style="fill:#D1D5DB;fill-rule:evenodd;clip-rule:evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 115.28 122.88"><path d="M25.38 57h64.88V37.34H69.59c-2.17 0-5.19-1.17-6.62-2.6s-2.3-4.01-2.3-6.17V7.64H8.15c-.18 0-.32.09-.41.18-.15.1-.19.23-.19.42v106.45c0 .14.09.32.18.41.09.14.28.18.41.18h81.51c.18 0 .17-.09.27-.18.14-.09.33-.28.33-.41v-11.16H25.38c-4.14 0-7.56-3.4-7.56-7.56V64.55c0-4.15 3.4-7.55 7.56-7.55m4.11 11.38h7.43v18.15h11.63v5.92H29.49zm20.4 12.05c0-3.93 1.09-6.99 3.28-9.17 2.19-2.19 5.24-3.28 9.15-3.28 4.01 0 7.09 1.08 9.26 3.22 2.17 2.15 3.25 5.16 3.25 9.04 0 2.81-.47 5.11-1.42 6.91q-1.425 2.7-4.11 4.2t-6.69 1.5c-2.71 0-4.96-.43-6.74-1.29-1.78-.87-3.22-2.23-4.32-4.11-1.11-1.87-1.66-4.21-1.66-7.02m7.42.01c0 2.43.45 4.17 1.36 5.23s2.14 1.59 3.7 1.59c1.6 0 2.84-.52 3.71-1.56.88-1.04 1.32-2.9 1.32-5.6 0-2.26-.46-3.92-1.37-4.96-.92-1.05-2.16-1.57-3.73-1.57-1.5 0-2.71.53-3.62 1.59-.91 1.08-1.37 2.83-1.37 5.28m33.11 3.3v-5.01h11.49v10.23c-2.2 1.5-4.15 2.53-5.83 3.07-1.69.54-3.7.81-6.02.81-2.86 0-5.19-.49-6.99-1.46s-3.19-2.42-4.18-4.35c-.99-1.92-1.48-4.13-1.48-6.63 0-2.63.54-4.91 1.62-6.85s2.67-3.41 4.76-4.42c1.63-.78 3.83-1.17 6.58-1.17 2.66 0 4.64.24 5.96.72s2.41 1.23 3.28 2.24 1.52 2.3 1.96 3.85l-7.16 1.29c-.3-.91-.8-1.61-1.5-2.09-.71-.49-1.6-.73-2.7-.73-1.62 0-2.92.57-3.89 1.7s-1.45 2.92-1.45 5.37c0 2.6.49 4.46 1.47 5.57.97 1.11 2.34 1.68 4.09 1.68q1.245 0 2.37-.36c.75-.24 1.61-.65 2.59-1.22v-2.25h-4.97zM97.79 57h9.93c4.16 0 7.56 3.41 7.56 7.56v31.42c0 4.15-3.41 7.56-7.56 7.56h-9.93v13.55c0 1.61-.65 3.04-1.7 4.1a5.74 5.74 0 0 1-4.1 1.7H5.81a5.74 5.74 0 0 1-4.1-1.7 5.74 5.74 0 0 1-1.7-4.1V5.85c0-1.61.65-3.04 1.7-4.1a5.8 5.8 0 0 1 4.1-1.7h58.72c.13-.05.27-.05.41-.05.64 0 1.29.28 1.75.69h.09c.09.05.14.09.23.18L97 31.23c.51.51.88 1.2.88 1.98 0 .23-.05.41-.09.65zM67.52 27.97V8.94l21.43 21.7H70.19c-.74 0-1.38-.32-1.89-.78-.46-.46-.78-1.15-.78-1.89" style="fill:#7A8291;fill-rule:evenodd;clip-rule:evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#fff" viewBox="0 0 24 24"><title>Haystack</title><path d="M2.008 0C.9 0 0 .9 0 2.008v19.984C0 23.1.9 24 2.008 24h19.984C23.1 24 24 23.1 24 21.992V2.008C24 .9 23.1 0 21.992 0Zm9.963 3.84c3.43 0 6.21 2.763 6.21 6.17v6.488a.27.27 0 0 1-.27.268 2.423 2.423 0 0 1-2.43-2.414V10.01c0-1.927-1.572-3.488-3.51-3.488S8.547 8.085 8.547 10.01v1.608a.263.263 0 0 0 .259.268h1.54a.27.27 0 0 0 .275-.263V9.945c0-.74.604-1.341 1.35-1.341s1.35.6 1.35 1.341V20.03a.275.275 0 0 1-.28.268 2.41 2.41 0 0 1-2.42-2.404v-3.23a.275.275 0 0 0-.276-.269H8.811a.264.264 0 0 0-.264.263v1.08c0 1.333-1.175 2.414-2.517 2.414a.27.27 0 0 1-.27-.268v-7.872c0-3.408 2.78-6.171 6.21-6.171"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="#0E9A89" viewBox="0 0 24 24"><title>Haystack</title><path d="M2.008 0C.9 0 0 .9 0 2.008v19.984C0 23.1.9 24 2.008 24h19.984C23.1 24 24 23.1 24 21.992V2.008C24 .9 23.1 0 21.992 0Zm9.963 3.84c3.43 0 6.21 2.763 6.21 6.17v6.488a.27.27 0 0 1-.27.268 2.423 2.423 0 0 1-2.43-2.414V10.01c0-1.927-1.572-3.488-3.51-3.488S8.547 8.085 8.547 10.01v1.608a.263.263 0 0 0 .259.268h1.54a.27.27 0 0 0 .275-.263V9.945c0-.74.604-1.341 1.35-1.341s1.35.6 1.35 1.341V20.03a.275.275 0 0 1-.28.268 2.41 2.41 0 0 1-2.42-2.404v-3.23a.275.275 0 0 0-.276-.269H8.811a.264.264 0 0 0-.264.263v1.08c0 1.333-1.175 2.414-2.517 2.414a.27.27 0 0 1-.27-.268v-7.872c0-3.408 2.78-6.171 6.21-6.171"/></svg>

Before

Width:  |  Height:  |  Size: 707 B

After

Width:  |  Height:  |  Size: 710 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="154" height="250" preserveAspectRatio="xMidYMid" viewBox="0 0 256 416"><path d="M201.816 230.216c-16.186 0-30.697 7.171-40.634 18.461l-25.463-18.026c2.703-7.442 4.255-15.433 4.255-23.797 0-8.219-1.498-16.076-4.112-23.408l25.406-17.835c9.936 11.233 24.409 18.365 40.548 18.365 29.875 0 54.184-24.305 54.184-54.184s-24.309-54.184-54.184-54.184-54.184 24.305-54.184 54.184c0 5.348.808 10.505 2.258 15.389l-25.423 17.844c-10.62-13.175-25.911-22.374-43.333-25.182v-30.64c24.544-5.155 43.037-26.962 43.037-53.019C124.171 24.305 99.862 0 69.987 0S15.803 24.305 15.803 54.184c0 25.708 18.014 47.246 42.067 52.769v31.038C25.044 143.753 0 172.401 0 206.854c0 34.621 25.292 63.374 58.355 68.94v32.774c-24.299 5.341-42.552 27.011-42.552 52.894 0 29.879 24.309 54.184 54.184 54.184s54.184-24.305 54.184-54.184c0-25.883-18.253-47.553-42.552-52.894v-32.775a69.97 69.97 0 0 0 42.6-24.776l25.633 18.143c-1.423 4.84-2.22 9.946-2.22 15.24 0 29.879 24.309 54.184 54.184 54.184S256 314.279 256 284.4s-24.309-54.184-54.184-54.184m0-126.695c14.487 0 26.27 11.788 26.27 26.271s-11.783 26.27-26.27 26.27-26.27-11.787-26.27-26.27 11.783-26.271 26.27-26.271m-158.1-49.337c0-14.483 11.784-26.27 26.271-26.27s26.27 11.787 26.27 26.27-11.783 26.27-26.27 26.27-26.271-11.787-26.271-26.27m52.541 307.278c0 14.483-11.783 26.27-26.27 26.27s-26.271-11.787-26.271-26.27 11.784-26.27 26.271-26.27 26.27 11.787 26.27 26.27m-26.272-117.97c-20.205 0-36.642-16.434-36.642-36.638 0-20.205 16.437-36.642 36.642-36.642 20.204 0 36.641 16.437 36.641 36.642 0 20.204-16.437 36.638-36.641 36.638m131.831 67.179c-14.487 0-26.27-11.788-26.27-26.271s11.783-26.27 26.27-26.27 26.27 11.787 26.27 26.27-11.783 26.271-26.27 26.271" style="fill:#fff"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="154" height="250" preserveAspectRatio="xMidYMid" viewBox="0 0 256 416"><path d="M201.816 230.216c-16.186 0-30.697 7.171-40.634 18.461l-25.463-18.026c2.703-7.442 4.255-15.433 4.255-23.797 0-8.219-1.498-16.076-4.112-23.408l25.406-17.835c9.936 11.233 24.409 18.365 40.548 18.365 29.875 0 54.184-24.305 54.184-54.184s-24.309-54.184-54.184-54.184-54.184 24.305-54.184 54.184c0 5.348.808 10.505 2.258 15.389l-25.423 17.844c-10.62-13.175-25.911-22.374-43.333-25.182v-30.64c24.544-5.155 43.037-26.962 43.037-53.019C124.171 24.305 99.862 0 69.987 0S15.803 24.305 15.803 54.184c0 25.708 18.014 47.246 42.067 52.769v31.038C25.044 143.753 0 172.401 0 206.854c0 34.621 25.292 63.374 58.355 68.94v32.774c-24.299 5.341-42.552 27.011-42.552 52.894 0 29.879 24.309 54.184 54.184 54.184s54.184-24.305 54.184-54.184c0-25.883-18.253-47.553-42.552-52.894v-32.775a69.97 69.97 0 0 0 42.6-24.776l25.633 18.143c-1.423 4.84-2.22 9.946-2.22 15.24 0 29.879 24.309 54.184 54.184 54.184S256 314.279 256 284.4s-24.309-54.184-54.184-54.184m0-126.695c14.487 0 26.27 11.788 26.27 26.271s-11.783 26.27-26.27 26.27-26.27-11.787-26.27-26.27 11.783-26.271 26.27-26.271m-158.1-49.337c0-14.483 11.784-26.27 26.271-26.27s26.27 11.787 26.27 26.27-11.783 26.27-26.27 26.27-26.271-11.787-26.271-26.27m52.541 307.278c0 14.483-11.783 26.27-26.27 26.27s-26.271-11.787-26.271-26.27 11.784-26.27 26.271-26.27 26.27 11.787 26.27 26.27m-26.272-117.97c-20.205 0-36.642-16.434-36.642-36.638 0-20.205 16.437-36.642 36.642-36.642 20.204 0 36.641 16.437 36.641 36.642 0 20.204-16.437 36.638-36.641 36.638m131.831 67.179c-14.487 0-26.27-11.788-26.27-26.271s11.783-26.27 26.27-26.27 26.27 11.787 26.27 26.27-11.783 26.271-26.27 26.271" style="fill:#7A8291"/></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,5 @@
<svg role="img" viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(2.1186441,37.661017)">
<path d="m 237.70492,0 c 76.95771,0 139.34426,62.386551 139.34426,139.34426 v 0 l -1.8e-4,197.00774 65.57395,-1.5e-4 c 31.68847,0 57.37705,25.68858 57.37705,57.37705 v 0 V 443 h -7.54607 c -18.22527,0 -33.09496,-14.59276 -33.43754,-32.81482 v 0 -8.22465 l -0.003,-0.40664 c -0.21715,-13.39321 -11.14191,-24.18352 -24.58687,-24.18352 v 0 H 106.55738 C 47.707363,377.37037 0,329.66301 0,270.81299 v 0 -131.46873 C 0,62.386551 62.386551,0 139.34426,0 v 0 z m -8.19672,41.018518 h -81.96722 l -1.76212,0.01428 C 87.741593,41.97378 40.983607,89.314381 40.983607,147.5759 v 0 123.20218 l 0.0088,1.08438 c 0.579068,35.71525 29.711746,64.48939 65.564993,64.48939 v 0 h 163.9344 c 36.2154,0 65.57377,-29.35838 65.57377,-65.57377 v 0 -123.20218 l -0.0143,-1.76213 C 335.11031,87.776505 287.76971,41.018518 229.5082,41.018518 Z m -81.63935,65.629632 c 9.34426,0 16.72131,7.62944 16.72131,17.22778 v 0 49.96055 l 56.80328,-58.57444 c 7.37705,-6.89111 17.45902,-7.38334 24.59017,-0.73834 v 0 0.24611 c 6.88524,6.39889 6.39344,16.98167 -1.22951,23.87278 v 0 l -42.04918,43.31556 48.93442,69.89555 c 5.90164,8.61389 4.18033,19.44278 -3.68852,24.61111 -8.60656,4.92223 -18.44262,2.21501 -24.34426,-6.39888 v 0 l -44.0164,-64.48112 -15,15.75112 v 40.36222 c 0,9.84444 -7.37705,17.22778 -16.72131,17.22778 -9.34426,0 -16.72131,-7.38334 -16.72131,-17.22778 v 0 -137.82222 c 0,-9.59834 7.37705,-17.22778 16.72131,-17.22778 z" fill="#5C62B0" fill-rule="evenodd" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LangChain</title><path d="M8.373 14.502c.013-.06.024-.118.038-.17l.061.145c.115.28.229.557.506.714-.012.254-.334.357-.552.326-.048-.114-.115-.228-.255-.164-.143.056-.3-.01-.266-.185.333-.012.407-.371.468-.666zM18.385 9.245c-.318 0-.616.122-.839.342l-.902.887c-.243.24-.368.572-.343.913l.006.056c.032.262.149.498.337.682.13.128.273.21.447.266a.866.866 0 01-.247.777l-.056.055a2.022 2.022 0 01-1.355-1.555l-.01-.057-.046.037c-.03.024-.06.05-.088.078l-.902.887a1.156 1.156 0 000 1.65c.231.228.535.342.84.342.304 0 .607-.114.838-.341l.902-.888a1.156 1.156 0 00-.436-1.921.953.953 0 01.276-.842 2.062 2.062 0 011.371 1.57l.01.057.047-.037c.03-.024.06-.05.088-.078l.902-.888a1.155 1.155 0 000-1.65 1.188 1.188 0 00-.84-.342z" fill="#1C3C3C"></path><path clip-rule="evenodd" d="M17.901 6H6.1C2.736 6 0 8.692 0 12s2.736 6 6.099 6H17.9C21.264 18 24 15.308 24 12s-2.736-6-6.099-6zm-5.821 9.407c-.195.04-.414.047-.562-.106-.045.1-.136.077-.221.056a.797.797 0 00-.061-.014c-.01.025-.017.048-.026.073-.329.021-.575-.309-.732-.558a4.991 4.991 0 00-.473-.21c-.172-.07-.345-.14-.509-.23a2.218 2.218 0 00-.004.173c-.002.244-.004.503-.227.651-.007.295.236.292.476.29.207-.003.41-.005.447.184a.485.485 0 01-.05.003c-.046 0-.092 0-.127.034-.117.111-.242.063-.372.013-.12-.046-.243-.094-.367-.02a2.318 2.318 0 00-.262.154.97.97 0 01-.548.194c-.024-.036-.014-.059.006-.08a.562.562 0 00.043-.056c.019-.028.035-.057.051-.084.054-.095.103-.18.242-.22-.185-.029-.344.055-.5.137l-.004.002a4.21 4.21 0 01-.065.034c-.097.04-.154.009-.212-.023-.082-.045-.168-.092-.376.04-.04-.032-.02-.061.002-.086.091-.109.21-.125.345-.119-.351-.193-.604-.056-.81.055-.182.098-.327.176-.471-.012-.065.017-.102.063-.138.108-.015.02-.03.038-.047.055-.035-.039-.027-.083-.018-.128l.005-.026a.242.242 0 00.003-.03l-.027-.01c-.053-.022-.105-.044-.09-.124-.117-.04-.2.03-.286.094-.054-.041-.01-.095.032-.145a.279.279 0 00.045-.065c.038-.065.103-.067.166-.069.054-.001.108-.003.145-.042.133-.075.297-.036.462.003.121.028.242.057.354.042.203.025.454-.18.352-.385-.186-.233-.184-.528-.183-.813v-.143c-.016-.108-.172-.233-.328-.358-.12-.095-.24-.191-.298-.28-.16-.177-.285-.382-.409-.585l-.015-.024c-.212-.404-.297-.86-.382-1.315-.103-.546-.205-1.09-.526-1.54-.266.144-.612.075-.841-.118-.12.107-.13.247-.138.396l-.001.014c-.297-.292-.26-.844-.023-1.17.097-.128.213-.233.342-.326.03-.021.04-.042.039-.074.235-1.04 1.836-.839 2.342-.103.167.206.281.442.395.678.137.283.273.566.5.795.22.237.452.463.684.689.359.35.718.699 1.032 1.089.49.587.839 1.276 1.144 1.97.05.092.08.193.11.293.044.15.089.299.2.417.026.035.084.088.149.148.156.143.357.328.289.409.009.019.027.04.05.06.032.028.074.058.116.088.122.087.25.178.16.25zm7.778-3.545l-.902.887c-.24.237-.537.413-.859.51l-.017.005-.006.015A2.021 2.021 0 0117.6 14l-.902.888c-.393.387-.916.6-1.474.6-.557 0-1.08-.213-1.474-.6a2.03 2.03 0 010-2.9l.902-.888c.242-.238.531-.409.859-.508l.016-.004.006-.016c.105-.272.265-.516.475-.724l.902-.887c.393-.387.917-.6 1.474-.6.558 0 1.08.213 1.474.6.394.387.61.902.61 1.45 0 .549-.216 1.064-.61 1.45v.001z" fill="#1C3C3C" fill-rule="evenodd"></path></svg>
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LangChain</title><path d="M8.373 14.502c.013-.06.024-.118.038-.17l.061.145c.115.28.229.557.506.714-.012.254-.334.357-.552.326-.048-.114-.115-.228-.255-.164-.143.056-.3-.01-.266-.185.333-.012.407-.371.468-.666zM18.385 9.245c-.318 0-.616.122-.839.342l-.902.887c-.243.24-.368.572-.343.913l.006.056c.032.262.149.498.337.682.13.128.273.21.447.266a.866.866 0 01-.247.777l-.056.055a2.022 2.022 0 01-1.355-1.555l-.01-.057-.046.037c-.03.024-.06.05-.088.078l-.902.887a1.156 1.156 0 000 1.65c.231.228.535.342.84.342.304 0 .607-.114.838-.341l.902-.888a1.156 1.156 0 00-.436-1.921.953.953 0 01.276-.842 2.062 2.062 0 011.371 1.57l.01.057.047-.037c.03-.024.06-.05.088-.078l.902-.888a1.155 1.155 0 000-1.65 1.188 1.188 0 00-.84-.342z" fill="#3B8686"></path><path clip-rule="evenodd" d="M17.901 6H6.1C2.736 6 0 8.692 0 12s2.736 6 6.099 6H17.9C21.264 18 24 15.308 24 12s-2.736-6-6.099-6zm-5.821 9.407c-.195.04-.414.047-.562-.106-.045.1-.136.077-.221.056a.797.797 0 00-.061-.014c-.01.025-.017.048-.026.073-.329.021-.575-.309-.732-.558a4.991 4.991 0 00-.473-.21c-.172-.07-.345-.14-.509-.23a2.218 2.218 0 00-.004.173c-.002.244-.004.503-.227.651-.007.295.236.292.476.29.207-.003.41-.005.447.184a.485.485 0 01-.05.003c-.046 0-.092 0-.127.034-.117.111-.242.063-.372.013-.12-.046-.243-.094-.367-.02a2.318 2.318 0 00-.262.154.97.97 0 01-.548.194c-.024-.036-.014-.059.006-.08a.562.562 0 00.043-.056c.019-.028.035-.057.051-.084.054-.095.103-.18.242-.22-.185-.029-.344.055-.5.137l-.004.002a4.21 4.21 0 01-.065.034c-.097.04-.154.009-.212-.023-.082-.045-.168-.092-.376.04-.04-.032-.02-.061.002-.086.091-.109.21-.125.345-.119-.351-.193-.604-.056-.81.055-.182.098-.327.176-.471-.012-.065.017-.102.063-.138.108-.015.02-.03.038-.047.055-.035-.039-.027-.083-.018-.128l.005-.026a.242.242 0 00.003-.03l-.027-.01c-.053-.022-.105-.044-.09-.124-.117-.04-.2.03-.286.094-.054-.041-.01-.095.032-.145a.279.279 0 00.045-.065c.038-.065.103-.067.166-.069.054-.001.108-.003.145-.042.133-.075.297-.036.462.003.121.028.242.057.354.042.203.025.454-.18.352-.385-.186-.233-.184-.528-.183-.813v-.143c-.016-.108-.172-.233-.328-.358-.12-.095-.24-.191-.298-.28-.16-.177-.285-.382-.409-.585l-.015-.024c-.212-.404-.297-.86-.382-1.315-.103-.546-.205-1.09-.526-1.54-.266.144-.612.075-.841-.118-.12.107-.13.247-.138.396l-.001.014c-.297-.292-.26-.844-.023-1.17.097-.128.213-.233.342-.326.03-.021.04-.042.039-.074.235-1.04 1.836-.839 2.342-.103.167.206.281.442.395.678.137.283.273.566.5.795.22.237.452.463.684.689.359.35.718.699 1.032 1.089.49.587.839 1.276 1.144 1.97.05.092.08.193.11.293.044.15.089.299.2.417.026.035.084.088.149.148.156.143.357.328.289.409.009.019.027.04.05.06.032.028.074.058.116.088.122.087.25.178.16.25zm7.778-3.545l-.902.887c-.24.237-.537.413-.859.51l-.017.005-.006.015A2.021 2.021 0 0117.6 14l-.902.888c-.393.387-.916.6-1.474.6-.557 0-1.08-.213-1.474-.6a2.03 2.03 0 010-2.9l.902-.888c.242-.238.531-.409.859-.508l.016-.004.006-.016c.105-.272.265-.516.475-.724l.902-.887c.393-.387.917-.6 1.474-.6.558 0 1.08.213 1.474.6.394.387.61.902.61 1.45 0 .549-.216 1.064-.61 1.45v.001z" fill="#3B8686" fill-rule="evenodd"></path></svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,3 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z" fill="#0467DF" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M4.89 5.57 0 14.002l2.521 4.4h5.05l4.396-7.718 4.512 7.709 4.996.037L24 14.057l-4.857-8.452-5.073-.015-2.076 3.598L9.94 5.57Zm.837.729h3.787l1.845 3.252H7.572Zm9.189.021 3.803.012 4.228 7.355-3.736-.027zm-9.82.346L6.94 9.914l-4.209 7.389-1.892-3.3Zm9.187.014 4.297 7.343-1.892 3.282-4.3-7.344zm-6.713 3.6h3.79l-4.212 7.394H3.361Zm11.64 4.109 3.74.027-1.893 3.281-3.74-.027z" fill="#3E9C2A" />
</svg>

After

Width:  |  Height:  |  Size: 482 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#fff" viewBox="0 0 24 24"><title>Ollama</title><path d="M16.361 10.26a.9.9 0 0 0-.558.47l-.072.148.001.207c0 .193.004.217.059.353.076.193.152.312.291.448.24.238.51.3.872.205a.86.86 0 0 0 .517-.436.75.75 0 0 0 .08-.498c-.064-.453-.33-.782-.724-.897a1.1 1.1 0 0 0-.466 0m-9.203.005c-.305.096-.533.32-.65.639a1.2 1.2 0 0 0-.06.52c.057.309.31.59.598.667.362.095.632.033.872-.205.14-.136.215-.255.291-.448.055-.136.059-.16.059-.353l.001-.207-.072-.148a.9.9 0 0 0-.565-.472 1 1 0 0 0-.474.007m4.184 2c-.131.071-.223.25-.195.383.031.143.157.288.353.407.105.063.112.072.117.136.004.038-.01.146-.029.243-.02.094-.036.194-.036.222.002.074.07.195.143.253.064.052.076.054.255.059.164.005.198.001.264-.03.169-.082.212-.234.15-.525-.052-.243-.042-.28.087-.355.137-.08.281-.219.324-.314a.365.365 0 0 0-.175-.48.4.4 0 0 0-.181-.033c-.126 0-.207.03-.355.124l-.085.053-.053-.032c-.219-.13-.259-.145-.391-.143a.4.4 0 0 0-.193.032m.39-2.195c-.373.036-.475.05-.654.086a4.5 4.5 0 0 0-.951.328c-.94.46-1.589 1.226-1.787 2.114-.04.176-.045.234-.045.53 0 .294.005.357.043.524.264 1.16 1.332 2.017 2.714 2.173.3.033 1.596.033 1.896 0 1.11-.125 2.064-.727 2.493-1.571.114-.226.169-.372.22-.602.039-.167.044-.23.044-.523 0-.297-.005-.355-.045-.531-.288-1.29-1.539-2.304-3.072-2.497a7 7 0 0 0-.855-.031zm.645.937a3.3 3.3 0 0 1 1.44.514c.223.148.537.458.671.662.166.251.26.508.303.82.02.143.01.251-.043.482-.08.345-.332.705-.672.957a3 3 0 0 1-.689.348c-.382.122-.632.144-1.525.138-.582-.006-.686-.01-.853-.042q-.856-.16-1.35-.68c-.264-.28-.385-.535-.45-.946-.03-.192.025-.509.137-.776.136-.326.488-.73.836-.963.403-.269.934-.46 1.422-.512.187-.02.586-.02.773-.002m-5.503-11a1.65 1.65 0 0 0-.683.298C5.617.74 5.173 1.666 4.985 2.819c-.07.436-.119 1.04-.119 1.503 0 .544.064 1.24.155 1.721.02.107.031.202.023.208l-.187.152a5.3 5.3 0 0 0-.949 1.02 5.5 5.5 0 0 0-.94 2.339 6.6 6.6 0 0 0-.023 1.357c.091.78.325 1.438.727 2.04l.13.195-.037.064c-.269.452-.498 1.105-.605 1.732-.084.496-.095.629-.095 1.294 0 .67.009.803.088 1.266.095.555.288 1.143.503 1.534.071.128.243.393.264.407.007.003-.014.067-.046.141a7.4 7.4 0 0 0-.548 1.873 5 5 0 0 0-.071.991c0 .56.031.832.148 1.279L3.42 24h1.478l-.05-.091c-.297-.552-.325-1.575-.068-2.597.117-.472.25-.819.498-1.296l.148-.29v-.177c0-.165-.003-.184-.057-.293a.9.9 0 0 0-.194-.25 1.7 1.7 0 0 1-.385-.543c-.424-.92-.506-2.286-.208-3.451.124-.486.329-.918.544-1.154a.8.8 0 0 0 .223-.531c0-.195-.07-.355-.224-.522a3.14 3.14 0 0 1-.817-1.729c-.14-.96.114-2.005.69-2.834.563-.814 1.353-1.336 2.237-1.475.199-.033.57-.028.776.01.226.04.367.028.512-.041.179-.085.268-.19.374-.431.093-.215.165-.333.36-.576.234-.29.46-.489.822-.729.413-.27.884-.467 1.352-.561.17-.035.25-.04.569-.04s.398.005.569.04a4.07 4.07 0 0 1 1.914.997c.117.109.398.457.488.602.034.057.095.177.132.267.105.241.195.346.374.43.14.068.286.082.503.045.343-.058.607-.053.943.016 1.144.23 2.14 1.173 2.581 2.437.385 1.108.276 2.267-.296 3.153-.097.15-.193.27-.333.419-.301.322-.301.722-.001 1.053.493.539.801 1.866.708 3.036-.062.772-.26 1.463-.533 1.854a2 2 0 0 1-.224.258.9.9 0 0 0-.194.25c-.054.109-.057.128-.057.293v.178l.148.29c.248.476.38.823.498 1.295.253 1.008.231 2.01-.059 2.581a1 1 0 0 0-.044.098c0 .006.329.009.732.009h.73l.02-.074.036-.134c.019-.076.057-.3.088-.516a9 9 0 0 0 0-1.258c-.11-.875-.295-1.57-.597-2.226-.032-.074-.053-.138-.046-.141a1.4 1.4 0 0 0 .108-.152c.376-.569.607-1.284.724-2.228.031-.26.031-1.378 0-1.628-.083-.645-.182-1.082-.348-1.525a6 6 0 0 0-.329-.7l-.038-.064.131-.194c.402-.604.636-1.262.727-2.04a6.6 6.6 0 0 0-.024-1.358 5.5 5.5 0 0 0-.939-2.339 5.3 5.3 0 0 0-.95-1.02l-.186-.152a.7.7 0 0 1 .023-.208c.208-1.087.201-2.443-.017-3.503-.19-.924-.535-1.658-.98-2.082-.354-.338-.716-.482-1.15-.455-.996.059-1.8 1.205-2.116 3.01a7 7 0 0 0-.097.726c0 .036-.007.066-.015.066a1 1 0 0 1-.149-.078A4.86 4.86 0 0 0 12 3.03c-.832 0-1.687.243-2.456.698a1 1 0 0 1-.148.078c-.008 0-.015-.03-.015-.066a7 7 0 0 0-.097-.725C8.997 1.392 8.337.319 7.46.048a2 2 0 0 0-.585-.041Zm.293 1.402c.248.197.523.759.682 1.388.03.113.06.244.069.292.007.047.026.152.041.233.067.365.098.76.102 1.24l.002.475-.12.175-.118.178h-.278c-.324 0-.646.041-.954.124l-.238.06c-.033.007-.038-.003-.057-.144a8.4 8.4 0 0 1 .016-2.323c.124-.788.413-1.501.696-1.711.067-.05.079-.049.157.013m9.825-.012c.17.126.358.46.498.888.28.854.36 2.028.212 3.145-.019.14-.024.151-.057.144l-.238-.06a3.7 3.7 0 0 0-.954-.124h-.278l-.119-.178-.119-.175.002-.474c.004-.669.066-1.19.214-1.772.157-.623.434-1.185.68-1.382.078-.062.09-.063.159-.012"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="#7A8291" viewBox="0 0 24 24"><title>Ollama</title><path d="M16.361 10.26a.9.9 0 0 0-.558.47l-.072.148.001.207c0 .193.004.217.059.353.076.193.152.312.291.448.24.238.51.3.872.205a.86.86 0 0 0 .517-.436.75.75 0 0 0 .08-.498c-.064-.453-.33-.782-.724-.897a1.1 1.1 0 0 0-.466 0m-9.203.005c-.305.096-.533.32-.65.639a1.2 1.2 0 0 0-.06.52c.057.309.31.59.598.667.362.095.632.033.872-.205.14-.136.215-.255.291-.448.055-.136.059-.16.059-.353l.001-.207-.072-.148a.9.9 0 0 0-.565-.472 1 1 0 0 0-.474.007m4.184 2c-.131.071-.223.25-.195.383.031.143.157.288.353.407.105.063.112.072.117.136.004.038-.01.146-.029.243-.02.094-.036.194-.036.222.002.074.07.195.143.253.064.052.076.054.255.059.164.005.198.001.264-.03.169-.082.212-.234.15-.525-.052-.243-.042-.28.087-.355.137-.08.281-.219.324-.314a.365.365 0 0 0-.175-.48.4.4 0 0 0-.181-.033c-.126 0-.207.03-.355.124l-.085.053-.053-.032c-.219-.13-.259-.145-.391-.143a.4.4 0 0 0-.193.032m.39-2.195c-.373.036-.475.05-.654.086a4.5 4.5 0 0 0-.951.328c-.94.46-1.589 1.226-1.787 2.114-.04.176-.045.234-.045.53 0 .294.005.357.043.524.264 1.16 1.332 2.017 2.714 2.173.3.033 1.596.033 1.896 0 1.11-.125 2.064-.727 2.493-1.571.114-.226.169-.372.22-.602.039-.167.044-.23.044-.523 0-.297-.005-.355-.045-.531-.288-1.29-1.539-2.304-3.072-2.497a7 7 0 0 0-.855-.031zm.645.937a3.3 3.3 0 0 1 1.44.514c.223.148.537.458.671.662.166.251.26.508.303.82.02.143.01.251-.043.482-.08.345-.332.705-.672.957a3 3 0 0 1-.689.348c-.382.122-.632.144-1.525.138-.582-.006-.686-.01-.853-.042q-.856-.16-1.35-.68c-.264-.28-.385-.535-.45-.946-.03-.192.025-.509.137-.776.136-.326.488-.73.836-.963.403-.269.934-.46 1.422-.512.187-.02.586-.02.773-.002m-5.503-11a1.65 1.65 0 0 0-.683.298C5.617.74 5.173 1.666 4.985 2.819c-.07.436-.119 1.04-.119 1.503 0 .544.064 1.24.155 1.721.02.107.031.202.023.208l-.187.152a5.3 5.3 0 0 0-.949 1.02 5.5 5.5 0 0 0-.94 2.339 6.6 6.6 0 0 0-.023 1.357c.091.78.325 1.438.727 2.04l.13.195-.037.064c-.269.452-.498 1.105-.605 1.732-.084.496-.095.629-.095 1.294 0 .67.009.803.088 1.266.095.555.288 1.143.503 1.534.071.128.243.393.264.407.007.003-.014.067-.046.141a7.4 7.4 0 0 0-.548 1.873 5 5 0 0 0-.071.991c0 .56.031.832.148 1.279L3.42 24h1.478l-.05-.091c-.297-.552-.325-1.575-.068-2.597.117-.472.25-.819.498-1.296l.148-.29v-.177c0-.165-.003-.184-.057-.293a.9.9 0 0 0-.194-.25 1.7 1.7 0 0 1-.385-.543c-.424-.92-.506-2.286-.208-3.451.124-.486.329-.918.544-1.154a.8.8 0 0 0 .223-.531c0-.195-.07-.355-.224-.522a3.14 3.14 0 0 1-.817-1.729c-.14-.96.114-2.005.69-2.834.563-.814 1.353-1.336 2.237-1.475.199-.033.57-.028.776.01.226.04.367.028.512-.041.179-.085.268-.19.374-.431.093-.215.165-.333.36-.576.234-.29.46-.489.822-.729.413-.27.884-.467 1.352-.561.17-.035.25-.04.569-.04s.398.005.569.04a4.07 4.07 0 0 1 1.914.997c.117.109.398.457.488.602.034.057.095.177.132.267.105.241.195.346.374.43.14.068.286.082.503.045.343-.058.607-.053.943.016 1.144.23 2.14 1.173 2.581 2.437.385 1.108.276 2.267-.296 3.153-.097.15-.193.27-.333.419-.301.322-.301.722-.001 1.053.493.539.801 1.866.708 3.036-.062.772-.26 1.463-.533 1.854a2 2 0 0 1-.224.258.9.9 0 0 0-.194.25c-.054.109-.057.128-.057.293v.178l.148.29c.248.476.38.823.498 1.295.253 1.008.231 2.01-.059 2.581a1 1 0 0 0-.044.098c0 .006.329.009.732.009h.73l.02-.074.036-.134c.019-.076.057-.3.088-.516a9 9 0 0 0 0-1.258c-.11-.875-.295-1.57-.597-2.226-.032-.074-.053-.138-.046-.141a1.4 1.4 0 0 0 .108-.152c.376-.569.607-1.284.724-2.228.031-.26.031-1.378 0-1.628-.083-.645-.182-1.082-.348-1.525a6 6 0 0 0-.329-.7l-.038-.064.131-.194c.402-.604.636-1.262.727-2.04a6.6 6.6 0 0 0-.024-1.358 5.5 5.5 0 0 0-.939-2.339 5.3 5.3 0 0 0-.95-1.02l-.186-.152a.7.7 0 0 1 .023-.208c.208-1.087.201-2.443-.017-3.503-.19-.924-.535-1.658-.98-2.082-.354-.338-.716-.482-1.15-.455-.996.059-1.8 1.205-2.116 3.01a7 7 0 0 0-.097.726c0 .036-.007.066-.015.066a1 1 0 0 1-.149-.078A4.86 4.86 0 0 0 12 3.03c-.832 0-1.687.243-2.456.698a1 1 0 0 1-.148.078c-.008 0-.015-.03-.015-.066a7 7 0 0 0-.097-.725C8.997 1.392 8.337.319 7.46.048a2 2 0 0 0-.585-.041Zm.293 1.402c.248.197.523.759.682 1.388.03.113.06.244.069.292.007.047.026.152.041.233.067.365.098.76.102 1.24l.002.475-.12.175-.118.178h-.278c-.324 0-.646.041-.954.124l-.238.06c-.033.007-.038-.003-.057-.144a8.4 8.4 0 0 1 .016-2.323c.124-.788.413-1.501.696-1.711.067-.05.079-.049.157.013m9.825-.012c.17.126.358.46.498.888.28.854.36 2.028.212 3.145-.019.14-.024.151-.057.144l-.238-.06a3.7 3.7 0 0 0-.954-.124h-.278l-.119-.178-.119-.175.002-.474c.004-.669.066-1.19.214-1.772.157-.623.434-1.185.68-1.382.078-.062.09-.063.159-.012"/></svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path fill="#fff" d="M44.559 19.646a11.957 11.957 0 0 0-1.028-9.822 12.094 12.094 0 0 0-13.026-5.802A11.962 11.962 0 0 0 21.485 0 12.097 12.097 0 0 0 9.95 8.373a11.964 11.964 0 0 0-7.997 5.8A12.097 12.097 0 0 0 3.44 28.356a11.957 11.957 0 0 0 1.028 9.822 12.094 12.094 0 0 0 13.026 5.802 11.953 11.953 0 0 0 9.02 4.02 12.096 12.096 0 0 0 11.54-8.379 11.964 11.964 0 0 0 7.997-5.8 12.099 12.099 0 0 0-1.491-14.177zM26.517 44.863a8.966 8.966 0 0 1-5.759-2.082 6.85 6.85 0 0 0 .284-.16L30.6 37.1c.49-.278.79-.799.786-1.361V22.265l4.04 2.332a.141.141 0 0 1 .078.111v11.16a9.006 9.006 0 0 1-8.987 8.995zM7.191 36.608a8.957 8.957 0 0 1-1.073-6.027c.071.042.195.119.284.17l9.558 5.52a1.556 1.556 0 0 0 1.57 0l11.67-6.738v4.665a.15.15 0 0 1-.057.124l-9.662 5.579a9.006 9.006 0 0 1-12.288-3.293zM4.675 15.744a8.966 8.966 0 0 1 4.682-3.943c0 .082-.005.228-.005.33v11.042a1.555 1.555 0 0 0 .785 1.359l11.669 6.736-4.04 2.333a.143.143 0 0 1-.136.012L7.967 28.03a9.006 9.006 0 0 1-3.293-12.284zm33.19 7.724L26.196 16.73l4.04-2.331a.143.143 0 0 1 .136-.012l9.664 5.579c4.302 2.485 5.776 7.989 3.29 12.29a8.991 8.991 0 0 1-4.68 3.943V24.827a1.553 1.553 0 0 0-.78-1.36zm4.02-6.051c-.07-.044-.195-.119-.283-.17l-9.558-5.52a1.556 1.556 0 0 0-1.57 0l-11.67 6.738V13.8a.15.15 0 0 1 .057-.124l9.662-5.574a8.995 8.995 0 0 1 13.36 9.315zm-25.277 8.315-4.04-2.333a.141.141 0 0 1-.079-.11v-11.16a8.997 8.997 0 0 1 14.753-6.91c-.073.04-.2.11-.283.161L17.4 10.9a1.552 1.552 0 0 0-.786 1.36l-.006 13.469zM18.803 21l5.198-3.002 5.197 3V27l-5.197 3-5.198-3z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path fill="#7A8291" d="M44.559 19.646a11.957 11.957 0 0 0-1.028-9.822 12.094 12.094 0 0 0-13.026-5.802A11.962 11.962 0 0 0 21.485 0 12.097 12.097 0 0 0 9.95 8.373a11.964 11.964 0 0 0-7.997 5.8A12.097 12.097 0 0 0 3.44 28.356a11.957 11.957 0 0 0 1.028 9.822 12.094 12.094 0 0 0 13.026 5.802 11.953 11.953 0 0 0 9.02 4.02 12.096 12.096 0 0 0 11.54-8.379 11.964 11.964 0 0 0 7.997-5.8 12.099 12.099 0 0 0-1.491-14.177zM26.517 44.863a8.966 8.966 0 0 1-5.759-2.082 6.85 6.85 0 0 0 .284-.16L30.6 37.1c.49-.278.79-.799.786-1.361V22.265l4.04 2.332a.141.141 0 0 1 .078.111v11.16a9.006 9.006 0 0 1-8.987 8.995zM7.191 36.608a8.957 8.957 0 0 1-1.073-6.027c.071.042.195.119.284.17l9.558 5.52a1.556 1.556 0 0 0 1.57 0l11.67-6.738v4.665a.15.15 0 0 1-.057.124l-9.662 5.579a9.006 9.006 0 0 1-12.288-3.293zM4.675 15.744a8.966 8.966 0 0 1 4.682-3.943c0 .082-.005.228-.005.33v11.042a1.555 1.555 0 0 0 .785 1.359l11.669 6.736-4.04 2.333a.143.143 0 0 1-.136.012L7.967 28.03a9.006 9.006 0 0 1-3.293-12.284zm33.19 7.724L26.196 16.73l4.04-2.331a.143.143 0 0 1 .136-.012l9.664 5.579c4.302 2.485 5.776 7.989 3.29 12.29a8.991 8.991 0 0 1-4.68 3.943V24.827a1.553 1.553 0 0 0-.78-1.36zm4.02-6.051c-.07-.044-.195-.119-.283-.17l-9.558-5.52a1.556 1.556 0 0 0-1.57 0l-11.67 6.738V13.8a.15.15 0 0 1 .057-.124l9.662-5.574a8.995 8.995 0 0 1 13.36 9.315zm-25.277 8.315-4.04-2.333a.141.141 0 0 1-.079-.11v-11.16a8.997 8.997 0 0 1 14.753-6.91c-.073.04-.2.11-.283.161L17.4 10.9a1.552 1.552 0 0 0-.786 1.36l-.006 13.469zM18.803 21l5.198-3.002 5.197 3V27l-5.197 3-5.198-3z"/></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#fff" viewBox="0 0 24 24"><title>OpenRouter</title><path d="M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="#7A8291" viewBox="0 0 24 24"><title>OpenRouter</title><path d="M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z"/></svg>

Before

Width:  |  Height:  |  Size: 685 B

After

Width:  |  Height:  |  Size: 688 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.7 KiB

View File

@@ -0,0 +1,10 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="#A5300F" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M17.4 7.7c0 4.6-4.7 4.9-4.7 8.6" />
<path d="M8 10.7c3.3 0 4.2 2.4 4.2 5.6" />
<circle cx="17.4" cy="4.6" r="3.1" fill="#FADDCD" />
<circle cx="4.9" cy="10.7" r="3.1" fill="#FADDCD" />
<rect x="7.9" y="16.3" width="9.6" height="6.6" rx="1.6" fill="#FADDCD" />
<path d="M11.3 18.6 9.9 19.9l1.4 1.3M14.1 18.6l1.4 1.3-1.4 1.3M13.1 18.3l-1.2 3.3" stroke-width="1.1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 571 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 1155 1000"><path fill="#fff" d="m577.3 0 577.4 1000H0z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 1155 1000"><path fill="#7A8291" d="m577.3 0 577.4 1000H0z"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

After

Width:  |  Height:  |  Size: 131 B

View File

@@ -0,0 +1,4 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m23.6 0-8.721 4.59L9.829 24h7.41z" fill="#30A2FF" />
<path d="M9.83 24V5.142H.4z" fill="#FDB515" />
</svg>

After

Width:  |  Height:  |  Size: 190 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" fill="#fff" stroke="#fff" viewBox="0 0 24 24"><path d="M20.98 11.802a1 1 0 0 0-.738-.771l-6.86-1.716 2.537-5.921a1 1 0 0 0-.317-1.192.996.996 0 0 0-1.234.024l-11 9a1 1 0 0 0 .39 1.744l6.719 1.681-3.345 5.854A1 1 0 0 0 8 22a1 1 0 0 0 .6-.2l12-9a1 1 0 0 0 .38-.998z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" fill="#7A8291" stroke="#7A8291" viewBox="0 0 24 24"><path d="M20.98 11.802a1 1 0 0 0-.738-.771l-6.86-1.716 2.537-5.921a1 1 0 0 0-.317-1.192.996.996 0 0 0-1.234.024l-11 9a1 1 0 0 0 .39 1.744l6.719 1.681-3.345 5.854A1 1 0 0 0 8 22a1 1 0 0 0 .6-.2l12-9a1 1 0 0 0 .38-.998z"/></svg>

Before

Width:  |  Height:  |  Size: 337 B

After

Width:  |  Height:  |  Size: 343 B

View File

@@ -1,5 +1,6 @@
.breadcrumb {
padding-left: 16px;
/* the bar sat flush against the tab strip above it */
padding: var(--spacing-4) 16px var(--spacing-2);
ol {
align-items: center;

View File

@@ -8,6 +8,5 @@ export enum FeatureKeys {
PREMIUM_SUPPORT = 'premium_support',
ANOMALY_DETECTION = 'anomaly_detection',
USE_JSON_BODY = 'use_json_body',
ENABLE_AI_OBSERVABILITY = 'enable_ai_observability',
ENABLE_METRICS_REDUCTION = 'enable_metrics_reduction',
}

View File

@@ -129,6 +129,10 @@ const themeColors = {
salmon2: '#FFAB91',
salmon3: '#E0876A',
},
/* Series palette (dark). Hues in the red band are deliberately absent: red is
reserved for thresholds and error states, so an arbitrary series must never
claim it. generateColor indexes by `hash % Object.keys(...).length`, so
adding or removing an entry recolors every existing chart. */
chartcolors: {
// Blues (3)
dodgerBlue: '#2F80ED',
@@ -152,13 +156,13 @@ const themeColors = {
// Oranges (3)
festivalOrange: '#F2994A',
coralOrange: '#E17055',
amber1: '#E1A155',
pumpkin: '#FF7F50',
// Reds (3)
radicalRed: '#FF1A66',
crimsonRed: '#EB5757',
fireRed: '#E10600',
// Olives / Greens (3)
olive1: '#DFC33A',
olive2: '#D5E55D',
green7: '#81C220',
// Pinks (3)
hotPink: '#E84393',
@@ -191,9 +195,9 @@ const themeColors = {
orange1: '#D35400',
orange2: '#E67E22',
orange3: '#F5B041',
red1: '#C0392B',
red2: '#E74C3C',
red3: '#EC7063',
green8: '#5AC02B',
green9: '#48E043',
green10: '#68E788',
pink1: '#D81B60',
pink2: '#E91E63',
pink3: '#F06292',
@@ -212,9 +216,9 @@ const themeColors = {
coral1: '#E67E22',
coral2: '#F39C12',
coral3: '#F5B041',
crimson1: '#C0392B',
crimson2: '#E74C3C',
crimson3: '#EC7063',
teal7: '#2BC07B',
teal8: '#43E0C5',
teal9: '#68D9E7',
violet1: '#8E44AD',
violet2: '#9B59B6',
violet3: '#BB8FCE',
@@ -224,18 +228,18 @@ const themeColors = {
forest1: '#27AE60',
forest2: '#2ECC71',
forest3: '#58D68D',
blush1: '#FF6F91',
cyan4: '#83C2EB',
blush2: '#FF85A2',
blush3: '#FFA0B3',
lavender1: '#9B59B6',
lavender2: '#AF7AC5',
lavender3: '#C39BD3',
tomato1: '#E74C3C',
tomato2: '#EC7063',
tomato3: '#F1948A',
salmon1: '#FF6B6B',
salmon2: '#FF8787',
salmon3: '#FFA1A1',
blue7: '#4375E0',
blue8: '#686DE7',
indigo1: '#A68EED',
indigo2: '#B980EA',
purple6: '#EE98D9',
olive3: '#F2F0AE',
mustard1: '#F1C40F',
mustard2: '#F7DC6F',
mustard3: '#F9E79F',
@@ -254,9 +258,9 @@ const themeColors = {
blue4: '#2874A6',
blue5: '#2E86C1',
blue6: '#3498DB',
red4: '#C0392B',
red5: '#E74C3C',
red6: '#EC7063',
purple4: '#A52BC0',
purple5: '#E043D0',
magenta4: '#E768B5',
orange4: '#D35400',
orange5: '#E67E22',
orange6: '#EB984E',
@@ -267,18 +271,19 @@ const themeColors = {
gold5: '#F1C40F',
gold6: '#F4D03F',
},
/* Series palette (light). Same red-free constraint as chartcolors above. */
lightModeColor: {
radicalRed: '#D81B60',
magenta1: '#D81B60',
dodgerBlueDark: '#1E5BD9',
steelgrey: '#344B6B',
steelpurple: '#5E548E',
steelindigo: '#8E4A7C',
steelpink: '#B63A6F',
steelcoral: '#E14B5A',
amber1: '#E1A14B',
steelorange: '#E76F2F',
steelgold: '#E09B00',
steelrust: '#C93A50',
olive1: '#C9BD3A',
steelgreen: '#2F7D69',
mediumOrchidDark: '#8E24AA',
@@ -286,17 +291,17 @@ const themeColors = {
seaGreen: '#1E7F5A',
turquoiseBlueDark: '#007EA7',
silverDark: '#5F5F5F',
outrageousOrangeDark: '#E64A19',
roseBudDark: '#D84315',
green1: '#ACDB24',
green2: '#66CC21',
deepSkyBlueDark: '#0277BD',
royalBlue: '#2A4FDB',
avocadoDark: '#6B6B1E',
mintGreenDark: '#2E9E55',
chestnut: '#8B3A3A',
green3: '#3F8B3A',
limaDark: '#5C7F00',
olive: '#6E7F00',
beautyBushDark: '#C93C3C',
green4: '#3CC964',
danube: '#4F6FB3',
oliveDrab: '#4F7F1A',
@@ -304,13 +309,13 @@ const themeColors = {
electricLimeDark: '#6B8F00',
robin: '#2F4FCC',
harleyOrange: '#CC2E12',
teal1: '#1FBF83',
gladeGreen: '#4F7F46',
hemlock: '#5C5C45',
vidaLoca: '#3D6B00',
rust: '#993300',
red: '#C62828',
teal2: '#28C6C1',
blue: '#1A237E',
green: '#1B7F3A',
purple: '#6A1B9A',
@@ -320,7 +325,7 @@ const themeColors = {
brown: '#7A3A1E',
teal: '#006D6F',
limeDark: '#4C8C2B',
maroon: '#6D1B1B',
cyan1: '#1B546D',
navy: '#0D1B5E',
gray: '#616161',
@@ -328,25 +333,25 @@ const themeColors = {
indigo: '#303F9F',
slateGray: '#556B7C',
chocolate: '#9C4A1A',
tomato: '#E53935',
blue1: '#3B74DF',
steelBlue: '#3A6EA5',
peruDark: '#B35E00',
darkOliveGreen: '#445B1F',
indianRed: '#B04040',
blue2: '#4041B0',
mediumSlateBlue: '#5C6BC0',
rosyBrownDark: '#A94444',
indigo1: '#6644A9',
darkSlateGray: '#2E4A4A',
fuchsia: '#C511C5',
salmonDark: '#E64A3C',
darkSalmonDark: '#C85A3A',
indigo2: '#AD42E0',
purple1: '#C83AC5',
paleVioletRedDark: '#C2186A',
mediumPurple: '#7E57C2',
darkOrchid: '#7B1FA2',
mediumSeaGreenDark: '#2E8B57',
lightCoralDark: '#E57373',
purple2: '#E573BC',
gold: '#D4AF37',
sandyBrownDark: '#C76A15',

View File

@@ -1,77 +0,0 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath } from 'react-router-dom';
import { Button } from 'antd';
import type { ColumnsType } from 'antd/lib/table';
import { ResizeTable } from 'components/ResizeTable';
import ROUTES from 'constants/routes';
import useComponentPermission from 'hooks/useComponentPermission';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import { Channels } from 'types/api/channels/getAll';
import Delete from './Delete';
function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element {
const { t } = useTranslation(['channels']);
const { notifications } = useNotifications();
const { user } = useAppContext();
const [action] = useComponentPermission(['new_alert_action'], user.role);
const onClickEditHandler = useCallback((id: string) => {
history.push(
generatePath(ROUTES.CHANNELS_EDIT, {
channelId: id,
}),
);
}, []);
const columns: ColumnsType<Channels> = [
{
title: t('column_channel_name'),
dataIndex: 'name',
key: 'name',
width: 100,
},
{
title: t('column_channel_type'),
dataIndex: 'type',
key: 'type',
width: 80,
},
];
if (action) {
columns.push({
title: t('column_channel_action'),
dataIndex: 'id',
key: 'action',
align: 'center',
width: 80,
render: (id: string): JSX.Element => (
<>
<Button onClick={(): void => onClickEditHandler(id)} type="link">
{t('column_channel_edit')}
</Button>
<Delete id={id} notifications={notifications} />
</>
),
});
}
return (
<ResizeTable
columns={columns}
dataSource={allChannels}
rowKey="id"
bordered
/>
);
}
interface AlertChannelsProps {
allChannels: Channels[];
}
export default AlertChannels;

View File

@@ -1,4 +0,0 @@
.alert-channels-container {
width: 100%;
padding: 0 var(--spacing-8);
}

View File

@@ -1,54 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from 'react-query';
import { Button } from 'antd';
import type { NotificationInstance } from 'antd/es/notification/interface';
import deleteChannel from 'api/channels/delete';
import APIError from 'types/api/error';
function Delete({ notifications, id }: DeleteProps): JSX.Element {
const { t } = useTranslation(['channels']);
const [loading, setLoading] = useState(false);
const queryClient = useQueryClient();
const onClickHandler = async (): Promise<void> => {
try {
setLoading(true);
await deleteChannel({
id,
});
notifications.success({
message: 'Success',
description: t('channel_delete_success'),
});
// Invalidate and refetch
queryClient.invalidateQueries(['getChannels']);
setLoading(false);
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
setLoading(false);
}
};
return (
<Button
loading={loading}
disabled={loading}
type="link"
onClick={onClickHandler}
>
Delete
</Button>
);
}
interface DeleteProps {
notifications: NotificationInstance;
id: string;
}
export default Delete;

View File

@@ -1,84 +0,0 @@
import ROUTES from 'constants/routes';
import AlertChannels from 'container/AllAlertChannels';
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
const successNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: {
success: successNotification,
error: jest.fn(),
},
})),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALL_CHANNELS}`,
}),
}));
describe('Alert Channels Settings List page', () => {
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2023-10-20'));
render(<AlertChannels />);
await waitFor(() =>
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
);
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
describe('Should display the Alert Channels page properly', () => {
it('Should check if "The alerts will be sent to all the configured channels." is visible', () => {
expect(screen.getByText('sending_channels_note')).toBeInTheDocument();
});
it('Should check if "New Alert Channel" Button is visble', () => {
expect(screen.getByText('button_new_channel')).toBeInTheDocument();
});
it('Should check if the help icon is visible and displays "tooltip_notification_channels', async () => {
const helpIcon = screen.getByRole('img', { name: /help/i });
fireEvent.mouseOver(helpIcon);
await waitFor(() => {
const tooltip = screen.getByText('tooltip_notification_channels');
expect(tooltip).toBeInTheDocument();
});
});
});
describe('Should check if the channels table is properly displayed', () => {
it('Should check if the table columns are properly displayed', () => {
expect(screen.getByText('column_channel_name')).toBeInTheDocument();
expect(screen.getByText('column_channel_type')).toBeInTheDocument();
expect(screen.getByText('column_channel_action')).toBeInTheDocument();
});
it('Should check if the data in the table is displayed properly', () => {
expect(screen.getByText('Dummy-Channel')).toBeInTheDocument();
expect(screen.getAllByText('slack')[0]).toBeInTheDocument();
expect(screen.getAllByText('column_channel_edit')[0]).toBeInTheDocument();
expect(screen.getAllByText('Delete')[0]).toBeInTheDocument();
});
it('Should check if clicking on Delete displays Success Toast "Channel Deleted Successfully"', async () => {
const deleteButton = screen.getAllByRole('button', { name: 'Delete' })[0];
expect(deleteButton).toBeInTheDocument();
act(() => {
fireEvent.click(deleteButton);
});
await waitFor(() => {
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_delete_success',
});
});
});
});
});

View File

@@ -1,78 +0,0 @@
import ROUTES from 'constants/routes';
import AlertChannels from 'container/AllAlertChannels';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
const successNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: {
success: successNotification,
error: jest.fn(),
},
})),
}));
jest.mock('hooks/useComponentPermission', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => [false]),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALL_CHANNELS}`,
}),
}));
describe('Alert Channels Settings List page (Normal User)', () => {
beforeEach(async () => {
jest.useFakeTimers();
render(<AlertChannels />);
await waitFor(() =>
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
);
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
describe('Should display the Alert Channels page properly', () => {
it('Should check if "The alerts will be sent to all the configured channels." is visible', async () => {
await waitFor(() =>
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
);
});
it('Should check if "New Alert Channel" Button is visble and disabled', async () => {
const newAlertButton = screen.getByRole('button', {
name: /button_new_channel/i,
});
await waitFor(() => expect(newAlertButton).toBeInTheDocument());
expect(newAlertButton).toBeDisabled();
});
it('Should check if the help icon is visible and displays "tooltip_notification_channels', async () => {
const helpIcon = screen.getByRole('img', { name: /help/i });
fireEvent.mouseOver(helpIcon);
await waitFor(() => {
const tooltip = screen.getByText('tooltip_notification_channels');
expect(tooltip).toBeInTheDocument();
});
});
});
describe('Should check if the channels table is properly displayed', () => {
it('Should check if the table columns are properly displayed', async () => {
expect(screen.getByText('column_channel_name')).toBeInTheDocument();
expect(screen.getByText('column_channel_type')).toBeInTheDocument();
expect(screen.queryByText('column_channel_action')).not.toBeInTheDocument();
});
it('Should check if the data in the table is displayed properly', async () => {
expect(screen.getByText('Dummy-Channel')).toBeInTheDocument();
expect(screen.getAllByText('slack')[0]).toBeInTheDocument();
expect(screen.queryByText('column_channel_edit')).not.toBeInTheDocument();
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
});
});
});

View File

@@ -1,914 +0,0 @@
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import {
GoogleChatInitialConfig,
IncidentIOInitialConfig,
JiraInitialConfig,
JsmOpsInitialConfig,
} from 'container/CreateAlertChannels/defaults';
import {
googleChatDescriptionDefaultValue,
googleChatTitleDefaultValue,
opsGenieDescriptionDefaultValue,
opsGenieMessageDefaultValue,
opsGeniePriorityDefaultValue,
pagerDutyAdditionalDetailsDefaultValue,
pagerDutyDescriptionDefaultValue,
pagerDutySeverityTextDefaultValue,
slackDescriptionDefaultValue,
slackTitleDefaultValue,
} from 'mocks-server/__mockdata__/alerts';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
act,
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { testLabelInputAndHelpValue } from './testUtils';
const successNotification = jest.fn();
const errorNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: {
success: successNotification,
error: errorNotification,
},
})),
}));
const showErrorModal = jest.fn();
jest.mock('providers/ErrorModalProvider', () => ({
__esModule: true,
...jest.requireActual('providers/ErrorModalProvider'),
useErrorModal: jest.fn(() => ({
showErrorModal,
})),
}));
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
}));
describe('Create Alert Channel', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('Should check if the new alert channel is properly displayed with the cascading fields of slack channel', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Slack} />);
});
afterEach(() => {
jest.clearAllMocks();
});
it('Should check if the title is "New Notification Channels"', () => {
expect(screen.getByText('page_title_create')).toBeInTheDocument();
});
it('Should check if the name label and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_channel_name',
testId: 'channel-name-textbox',
});
});
it('Should check if Send resolved alerts label and checkbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_send_resolved',
testId: 'field-send-resolved-checkbox',
});
});
it('Should check if channel type label and dropdown are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_channel_type',
testId: 'channel-type-select',
});
});
// Default Channel type (Slack) fields
it('Should check if the selected item in the type dropdown has text "Slack"', () => {
expect(screen.getByText('Slack')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Recepient label, input, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_recipient',
testId: 'slack-channel-textbox',
helpText: 'slack_channel_help',
});
});
it('Should check if Title label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_title',
testId: 'title-textarea',
});
});
it('Should check if Title contains template', () => {
const titleTextArea = screen.getByTestId('title-textarea');
expect(titleTextArea).toHaveTextContent(slackTitleDefaultValue);
});
it('Should check if Description label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_description',
testId: 'description-textarea',
});
});
it('Should check if Description contains template', () => {
const descriptionTextArea = screen.getByTestId('description-textarea');
expect(descriptionTextArea).toHaveTextContent(slackDescriptionDefaultValue);
});
it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => {
expect(screen.getByText('button_save_channel')).toBeInTheDocument();
expect(screen.getByText('button_test_channel')).toBeInTheDocument();
expect(screen.getByText('button_return')).toBeInTheDocument();
});
it('Should check if saving the form without filling the name displays error notification', async () => {
const saveButton = screen.getByRole('button', {
name: 'button_save_channel',
});
fireEvent.click(saveButton);
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'channel_name_required',
}),
);
});
it('Should check if clicking on Test button shows "An alert has been sent to this channel" success message if testing passes', async () => {
server.use(
rest.post('http://localhost/api/v1/testChannel', (req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: 'test alert sent',
}),
),
),
);
const testButton = screen.getByRole('button', {
name: 'button_test_channel',
});
fireEvent.click(testButton);
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_test_done',
}),
);
});
it('Should check if clicking on Test button shows "Something went wrong" error message if testing fails', async () => {
const testButton = screen.getByRole('button', {
name: 'button_test_channel',
});
act(() => {
fireEvent.click(testButton);
});
await waitFor(() => expect(showErrorModal).toHaveBeenCalled());
});
});
describe('New Alert Channel Cascading Fields Based on Channel Type', () => {
describe('Webhook', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Webhook} />);
});
it('Should check if the selected item in the type dropdown has text "Webhook"', () => {
expect(screen.getByText('Webhook')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Webhook User Name label, input, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_username',
testId: 'webhook-username-textbox',
helpText: 'help_webhook_username',
});
});
it('Should check if Password label and textbox, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'Password (optional)',
testId: 'webhook-password-textbox',
helpText: 'help_webhook_password',
});
});
});
describe('PagerDuty', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Pagerduty} />);
});
it('Should check if the selected item in the type dropdown has text "Pagerduty"', () => {
expect(screen.getByText('Pagerduty')).toBeInTheDocument();
});
it('Should check if Routing key label, required, and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_routing_key',
testId: 'pager-routing-key-textbox',
});
});
it('Should check if Description label, required, info (Shows up as description in pagerduty), and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_description',
testId: 'pager-description-textarea',
helpText: 'help_pager_description',
});
});
it('Should check if the description contains default template', () => {
const descriptionTextArea = screen.getByTestId(
'pager-description-textarea',
);
expect(descriptionTextArea).toHaveTextContent(
pagerDutyDescriptionDefaultValue,
);
});
it('Should check if Severity label, info (help_pager_severity), and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_severity',
testId: 'pager-severity-textbox',
helpText: 'help_pager_severity',
});
});
it('Should check if Severity contains the default template', () => {
const severityTextbox = screen.getByTestId('pager-severity-textbox');
expect(severityTextbox).toHaveValue(pagerDutySeverityTextDefaultValue);
});
it('Should check if Additional Information label, text area, and help text (help_pager_details) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_details',
testId: 'pager-additional-details-textarea',
helpText: 'help_pager_details',
});
});
it('Should check if Additional Information contains the default template', () => {
const detailsTextArea = screen.getByTestId(
'pager-additional-details-textarea',
);
expect(detailsTextArea).toHaveValue(pagerDutyAdditionalDetailsDefaultValue);
});
it('Should check if Group label, text area, and info (help_pager_group) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_group',
testId: 'pager-group-textarea',
helpText: 'help_pager_group',
});
});
it('Should check if Class label, text area, and info (help_pager_class) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_class',
testId: 'pager-class-textarea',
helpText: 'help_pager_class',
});
});
it('Should check if Client label, text area, and info (Shows up as event source in Pagerduty) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_client',
testId: 'pager-client-textarea',
helpText: 'help_pager_client',
});
});
it('Should check if Client input contains the default value "SigNoz Alert Manager"', () => {
const clientTextArea = screen.getByTestId('pager-client-textarea');
expect(clientTextArea).toHaveValue('SigNoz Alert Manager');
});
it('Should check if Client URL label, text area, and info (Shows up as event source link in Pagerduty) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_client_url',
testId: 'pager-client-url-textarea',
helpText: 'help_pager_client_url',
});
});
it('Should check if Client URL contains the default value "https://enter-signoz-host-n-port-here/alerts"', () => {
const clientUrlTextArea = screen.getByTestId('pager-client-url-textarea');
expect(clientUrlTextArea).toHaveValue(
'https://enter-signoz-host-n-port-here/alerts',
);
});
});
describe('Opsgenie', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Opsgenie} />);
});
it('Should check if the selected item in the type dropdown has text "Opsgenie"', () => {
expect(screen.getByText('Opsgenie')).toBeInTheDocument();
});
it('Should check if API key label, required, and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_api_key',
testId: 'opsgenie-api-key-textbox',
required: true,
});
});
it('Should check if Message label, required, info (Shows up as message in opsgenie), and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_message',
testId: 'opsgenie-message-textarea',
helpText: 'help_opsgenie_message',
required: true,
});
});
it('Should check if Message contains the default template', () => {
const messageTextArea = screen.getByTestId('opsgenie-message-textarea');
expect(messageTextArea).toHaveValue(opsGenieMessageDefaultValue);
});
it('Should check if Description label, required, info (Shows up as description in opsgenie), and text area are displayed properly `{{ if gt (len .Alerts.Firing) 0 -}}', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_description',
testId: 'opsgenie-description-textarea',
helpText: 'help_opsgenie_description',
required: true,
});
});
it('Should check if Description label, required, info (Shows up as description in opsgenie), and text area are displayed properly `{{ if gt (len .Alerts.Firing) 0 -}}', () => {
const descriptionTextArea = screen.getByTestId(
'opsgenie-description-textarea',
);
expect(descriptionTextArea).toHaveTextContent(
opsGenieDescriptionDefaultValue,
);
});
it('Should check if Priority label, required, info (help_opsgenie_priority), and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_priority',
testId: 'opsgenie-priority-textarea',
helpText: 'help_opsgenie_priority',
required: true,
});
});
it('Should check if Message contains the default template', () => {
const priorityTextArea = screen.getByTestId('opsgenie-priority-textarea');
expect(priorityTextArea).toHaveValue(opsGeniePriorityDefaultValue);
});
});
describe('Email', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Email} />);
});
it('Should check if the selected item in the type dropdown has text "Email"', () => {
expect(screen.getByText('Email')).toBeInTheDocument();
});
it('Should check if API key label, required, info(help_email_to), and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_email_to',
testId: 'email-to-textbox',
helpText: 'help_email_to',
required: true,
});
});
});
describe('Microsoft Teams', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.MsTeams} />);
});
it('Should check if the selected item in the type dropdown has text "msteams"', () => {
expect(screen.getByText('Microsoft Teams')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Title label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_title',
testId: 'title-textarea',
});
});
it('Should check if Title contains template', () => {
const titleTextArea = screen.getByTestId('title-textarea');
expect(titleTextArea).toHaveTextContent(slackTitleDefaultValue);
});
it('Should check if Description label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_description',
testId: 'description-textarea',
});
});
it('Should check if Description contains template', () => {
const descriptionTextArea = screen.getByTestId('description-textarea');
expect(descriptionTextArea).toHaveTextContent(slackDescriptionDefaultValue);
});
});
describe('Google Chat', () => {
const validWebhookUrl =
'https://chat.googleapis.com/v1/spaces/AAAA/messages?key=dummy_key&token=dummy_token';
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.GoogleChat} />);
});
// paste instead of type: a per-keystroke re-render of the whole form
// pushes these tests past the 5s jest timeout on slower CI runners
async function fillField(
user: ReturnType<typeof userEvent.setup>,
testId: string,
value: string,
): Promise<void> {
await user.click(screen.getByTestId(testId));
await user.paste(value);
}
it('Should check if the selected item in the type dropdown has text "Google Chat"', () => {
expect(screen.getByText('Google Chat')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Title contains the google chat template', () => {
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
googleChatTitleDefaultValue,
);
});
it('Should check if Description contains the google chat template', () => {
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
googleChatDescriptionDefaultValue,
);
});
it('Should check if saving with a webhook url outside chat.googleapis.com displays error notification', async () => {
const user = userEvent.setup();
await fillField(user, 'channel-name-textbox', 'gchat-channel');
await fillField(user, 'webhook-url-textbox', 'https://example.com/webhook');
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'google_chat_webhook_url_invalid',
}),
);
});
it('Should check if saving sends a googlechat_configs payload', async () => {
let requestBody: unknown;
server.use(
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
requestBody = await req.json();
return res(
ctx.status(201),
ctx.json({ status: 'success', data: 'channel created' }),
);
}),
);
const user = userEvent.setup();
await fillField(user, 'channel-name-textbox', 'gchat-channel');
await fillField(user, 'webhook-url-textbox', validWebhookUrl);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_creation_done',
}),
);
expect(requestBody).toStrictEqual({
name: 'gchat-channel',
googlechat_configs: [
{
webhook_url: validWebhookUrl,
title: GoogleChatInitialConfig.title,
text: GoogleChatInitialConfig.text,
send_resolved: true,
},
],
});
});
});
describe('Jira', () => {
const validSite = 'https://acme.atlassian.net';
const fillRequired = async (
user: ReturnType<typeof userEvent.setup>,
site: string,
): Promise<void> => {
await user.type(screen.getByTestId('channel-name-textbox'), 'jira-channel');
await user.type(screen.getByTestId('jira-site-textbox'), site);
await user.type(screen.getByTestId('jira-email-textbox'), 'me@acme.com');
await user.type(screen.getByTestId('jira-api-token-textbox'), 'tok123');
await user.type(screen.getByTestId('jira-project-textbox'), 'KAN');
};
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Jira} />);
});
it('Should check if the selected item in the type dropdown has text "Jira"', () => {
expect(screen.getByText('Jira')).toBeInTheDocument();
});
it('Should check if the Site URL field is displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_jira_site',
testId: 'jira-site-textbox',
});
});
it('Should prefill the issue type with Task', () => {
expect(screen.getByTestId('jira-issue-type-textbox')).toHaveValue('Task');
});
it('Should show the service-account recommendation tip linking to the docs', () => {
expect(screen.getByTestId('jira-service-account-tip')).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'jira_service_account_tip_link' }),
).toHaveAttribute(
'href',
'https://signoz.io/docs/alerts-management/notification-channel/jira/#use-a-service-account-recommended',
);
});
it('Should display an error when the site is not an atlassian.net URL', async () => {
const user = userEvent.setup({ delay: null });
await fillRequired(user, 'https://example.com');
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'jira_site_invalid',
}),
);
}, 15000);
it('Should send a jira_configs payload with basic auth', async () => {
let requestBody: unknown;
server.use(
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
requestBody = await req.json();
return res(
ctx.status(201),
ctx.json({ status: 'success', data: 'channel created' }),
);
}),
);
const user = userEvent.setup({ delay: null });
await fillRequired(user, validSite);
await user.click(screen.getByText('jira_advanced_section'));
await user.type(
screen.getByTestId('jira-wont-fix-resolution-textbox'),
"Won't Do",
);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_creation_done',
}),
);
expect(requestBody).toStrictEqual({
name: 'jira-channel',
jira_configs: [
{
site: validSite,
project: 'KAN',
issue_type: 'Task',
summary: JiraInitialConfig.summary,
description: JiraInitialConfig.description,
send_resolved: true,
wont_fix_resolution: "Won't Do",
http_config: {
basic_auth: { username: 'me@acme.com', password: 'tok123' },
},
},
],
});
}, 15000);
it('Should block save when the reopen window is below the 1m minimum', async () => {
const user = userEvent.setup({ delay: null });
await fillRequired(user, validSite);
await user.click(screen.getByText('jira_advanced_section'));
await user.type(screen.getByTestId('jira-reopen-duration-textbox'), '30s');
// the rule surfaces an inline message, not just a red border
await expect(
screen.findByText('jira_reopen_duration_invalid'),
).resolves.toBeInTheDocument();
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'jira_reopen_duration_invalid',
}),
);
}, 15000);
});
describe('JSM Ops', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.JsmOps} />);
});
it('Should show "Jira Service Management Ops" as the selected type', () => {
expect(screen.getByText('Jira Service Management Ops')).toBeInTheDocument();
});
it('Should display the API key field properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_jsmops_api_key',
testId: 'jsmops-api-key-textbox',
});
});
it('Should show the tip linking to the JSM Ops docs', () => {
expect(screen.getByTestId('jsmops-tip')).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'jsmops_tip_link' }),
).toHaveAttribute(
'href',
'https://signoz.io/docs/alerts-management/notification-channel/jsm-ops/',
);
});
it('Should block save when the API key is missing', async () => {
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'jsmops-channel',
);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'api_key_required',
}),
);
});
it('Should send a jsmops_configs payload with prefilled defaults', async () => {
let requestBody: unknown;
server.use(
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
requestBody = await req.json();
return res(
ctx.status(201),
ctx.json({ status: 'success', data: 'channel created' }),
);
}),
);
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'jsmops-channel',
);
await user.type(screen.getByTestId('jsmops-api-key-textbox'), 'key-abc');
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_creation_done',
}),
);
expect(requestBody).toStrictEqual({
name: 'jsmops-channel',
jsmops_configs: [
{
api_key: 'key-abc',
send_resolved: true,
message: JsmOpsInitialConfig.message,
description: JsmOpsInitialConfig.description,
priority: JsmOpsInitialConfig.priority,
tags: JsmOpsInitialConfig.tags?.join(','),
},
],
});
});
});
describe('incident.io', () => {
const incidentIOURL =
'https://api.incident.io/v2/alert_events/http/01M0D1JNVBGBGVTWX053EM12XV';
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.IncidentIO} />);
});
it('Should display the URL and token fields with the docs tip', () => {
testLabelInputAndHelpValue({
labelText: 'field_incidentio_url',
testId: 'incidentio-url-textbox',
});
testLabelInputAndHelpValue({
labelText: 'field_incidentio_token',
testId: 'incidentio-token-textbox',
});
expect(screen.getByTestId('incidentio-tip')).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'incidentio_tip_link' }),
).toHaveAttribute(
'href',
'https://signoz.io/docs/alerts-management/notification-channel/incidentio/',
);
});
it('Should block save when the URL or token is missing', async () => {
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'incidentio-channel',
);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'incidentio_required_fields',
}),
);
});
it('Should display an error when the URL is not an alert events URL', async () => {
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'incidentio-channel',
);
await user.type(
screen.getByTestId('incidentio-url-textbox'),
'https://api.incident.io/v2/incidents',
);
await user.type(screen.getByTestId('incidentio-token-textbox'), 'tok-abc');
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'incidentio_url_invalid',
}),
);
}, 15000);
it('Should send an incidentio_configs payload with prefilled defaults', async () => {
let requestBody: unknown;
server.use(
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
requestBody = await req.json();
return res(
ctx.status(201),
ctx.json({ status: 'success', data: 'channel created' }),
);
}),
);
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'incidentio-channel',
);
await user.type(
screen.getByTestId('incidentio-url-textbox'),
incidentIOURL,
);
await user.type(screen.getByTestId('incidentio-token-textbox'), 'tok-abc');
await user.click(screen.getByTestId('incidentio-metadata-add'));
await user.type(screen.getByTestId('incidentio-metadata-key-0'), 'team');
await user.type(screen.getByTestId('incidentio-metadata-value-0'), 'core');
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_creation_done',
}),
);
expect(requestBody).toStrictEqual({
name: 'incidentio-channel',
incidentio_configs: [
{
url: incidentIOURL,
token: 'tok-abc',
send_resolved: true,
title: IncidentIOInitialConfig.title,
description: IncidentIOInitialConfig.description,
metadata: { team: 'core' },
},
],
});
}, 15000);
});
describe('Changing the channel type', () => {
async function selectType(
user: ReturnType<typeof userEvent.setup>,
optionText: string,
): Promise<void> {
// the type dropdown opens on the inner search input of the antd select
await user.click(screen.getByRole('combobox'));
await user.click(await screen.findByTitle(optionText));
}
it('Should check if switching to Google Chat and back swaps the prefilled templates', async () => {
const user = userEvent.setup();
render(<CreateAlertChannels preType={ChannelType.Slack} />);
await selectType(user, 'Google Chat');
await waitFor(() =>
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
googleChatTitleDefaultValue,
),
);
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
googleChatDescriptionDefaultValue,
);
await selectType(user, 'Slack');
await waitFor(() =>
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
slackTitleDefaultValue,
),
);
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
slackDescriptionDefaultValue,
);
});
it('Should check if switching to Pagerduty prefills the pagerduty description and not the opsgenie one', async () => {
const user = userEvent.setup();
render(<CreateAlertChannels preType={ChannelType.Opsgenie} />);
await selectType(user, 'Pagerduty');
await waitFor(() =>
expect(screen.getByTestId('pager-description-textarea')).toHaveTextContent(
pagerDutyDescriptionDefaultValue,
),
);
});
});
});
});

View File

@@ -1,336 +0,0 @@
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import {
opsGenieDescriptionDefaultValue,
opsGenieMessageDefaultValue,
opsGeniePriorityDefaultValue,
pagerDutyAdditionalDetailsDefaultValue,
pagerDutyDescriptionDefaultValue,
pagerDutySeverityTextDefaultValue,
slackDescriptionDefaultValue,
slackTitleDefaultValue,
} from 'mocks-server/__mockdata__/alerts';
import { render, screen } from 'tests/test-utils';
import { testLabelInputAndHelpValue } from './testUtils';
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
}));
describe('Create Alert Channel (Normal User)', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('Should check if the new alert channel is properly displayed with the cascading fields of slack channel', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Slack} />);
});
it('Should check if the title is "New Notification Channels"', () => {
expect(screen.getByText('page_title_create')).toBeInTheDocument();
});
it('Should check if the name label and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_channel_name',
testId: 'channel-name-textbox',
});
});
it('Should check if Send resolved alerts label and checkbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_send_resolved',
testId: 'field-send-resolved-checkbox',
});
});
it('Should check if channel type label and dropdown are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_channel_type',
testId: 'channel-type-select',
});
});
// Default Channel type (Slack) fields
it('Should check if the selected item in the type dropdown has text "Slack"', () => {
expect(screen.getByText('Slack')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Recepient label, input, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_recipient',
testId: 'slack-channel-textbox',
helpText: 'slack_channel_help',
});
});
it('Should check if Title label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_title',
testId: 'title-textarea',
});
});
it('Should check if Title contains template', () => {
const titleTextArea = screen.getByTestId('title-textarea');
expect(titleTextArea).toHaveTextContent(slackTitleDefaultValue);
});
it('Should check if Description label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_description',
testId: 'description-textarea',
});
});
it('Should check if Description contains template', () => {
const descriptionTextArea = screen.getByTestId('description-textarea');
expect(descriptionTextArea).toHaveTextContent(slackDescriptionDefaultValue);
});
it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => {
expect(screen.getByText('button_save_channel')).toBeInTheDocument();
expect(screen.getByText('button_test_channel')).toBeInTheDocument();
expect(screen.getByText('button_return')).toBeInTheDocument();
});
});
describe('New Alert Channel Cascading Fields Based on Channel Type', () => {
describe('Webhook', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Webhook} />);
});
it('Should check if the selected item in the type dropdown has text "Webhook"', () => {
expect(screen.getByText('Webhook')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Webhook User Name label, input, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_username',
testId: 'webhook-username-textbox',
helpText: 'help_webhook_username',
});
});
it('Should check if Password label and textbox, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'Password (optional)',
testId: 'webhook-password-textbox',
helpText: 'help_webhook_password',
});
});
});
describe('PagerDuty', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Pagerduty} />);
});
it('Should check if the selected item in the type dropdown has text "Pagerduty"', () => {
expect(screen.getByText('Pagerduty')).toBeInTheDocument();
});
it('Should check if Routing key label, required, and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_routing_key',
testId: 'pager-routing-key-textbox',
});
});
it('Should check if Description label, required, info (Shows up as description in pagerduty), and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_description',
testId: 'pager-description-textarea',
helpText: 'help_pager_description',
});
});
it('Should check if the description contains default template', () => {
const descriptionTextArea = screen.getByTestId(
'pager-description-textarea',
);
expect(descriptionTextArea).toHaveTextContent(
pagerDutyDescriptionDefaultValue,
);
});
it('Should check if Severity label, info (help_pager_severity), and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_severity',
testId: 'pager-severity-textbox',
helpText: 'help_pager_severity',
});
});
it('Should check if Severity contains the default template', () => {
const severityTextbox = screen.getByTestId('pager-severity-textbox');
expect(severityTextbox).toHaveValue(pagerDutySeverityTextDefaultValue);
});
it('Should check if Additional Information label, text area, and help text (help_pager_details) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_details',
testId: 'pager-additional-details-textarea',
helpText: 'help_pager_details',
});
});
it('Should check if Additional Information contains the default template', () => {
const detailsTextArea = screen.getByTestId(
'pager-additional-details-textarea',
);
expect(detailsTextArea).toHaveValue(pagerDutyAdditionalDetailsDefaultValue);
});
it('Should check if Group label, text area, and info (help_pager_group) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_group',
testId: 'pager-group-textarea',
helpText: 'help_pager_group',
});
});
it('Should check if Class label, text area, and info (help_pager_class) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_class',
testId: 'pager-class-textarea',
helpText: 'help_pager_class',
});
});
it('Should check if Client label, text area, and info (Shows up as event source in Pagerduty) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_client',
testId: 'pager-client-textarea',
helpText: 'help_pager_client',
});
});
it('Should check if Client input contains the default value "SigNoz Alert Manager"', () => {
const clientTextArea = screen.getByTestId('pager-client-textarea');
expect(clientTextArea).toHaveValue('SigNoz Alert Manager');
});
it('Should check if Client URL label, text area, and info (Shows up as event source link in Pagerduty) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_client_url',
testId: 'pager-client-url-textarea',
helpText: 'help_pager_client_url',
});
});
it('Should check if Client URL contains the default value "https://enter-signoz-host-n-port-here/alerts"', () => {
const clientUrlTextArea = screen.getByTestId('pager-client-url-textarea');
expect(clientUrlTextArea).toHaveValue(
'https://enter-signoz-host-n-port-here/alerts',
);
});
});
describe('Opsgenie', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Opsgenie} />);
});
it('Should check if the selected item in the type dropdown has text "Opsgenie"', () => {
expect(screen.getByText('Opsgenie')).toBeInTheDocument();
});
it('Should check if API key label, required, and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_api_key',
testId: 'opsgenie-api-key-textbox',
required: true,
});
});
it('Should check if Message label, required, info (Shows up as message in opsgenie), and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_message',
testId: 'opsgenie-message-textarea',
helpText: 'help_opsgenie_message',
required: true,
});
});
it('Should check if Message contains the default template', () => {
const messageTextArea = screen.getByTestId('opsgenie-message-textarea');
expect(messageTextArea).toHaveValue(opsGenieMessageDefaultValue);
});
it('Should check if Description label, required, info (Shows up as description in opsgenie), and text area are displayed properly `{{ if gt (len .Alerts.Firing) 0 -}}', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_description',
testId: 'opsgenie-description-textarea',
helpText: 'help_opsgenie_description',
required: true,
});
});
it('Should check if Description label, required, info (Shows up as description in opsgenie), and text area are displayed properly `{{ if gt (len .Alerts.Firing) 0 -}}', () => {
const descriptionTextArea = screen.getByTestId(
'opsgenie-description-textarea',
);
expect(descriptionTextArea).toHaveTextContent(
opsGenieDescriptionDefaultValue,
);
});
it('Should check if Priority label, required, info (help_opsgenie_priority), and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_priority',
testId: 'opsgenie-priority-textarea',
helpText: 'help_opsgenie_priority',
required: true,
});
});
it('Should check if Message contains the default template', () => {
const priorityTextArea = screen.getByTestId('opsgenie-priority-textarea');
expect(priorityTextArea).toHaveValue(opsGeniePriorityDefaultValue);
});
});
describe('Email', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Email} />);
});
it('Should check if the selected item in the type dropdown has text "Email"', () => {
expect(screen.getByText('Email')).toBeInTheDocument();
});
it('Should check if API key label, required, info(help_email_to), and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_email_to',
testId: 'email-to-textbox',
helpText: 'help_email_to',
required: true,
});
});
});
describe('Microsoft Teams', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.MsTeams} />);
});
it('Should check if the selected item in the type dropdown has text "Microsoft Teams"', () => {
expect(screen.getByText('Microsoft Teams')).toBeInTheDocument();
});
it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => {
expect(
screen.getByRole('button', { name: 'button_save_channel' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'button_test_channel' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'button_return' }),
).toBeInTheDocument();
});
it.skip('Should check if save and test buttons are disabled', () => {
expect(
screen.getByRole('button', { name: 'button_save_channel' }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: 'button_test_channel' }),
).toBeDisabled();
});
});
});
});

View File

@@ -1,120 +0,0 @@
import EditAlertChannels from 'container/EditAlertChannels';
import {
editAlertChannelInitialValue,
editSlackDescriptionDefaultValue,
slackTitleDefaultValue,
} from 'mocks-server/__mockdata__/alerts';
import { render, screen } from 'tests/test-utils';
import { testLabelInputAndHelpValue } from './testUtils';
const successNotification = jest.fn();
const errorNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: {
success: successNotification,
error: errorNotification,
},
})),
}));
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
}));
describe('Should check if the edit alert channel is properly displayed', () => {
beforeEach(() => {
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
});
afterEach(() => {
jest.clearAllMocks();
});
it('Should check if the title is "Edit Notification Channels"', () => {
expect(screen.getByText('page_title_edit')).toBeInTheDocument();
});
it('Should check if the name label and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_channel_name',
testId: 'channel-name-textbox',
value: 'Dummy-Channel',
});
});
it('Should check if Send resolved alerts label and checkbox are displayed properly and the checkbox is checked', () => {
testLabelInputAndHelpValue({
labelText: 'field_send_resolved',
testId: 'field-send-resolved-checkbox',
});
expect(screen.getByTestId('field-send-resolved-checkbox')).toBeChecked();
});
it('Should check if channel type label and dropdown are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_channel_type',
testId: 'channel-type-select',
});
});
it('Should check if the selected item in the type dropdown has text "Slack"', () => {
expect(screen.getByText('Slack')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
value:
'https://discord.com/api/webhooks/dummy_webhook_id/dummy_webhook_token/slack',
});
});
it('Should check if Recepient label, input, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_recipient',
testId: 'slack-channel-textbox',
helpText: 'slack_channel_help',
value: '#dummy_channel',
});
});
it('Should check if Title label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_title',
testId: 'title-textarea',
});
});
it('Should check if Title contains template', () => {
const titleTextArea = screen.getByTestId('title-textarea');
expect(titleTextArea).toHaveTextContent(slackTitleDefaultValue);
});
it('Should check if Description label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_description',
testId: 'description-textarea',
});
});
it('Should check if Description contains template', () => {
const descriptionTextArea = screen.getByTestId('description-textarea');
expect(descriptionTextArea).toHaveTextContent(
editSlackDescriptionDefaultValue,
);
});
it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => {
expect(screen.getByText('button_save_channel')).toBeInTheDocument();
expect(screen.getByText('button_test_channel')).toBeInTheDocument();
expect(screen.getByText('button_return')).toBeInTheDocument();
});
});

View File

@@ -1,186 +0,0 @@
import EditAlertChannels from 'container/EditAlertChannels';
import { editAlertChannelInitialValue } from 'mocks-server/__mockdata__/alerts';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: { success: jest.fn(), error: jest.fn() },
})),
}));
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
}));
interface EditRequest {
id: string;
body: { name: string; slack_configs: { send_resolved: boolean }[] };
}
// Captures the PUT /channels/:id request the edit form fires, so assertions can
// run against the real HTTP payload instead of a hand-mocked api client.
function mockEditChannel(): { calls: EditRequest[] } {
const result: { calls: EditRequest[] } = { calls: [] };
server.use(
rest.put('http://localhost/api/v1/channels/:id', async (req, res, ctx) => {
result.calls.push({
id: req.params.id as string,
body: await req.json(),
});
return res(
ctx.status(200),
ctx.json({ status: 'success', data: 'channel updated' }),
);
}),
);
return result;
}
describe('EditAlertChannels save', () => {
afterEach(() => jest.clearAllMocks());
it('sends the channelId in the edit request (regression: empty id)', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].id).toBe('3');
});
it('blocks jira save when the reopen window is below the 1m minimum', async () => {
const edit = mockEditChannel();
const jiraInitialValue = {
type: 'jira',
name: 'jira-channel',
site: 'https://acme.atlassian.net',
username: 'user@acme.io',
password: 'token',
project: 'OPS',
issue_type: 'Task',
send_resolved: true,
reopen_duration: '30s',
};
const { unmount } = render(
<EditAlertChannels channelId="3" initialValue={jiraInitialValue} />,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('save-channel-button'));
expect(edit.calls).toHaveLength(0);
unmount();
render(
<EditAlertChannels
channelId="3"
initialValue={{ ...jiraInitialValue, reopen_duration: '72h' }}
/>,
);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
});
it('preserves the jira wont-fix resolution on save', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="3"
initialValue={{
type: 'jira',
name: 'jira-channel',
site: 'https://acme.atlassian.net',
username: 'user@acme.io',
password: 'token',
project: 'OPS',
issue_type: 'Task',
send_resolved: true,
wont_fix_resolution: "Won't Do",
}}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].body).toStrictEqual({
name: 'jira-channel',
jira_configs: [
{
site: 'https://acme.atlassian.net',
project: 'OPS',
issue_type: 'Task',
send_resolved: true,
wont_fix_resolution: "Won't Do",
http_config: {
basic_auth: { username: 'user@acme.io', password: 'token' },
},
},
],
});
});
it('sends an incidentio_configs payload when editing an incident.io channel', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="4"
initialValue={{
type: 'incidentio',
name: 'incidentio-channel',
url: 'https://api.incident.io/v2/alert_events/http/01M0D1JNVBGBGVTWX053EM12XV',
token: 'tok-abc',
send_resolved: true,
metadata: { env: 'prod' },
}}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].id).toBe('4');
expect(edit.calls[0].body).toStrictEqual({
name: 'incidentio-channel',
incidentio_configs: [
{
url: 'https://api.incident.io/v2/alert_events/http/01M0D1JNVBGBGVTWX053EM12XV',
token: 'tok-abc',
send_resolved: true,
metadata: { env: 'prod' },
},
],
});
});
it('persists send_resolved toggle in the edit request', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
const user = userEvent.setup();
const sendResolved = screen.getByTestId('field-send-resolved-checkbox');
expect(sendResolved).toBeChecked();
await user.click(sendResolved);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].id).toBe('3');
expect(edit.calls[0].body.slack_configs[0].send_resolved).toBe(false);
});
});

View File

@@ -1,31 +0,0 @@
import { screen } from 'tests/test-utils';
export const testLabelInputAndHelpValue = ({
labelText,
testId,
helpText,
required = false,
value,
}: {
labelText: string;
testId: string;
helpText?: string;
required?: boolean;
value?: string;
}): void => {
const label = screen.getByText(labelText);
expect(label).toBeInTheDocument();
const input = screen.getByTestId(testId);
expect(input).toBeInTheDocument();
if (helpText !== undefined) {
expect(screen.getByText(helpText)).toBeInTheDocument();
}
if (required) {
expect(input).toBeRequired();
}
if (value) {
expect(input).toHaveValue(value);
}
};

View File

@@ -1,95 +0,0 @@
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { Plus } from '@signozhq/icons';
import { Tooltip, Flex } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import getAll from 'api/channels/getAll';
import logEvent from 'api/common/logEvent';
import Spinner from 'components/Spinner';
import TextToolTip from 'components/TextToolTip';
import ROUTES from 'constants/routes';
import useComponentPermission from 'hooks/useComponentPermission';
import history from 'lib/history';
import { isUndefined } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import AlertChannelsComponent from './AlertChannels';
import { Button, ButtonContainer, RightActionContainer } from './styles';
import './AllAlertChannels.styles.scss';
const { Text } = Typography;
function AlertChannels(): JSX.Element {
const { t } = useTranslation(['channels']);
const { user } = useAppContext();
const [addNewChannelPermission] = useComponentPermission(
['add_new_channel'],
user.role,
);
const onToggleHandler = useCallback(() => {
history.push(ROUTES.CHANNELS_NEW);
}, []);
const { isLoading, data, error } = useQuery<
SuccessResponseV2<Channels[]>,
APIError
>(['getChannels'], {
queryFn: () => getAll(),
});
useEffect(() => {
if (!isUndefined(data?.data)) {
logEvent('Alert Channel: Channel list page visited', {
number: data?.data?.length,
});
}
}, [data?.data]);
if (error) {
return <Typography>{error.getErrorMessage()}</Typography>;
}
if (isLoading || isUndefined(data?.data)) {
return <Spinner tip={t('loading_channels_message')} height="90vh" />;
}
return (
<div className="alert-channels-container">
<ButtonContainer>
<Text truncate={1} color="muted">
{t('sending_channels_note')}
</Text>
<RightActionContainer>
<TextToolTip
text={t('tooltip_notification_channels')}
url="https://signoz.io/docs/setup-alerts-notification/"
/>
<Tooltip
title={
!addNewChannelPermission
? 'Ask an admin to create alert channel'
: undefined
}
>
<Button onClick={onToggleHandler} disabled={!addNewChannelPermission}>
<Flex align="center" justify="center" gap={4}>
<Plus size="md" /> {t('button_new_channel')}
</Flex>
</Button>
</Tooltip>
</RightActionContainer>
</ButtonContainer>
<AlertChannelsComponent allChannels={data?.data || []} />
</div>
);
}
export default AlertChannels;

View File

@@ -1,26 +0,0 @@
import { Button as ButtonComponent } from 'antd';
import styled from 'styled-components';
export const RightActionContainer = styled.div`
&&& {
display: flex;
align-items: center;
}
`;
export const ButtonContainer = styled.div`
&&& {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
margin-bottom: 1rem;
padding-right: 1rem;
}
`;
export const Button = styled(ButtonComponent)`
&&& {
margin-left: 1rem;
}
`;

View File

@@ -1,13 +0,0 @@
.create-alert-channels-container {
width: 100%;
border: 1px solid var(--l1-border);
background: var(--l2-background);
border-radius: 3px;
padding: 16px;
.form-alert-channels-title {
margin-top: 0px;
margin-bottom: 16px;
}
}

View File

@@ -0,0 +1,110 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import { toChannelConfig, toPostableChannel } from './channelConfig';
import { toChannelFormState } from './channelFormValues';
import { ChannelFormValues, ChannelKind } from './types';
const slackValues: ChannelFormValues = {
name: 'prod alerts',
apiUrl: 'https://hooks.slack.com/services/T/B/X',
channel: '#alerts',
title: 'title template',
titleLink: 'https://signoz.io',
text: 'body template',
pretext: 'pretext',
fallback: 'fallback',
footer: 'footer',
color: 'danger',
fields: [{ title: 'env', value: 'prod', short: true }],
actions: [{ type: 'button', text: 'Runbook', url: 'https://runbook' }],
sendResolved: true,
};
describe('toChannelConfig', () => {
it('sends every field the slack spec models, new ones included', () => {
expect(toChannelConfig(ChannelKind.slack, slackValues)).toStrictEqual({
kind: 'slack',
spec: {
apiUrl: 'https://hooks.slack.com/services/T/B/X',
channel: '#alerts',
title: 'title template',
titleLink: 'https://signoz.io',
text: 'body template',
pretext: 'pretext',
fallback: 'fallback',
footer: 'footer',
color: 'danger',
fields: [{ title: 'env', value: 'prod', short: true }],
actions: [{ type: 'button', text: 'Runbook', url: 'https://runbook' }],
sendResolved: true,
},
});
});
it('drops untouched fields so the api applies its own defaults', () => {
expect(
toChannelConfig(ChannelKind.webhook, { url: 'https://example.com/hook' }),
).toStrictEqual({
kind: 'webhook',
spec: { url: 'https://example.com/hook' },
});
});
it('keeps a false the user chose rather than treating it as blank', () => {
expect(
toChannelConfig(ChannelKind.webhook, {
url: 'https://example.com/hook',
sendResolved: false,
}),
).toMatchObject({ spec: { sendResolved: false } });
});
it('sends only the selected kind s fields, not another kind s leftovers', () => {
const config = toChannelConfig(ChannelKind.msteams, {
webhookUrl: 'https://teams.example.com/hook',
title: 'shared title',
// left over from a kind the user switched away from
routingKey: 'pagerduty-key',
apiUrl: 'https://hooks.slack.com/services/T/B/X',
});
expect(config.spec).toStrictEqual({
webhookUrl: 'https://teams.example.com/hook',
title: 'shared title',
});
});
});
describe('toPostableChannel', () => {
it('lets the api generate the immutable name from the display name', () => {
expect(toPostableChannel(ChannelKind.slack, slackValues)).toMatchObject({
generateName: true,
displayName: 'prod alerts',
});
});
it('leaves the display name out of the spec', () => {
expect(
toPostableChannel(ChannelKind.slack, slackValues).config.spec,
).not.toHaveProperty('name');
});
});
describe('toChannelFormState', () => {
it('round-trips a channel back into the form it was built from', () => {
const channel = {
id: '1',
name: 'prod-alerts',
displayName: 'prod alerts',
createdAt: '2026-09-01T00:00:00Z',
updatedAt: '2026-09-01T00:00:00Z',
config: toChannelConfig(ChannelKind.slack, slackValues),
} as AlertmanagertypesGettableNotificationChannelDTO;
const { kind, values } = toChannelFormState(channel);
expect(kind).toBe(ChannelKind.slack);
expect(toChannelConfig(kind, values)).toStrictEqual(channel.config);
expect(values.name).toBe('prod alerts');
});
});

View File

@@ -0,0 +1,174 @@
import {
AlertmanagertypesChannelConfigDTO,
AlertmanagertypesChannelEmailConfigDTO,
AlertmanagertypesChannelGoogleChatConfigDTO,
AlertmanagertypesChannelIncidentIOConfigDTO,
AlertmanagertypesChannelJiraConfigDTO,
AlertmanagertypesChannelJSMOpsConfigDTO,
AlertmanagertypesChannelMSTeamsConfigDTO,
AlertmanagertypesChannelOpsgenieConfigDTO,
AlertmanagertypesChannelPagerdutyConfigDTO,
AlertmanagertypesChannelSlackConfigDTO,
AlertmanagertypesChannelWebhookConfigDTO,
AlertmanagertypesPostableNotificationChannelDTO,
AlertmanagertypesTestableNotificationChannelDTO,
AlertmanagertypesUpdatableNotificationChannelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { isEmpty, isNil, omitBy, pick } from 'lodash-es';
import { ChannelFormValues, ChannelKind, ChannelSpecFormValues } from './types';
/**
* The fields each kind sends. `satisfies` ties every entry to that kind's
* generated spec, so a key the API does not model fails to compile.
*/
const SPEC_FIELDS = {
[ChannelKind.slack]: [
'apiUrl',
'channel',
'title',
'titleLink',
'text',
'pretext',
'fallback',
'footer',
'color',
'fields',
'actions',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelSlackConfigDTO)[],
[ChannelKind.webhook]: [
'url',
'username',
'password',
'bearerToken',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelWebhookConfigDTO)[],
[ChannelKind.email]: [
'to',
'html',
'headers',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelEmailConfigDTO)[],
[ChannelKind.pagerduty]: [
'routingKey',
'client',
'clientUrl',
'description',
'severity',
'component',
'group',
'class',
'url',
'details',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelPagerdutyConfigDTO)[],
[ChannelKind.opsgenie]: [
'apiKey',
'apiUrl',
'message',
'description',
'source',
'priority',
'details',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelOpsgenieConfigDTO)[],
[ChannelKind.msteams]: [
'webhookUrl',
'title',
'text',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelMSTeamsConfigDTO)[],
[ChannelKind.googlechat]: [
'webhookUrl',
'title',
'text',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelGoogleChatConfigDTO)[],
[ChannelKind.jira]: [
'site',
'project',
'issueType',
'email',
'apiToken',
'summary',
'description',
'priority',
'labels',
'resolveTransition',
'reopenTransition',
'wontFixResolution',
'reopenDuration',
'customFields',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelJiraConfigDTO)[],
[ChannelKind.jsmops]: [
'apiKey',
'message',
'description',
'priority',
'tags',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelJSMOpsConfigDTO)[],
[ChannelKind.incidentio]: [
'url',
'token',
'title',
'description',
'metadata',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelIncidentIOConfigDTO)[],
} satisfies Record<ChannelKind, readonly (keyof ChannelSpecFormValues)[]>;
/**
* The API rejects a key it does not model and applies its own default for an
* absent one, so an untouched field is dropped rather than sent empty. `false`
* and `0` are values a user chose, so only blanks go.
*/
function omitBlank(spec: Record<string, unknown>): Record<string, unknown> {
return omitBy(
spec,
(value) =>
isNil(value) ||
value === '' ||
((Array.isArray(value) || typeof value === 'object') && isEmpty(value)),
);
}
export function toChannelConfig(
kind: ChannelKind,
values: ChannelFormValues,
): AlertmanagertypesChannelConfigDTO {
const spec = omitBlank(pick(values, SPEC_FIELDS[kind]));
// Each variant narrows `kind` to its own single-member enum, which a value
// typed as the shared ChannelKind cannot satisfy. The string values are the
// same, so the union is asserted once here rather than per kind.
return { kind, spec } as unknown as AlertmanagertypesChannelConfigDTO;
}
export function toPostableChannel(
kind: ChannelKind,
values: ChannelFormValues,
): AlertmanagertypesPostableNotificationChannelDTO {
return {
// the API derives the immutable dns1123 name from the display name
generateName: true,
displayName: values.name ?? '',
config: toChannelConfig(kind, values),
};
}
export function toUpdatableChannel(
kind: ChannelKind,
values: ChannelFormValues,
): AlertmanagertypesUpdatableNotificationChannelDTO {
return { config: toChannelConfig(kind, values) };
}
export function toTestableChannel(
kind: ChannelKind,
values: ChannelFormValues,
): AlertmanagertypesTestableNotificationChannelDTO {
return { config: toChannelConfig(kind, values) };
}

View File

@@ -0,0 +1,25 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import { ChannelFormValues, ChannelKind } from './types';
export interface ChannelFormState {
kind: ChannelKind;
values: ChannelFormValues;
}
/**
* The form edits the spec the API returns, so loading a channel lifts its
* display name alongside that spec rather than remapping field by field.
*/
export function toChannelFormState(
channel: AlertmanagertypesGettableNotificationChannelDTO,
): ChannelFormState {
return {
kind: channel.config.kind as string as ChannelKind,
values: {
...channel.config.spec,
// the API keeps `name` immutable and exposes the editable label separately
name: channel.displayName,
},
};
}

View File

@@ -0,0 +1,137 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import { toChannelConfig, toUpdatableChannel } from './channelConfig';
import { toChannelFormState } from './channelFormValues';
import { ChannelKind } from './types';
/**
* Loading a channel and saving it again without touching anything must send the
* API exactly what it returned. A field the form model drops silently loses a
* user's configuration on the next save, because the update replaces the whole
* config.
*/
const SPECS: Record<string, Record<string, unknown>> = {
slack: {
apiUrl: 'https://hooks.slack.com/services/T/B/X',
channel: '#ops',
title: 'title',
titleLink: 'https://signoz.io',
text: 'body',
pretext: 'pre',
fallback: 'fall',
footer: 'foot',
color: 'danger',
fields: [{ title: 'env', value: 'prod', short: true }],
actions: [{ type: 'button', text: 'Runbook', url: 'https://runbook' }],
sendResolved: true,
},
webhook: {
url: 'https://example.com/hook',
username: 'u',
password: 'p',
bearerToken: 't',
sendResolved: true,
},
email: {
to: 'oncall@signoz.io',
html: '<p>a</p>',
headers: { 'X-Team': 'ops' },
sendResolved: true,
},
pagerduty: {
routingKey: 'k',
client: 'c',
clientUrl: 'https://c',
description: 'd',
severity: 'critical',
component: 'comp',
group: 'grp',
class: 'cls',
url: 'https://events.pagerduty.com',
details: { firing: '{{ .Alerts.Firing }}' },
sendResolved: true,
},
opsgenie: {
apiKey: 'k',
apiUrl: 'https://api.opsgenie.com',
message: 'm',
description: 'd',
source: 's',
priority: 'P1',
details: { env: 'prod' },
sendResolved: true,
},
msteams: {
webhookUrl: 'https://teams',
title: 't',
text: 'b',
sendResolved: true,
},
googlechat: {
webhookUrl: 'https://chat.googleapis.com/v1/spaces/A',
title: 't',
text: 'b',
sendResolved: true,
},
jira: {
site: 'https://acme.atlassian.net',
project: 'OPS',
issueType: 'Task',
email: 'a@b.c',
apiToken: 'tok',
summary: 's',
description: 'd',
priority: 'High',
labels: ['one', 'two'],
resolveTransition: 'Done',
reopenTransition: 'Reopen',
wontFixResolution: 'WontFix',
reopenDuration: '72h',
customFields: { customfield_1: 'v' },
sendResolved: true,
},
jsmops: {
apiKey: 'k',
message: 'm',
description: 'd',
priority: 'P2',
tags: 'prod,db',
sendResolved: true,
},
incidentio: {
url: 'https://api.incident.io/v2/alert_events/http/abc',
token: 'tok',
title: 't',
description: 'd',
metadata: { team: 'ops' },
sendResolved: true,
},
};
describe('load then save round trip', () => {
it.each(Object.keys(SPECS))('keeps every %s field', (kind) => {
const channel = {
id: '1',
name: `${kind}-channel`,
displayName: `${kind} channel`,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
config: { kind, spec: SPECS[kind] },
} as unknown as AlertmanagertypesGettableNotificationChannelDTO;
const { kind: loadedKind, values } = toChannelFormState(channel);
const saved = toUpdatableChannel(loadedKind, values);
expect(loadedKind).toBe(kind as ChannelKind);
expect(saved.config).toStrictEqual(channel.config);
});
it('never sends the display name inside the spec', () => {
const config = toChannelConfig(ChannelKind.slack, {
name: 'prod alerts',
apiUrl: 'https://hooks.slack.com/x',
});
expect(config.spec).not.toHaveProperty('name');
});
});

View File

@@ -1,194 +0,0 @@
export interface Channel {
send_resolved?: boolean;
name: string;
filter?: Partial<Array<LabelFilterStatement>>;
}
export interface SlackChannel extends Channel {
api_url?: string;
channel?: string;
title?: string;
text?: string;
}
export interface WebhookChannel extends Channel {
api_url?: string;
// basic auth
username?: string;
password?: string;
}
// PagerChannel configures alert manager to send
// events to pagerduty
export interface PagerChannel extends Channel {
// ref: https://prometheus.io/docs/alerting/latest/configuration/#pagerduty_config
routing_key?: string;
// displays source of the event in pager duty
client?: string;
client_url?: string;
// A description of the incident
description?: string;
// Severity of the incident
severity?: string;
// The part or component of the affected system that is broken
component?: string;
// A cluster or grouping of sources
group?: string;
// The class/type of the event.
class?: string;
details?: string;
detailsArray?: Record<string, string>;
}
// OpsgenieChannel configures alert manager to send
// events to opsgenie
export interface OpsgenieChannel extends Channel {
// ref: https://prometheus.io/docs/alerting/latest/configuration/#opsgenie_config
api_key: string;
message?: string;
// A description of the incident
description?: string;
// A backlink to the sender of the notification.
source?: string;
// A set of arbitrary key/value pairs that provide further detail
// about the alert.
details?: string;
detailsArray?: Record<string, string>;
// Priority level of alert. Possible values are P1, P2, P3, P4, and P5.
priority?: string;
}
export interface EmailChannel extends Channel {
// comma separated list of email addresses to send alerts to
to: string;
// HTML body of the email notification.
html: string;
// Further headers email header key/value pairs.
// [ headers: { <string>: <tmpl_string>, ... } ]
headers: Record<string, string>;
}
export const ValidatePagerChannel = (p: PagerChannel): string => {
if (!p) {
return 'Received unexpected input for this channel, please contact your administrator ';
}
if (!p.name || p.name === '') {
return 'Name is mandatory for creating a channel';
}
if (!p.routing_key || p.routing_key === '') {
return 'Routing Key is mandatory for creating pagerduty channel';
}
// validate details json
try {
JSON.parse(p.details || '{}');
} catch (e) {
return 'failed to parse additional information, please enter a valid json';
}
return '';
};
export enum ChannelType {
Slack = 'slack',
Email = 'email',
Webhook = 'webhook',
Pagerduty = 'pagerduty',
Opsgenie = 'opsgenie',
MsTeams = 'msteams',
GoogleChat = 'googlechat',
Jira = 'jira',
JsmOps = 'jsmops',
IncidentIO = 'incidentio',
}
// LabelFilterStatement will be used for preparing filter conditions / matchers
export interface LabelFilterStatement {
// ref: https://prometheus.io/docs/alerting/latest/configuration/#matcher
// label name
name: string;
// comparators supported by promql are =, !=, =~, or !~. =
comparator: string;
// filter value
value: string;
}
export interface MsTeamsChannel extends Channel {
webhook_url?: string;
title?: string;
text?: string;
}
export interface GoogleChatChannel extends Channel {
// incoming webhook url of the google chat space, must be an
// https url on chat.googleapis.com
webhook_url?: string;
title?: string;
text?: string;
}
// JiraChannel configures the Jira Cloud alert channel. Auth is basic auth
// (Atlassian account email + API token) carried in username / password.
export interface JiraChannel extends Channel {
// Jira Cloud base URL, e.g. https://acme.atlassian.net
site: string;
project: string;
issue_type: string;
// issue title template
summary?: string;
// issue body template, rendered to rich text server-side
description?: string;
// basic auth: username is the Atlassian account email, password is the API token
username: string;
password: string;
priority?: string;
labels?: string[];
resolve_transition?: string;
reopen_transition?: string;
// issues resolved with this resolution are never reopened; a refire
// creates a new issue instead
wont_fix_resolution?: string;
// duration string, e.g. 72h or 3d
reopen_duration?: string;
}
// IncidentIOChannel configures the incident.io alert channel, backed by an
// incident.io HTTP alert source (Alert Events V2 API).
export interface IncidentIOChannel extends Channel {
// per-source alert events URL, e.g.
// https://api.incident.io/v2/alert_events/http/<source_config_id>
url: string;
// the alert source's secret token
token: string;
// alert title template
title?: string;
// alert body template (markdown, rendered natively by incident.io)
description?: string;
// extra metadata pairs merged over the alert's labels (channel wins on clash)
metadata?: Record<string, string>;
}
// JsmOpsChannel configures the Jira Service Management Ops alert channel
// (ex-Opsgenie alert API). Auth is the JSM integration API key.
export interface JsmOpsChannel extends Channel {
api_key: string;
// alert title template
message?: string;
// alert body template (markdown, rendered to HTML server-side)
description?: string;
// priority template, resolves to P1-P5
priority?: string;
// tags, joined to a comma-separated string for the backend
tags?: string[];
}

View File

@@ -1,20 +1,20 @@
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
IncidentIOChannel,
JiraChannel,
JsmOpsChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
WebhookChannel,
} from './config';
AlertmanagertypesChannelEmailConfigDTO,
AlertmanagertypesChannelGoogleChatConfigDTO,
AlertmanagertypesChannelIncidentIOConfigDTO,
AlertmanagertypesChannelJiraConfigDTO,
AlertmanagertypesChannelJSMOpsConfigDTO,
AlertmanagertypesChannelOpsgenieConfigDTO,
AlertmanagertypesChannelPagerdutyConfigDTO,
AlertmanagertypesChannelSlackConfigDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ChannelKind, ChannelSpecFormValues } from './types';
// shared by slack and ms teams, both render the same title / description boxes
export const SlackInitialConfig: Partial<SlackChannel> = {
text: `{{ range .Alerts -}}
export const SlackInitialConfig: Partial<AlertmanagertypesChannelSlackConfigDTO> =
{
text: `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
*Summary:* {{ .Annotations.summary }}
@@ -26,7 +26,7 @@ export const SlackInitialConfig: Partial<SlackChannel> = {
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
{{ end }}
{{ end }}`,
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
{{" "}}(
{{- with .CommonLabels.Remove .GroupLabels.Names }}
@@ -37,27 +37,29 @@ export const SlackInitialConfig: Partial<SlackChannel> = {
{{- end -}}
)
{{- end }}`,
};
};
// mirrors DefaultGoogleChatReceiverConfig in pkg/types/alertmanagertypes/googlechat.go,
// which the backend applies when title / text are left empty
export const GoogleChatInitialConfig: Partial<GoogleChatChannel> = {
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
text: `{{ range .Alerts -}}
export const GoogleChatInitialConfig: Partial<AlertmanagertypesChannelGoogleChatConfigDTO> =
{
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
text: `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}{{ if .Annotations.summary }}
**Summary:** {{ .Annotations.summary }}{{ end }}{{ if .Annotations.description }}
**Description:** {{ .Annotations.description }}{{ end }}
{{ end }}`,
};
};
// mirrors DefaultJiraSummaryTemplate / DefaultJiraDescriptionTemplate in
// pkg/types/alertmanagertypes/jira.go, which the backend applies when the
// summary / description are left empty. The description is markdown here and is
// wrapped in the ADF status panel + deep-links server-side.
export const JiraInitialConfig: Partial<JiraChannel> = {
issue_type: 'Task',
summary: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
export const JiraInitialConfig: Partial<AlertmanagertypesChannelJiraConfigDTO> =
{
issueType: 'Task',
summary: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
{{ if .Annotations.summary }}
**Summary:** {{ .Annotations.summary }}
@@ -65,10 +67,11 @@ export const JiraInitialConfig: Partial<JiraChannel> = {
**Description:** {{ .Annotations.description }}
{{ end }}
{{ end }}`,
};
};
export const PagerInitialConfig: Partial<PagerChannel> = {
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
export const PagerInitialConfig: Partial<AlertmanagertypesChannelPagerdutyConfigDTO> =
{
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
{{" "}}(
{{- with .CommonLabels.Remove .GroupLabels.Names }}
@@ -79,20 +82,21 @@ export const PagerInitialConfig: Partial<PagerChannel> = {
{{- end -}}
)
{{- end }}`,
severity: '{{ (index .Alerts 0).Labels.severity }}',
client: 'SigNoz Alert Manager',
client_url: 'https://enter-signoz-host-n-port-here/alerts',
details: JSON.stringify({
firing: `{{ .Alerts.Firing | toJson }}`,
resolved: `{{ .Alerts.Resolved | toJson }}`,
num_firing: '{{ .Alerts.Firing | len }}',
num_resolved: '{{ .Alerts.Resolved | len }}',
}),
};
severity: '{{ (index .Alerts 0).Labels.severity }}',
client: 'SigNoz Alert Manager',
clientUrl: 'https://enter-signoz-host-n-port-here/alerts',
details: {
firing: `{{ .Alerts.Firing | toJson }}`,
resolved: `{{ .Alerts.Resolved | toJson }}`,
num_firing: '{{ .Alerts.Firing | len }}',
num_resolved: '{{ .Alerts.Resolved | len }}',
},
};
export const OpsgenieInitialConfig: Partial<OpsgenieChannel> = {
message: '{{ .CommonLabels.alertname }}',
description: `{{ if gt (len .Alerts.Firing) 0 -}}
export const OpsgenieInitialConfig: Partial<AlertmanagertypesChannelOpsgenieConfigDTO> =
{
message: '{{ .CommonLabels.alertname }}',
description: `{{ if gt (len .Alerts.Firing) 0 -}}
Alerts Firing:
{{ range .Alerts.Firing }}
- Message: {{ .Annotations.description }}
@@ -114,19 +118,20 @@ export const OpsgenieInitialConfig: Partial<OpsgenieChannel> = {
{{ end }} Source: {{ .GeneratorURL }}
{{ end }}
{{- end }}`,
priority:
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
};
priority:
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
};
// mirrors DefaultJSMOpsMessageTemplate / DefaultJSMOpsDescriptionTemplate in
// pkg/types/alertmanagertypes/jsmops.go, applied by the backend when message /
// description are left empty. send_resolved is seeded on so JSM alerts close on
// resolve (the backend cannot default it, see jsmops.go). priority mirrors the
// Opsgenie template mapping severity to P1-P5.
export const JsmOpsInitialConfig: Partial<JsmOpsChannel> = {
send_resolved: true,
message: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
export const JsmOpsInitialConfig: Partial<AlertmanagertypesChannelJSMOpsConfigDTO> =
{
sendResolved: true,
message: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
{{ if .Annotations.summary }}**Summary:** {{ .Annotations.summary }}
@@ -140,19 +145,20 @@ export const JsmOpsInitialConfig: Partial<JsmOpsChannel> = {
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
{{ end }}{{ end }}`,
priority:
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
tags: ['signoz-alert'],
};
priority:
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
tags: 'signoz-alert',
};
// mirrors DefaultIncidentIOTitleTemplate / DefaultIncidentIODescriptionTemplate
// in pkg/types/alertmanagertypes/incidentio.go, applied by the backend when
// title / description are left empty. send_resolved is seeded on so incident.io
// alerts resolve with the rule (the backend cannot default it).
export const IncidentIOInitialConfig: Partial<IncidentIOChannel> = {
send_resolved: true,
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
export const IncidentIOInitialConfig: Partial<AlertmanagertypesChannelIncidentIOConfigDTO> =
{
sendResolved: true,
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
{{ if .Annotations.summary }}**Summary:** {{ .Annotations.summary }}
@@ -166,11 +172,12 @@ export const IncidentIOInitialConfig: Partial<IncidentIOChannel> = {
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
{{ end }}{{ end }}`,
};
};
export const EmailInitialConfig: Partial<EmailChannel> = {
send_resolved: true,
html: `<!--
export const EmailInitialConfig: Partial<AlertmanagertypesChannelEmailConfigDTO> =
{
sendResolved: true,
html: `<!--
Credits: https://github.com/mailgun/transactional-email-templates
-->
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
@@ -562,33 +569,20 @@ export const EmailInitialConfig: Partial<EmailChannel> = {
</table>
</body>
</html>`,
};
};
// prefilled values of every channel type, keyed by type so the form can apply
// exactly one set of defaults and swap it when the type changes
export const ChannelInitialConfig: Record<
ChannelType,
Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel &
JiraChannel &
JsmOpsChannel &
IncidentIOChannel
>
> = {
[ChannelType.Slack]: SlackInitialConfig,
[ChannelType.MsTeams]: SlackInitialConfig,
[ChannelType.GoogleChat]: GoogleChatInitialConfig,
[ChannelType.Jira]: JiraInitialConfig,
[ChannelType.JsmOps]: JsmOpsInitialConfig,
[ChannelType.IncidentIO]: IncidentIOInitialConfig,
[ChannelType.Pagerduty]: PagerInitialConfig,
[ChannelType.Opsgenie]: OpsgenieInitialConfig,
[ChannelType.Email]: EmailInitialConfig,
[ChannelType.Webhook]: {},
};
export const ChannelInitialConfig: Record<ChannelKind, ChannelSpecFormValues> =
{
[ChannelKind.slack]: SlackInitialConfig,
[ChannelKind.msteams]: SlackInitialConfig,
[ChannelKind.googlechat]: GoogleChatInitialConfig,
[ChannelKind.jira]: JiraInitialConfig,
[ChannelKind.jsmops]: JsmOpsInitialConfig,
[ChannelKind.incidentio]: IncidentIOInitialConfig,
[ChannelKind.pagerduty]: PagerInitialConfig,
[ChannelKind.opsgenie]: OpsgenieInitialConfig,
[ChannelKind.email]: EmailInitialConfig,
[ChannelKind.webhook]: {},
};

View File

@@ -1,818 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Form } from 'antd';
import createEmail from 'api/channels/createEmail';
import createMsTeamsApi from 'api/channels/createMsTeams';
import createOpsgenie from 'api/channels/createOpsgenie';
import createPagerApi from 'api/channels/createPager';
import createSlackApi from 'api/channels/createSlack';
import createWebhookApi from 'api/channels/createWebhook';
import testEmail from 'api/channels/testEmail';
import testMsTeamsApi from 'api/channels/testMsTeams';
import testOpsGenie from 'api/channels/testOpsgenie';
import testPagerApi from 'api/channels/testPager';
import testSlackApi from 'api/channels/testSlack';
import testWebhookApi from 'api/channels/testWebhook';
import logEvent from 'api/common/logEvent';
import {
useCreateChannel,
useTestChannel,
} from 'api/generated/services/channels';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import ROUTES from 'constants/routes';
import FormAlertChannels from 'container/FormAlertChannels';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
IncidentIOChannel,
JiraChannel,
JsmOpsChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
ValidatePagerChannel,
WebhookChannel,
} from './config';
import { ChannelInitialConfig } from './defaults';
import {
isChannelType,
isValidGoogleChatWebhookURL,
isValidIncidentIOURL,
isValidJiraReopenDuration,
isValidJiraSiteURL,
prepareGoogleChatRequest,
prepareIncidentIORequest,
prepareJiraRequest,
prepareJsmOpsRequest,
} from './utils';
import './CreateAlertChannels.styles.scss';
function CreateAlertChannels({
preType = ChannelType.Slack,
}: CreateAlertChannelsProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('channels');
const { showErrorModal } = useErrorModal();
const [formInstance] = Form.useForm();
useEffect(() => {
logEvent('Alert Channel: Create channel page visited', {});
}, []);
const [selectedConfig, setSelectedConfig] = useState<
Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel &
JiraChannel &
JsmOpsChannel &
IncidentIOChannel
>
>(() => ({
send_resolved: true,
...ChannelInitialConfig[preType],
}));
const [savingState, setSavingState] = useState<boolean>(false);
const [testingState, setTestingState] = useState<boolean>(false);
const { notifications } = useNotifications();
const { mutateAsync: createChannel } = useCreateChannel();
const { mutateAsync: testChannel } = useTestChannel();
const [type, setType] = useState<ChannelType>(preType);
const onTypeChangeHandler = useCallback(
(value: string) => {
const nextType = value as ChannelType;
if (nextType === type) {
return;
}
setType(nextType);
// the fields the types share (title, text, description) keep the value of
// the type that was selected before, so the new type's defaults have to be
// written to both the config and the form
const defaults = ChannelInitialConfig[nextType];
setSelectedConfig((selectedConfig) => ({ ...selectedConfig, ...defaults }));
formInstance.setFieldsValue(defaults);
},
[type, formInstance],
);
const prepareSlackRequest = useCallback(
() => ({
api_url: selectedConfig?.api_url || '',
channel: selectedConfig?.channel || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
text: selectedConfig?.text || '',
title: selectedConfig?.title || '',
}),
[selectedConfig],
);
const onSlackHandler = useCallback(async () => {
if (!selectedConfig.api_url) {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
return;
}
setSavingState(true);
try {
await createSlackApi(prepareSlackRequest());
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [selectedConfig, notifications, t, prepareSlackRequest, showErrorModal]);
const prepareWebhookRequest = useCallback(() => {
// initial api request without auth params
let request: WebhookChannel = {
api_url: selectedConfig?.api_url || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
};
if (selectedConfig?.username !== '' || selectedConfig?.password !== '') {
if (selectedConfig?.username !== '') {
// if username is not null then password must be passed
if (selectedConfig?.password !== '') {
request = {
...request,
username: selectedConfig.username,
password: selectedConfig.password,
};
} else {
notifications.error({
message: 'Error',
description: t('username_no_password'),
});
}
} else if (selectedConfig?.password !== '') {
// only password entered, set bearer token
request = {
...request,
username: '',
password: selectedConfig.password,
};
}
}
return request;
}, [notifications, t, selectedConfig]);
const onWebhookHandler = useCallback(async () => {
if (!selectedConfig.api_url) {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
return;
}
setSavingState(true);
try {
const request = prepareWebhookRequest();
await createWebhookApi(request);
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
selectedConfig.api_url,
notifications,
t,
prepareWebhookRequest,
showErrorModal,
]);
const preparePagerRequest = useCallback(() => {
const validationError = ValidatePagerChannel(selectedConfig as PagerChannel);
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return null;
}
return {
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
routing_key: selectedConfig?.routing_key || '',
client: selectedConfig?.client || '',
client_url: selectedConfig?.client_url || '',
description: selectedConfig?.description || '',
severity: selectedConfig?.severity || '',
component: selectedConfig?.component || '',
group: selectedConfig?.group || '',
class: selectedConfig?.class || '',
details: selectedConfig.details || '',
detailsArray: JSON.parse(selectedConfig.details || '{}'),
};
}, [selectedConfig, notifications]);
const onPagerHandler = useCallback(async () => {
setSavingState(true);
const request = preparePagerRequest();
try {
if (request) {
await createPagerApi(request);
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
}
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} catch (error) {
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [preparePagerRequest, t, notifications, showErrorModal]);
const prepareOpsgenieRequest = useCallback(
() => ({
api_key: selectedConfig?.api_key || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
description: selectedConfig?.description || '',
message: selectedConfig?.message || '',
priority: selectedConfig?.priority || '',
}),
[selectedConfig],
);
const onOpsgenieHandler = useCallback(async () => {
if (!selectedConfig.api_key) {
notifications.error({
message: 'Error',
description: t('api_key_required'),
});
return;
}
setSavingState(true);
try {
await createOpsgenie(prepareOpsgenieRequest());
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
selectedConfig.api_key,
notifications,
t,
prepareOpsgenieRequest,
showErrorModal,
]);
const prepareEmailRequest = useCallback(
() => ({
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
to: selectedConfig?.to || '',
html: selectedConfig?.html || '',
headers: selectedConfig?.headers || {},
}),
[selectedConfig],
);
const onEmailHandler = useCallback(async () => {
if (!selectedConfig.to) {
notifications.error({
message: 'Error',
description: t('to_required'),
});
return;
}
setSavingState(true);
try {
const request = prepareEmailRequest();
await createEmail(request);
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [prepareEmailRequest, notifications, t, showErrorModal, selectedConfig.to]);
const prepareMsTeamsRequest = useCallback(
() => ({
webhook_url: selectedConfig?.webhook_url || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
text: selectedConfig?.text || '',
title: selectedConfig?.title || '',
}),
[selectedConfig],
);
const onMsTeamsHandler = useCallback(async () => {
if (!selectedConfig.webhook_url) {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
return;
}
setSavingState(true);
try {
await createMsTeamsApi(prepareMsTeamsRequest());
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
selectedConfig.webhook_url,
notifications,
t,
prepareMsTeamsRequest,
showErrorModal,
]);
const validateGoogleChatConfig = useCallback((): boolean => {
if (!selectedConfig.webhook_url) {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
return false;
}
if (!isValidGoogleChatWebhookURL(selectedConfig.webhook_url)) {
notifications.error({
message: 'Error',
description: t('google_chat_webhook_url_invalid'),
});
return false;
}
return true;
}, [selectedConfig.webhook_url, notifications, t]);
const onGoogleChatHandler = useCallback(async () => {
if (!validateGoogleChatConfig()) {
return { status: 'failed', statusMessage: t('channel_creation_failed') };
}
setSavingState(true);
try {
await createChannel({ data: prepareGoogleChatRequest(selectedConfig) });
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
validateGoogleChatConfig,
createChannel,
selectedConfig,
notifications,
t,
showErrorModal,
]);
const validateJiraConfig = useCallback((): boolean => {
if (
!selectedConfig.site ||
!selectedConfig.username ||
!selectedConfig.password ||
!selectedConfig.project ||
!selectedConfig.issue_type
) {
notifications.error({
message: 'Error',
description: t('jira_required_fields'),
});
return false;
}
if (!isValidJiraSiteURL(selectedConfig.site)) {
notifications.error({
message: 'Error',
description: t('jira_site_invalid'),
});
return false;
}
if (
selectedConfig.reopen_duration &&
!isValidJiraReopenDuration(selectedConfig.reopen_duration)
) {
notifications.error({
message: 'Error',
description: t('jira_reopen_duration_invalid'),
});
return false;
}
return true;
}, [selectedConfig, notifications, t]);
const onJiraHandler = useCallback(async () => {
if (!validateJiraConfig()) {
return { status: 'failed', statusMessage: t('channel_creation_failed') };
}
setSavingState(true);
try {
await createChannel({ data: prepareJiraRequest(selectedConfig) });
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
validateJiraConfig,
createChannel,
selectedConfig,
notifications,
t,
showErrorModal,
]);
const validateJsmOpsConfig = useCallback((): boolean => {
if (!selectedConfig.api_key) {
notifications.error({
message: 'Error',
description: t('api_key_required'),
});
return false;
}
return true;
}, [selectedConfig.api_key, notifications, t]);
const onJsmOpsHandler = useCallback(async () => {
if (!validateJsmOpsConfig()) {
return { status: 'failed', statusMessage: t('channel_creation_failed') };
}
setSavingState(true);
try {
await createChannel({ data: prepareJsmOpsRequest(selectedConfig) });
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
validateJsmOpsConfig,
createChannel,
selectedConfig,
notifications,
t,
showErrorModal,
]);
const validateIncidentIOConfig = useCallback((): boolean => {
if (!selectedConfig.url || !selectedConfig.token) {
notifications.error({
message: 'Error',
description: t('incidentio_required_fields'),
});
return false;
}
if (!isValidIncidentIOURL(selectedConfig.url)) {
notifications.error({
message: 'Error',
description: t('incidentio_url_invalid'),
});
return false;
}
return true;
}, [selectedConfig.url, selectedConfig.token, notifications, t]);
const onIncidentIOHandler = useCallback(async () => {
if (!validateIncidentIOConfig()) {
return { status: 'failed', statusMessage: t('channel_creation_failed') };
}
setSavingState(true);
try {
await createChannel({ data: prepareIncidentIORequest(selectedConfig) });
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
validateIncidentIOConfig,
createChannel,
selectedConfig,
notifications,
t,
showErrorModal,
]);
const onSaveHandler = useCallback(
async (value: ChannelType) => {
if (!selectedConfig.name) {
notifications.error({
message: 'Error',
description: t('channel_name_required'),
});
return;
}
const functionMapper = {
[ChannelType.Slack]: onSlackHandler,
[ChannelType.Webhook]: onWebhookHandler,
[ChannelType.Pagerduty]: onPagerHandler,
[ChannelType.Opsgenie]: onOpsgenieHandler,
[ChannelType.MsTeams]: onMsTeamsHandler,
[ChannelType.Email]: onEmailHandler,
[ChannelType.GoogleChat]: onGoogleChatHandler,
[ChannelType.Jira]: onJiraHandler,
[ChannelType.JsmOps]: onJsmOpsHandler,
[ChannelType.IncidentIO]: onIncidentIOHandler,
};
if (isChannelType(value)) {
const functionToCall = functionMapper[value as keyof typeof functionMapper];
if (functionToCall) {
const result = await functionToCall();
logEvent('Alert Channel: Save channel', {
type: value,
sendResolvedAlert: selectedConfig?.send_resolved,
name: selectedConfig?.name,
new: 'true',
status: result?.status,
statusMessage: result?.statusMessage,
});
} else {
notifications.error({
message: 'Error',
description: t('selected_channel_invalid'),
});
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
onSlackHandler,
onWebhookHandler,
onPagerHandler,
onOpsgenieHandler,
onMsTeamsHandler,
onEmailHandler,
onGoogleChatHandler,
onJiraHandler,
onJsmOpsHandler,
onIncidentIOHandler,
notifications,
t,
],
);
const performChannelTest = useCallback(
async (channelType: ChannelType) => {
setTestingState(true);
try {
let request;
switch (channelType) {
case ChannelType.Webhook:
request = prepareWebhookRequest();
await testWebhookApi(request);
break;
case ChannelType.Slack:
request = prepareSlackRequest();
await testSlackApi(request);
break;
case ChannelType.Pagerduty:
request = preparePagerRequest();
if (request) {
await testPagerApi(request);
}
break;
case ChannelType.MsTeams:
request = prepareMsTeamsRequest();
await testMsTeamsApi(request);
break;
case ChannelType.Opsgenie:
request = prepareOpsgenieRequest();
await testOpsGenie(request);
break;
case ChannelType.Email:
request = prepareEmailRequest();
await testEmail(request);
break;
case ChannelType.GoogleChat:
if (!validateGoogleChatConfig()) {
setTestingState(false);
return;
}
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
break;
case ChannelType.Jira:
if (!validateJiraConfig()) {
setTestingState(false);
return;
}
await testChannel({ data: prepareJiraRequest(selectedConfig) });
break;
case ChannelType.JsmOps:
if (!validateJsmOpsConfig()) {
setTestingState(false);
return;
}
await testChannel({ data: prepareJsmOpsRequest(selectedConfig) });
break;
case ChannelType.IncidentIO:
if (!validateIncidentIOConfig()) {
setTestingState(false);
return;
}
await testChannel({ data: prepareIncidentIORequest(selectedConfig) });
break;
default:
notifications.error({
message: 'Error',
description: t('test_unsupported'),
});
setTestingState(false);
return;
}
notifications.success({
message: 'Success',
description: t('channel_test_done'),
});
logEvent('Alert Channel: Test notification', {
type: channelType,
sendResolvedAlert: selectedConfig?.send_resolved,
name: selectedConfig?.name,
new: 'true',
status: 'Test success',
});
} catch (error) {
showErrorModal(
error instanceof APIError
? error
: toAPIError(error as ErrorType<RenderErrorResponseDTO>),
);
logEvent('Alert Channel: Test notification', {
type: channelType,
sendResolvedAlert: selectedConfig?.send_resolved,
name: selectedConfig?.name,
new: 'true',
status: 'Test failed',
});
}
setTestingState(false);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
prepareWebhookRequest,
t,
preparePagerRequest,
prepareOpsgenieRequest,
prepareSlackRequest,
prepareMsTeamsRequest,
prepareEmailRequest,
validateGoogleChatConfig,
validateJiraConfig,
validateJsmOpsConfig,
validateIncidentIOConfig,
testChannel,
notifications,
],
);
const onTestHandler = useCallback(
async (value: ChannelType) => {
performChannelTest(value);
},
[performChannelTest],
);
return (
<div className="create-alert-channels-container">
<FormAlertChannels
{...{
formInstance,
onTypeChangeHandler,
setSelectedConfig,
type,
onTestHandler,
onSaveHandler,
savingState,
testingState,
title: t('page_title_create'),
initialValue: {
type,
...selectedConfig,
},
}}
/>
</div>
);
}
interface CreateAlertChannelsProps {
preType: ChannelType;
}
export default CreateAlertChannels;

View File

@@ -0,0 +1,43 @@
import {
AlertmanagertypesChannelEmailConfigDTO,
AlertmanagertypesChannelGoogleChatConfigDTO,
AlertmanagertypesChannelIncidentIOConfigDTO,
AlertmanagertypesChannelJiraConfigDTO,
AlertmanagertypesChannelJSMOpsConfigDTO,
AlertmanagertypesChannelKindDTO,
AlertmanagertypesChannelMSTeamsConfigDTO,
AlertmanagertypesChannelOpsgenieConfigDTO,
AlertmanagertypesChannelPagerdutyConfigDTO,
AlertmanagertypesChannelSlackConfigDTO,
AlertmanagertypesChannelWebhookConfigDTO,
} from 'api/generated/services/sigNoz.schemas';
/** The API's own vocabulary for a channel's kind. */
export const ChannelKind = AlertmanagertypesChannelKindDTO;
export type ChannelKind = AlertmanagertypesChannelKindDTO;
/**
* Every kind's spec merged into one object, so switching kind mid-form keeps the
* fields the kinds share (title, text, description). Each field is typed by the
* generated schema, so what is optional here is what the API models as optional.
*/
export type ChannelSpecFormValues = Partial<
AlertmanagertypesChannelSlackConfigDTO &
AlertmanagertypesChannelWebhookConfigDTO &
AlertmanagertypesChannelEmailConfigDTO &
AlertmanagertypesChannelPagerdutyConfigDTO &
AlertmanagertypesChannelOpsgenieConfigDTO &
AlertmanagertypesChannelMSTeamsConfigDTO &
AlertmanagertypesChannelGoogleChatConfigDTO &
AlertmanagertypesChannelJiraConfigDTO &
AlertmanagertypesChannelJSMOpsConfigDTO &
AlertmanagertypesChannelIncidentIOConfigDTO
>;
/**
* The form edits a spec plus the channel's display name, which the API carries
* outside the config.
*/
export type ChannelFormValues = ChannelSpecFormValues & {
name?: string;
};

View File

@@ -1,23 +1,3 @@
import {
AlertmanagertypesIncidentIOReceiverConfigDTO,
AlertmanagertypesJiraReceiverConfigDTO,
AlertmanagertypesJSMOpsReceiverConfigDTO,
AlertmanagertypesPostableChannelDTO,
ConfigSecretURLDTO,
ModelDurationDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
ChannelType,
GoogleChatChannel,
IncidentIOChannel,
JiraChannel,
JsmOpsChannel,
} from './config';
export const isChannelType = (type: string): type is ChannelType =>
Object.values(ChannelType).includes(type as ChannelType);
const GOOGLE_CHAT_WEBHOOK_HOST = 'chat.googleapis.com';
// the backend enforces the same two rules, this is only for a nicer error experience
@@ -32,22 +12,6 @@ export const isValidGoogleChatWebhookURL = (url: string): boolean => {
}
};
// create, update and test all send the same body shape
export const prepareGoogleChatRequest = (
config: Partial<GoogleChatChannel>,
): AlertmanagertypesPostableChannelDTO => ({
name: config.name || '',
googlechat_configs: [
{
// the generated type models go's config.SecretURL as an object, the api takes a string
webhook_url: (config.webhook_url || '') as unknown as ConfigSecretURLDTO,
title: config.title || '',
text: config.text || '',
send_resolved: config.send_resolved || false,
},
],
});
const JIRA_CLOUD_HOST_SUFFIX = '.atlassian.net';
// the backend enforces the same rule, this is only for a nicer error experience
@@ -93,87 +57,6 @@ export const isValidJiraReopenDuration = (value: string): boolean => {
return totalMs >= JIRA_MIN_REOPEN_MS;
};
// create, update and test all send the same body shape. Optional fields are
// omitted when empty so the backend applies its defaults.
export const prepareJiraRequest = (
config: Partial<JiraChannel>,
): AlertmanagertypesPostableChannelDTO => {
const jira: AlertmanagertypesJiraReceiverConfigDTO = {
site: config.site || '',
project: config.project || '',
issue_type: config.issue_type || '',
send_resolved: config.send_resolved || false,
http_config: {
basic_auth: {
username: config.username || '',
password: config.password || '',
},
},
};
if (config.summary) {
jira.summary = config.summary;
}
if (config.description) {
jira.description = config.description;
}
if (config.priority) {
jira.priority = config.priority;
}
if (config.labels?.length) {
jira.labels = config.labels;
}
if (config.resolve_transition) {
jira.resolve_transition = config.resolve_transition;
}
if (config.reopen_transition) {
jira.reopen_transition = config.reopen_transition;
}
if (config.wont_fix_resolution) {
jira.wont_fix_resolution = config.wont_fix_resolution;
}
if (config.reopen_duration) {
// the generated type models go's model.Duration as a number, the api takes a
// duration string like "72h"
jira.reopen_duration = config.reopen_duration as unknown as ModelDurationDTO;
}
return {
name: config.name || '',
jira_configs: [jira],
};
};
// create, update and test all send the same body shape. Optional fields are
// omitted when empty so the backend applies its defaults.
export const prepareJsmOpsRequest = (
config: Partial<JsmOpsChannel>,
): AlertmanagertypesPostableChannelDTO => {
const jsmops: AlertmanagertypesJSMOpsReceiverConfigDTO = {
api_key: config.api_key || '',
send_resolved: config.send_resolved || false,
};
if (config.message) {
jsmops.message = config.message;
}
if (config.description) {
jsmops.description = config.description;
}
if (config.priority) {
jsmops.priority = config.priority;
}
if (config.tags?.length) {
// the backend takes a comma-separated string and splits it back
jsmops.tags = config.tags.join(',');
}
return {
name: config.name || '',
jsmops_configs: [jsmops],
};
};
const INCIDENTIO_EVENTS_PATH_PREFIX = '/v2/alert_events/http/';
// the backend enforces the same rule, this is only for a nicer error experience
@@ -190,33 +73,3 @@ export const isValidIncidentIOURL = (url: string): boolean => {
return false;
}
};
// create, update and test all send the same body shape. Optional fields are
// omitted when empty so the backend applies its defaults.
export const prepareIncidentIORequest = (
config: Partial<IncidentIOChannel>,
): AlertmanagertypesPostableChannelDTO => {
const incidentio: AlertmanagertypesIncidentIOReceiverConfigDTO = {
url: config.url || '',
token: config.token || '',
send_resolved: config.send_resolved || false,
};
if (config.title) {
incidentio.title = config.title;
}
if (config.description) {
incidentio.description = config.description;
}
const metadata = Object.fromEntries(
Object.entries(config.metadata || {}).filter(([key]) => key.trim() !== ''),
);
if (Object.keys(metadata).length > 0) {
incidentio.metadata = metadata;
}
return {
name: config.name || '',
incidentio_configs: [incidentio],
};
};

View File

@@ -0,0 +1,104 @@
import { TFunction } from 'i18next';
import { ChannelFormValues, ChannelKind } from './types';
import {
isValidGoogleChatWebhookURL,
isValidIncidentIOURL,
isValidJiraReopenDuration,
isValidJiraSiteURL,
} from './utils';
type Validator = (values: ChannelFormValues, t: TFunction) => string | null;
const requireWebhookUrl: Validator = (values, t) =>
values.webhookUrl ? null : t('webhook_url_required');
const requireApiKey: Validator = (values, t) =>
values.apiKey ? null : t('api_key_required');
const validateSlack: Validator = (values, t) =>
values.apiUrl ? null : t('webhook_url_required');
const validateWebhook: Validator = (values, t) => {
if (!values.url) {
return t('webhook_url_required');
}
// the API allows bearer-only and no-auth webhooks, but a username without its
// password is still an incomplete basic auth pair
return values.username && !values.password ? t('username_no_password') : null;
};
const validatePagerduty: Validator = (values, t) =>
values.routingKey ? null : t('routing_key_required');
const validateEmail: Validator = (values, t) =>
values.to ? null : t('to_required');
const validateGoogleChat: Validator = (values, t) => {
if (!values.webhookUrl) {
return t('webhook_url_required');
}
return isValidGoogleChatWebhookURL(values.webhookUrl)
? null
: t('google_chat_webhook_url_invalid');
};
const validateJira: Validator = (values, t) => {
if (
!values.site ||
!values.email ||
!values.apiToken ||
!values.project ||
!values.issueType
) {
return t('jira_required_fields');
}
if (!isValidJiraSiteURL(values.site)) {
return t('jira_site_invalid');
}
if (
values.reopenDuration &&
!isValidJiraReopenDuration(values.reopenDuration)
) {
return t('jira_reopen_duration_invalid');
}
return null;
};
const validateIncidentIO: Validator = (values, t) => {
if (!values.url || !values.token) {
return t('incidentio_required_fields');
}
return isValidIncidentIOURL(values.url) ? null : t('incidentio_url_invalid');
};
const VALIDATORS: Record<ChannelKind, Validator> = {
[ChannelKind.slack]: validateSlack,
[ChannelKind.webhook]: validateWebhook,
[ChannelKind.pagerduty]: validatePagerduty,
[ChannelKind.opsgenie]: requireApiKey,
[ChannelKind.jsmops]: requireApiKey,
[ChannelKind.email]: validateEmail,
[ChannelKind.msteams]: requireWebhookUrl,
[ChannelKind.googlechat]: validateGoogleChat,
[ChannelKind.jira]: validateJira,
[ChannelKind.incidentio]: validateIncidentIO,
};
/**
* Client-side validation for the fields the API rejects outright, so a save
* round trip is not spent on an obviously incomplete form. Returns the message
* to show, or null when the form can be submitted.
*/
export function validateChannel(
kind: ChannelKind,
values: ChannelFormValues,
t: TFunction,
): string | null {
if (!values.name) {
return t('channel_name_required');
}
const validate = VALIDATORS[kind];
return validate ? validate(values, t) : t('selected_channel_invalid');
}

View File

@@ -1,12 +1,8 @@
import { useQuery } from 'react-query';
import { Button, Tooltip } from 'antd';
import getAllChannels from 'api/channels/getAll';
import classNames from 'classnames';
import { ChartLine } from '@signozhq/icons';
import { SuccessResponseV2 } from 'types/api';
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { useCreateAlertState } from '../context';
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
@@ -25,10 +21,8 @@ function AlertCondition(): JSX.Element {
isLoading: isLoadingChannels,
isError: isErrorChannels,
refetch: refreshChannels,
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const channels = data?.data || [];
} = useChannelOptions();
const channels = data || [];
const showMultipleTabs =
alertType === AlertTypes.ANOMALY_BASED_ALERT ||

View File

@@ -2,12 +2,12 @@ import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Channels } from 'types/api/channels/getAll';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import { CreateAlertProvider } from '../../context';
import AlertThreshold from '../AlertThreshold';
const mockChannels: Channels[] = [];
const mockChannels: ChannelOption[] = [];
const mockRefreshChannels = jest.fn();
const mockIsLoadingChannels = false;
const mockIsErrorChannels = false;
@@ -85,16 +85,17 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
}));
// Mock getAllChannels API
jest.mock('api/channels/getAll', () => ({
jest.mock('hooks/notificationChannels/useChannelOptions', () => ({
__esModule: true,
default: jest.fn(() =>
Promise.resolve({
data: [
{ id: '1', name: 'Email Channel' },
{ id: '2', name: 'Slack Channel' },
] as Channels[],
}),
),
useChannelOptions: jest.fn(() => ({
data: [
{ id: '1', name: 'Email Channel' },
{ id: '2', name: 'Slack Channel' },
] as ChannelOption[],
isLoading: false,
isError: false,
refetch: jest.fn(),
})),
}));
// Mock alert format categories

View File

@@ -3,7 +3,7 @@ import type { DefaultOptionType } from 'antd/es/select';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
import { Channels } from 'types/api/channels/getAll';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import * as context from '../../context';
import ThresholdItem from '../ThresholdItem';
@@ -57,7 +57,7 @@ const mockThreshold = {
color: '#ff0000',
};
const mockChannels: Channels[] = [
const mockChannels: ChannelOption[] = [
{
id: TEST_CONSTANTS.CHANNEL_1,
name: TEST_CONSTANTS.EMAIL_CHANNEL_NAME,

View File

@@ -1,5 +1,5 @@
import type { DefaultOptionType } from 'antd/es/select';
import { Channels } from 'types/api/channels/getAll';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import {
NotificationSettingsAction,
@@ -21,7 +21,7 @@ export interface ThresholdItemProps {
updateThreshold: UpdateThreshold;
removeThreshold: (thresholdId: string) => void;
showRemoveButton: boolean;
channels: Channels[];
channels: ChannelOption[];
isLoadingChannels: boolean;
units: DefaultOptionType[];
isErrorChannels: boolean;
@@ -29,7 +29,7 @@ export interface ThresholdItemProps {
}
export interface AnomalyAndThresholdProps {
channels: Channels[];
channels: ChannelOption[];
isLoadingChannels: boolean;
isErrorChannels: boolean;
refreshChannels: () => void;

View File

@@ -1,863 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Form } from 'antd';
import editEmail from 'api/channels/editEmail';
import editMsTeamsApi from 'api/channels/editMsTeams';
import editOpsgenie from 'api/channels/editOpsgenie';
import editPagerApi from 'api/channels/editPager';
import editSlackApi from 'api/channels/editSlack';
import editWebhookApi from 'api/channels/editWebhook';
import testEmail from 'api/channels/testEmail';
import testMsTeamsApi from 'api/channels/testMsTeams';
import testOpsgenie from 'api/channels/testOpsgenie';
import testPagerApi from 'api/channels/testPager';
import testSlackApi from 'api/channels/testSlack';
import testWebhookApi from 'api/channels/testWebhook';
import logEvent from 'api/common/logEvent';
import {
useTestChannel,
useUpdateChannelByID,
} from 'api/generated/services/channels';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import ROUTES from 'constants/routes';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
IncidentIOChannel,
JiraChannel,
JsmOpsChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
ValidatePagerChannel,
WebhookChannel,
} from 'container/CreateAlertChannels/config';
import {
isValidGoogleChatWebhookURL,
isValidIncidentIOURL,
isValidJiraReopenDuration,
isValidJiraSiteURL,
prepareGoogleChatRequest,
prepareIncidentIORequest,
prepareJiraRequest,
prepareJsmOpsRequest,
} from 'container/CreateAlertChannels/utils';
import FormAlertChannels from 'container/FormAlertChannels';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
function EditAlertChannels({
initialValue,
channelId: id,
}: EditAlertChannelsProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('channels');
const [formInstance] = Form.useForm();
const [selectedConfig, setSelectedConfig] = useState<
Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel &
JiraChannel &
JsmOpsChannel &
IncidentIOChannel
>
>({
...initialValue,
});
const [savingState, setSavingState] = useState<boolean>(false);
const [testingState, setTestingState] = useState<boolean>(false);
const { notifications } = useNotifications();
const { mutateAsync: updateChannel } = useUpdateChannelByID();
const { mutateAsync: testChannel } = useTestChannel();
const notifyError = useCallback(
(error: unknown): APIError => {
const apiError =
error instanceof APIError
? error
: toAPIError(error as ErrorType<RenderErrorResponseDTO>);
notifications.error({
message: apiError.getErrorCode(),
description: apiError.getErrorMessage(),
});
return apiError;
},
[notifications],
);
const [type, setType] = useState<ChannelType>(
initialValue?.type ? (initialValue.type as ChannelType) : ChannelType.Slack,
);
const onTypeChangeHandler = useCallback((value: string) => {
setType(value as ChannelType);
}, []);
useEffect(() => {
formInstance.setFieldsValue({
...initialValue,
});
}, [formInstance, initialValue]);
const prepareSlackRequest = useCallback(
() => ({
api_url: selectedConfig?.api_url || '',
channel: selectedConfig?.channel || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
text: selectedConfig?.text || '',
title: selectedConfig?.title || '',
id,
}),
[id, selectedConfig],
);
const onSlackEditHandler = useCallback(async () => {
setSavingState(true);
if (selectedConfig?.api_url === '') {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
setSavingState(false);
return { status: 'failed', statusMessage: t('webhook_url_required') };
}
try {
await editSlackApi(prepareSlackRequest());
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return {
status: 'failed',
statusMessage:
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [prepareSlackRequest, t, notifications, selectedConfig]);
const prepareWebhookRequest = useCallback(() => {
const { name, username, password } = selectedConfig;
return {
api_url: selectedConfig?.api_url || '',
name: name || '',
send_resolved: selectedConfig?.send_resolved || false,
username,
password,
id,
};
}, [id, selectedConfig]);
const onWebhookEditHandler = useCallback(async () => {
setSavingState(true);
const { username, password } = selectedConfig;
const showError = (msg: string): void => {
notifications.error({
message: 'Error',
description: msg,
});
};
if (selectedConfig?.api_url === '') {
showError(t('webhook_url_required'));
setSavingState(false);
return { status: 'failed', statusMessage: t('webhook_url_required') };
}
if (username && (!password || password === '')) {
showError(t('username_no_password'));
setSavingState(false);
return { status: 'failed', statusMessage: t('username_no_password') };
}
try {
await editWebhookApi(prepareWebhookRequest());
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return {
status: 'failed',
statusMessage:
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [prepareWebhookRequest, t, notifications, selectedConfig]);
const prepareEmailRequest = useCallback(
() => ({
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
to: selectedConfig.to || '',
html: selectedConfig.html || '',
headers: selectedConfig.headers || {},
id,
}),
[id, selectedConfig],
);
const onEmailEditHandler = useCallback(async () => {
setSavingState(true);
const request = prepareEmailRequest();
try {
await editEmail(request);
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return {
status: 'failed',
statusMessage:
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [prepareEmailRequest, t, notifications]);
const preparePagerRequest = useCallback(
() => ({
name: selectedConfig.name || '',
send_resolved: selectedConfig?.send_resolved || false,
routing_key: selectedConfig.routing_key,
client: selectedConfig.client,
client_url: selectedConfig.client_url,
description: selectedConfig.description,
severity: selectedConfig.severity,
component: selectedConfig.component,
class: selectedConfig.class,
group: selectedConfig.group,
details: selectedConfig.details,
detailsArray: JSON.parse(selectedConfig.details || '{}'),
id,
}),
[id, selectedConfig],
);
const onPagerEditHandler = useCallback(async () => {
setSavingState(true);
const validationError = ValidatePagerChannel(selectedConfig as PagerChannel);
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setSavingState(false);
return { status: 'failed', statusMessage: validationError };
}
try {
await editPagerApi(preparePagerRequest());
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return {
status: 'failed',
statusMessage:
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [preparePagerRequest, notifications, selectedConfig, t]);
const prepareOpsgenieRequest = useCallback(
() => ({
name: selectedConfig.name || '',
send_resolved: selectedConfig?.send_resolved || false,
api_key: selectedConfig.api_key || '',
message: selectedConfig.message || '',
description: selectedConfig.description || '',
priority: selectedConfig.priority || '',
id,
}),
[id, selectedConfig],
);
const onOpsgenieEditHandler = useCallback(async () => {
setSavingState(true);
if (selectedConfig?.api_key === '') {
notifications.error({
message: 'Error',
description: t('api_key_required'),
});
setSavingState(false);
return { status: 'failed', statusMessage: t('api_key_required') };
}
try {
await editOpsgenie(prepareOpsgenieRequest());
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return {
status: 'failed',
statusMessage:
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [prepareOpsgenieRequest, t, notifications, selectedConfig]);
const prepareMsTeamsRequest = useCallback(
() => ({
webhook_url: selectedConfig?.webhook_url || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
text: selectedConfig?.text || '',
title: selectedConfig?.title || '',
id,
}),
[id, selectedConfig],
);
const onMsTeamsEditHandler = useCallback(async () => {
setSavingState(true);
if (selectedConfig?.webhook_url === '') {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
setSavingState(false);
return { status: 'failed', statusMessage: t('webhook_url_required') };
}
try {
await editMsTeamsApi(prepareMsTeamsRequest());
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return {
status: 'failed',
statusMessage:
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [prepareMsTeamsRequest, t, notifications, selectedConfig]);
const validateGoogleChatConfig = useCallback((): string => {
if (!selectedConfig?.webhook_url) {
return t('webhook_url_required');
}
if (!isValidGoogleChatWebhookURL(selectedConfig.webhook_url)) {
return t('google_chat_webhook_url_invalid');
}
return '';
}, [selectedConfig, t]);
const onGoogleChatEditHandler = useCallback(async () => {
const validationError = validateGoogleChatConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return { status: 'failed', statusMessage: validationError };
}
setSavingState(true);
try {
await updateChannel({
pathParams: { id },
data: prepareGoogleChatRequest(selectedConfig),
});
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
const apiError = notifyError(error);
return {
status: 'failed',
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [
validateGoogleChatConfig,
updateChannel,
id,
selectedConfig,
notifications,
notifyError,
t,
]);
const validateJiraConfig = useCallback((): string => {
if (
!selectedConfig.site ||
!selectedConfig.username ||
!selectedConfig.password ||
!selectedConfig.project ||
!selectedConfig.issue_type
) {
return t('jira_required_fields');
}
if (!isValidJiraSiteURL(selectedConfig.site)) {
return t('jira_site_invalid');
}
if (
selectedConfig.reopen_duration &&
!isValidJiraReopenDuration(selectedConfig.reopen_duration)
) {
return t('jira_reopen_duration_invalid');
}
return '';
}, [selectedConfig, t]);
const onJiraEditHandler = useCallback(async () => {
const validationError = validateJiraConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return { status: 'failed', statusMessage: validationError };
}
setSavingState(true);
try {
await updateChannel({
pathParams: { id },
data: prepareJiraRequest(selectedConfig),
});
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
const apiError = notifyError(error);
return {
status: 'failed',
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [
validateJiraConfig,
updateChannel,
id,
selectedConfig,
notifications,
notifyError,
t,
]);
const validateJsmOpsConfig = useCallback((): string => {
if (!selectedConfig.api_key) {
return t('api_key_required');
}
return '';
}, [selectedConfig, t]);
const onJsmOpsEditHandler = useCallback(async () => {
const validationError = validateJsmOpsConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return { status: 'failed', statusMessage: validationError };
}
setSavingState(true);
try {
await updateChannel({
pathParams: { id },
data: prepareJsmOpsRequest(selectedConfig),
});
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
const apiError = notifyError(error);
return {
status: 'failed',
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [
validateJsmOpsConfig,
updateChannel,
id,
selectedConfig,
notifications,
notifyError,
t,
]);
const validateIncidentIOConfig = useCallback((): string => {
if (!selectedConfig.url || !selectedConfig.token) {
return t('incidentio_required_fields');
}
if (!isValidIncidentIOURL(selectedConfig.url)) {
return t('incidentio_url_invalid');
}
return '';
}, [selectedConfig, t]);
const onIncidentIOEditHandler = useCallback(async () => {
const validationError = validateIncidentIOConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return { status: 'failed', statusMessage: validationError };
}
setSavingState(true);
try {
await updateChannel({
pathParams: { id },
data: prepareIncidentIORequest(selectedConfig),
});
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
const apiError = notifyError(error);
return {
status: 'failed',
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [
validateIncidentIOConfig,
updateChannel,
id,
selectedConfig,
notifications,
notifyError,
t,
]);
const onSaveHandler = useCallback(
async (value: ChannelType) => {
let result;
if (value === ChannelType.Slack) {
result = await onSlackEditHandler();
} else if (value === ChannelType.Webhook) {
result = await onWebhookEditHandler();
} else if (value === ChannelType.Pagerduty) {
result = await onPagerEditHandler();
} else if (value === ChannelType.MsTeams) {
result = await onMsTeamsEditHandler();
} else if (value === ChannelType.Opsgenie) {
result = await onOpsgenieEditHandler();
} else if (value === ChannelType.Email) {
result = await onEmailEditHandler();
} else if (value === ChannelType.GoogleChat) {
result = await onGoogleChatEditHandler();
} else if (value === ChannelType.Jira) {
result = await onJiraEditHandler();
} else if (value === ChannelType.JsmOps) {
result = await onJsmOpsEditHandler();
} else if (value === ChannelType.IncidentIO) {
result = await onIncidentIOEditHandler();
}
logEvent('Alert Channel: Save channel', {
type: value,
sendResolvedAlert: selectedConfig?.send_resolved,
name: selectedConfig?.name,
new: 'false',
status: result?.status,
statusMessage: result?.statusMessage,
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
onSlackEditHandler,
onWebhookEditHandler,
onPagerEditHandler,
onMsTeamsEditHandler,
onOpsgenieEditHandler,
onEmailEditHandler,
onGoogleChatEditHandler,
onJiraEditHandler,
onJsmOpsEditHandler,
onIncidentIOEditHandler,
],
);
const performChannelTest = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
async (channelType: ChannelType) => {
setTestingState(true);
try {
let request;
switch (channelType) {
case ChannelType.Webhook:
request = prepareWebhookRequest();
await testWebhookApi(request);
break;
case ChannelType.Slack:
request = prepareSlackRequest();
await testSlackApi(request);
break;
case ChannelType.Pagerduty:
request = preparePagerRequest();
if (request) {
await testPagerApi(request);
}
break;
case ChannelType.MsTeams:
request = prepareMsTeamsRequest();
if (request) {
await testMsTeamsApi(request);
}
break;
case ChannelType.Opsgenie:
request = prepareOpsgenieRequest();
if (request) {
await testOpsgenie(request);
}
break;
case ChannelType.Email:
request = prepareEmailRequest();
if (request) {
await testEmail(request);
}
break;
case ChannelType.GoogleChat: {
const validationError = validateGoogleChatConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setTestingState(false);
return;
}
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
break;
}
case ChannelType.Jira: {
const validationError = validateJiraConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setTestingState(false);
return;
}
await testChannel({ data: prepareJiraRequest(selectedConfig) });
break;
}
case ChannelType.JsmOps: {
const validationError = validateJsmOpsConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setTestingState(false);
return;
}
await testChannel({ data: prepareJsmOpsRequest(selectedConfig) });
break;
}
case ChannelType.IncidentIO: {
const validationError = validateIncidentIOConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setTestingState(false);
return;
}
await testChannel({ data: prepareIncidentIORequest(selectedConfig) });
break;
}
default:
notifications.error({
message: 'Error',
description: t('test_unsupported'),
});
setTestingState(false);
return;
}
notifications.success({
message: 'Success',
description: t('channel_test_done'),
});
logEvent('Alert Channel: Test notification', {
type: channelType,
sendResolvedAlert: selectedConfig?.send_resolved,
name: selectedConfig?.name,
new: 'false',
status: 'Test success',
});
} catch (error) {
notifyError(error);
logEvent('Alert Channel: Test notification', {
type: channelType,
sendResolvedAlert: selectedConfig?.send_resolved,
name: selectedConfig?.name,
new: 'false',
status: 'Test failed',
});
}
setTestingState(false);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
t,
notifyError,
validateGoogleChatConfig,
validateJiraConfig,
validateJsmOpsConfig,
validateIncidentIOConfig,
testChannel,
prepareWebhookRequest,
preparePagerRequest,
prepareSlackRequest,
prepareMsTeamsRequest,
prepareOpsgenieRequest,
prepareEmailRequest,
notifications,
],
);
const onTestHandler = useCallback(
async (value: ChannelType) => {
performChannelTest(value);
},
[performChannelTest],
);
return (
<FormAlertChannels
{...{
formInstance,
onTypeChangeHandler,
setSelectedConfig,
type,
onTestHandler,
onSaveHandler,
testingState,
savingState,
title: t('page_title_edit'),
initialValue,
editing: true,
}}
/>
);
}
interface EditAlertChannelsProps {
initialValue: {
[x: string]: unknown;
};
channelId: string;
}
export default EditAlertChannels;

View File

@@ -2,7 +2,7 @@ import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@signozhq/ui/input';
import { Form } from 'antd';
import { EmailChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
function EmailForm({ setSelectedConfig }: EmailFormProps): JSX.Element {
const { t } = useTranslation('channels');
@@ -43,7 +43,7 @@ function EmailForm({ setSelectedConfig }: EmailFormProps): JSX.Element {
}
interface EmailFormProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<EmailChannel>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
}
export default EmailForm;

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { GoogleChatChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import { isValidGoogleChatWebhookURL } from '../../CreateAlertChannels/utils';
function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
@@ -12,7 +12,7 @@ function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
return (
<>
<Form.Item
name="webhook_url"
name="webhookUrl"
label={t('field_webhook_url')}
required
rules={[
@@ -38,7 +38,7 @@ function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
webhook_url: event.target.value,
webhookUrl: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
@@ -76,7 +76,7 @@ function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
}
interface GoogleChatProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<GoogleChatChannel>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
}
export default GoogleChat;

View File

@@ -4,7 +4,7 @@ import { Minus, Plus } from '@signozhq/icons';
import { Button, Form, Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { IncidentIOChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
interface MetadataRow {
key: string;
@@ -23,7 +23,7 @@ function IncidentIOSettings({
})),
);
const update = (patch: Partial<IncidentIOChannel>): void =>
const update = (patch: Partial<ChannelSpecFormValues>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const syncMetadata = (rows: MetadataRow[]): void => {
@@ -161,7 +161,7 @@ function IncidentIOSettings({
}
interface IncidentIOProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<IncidentIOChannel>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
initialMetadata?: Record<string, string>;
}

View File

@@ -4,7 +4,7 @@ import { Collapse, Form, Input, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { JiraChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import {
isValidJiraReopenDuration,
isValidJiraSiteURL,
@@ -13,7 +13,7 @@ import {
function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
const { t } = useTranslation('channels');
const update = (patch: Partial<JiraChannel>): void =>
const update = (patch: Partial<ChannelSpecFormValues>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const advanced = (
@@ -45,49 +45,49 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
</Form.Item>
<Form.Item
name="resolve_transition"
name="resolveTransition"
label={t('field_jira_resolve_transition')}
help={t('help_jira_resolve_transition')}
>
<Input
placeholder={t('placeholder_jira_resolve_transition')}
onChange={(event): void =>
update({ resolve_transition: event.target.value })
update({ resolveTransition: event.target.value })
}
data-testid="jira-resolve-transition-textbox"
/>
</Form.Item>
<Form.Item
name="reopen_transition"
name="reopenTransition"
label={t('field_jira_reopen_transition')}
help={t('help_jira_reopen_transition')}
>
<Input
placeholder={t('placeholder_jira_reopen_transition')}
onChange={(event): void =>
update({ reopen_transition: event.target.value })
update({ reopenTransition: event.target.value })
}
data-testid="jira-reopen-transition-textbox"
/>
</Form.Item>
<Form.Item
name="wont_fix_resolution"
name="wontFixResolution"
label={t('field_jira_wont_fix_resolution')}
help={t('help_jira_wont_fix_resolution')}
>
<Input
placeholder={t('placeholder_jira_wont_fix_resolution')}
onChange={(event): void =>
update({ wont_fix_resolution: event.target.value })
update({ wontFixResolution: event.target.value })
}
data-testid="jira-wont-fix-resolution-textbox"
/>
</Form.Item>
<Form.Item
name="reopen_duration"
name="reopenDuration"
label={t('field_jira_reopen_duration')}
extra={t('help_jira_reopen_duration')}
rules={[
@@ -111,7 +111,7 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
>
<Input
placeholder={t('placeholder_jira_reopen_duration')}
onChange={(event): void => update({ reopen_duration: event.target.value })}
onChange={(event): void => update({ reopenDuration: event.target.value })}
data-testid="jira-reopen-duration-textbox"
/>
</Form.Item>
@@ -167,26 +167,26 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
</Form.Item>
<Form.Item
name="username"
name="email"
label={t('field_jira_email')}
help={t('help_jira_email')}
required
>
<Input
onChange={(event): void => update({ username: event.target.value })}
onChange={(event): void => update({ email: event.target.value })}
data-testid="jira-email-textbox"
/>
</Form.Item>
<Form.Item
name="password"
name="apiToken"
label={t('field_jira_api_token')}
help={t('help_jira_api_token')}
required
>
<Input
type="password"
onChange={(event): void => update({ password: event.target.value })}
onChange={(event): void => update({ apiToken: event.target.value })}
data-testid="jira-api-token-textbox"
/>
</Form.Item>
@@ -200,13 +200,13 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
</Form.Item>
<Form.Item
name="issue_type"
name="issueType"
label={t('field_jira_issue_type')}
help={t('help_jira_issue_type')}
required
>
<Input
onChange={(event): void => update({ issue_type: event.target.value })}
onChange={(event): void => update({ issueType: event.target.value })}
data-testid="jira-issue-type-textbox"
/>
</Form.Item>
@@ -250,7 +250,7 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
}
interface JiraProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<JiraChannel>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
}
export default JiraSettings;

View File

@@ -3,12 +3,12 @@ import { useTranslation } from 'react-i18next';
import { Collapse, Form, Input, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { JsmOpsChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
const { t } = useTranslation('channels');
const update = (patch: Partial<JsmOpsChannel>): void =>
const update = (patch: Partial<ChannelSpecFormValues>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const advanced = (
@@ -29,12 +29,17 @@ function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
name="tags"
label={t('field_jsmops_tags')}
help={t('help_jsmops_tags')}
// the API takes one comma-separated string, the control edits chips
getValueProps={(value: string | undefined): { value: string[] } => ({
value: value ? value.split(',') : [],
})}
normalize={(value: string[]): string => value.join(',')}
>
<Select
mode="tags"
open={false}
placeholder={t('placeholder_jsmops_tags')}
onChange={(value): void => update({ tags: value as string[] })}
onChange={(value): void => update({ tags: (value as string[]).join(',') })}
data-testid="jsmops-tags-select"
/>
</Form.Item>
@@ -60,14 +65,14 @@ function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
</Typography.Text>
<Form.Item
name="api_key"
name="apiKey"
label={t('field_jsmops_api_key')}
help={t('help_jsmops_api_key')}
required
>
<Input
type="password"
onChange={(event): void => update({ api_key: event.target.value })}
onChange={(event): void => update({ apiKey: event.target.value })}
data-testid="jsmops-api-key-textbox"
/>
</Form.Item>
@@ -111,7 +116,7 @@ function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
}
interface JsmOpsProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<JsmOpsChannel>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
}
export default JsmOpsSettings;

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { MsTeamsChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
const { t } = useTranslation('channels');
@@ -11,7 +11,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
return (
<>
<Form.Item
name="webhook_url"
name="webhookUrl"
label={t('field_webhook_url')}
tooltip={{
title: (
@@ -28,7 +28,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
webhook_url: event.target.value,
webhookUrl: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
@@ -67,7 +67,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
interface MsTeamsProps {
setSelectedConfig: React.Dispatch<
React.SetStateAction<Partial<MsTeamsChannel>>
React.SetStateAction<Partial<ChannelSpecFormValues>>
>;
}

View File

@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { OpsgenieChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
const { TextArea } = Input;
@@ -21,7 +21,7 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
return (
<>
<Form.Item
name="api_key"
name="apiKey"
label={t('field_opsgenie_api_key')}
tooltip={{
title: (
@@ -36,7 +36,7 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
required
>
<Input
onChange={handleInputChange('api_key')}
onChange={handleInputChange('apiKey')}
data-testid="opsgenie-api-key-textbox"
/>
</Form.Item>
@@ -88,7 +88,7 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
interface OpsgenieFormProps {
setSelectedConfig: React.Dispatch<
React.SetStateAction<Partial<OpsgenieChannel>>
React.SetStateAction<Partial<ChannelSpecFormValues>>
>;
}

View File

@@ -3,16 +3,20 @@ import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { PagerChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import PagerDetails from './PagerDetails';
const { TextArea } = Input;
function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
function PagerForm({
setSelectedConfig,
initialDetails,
}: PagerFormProps): JSX.Element {
const { t } = useTranslation('channels');
return (
<>
<Form.Item
name="routing_key"
name="routingKey"
label={t('field_pager_routing_key')}
tooltip={{
title: (
@@ -29,7 +33,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
routing_key: event.target.value,
routingKey: event.target.value,
}));
}}
data-testid="pager-routing-key-textbox"
@@ -71,22 +75,10 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
/>
</Form.Item>
<Form.Item
name="details"
help={t('help_pager_details')}
label={t('field_pager_details')}
>
<TextArea
rows={4}
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
details: event.target.value,
}))
}
data-testid="pager-additional-details-textarea"
/>
</Form.Item>
<PagerDetails
setSelectedConfig={setSelectedConfig}
initialDetails={initialDetails}
/>
<Form.Item
name="component"
@@ -151,7 +143,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
</Form.Item>
<Form.Item
name="client_url"
name="clientUrl"
help={t('help_pager_client_url')}
label={t('field_pager_client_url')}
>
@@ -159,7 +151,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
client_url: event.target.value,
clientUrl: event.target.value,
}))
}
data-testid="pager-client-url-textarea"
@@ -170,7 +162,8 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
}
interface PagerFormProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<PagerChannel>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
initialDetails?: Record<string, string>;
}
export default PagerForm;

View File

@@ -0,0 +1,64 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
const { TextArea } = Input;
interface PagerDetailsProps {
setSelectedConfig: Dispatch<SetStateAction<ChannelSpecFormValues>>;
initialDetails?: Record<string, string>;
}
const serialize = (details?: Record<string, string>): string =>
details && Object.keys(details).length > 0 ? JSON.stringify(details) : '';
/**
* The API models these details as a map, so the raw JSON the box edits is held
* here: a half-typed object is not valid JSON and would not survive a round trip
* through the form's parsed value.
*/
function PagerDetails({
setSelectedConfig,
initialDetails,
}: PagerDetailsProps): JSX.Element {
const { t } = useTranslation('channels');
const [text, setText] = useState<string>(() => serialize(initialDetails));
const [error, setError] = useState<string | null>(null);
const onChange = (value: string): void => {
setText(value);
if (value.trim() === '') {
setError(null);
setSelectedConfig((config) => ({ ...config, details: {} }));
return;
}
try {
const parsed = JSON.parse(value);
setError(null);
setSelectedConfig((config) => ({ ...config, details: parsed }));
} catch {
setError(t('pager_details_invalid_json'));
}
};
return (
<Form.Item
help={error ?? t('help_pager_details')}
label={t('field_pager_details')}
validateStatus={error ? 'error' : undefined}
>
<TextArea
rows={4}
value={text}
onChange={(event): void => onChange(event.target.value)}
data-testid="pager-additional-details-textarea"
/>
</Form.Item>
);
}
export default PagerDetails;

View File

@@ -3,17 +3,28 @@ import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { SlackChannel } from '../../CreateAlertChannels/config';
import {
AlertmanagertypesChannelSlackActionDTO,
AlertmanagertypesChannelSlackFieldDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import SlackActions from './SlackActions';
import SlackFields from './SlackFields';
const { TextArea } = Input;
function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
function Slack({
setSelectedConfig,
initialFields,
initialActions,
}: SlackProps): JSX.Element {
const { t } = useTranslation('channels');
return (
<>
<Form.Item
name="api_url"
name="apiUrl"
label={t('field_webhook_url')}
tooltip={{
title: (
@@ -30,7 +41,7 @@ function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
api_url: event.target.value,
apiUrl: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
@@ -67,6 +78,18 @@ function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
/>
</Form.Item>
<Form.Item name="titleLink" label={t('field_slack_title_link')}>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
titleLink: event.target.value,
}))
}
data-testid="title-link-textbox"
/>
</Form.Item>
<Form.Item name="text" label={t('field_slack_description')}>
<TextArea
onChange={(event): void =>
@@ -79,12 +102,85 @@ function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
data-testid="description-textarea"
/>
</Form.Item>
<Form.Item
name="color"
label={t('field_slack_color')}
help={t('help_slack_color')}
>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
color: event.target.value,
}))
}
placeholder={t('placeholder_slack_color')}
data-testid="slack-color-textbox"
/>
</Form.Item>
<Form.Item
name="pretext"
label={t('field_slack_pretext')}
help={t('help_slack_pretext')}
>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
pretext: event.target.value,
}))
}
data-testid="slack-pretext-textbox"
/>
</Form.Item>
<Form.Item
name="fallback"
label={t('field_slack_fallback')}
help={t('help_slack_fallback')}
>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
fallback: event.target.value,
}))
}
data-testid="slack-fallback-textbox"
/>
</Form.Item>
<Form.Item name="footer" label={t('field_slack_footer')}>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
footer: event.target.value,
}))
}
data-testid="slack-footer-textbox"
/>
</Form.Item>
<SlackFields
setSelectedConfig={setSelectedConfig}
initialFields={initialFields}
/>
<SlackActions
setSelectedConfig={setSelectedConfig}
initialActions={initialActions}
/>
</>
);
}
interface SlackProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<SlackChannel>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
initialFields?: AlertmanagertypesChannelSlackFieldDTO[];
initialActions?: AlertmanagertypesChannelSlackActionDTO[];
}
export default Slack;

View File

@@ -0,0 +1,123 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Minus, Plus } from '@signozhq/icons';
import { Button, Form, Input } from 'antd';
import { AlertmanagertypesChannelSlackActionDTO } from 'api/generated/services/sigNoz.schemas';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
interface SlackActionsProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
initialActions?: AlertmanagertypesChannelSlackActionDTO[];
}
const emptyAction: AlertmanagertypesChannelSlackActionDTO = {
type: 'button',
text: '',
url: '',
};
// Buttons Slack renders under the attachment. `type` and `text` are required by
// the API; a `button` carrying a url is the link-out case, the rest drive a
// Slack app's own callbacks.
function SlackActions({
setSelectedConfig,
initialActions,
}: SlackActionsProps): JSX.Element {
const { t } = useTranslation('channels');
const [rows, setRows] = useState<AlertmanagertypesChannelSlackActionDTO[]>(
() => initialActions ?? [],
);
const sync = (next: AlertmanagertypesChannelSlackActionDTO[]): void => {
setRows(next);
setSelectedConfig((value) => ({
...value,
actions: next.filter((row) => row.text.trim() !== ''),
}));
};
const updateRow = (
index: number,
patch: Partial<AlertmanagertypesChannelSlackActionDTO>,
): void =>
sync(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
return (
<Form.Item label={t('field_slack_actions')} help={t('help_slack_actions')}>
{rows.map((row, index) => (
// the rows have no stable id, and reordering is not offered
// eslint-disable-next-line react/no-array-index-key
<div key={index} className="slack-actions-row">
<Input
value={row.text}
placeholder={t('placeholder_slack_action_text')}
onChange={(event): void => updateRow(index, { text: event.target.value })}
data-testid={`slack-action-text-${index}`}
/>
<Input
value={row.url}
placeholder={t('placeholder_slack_action_url')}
onChange={(event): void => updateRow(index, { url: event.target.value })}
data-testid={`slack-action-url-${index}`}
/>
<Input
value={row.type}
placeholder={t('placeholder_slack_action_type')}
onChange={(event): void => updateRow(index, { type: event.target.value })}
data-testid={`slack-action-type-${index}`}
/>
<Input
value={row.name ?? ''}
placeholder={t('placeholder_slack_action_name')}
onChange={(event): void => updateRow(index, { name: event.target.value })}
data-testid={`slack-action-name-${index}`}
/>
<Input
value={row.value ?? ''}
placeholder={t('placeholder_slack_action_value')}
onChange={(event): void =>
updateRow(index, { value: event.target.value })
}
data-testid={`slack-action-value-${index}`}
/>
<Input
value={row.style ?? ''}
placeholder={t('placeholder_slack_action_style')}
onChange={(event): void =>
updateRow(index, { style: event.target.value })
}
data-testid={`slack-action-style-${index}`}
/>
<Input
value={row.confirm?.text ?? ''}
placeholder={t('placeholder_slack_action_confirm')}
onChange={(event): void =>
updateRow(index, {
confirm: event.target.value ? { text: event.target.value } : undefined,
})
}
data-testid={`slack-action-confirm-${index}`}
/>
<Button
type="text"
icon={<Minus size={14} />}
aria-label={t('remove_slack_action')}
onClick={(): void => sync(rows.filter((_, i) => i !== index))}
data-testid={`slack-action-remove-${index}`}
/>
</div>
))}
<Button
type="dashed"
icon={<Plus size={14} />}
onClick={(): void => sync([...rows, { ...emptyAction }])}
data-testid="slack-action-add"
>
{t('add_slack_action')}
</Button>
</Form.Item>
);
}
export default SlackActions;

Some files were not shown because too many files have changed in this diff Show More