diff --git a/alertmanager/README.md b/alertmanager/README.md new file mode 100644 index 000000000..a9330c2eb --- /dev/null +++ b/alertmanager/README.md @@ -0,0 +1,41 @@ +# Alert Manager Plugin + +### How to install + +This plugin requires react and react-dom 18 + +Install peer dependencies: + +```bash +npm install react@18 react-dom@18 +``` + +Install the plugin: + +```bash +npm install @perses-dev/alert-manager-plugin +``` + +## Development + +### Setup + +Install dependencies: + +```bash +npm install +``` + +### Get Started + +Start the dev server: + +```bash +npm run dev +``` + +Build the plugin for distribution: + +```bash +npm run build +``` diff --git a/alertmanager/go.mod b/alertmanager/go.mod new file mode 100644 index 000000000..0525cf514 --- /dev/null +++ b/alertmanager/go.mod @@ -0,0 +1,32 @@ +module github.com/perses/plugins/alertmanager + +go 1.25.7 + +require github.com/perses/perses v0.53.1 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jpillora/backoff v1.0.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/muhlemmer/gu v0.3.1 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect + github.com/perses/common v0.30.2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.17.0 // indirect + github.com/zitadel/oidc/v3 v3.45.4 // indirect + github.com/zitadel/schema v1.3.2 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/alertmanager/go.sum b/alertmanager/go.sum new file mode 100644 index 000000000..1d709f9ee --- /dev/null +++ b/alertmanager/go.sum @@ -0,0 +1,70 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nexucis/lamenv v0.5.2 h1:tK/u3XGhCq9qIoVNcXsK9LZb8fKopm0A5weqSRvHd7M= +github.com/nexucis/lamenv v0.5.2/go.mod h1:HusJm6ltmmT7FMG8A750mOLuME6SHCsr2iFYxp5fFi0= +github.com/perses/common v0.30.2 h1:RAiVxUpX76lTCb4X7pfcXSvYdXQmZwKi4oDKAEO//u0= +github.com/perses/common v0.30.2/go.mod h1:DFtur1QPah2/ChXbKKhw7djYdwNgz27s5fPKpiK0Xao= +github.com/perses/perses v0.53.1 h1:9VY/6p9QWrZwPSV7qiwTMSOsgcB37Lb1AXKT0ORXc6I= +github.com/perses/perses v0.53.1/go.mod h1:ro8fsgBkHYOdrL/MV+fdP9mflKzYCy/+gcbxiaReI/A= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/zitadel/oidc/v3 v3.45.4 h1:GKyWaPRVQ8sCu9XgJ3NgNGtG52FzwVJpzXjIUG2+YrI= +github.com/zitadel/oidc/v3 v3.45.4/go.mod h1:XALmFXS9/kSom9B6uWin1yJ2WTI/E4Ti5aXJdewAVEs= +github.com/zitadel/schema v1.3.2 h1:gfJvt7dOMfTmxzhscZ9KkapKo3Nei3B6cAxjav+lyjI= +github.com/zitadel/schema v1.3.2/go.mod h1:IZmdfF9Wu62Zu6tJJTH3UsArevs3Y4smfJIj3L8fzxw= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/alertmanager/jest.config.ts b/alertmanager/jest.config.ts new file mode 100644 index 000000000..5190fb4de --- /dev/null +++ b/alertmanager/jest.config.ts @@ -0,0 +1,23 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Config } from '@jest/types'; +import shared from '../jest.shared'; + +const jestConfig: Config.InitialOptions = { + ...shared, + + setupFilesAfterEnv: [...(shared.setupFilesAfterEnv ?? []), '/src/setup-tests.ts'], +}; + +export default jestConfig; diff --git a/alertmanager/package.json b/alertmanager/package.json new file mode 100644 index 000000000..3a262efd0 --- /dev/null +++ b/alertmanager/package.json @@ -0,0 +1,115 @@ +{ + "name": "@perses-dev/alert-manager-plugin", + "version": "0.1.0", + "homepage": "https://github.com/perses/plugins/blob/main/README.md", + "repository": { + "type": "git", + "url": "git+https://github.com/perses/plugins.git" + }, + "bugs": { + "url": "https://github.com/perses/plugins/issues" + }, + "scripts": { + "dev": "rsbuild dev", + "build": "npm run build-mf && concurrently \"npm:build:*\"", + "build-mf": "rsbuild build", + "build:cjs": "swc ./src -d dist/lib/cjs --strip-leading-paths --config-file ../.cjs.swcrc", + "build:esm": "swc ./src -d dist/lib --strip-leading-paths --config-file ../.swcrc", + "build:types": "tsc --project tsconfig.build.json", + "lint": "eslint src --ext .ts,.tsx", + "test": "cross-env LC_ALL=C TZ=UTC jest", + "type-check": "tsc --noEmit" + }, + "main": "lib/cjs/index.js", + "module": "lib/index.js", + "types": "lib/index.d.ts", + "peerDependencies": { + "@emotion/react": "^11.7.1", + "@emotion/styled": "^11.6.0", + "@perses-dev/components": "^0.54.0-beta.1", + "@perses-dev/core": "^0.53.0", + "@perses-dev/spec": "^0.2.0-beta.1", + "@perses-dev/dashboards": "^0.54.0-beta.1", + "@perses-dev/explore": "^0.54.0-beta.1", + "@perses-dev/plugin-system": "^0.54.0-beta.1", + "@tanstack/react-query": "^4.39.1", + "date-fns": "^4.1.0", + "lodash": "^4.17.21", + "react": "^17.0.2 || ^18.0.0", + "react-dom": "^17.0.2 || ^18.0.0" + }, + "files": [ + "lib/**/*", + "__mf/**/*", + "mf-manifest.json", + "mf-stats.json" + ], + "perses": { + "schemasPath": "schemas", + "plugins": [ + { + "kind": "Datasource", + "spec": { + "display": { + "name": "Alert Manager Datasource" + }, + "name": "AlertManagerDatasource" + } + }, + { + "kind": "AlertsQuery", + "spec": { + "display": { + "name": "Alert Manager Alerts Query" + }, + "name": "AlertManagerAlertsQuery" + } + }, + { + "kind": "SilencesQuery", + "spec": { + "display": { + "name": "Alert Manager Silences Query" + }, + "name": "AlertManagerSilencesQuery" + } + }, + { + "kind": "Panel", + "spec": { + "display": { + "name": "Alert Table" + }, + "name": "AlertTable" + } + }, + { + "kind": "Panel", + "spec": { + "display": { + "name": "Silence Table" + }, + "name": "SilenceTable" + } + }, + { + "kind": "Explore", + "spec": { + "display": { + "name": "Alert Manager Alerts Explorer" + }, + "name": "AlertManagerAlertsExplorer" + } + }, + { + "kind": "Explore", + "spec": { + "display": { + "name": "Alert Manager Silences Explorer" + }, + "name": "AlertManagerSilencesExplorer" + } + } + ] + } +} diff --git a/alertmanager/rsbuild.config.ts b/alertmanager/rsbuild.config.ts new file mode 100644 index 000000000..ff31fd5af --- /dev/null +++ b/alertmanager/rsbuild.config.ts @@ -0,0 +1,47 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { pluginReact } from '@rsbuild/plugin-react'; +import { createConfigForPlugin } from '../rsbuild.shared'; + +export default createConfigForPlugin({ + name: 'AlertManager', + rsbuildConfig: { + server: { port: 3015 }, + plugins: [pluginReact()], + }, + moduleFederation: { + exposes: { + './AlertManagerDatasource': './src/plugins/alertmanager-datasource.tsx', + './AlertManagerAlertsQuery': './src/plugins/alertmanager-alerts-query/AlertManagerAlertsQuery.ts', + './AlertManagerSilencesQuery': './src/plugins/alertmanager-silences-query/AlertManagerSilencesQuery.ts', + './AlertTable': './src/plugins/alert-table/AlertTable.ts', + './SilenceTable': './src/plugins/silence-table/SilenceTable.ts', + './AlertManagerAlertsExplorer': './src/explore/AlertManagerAlertsExplorer.tsx', + './AlertManagerSilencesExplorer': './src/explore/AlertManagerSilencesExplorer.tsx', + }, + shared: { + react: { requiredVersion: '18.2.0', singleton: true }, + 'react-dom': { requiredVersion: '18.2.0', singleton: true }, + 'date-fns': { singleton: true }, + lodash: { singleton: true }, + '@perses-dev/components': { singleton: true }, + '@perses-dev/plugin-system': { singleton: true }, + '@perses-dev/explore': { singleton: true }, + '@perses-dev/dashboards': { singleton: true }, + '@emotion/react': { requiredVersion: '^11.11.3', singleton: true }, + '@emotion/styled': { singleton: true }, + '@tanstack/react-query': { singleton: true }, + }, + }, +}); diff --git a/alertmanager/schemas/alert-table/alert-table.cue b/alertmanager/schemas/alert-table/alert-table.cue new file mode 100644 index 000000000..5c2a239b9 --- /dev/null +++ b/alertmanager/schemas/alert-table/alert-table.cue @@ -0,0 +1,40 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +kind: "AlertTable" +spec: close({ + defaultGroupBy?: [...string] + columns?: [...close({ + name: string + header?: string + enableSorting?: bool + sort?: "asc" | "desc" + sortMode?: "alphabetical" | "numeric" | "severity" + })] + deduplication?: close({ + mode: "none" | "fingerprint" | "labels" + labels?: [...string] + }) + allowedActions?: [...("silence" | "runbook")] + labelColorMappings?: [...close({ + labelKey: string + mode: "auto" | "severity" | "manual" + overrides?: [...close({ + value: string + isRegex: bool + color: string + })] + })] +}) diff --git a/alertmanager/schemas/alertmanager-alerts-query/alertmanager-alerts-query.cue b/alertmanager/schemas/alertmanager-alerts-query/alertmanager-alerts-query.cue new file mode 100644 index 000000000..3251f23d4 --- /dev/null +++ b/alertmanager/schemas/alertmanager-alerts-query/alertmanager-alerts-query.cue @@ -0,0 +1,28 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +kind: "AlertManagerAlertsQuery" +spec: close({ + datasource?: { + kind: "AlertManagerDatasource" + name?: string + } + filters?: [...string] + active?: bool + silenced?: bool + inhibited?: bool + unprocessed?: bool + receiver?: string +}) diff --git a/alertmanager/schemas/alertmanager-silences-query/alertmanager-silences-query.cue b/alertmanager/schemas/alertmanager-silences-query/alertmanager-silences-query.cue new file mode 100644 index 000000000..816d2adf3 --- /dev/null +++ b/alertmanager/schemas/alertmanager-silences-query/alertmanager-silences-query.cue @@ -0,0 +1,23 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +kind: "AlertManagerSilencesQuery" +spec: close({ + datasource?: { + kind: "AlertManagerDatasource" + name?: string + } + filters?: [...string] +}) diff --git a/alertmanager/schemas/datasource/alertmanager.cue b/alertmanager/schemas/datasource/alertmanager.cue new file mode 100644 index 000000000..c14c388ff --- /dev/null +++ b/alertmanager/schemas/datasource/alertmanager.cue @@ -0,0 +1,38 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +#kind: "AlertManagerDatasource" + +kind: #kind +spec: { + directUrl?: string + proxy?: { + kind: "HTTPProxy" + spec: { + url: string + allowedEndpoints?: [...{ + method: string + endpointPattern: string + }] + headers?: [string]: string + secret?: string + } + } +} + +#selector: { + kind: #kind + name?: string +} diff --git a/alertmanager/schemas/silence-table/silence-table.cue b/alertmanager/schemas/silence-table/silence-table.cue new file mode 100644 index 000000000..3bf1d40bf --- /dev/null +++ b/alertmanager/schemas/silence-table/silence-table.cue @@ -0,0 +1,26 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +kind: "SilenceTable" +spec: close({ + columns?: [...close({ + name: string + header?: string + enableSorting?: bool + sort?: "asc" | "desc" + sortMode?: "alphabetical" | "date" | "status" + })] + allowedActions?: [...("expire")] +}) diff --git a/alertmanager/sdk/go/datasource/datasource.go b/alertmanager/sdk/go/datasource/datasource.go new file mode 100644 index 000000000..2ad974860 --- /dev/null +++ b/alertmanager/sdk/go/datasource/datasource.go @@ -0,0 +1,105 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package datasource + +import ( + "encoding/json" + "fmt" + + "github.com/perses/perses/go-sdk/datasource" + "github.com/perses/perses/pkg/model/api/v1/datasource/http" +) + +const PluginKind = "AlertManagerDatasource" + +type PluginSpec struct { + DirectURL string `json:"directUrl,omitempty" yaml:"directUrl,omitempty"` + Proxy *http.Proxy `json:"proxy,omitempty" yaml:"proxy,omitempty"` +} + +func (s *PluginSpec) UnmarshalJSON(data []byte) error { + type plain PluginSpec + var tmp PluginSpec + if err := json.Unmarshal(data, (*plain)(&tmp)); err != nil { + return err + } + if err := (&tmp).validate(); err != nil { + return err + } + *s = tmp + return nil +} + +func (s *PluginSpec) UnmarshalYAML(unmarshal func(interface{}) error) error { + var tmp PluginSpec + type plain PluginSpec + if err := unmarshal((*plain)(&tmp)); err != nil { + return err + } + if err := (&tmp).validate(); err != nil { + return err + } + *s = tmp + return nil +} + +func (s *PluginSpec) validate() error { + if len(s.DirectURL) == 0 && s.Proxy == nil { + return fmt.Errorf("directUrl or proxy cannot be empty") + } + if len(s.DirectURL) > 0 && s.Proxy != nil { + return fmt.Errorf("at most directUrl or proxy must be configured") + } + return nil +} + +type Option func(plugin *Builder) error + +func create(options ...Option) (Builder, error) { + builder := &Builder{ + PluginSpec: PluginSpec{}, + } + + for _, opt := range options { + if err := opt(builder); err != nil { + return *builder, err + } + } + + return *builder, nil +} + +type Builder struct { + PluginSpec `json:",inline" yaml:",inline"` +} + +func AlertManager(options ...Option) datasource.Option { + return func(builder *datasource.Builder) error { + plugin, err := create(options...) + if err != nil { + return err + } + + builder.Spec.Plugin.Kind = PluginKind + builder.Spec.Plugin.Spec = plugin.PluginSpec + return nil + } +} + +func Selector(datasourceName string) *datasource.Selector { + return &datasource.Selector{ + Kind: PluginKind, + Name: datasourceName, + } +} diff --git a/alertmanager/sdk/go/datasource/options.go b/alertmanager/sdk/go/datasource/options.go new file mode 100644 index 000000000..36d6c659d --- /dev/null +++ b/alertmanager/sdk/go/datasource/options.go @@ -0,0 +1,34 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package datasource + +import "github.com/perses/perses/go-sdk/http" + +func DirectURL(url string) Option { + return func(builder *Builder) error { + builder.DirectURL = url + return nil + } +} + +func HTTPProxy(url string, options ...http.Option) Option { + return func(builder *Builder) error { + p, err := http.New(url, options...) + if err != nil { + return err + } + builder.Proxy = &p.Proxy + return nil + } +} diff --git a/alertmanager/sdk/go/query/alerts/alerts.go b/alertmanager/sdk/go/query/alerts/alerts.go new file mode 100644 index 000000000..24dc242cf --- /dev/null +++ b/alertmanager/sdk/go/query/alerts/alerts.go @@ -0,0 +1,65 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package alerts + +import ( + "github.com/perses/perses/go-sdk/datasource" + "github.com/perses/perses/go-sdk/query" + "github.com/perses/perses/pkg/model/api/v1/common" + "github.com/perses/perses/pkg/model/api/v1/plugin" +) + +const PluginKind = "AlertManagerAlertsQuery" + +type PluginSpec struct { + Datasource *datasource.Selector `json:"datasource,omitempty" yaml:"datasource,omitempty"` + Filters []string `json:"filters,omitempty" yaml:"filters,omitempty"` + Active *bool `json:"active,omitempty" yaml:"active,omitempty"` + Silenced *bool `json:"silenced,omitempty" yaml:"silenced,omitempty"` + Inhibited *bool `json:"inhibited,omitempty" yaml:"inhibited,omitempty"` + Unprocessed *bool `json:"unprocessed,omitempty" yaml:"unprocessed,omitempty"` + Receiver string `json:"receiver,omitempty" yaml:"receiver,omitempty"` +} + +type Option func(plugin *Builder) error + +func create(options ...Option) (Builder, error) { + builder := &Builder{ + PluginSpec: PluginSpec{}, + } + + for _, opt := range options { + if err := opt(builder); err != nil { + return *builder, err + } + } + + return *builder, nil +} + +type Builder struct { + PluginSpec `json:",inline" yaml:",inline"` +} + +func AlertsQuery(options ...Option) query.Option { + plg, err := create(options...) + return query.Option{ + Kind: plugin.KindAlertsQuery, + Plugin: common.Plugin{ + Kind: PluginKind, + Spec: plg, + }, + Error: err, + } +} diff --git a/alertmanager/sdk/go/query/alerts/options.go b/alertmanager/sdk/go/query/alerts/options.go new file mode 100644 index 000000000..28ae61e24 --- /dev/null +++ b/alertmanager/sdk/go/query/alerts/options.go @@ -0,0 +1,67 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package alerts + +import ( + amDatasource "github.com/perses/plugins/alertmanager/sdk/go/datasource" +) + +func Datasource(datasourceName string) Option { + return func(builder *Builder) error { + builder.Datasource = amDatasource.Selector(datasourceName) + return nil + } +} + +func Filters(filters ...string) Option { + return func(builder *Builder) error { + builder.Filters = filters + return nil + } +} + +func Active(active bool) Option { + return func(builder *Builder) error { + builder.Active = &active + return nil + } +} + +func Silenced(silenced bool) Option { + return func(builder *Builder) error { + builder.Silenced = &silenced + return nil + } +} + +func Inhibited(inhibited bool) Option { + return func(builder *Builder) error { + builder.Inhibited = &inhibited + return nil + } +} + +func Unprocessed(unprocessed bool) Option { + return func(builder *Builder) error { + builder.Unprocessed = &unprocessed + return nil + } +} + +func Receiver(receiver string) Option { + return func(builder *Builder) error { + builder.Receiver = receiver + return nil + } +} diff --git a/alertmanager/sdk/go/query/silences/options.go b/alertmanager/sdk/go/query/silences/options.go new file mode 100644 index 000000000..0c31c13c1 --- /dev/null +++ b/alertmanager/sdk/go/query/silences/options.go @@ -0,0 +1,32 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package silences + +import ( + amDatasource "github.com/perses/plugins/alertmanager/sdk/go/datasource" +) + +func Datasource(datasourceName string) Option { + return func(builder *Builder) error { + builder.Datasource = amDatasource.Selector(datasourceName) + return nil + } +} + +func Filters(filters ...string) Option { + return func(builder *Builder) error { + builder.Filters = filters + return nil + } +} diff --git a/alertmanager/sdk/go/query/silences/silences.go b/alertmanager/sdk/go/query/silences/silences.go new file mode 100644 index 000000000..946ef2f00 --- /dev/null +++ b/alertmanager/sdk/go/query/silences/silences.go @@ -0,0 +1,60 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package silences + +import ( + "github.com/perses/perses/go-sdk/datasource" + "github.com/perses/perses/go-sdk/query" + "github.com/perses/perses/pkg/model/api/v1/common" + "github.com/perses/perses/pkg/model/api/v1/plugin" +) + +const PluginKind = "AlertManagerSilencesQuery" + +type PluginSpec struct { + Datasource *datasource.Selector `json:"datasource,omitempty" yaml:"datasource,omitempty"` + Filters []string `json:"filters,omitempty" yaml:"filters,omitempty"` +} + +type Option func(plugin *Builder) error + +func create(options ...Option) (Builder, error) { + builder := &Builder{ + PluginSpec: PluginSpec{}, + } + + for _, opt := range options { + if err := opt(builder); err != nil { + return *builder, err + } + } + + return *builder, nil +} + +type Builder struct { + PluginSpec `json:",inline" yaml:",inline"` +} + +func SilencesQuery(options ...Option) query.Option { + plg, err := create(options...) + return query.Option{ + Kind: plugin.KindSilencesQuery, + Plugin: common.Plugin{ + Kind: PluginKind, + Spec: plg, + }, + Error: err, + } +} diff --git a/alertmanager/src/bootstrap.tsx b/alertmanager/src/bootstrap.tsx new file mode 100644 index 000000000..06f1ffc5a --- /dev/null +++ b/alertmanager/src/bootstrap.tsx @@ -0,0 +1,18 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import ReactDOM from 'react-dom/client'; + +const root = ReactDOM.createRoot(document.getElementById('root')!); +root.render(); diff --git a/alertmanager/src/components/ColumnsEditor.tsx b/alertmanager/src/components/ColumnsEditor.tsx new file mode 100644 index 000000000..707abbf9f --- /dev/null +++ b/alertmanager/src/components/ColumnsEditor.tsx @@ -0,0 +1,279 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + Box, + Button, + Checkbox, + Divider, + FormControl, + FormControlLabel, + IconButton, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { OptionsEditorGroup } from '@perses-dev/components'; +import ArrowUpIcon from 'mdi-material-ui/ArrowUp'; +import ArrowDownIcon from 'mdi-material-ui/ArrowDown'; +import DeleteIcon from 'mdi-material-ui/Delete'; +import PlusIcon from 'mdi-material-ui/Plus'; +import { ReactElement, useCallback, useRef } from 'react'; + +export interface BaseColumnDefinition { + name: string; + header?: string; + enableSorting?: boolean; + sort?: 'asc' | 'desc'; + sortMode?: string; +} + +export type ColumnUpdater = (index: number, updater: (draft: C) => void) => void; + +export interface ColumnsEditorProps { + columns: C[]; + description: string; + sortModeLabels: Record; + defaultSortMode: string; + getDisplayName: (column: C) => string; + getHeaderPlaceholder: (column: C) => string; + onAdd: () => void; + onRemove: (index: number) => void; + onUpdate: ColumnUpdater; + onMoveUp: (index: number) => void; + onMoveDown: (index: number) => void; + renderNameField: (column: C, index: number, onUpdate: ColumnUpdater) => ReactElement; +} + +const DEFAULT_SORT_LABELS: Record = { + none: 'None', + asc: 'Ascending', + desc: 'Descending', +}; + +function ColumnEntry({ + column, + index, + isFirst, + isLast, + sortModeLabels, + defaultSortMode, + getDisplayName, + getHeaderPlaceholder, + onUpdate, + onRemove, + onMoveUp, + onMoveDown, + renderNameField, +}: { + column: C; + index: number; + isFirst: boolean; + isLast: boolean; + sortModeLabels: Record; + defaultSortMode: string; + getDisplayName: (column: C) => string; + getHeaderPlaceholder: (column: C) => string; + onUpdate: ColumnUpdater; + onRemove: (index: number) => void; + onMoveUp: (index: number) => void; + onMoveDown: (index: number) => void; + renderNameField: (column: C, index: number, onUpdate: ColumnUpdater) => ReactElement; +}): ReactElement { + return ( + + + + {getDisplayName(column)} + + onMoveUp(index)} disabled={isFirst} aria-label="Move column up"> + + + onMoveDown(index)} disabled={isLast} aria-label="Move column down"> + + + onRemove(index)} aria-label="Remove column"> + + + + + + {renderNameField(column, index, onUpdate)} + + onUpdate(index, (draft) => { + draft.header = e.target.value || undefined; + }) + } + size="small" + sx={{ flex: 1 }} + placeholder={getHeaderPlaceholder(column)} + /> + + + onUpdate(index, (draft) => { + draft.enableSorting = e.target.checked ? undefined : false; + }) + } + size="small" + /> + } + label="Allow header sorting" + /> + + + Sort mode + + + + Default sort + + + + + + ); +} + +export function ColumnsEditor(props: ColumnsEditorProps): ReactElement { + const { + columns, + description, + sortModeLabels, + defaultSortMode, + getDisplayName, + getHeaderPlaceholder, + onAdd, + onRemove, + onUpdate, + onMoveUp, + onMoveDown, + renderNameField, + } = props; + + const idCounterRef = useRef(0); + const idsRef = useRef([]); + + while (idsRef.current.length < columns.length) { + idsRef.current.push(idCounterRef.current++); + } + idsRef.current.length = columns.length; + + const handleAdd = useCallback((): void => { + idsRef.current.push(idCounterRef.current++); + onAdd(); + }, [onAdd]); + + const handleRemove = useCallback( + (index: number): void => { + idsRef.current.splice(index, 1); + onRemove(index); + }, + [onRemove] + ); + + const handleMoveUp = useCallback( + (index: number): void => { + if (index <= 0) return; + const ids = idsRef.current; + const id = ids.splice(index, 1)[0]!; + ids.splice(index - 1, 0, id); + onMoveUp(index); + }, + [onMoveUp] + ); + + const handleMoveDown = useCallback( + (index: number): void => { + const ids = idsRef.current; + if (index >= ids.length - 1) return; + const id = ids.splice(index, 1)[0]!; + ids.splice(index + 1, 0, id); + onMoveDown(index); + }, + [onMoveDown] + ); + + return ( + + + + {description} + + {columns.map((column, index) => ( + + {index > 0 && } + + + ))} + + + + ); +} diff --git a/alertmanager/src/components/LazyTextField.tsx b/alertmanager/src/components/LazyTextField.tsx new file mode 100644 index 000000000..719b79d75 --- /dev/null +++ b/alertmanager/src/components/LazyTextField.tsx @@ -0,0 +1,44 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { TextField } from '@mui/material'; +import { ChangeEvent, ReactElement, useCallback, useEffect, useState } from 'react'; + +export interface LazyTextFieldProps { + label: string; + value?: string; + onCommit: (nextValue: string) => void; + placeholder?: string; + helperText?: string; + multiline?: boolean; + minRows?: number; +} + +export function LazyTextField(props: LazyTextFieldProps): ReactElement { + const { value, onCommit, ...textFieldProps } = props; + const [draftValue, setDraftValue] = useState(value ?? ''); + + useEffect(() => { + setDraftValue(value ?? ''); + }, [value]); + + const handleChange = useCallback((event: ChangeEvent): void => { + setDraftValue(event.target.value); + }, []); + + const handleBlur = useCallback((): void => { + onCommit(draftValue); + }, [draftValue, onCommit]); + + return ; +} diff --git a/alertmanager/src/components/MatcherEditor.tsx b/alertmanager/src/components/MatcherEditor.tsx new file mode 100644 index 000000000..d5020b237 --- /dev/null +++ b/alertmanager/src/components/MatcherEditor.tsx @@ -0,0 +1,105 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactElement, useId } from 'react'; +import { + IconButton, + MenuItem, + Stack, + TextField, + Select, + FormControl, + InputLabel, + SelectChangeEvent, +} from '@mui/material'; +import DeleteIcon from 'mdi-material-ui/Delete'; + +export interface MatcherValue { + name: string; + value: string; + isEqual: boolean; + isRegex: boolean; +} + +export interface MatcherEditorProps { + matcher: MatcherValue; + onChange: (matcher: MatcherValue) => void; + onRemove: () => void; +} + +type MatchType = '=' | '!=' | '=~' | '!~'; + +function getMatchType(matcher: MatcherValue): MatchType { + if (matcher.isRegex) { + return matcher.isEqual ? '=~' : '!~'; + } + return matcher.isEqual ? '=' : '!='; +} + +function parseMatchType(matchType: MatchType): { isEqual: boolean; isRegex: boolean } { + switch (matchType) { + case '=': + return { isEqual: true, isRegex: false }; + case '!=': + return { isEqual: false, isRegex: false }; + case '=~': + return { isEqual: true, isRegex: true }; + case '!~': + return { isEqual: false, isRegex: true }; + } +} + +/** + * A form for editing a single matcher with fields for name, value, and match type. + */ +export function MatcherEditor({ matcher, onChange, onRemove }: MatcherEditorProps): ReactElement { + const matchTypeLabelId = useId(); + + const handleNameChange = (e: React.ChangeEvent): void => { + onChange({ ...matcher, name: e.target.value }); + }; + + const handleValueChange = (e: React.ChangeEvent): void => { + onChange({ ...matcher, value: e.target.value }); + }; + + const handleMatchTypeChange = (e: SelectChangeEvent): void => { + const { isEqual, isRegex } = parseMatchType(e.target.value as MatchType); + onChange({ ...matcher, isEqual, isRegex }); + }; + + return ( + + + + Match + + + + + + + + ); +} diff --git a/alertmanager/src/components/MatchersList.test.tsx b/alertmanager/src/components/MatchersList.test.tsx new file mode 100644 index 000000000..e8e4b9c28 --- /dev/null +++ b/alertmanager/src/components/MatchersList.test.tsx @@ -0,0 +1,56 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { render, screen } from '@testing-library/react'; +import { MatchersList } from './MatchersList'; + +describe('MatchersList', () => { + it('renders equality matcher as name="value"', () => { + render(); + expect(screen.getByText('alertname="HighMemory"')).toBeInTheDocument(); + }); + + it('renders negative equality matcher as name!="value"', () => { + render(); + expect(screen.getByText('severity!="info"')).toBeInTheDocument(); + }); + + it('renders regex matcher as name=~"value"', () => { + render(); + expect(screen.getByText('instance=~"server-.*"')).toBeInTheDocument(); + }); + + it('renders negative regex matcher as name!~"value"', () => { + render(); + expect(screen.getByText('job!~"test.*"')).toBeInTheDocument(); + }); + + it('renders multiple matchers as separate chips', () => { + render( + + ); + expect(screen.getByText('alertname="HighMemory"')).toBeInTheDocument(); + expect(screen.getByText('severity="critical"')).toBeInTheDocument(); + }); + + it('renders empty list when no matchers provided', () => { + const { container } = render(); + // Should render the container but no chips + expect(container.querySelector('.MuiChip-root')).toBeNull(); + }); +}); diff --git a/alertmanager/src/components/MatchersList.tsx b/alertmanager/src/components/MatchersList.tsx new file mode 100644 index 000000000..25d85c9c8 --- /dev/null +++ b/alertmanager/src/components/MatchersList.tsx @@ -0,0 +1,43 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Chip, Stack } from '@mui/material'; +import { SilenceMatcher } from '@perses-dev/spec'; +import { ReactElement } from 'react'; + +export interface MatchersListProps { + matchers: SilenceMatcher[]; +} + +function formatMatcher(matcher: SilenceMatcher): string { + let operator: string; + if (matcher.isRegex) { + operator = (matcher.isEqual ?? true) ? '=~' : '!~'; + } else { + operator = (matcher.isEqual ?? true) ? '=' : '!='; + } + return `${matcher.name}${operator}"${matcher.value}"`; +} + +/** + * Renders a list of matchers as MUI Chips. + */ +export function MatchersList({ matchers }: MatchersListProps): ReactElement { + return ( + + {matchers.map((matcher, index) => ( + + ))} + + ); +} diff --git a/alertmanager/src/components/SilenceForm.tsx b/alertmanager/src/components/SilenceForm.tsx new file mode 100644 index 000000000..b374c1903 --- /dev/null +++ b/alertmanager/src/components/SilenceForm.tsx @@ -0,0 +1,174 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, Typography } from '@mui/material'; +import AddIcon from 'mdi-material-ui/Plus'; +import { ReactElement, useState } from 'react'; +import { PostableSilence } from '../model/api-types'; +import { MatcherEditor, MatcherValue } from './MatcherEditor'; + +export interface SilenceFormProps { + open: boolean; + onClose: () => void; + onSubmit: (silence: PostableSilence) => void; + initialSilence?: Partial; +} + +const DEFAULT_MATCHER: MatcherValue = { + name: '', + value: '', + isEqual: true, + isRegex: false, +}; + +function toLocalDatetimeString(date: Date): string { + const pad = (n: number): string => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function getDefaultEndTime(): string { + const end = new Date(); + end.setMinutes(end.getMinutes() + 30); + return toLocalDatetimeString(end); +} + +function getNowLocalISO(): string { + return toLocalDatetimeString(new Date()); +} + +/** + * Form dialog for creating or editing silences. + */ +export function SilenceForm({ open, onClose, onSubmit, initialSilence }: SilenceFormProps): ReactElement { + const [matchers, setMatchers] = useState( + initialSilence?.matchers?.length + ? initialSilence.matchers.map((m) => ({ + name: m.name, + value: m.value, + isEqual: m.isEqual, + isRegex: m.isRegex, + })) + : [{ ...DEFAULT_MATCHER }] + ); + const [startsAt, setStartsAt] = useState(initialSilence?.startsAt?.slice(0, 16) ?? getNowLocalISO()); + const [endsAt, setEndsAt] = useState(initialSilence?.endsAt?.slice(0, 16) ?? getDefaultEndTime()); + const [createdBy, setCreatedBy] = useState(initialSilence?.createdBy ?? ''); + const [comment, setComment] = useState(initialSilence?.comment ?? ''); + + const handleAddMatcher = (): void => { + setMatchers([...matchers, { ...DEFAULT_MATCHER }]); + }; + + const handleMatcherChange = (index: number, updated: MatcherValue): void => { + const next = [...matchers]; + next[index] = updated; + setMatchers(next); + }; + + const handleRemoveMatcher = (index: number): void => { + setMatchers(matchers.filter((_, i) => i !== index)); + }; + + const handleSubmit = (): void => { + const startsAtDate = new Date(startsAt); + const endsAtDate = new Date(endsAt); + if (isNaN(startsAtDate.getTime()) || isNaN(endsAtDate.getTime())) return; + + const silence: PostableSilence = { + id: initialSilence?.id, + matchers: matchers.map((m) => ({ + name: m.name, + value: m.value, + isEqual: m.isEqual, + isRegex: m.isRegex, + })), + startsAt: startsAtDate.toISOString(), + endsAt: endsAtDate.toISOString(), + createdBy, + comment, + }; + onSubmit(silence); + }; + + const startsAtDate = new Date(startsAt); + const endsAtDate = new Date(endsAt); + const isValidDates = + !isNaN(startsAtDate.getTime()) && !isNaN(endsAtDate.getTime()) && endsAtDate.getTime() > startsAtDate.getTime(); + const isValid = + matchers.length > 0 && matchers.every((m) => m.name.length > 0) && createdBy.length > 0 && isValidDates; + + return ( + + {initialSilence?.id ? 'Edit Silence' : 'Create Silence'} + + + Matchers + {matchers.map((matcher, index) => ( + handleMatcherChange(index, updated)} + onRemove={() => handleRemoveMatcher(index)} + /> + ))} + + + + setStartsAt(e.target.value)} + size="small" + InputLabelProps={{ shrink: true }} + sx={{ flex: 1 }} + /> + setEndsAt(e.target.value)} + size="small" + InputLabelProps={{ shrink: true }} + sx={{ flex: 1 }} + /> + + + setCreatedBy(e.target.value)} + size="small" + required + /> + setComment(e.target.value)} + size="small" + multiline + rows={2} + /> + + + + + + + + ); +} diff --git a/alertmanager/src/components/StatusBadge.test.tsx b/alertmanager/src/components/StatusBadge.test.tsx new file mode 100644 index 000000000..f282695b1 --- /dev/null +++ b/alertmanager/src/components/StatusBadge.test.tsx @@ -0,0 +1,61 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { render, screen } from '@testing-library/react'; +import { StatusBadge } from './StatusBadge'; + +describe('StatusBadge', () => { + describe('alert variant', () => { + it('renders "Firing" for firing state', () => { + render(); + expect(screen.getByText('Firing')).toBeInTheDocument(); + }); + + it('renders "Silenced" for suppressed state', () => { + render(); + expect(screen.getByText('Silenced')).toBeInTheDocument(); + }); + + it('renders "Pending" for pending state', () => { + render(); + expect(screen.getByText('Pending')).toBeInTheDocument(); + }); + + it('defaults to alert variant when variant is omitted', () => { + render(); + expect(screen.getByText('Firing')).toBeInTheDocument(); + }); + }); + + describe('silence variant', () => { + it('renders "Active" for active state', () => { + render(); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + it('renders "Expired" for expired state', () => { + render(); + expect(screen.getByText('Expired')).toBeInTheDocument(); + }); + + it('renders "Pending" for pending state', () => { + render(); + expect(screen.getByText('Pending')).toBeInTheDocument(); + }); + }); + + it('renders unknown status as-is', () => { + render(); + expect(screen.getByText('unknown-state')).toBeInTheDocument(); + }); +}); diff --git a/alertmanager/src/components/StatusBadge.tsx b/alertmanager/src/components/StatusBadge.tsx new file mode 100644 index 000000000..a45864c84 --- /dev/null +++ b/alertmanager/src/components/StatusBadge.tsx @@ -0,0 +1,52 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactElement } from 'react'; +import { Chip, ChipProps } from '@mui/material'; + +export interface StatusBadgeProps { + status: string; + variant?: 'alert' | 'silence'; +} + +interface StatusConfig { + label: string; + color: ChipProps['color']; +} + +const ALERT_STATUS_MAP: Record = { + firing: { label: 'Firing', color: 'error' }, + suppressed: { label: 'Silenced', color: 'warning' }, + pending: { label: 'Pending', color: 'default' }, + resolved: { label: 'Resolved', color: 'success' }, + inactive: { label: 'Inactive', color: 'default' }, +}; + +const SILENCE_STATUS_MAP: Record = { + active: { label: 'Active', color: 'success' }, + expired: { label: 'Expired', color: 'default' }, + pending: { label: 'Pending', color: 'warning' }, +}; + +/** + * Renders a colored badge/chip based on alert or silence status. + */ +export function StatusBadge({ status, variant = 'alert' }: StatusBadgeProps): ReactElement { + const statusMap = variant === 'silence' ? SILENCE_STATUS_MAP : ALERT_STATUS_MAP; + const config = statusMap[status]; + + const label = config?.label ?? status; + const color = config?.color ?? 'default'; + + return ; +} diff --git a/alertmanager/src/components/index.ts b/alertmanager/src/components/index.ts new file mode 100644 index 000000000..2cb5ce99e --- /dev/null +++ b/alertmanager/src/components/index.ts @@ -0,0 +1,27 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export { StatusBadge } from './StatusBadge'; +export type { StatusBadgeProps } from './StatusBadge'; + +export { MatchersList } from './MatchersList'; +export type { MatchersListProps } from './MatchersList'; + +export { MatcherEditor } from './MatcherEditor'; +export type { MatcherEditorProps, MatcherValue } from './MatcherEditor'; + +export { SilenceForm } from './SilenceForm'; +export type { SilenceFormProps } from './SilenceForm'; + +export { LazyTextField } from './LazyTextField'; +export type { LazyTextFieldProps } from './LazyTextField'; diff --git a/alertmanager/src/explore/AlertManagerAlertsExplorer.tsx b/alertmanager/src/explore/AlertManagerAlertsExplorer.tsx new file mode 100644 index 000000000..6fbc69cd0 --- /dev/null +++ b/alertmanager/src/explore/AlertManagerAlertsExplorer.tsx @@ -0,0 +1,67 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactElement, useState } from 'react'; +import { Box, Stack } from '@mui/material'; +import { DataQueriesProvider, MultiQueryEditor } from '@perses-dev/plugin-system'; +import { Panel } from '@perses-dev/dashboards'; +import { useExplorerManagerContext } from '@perses-dev/explore'; +import { QueryDefinition } from '@perses-dev/core'; + +interface AlertsExplorerQueryParams { + queries?: QueryDefinition[]; +} + +const PANEL_PREVIEW_HEIGHT = 600; + +export function AlertManagerAlertsExplorer(): ReactElement { + const { + data: { queries = [] }, + setData, + } = useExplorerManagerContext(); + + const [queryDefinitions, setQueryDefinitions] = useState(queries); + + const definitions = queries.length + ? queries.map((query: QueryDefinition) => ({ + kind: query.spec.plugin.kind, + spec: query.spec.plugin.spec, + })) + : []; + + return ( + + setQueryDefinitions(state)} + queries={queryDefinitions} + onQueryRun={() => setData({ queries: queryDefinitions })} + /> + + + + + + + ); +} diff --git a/alertmanager/src/explore/AlertManagerSilencesExplorer.tsx b/alertmanager/src/explore/AlertManagerSilencesExplorer.tsx new file mode 100644 index 000000000..52631a0f2 --- /dev/null +++ b/alertmanager/src/explore/AlertManagerSilencesExplorer.tsx @@ -0,0 +1,125 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactElement, useCallback, useMemo, useRef, useState } from 'react'; +import { Box, Button, Stack } from '@mui/material'; +import BellOffIcon from 'mdi-material-ui/BellOff'; +import { DataQueriesProvider, MultiQueryEditor, useDatasourceClient } from '@perses-dev/plugin-system'; +import { Panel } from '@perses-dev/dashboards'; +import { useExplorerManagerContext } from '@perses-dev/explore'; +import { DatasourceSelector, QueryDefinition } from '@perses-dev/core'; +import { useSnackbar } from '@perses-dev/components'; +import { useQueryClient } from '@tanstack/react-query'; +import { SilenceForm } from '../components/SilenceForm'; +import { AlertManagerClient, DEFAULT_ALERTMANAGER, PostableSilence } from '../model'; + +interface SilencesExplorerQueryParams { + queries?: QueryDefinition[]; +} + +const PANEL_PREVIEW_HEIGHT = 600; + +function extractSelectorFromQueries(queries: QueryDefinition[]): DatasourceSelector { + const spec: unknown = queries[0]?.spec?.plugin?.spec; + if (spec && typeof spec === 'object' && 'datasource' in spec) { + const ds = (spec as Record).datasource; + if (ds && typeof ds === 'object' && 'kind' in ds) { + return ds as DatasourceSelector; + } + } + return DEFAULT_ALERTMANAGER; +} + +function CreateSilenceButton({ queries }: { queries: QueryDefinition[] }): ReactElement { + const datasourceSelector = useMemo(() => extractSelectorFromQueries(queries), [queries]); + const { data: amClient } = useDatasourceClient(datasourceSelector); + const queryClient = useQueryClient(); + const { successSnackbar, exceptionSnackbar } = useSnackbar(); + + const [open, setOpen] = useState(false); + const formKeyRef = useRef(0); + + const handleOpen = useCallback(() => { + formKeyRef.current++; + setOpen(true); + }, []); + + const handleSubmit = useCallback( + async (silence: PostableSilence) => { + if (!amClient) return; + try { + await amClient.createSilence(silence); + setOpen(false); + successSnackbar('Silence created successfully'); + queryClient.invalidateQueries({ queryKey: ['query', 'SilencesQuery'] }); + } catch (err) { + exceptionSnackbar(err); + } + }, + [amClient, queryClient, successSnackbar, exceptionSnackbar] + ); + + return ( + <> + + setOpen(false)} onSubmit={handleSubmit} /> + + ); +} + +export function AlertManagerSilencesExplorer(): ReactElement { + const { + data: { queries = [] }, + setData, + } = useExplorerManagerContext(); + + const [queryDefinitions, setQueryDefinitions] = useState(queries); + + const definitions = queries.length + ? queries.map((query: QueryDefinition) => ({ + kind: query.spec.plugin.kind, + spec: query.spec.plugin.spec, + })) + : []; + + return ( + + + + + setQueryDefinitions(state)} + queries={queryDefinitions} + onQueryRun={() => setData({ queries: queryDefinitions })} + /> + + + + + + + ); +} diff --git a/alertmanager/src/getPluginModule.ts b/alertmanager/src/getPluginModule.ts new file mode 100644 index 000000000..2bf440477 --- /dev/null +++ b/alertmanager/src/getPluginModule.ts @@ -0,0 +1,27 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { PluginModuleResource, PluginModuleSpec } from '@perses-dev/plugin-system'; +import packageJson from '../package.json'; + +export function getPluginModule(): PluginModuleResource { + const { name, version, perses } = packageJson; + return { + kind: 'PluginModule', + metadata: { + name, + version, + }, + spec: perses as PluginModuleSpec, + }; +} diff --git a/alertmanager/src/index-federation.ts b/alertmanager/src/index-federation.ts new file mode 100644 index 000000000..df7f0bd82 --- /dev/null +++ b/alertmanager/src/index-federation.ts @@ -0,0 +1,14 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import('./bootstrap'); diff --git a/alertmanager/src/index.ts b/alertmanager/src/index.ts new file mode 100644 index 000000000..f96eb6d53 --- /dev/null +++ b/alertmanager/src/index.ts @@ -0,0 +1,17 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export { getPluginModule } from './getPluginModule'; +export * from './model'; +export * from './plugins'; +export * from './components'; diff --git a/alertmanager/src/model/alertmanager-client.test.ts b/alertmanager/src/model/alertmanager-client.test.ts new file mode 100644 index 000000000..87fff2f02 --- /dev/null +++ b/alertmanager/src/model/alertmanager-client.test.ts @@ -0,0 +1,282 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { createSilence, deleteSilence, getAlerts, getSilence, getSilences, getStatus } from './alertmanager-client'; + +type FetchResponse = { + ok: boolean; + status: number; + statusText: string; + json: () => Promise; +}; + +const fetchMock = jest.fn(); +jest.mock('@perses-dev/core', () => ({ + ...jest.requireActual('@perses-dev/core'), + fetch: (...args: unknown[]): Promise => fetchMock(...args), +})); + +function mockOkResponse(body: unknown): FetchResponse { + return { + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(body), + }; +} + +function mockErrorResponse(status: number, statusText: string, body?: unknown): FetchResponse { + return { + ok: false, + status, + statusText, + json: + body !== undefined + ? (): Promise => Promise.resolve(body) + : (): Promise => Promise.reject(new Error('no body')), + }; +} + +describe('alertmanager-client', () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + describe('getAlerts', () => { + it('calls GET /api/v2/alerts with no params', async () => { + fetchMock.mockResolvedValueOnce(mockOkResponse([])); + + await getAlerts(undefined, { datasourceUrl: 'http://am.example' }); + + expect(fetchMock).toHaveBeenCalledWith('http://am.example/api/v2/alerts', { + method: 'GET', + headers: {}, + }); + }); + + it('appends filter params as repeated query parameters', async () => { + fetchMock.mockResolvedValueOnce(mockOkResponse([])); + + await getAlerts( + { + filter: ['alertname="TestAlert"', 'severity="critical"'], + silenced: true, + inhibited: false, + active: true, + receiver: 'team-a', + }, + { datasourceUrl: 'http://am.example' } + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://am.example/api/v2/alerts?filter=alertname%3D%22TestAlert%22&filter=severity%3D%22critical%22&silenced=true&inhibited=false&active=true&receiver=team-a', + { + method: 'GET', + headers: {}, + } + ); + }); + + it('passes custom headers', async () => { + fetchMock.mockResolvedValueOnce(mockOkResponse([])); + + await getAlerts(undefined, { + datasourceUrl: 'http://am.example', + headers: { Authorization: 'Bearer token123' }, + }); + + expect(fetchMock).toHaveBeenCalledWith('http://am.example/api/v2/alerts', { + method: 'GET', + headers: { Authorization: 'Bearer token123' }, + }); + }); + + it('parses alert response JSON', async () => { + const mockAlerts = [ + { + labels: { alertname: 'TestAlert' }, + annotations: { summary: 'Test' }, + startsAt: '2024-01-01T00:00:00Z', + endsAt: '2024-01-01T01:00:00Z', + fingerprint: 'abc123', + status: { state: 'active', silencedBy: [], inhibitedBy: [] }, + receivers: [{ name: 'default' }], + updatedAt: '2024-01-01T00:00:00Z', + }, + ]; + fetchMock.mockResolvedValueOnce(mockOkResponse(mockAlerts)); + + const result = await getAlerts(undefined, { datasourceUrl: 'http://am.example' }); + + expect(result).toEqual(mockAlerts); + expect(result[0]?.labels.alertname).toBe('TestAlert'); + }); + + it('throws on non-ok response with JSON error body', async () => { + fetchMock.mockResolvedValueOnce( + mockErrorResponse(500, 'Internal Server Error', { message: 'backend overloaded' }) + ); + + await expect(getAlerts(undefined, { datasourceUrl: 'http://am.example' })).rejects.toThrow('backend overloaded'); + }); + + it('throws with statusText when error body has no message', async () => { + fetchMock.mockResolvedValueOnce(mockErrorResponse(502, 'Bad Gateway')); + + await expect(getAlerts(undefined, { datasourceUrl: 'http://am.example' })).rejects.toThrow('Bad Gateway'); + }); + }); + + describe('getSilences', () => { + it('calls GET /api/v2/silences with no params', async () => { + fetchMock.mockResolvedValueOnce(mockOkResponse([])); + + await getSilences(undefined, { datasourceUrl: 'http://am.example' }); + + expect(fetchMock).toHaveBeenCalledWith('http://am.example/api/v2/silences', { + method: 'GET', + headers: {}, + }); + }); + + it('appends filter params', async () => { + fetchMock.mockResolvedValueOnce(mockOkResponse([])); + + await getSilences({ filter: ['alertname="TestAlert"'] }, { datasourceUrl: 'http://am.example' }); + + expect(fetchMock).toHaveBeenCalledWith('http://am.example/api/v2/silences?filter=alertname%3D%22TestAlert%22', { + method: 'GET', + headers: {}, + }); + }); + }); + + describe('getSilence', () => { + it('calls GET /api/v2/silence/{id} with URL-encoded id', async () => { + fetchMock.mockResolvedValueOnce( + mockOkResponse({ + id: 'silence-123', + status: { state: 'active' }, + matchers: [], + }) + ); + + await getSilence('silence/123', { datasourceUrl: 'http://am.example' }); + + expect(fetchMock).toHaveBeenCalledWith('http://am.example/api/v2/silence/silence%2F123', { + method: 'GET', + headers: {}, + }); + }); + }); + + describe('createSilence', () => { + it('calls POST /api/v2/silences with JSON body', async () => { + fetchMock.mockResolvedValueOnce(mockOkResponse({ silenceID: 'new-id-123' })); + + const silence = { + comment: 'Maintenance window', + createdBy: 'admin', + startsAt: '2024-01-01T00:00:00Z', + endsAt: '2024-01-01T04:00:00Z', + matchers: [{ name: 'alertname', value: 'TestAlert', isRegex: false, isEqual: true }], + }; + + const result = await createSilence(silence, { datasourceUrl: 'http://am.example' }); + + expect(fetchMock).toHaveBeenCalledWith('http://am.example/api/v2/silences', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(silence), + }); + expect(result).toEqual({ silenceID: 'new-id-123' }); + }); + }); + + describe('deleteSilence', () => { + it('calls DELETE /api/v2/silence/{id}', async () => { + fetchMock.mockResolvedValueOnce({ ok: true, status: 200, statusText: 'OK' }); + + await deleteSilence('silence-456', { datasourceUrl: 'http://am.example' }); + + expect(fetchMock).toHaveBeenCalledWith('http://am.example/api/v2/silence/silence-456', { + method: 'DELETE', + headers: {}, + }); + }); + + it('throws with JSON error message on non-ok response', async () => { + fetchMock.mockResolvedValueOnce(mockErrorResponse(400, 'Bad Request', { message: 'silence already expired' })); + + await expect(deleteSilence('silence-456', { datasourceUrl: 'http://am.example' })).rejects.toThrow( + 'silence already expired' + ); + }); + + it('throws with statusText when error response has no JSON body', async () => { + fetchMock.mockResolvedValueOnce(mockErrorResponse(500, 'Internal Server Error')); + + await expect(deleteSilence('silence-456', { datasourceUrl: 'http://am.example' })).rejects.toThrow( + 'Internal Server Error' + ); + }); + + it('throws with statusText when error JSON has no message field', async () => { + fetchMock.mockResolvedValueOnce(mockErrorResponse(403, 'Forbidden', { error: 'not authorized' })); + + await expect(deleteSilence('silence-456', { datasourceUrl: 'http://am.example' })).rejects.toThrow('Forbidden'); + }); + }); + + describe('getStatus', () => { + it('calls GET /api/v2/status', async () => { + const mockStatus = { + cluster: { status: 'ready', peers: [] }, + config: { original: '' }, + uptime: '2024-01-01T00:00:00Z', + versionInfo: { + branch: 'main', + buildDate: '2024-01-01', + buildUser: 'ci', + goVersion: '1.21', + revision: 'abc', + version: '0.27.0', + }, + }; + fetchMock.mockResolvedValueOnce(mockOkResponse(mockStatus)); + + const result = await getStatus({ datasourceUrl: 'http://am.example' }); + + expect(fetchMock).toHaveBeenCalledWith('http://am.example/api/v2/status', { + method: 'GET', + headers: {}, + }); + expect(result.versionInfo.version).toBe('0.27.0'); + }); + }); + + describe('error handling', () => { + it('throws on invalid JSON response', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.reject(new Error('Unexpected token')), + }); + + await expect(getAlerts(undefined, { datasourceUrl: 'http://am.example' })).rejects.toThrow( + 'Invalid response from server' + ); + }); + }); +}); diff --git a/alertmanager/src/model/alertmanager-client.ts b/alertmanager/src/model/alertmanager-client.ts new file mode 100644 index 000000000..6469d7f44 --- /dev/null +++ b/alertmanager/src/model/alertmanager-client.ts @@ -0,0 +1,144 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { fetch, RequestHeaders } from '@perses-dev/core'; +import { DatasourceClient } from '@perses-dev/plugin-system'; +import { + AlertManagerStatus, + AlertsQueryParams, + GettableAlert, + GettableSilence, + PostableSilence, + SilencesQueryParams, +} from './api-types'; + +export interface AlertManagerQueryOptions { + datasourceUrl: string; + headers?: RequestHeaders; +} + +export interface AlertManagerClient extends DatasourceClient { + options: AlertManagerQueryOptions; + getAlerts(params?: AlertsQueryParams, headers?: RequestHeaders): Promise; + getSilences(params?: SilencesQueryParams, headers?: RequestHeaders): Promise; + getSilence(id: string, headers?: RequestHeaders): Promise; + createSilence(silence: PostableSilence, headers?: RequestHeaders): Promise<{ silenceID: string }>; + deleteSilence(id: string, headers?: RequestHeaders): Promise; + getStatus(headers?: RequestHeaders): Promise; +} + +async function throwOnError(response: Response): Promise { + if (!response.ok) { + let message = response.statusText; + try { + const body = await response.json(); + if (body?.message) message = body.message; + } catch { + // no JSON body + } + throw new Error(message); + } +} + +const executeRequest = async (...args: Parameters): Promise => { + const response = await fetch(...args); + await throwOnError(response); + try { + return await response.json(); + } catch (e) { + console.error('Invalid response from server', e); + throw new Error('Invalid response from server'); + } +}; + +function buildSearchParams(params: Record | object): URLSearchParams { + const urlSearchParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value === undefined) { + return; + } + if (Array.isArray(value)) { + value.forEach((entry) => urlSearchParams.append(key, String(entry))); + return; + } + urlSearchParams.append(key, String(value)); + }); + return urlSearchParams; +} + +function fetchWithGet( + apiURI: string, + params: Record | object, + queryOptions: AlertManagerQueryOptions +): Promise { + const { datasourceUrl, headers = {} } = queryOptions; + + let url = `${datasourceUrl}${apiURI}`; + const urlParams = buildSearchParams(params).toString(); + if (urlParams !== '') { + url += `?${urlParams}`; + } + + return executeRequest(url, { + method: 'GET', + headers, + }); +} + +export function getAlerts( + params?: AlertsQueryParams, + queryOptions?: AlertManagerQueryOptions +): Promise { + const opts = queryOptions ?? { datasourceUrl: '' }; + return fetchWithGet('/api/v2/alerts', params ?? {}, opts); +} + +export function getSilences( + params?: SilencesQueryParams, + queryOptions?: AlertManagerQueryOptions +): Promise { + const opts = queryOptions ?? { datasourceUrl: '' }; + return fetchWithGet('/api/v2/silences', params ?? {}, opts); +} + +export function getSilence(id: string, queryOptions: AlertManagerQueryOptions): Promise { + return fetchWithGet(`/api/v2/silence/${encodeURIComponent(id)}`, {}, queryOptions); +} + +export function createSilence( + silence: PostableSilence, + queryOptions: AlertManagerQueryOptions +): Promise<{ silenceID: string }> { + const { datasourceUrl, headers = {} } = queryOptions; + + return executeRequest<{ silenceID: string }>(`${datasourceUrl}/api/v2/silences`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(silence), + }); +} + +export async function deleteSilence(id: string, queryOptions: AlertManagerQueryOptions): Promise { + const { datasourceUrl, headers = {} } = queryOptions; + + const response = await fetch(`${datasourceUrl}/api/v2/silence/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers, + }); + + await throwOnError(response); +} + +export function getStatus(queryOptions: AlertManagerQueryOptions): Promise { + return fetchWithGet('/api/v2/status', {}, queryOptions); +} diff --git a/alertmanager/src/model/alertmanager-selectors.ts b/alertmanager/src/model/alertmanager-selectors.ts new file mode 100644 index 000000000..57d3d618a --- /dev/null +++ b/alertmanager/src/model/alertmanager-selectors.ts @@ -0,0 +1,50 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { DatasourceSelector, QueryDefinition } from '@perses-dev/core'; +import { DatasourceSelectValue, isVariableDatasource } from '@perses-dev/plugin-system'; + +export const ALERTMANAGER_DATASOURCE_KIND = 'AlertManagerDatasource' as const; + +export interface AlertManagerDatasourceSelector extends DatasourceSelector { + kind: typeof ALERTMANAGER_DATASOURCE_KIND; +} + +export const DEFAULT_ALERTMANAGER: AlertManagerDatasourceSelector = { kind: ALERTMANAGER_DATASOURCE_KIND }; + +export function isDefaultAlertManagerSelector(datasourceSelectValue: DatasourceSelectValue): boolean { + return !isVariableDatasource(datasourceSelectValue) && datasourceSelectValue.name === undefined; +} + +export function isAlertManagerDatasourceSelector( + datasourceSelectValue: DatasourceSelectValue +): datasourceSelectValue is AlertManagerDatasourceSelector { + return isVariableDatasource(datasourceSelectValue) || datasourceSelectValue.kind === ALERTMANAGER_DATASOURCE_KIND; +} + +export function extractDatasourceSelector(queryResults: Array<{ definition: QueryDefinition }>): DatasourceSelector { + const defSpec: unknown = queryResults[0]?.definition?.spec; + if (defSpec && typeof defSpec === 'object' && 'plugin' in defSpec) { + const plugin = (defSpec as Record).plugin; + if (plugin && typeof plugin === 'object' && 'spec' in plugin) { + const spec = (plugin as Record).spec; + if (spec && typeof spec === 'object' && 'datasource' in spec) { + const ds = (spec as Record).datasource; + if (ds && typeof ds === 'object' && 'kind' in ds) { + return ds as DatasourceSelector; + } + } + } + } + return DEFAULT_ALERTMANAGER; +} diff --git a/alertmanager/src/model/api-types.ts b/alertmanager/src/model/api-types.ts new file mode 100644 index 000000000..b2da01a48 --- /dev/null +++ b/alertmanager/src/model/api-types.ts @@ -0,0 +1,139 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Alert Manager v2 API response types. + * Based on the Alertmanager OpenAPI v2 spec. + * @see https://github.com/prometheus/alertmanager/blob/main/api/v2/openapi.yaml + */ + +/** + * A matcher used by silences and alerts. + */ +export interface Matcher { + name: string; + value: string; + isRegex: boolean; + isEqual: boolean; +} + +/** + * Alert status from the Alertmanager API. + */ +export interface AlertStatus { + state: 'unprocessed' | 'active' | 'suppressed'; + silencedBy: string[]; + inhibitedBy: string[]; + mutedBy: string[]; +} + +/** + * A gettable alert returned by the Alertmanager API. + */ +export interface GettableAlert { + annotations: Record; + endsAt: string; + fingerprint: string; + receivers: Array<{ name: string }>; + startsAt: string; + status: AlertStatus; + updatedAt: string; + generatorURL?: string; + labels: Record; +} + +/** + * Silence status from the Alertmanager API. + */ +export interface SilenceStatus { + state: 'expired' | 'active' | 'pending'; +} + +/** + * A gettable silence returned by the Alertmanager API. + */ +export interface GettableSilence { + id: string; + status: SilenceStatus; + updatedAt: string; + comment: string; + createdBy: string; + endsAt: string; + matchers: Matcher[]; + startsAt: string; +} + +/** + * A postable silence used for creating or updating silences. + */ +export interface PostableSilence { + id?: string; + comment: string; + createdBy: string; + endsAt: string; + matchers: Matcher[]; + startsAt: string; +} + +/** + * Cluster status from the Alertmanager API. + */ +export interface ClusterStatus { + name?: string; + status: string; + peers: Array<{ + name: string; + address: string; + }>; +} + +/** + * Version info from the Alertmanager API. + */ +export interface VersionInfo { + branch: string; + buildDate: string; + buildUser: string; + goVersion: string; + revision: string; + version: string; +} + +/** + * Alertmanager status response. + */ +export interface AlertManagerStatus { + cluster: ClusterStatus; + config: { original: string }; + uptime: string; + versionInfo: VersionInfo; +} + +/** + * Parameters for querying alerts. + */ +export interface AlertsQueryParams { + filter?: string[]; + silenced?: boolean; + inhibited?: boolean; + active?: boolean; + unprocessed?: boolean; + receiver?: string; +} + +/** + * Parameters for querying silences. + */ +export interface SilencesQueryParams { + filter?: string[]; +} diff --git a/alertmanager/src/model/index.ts b/alertmanager/src/model/index.ts new file mode 100644 index 000000000..c6bb5d3ac --- /dev/null +++ b/alertmanager/src/model/index.ts @@ -0,0 +1,16 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export * from './api-types'; +export * from './alertmanager-client'; +export * from './alertmanager-selectors'; diff --git a/alertmanager/src/plugins/AlertManagerDatasourceEditor.tsx b/alertmanager/src/plugins/AlertManagerDatasourceEditor.tsx new file mode 100644 index 000000000..2fb1bff20 --- /dev/null +++ b/alertmanager/src/plugins/AlertManagerDatasourceEditor.tsx @@ -0,0 +1,75 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { HTTPSettingsEditor } from '@perses-dev/plugin-system'; +import { ReactElement } from 'react'; +import { AlertManagerDatasourceSpec } from './types'; + +export interface AlertManagerDatasourceEditorProps { + value: AlertManagerDatasourceSpec; + onChange: (next: AlertManagerDatasourceSpec) => void; + isReadonly?: boolean; +} + +export function AlertManagerDatasourceEditor(props: AlertManagerDatasourceEditorProps): ReactElement { + const { value, onChange, isReadonly } = props; + + const initialSpecDirect: AlertManagerDatasourceSpec = { + directUrl: '', + }; + + const initialSpecProxy: AlertManagerDatasourceSpec = { + proxy: { + kind: 'HTTPProxy', + spec: { + allowedEndpoints: [ + { + endpointPattern: '/api/v2/alerts', + method: 'GET', + }, + { + endpointPattern: '/api/v2/silences', + method: 'GET', + }, + { + endpointPattern: '/api/v2/silences', + method: 'POST', + }, + { + endpointPattern: '/api/v2/silence/.*', + method: 'GET', + }, + { + endpointPattern: '/api/v2/silence/.*', + method: 'DELETE', + }, + { + endpointPattern: '/api/v2/status', + method: 'GET', + }, + ], + url: '', + }, + }, + }; + + return ( + + ); +} diff --git a/alertmanager/src/plugins/alert-table/AlertTable.ts b/alertmanager/src/plugins/alert-table/AlertTable.ts new file mode 100644 index 000000000..6b359931b --- /dev/null +++ b/alertmanager/src/plugins/alert-table/AlertTable.ts @@ -0,0 +1,37 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { PanelPlugin } from '@perses-dev/plugin-system'; +import { AlertTableColumnsEditor } from './AlertTableColumnsEditor'; +import { AlertTableDeduplicationEditor } from './AlertTableDeduplicationEditor'; +import { AlertTableLabelsEditor } from './AlertTableLabelsEditor'; +import { AlertTableOptionsEditor } from './AlertTableOptionsEditor'; +import { AlertTablePanel, AlertTablePanelProps } from './AlertTablePanel'; +import { AlertTableOptions } from './alert-table-model'; + +/** + * Panel plugin for displaying Alert Manager alerts in a hierarchical table. + */ +export const AlertTable: PanelPlugin = { + PanelComponent: AlertTablePanel, + supportedQueryTypes: ['AlertsQuery'], + panelOptionsEditorComponents: [ + { label: 'General', content: AlertTableOptionsEditor }, + { label: 'Columns', content: AlertTableColumnsEditor }, + { label: 'Labels', content: AlertTableLabelsEditor }, + { label: 'Deduplication', content: AlertTableDeduplicationEditor }, + ], + createInitialOptions: () => ({ + defaultGroupBy: ['alertname'], + }), +}; diff --git a/alertmanager/src/plugins/alert-table/AlertTableColumnsEditor.tsx b/alertmanager/src/plugins/alert-table/AlertTableColumnsEditor.tsx new file mode 100644 index 000000000..249ec00dd --- /dev/null +++ b/alertmanager/src/plugins/alert-table/AlertTableColumnsEditor.tsx @@ -0,0 +1,120 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { TextField } from '@mui/material'; +import { OptionsEditorProps } from '@perses-dev/plugin-system'; +import { produce } from 'immer'; +import { ReactElement, useCallback } from 'react'; +import { ColumnsEditor } from '../../components/ColumnsEditor'; +import { AlertTableOptions, ColumnDefinition, ColumnSortMode } from './alert-table-model'; + +const SORT_MODE_LABELS: Record = { + alphabetical: 'Alphabetical', + numeric: 'Numeric', + severity: 'Severity (critical → other)', +}; + +export function AlertTableColumnsEditor(props: OptionsEditorProps): ReactElement { + const { value, onChange } = props; + const columns = value.columns ?? []; + + const handleAddColumn = useCallback((): void => { + onChange( + produce(value, (draft) => { + if (!draft.columns) draft.columns = []; + draft.columns.push({ name: 'severity' }); + }) + ); + }, [value, onChange]); + + const handleRemoveColumn = useCallback( + (index: number): void => { + onChange( + produce(value, (draft) => { + draft.columns?.splice(index, 1); + }) + ); + }, + [value, onChange] + ); + + const handleUpdateColumn = useCallback( + (index: number, updater: (draft: ColumnDefinition) => void): void => { + onChange( + produce(value, (draft) => { + const column = draft.columns?.[index]; + if (column) { + updater(column); + } + }) + ); + }, + [value, onChange] + ); + + const handleMoveUp = useCallback( + (index: number): void => { + if (index <= 0) return; + onChange( + produce(value, (draft) => { + if (!draft.columns) return; + const item = draft.columns.splice(index, 1)[0]!; + draft.columns.splice(index - 1, 0, item); + }) + ); + }, + [value, onChange] + ); + + const handleMoveDown = useCallback( + (index: number): void => { + onChange( + produce(value, (draft) => { + if (!draft.columns || index >= draft.columns.length - 1) return; + const item = draft.columns.splice(index, 1)[0]!; + draft.columns.splice(index + 1, 0, item); + }) + ); + }, + [value, onChange] + ); + + return ( + + columns={columns} + description="Status and Alert Name are always shown. Add extra columns below." + sortModeLabels={SORT_MODE_LABELS} + defaultSortMode="alphabetical" + getDisplayName={(col) => col.header || col.name || 'New column'} + getHeaderPlaceholder={(col) => col.name || 'Column header'} + onAdd={handleAddColumn} + onRemove={handleRemoveColumn} + onUpdate={handleUpdateColumn} + onMoveUp={handleMoveUp} + onMoveDown={handleMoveDown} + renderNameField={(col, index, onUpdate) => ( + + onUpdate(index, (draft) => { + draft.name = e.target.value; + }) + } + size="small" + fullWidth + /> + )} + /> + ); +} diff --git a/alertmanager/src/plugins/alert-table/AlertTableDeduplicationEditor.tsx b/alertmanager/src/plugins/alert-table/AlertTableDeduplicationEditor.tsx new file mode 100644 index 000000000..116d686ef --- /dev/null +++ b/alertmanager/src/plugins/alert-table/AlertTableDeduplicationEditor.tsx @@ -0,0 +1,106 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Autocomplete, Chip, FormControl, InputLabel, MenuItem, Select, TextField, Typography } from '@mui/material'; +import { OptionsEditorGroup } from '@perses-dev/components'; +import { OptionsEditorProps } from '@perses-dev/plugin-system'; +import { produce } from 'immer'; +import { ReactElement, SyntheticEvent, useCallback } from 'react'; +import { AlertDeduplicationConfig, AlertTableOptions } from './alert-table-model'; + +const MODE_DESCRIPTIONS: Record = { + none: 'No deduplication. All alerts from all datasources are shown as-is.', + fingerprint: 'Alerts with the same fingerprint from different datasources are merged. This is the default.', + labels: 'Alerts are considered duplicates when all specified label values match.', +}; + +export function AlertTableDeduplicationEditor(props: OptionsEditorProps): ReactElement { + const { value, onChange } = props; + const dedup = value.deduplication ?? { mode: 'fingerprint' as const }; + + const handleModeChange = useCallback( + (mode: AlertDeduplicationConfig['mode']): void => { + onChange( + produce(value, (draft) => { + draft.deduplication = { mode }; + if (mode === 'labels') { + draft.deduplication.labels = draft.deduplication.labels ?? []; + } + }) + ); + }, + [value, onChange] + ); + + const handleLabelsChange = useCallback( + (_event: SyntheticEvent, newLabels: string[]): void => { + onChange( + produce(value, (draft) => { + if (!draft.deduplication) { + draft.deduplication = { mode: 'labels' }; + } + draft.deduplication.labels = newLabels.length > 0 ? newLabels : undefined; + }) + ); + }, + [value, onChange] + ); + + return ( + <> + + + Deduplication Mode + + + + {MODE_DESCRIPTIONS[dedup.mode]} + + + {dedup.mode === 'labels' && ( + + + multiple + freeSolo + options={[] as string[]} + value={dedup.labels ?? []} + onChange={handleLabelsChange} + renderTags={(tagValues, getTagProps): ReactElement[] => + tagValues.map((option, index) => { + const { key, ...rest } = getTagProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + )} + + ); +} diff --git a/alertmanager/src/plugins/alert-table/AlertTableLabelsEditor.tsx b/alertmanager/src/plugins/alert-table/AlertTableLabelsEditor.tsx new file mode 100644 index 000000000..060aeb158 --- /dev/null +++ b/alertmanager/src/plugins/alert-table/AlertTableLabelsEditor.tsx @@ -0,0 +1,248 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + Box, + Button, + Checkbox, + Divider, + FormControl, + FormControlLabel, + IconButton, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { OptionsEditorGroup } from '@perses-dev/components'; +import { OptionsEditorProps } from '@perses-dev/plugin-system'; +import { produce } from 'immer'; +import DeleteIcon from 'mdi-material-ui/Delete'; +import PlusIcon from 'mdi-material-ui/Plus'; +import { ReactElement, useCallback, useRef } from 'react'; +import { AlertTableOptions, LabelColorMapping, LabelColorOverride } from './alert-table-model'; + +const MODE_LABELS: Record = { + auto: 'Auto (hash-derived colors)', + severity: 'Severity (built-in defaults)', + manual: 'Manual (overrides only)', +}; + +function LabelMappingEntry({ + mapping, + index, + onUpdate, + onRemove, +}: { + mapping: LabelColorMapping; + index: number; + onUpdate: (index: number, updater: (draft: LabelColorMapping) => void) => void; + onRemove: (index: number) => void; +}): ReactElement { + const handleAddOverride = useCallback((): void => { + onUpdate(index, (draft) => { + if (!draft.overrides) draft.overrides = []; + draft.overrides.push({ value: '', isRegex: false, color: '#1976d2' }); + }); + }, [index, onUpdate]); + + const handleRemoveOverride = useCallback( + (overrideIndex: number): void => { + onUpdate(index, (draft) => { + draft.overrides?.splice(overrideIndex, 1); + }); + }, + [index, onUpdate] + ); + + const handleUpdateOverride = useCallback( + (overrideIndex: number, field: keyof LabelColorOverride, fieldValue: string | boolean): void => { + onUpdate(index, (draft) => { + const override = draft.overrides?.[overrideIndex]; + if (override) { + if (field === 'isRegex') { + override.isRegex = fieldValue as boolean; + } else { + override[field] = fieldValue as string; + } + } + }); + }, + [index, onUpdate] + ); + + return ( + + + + {mapping.labelKey || 'New label'} + onRemove(index)} aria-label="Remove label mapping"> + + + + + onUpdate(index, (draft) => { + draft.labelKey = e.target.value; + }) + } + size="small" + fullWidth + /> + + Mode + + + + + Overrides + + + {(mapping.overrides ?? []).map((override, overrideIndex) => ( + + handleUpdateOverride(overrideIndex, 'value', e.target.value)} + size="small" + sx={{ flex: 1 }} + /> + handleUpdateOverride(overrideIndex, 'isRegex', e.target.checked)} + size="small" + /> + } + label="Regex" + /> + handleUpdateOverride(overrideIndex, 'color', e.target.value)} + style={{ width: 36, height: 28, border: 'none', cursor: 'pointer' }} + /> + handleRemoveOverride(overrideIndex)} + aria-label="Remove override" + > + + + + ))} + + + + + + ); +} + +export function AlertTableLabelsEditor(props: OptionsEditorProps): ReactElement { + const { value, onChange } = props; + const mappings = value.labelColorMappings ?? []; + + const handleAddMapping = useCallback((): void => { + onChange( + produce(value, (draft) => { + if (!draft.labelColorMappings) draft.labelColorMappings = []; + draft.labelColorMappings.push({ labelKey: '', mode: 'auto' }); + }) + ); + }, [value, onChange]); + + const handleRemoveMapping = useCallback( + (index: number): void => { + onChange( + produce(value, (draft) => { + draft.labelColorMappings?.splice(index, 1); + }) + ); + }, + [value, onChange] + ); + + const handleUpdateMapping = useCallback( + (index: number, updater: (draft: LabelColorMapping) => void): void => { + onChange( + produce(value, (draft) => { + const mapping = draft.labelColorMappings?.[index]; + if (mapping) { + updater(mapping); + } + }) + ); + }, + [value, onChange] + ); + + const idCounterRef = useRef(0); + const idMapRef = useRef(new WeakMap()); + const getStableId = (mapping: LabelColorMapping): number => { + let id = idMapRef.current.get(mapping); + if (id === undefined) { + id = idCounterRef.current++; + idMapRef.current.set(mapping, id); + } + return id; + }; + + return ( + + + {mappings.length === 0 && ( + + No label color mappings configured. Add one to color label values in the table. + + )} + {mappings.map((mapping, index) => ( + + {index > 0 && } + + + ))} + + + + ); +} diff --git a/alertmanager/src/plugins/alert-table/AlertTableOptionsEditor.tsx b/alertmanager/src/plugins/alert-table/AlertTableOptionsEditor.tsx new file mode 100644 index 000000000..9f0a5f953 --- /dev/null +++ b/alertmanager/src/plugins/alert-table/AlertTableOptionsEditor.tsx @@ -0,0 +1,112 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Autocomplete, Checkbox, Chip, FormControlLabel, TextField } from '@mui/material'; +import { OptionsEditorGroup } from '@perses-dev/components'; +import { OptionsEditorProps } from '@perses-dev/plugin-system'; +import { produce } from 'immer'; +import { ReactElement, SyntheticEvent, useCallback } from 'react'; +import { AlertAction, AlertTableOptions, ALL_ALERT_ACTIONS } from './alert-table-model'; + +const ACTION_LABELS: Record = { + silence: 'Silence alert', + runbook: 'View runbook', +}; + +export function AlertTableOptionsEditor(props: OptionsEditorProps): ReactElement { + const { value, onChange } = props; + const effectiveActions = value.allowedActions ?? ALL_ALERT_ACTIONS; + const groupBy = value.defaultGroupBy ?? ['alertname']; + + const handleGroupByChange = useCallback( + (_event: SyntheticEvent, newValue: string[]): void => { + onChange( + produce(value, (draft) => { + draft.defaultGroupBy = newValue.length > 0 ? newValue : undefined; + }) + ); + }, + [value, onChange] + ); + + const handleToggle = useCallback( + (action: AlertAction, checked: boolean) => { + onChange( + produce(value, (draft) => { + const current = draft.allowedActions ?? [...ALL_ALERT_ACTIONS]; + if (checked) { + if (!current.includes(action)) current.push(action); + } else { + const idx = current.indexOf(action); + if (idx >= 0) current.splice(idx, 1); + } + draft.allowedActions = current; + }) + ); + }, + [value, onChange] + ); + + return ( + <> + + + multiple + freeSolo + options={[] as string[]} + value={groupBy} + onChange={handleGroupByChange} + renderTags={(tagValues, getTagProps): ReactElement[] => + tagValues.map((option, index) => { + const { key, ...rest } = getTagProps({ index }); + const isVariable = option.includes('$'); + return ( + + ); + }) + } + renderInput={(params) => ( + + )} + /> + + + {ALL_ALERT_ACTIONS.map((action) => ( + handleToggle(action, e.target.checked)} + size="small" + /> + } + label={ACTION_LABELS[action]} + /> + ))} + + + ); +} diff --git a/alertmanager/src/plugins/alert-table/AlertTablePanel.tsx b/alertmanager/src/plugins/alert-table/AlertTablePanel.tsx new file mode 100644 index 000000000..116fd448b --- /dev/null +++ b/alertmanager/src/plugins/alert-table/AlertTablePanel.tsx @@ -0,0 +1,667 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + Box, + Chip, + FormControl, + IconButton, + InputAdornment, + InputLabel, + MenuItem, + Select, + SelectChangeEvent, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TableSortLabel, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { useSnackbar } from '@perses-dev/components'; +import { + PanelProps, + parseVariables, + replaceVariablesInString, + useAllVariableValues, + useDatasourceClient, +} from '@perses-dev/plugin-system'; +import { Alert, AlertsData } from '@perses-dev/spec'; +import { useQueryClient } from '@tanstack/react-query'; +import BellOffIcon from 'mdi-material-ui/BellOff'; +import BookOpenIcon from 'mdi-material-ui/BookOpen'; +import ChevronDownIcon from 'mdi-material-ui/ChevronDown'; +import ChevronRightIcon from 'mdi-material-ui/ChevronRight'; +import MagnifyIcon from 'mdi-material-ui/Magnify'; +import UnfoldLessHorizontalIcon from 'mdi-material-ui/UnfoldLessHorizontal'; +import UnfoldMoreHorizontalIcon from 'mdi-material-ui/UnfoldMoreHorizontal'; +import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { SilenceForm } from '../../components/SilenceForm'; +import { StatusBadge } from '../../components/StatusBadge'; +import { AlertManagerClient, extractDatasourceSelector } from '../../model'; +import { PostableSilence } from '../../model/api-types'; +import { + AlertAction, + AlertTableOptions, + ALL_ALERT_ACTIONS, + ColumnDefinition, + deduplicateAlerts, + extractLabelKeys, + getGroupKey, + getGroupSummary, + GroupSummary, + LabelColorMapping, +} from './alert-table-model'; +import { compareAlertsByColumn, compareGroupsByColumn, SortState } from './alert-table-sorting'; +import { getLabelColor } from './label-colors'; + +export type AlertTablePanelProps = PanelProps; + +interface AlertGroup { + key: string; + alerts: Alert[]; + summary: GroupSummary; +} + +function GroupSummaryChips({ summary }: { summary: GroupSummary }): ReactElement { + return ( + + {summary.firing > 0 && } + {summary.suppressed > 0 && } + {summary.pending > 0 && } + + ); +} + +function LabelValueSummaryChips({ + summary, + mapping, +}: { + summary: GroupSummary; + mapping: LabelColorMapping; +}): ReactElement | null { + const counts = summary.labelCounts?.[mapping.labelKey]; + if (!counts || Object.keys(counts).length === 0) { + return null; + } + + return ( + + {Object.entries(counts) + .sort(([, a], [, b]) => b - a) + .map(([value, count]) => { + const bgColor = getLabelColor(value, mapping); + return ( + + ); + })} + + ); +} + +function AlertRow({ + alert, + allowedActions, + columnDefs, + columnKeySet, + mappingsByKey, + onSilence, + duplicateCount, + showDuplicates, +}: { + alert: Alert; + allowedActions: AlertAction[]; + columnDefs: ColumnDefinition[]; + columnKeySet: Set; + mappingsByKey: Map; + onSilence: (alert: Alert) => void; + duplicateCount?: number; + showDuplicates: boolean; +}): ReactElement { + const runbookUrl = alert.annotations?.runbook_url; + const showActions = allowedActions.length > 0; + + return ( + + + + + {alert.name} + {columnDefs.map((col) => { + const value = alert.labels[col.name]; + const mapping = mappingsByKey.get(col.name); + if (value !== undefined && mapping) { + const bgColor = getLabelColor(value, mapping); + return ( + + + + ); + } + return {value ?? ''}; + })} + {showDuplicates && ( + + {duplicateCount !== undefined && ( + + )} + + )} + + + {Object.entries(alert.labels) + .filter(([key]) => key !== 'alertname' && !columnKeySet.has(key)) + .map(([key, value]) => { + const mapping = mappingsByKey.get(key); + if (mapping) { + const bgColor = getLabelColor(value, mapping); + return ( + + ); + } + return ; + })} + + + {showActions && ( + + + {allowedActions.includes('silence') && !alert.suppressed && ( + + onSilence(alert)}> + + + + )} + {allowedActions.includes('runbook') && runbookUrl && ( + + + + + + )} + + + )} + + ); +} + +function AlertGroupRow({ + group, + expanded, + allowedActions, + columnDefs, + columnKeySet, + labelColorMappings, + mappingsByKey, + onToggle, + onSilence, + duplicateCounts, + showDuplicates, +}: { + group: AlertGroup; + expanded: boolean; + allowedActions: AlertAction[]; + columnDefs: ColumnDefinition[]; + columnKeySet: Set; + labelColorMappings: LabelColorMapping[]; + mappingsByKey: Map; + onToggle: () => void; + onSilence: (alert: Alert) => void; + duplicateCounts: Map; + showDuplicates: boolean; +}): ReactElement { + const showActions = allowedActions.length > 0; + + return ( + <> + td': { fontWeight: 'bold' } }} onClick={onToggle}> + + + {expanded ? : } + + {group.key || 'Ungrouped'} + + + + + {columnDefs.map((col) => { + const counts = group.summary.labelCounts?.[col.name]; + const mapping = mappingsByKey.get(col.name); + if (counts && mapping) { + return ( + + + + ); + } + if (counts) { + return ( + + + {Object.entries(counts) + .sort(([, a], [, b]) => b - a) + .map(([value, count]) => ( + + ))} + + + ); + } + return ; + })} + {showDuplicates && } + + + + {labelColorMappings + .filter((mapping) => !columnKeySet.has(mapping.labelKey)) + .map((mapping) => ( + + ))} + + + {showActions && } + + {expanded && + group.alerts.map((alert, idx) => ( + + ))} + + ); +} + +/** + * Alert hierarchical table panel component. + * Displays alerts grouped by configurable labels with deduplication. + */ +export function AlertTablePanel({ spec, queryResults, contentDimensions }: AlertTablePanelProps): ReactElement { + const datasourceSelector = useMemo(() => extractDatasourceSelector(queryResults), [queryResults]); + const { data: amClient } = useDatasourceClient(datasourceSelector); + const queryClient = useQueryClient(); + + const [silenceTarget, setSilenceTarget] = useState(null); + const silenceKeyRef = useRef(0); + const handleSetSilenceTarget = useCallback((alert: Alert) => { + silenceKeyRef.current++; + setSilenceTarget(alert); + }, []); + const [search, setSearch] = useState(''); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const { successSnackbar, exceptionSnackbar } = useSnackbar(); + + const handleSilenceSubmit = useCallback( + async (silence: PostableSilence) => { + if (!amClient) return; + try { + await amClient.createSilence(silence); + setSilenceTarget(null); + successSnackbar('Silence created successfully'); + queryClient.invalidateQueries({ queryKey: ['query', 'AlertsQuery'] }); + queryClient.invalidateQueries({ queryKey: ['query', 'SilencesQuery'] }); + } catch (err) { + exceptionSnackbar(err); + } + }, + [amClient, queryClient, successSnackbar, exceptionSnackbar] + ); + + const silenceInitial = useMemo(() => { + if (!silenceTarget) return undefined; + return { + matchers: Object.entries(silenceTarget.labels).map(([name, value]) => ({ + name, + value, + isEqual: true, + isRegex: false, + })), + }; + }, [silenceTarget]); + + const defaultGroupBy = useMemo(() => spec.defaultGroupBy ?? ['alertname'], [spec.defaultGroupBy]); + const variableNames = useMemo( + () => [...new Set(defaultGroupBy.flatMap((entry) => parseVariables(entry)))], + [defaultGroupBy] + ); + const variableState = useAllVariableValues(variableNames); + + const resolvedDefaultGroupBy = useMemo(() => { + const result: string[] = []; + for (const entry of defaultGroupBy) { + const vars = parseVariables(entry); + if (vars.length === 0) { + result.push(entry); + } else if (vars.length === 1 && (entry === `$${vars[0]}` || entry === `\${${vars[0]}}`)) { + const varState = variableState[vars[0] ?? '']; + if (varState?.value) { + if (Array.isArray(varState.value)) { + result.push(...varState.value); + } else { + result.push(varState.value); + } + } + } else { + const resolved = replaceVariablesInString(entry, variableState); + if (resolved) result.push(resolved); + } + } + return result; + }, [defaultGroupBy, variableState]); + + const [groupBy, setGroupBy] = useState(resolvedDefaultGroupBy); + + useEffect(() => { + setGroupBy(resolvedDefaultGroupBy); + }, [resolvedDefaultGroupBy]); + + const effectiveActions = useMemo( + () => spec.allowedActions ?? ALL_ALERT_ACTIONS, + [spec.allowedActions] + ); + const showActionsColumn = effectiveActions.length > 0; + const columnDefs = useMemo(() => spec.columns ?? [], [spec.columns]); + const columnKeySet = useMemo(() => new Set(columnDefs.map((c) => c.name)), [columnDefs]); + const labelColorMappings = useMemo(() => spec.labelColorMappings ?? [], [spec.labelColorMappings]); + const labelKeys = useMemo(() => labelColorMappings.map((m) => m.labelKey), [labelColorMappings]); + const mappingsByKey = useMemo(() => new Map(labelColorMappings.map((m) => [m.labelKey, m])), [labelColorMappings]); + + const allTrackedKeys = useMemo( + () => [...new Set([...labelKeys, ...columnDefs.map((c) => c.name)])], + [labelKeys, columnDefs] + ); + + const initialSort = useMemo(() => { + const col = columnDefs.find((c) => c.sort && c.enableSorting !== false); + if (!col?.sort) return null; + return { columnName: col.name, direction: col.sort, mode: col.sortMode ?? 'alphabetical' }; + }, [columnDefs]); + const [sortState, setSortState] = useState(initialSort); + + const handleSortClick = useCallback((col: ColumnDefinition): void => { + setSortState((prev) => { + if (prev?.columnName === col.name) { + return prev.direction === 'asc' ? { ...prev, direction: 'desc' } : null; + } + return { columnName: col.name, direction: 'asc', mode: col.sortMode ?? 'alphabetical' }; + }); + }, []); + + // Flatten all alerts from all query results + const allAlerts = useMemo(() => { + return queryResults.flatMap((result) => result.data?.alerts ?? []); + }, [queryResults]); + + // Deduplicate + const dedupConfig = useMemo(() => spec.deduplication ?? { mode: 'fingerprint' as const }, [spec.deduplication]); + const dedupResult = useMemo(() => deduplicateAlerts(allAlerts, dedupConfig), [allAlerts, dedupConfig]); + const dedupAlerts = dedupResult.alerts; + const duplicateCounts = dedupResult.duplicateCounts; + const hasDuplicates = duplicateCounts.size > 0; + + // Filter by search term + const alerts = useMemo(() => { + const term = search.trim().toLowerCase(); + if (!term) return dedupAlerts; + return dedupAlerts.filter((a: Alert) => { + if (a.state.toLowerCase().includes(term)) return true; + if (a.suppressed && ('suppressed'.includes(term) || 'silenced'.includes(term))) return true; + if (Object.values(a.labels).some((v) => v.toLowerCase().includes(term))) return true; + if (a.annotations && Object.values(a.annotations).some((v) => v.toLowerCase().includes(term))) return true; + return false; + }); + }, [dedupAlerts, search]); + + // Extract available label keys for group-by selector + const availableLabelKeys = useMemo(() => extractLabelKeys(alerts), [alerts]); + + // Group alerts + const groups = useMemo(() => { + let result: AlertGroup[]; + + if (groupBy.length === 0) { + result = [{ key: '', alerts: [...alerts], summary: getGroupSummary(alerts, allTrackedKeys) }]; + } else { + const groupMap = new Map(); + for (const alert of alerts) { + const key = getGroupKey(alert, groupBy); + const existing = groupMap.get(key) ?? []; + existing.push(alert); + groupMap.set(key, existing); + } + + result = Array.from(groupMap.entries()).map(([key, groupAlerts]) => ({ + key, + alerts: groupAlerts, + summary: getGroupSummary(groupAlerts, allTrackedKeys), + })); + } + + if (sortState) { + for (const group of result) { + group.alerts.sort((a, b) => compareAlertsByColumn(a, b, sortState)); + } + result.sort((a, b) => + compareGroupsByColumn( + a.summary.labelCounts?.[sortState.columnName], + b.summary.labelCounts?.[sortState.columnName], + sortState + ) + ); + } + + return result; + }, [alerts, groupBy, allTrackedKeys, sortState]); + + const prevGroupKeysRef = useRef(''); + useEffect(() => { + const currentKeys = groups.map((g) => g.key).join('\0'); + if (currentKeys === prevGroupKeysRef.current) return; + prevGroupKeysRef.current = currentKeys; + + if (groups.length === 1) { + setExpandedGroups(new Set(groups.map((g) => g.key))); + } else { + setExpandedGroups(new Set()); + } + }, [groups]); + + const handleToggleGroup = useCallback((key: string) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, []); + + const handleExpandAll = useCallback(() => { + setExpandedGroups(new Set(groups.map((g) => g.key))); + }, [groups]); + + const handleCollapseAll = useCallback(() => { + setExpandedGroups(new Set()); + }, []); + + const handleGroupByChange = (event: SelectChangeEvent): void => { + const value = event.target.value; + setGroupBy(typeof value === 'string' ? value.split(',') : value); + }; + + if (alerts.length === 0) { + return ( + + No alerts to display + + ); + } + + return ( + + + setSearch(e.target.value)} + sx={{ minWidth: 200 }} + slotProps={{ + htmlInput: { 'aria-label': 'Search alerts' }, + input: { + startAdornment: ( + + + + ), + }, + }} + /> + + Group by + + + + {alerts.length} alert{alerts.length !== 1 ? 's' : ''} in {groups.length} group + {groups.length !== 1 ? 's' : ''} + + + + + + + + + + + + + + + + + + + Status + Alert Name + {columnDefs.map((col) => ( + + {col.enableSorting !== false ? ( + handleSortClick(col)} + > + {col.header ?? col.name} + + ) : ( + (col.header ?? col.name) + )} + + ))} + {hasDuplicates && Duplicates} + Labels + {showActionsColumn && Actions} + + + + {groups.map((group) => ( + handleToggleGroup(group.key)} + onSilence={handleSetSilenceTarget} + duplicateCounts={duplicateCounts} + showDuplicates={hasDuplicates} + /> + ))} + +
+
+ setSilenceTarget(null)} + onSubmit={handleSilenceSubmit} + initialSilence={silenceInitial} + /> +
+ ); +} diff --git a/alertmanager/src/plugins/alert-table/alert-table-model.test.ts b/alertmanager/src/plugins/alert-table/alert-table-model.test.ts new file mode 100644 index 000000000..a52f656cf --- /dev/null +++ b/alertmanager/src/plugins/alert-table/alert-table-model.test.ts @@ -0,0 +1,193 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Alert } from '@perses-dev/spec'; +import { deduplicateAlerts, extractLabelKeys, getGroupKey, getGroupSummary } from './alert-table-model'; + +const makeAlert = (overrides: Partial = {}): Alert => ({ + id: 'abc123', + name: 'TestAlert', + state: 'firing', + labels: { alertname: 'TestAlert', severity: 'critical' }, + annotations: {}, + severity: 'critical', + startsAt: '2024-01-01T00:00:00Z', + endsAt: '2024-01-01T01:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + receivers: [], + ...overrides, +}); + +describe('deduplicateAlerts', () => { + it('deduplicates alerts with the same fingerprint', () => { + const alerts: Alert[] = [ + makeAlert({ id: 'fp1', labels: { alertname: 'A', severity: 'critical' } }), + makeAlert({ id: 'fp1', labels: { alertname: 'A', severity: 'critical' } }), + makeAlert({ id: 'fp2', labels: { alertname: 'B', severity: 'warning' } }), + ]; + + const result = deduplicateAlerts(alerts, { mode: 'fingerprint' }); + expect(result.alerts).toHaveLength(2); + }); + + it('deduplicates by specified labels', () => { + const alerts: Alert[] = [ + makeAlert({ id: 'fp1', labels: { alertname: 'A', severity: 'critical', instance: 'a' } }), + makeAlert({ id: 'fp2', labels: { alertname: 'A', severity: 'critical', instance: 'b' } }), + makeAlert({ id: 'fp3', labels: { alertname: 'B', severity: 'warning', instance: 'a' } }), + ]; + + const result = deduplicateAlerts(alerts, { mode: 'labels', labels: ['alertname', 'severity'] }); + expect(result.alerts).toHaveLength(2); + }); + + it('returns all alerts when all fingerprints are unique', () => { + const alerts: Alert[] = [makeAlert({ id: 'fp1' }), makeAlert({ id: 'fp2' }), makeAlert({ id: 'fp3' })]; + + const result = deduplicateAlerts(alerts, { mode: 'fingerprint' }); + expect(result.alerts).toHaveLength(3); + expect(result.duplicateCounts.size).toBe(0); + }); + + it('returns empty result for empty input', () => { + const result = deduplicateAlerts([], { mode: 'fingerprint' }); + expect(result.alerts).toEqual([]); + expect(result.duplicateCounts.size).toBe(0); + }); + + it('deduplicates alerts with same fingerprint by falling back to labels', () => { + const alerts: Alert[] = [ + makeAlert({ id: '', labels: { alertname: 'A', severity: 'critical' } }), + makeAlert({ id: '', labels: { alertname: 'B', severity: 'warning' } }), + ]; + + const result = deduplicateAlerts(alerts, { mode: 'fingerprint' }); + expect(result.alerts).toHaveLength(2); + }); + + it('deduplicates alerts with identical labels when fingerprints are empty', () => { + const alerts: Alert[] = [ + makeAlert({ id: '', labels: { alertname: 'A', severity: 'critical' } }), + makeAlert({ id: '', labels: { alertname: 'A', severity: 'critical' } }), + ]; + + const result = deduplicateAlerts(alerts, { mode: 'fingerprint' }); + expect(result.alerts).toHaveLength(1); + }); + + it('tracks duplicate counts per alert', () => { + const alerts: Alert[] = [ + makeAlert({ id: 'fp1' }), + makeAlert({ id: 'fp1' }), + makeAlert({ id: 'fp1' }), + makeAlert({ id: 'fp2' }), + ]; + + const result = deduplicateAlerts(alerts, { mode: 'fingerprint' }); + expect(result.alerts).toHaveLength(2); + expect(result.duplicateCounts.get(result.alerts[0]!)).toBe(3); + expect(result.duplicateCounts.has(result.alerts[1]!)).toBe(false); + }); +}); + +describe('extractLabelKeys', () => { + it('extracts all unique label keys from alerts', () => { + const alerts: Alert[] = [ + makeAlert({ labels: { alertname: 'A', severity: 'critical' } }), + makeAlert({ labels: { alertname: 'B', instance: 'server-1' } }), + ]; + + const keys = extractLabelKeys(alerts); + expect(keys).toEqual(expect.arrayContaining(['alertname', 'severity', 'instance'])); + expect(keys).toHaveLength(3); + }); + + it('returns empty array for empty alerts', () => { + expect(extractLabelKeys([])).toEqual([]); + }); +}); + +describe('getGroupKey', () => { + it('builds group key from specified labels', () => { + const alert = makeAlert({ labels: { alertname: 'HighMemory', severity: 'critical', instance: 'a' } }); + const key = getGroupKey(alert, ['alertname', 'severity']); + expect(key).toBe('alertname=HighMemory,severity=critical'); + }); + + it('uses empty string for missing labels', () => { + const alert = makeAlert({ labels: { alertname: 'Test' } }); + const key = getGroupKey(alert, ['alertname', 'missing']); + expect(key).toBe('alertname=Test,missing='); + }); + + it('returns empty string for no group-by labels', () => { + const alert = makeAlert(); + const key = getGroupKey(alert, []); + expect(key).toBe(''); + }); +}); + +describe('getGroupSummary', () => { + it('counts alerts by state', () => { + const alerts: Alert[] = [ + makeAlert({ state: 'firing' }), + makeAlert({ state: 'firing' }), + makeAlert({ state: 'firing', suppressed: true }), + makeAlert({ state: 'pending' }), + ]; + + const summary = getGroupSummary(alerts); + expect(summary).toEqual({ + total: 4, + firing: 2, + suppressed: 1, + pending: 1, + }); + }); + + it('handles empty group', () => { + const summary = getGroupSummary([]); + expect(summary).toEqual({ + total: 0, + firing: 0, + suppressed: 0, + pending: 0, + }); + }); + + it('computes labelCounts when labelKeys are provided', () => { + const alerts: Alert[] = [ + makeAlert({ + state: 'firing', + labels: { alertname: 'A', severity: 'critical' }, + }), + makeAlert({ + state: 'firing', + labels: { alertname: 'B', severity: 'warning' }, + }), + makeAlert({ + state: 'firing', + suppressed: true, + labels: { alertname: 'A', severity: 'critical' }, + }), + ]; + + const summary = getGroupSummary(alerts, ['severity']); + expect(summary.total).toBe(3); + expect(summary.firing).toBe(2); + expect(summary.suppressed).toBe(1); + expect(summary.labelCounts).toEqual({ + severity: { critical: 2, warning: 1 }, + }); + }); +}); diff --git a/alertmanager/src/plugins/alert-table/alert-table-model.ts b/alertmanager/src/plugins/alert-table/alert-table-model.ts new file mode 100644 index 000000000..c375c06eb --- /dev/null +++ b/alertmanager/src/plugins/alert-table/alert-table-model.ts @@ -0,0 +1,187 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Alert } from '@perses-dev/spec'; + +/** + * Configuration for how alerts should be deduplicated across datasources. + */ +export interface AlertDeduplicationConfig { + mode: 'none' | 'fingerprint' | 'labels'; + labels?: string[]; +} + +export interface DeduplicationResult { + alerts: Alert[]; + duplicateCounts: Map; +} + +export type AlertAction = 'silence' | 'runbook'; + +export const ALL_ALERT_ACTIONS: AlertAction[] = ['silence', 'runbook']; + +export interface LabelColorOverride { + value: string; + isRegex: boolean; + color: string; +} + +export interface LabelColorMapping { + labelKey: string; + mode: 'auto' | 'severity' | 'manual'; + overrides?: LabelColorOverride[]; +} + +export type SortDirection = 'asc' | 'desc'; + +export type ColumnSortMode = 'alphabetical' | 'numeric' | 'severity'; + +export interface ColumnDefinition { + name: string; + header?: string; + enableSorting?: boolean; + sort?: SortDirection; + sortMode?: ColumnSortMode; +} + +/** + * Options for the AlertTable panel plugin. + */ +export interface AlertTableOptions { + defaultGroupBy?: string[]; + columns?: ColumnDefinition[]; + deduplication?: AlertDeduplicationConfig; + allowedActions?: AlertAction[]; + labelColorMappings?: LabelColorMapping[]; +} + +/** + * Summary of alert counts by state within a group. + */ +export interface GroupSummary { + total: number; + firing: number; + suppressed: number; + pending: number; + labelCounts?: Record>; +} + +/** + * Deduplicate alerts based on the configured deduplication mode. + * - 'fingerprint' (default): Uses the alert's fingerprint field. + * - 'labels': Uses a combination of specified label values. + */ +export function deduplicateAlerts(alerts: Alert[], config: AlertDeduplicationConfig): DeduplicationResult { + if (alerts.length === 0 || config.mode === 'none') return { alerts, duplicateCounts: new Map() }; + + const seen = new Map(); + const counts = new Map(); + const result: Alert[] = []; + + for (const alert of alerts) { + let key: string; + if (config.mode === 'labels' && config.labels) { + key = config.labels.map((l) => `${l}=${alert.labels[l] ?? ''}`).join(','); + } else if (alert.id) { + key = alert.id; + } else { + key = JSON.stringify(alert.labels); + } + + if (!seen.has(key)) { + seen.set(key, alert); + counts.set(key, 1); + result.push(alert); + } else { + counts.set(key, (counts.get(key) ?? 1) + 1); + } + } + + const duplicateCounts = new Map(); + for (const [key, alert] of seen) { + const count = counts.get(key) ?? 1; + if (count > 1) { + duplicateCounts.set(alert, count); + } + } + + return { alerts: result, duplicateCounts }; +} + +/** + * Extract all unique label keys from a list of alerts. + */ +export function extractLabelKeys(alerts: Alert[]): string[] { + const keys = new Set(); + for (const alert of alerts) { + for (const key of Object.keys(alert.labels)) { + keys.add(key); + } + } + return Array.from(keys).sort(); +} + +/** + * Build a group key string from an alert's labels and the specified group-by labels. + */ +export function getGroupKey(alert: Alert, groupBy: string[]): string { + return groupBy.map((label) => `${label}=${alert.labels[label] ?? ''}`).join(','); +} + +/** + * Compute a summary of alert counts by state for a group of alerts. + */ +export function getGroupSummary(alerts: Alert[], labelKeys?: string[]): GroupSummary { + const summary: GroupSummary = { total: 0, firing: 0, suppressed: 0, pending: 0 }; + const labelCounts: Record> = {}; + + if (labelKeys) { + for (const key of labelKeys) { + labelCounts[key] = {}; + } + } + + for (const alert of alerts) { + summary.total++; + if (alert.suppressed) { + summary.suppressed++; + } else { + switch (alert.state) { + case 'firing': + summary.firing++; + break; + case 'pending': + summary.pending++; + break; + } + } + + if (labelKeys) { + for (const key of labelKeys) { + const labelValue = alert.labels[key]; + if (labelValue !== undefined) { + const counts = labelCounts[key]; + if (counts) { + counts[labelValue] = (counts[labelValue] ?? 0) + 1; + } + } + } + } + } + + if (labelKeys && labelKeys.length > 0) { + summary.labelCounts = labelCounts; + } + + return summary; +} diff --git a/alertmanager/src/plugins/alert-table/alert-table-sorting.test.ts b/alertmanager/src/plugins/alert-table/alert-table-sorting.test.ts new file mode 100644 index 000000000..f3517ab4e --- /dev/null +++ b/alertmanager/src/plugins/alert-table/alert-table-sorting.test.ts @@ -0,0 +1,106 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Alert } from '@perses-dev/spec'; +import { compareAlertsByColumn, compareGroupsByColumn, SortState } from './alert-table-sorting'; + +const makeAlert = (labels: Record): Alert => ({ + id: 'fp', + name: labels['alertname'] ?? '', + state: 'firing', + labels, + annotations: {}, + startsAt: '2024-01-01T00:00:00Z', + endsAt: '2024-01-01T01:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + receivers: [], +}); + +describe('compareAlertsByColumn', () => { + it('sorts alphabetically ascending', () => { + const sort: SortState = { columnName: 'severity', direction: 'asc', mode: 'alphabetical' }; + const a = makeAlert({ severity: 'critical' }); + const b = makeAlert({ severity: 'warning' }); + + expect(compareAlertsByColumn(a, b, sort)).toBeLessThan(0); + }); + + it('sorts alphabetically descending', () => { + const sort: SortState = { columnName: 'severity', direction: 'desc', mode: 'alphabetical' }; + const a = makeAlert({ severity: 'critical' }); + const b = makeAlert({ severity: 'warning' }); + + expect(compareAlertsByColumn(a, b, sort)).toBeGreaterThan(0); + }); + + it('sorts numerically ascending', () => { + const sort: SortState = { columnName: 'count', direction: 'asc', mode: 'numeric' }; + const a = makeAlert({ count: '5' }); + const b = makeAlert({ count: '20' }); + + expect(compareAlertsByColumn(a, b, sort)).toBeLessThan(0); + }); + + it('sorts numerically descending', () => { + const sort: SortState = { columnName: 'count', direction: 'desc', mode: 'numeric' }; + const a = makeAlert({ count: '5' }); + const b = makeAlert({ count: '20' }); + + expect(compareAlertsByColumn(a, b, sort)).toBeGreaterThan(0); + }); + + it('sorts by severity weight ascending (critical before warning)', () => { + const sort: SortState = { columnName: 'severity', direction: 'asc', mode: 'severity' }; + const a = makeAlert({ severity: 'critical' }); + const b = makeAlert({ severity: 'warning' }); + + expect(compareAlertsByColumn(a, b, sort)).toBeLessThan(0); + }); + + it('sorts by severity weight descending (warning before critical)', () => { + const sort: SortState = { columnName: 'severity', direction: 'desc', mode: 'severity' }; + const a = makeAlert({ severity: 'critical' }); + const b = makeAlert({ severity: 'warning' }); + + expect(compareAlertsByColumn(a, b, sort)).toBeGreaterThan(0); + }); + + it('pushes undefined values to the end', () => { + const sort: SortState = { columnName: 'severity', direction: 'asc', mode: 'alphabetical' }; + const a = makeAlert({}); + const b = makeAlert({ severity: 'warning' }); + + expect(compareAlertsByColumn(a, b, sort)).toBeGreaterThan(0); + }); +}); + +describe('compareGroupsByColumn', () => { + it('returns 0 for two empty counts', () => { + const sort: SortState = { columnName: 'severity', direction: 'asc', mode: 'alphabetical' }; + expect(compareGroupsByColumn(undefined, undefined, sort)).toBe(0); + }); + + it('pushes empty counts to the end', () => { + const sort: SortState = { columnName: 'severity', direction: 'asc', mode: 'alphabetical' }; + expect(compareGroupsByColumn(undefined, { critical: 1 }, sort)).toBeGreaterThan(0); + expect(compareGroupsByColumn({ critical: 1 }, undefined, sort)).toBeLessThan(0); + }); + + it('sorts by severity mode using most critical value', () => { + const sort: SortState = { columnName: 'severity', direction: 'asc', mode: 'severity' }; + const aCounts = { critical: 2 }; + const bCounts = { warning: 5 }; + + expect(compareGroupsByColumn(aCounts, bCounts, sort)).toBeLessThan(0); + }); +}); diff --git a/alertmanager/src/plugins/alert-table/alert-table-sorting.ts b/alertmanager/src/plugins/alert-table/alert-table-sorting.ts new file mode 100644 index 000000000..2d07bdd57 --- /dev/null +++ b/alertmanager/src/plugins/alert-table/alert-table-sorting.ts @@ -0,0 +1,145 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Alert } from '@perses-dev/spec'; +import { ColumnSortMode, SortDirection } from './alert-table-model'; +import { getSeverityWeight, SEVERITY_ORDER } from './label-colors'; + +export interface SortState { + columnName: string; + direction: SortDirection; + mode: ColumnSortMode; +} + +function directionMultiplier(direction: SortDirection): number { + return direction === 'asc' ? 1 : -1; +} + +function compareAlphabetical(a: string | undefined, b: string | undefined): number { + if (a === undefined && b === undefined) return 0; + if (a === undefined) return 1; + if (b === undefined) return -1; + return a.localeCompare(b); +} + +function compareNumeric(a: string | undefined, b: string | undefined): number { + const na = a !== undefined ? parseFloat(a) : NaN; + const nb = b !== undefined ? parseFloat(b) : NaN; + if (isNaN(na) && isNaN(nb)) return 0; + if (isNaN(na)) return 1; + if (isNaN(nb)) return -1; + return na - nb; +} + +function compareSeverity(a: string | undefined, b: string | undefined): number { + const wa = a !== undefined ? getSeverityWeight(a) : SEVERITY_ORDER.length; + const wb = b !== undefined ? getSeverityWeight(b) : SEVERITY_ORDER.length; + return wa - wb; +} + +export function compareAlertsByColumn(a: Alert, b: Alert, sort: SortState): number { + const va = a.labels[sort.columnName]; + const vb = b.labels[sort.columnName]; + let result: number; + + switch (sort.mode) { + case 'numeric': + result = compareNumeric(va, vb); + break; + case 'severity': + result = compareSeverity(va, vb); + break; + case 'alphabetical': + default: + result = compareAlphabetical(va, vb); + break; + } + + return result * directionMultiplier(sort.direction); +} + +function getMostFrequentValue(counts: Record): { value: string | undefined; count: number } { + let maxCount = 0; + let maxValue: string | undefined; + for (const [value, count] of Object.entries(counts)) { + if (count > maxCount) { + maxCount = count; + maxValue = value; + } + } + return { value: maxValue, count: maxCount }; +} + +function getMostCriticalSeverity(counts: Record): { weight: number; count: number } { + let minWeight = SEVERITY_ORDER.length; + let countAtMin = 0; + for (const [value, count] of Object.entries(counts)) { + const weight = getSeverityWeight(value); + if (weight < minWeight) { + minWeight = weight; + countAtMin = count; + } else if (weight === minWeight) { + countAtMin += count; + } + } + return { weight: minWeight, count: countAtMin }; +} + +export function compareGroupsByColumn( + aCounts: Record | undefined, + bCounts: Record | undefined, + sort: SortState +): number { + const emptyA = !aCounts || Object.keys(aCounts).length === 0; + const emptyB = !bCounts || Object.keys(bCounts).length === 0; + if (emptyA && emptyB) return 0; + if (emptyA) return 1; + if (emptyB) return -1; + + let result: number; + + switch (sort.mode) { + case 'severity': { + const sevA = getMostCriticalSeverity(aCounts); + const sevB = getMostCriticalSeverity(bCounts); + result = sevA.weight - sevB.weight; + if (result === 0) { + result = sevB.count - sevA.count; + } + break; + } + case 'numeric': { + const freqA = getMostFrequentValue(aCounts); + const freqB = getMostFrequentValue(bCounts); + const numResult = compareNumeric(freqA.value, freqB.value); + if (numResult !== 0) { + return numResult * directionMultiplier(sort.direction); + } + // Tie-break by count is always descending (larger groups first) regardless of sort direction + return freqB.count - freqA.count; + } + case 'alphabetical': + default: { + const freqA = getMostFrequentValue(aCounts); + const freqB = getMostFrequentValue(bCounts); + const alphaResult = compareAlphabetical(freqA.value, freqB.value); + if (alphaResult !== 0) { + return alphaResult * directionMultiplier(sort.direction); + } + // Tie-break by count is always descending (larger groups first) regardless of sort direction + return freqB.count - freqA.count; + } + } + + return result * directionMultiplier(sort.direction); +} diff --git a/alertmanager/src/plugins/alert-table/label-colors.test.ts b/alertmanager/src/plugins/alert-table/label-colors.test.ts new file mode 100644 index 000000000..007a05ae2 --- /dev/null +++ b/alertmanager/src/plugins/alert-table/label-colors.test.ts @@ -0,0 +1,102 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { getSeverityWeight, hashStringToColor, getLabelColor, SEVERITY_ORDER } from './label-colors'; + +describe('getSeverityWeight', () => { + it('returns correct weight for known severity levels', () => { + expect(getSeverityWeight('critical')).toBe(0); + expect(getSeverityWeight('error')).toBe(1); + expect(getSeverityWeight('warning')).toBe(2); + expect(getSeverityWeight('info')).toBe(3); + }); + + it('handles abbreviations case-insensitively', () => { + expect(getSeverityWeight('CRITICAL')).toBe(0); + expect(getSeverityWeight('Warn')).toBe(2); + expect(getSeverityWeight('crit')).toBe(0); + expect(getSeverityWeight('fatal')).toBe(0); + }); + + it('returns max weight for unknown values', () => { + expect(getSeverityWeight('banana')).toBe(SEVERITY_ORDER.length); + }); +}); + +describe('hashStringToColor', () => { + it('returns a valid HSL color string', () => { + const color = hashStringToColor('test'); + expect(color).toMatch(/^hsl\(\d+, \d+%, \d+%\)$/); + }); + + it('returns the same color for the same input', () => { + expect(hashStringToColor('foo')).toBe(hashStringToColor('foo')); + }); + + it('returns different colors for different inputs', () => { + expect(hashStringToColor('foo')).not.toBe(hashStringToColor('bar')); + }); +}); + +describe('getLabelColor', () => { + it('returns severity color for severity mode', () => { + const color = getLabelColor('critical', { labelKey: 'severity', mode: 'severity' }); + expect(color).toBe('#d32f2f'); + }); + + it('returns hash color for auto mode', () => { + const color = getLabelColor('my-value', { labelKey: 'env', mode: 'auto' }); + expect(color).toMatch(/^hsl\(/); + }); + + it('returns fallback for manual mode without override match', () => { + const color = getLabelColor('unmatched', { labelKey: 'env', mode: 'manual' }); + expect(color).toBe('#9e9e9e'); + }); + + it('applies overrides before mode-based color', () => { + const color = getLabelColor('prod', { + labelKey: 'env', + mode: 'auto', + overrides: [{ value: 'prod', isRegex: false, color: '#ff0000' }], + }); + expect(color).toBe('#ff0000'); + }); + + it('supports regex overrides', () => { + const color = getLabelColor('staging-us-east', { + labelKey: 'env', + mode: 'auto', + overrides: [{ value: '^staging', isRegex: true, color: '#00ff00' }], + }); + expect(color).toBe('#00ff00'); + }); + + it('falls through to mode color when regex override does not match', () => { + const color = getLabelColor('production', { + labelKey: 'env', + mode: 'severity', + overrides: [{ value: '^staging', isRegex: true, color: '#00ff00' }], + }); + expect(color).not.toBe('#00ff00'); + }); + + it('handles invalid regex gracefully', () => { + const color = getLabelColor('test', { + labelKey: 'env', + mode: 'auto', + overrides: [{ value: '[invalid', isRegex: true, color: '#00ff00' }], + }); + expect(color).toMatch(/^hsl\(/); + }); +}); diff --git a/alertmanager/src/plugins/alert-table/label-colors.ts b/alertmanager/src/plugins/alert-table/label-colors.ts new file mode 100644 index 000000000..703e93bd4 --- /dev/null +++ b/alertmanager/src/plugins/alert-table/label-colors.ts @@ -0,0 +1,116 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { LabelColorMapping } from './alert-table-model'; + +const SEVERITY_LEVEL_COLORS: Record = { + critical: '#d32f2f', + error: '#ef6c00', + warning: '#f9a825', + info: '#0288d1', + debug: '#7b1fa2', + trace: '#6a1b9a', + unknown: '#757575', + other: '#9e9e9e', +}; + +const SEVERITY_ABBREVIATIONS: Record = { + critical: ['critical', 'crit', 'fatal', 'emerg', 'alert', 'page'], + error: ['error', 'err', 'eror', 'high', 'severe'], + warning: ['warning', 'warn', 'medium'], + info: ['info', 'inf', 'information', 'informational', 'notice', 'low'], + debug: ['debug', 'dbug'], + trace: ['trace'], + unknown: ['unknown'], + other: ['other', 'none', 'minor', ''], +}; + +const SEVERITY_COLORS: Record = {}; +for (const [level, abbrevs] of Object.entries(SEVERITY_ABBREVIATIONS)) { + const color = SEVERITY_LEVEL_COLORS[level]!; + for (const abbrev of abbrevs) { + SEVERITY_COLORS[abbrev] = color; + } +} + +export const SEVERITY_ORDER: string[] = ['critical', 'error', 'warning', 'info', 'debug', 'trace', 'unknown', 'other']; + +export function getSeverityWeight(value: string): number { + const normalized = value.toLowerCase(); + for (const [level, abbrevs] of Object.entries(SEVERITY_ABBREVIATIONS)) { + if (abbrevs.includes(normalized)) { + const idx = SEVERITY_ORDER.indexOf(level); + return idx === -1 ? SEVERITY_ORDER.length : idx; + } + } + return SEVERITY_ORDER.length; +} + +const MANUAL_FALLBACK = '#9e9e9e'; +const ERROR_HUE_CUTOFF = 20; + +export function hashStringToColor(value: string): string { + let hash = 5381; + for (let i = 0; i < value.length; i++) { + hash = (hash * 33) ^ value.charCodeAt(i); + } + hash = Math.abs(hash); + + const hue = ERROR_HUE_CUTOFF + (hash % (360 - ERROR_HUE_CUTOFF)); + const saturation = 55 + (hash % 25); + const lightness = 45 + (hash % 15); + return `hsl(${hue}, ${saturation}%, ${lightness}%)`; +} + +const MAX_REGEX_CACHE_SIZE = 1000; +const regexCache = new Map(); + +function getCompiledRegex(pattern: string): RegExp | null { + if (regexCache.has(pattern)) return regexCache.get(pattern)!; + if (regexCache.size >= MAX_REGEX_CACHE_SIZE) regexCache.clear(); + try { + const re = new RegExp(pattern); + regexCache.set(pattern, re); + return re; + } catch { + regexCache.set(pattern, null); + return null; + } +} + +function matchesOverride(labelValue: string, pattern: string, isRegex: boolean): boolean { + if (isRegex) { + const re = getCompiledRegex(pattern); + return re !== null && re.test(labelValue); + } + return labelValue === pattern; +} + +export function getLabelColor(value: string, mapping: LabelColorMapping): string { + if (mapping.overrides) { + for (const override of mapping.overrides) { + if (matchesOverride(value, override.value, override.isRegex)) { + return override.color; + } + } + } + + switch (mapping.mode) { + case 'severity': + return SEVERITY_COLORS[value.toLowerCase()] ?? hashStringToColor(value); + case 'auto': + return hashStringToColor(value); + case 'manual': + return MANUAL_FALLBACK; + } +} diff --git a/alertmanager/src/plugins/alertmanager-alerts-query/AlertManagerAlertsQuery.ts b/alertmanager/src/plugins/alertmanager-alerts-query/AlertManagerAlertsQuery.ts new file mode 100644 index 000000000..271bb4f32 --- /dev/null +++ b/alertmanager/src/plugins/alertmanager-alerts-query/AlertManagerAlertsQuery.ts @@ -0,0 +1,38 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AlertsQueryPlugin, isVariableDatasource, parseVariables } from '@perses-dev/plugin-system'; +import { AlertManagerAlertsQuerySpec } from '../types'; +import { getAlertsData } from './get-alerts-data'; +import { AlertManagerAlertsQueryEditor } from './AlertManagerAlertsQueryEditor'; + +export const AlertManagerAlertsQuery: AlertsQueryPlugin = { + getAlertsData, + OptionsEditorComponent: AlertManagerAlertsQueryEditor, + createInitialOptions: (): AlertManagerAlertsQuerySpec => ({ + datasource: undefined, + filters: [], + active: true, + silenced: false, + inhibited: false, + receiver: undefined, + }), + dependsOn: (spec: AlertManagerAlertsQuerySpec) => { + const datasourceVariables = isVariableDatasource(spec.datasource) ? parseVariables(spec.datasource ?? '') : []; + const filterVariables = spec.filters?.flatMap((f) => parseVariables(f)) ?? []; + const receiverVariables = spec.receiver ? parseVariables(spec.receiver) : []; + return { + variables: [...new Set([...datasourceVariables, ...filterVariables, ...receiverVariables])], + }; + }, +}; diff --git a/alertmanager/src/plugins/alertmanager-alerts-query/AlertManagerAlertsQueryEditor.tsx b/alertmanager/src/plugins/alertmanager-alerts-query/AlertManagerAlertsQueryEditor.tsx new file mode 100644 index 000000000..2ddfd4f46 --- /dev/null +++ b/alertmanager/src/plugins/alertmanager-alerts-query/AlertManagerAlertsQueryEditor.tsx @@ -0,0 +1,154 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Checkbox, FormControl, FormControlLabel, Stack } from '@mui/material'; +import { useId } from '@perses-dev/components'; +import { DatasourceSelect, DatasourceSelectProps } from '@perses-dev/plugin-system'; +import { produce } from 'immer'; +import { ReactElement, useCallback } from 'react'; +import { LazyTextField } from '../../components/LazyTextField'; +import { + ALERTMANAGER_DATASOURCE_KIND, + DEFAULT_ALERTMANAGER, + isAlertManagerDatasourceSelector, + isDefaultAlertManagerSelector, +} from '../../model'; +import { AlertManagerAlertsQuerySpec } from '../types'; + +interface AlertManagerAlertsQueryEditorProps { + value: AlertManagerAlertsQuerySpec; + onChange: (next: AlertManagerAlertsQuerySpec) => void; +} + +export function AlertManagerAlertsQueryEditor(props: AlertManagerAlertsQueryEditorProps): ReactElement { + const { onChange, value } = props; + const datasourceSelectValue = value.datasource ?? DEFAULT_ALERTMANAGER; + const datasourceSelectLabelID = useId('alertmanager-datasource-label'); + + const handleDatasourceChange: DatasourceSelectProps['onChange'] = (next) => { + if (isAlertManagerDatasourceSelector(next)) { + onChange( + produce(value, (draft) => { + draft.datasource = isDefaultAlertManagerSelector(next) ? undefined : next; + }) + ); + return; + } + + throw new Error('Got unexpected non-AlertManager datasource selector'); + }; + + const handleFiltersChange = useCallback( + (filtersText: string): void => { + onChange( + produce(value, (draft) => { + draft.filters = filtersText + .split('\n') + .map((f) => f.trim()) + .filter((f) => f !== ''); + }) + ); + }, + [onChange, value] + ); + + const handleBooleanChange = useCallback( + (field: 'active' | 'silenced' | 'inhibited' | 'unprocessed', checked: boolean): void => { + onChange( + produce(value, (draft) => { + draft[field] = checked; + }) + ); + }, + [onChange, value] + ); + + const handleReceiverChange = useCallback( + (receiver: string): void => { + onChange( + produce(value, (draft) => { + draft.receiver = receiver.trim() === '' ? undefined : receiver.trim(); + }) + ); + }, + [onChange, value] + ); + + return ( + + + + + + handleBooleanChange('active', e.target.checked)} + /> + } + label="Active" + /> + handleBooleanChange('silenced', e.target.checked)} + /> + } + label="Silenced" + /> + handleBooleanChange('inhibited', e.target.checked)} + /> + } + label="Inhibited" + /> + handleBooleanChange('unprocessed', e.target.checked)} + /> + } + label="Unprocessed" + /> + + + + + ); +} diff --git a/alertmanager/src/plugins/alertmanager-alerts-query/get-alerts-data.test.ts b/alertmanager/src/plugins/alertmanager-alerts-query/get-alerts-data.test.ts new file mode 100644 index 000000000..619e514f9 --- /dev/null +++ b/alertmanager/src/plugins/alertmanager-alerts-query/get-alerts-data.test.ts @@ -0,0 +1,159 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AlertsQueryContext } from '@perses-dev/plugin-system'; +import { AlertManagerClient, GettableAlert } from '../../model'; +import { AlertManagerDatasource } from '../alertmanager-datasource'; +import { AlertManagerAlertsQuerySpec } from '../types'; +import { getAlertsData } from './get-alerts-data'; + +const datasource = { directUrl: 'http://am.example' }; + +const mockAlerts: GettableAlert[] = [ + { + labels: { alertname: 'HighMemory', severity: 'critical', instance: 'server-1' }, + annotations: { summary: 'Memory usage is above 90%' }, + startsAt: '2024-01-01T00:00:00Z', + endsAt: '2024-01-01T01:00:00Z', + fingerprint: 'abc123', + status: { state: 'active' as const, silencedBy: [], inhibitedBy: [], mutedBy: [] }, + receivers: [{ name: 'default' }], + updatedAt: '2024-01-01T00:00:00Z', + generatorURL: 'http://prometheus.example/graph?...', + }, + { + labels: { alertname: 'HighCPU', severity: 'warning', instance: 'server-2' }, + annotations: { summary: 'CPU usage is above 80%' }, + startsAt: '2024-01-01T00:30:00Z', + endsAt: '2024-01-01T01:30:00Z', + fingerprint: 'def456', + status: { state: 'suppressed' as const, silencedBy: ['silence-1'], inhibitedBy: [], mutedBy: [] }, + receivers: [{ name: 'team-a' }], + updatedAt: '2024-01-01T00:30:00Z', + }, +]; + +const makeClient = (): AlertManagerClient => { + const client = AlertManagerDatasource.createClient(datasource, {}); + client.getAlerts = jest.fn(async () => mockAlerts); + return client; +}; + +function createContext(client: AlertManagerClient): AlertsQueryContext { + return { + variableState: {}, + datasourceStore: { + getDatasource: jest.fn(), + getDatasourceClient: jest.fn(() => + Promise.resolve(client) + ) as AlertsQueryContext['datasourceStore']['getDatasourceClient'], + listDatasourceSelectItems: jest.fn(async () => []), + getLocalDatasources: jest.fn(), + setLocalDatasources: jest.fn(), + getSavedDatasources: jest.fn(), + setSavedDatasources: jest.fn(), + }, + }; +} + +describe('getAlertsData', () => { + it('transforms API alerts into AlertsData format', async () => { + const client = makeClient(); + const spec: AlertManagerAlertsQuerySpec = {}; + const context = createContext(client); + + const result = await getAlertsData(spec, context as unknown as Parameters[1]); + + expect(result.alerts).toHaveLength(2); + expect(result.alerts[0]).toEqual({ + id: 'abc123', + name: 'HighMemory', + state: 'firing', + labels: { alertname: 'HighMemory', severity: 'critical', instance: 'server-1' }, + annotations: { summary: 'Memory usage is above 90%' }, + severity: 'critical', + startsAt: '2024-01-01T00:00:00Z', + endsAt: '2024-01-01T01:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + sourceURL: 'http://prometheus.example/graph?...', + receivers: ['default'], + }); + }); + + it('maps suppressed alerts with silencedBy info', async () => { + const client = makeClient(); + const spec: AlertManagerAlertsQuerySpec = {}; + const context = createContext(client); + + const result = await getAlertsData(spec, context as unknown as Parameters[1]); + + expect(result.alerts[1]?.suppressed).toBe(true); + expect(result.alerts[1]?.state).toBe('firing'); + expect(result.alerts[1]?.suppressedBy).toEqual([{ type: 'silence', id: 'silence-1' }]); + }); + + it('passes query parameters to the client', async () => { + const client = makeClient(); + const spec: AlertManagerAlertsQuerySpec = { + filters: ['alertname="HighMemory"'], + active: true, + silenced: false, + inhibited: false, + receiver: 'team-a', + }; + const context = createContext(client); + + await getAlertsData(spec, context as unknown as Parameters[1]); + + expect(client.getAlerts).toHaveBeenCalledWith({ + filter: ['alertname="HighMemory"'], + active: true, + silenced: false, + inhibited: false, + receiver: 'team-a', + }); + }); + + it('returns empty alerts array when API returns empty', async () => { + const client = makeClient(); + client.getAlerts = jest.fn(async () => []); + const spec: AlertManagerAlertsQuerySpec = {}; + const context = createContext(client); + + const result = await getAlertsData(spec, context as unknown as Parameters[1]); + + expect(result.alerts).toEqual([]); + }); + + it('interpolates variables in filters and receiver', async () => { + const client = makeClient(); + const spec: AlertManagerAlertsQuerySpec = { + filters: ['team="$team"'], + receiver: '$receiver', + }; + const context = createContext(client); + context.variableState = { + team: { value: 'ops', loading: false }, + receiver: { value: 'slack-ops', loading: false }, + }; + + await getAlertsData(spec, context as unknown as Parameters[1]); + + expect(client.getAlerts).toHaveBeenCalledWith( + expect.objectContaining({ + filter: ['team="ops"'], + receiver: 'slack-ops', + }) + ); + }); +}); diff --git a/alertmanager/src/plugins/alertmanager-alerts-query/get-alerts-data.ts b/alertmanager/src/plugins/alertmanager-alerts-query/get-alerts-data.ts new file mode 100644 index 000000000..5dbb9f05a --- /dev/null +++ b/alertmanager/src/plugins/alertmanager-alerts-query/get-alerts-data.ts @@ -0,0 +1,139 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AlertsQueryContext, datasourceSelectValueToSelector, replaceVariables } from '@perses-dev/plugin-system'; +import { Alert, AlertState, AlertsData, SuppressionRule } from '@perses-dev/spec'; +import { ALERTMANAGER_DATASOURCE_KIND, AlertManagerClient, DEFAULT_ALERTMANAGER, GettableAlert } from '../../model'; +import { AlertManagerAlertsQuerySpec } from '../types'; + +/** + * Map Alertmanager API alert state to the generic AlertState. + * + * AM `active` → generic `firing` + * AM `suppressed` → generic `firing` (with `suppressed: true`) + * AM `unprocessed` → generic `pending` + */ +function mapAlertState(amState: 'unprocessed' | 'active' | 'suppressed'): AlertState { + switch (amState) { + case 'active': + case 'suppressed': + return 'firing'; + case 'unprocessed': + return 'pending'; + } +} + +/** + * Build SuppressionRule entries from the Alertmanager status fields. + */ +function buildSuppressionRules(status: GettableAlert['status']): SuppressionRule[] { + const rules: SuppressionRule[] = []; + for (const id of status.silencedBy) { + rules.push({ type: 'silence', id }); + } + for (const id of status.inhibitedBy) { + rules.push({ type: 'inhibition', id }); + } + for (const id of status.mutedBy) { + rules.push({ type: 'mute', id }); + } + return rules; +} + +/** + * Transform a GettableAlert from the Alertmanager API into our normalized Alert format. + */ +function transformAlert(apiAlert: GettableAlert): Alert { + const isSuppressed = apiAlert.status.state === 'suppressed'; + const suppressionRules = buildSuppressionRules(apiAlert.status); + + const alert: Alert = { + id: apiAlert.fingerprint, + name: apiAlert.labels['alertname'] ?? '', + state: mapAlertState(apiAlert.status.state), + labels: apiAlert.labels, + annotations: apiAlert.annotations, + startsAt: apiAlert.startsAt, + endsAt: apiAlert.endsAt, + updatedAt: apiAlert.updatedAt, + receivers: apiAlert.receivers.map((r) => r.name), + }; + + if (apiAlert.labels['severity']) { + alert.severity = apiAlert.labels['severity']; + } + + if (apiAlert.generatorURL) { + alert.sourceURL = apiAlert.generatorURL; + } + + if (isSuppressed) { + alert.suppressed = true; + } + + if (suppressionRules.length > 0) { + alert.suppressedBy = suppressionRules; + } + + return alert; +} + +/** + * Get alerts data from Alert Manager, transforming the API response into the plugin-system AlertsData format. + */ +export async function getAlertsData( + spec: AlertManagerAlertsQuerySpec, + context: AlertsQueryContext +): Promise { + const listDatasourceSelectItems = + await context.datasourceStore.listDatasourceSelectItems(ALERTMANAGER_DATASOURCE_KIND); + const datasourceSelector = + datasourceSelectValueToSelector(spec.datasource, context.variableState, listDatasourceSelectItems) ?? + DEFAULT_ALERTMANAGER; + const client = await context.datasourceStore.getDatasourceClient(datasourceSelector); + + const interpolatedFilters = spec.filters?.map((f) => replaceVariables(f, context.variableState)); + const interpolatedReceiver = spec.receiver ? replaceVariables(spec.receiver, context.variableState) : undefined; + + const apiAlerts = await client.getAlerts({ + filter: interpolatedFilters, + active: spec.active, + silenced: spec.silenced, + inhibited: spec.inhibited, + unprocessed: spec.unprocessed, + receiver: interpolatedReceiver, + }); + + const alerts = apiAlerts.map(transformAlert); + + return { + alerts, + metadata: { + executedQueryString: buildQueryString(spec), + }, + }; +} + +function buildQueryString(spec: AlertManagerAlertsQuerySpec): string { + return JSON.stringify( + Object.fromEntries( + Object.entries({ + filters: spec.filters, + active: spec.active, + silenced: spec.silenced, + inhibited: spec.inhibited, + receiver: spec.receiver, + }).filter(([, value]) => value !== undefined) + ) + ); +} diff --git a/alertmanager/src/plugins/alertmanager-datasource.tsx b/alertmanager/src/plugins/alertmanager-datasource.tsx new file mode 100644 index 000000000..ee858da76 --- /dev/null +++ b/alertmanager/src/plugins/alertmanager-datasource.tsx @@ -0,0 +1,58 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { DatasourcePlugin } from '@perses-dev/plugin-system'; +import { + AlertManagerClient, + getAlerts, + getSilences, + getSilence, + createSilence, + deleteSilence, + getStatus, +} from '../model'; +import { AlertManagerDatasourceSpec } from './types'; +import { AlertManagerDatasourceEditor } from './AlertManagerDatasourceEditor'; + +const createClient: DatasourcePlugin['createClient'] = ( + spec, + options +) => { + const { directUrl, proxy } = spec; + const { proxyUrl } = options; + + const datasourceUrl = directUrl ?? proxyUrl; + if (datasourceUrl === undefined) { + throw new Error('No URL specified for Alert Manager client. You can use directUrl in the spec to configure it.'); + } + + const specHeaders = proxy?.spec.headers; + + return { + options: { + datasourceUrl, + }, + getAlerts: (params, headers) => getAlerts(params, { datasourceUrl, headers: headers ?? specHeaders }), + getSilences: (params, headers) => getSilences(params, { datasourceUrl, headers: headers ?? specHeaders }), + getSilence: (id, headers) => getSilence(id, { datasourceUrl, headers: headers ?? specHeaders }), + createSilence: (silence, headers) => createSilence(silence, { datasourceUrl, headers: headers ?? specHeaders }), + deleteSilence: (id, headers) => deleteSilence(id, { datasourceUrl, headers: headers ?? specHeaders }), + getStatus: (headers) => getStatus({ datasourceUrl, headers: headers ?? specHeaders }), + }; +}; + +export const AlertManagerDatasource: DatasourcePlugin = { + createClient, + OptionsEditorComponent: AlertManagerDatasourceEditor, + createInitialOptions: () => ({ directUrl: '' }), +}; diff --git a/alertmanager/src/plugins/alertmanager-silences-query/AlertManagerSilencesQuery.ts b/alertmanager/src/plugins/alertmanager-silences-query/AlertManagerSilencesQuery.ts new file mode 100644 index 000000000..542986368 --- /dev/null +++ b/alertmanager/src/plugins/alertmanager-silences-query/AlertManagerSilencesQuery.ts @@ -0,0 +1,33 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { isVariableDatasource, parseVariables, SilencesQueryPlugin } from '@perses-dev/plugin-system'; +import { AlertManagerSilencesQuerySpec } from '../types'; +import { getSilencesData } from './get-silences-data'; +import { AlertManagerSilencesQueryEditor } from './AlertManagerSilencesQueryEditor'; + +export const AlertManagerSilencesQuery: SilencesQueryPlugin = { + getSilencesData, + OptionsEditorComponent: AlertManagerSilencesQueryEditor, + createInitialOptions: (): AlertManagerSilencesQuerySpec => ({ + datasource: undefined, + filters: [], + }), + dependsOn: (spec: AlertManagerSilencesQuerySpec) => { + const datasourceVariables = isVariableDatasource(spec.datasource) ? parseVariables(spec.datasource ?? '') : []; + const filterVariables = spec.filters?.flatMap((f) => parseVariables(f)) ?? []; + return { + variables: [...new Set([...datasourceVariables, ...filterVariables])], + }; + }, +}; diff --git a/alertmanager/src/plugins/alertmanager-silences-query/AlertManagerSilencesQueryEditor.tsx b/alertmanager/src/plugins/alertmanager-silences-query/AlertManagerSilencesQueryEditor.tsx new file mode 100644 index 000000000..ce30c3caf --- /dev/null +++ b/alertmanager/src/plugins/alertmanager-silences-query/AlertManagerSilencesQueryEditor.tsx @@ -0,0 +1,88 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { FormControl, Stack } from '@mui/material'; +import { useId } from '@perses-dev/components'; +import { DatasourceSelect, DatasourceSelectProps } from '@perses-dev/plugin-system'; +import { produce } from 'immer'; +import { ReactElement, useCallback } from 'react'; +import { LazyTextField } from '../../components/LazyTextField'; +import { + ALERTMANAGER_DATASOURCE_KIND, + DEFAULT_ALERTMANAGER, + isAlertManagerDatasourceSelector, + isDefaultAlertManagerSelector, +} from '../../model'; +import { AlertManagerSilencesQuerySpec } from '../types'; + +interface AlertManagerSilencesQueryEditorProps { + value: AlertManagerSilencesQuerySpec; + onChange: (next: AlertManagerSilencesQuerySpec) => void; +} + +export function AlertManagerSilencesQueryEditor(props: AlertManagerSilencesQueryEditorProps): ReactElement { + const { onChange, value } = props; + const datasourceSelectValue = value.datasource ?? DEFAULT_ALERTMANAGER; + const datasourceSelectLabelID = useId('alertmanager-silences-datasource-label'); + + const handleDatasourceChange: DatasourceSelectProps['onChange'] = (next) => { + if (isAlertManagerDatasourceSelector(next)) { + onChange( + produce(value, (draft) => { + draft.datasource = isDefaultAlertManagerSelector(next) ? undefined : next; + }) + ); + return; + } + + throw new Error('Got unexpected non-AlertManager datasource selector'); + }; + + const handleFiltersChange = useCallback( + (filtersText: string): void => { + onChange( + produce(value, (draft) => { + draft.filters = filtersText + .split('\n') + .map((f) => f.trim()) + .filter((f) => f !== ''); + }) + ); + }, + [onChange, value] + ); + + return ( + + + + + + + ); +} diff --git a/alertmanager/src/plugins/alertmanager-silences-query/get-silences-data.test.ts b/alertmanager/src/plugins/alertmanager-silences-query/get-silences-data.test.ts new file mode 100644 index 000000000..1ec11e708 --- /dev/null +++ b/alertmanager/src/plugins/alertmanager-silences-query/get-silences-data.test.ts @@ -0,0 +1,147 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { SilencesQueryContext } from '@perses-dev/plugin-system'; +import { AlertManagerClient } from '../../model'; +import { AlertManagerDatasource } from '../alertmanager-datasource'; +import { AlertManagerSilencesQuerySpec } from '../types'; +import { getSilencesData } from './get-silences-data'; + +const datasource = { directUrl: 'http://am.example' }; + +const mockSilences = [ + { + id: 'silence-1', + status: { state: 'active' as const }, + updatedAt: '2024-01-01T00:00:00Z', + comment: 'Maintenance window', + createdBy: 'admin', + endsAt: '2024-01-01T04:00:00Z', + matchers: [{ name: 'alertname', value: 'HighMemory', isRegex: false, isEqual: true }], + startsAt: '2024-01-01T00:00:00Z', + }, + { + id: 'silence-2', + status: { state: 'expired' as const }, + updatedAt: '2024-01-02T00:00:00Z', + comment: 'Testing', + createdBy: 'dev', + endsAt: '2024-01-02T01:00:00Z', + matchers: [ + { name: 'severity', value: 'warning', isRegex: false, isEqual: true }, + { name: 'instance', value: 'server-.*', isRegex: true, isEqual: true }, + ], + startsAt: '2024-01-02T00:00:00Z', + }, +]; + +const makeClient = (): AlertManagerClient => { + const client = AlertManagerDatasource.createClient(datasource, {}); + client.getSilences = jest.fn(async () => mockSilences); + return client; +}; + +function createContext(client: AlertManagerClient): SilencesQueryContext { + return { + variableState: {}, + datasourceStore: { + getDatasource: jest.fn(), + getDatasourceClient: jest.fn(() => + Promise.resolve(client) + ) as SilencesQueryContext['datasourceStore']['getDatasourceClient'], + listDatasourceSelectItems: jest.fn(async () => []), + getLocalDatasources: jest.fn(), + setLocalDatasources: jest.fn(), + getSavedDatasources: jest.fn(), + setSavedDatasources: jest.fn(), + }, + }; +} + +describe('getSilencesData', () => { + it('transforms API silences into SilencesData format', async () => { + const client = makeClient(); + const spec: AlertManagerSilencesQuerySpec = {}; + const context = createContext(client); + + const result = await getSilencesData(spec, context as unknown as Parameters[1]); + + expect(result.silences).toHaveLength(2); + expect(result.silences[0]).toEqual({ + id: 'silence-1', + state: 'active', + matchers: [{ name: 'alertname', value: 'HighMemory', isRegex: false, isEqual: true }], + startsAt: '2024-01-01T00:00:00Z', + endsAt: '2024-01-01T04:00:00Z', + createdBy: 'admin', + comment: 'Maintenance window', + updatedAt: '2024-01-01T00:00:00Z', + }); + }); + + it('maps expired silence status correctly', async () => { + const client = makeClient(); + const spec: AlertManagerSilencesQuerySpec = {}; + const context = createContext(client); + + const result = await getSilencesData(spec, context as unknown as Parameters[1]); + + expect(result.silences[1]?.state).toBe('expired'); + expect(result.silences[1]?.matchers).toHaveLength(2); + expect(result.silences[1]?.matchers[1]?.isRegex).toBe(true); + }); + + it('passes filter parameters to the client', async () => { + const client = makeClient(); + const spec: AlertManagerSilencesQuerySpec = { + filters: ['alertname="HighMemory"'], + }; + const context = createContext(client); + + await getSilencesData(spec, context as unknown as Parameters[1]); + + expect(client.getSilences).toHaveBeenCalledWith({ + filter: ['alertname="HighMemory"'], + }); + }); + + it('returns empty silences array when API returns empty', async () => { + const client = makeClient(); + client.getSilences = jest.fn(async () => []); + const spec: AlertManagerSilencesQuerySpec = {}; + const context = createContext(client); + + const result = await getSilencesData(spec, context as unknown as Parameters[1]); + + expect(result.silences).toEqual([]); + }); + + it('interpolates variables in filters', async () => { + const client = makeClient(); + const spec: AlertManagerSilencesQuerySpec = { + filters: ['team="$team"'], + }; + const context = createContext(client); + context.variableState = { + team: { value: 'ops', loading: false }, + }; + + await getSilencesData(spec, context as unknown as Parameters[1]); + + expect(client.getSilences).toHaveBeenCalledWith( + expect.objectContaining({ + filter: ['team="ops"'], + }) + ); + }); +}); diff --git a/alertmanager/src/plugins/alertmanager-silences-query/get-silences-data.ts b/alertmanager/src/plugins/alertmanager-silences-query/get-silences-data.ts new file mode 100644 index 000000000..f633b2ecd --- /dev/null +++ b/alertmanager/src/plugins/alertmanager-silences-query/get-silences-data.ts @@ -0,0 +1,77 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { datasourceSelectValueToSelector, replaceVariables, SilencesQueryContext } from '@perses-dev/plugin-system'; +import { Silence, SilencesData } from '@perses-dev/spec'; +import { ALERTMANAGER_DATASOURCE_KIND, AlertManagerClient, DEFAULT_ALERTMANAGER, GettableSilence } from '../../model'; +import { AlertManagerSilencesQuerySpec } from '../types'; +/** + * Transform a GettableSilence from the Alertmanager API into our normalized Silence format. + */ +function transformSilence(apiSilence: GettableSilence): Silence { + return { + id: apiSilence.id, + state: apiSilence.status.state, + matchers: apiSilence.matchers.map((m) => ({ + name: m.name, + value: m.value, + isRegex: m.isRegex, + isEqual: m.isEqual, + })), + startsAt: apiSilence.startsAt, + endsAt: apiSilence.endsAt, + createdBy: apiSilence.createdBy, + comment: apiSilence.comment, + updatedAt: apiSilence.updatedAt, + }; +} + +/** + * Get silences data from Alert Manager, transforming the API response into the plugin-system SilencesData format. + */ +export async function getSilencesData( + spec: AlertManagerSilencesQuerySpec, + context: SilencesQueryContext +): Promise { + const listDatasourceSelectItems = + await context.datasourceStore.listDatasourceSelectItems(ALERTMANAGER_DATASOURCE_KIND); + const datasourceSelector = + datasourceSelectValueToSelector(spec.datasource, context.variableState, listDatasourceSelectItems) ?? + DEFAULT_ALERTMANAGER; + const client = await context.datasourceStore.getDatasourceClient(datasourceSelector); + + const interpolatedFilters = spec.filters?.map((f) => replaceVariables(f, context.variableState)); + + const apiSilences = await client.getSilences({ + filter: interpolatedFilters, + }); + + const silences = apiSilences.map(transformSilence); + + return { + silences, + metadata: { + executedQueryString: buildQueryString(spec), + }, + }; +} + +function buildQueryString(spec: AlertManagerSilencesQuerySpec): string { + return JSON.stringify( + Object.fromEntries( + Object.entries({ + filters: spec.filters, + }).filter(([, value]) => value !== undefined) + ) + ); +} diff --git a/alertmanager/src/plugins/index.ts b/alertmanager/src/plugins/index.ts new file mode 100644 index 000000000..d974ca9e1 --- /dev/null +++ b/alertmanager/src/plugins/index.ts @@ -0,0 +1,44 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export * from './alertmanager-datasource'; +export * from './types'; +export * from './AlertManagerDatasourceEditor'; +export * from './alertmanager-alerts-query/AlertManagerAlertsQuery'; +export * from './alertmanager-alerts-query/get-alerts-data'; +export * from './alertmanager-alerts-query/AlertManagerAlertsQueryEditor'; +export * from './alertmanager-silences-query/AlertManagerSilencesQuery'; +export * from './alertmanager-silences-query/get-silences-data'; +export * from './alertmanager-silences-query/AlertManagerSilencesQueryEditor'; +export * from './alert-table/AlertTable'; +export * from './alert-table/alert-table-model'; +export * from './alert-table/AlertTablePanel'; +export * from './silence-table/SilenceTable'; +export { + ALL_SILENCE_ACTIONS, + DEFAULT_COLUMN_HEADERS, + DEFAULT_SILENCE_COLUMNS, + getSilenceDuration, + getSilenceFieldValue, + inferSortMode, +} from './silence-table/silence-table-model'; +export type { + SilenceAction, + SilenceColumnDefinition, + SilenceColumnSortMode, + SilenceFieldName, + SilenceTableOptions, +} from './silence-table/silence-table-model'; +export { compareSilencesByColumn } from './silence-table/silence-table-sorting'; +export type { SilenceSortState } from './silence-table/silence-table-sorting'; +export * from './silence-table/SilenceTablePanel'; diff --git a/alertmanager/src/plugins/silence-table/SilenceTable.ts b/alertmanager/src/plugins/silence-table/SilenceTable.ts new file mode 100644 index 000000000..9d5b82194 --- /dev/null +++ b/alertmanager/src/plugins/silence-table/SilenceTable.ts @@ -0,0 +1,31 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { PanelPlugin } from '@perses-dev/plugin-system'; +import { SilenceTableColumnsEditor } from './SilenceTableColumnsEditor'; +import { SilenceTableOptionsEditor } from './SilenceTableOptionsEditor'; +import { SilenceTablePanel } from './SilenceTablePanel'; +import { SilenceTableOptions } from './silence-table-model'; + +/** + * Panel plugin for displaying Alert Manager silences in a table. + */ +export const SilenceTable: PanelPlugin = { + PanelComponent: SilenceTablePanel as PanelPlugin['PanelComponent'], + supportedQueryTypes: ['SilencesQuery'], + panelOptionsEditorComponents: [ + { label: 'General', content: SilenceTableOptionsEditor }, + { label: 'Columns', content: SilenceTableColumnsEditor }, + ], + createInitialOptions: () => ({}), +}; diff --git a/alertmanager/src/plugins/silence-table/SilenceTableColumnsEditor.tsx b/alertmanager/src/plugins/silence-table/SilenceTableColumnsEditor.tsx new file mode 100644 index 000000000..bf68b7254 --- /dev/null +++ b/alertmanager/src/plugins/silence-table/SilenceTableColumnsEditor.tsx @@ -0,0 +1,153 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { FormControl, InputLabel, MenuItem, Select } from '@mui/material'; +import { OptionsEditorProps } from '@perses-dev/plugin-system'; +import { produce } from 'immer'; +import { ReactElement, useCallback } from 'react'; +import { ColumnsEditor } from '../../components/ColumnsEditor'; +import { + DEFAULT_COLUMN_HEADERS, + DEFAULT_SILENCE_COLUMNS, + SilenceColumnDefinition, + SilenceColumnSortMode, + SilenceFieldName, + SilenceTableOptions, + inferSortMode, +} from './silence-table-model'; + +const DEFAULT_FIELD_NAMES = new Set(DEFAULT_SILENCE_COLUMNS.map((c) => c.name)); + +const ALL_FIELDS: SilenceFieldName[] = [ + 'status', + 'matchers', + 'createdBy', + 'startsAt', + 'endsAt', + 'duration', + 'comment', + 'updatedAt', +]; + +const FIELD_OPTIONS: SilenceFieldName[] = ALL_FIELDS.filter((f) => !DEFAULT_FIELD_NAMES.has(f)); + +const SORT_MODE_LABELS: Record = { + alphabetical: 'Alphabetical', + date: 'Date (chronological)', + status: 'Status (active > pending > expired)', +}; + +export function SilenceTableColumnsEditor(props: OptionsEditorProps): ReactElement { + const { value, onChange } = props; + const columns = value.columns ?? []; + + const handleAddColumn = useCallback((): void => { + onChange( + produce(value, (draft) => { + if (!draft.columns) draft.columns = []; + draft.columns.push({ name: FIELD_OPTIONS[0] ?? 'updatedAt' }); + }) + ); + }, [value, onChange]); + + const handleRemoveColumn = useCallback( + (index: number): void => { + onChange( + produce(value, (draft) => { + draft.columns?.splice(index, 1); + }) + ); + }, + [value, onChange] + ); + + const handleUpdateColumn = useCallback( + (index: number, updater: (draft: SilenceColumnDefinition) => void): void => { + onChange( + produce(value, (draft) => { + const column = draft.columns?.[index]; + if (column) { + updater(column); + } + }) + ); + }, + [value, onChange] + ); + + const handleMoveUp = useCallback( + (index: number): void => { + if (index <= 0) return; + onChange( + produce(value, (draft) => { + if (!draft.columns) return; + const item = draft.columns.splice(index, 1)[0]!; + draft.columns.splice(index - 1, 0, item); + }) + ); + }, + [value, onChange] + ); + + const handleMoveDown = useCallback( + (index: number): void => { + onChange( + produce(value, (draft) => { + if (!draft.columns || index >= draft.columns.length - 1) return; + const item = draft.columns.splice(index, 1)[0]!; + draft.columns.splice(index + 1, 0, item); + }) + ); + }, + [value, onChange] + ); + + return ( + + columns={columns} + description="Status and Matchers are always shown. Add extra columns below." + sortModeLabels={SORT_MODE_LABELS} + defaultSortMode="alphabetical" + getDisplayName={(col) => col.header || DEFAULT_COLUMN_HEADERS[col.name] || 'New column'} + getHeaderPlaceholder={(col) => DEFAULT_COLUMN_HEADERS[col.name] || 'Column header'} + onAdd={handleAddColumn} + onRemove={handleRemoveColumn} + onUpdate={handleUpdateColumn} + onMoveUp={handleMoveUp} + onMoveDown={handleMoveDown} + renderNameField={(col, index, onUpdate) => ( + + Field + + + )} + /> + ); +} diff --git a/alertmanager/src/plugins/silence-table/SilenceTableOptionsEditor.tsx b/alertmanager/src/plugins/silence-table/SilenceTableOptionsEditor.tsx new file mode 100644 index 000000000..93bbd1567 --- /dev/null +++ b/alertmanager/src/plugins/silence-table/SilenceTableOptionsEditor.tsx @@ -0,0 +1,64 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Checkbox, FormControlLabel } from '@mui/material'; +import { OptionsEditorGroup } from '@perses-dev/components'; +import { OptionsEditorProps } from '@perses-dev/plugin-system'; +import { produce } from 'immer'; +import { ReactElement, useCallback } from 'react'; +import { ALL_SILENCE_ACTIONS, SilenceAction, SilenceTableOptions } from './silence-table-model'; + +const ACTION_LABELS: Record = { + expire: 'Expire silence', +}; + +export function SilenceTableOptionsEditor(props: OptionsEditorProps): ReactElement { + const { value, onChange } = props; + const effectiveActions = value.allowedActions ?? ALL_SILENCE_ACTIONS; + + const handleToggle = useCallback( + (action: SilenceAction, checked: boolean) => { + onChange( + produce(value, (draft) => { + const current = draft.allowedActions ?? [...ALL_SILENCE_ACTIONS]; + if (checked) { + if (!current.includes(action)) current.push(action); + } else { + const idx = current.indexOf(action); + if (idx >= 0) current.splice(idx, 1); + } + draft.allowedActions = current; + }) + ); + }, + [value, onChange] + ); + + return ( + + {ALL_SILENCE_ACTIONS.map((action) => ( + handleToggle(action, e.target.checked)} + size="small" + /> + } + label={ACTION_LABELS[action]} + /> + ))} + + ); +} diff --git a/alertmanager/src/plugins/silence-table/SilenceTablePanel.tsx b/alertmanager/src/plugins/silence-table/SilenceTablePanel.tsx new file mode 100644 index 000000000..f7a503a11 --- /dev/null +++ b/alertmanager/src/plugins/silence-table/SilenceTablePanel.tsx @@ -0,0 +1,292 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + Box, + IconButton, + InputAdornment, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TableSortLabel, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { Dialog, useSnackbar } from '@perses-dev/components'; +import { PanelProps, useDatasourceClient } from '@perses-dev/plugin-system'; +import { Silence, SilencesData } from '@perses-dev/spec'; +import { useQueryClient } from '@tanstack/react-query'; +import DeleteIcon from 'mdi-material-ui/Delete'; +import MagnifyIcon from 'mdi-material-ui/Magnify'; +import { ReactElement, useCallback, useMemo, useState } from 'react'; +import { MatchersList } from '../../components/MatchersList'; +import { StatusBadge } from '../../components/StatusBadge'; +import { AlertManagerClient, extractDatasourceSelector } from '../../model'; +import { + ALL_SILENCE_ACTIONS, + DEFAULT_COLUMN_HEADERS, + DEFAULT_SILENCE_COLUMNS, + SilenceAction, + SilenceColumnDefinition, + SilenceFieldName, + SilenceTableOptions, + getSilenceDuration, + inferSortMode, +} from './silence-table-model'; +import { SilenceSortState, compareSilencesByColumn } from './silence-table-sorting'; + +export type SilenceTablePanelProps = PanelProps; + +function renderSilenceCell(silence: Silence, field: SilenceFieldName): ReactElement { + switch (field) { + case 'status': + return ; + case 'matchers': + return ; + case 'startsAt': + case 'endsAt': + case 'updatedAt': { + const raw = field === 'updatedAt' ? silence.updatedAt : silence[field]; + return <>{raw ? new Date(raw).toLocaleString() : ''}; + } + case 'duration': + return <>{getSilenceDuration(silence)}; + case 'comment': + return ( + + + {silence.comment ?? ''} + + + ); + case 'createdBy': + return <>{silence.createdBy}; + } +} + +function SilenceRow({ + silence, + columnDefs, + allowedActions, + onExpire, +}: { + silence: Silence; + columnDefs: SilenceColumnDefinition[]; + allowedActions: SilenceAction[]; + onExpire: (silence: Silence) => void; +}): ReactElement { + const showActions = allowedActions.length > 0; + const isExpired = silence.state === 'expired'; + + return ( + + {columnDefs.map((col) => ( + {renderSilenceCell(silence, col.name)} + ))} + {showActions && ( + + + {allowedActions.includes('expire') && ( + + + onExpire(silence)} + disabled={isExpired} + > + + + + + )} + + + )} + + ); +} + +/** + * Silence table panel component. + * Displays silences with matchers, status, creator, duration, and actions. + */ +export function SilenceTablePanel({ spec, queryResults, contentDimensions }: SilenceTablePanelProps): ReactElement { + const datasourceSelector = useMemo(() => extractDatasourceSelector(queryResults), [queryResults]); + const { data: amClient } = useDatasourceClient(datasourceSelector); + const queryClient = useQueryClient(); + + const [expireTarget, setExpireTarget] = useState(null); + const [isExpiring, setIsExpiring] = useState(false); + const { successSnackbar, exceptionSnackbar } = useSnackbar(); + + const handleExpire = useCallback(async () => { + if (!amClient || !expireTarget) return; + setIsExpiring(true); + try { + await amClient.deleteSilence(expireTarget.id); + setExpireTarget(null); + successSnackbar('Silence expired successfully'); + queryClient.invalidateQueries({ queryKey: ['query', 'AlertsQuery'] }); + queryClient.invalidateQueries({ queryKey: ['query', 'SilencesQuery'] }); + } catch (err) { + exceptionSnackbar(err); + } finally { + setIsExpiring(false); + } + }, [amClient, expireTarget, queryClient, successSnackbar, exceptionSnackbar]); + + const [search, setSearch] = useState(''); + + const effectiveActions = useMemo( + () => spec?.allowedActions ?? ALL_SILENCE_ACTIONS, + [spec?.allowedActions] + ); + const showActionsColumn = effectiveActions.length > 0; + + const columnDefs = useMemo(() => { + if (!spec.columns || spec.columns.length === 0) return DEFAULT_SILENCE_COLUMNS; + const defaultNames = new Set(DEFAULT_SILENCE_COLUMNS.map((c) => c.name)); + const extra = spec.columns.filter((c) => !defaultNames.has(c.name)); + return [...DEFAULT_SILENCE_COLUMNS, ...extra]; + }, [spec.columns]); + + const initialSort = useMemo(() => { + const col = columnDefs.find((c) => c.sort && c.enableSorting !== false); + if (!col?.sort) return null; + return { fieldName: col.name, direction: col.sort, mode: col.sortMode ?? inferSortMode(col.name) }; + }, [columnDefs]); + const [sortState, setSortState] = useState(initialSort); + + const handleSortClick = useCallback((col: SilenceColumnDefinition): void => { + setSortState((prev) => { + if (prev?.fieldName === col.name) { + return prev.direction === 'asc' ? { ...prev, direction: 'desc' } : null; + } + return { fieldName: col.name, direction: 'asc', mode: col.sortMode ?? inferSortMode(col.name) }; + }); + }, []); + + // Flatten all silences from all query results + const allSilences = useMemo(() => { + return queryResults.flatMap((result) => result.data?.silences ?? []); + }, [queryResults]); + + const filteredSilences = useMemo(() => { + const term = search.trim().toLowerCase(); + if (!term) return allSilences; + return allSilences.filter((s) => { + if (s.state.toLowerCase().includes(term)) return true; + if (s.createdBy.toLowerCase().includes(term)) return true; + if (s.comment?.toLowerCase().includes(term)) return true; + return s.matchers.some((m) => m.name.toLowerCase().includes(term) || m.value.toLowerCase().includes(term)); + }); + }, [allSilences, search]); + + const silences = useMemo(() => { + if (!sortState) return filteredSilences; + return [...filteredSilences].sort((a, b) => compareSilencesByColumn(a, b, sortState)); + }, [filteredSilences, sortState]); + + if (silences.length === 0) { + return ( + + No silences to display + + ); + } + + return ( + + + setSearch(e.target.value)} + fullWidth + slotProps={{ + htmlInput: { 'aria-label': 'Search silences' }, + input: { + startAdornment: ( + + + + ), + }, + }} + /> + + + + + {columnDefs.map((col) => ( + + {col.enableSorting !== false ? ( + handleSortClick(col)} + > + {col.header ?? DEFAULT_COLUMN_HEADERS[col.name]} + + ) : ( + (col.header ?? DEFAULT_COLUMN_HEADERS[col.name]) + )} + + ))} + {showActionsColumn && Actions} + + + + {silences.map((silence) => ( + + ))} + +
+ setExpireTarget(null)}> + setExpireTarget(null)}>Expire Silence + + Are you sure you want to expire this silence? Alerts matching its matchers will start firing again. + + + setExpireTarget(null)}>Cancel + + {isExpiring ? 'Expiring...' : 'Expire'} + + + +
+ ); +} diff --git a/alertmanager/src/plugins/silence-table/silence-table-model.test.ts b/alertmanager/src/plugins/silence-table/silence-table-model.test.ts new file mode 100644 index 000000000..dad189c96 --- /dev/null +++ b/alertmanager/src/plugins/silence-table/silence-table-model.test.ts @@ -0,0 +1,134 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Silence } from '@perses-dev/spec'; +import { getSilenceDuration, getSilenceFieldValue, inferSortMode } from './silence-table-model'; + +const makeSilence = (overrides: Partial = {}): Silence => ({ + id: 'silence-1', + state: 'active', + matchers: [{ name: 'alertname', value: 'Test', isRegex: false, isEqual: true }], + startsAt: '2024-01-01T00:00:00Z', + endsAt: '2024-01-01T02:00:00Z', + createdBy: 'admin', + comment: 'Test silence', + updatedAt: '2024-01-01T00:00:00Z', + ...overrides, +}); + +describe('getSilenceDuration', () => { + it('calculates duration between start and end', () => { + const silence = makeSilence({ + startsAt: '2024-01-01T00:00:00Z', + endsAt: '2024-01-01T02:00:00Z', + }); + const duration = getSilenceDuration(silence); + expect(duration).toBe('2h'); + }); + + it('handles multi-day durations', () => { + const silence = makeSilence({ + startsAt: '2024-01-01T00:00:00Z', + endsAt: '2024-01-03T12:00:00Z', + }); + const duration = getSilenceDuration(silence); + expect(duration).toBe('2d 12h'); + }); + + it('handles minute-only durations', () => { + const silence = makeSilence({ + startsAt: '2024-01-01T00:00:00Z', + endsAt: '2024-01-01T00:30:00Z', + }); + const duration = getSilenceDuration(silence); + expect(duration).toBe('30m'); + }); + + it('returns 0m for zero-length duration', () => { + const silence = makeSilence({ + startsAt: '2024-01-01T00:00:00Z', + endsAt: '2024-01-01T00:00:00Z', + }); + const duration = getSilenceDuration(silence); + expect(duration).toBe('0m'); + }); +}); + +describe('getSilenceFieldValue', () => { + it('returns status state', () => { + const silence = makeSilence({ state: 'active' }); + expect(getSilenceFieldValue(silence, 'status')).toBe('active'); + }); + + it('returns createdBy', () => { + const silence = makeSilence({ createdBy: 'bob' }); + expect(getSilenceFieldValue(silence, 'createdBy')).toBe('bob'); + }); + + it('returns startsAt', () => { + const silence = makeSilence({ startsAt: '2024-06-01T12:00:00Z' }); + expect(getSilenceFieldValue(silence, 'startsAt')).toBe('2024-06-01T12:00:00Z'); + }); + + it('returns endsAt', () => { + const silence = makeSilence({ endsAt: '2024-06-02T12:00:00Z' }); + expect(getSilenceFieldValue(silence, 'endsAt')).toBe('2024-06-02T12:00:00Z'); + }); + + it('returns duration string', () => { + const silence = makeSilence({ + startsAt: '2024-01-01T00:00:00Z', + endsAt: '2024-01-01T02:00:00Z', + }); + expect(getSilenceFieldValue(silence, 'duration')).toBe('2h'); + }); + + it('returns comment', () => { + const silence = makeSilence({ comment: 'maintenance' }); + expect(getSilenceFieldValue(silence, 'comment')).toBe('maintenance'); + }); + + it('returns matchers as joined string', () => { + const silence = makeSilence({ + matchers: [ + { name: 'alertname', value: 'Test', isRegex: false, isEqual: true }, + { name: 'severity', value: 'critical', isRegex: false, isEqual: true }, + ], + }); + expect(getSilenceFieldValue(silence, 'matchers')).toBe('alertname=Test, severity=critical'); + }); + + it('returns empty string for empty updatedAt', () => { + const silence = makeSilence({ updatedAt: '' }); + expect(getSilenceFieldValue(silence, 'updatedAt')).toBe(''); + }); +}); + +describe('inferSortMode', () => { + it('returns status for status field', () => { + expect(inferSortMode('status')).toBe('status'); + }); + + it('returns date for date fields', () => { + expect(inferSortMode('startsAt')).toBe('date'); + expect(inferSortMode('endsAt')).toBe('date'); + expect(inferSortMode('updatedAt')).toBe('date'); + }); + + it('returns alphabetical for other fields', () => { + expect(inferSortMode('createdBy')).toBe('alphabetical'); + expect(inferSortMode('comment')).toBe('alphabetical'); + expect(inferSortMode('duration')).toBe('alphabetical'); + expect(inferSortMode('matchers')).toBe('alphabetical'); + }); +}); diff --git a/alertmanager/src/plugins/silence-table/silence-table-model.ts b/alertmanager/src/plugins/silence-table/silence-table-model.ts new file mode 100644 index 000000000..41f89d59e --- /dev/null +++ b/alertmanager/src/plugins/silence-table/silence-table-model.ts @@ -0,0 +1,120 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Silence } from '@perses-dev/spec'; + +export type SilenceAction = 'expire'; + +export const ALL_SILENCE_ACTIONS: SilenceAction[] = ['expire']; + +export type SortDirection = 'asc' | 'desc'; + +export type SilenceColumnSortMode = 'alphabetical' | 'date' | 'status'; + +export type SilenceFieldName = + | 'status' + | 'matchers' + | 'createdBy' + | 'startsAt' + | 'endsAt' + | 'duration' + | 'comment' + | 'updatedAt'; + +export interface SilenceColumnDefinition { + name: SilenceFieldName; + header?: string; + enableSorting?: boolean; + sort?: SortDirection; + sortMode?: SilenceColumnSortMode; +} + +export const DEFAULT_COLUMN_HEADERS: Record = { + status: 'Status', + matchers: 'Matchers', + createdBy: 'Creator', + startsAt: 'Starts At', + endsAt: 'Ends At', + duration: 'Duration', + comment: 'Comment', + updatedAt: 'Updated At', +}; + +export const DEFAULT_SILENCE_COLUMNS: SilenceColumnDefinition[] = [ + { name: 'status', sort: 'asc', sortMode: 'status' }, + { name: 'matchers', enableSorting: false }, +]; + +export function inferSortMode(field: SilenceFieldName): SilenceColumnSortMode { + switch (field) { + case 'status': + return 'status'; + case 'startsAt': + case 'endsAt': + case 'updatedAt': + return 'date'; + default: + return 'alphabetical'; + } +} + +export function getSilenceFieldValue(silence: Silence, field: SilenceFieldName): string { + switch (field) { + case 'status': + return silence.state; + case 'matchers': + return silence.matchers.map((m) => `${m.name}=${m.value}`).join(', '); + case 'createdBy': + return silence.createdBy; + case 'startsAt': + return silence.startsAt; + case 'endsAt': + return silence.endsAt; + case 'duration': + return getSilenceDuration(silence); + case 'comment': + return silence.comment ?? ''; + case 'updatedAt': + return silence.updatedAt ?? ''; + } +} + +/** + * Options for the SilenceTable panel plugin. + */ +export interface SilenceTableOptions { + columns?: SilenceColumnDefinition[]; + allowedActions?: SilenceAction[]; +} + +/** + * Calculate a human-readable duration string from a silence's start and end times. + */ +export function getSilenceDuration(silence: Silence): string { + const startMs = new Date(silence.startsAt).getTime(); + const endMs = new Date(silence.endsAt).getTime(); + const diffMs = endMs - startMs; + if (diffMs <= 0) return '0m'; + + const totalMinutes = Math.floor(diffMs / (1000 * 60)); + const days = Math.floor(totalMinutes / (60 * 24)); + const hours = Math.floor((totalMinutes % (60 * 24)) / 60); + const minutes = totalMinutes % 60; + + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + + return parts.join(' ') || '0m'; +} diff --git a/alertmanager/src/plugins/silence-table/silence-table-sorting.test.ts b/alertmanager/src/plugins/silence-table/silence-table-sorting.test.ts new file mode 100644 index 000000000..25dcb5c47 --- /dev/null +++ b/alertmanager/src/plugins/silence-table/silence-table-sorting.test.ts @@ -0,0 +1,122 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Silence } from '@perses-dev/spec'; +import { compareSilencesByColumn, SilenceSortState } from './silence-table-sorting'; + +const makeSilence = (overrides: Partial = {}): Silence => ({ + id: 'silence-1', + state: 'active', + matchers: [{ name: 'alertname', value: 'Test', isRegex: false, isEqual: true }], + startsAt: '2024-01-01T00:00:00Z', + endsAt: '2024-01-01T02:00:00Z', + createdBy: 'admin', + comment: 'Test silence', + updatedAt: '2024-01-01T00:00:00Z', + ...overrides, +}); + +describe('compareSilencesByColumn', () => { + describe('status sort', () => { + const sort: SilenceSortState = { fieldName: 'status', direction: 'asc', mode: 'status' }; + + it('sorts active before pending before expired (asc)', () => { + const silences = [ + makeSilence({ id: '1', state: 'expired' }), + makeSilence({ id: '2', state: 'active' }), + makeSilence({ id: '3', state: 'pending' }), + ]; + silences.sort((a, b) => compareSilencesByColumn(a, b, sort)); + expect(silences.map((s) => s.state)).toEqual(['active', 'pending', 'expired']); + }); + + it('reverses order for desc', () => { + const descSort: SilenceSortState = { ...sort, direction: 'desc' }; + const silences = [ + makeSilence({ id: '1', state: 'active' }), + makeSilence({ id: '2', state: 'expired' }), + makeSilence({ id: '3', state: 'pending' }), + ]; + silences.sort((a, b) => compareSilencesByColumn(a, b, descSort)); + expect(silences.map((s) => s.state)).toEqual(['expired', 'pending', 'active']); + }); + }); + + describe('alphabetical sort', () => { + const sort: SilenceSortState = { fieldName: 'createdBy', direction: 'asc', mode: 'alphabetical' }; + + it('sorts alphabetically ascending', () => { + const silences = [ + makeSilence({ id: '1', createdBy: 'charlie' }), + makeSilence({ id: '2', createdBy: 'alice' }), + makeSilence({ id: '3', createdBy: 'bob' }), + ]; + silences.sort((a, b) => compareSilencesByColumn(a, b, sort)); + expect(silences.map((s) => s.createdBy)).toEqual(['alice', 'bob', 'charlie']); + }); + + it('sorts alphabetically descending', () => { + const descSort: SilenceSortState = { ...sort, direction: 'desc' }; + const silences = [ + makeSilence({ id: '1', createdBy: 'alice' }), + makeSilence({ id: '2', createdBy: 'charlie' }), + makeSilence({ id: '3', createdBy: 'bob' }), + ]; + silences.sort((a, b) => compareSilencesByColumn(a, b, descSort)); + expect(silences.map((s) => s.createdBy)).toEqual(['charlie', 'bob', 'alice']); + }); + }); + + describe('date sort', () => { + const sort: SilenceSortState = { fieldName: 'startsAt', direction: 'asc', mode: 'date' }; + + it('sorts by date ascending', () => { + const silences = [ + makeSilence({ id: '1', startsAt: '2024-03-01T00:00:00Z' }), + makeSilence({ id: '2', startsAt: '2024-01-01T00:00:00Z' }), + makeSilence({ id: '3', startsAt: '2024-02-01T00:00:00Z' }), + ]; + silences.sort((a, b) => compareSilencesByColumn(a, b, sort)); + expect(silences.map((s) => s.startsAt)).toEqual([ + '2024-01-01T00:00:00Z', + '2024-02-01T00:00:00Z', + '2024-03-01T00:00:00Z', + ]); + }); + + it('sorts by date descending', () => { + const descSort: SilenceSortState = { ...sort, direction: 'desc' }; + const silences = [ + makeSilence({ id: '1', startsAt: '2024-01-01T00:00:00Z' }), + makeSilence({ id: '2', startsAt: '2024-03-01T00:00:00Z' }), + makeSilence({ id: '3', startsAt: '2024-02-01T00:00:00Z' }), + ]; + silences.sort((a, b) => compareSilencesByColumn(a, b, descSort)); + expect(silences.map((s) => s.startsAt)).toEqual([ + '2024-03-01T00:00:00Z', + '2024-02-01T00:00:00Z', + '2024-01-01T00:00:00Z', + ]); + }); + + it('pushes empty dates to the end', () => { + const silences = [ + makeSilence({ id: '1', updatedAt: '' }), + makeSilence({ id: '2', updatedAt: '2024-01-01T00:00:00Z' }), + ]; + const updatedAtSort: SilenceSortState = { fieldName: 'updatedAt', direction: 'asc', mode: 'date' }; + silences.sort((a, b) => compareSilencesByColumn(a, b, updatedAtSort)); + expect(silences.map((s) => s.updatedAt)).toEqual(['2024-01-01T00:00:00Z', '']); + }); + }); +}); diff --git a/alertmanager/src/plugins/silence-table/silence-table-sorting.ts b/alertmanager/src/plugins/silence-table/silence-table-sorting.ts new file mode 100644 index 000000000..abe92adc7 --- /dev/null +++ b/alertmanager/src/plugins/silence-table/silence-table-sorting.ts @@ -0,0 +1,74 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Silence } from '@perses-dev/spec'; +import { SilenceColumnSortMode, SilenceFieldName, SortDirection, getSilenceFieldValue } from './silence-table-model'; + +export interface SilenceSortState { + fieldName: SilenceFieldName; + direction: SortDirection; + mode: SilenceColumnSortMode; +} + +const STATUS_WEIGHT: Record = { + active: 0, + pending: 1, + expired: 2, +}; + +function directionMultiplier(direction: SortDirection): number { + return direction === 'asc' ? 1 : -1; +} + +function compareAlphabetical(a: string, b: string): number { + if (!a && !b) return 0; + if (!a) return 1; + if (!b) return -1; + return a.localeCompare(b); +} + +function compareDate(a: string, b: string): number { + const ta = a ? new Date(a).getTime() : NaN; + const tb = b ? new Date(b).getTime() : NaN; + if (isNaN(ta) && isNaN(tb)) return 0; + if (isNaN(ta)) return 1; + if (isNaN(tb)) return -1; + return ta - tb; +} + +function compareStatus(a: string, b: string): number { + const wa = STATUS_WEIGHT[a] ?? 3; + const wb = STATUS_WEIGHT[b] ?? 3; + return wa - wb; +} + +export function compareSilencesByColumn(a: Silence, b: Silence, sort: SilenceSortState): number { + const va = getSilenceFieldValue(a, sort.fieldName); + const vb = getSilenceFieldValue(b, sort.fieldName); + let result: number; + + switch (sort.mode) { + case 'date': + result = compareDate(va, vb); + break; + case 'status': + result = compareStatus(va, vb); + break; + case 'alphabetical': + default: + result = compareAlphabetical(va, vb); + break; + } + + return result * directionMultiplier(sort.direction); +} diff --git a/alertmanager/src/plugins/types.ts b/alertmanager/src/plugins/types.ts new file mode 100644 index 000000000..9918176ca --- /dev/null +++ b/alertmanager/src/plugins/types.ts @@ -0,0 +1,45 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { HTTPProxy } from '@perses-dev/core'; +import { DatasourceSelectValue } from '@perses-dev/plugin-system'; +import { AlertManagerDatasourceSelector } from '../model/alertmanager-selectors'; + +/** + * Datasource spec for the Alert Manager datasource plugin. + */ +export interface AlertManagerDatasourceSpec { + directUrl?: string; + proxy?: HTTPProxy; +} + +/** + * Query spec for the Alert Manager alerts query plugin. + */ +export interface AlertManagerAlertsQuerySpec { + datasource?: DatasourceSelectValue; + filters?: string[]; + active?: boolean; + silenced?: boolean; + inhibited?: boolean; + unprocessed?: boolean; + receiver?: string; +} + +/** + * Query spec for the Alert Manager silences query plugin. + */ +export interface AlertManagerSilencesQuerySpec { + datasource?: DatasourceSelectValue; + filters?: string[]; +} diff --git a/alertmanager/src/setup-tests.ts b/alertmanager/src/setup-tests.ts new file mode 100644 index 000000000..8cb37242a --- /dev/null +++ b/alertmanager/src/setup-tests.ts @@ -0,0 +1,16 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '@testing-library/jest-dom'; + +jest.mock('echarts/core'); diff --git a/alertmanager/tsconfig.build.json b/alertmanager/tsconfig.build.json new file mode 100644 index 000000000..055915f9e --- /dev/null +++ b/alertmanager/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["**/*.stories.*", "**/*.test.*", "**/*.map"], + "compilerOptions": { + "emitDeclarationOnly": true, + "declaration": true, + "preserveWatchOutput": true, + }, +} diff --git a/alertmanager/tsconfig.json b/alertmanager/tsconfig.json new file mode 100644 index 000000000..28852c59e --- /dev/null +++ b/alertmanager/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist/lib", + "rootDir": "./src", + }, + "include": ["src"], +} diff --git a/package-lock.json b/package-lock.json index 03b06e0bb..442f4e02b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "perses-plugins", "version": "0.1.0", "workspaces": [ + "alertmanager", "barchart", "clickhouse", "datasourcevariable", @@ -84,6 +85,25 @@ "react-dom": "^17.0.2 || ^18.0.0" } }, + "alertmanager": { + "name": "@perses-dev/alert-manager-plugin", + "version": "0.1.0", + "peerDependencies": { + "@emotion/react": "^11.7.1", + "@emotion/styled": "^11.6.0", + "@perses-dev/components": "^0.54.0-beta.1", + "@perses-dev/core": "^0.53.0", + "@perses-dev/dashboards": "^0.54.0-beta.1", + "@perses-dev/explore": "^0.54.0-beta.1", + "@perses-dev/plugin-system": "^0.54.0-beta.1", + "@perses-dev/spec": "^0.2.0-beta.1", + "@tanstack/react-query": "^4.39.1", + "date-fns": "^4.1.0", + "lodash": "^4.17.21", + "react": "^17.0.2 || ^18.0.0", + "react-dom": "^17.0.2 || ^18.0.0" + } + }, "barchart": { "name": "@perses-dev/bar-chart-plugin", "version": "0.12.0", @@ -3856,6 +3876,10 @@ "node": ">= 8" } }, + "node_modules/@perses-dev/alert-manager-plugin": { + "resolved": "alertmanager", + "link": true + }, "node_modules/@perses-dev/bar-chart-plugin": { "resolved": "barchart", "link": true @@ -3915,6 +3939,62 @@ "zod": "^3.21.4" } }, + "node_modules/@perses-dev/components/node_modules/@perses-dev/spec": { + "version": "0.2.0-beta.0", + "resolved": "https://registry.npmjs.org/@perses-dev/spec/-/spec-0.2.0-beta.0.tgz", + "integrity": "sha512-9qT3ofOjBcO7okudC9Rz8t8ugNcTscYincvvmyFtZ/9oHrkDTiJPcRHLZYoCPE6r70OEj27JpvCqWCCil5yA1Q==", + "license": "Apache-2.0", + "dependencies": { + "date-fns": "^4.1.0", + "mathjs": "^15.1.1", + "zod": "^3.21.4" + } + }, + "node_modules/@perses-dev/components/node_modules/@perses-dev/spec/node_modules/mathjs": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.2.0.tgz", + "integrity": "sha512-UAQzSVob9rNLdGpqcFMYmSu9dkuLYy7Lr2hBEQS5SHQdknA9VppJz3cy2KkpMzTODunad6V6cNv+5kOLsePLow==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.26.10", + "complex.js": "^2.2.5", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^5.2.1", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.2.1" + }, + "bin": { + "mathjs": "bin/cli.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@perses-dev/components/node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/@perses-dev/components/node_modules/typed-function": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.2.tgz", + "integrity": "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, "node_modules/@perses-dev/core": { "version": "0.53.1", "resolved": "https://registry.npmjs.org/@perses-dev/core/-/core-0.53.1.tgz", @@ -3972,6 +4052,62 @@ "zod": "^3.21.4" } }, + "node_modules/@perses-dev/dashboards/node_modules/@perses-dev/spec": { + "version": "0.2.0-beta.0", + "resolved": "https://registry.npmjs.org/@perses-dev/spec/-/spec-0.2.0-beta.0.tgz", + "integrity": "sha512-9qT3ofOjBcO7okudC9Rz8t8ugNcTscYincvvmyFtZ/9oHrkDTiJPcRHLZYoCPE6r70OEj27JpvCqWCCil5yA1Q==", + "license": "Apache-2.0", + "dependencies": { + "date-fns": "^4.1.0", + "mathjs": "^15.1.1", + "zod": "^3.21.4" + } + }, + "node_modules/@perses-dev/dashboards/node_modules/@perses-dev/spec/node_modules/mathjs": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.2.0.tgz", + "integrity": "sha512-UAQzSVob9rNLdGpqcFMYmSu9dkuLYy7Lr2hBEQS5SHQdknA9VppJz3cy2KkpMzTODunad6V6cNv+5kOLsePLow==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.26.10", + "complex.js": "^2.2.5", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^5.2.1", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.2.1" + }, + "bin": { + "mathjs": "bin/cli.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@perses-dev/dashboards/node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/@perses-dev/dashboards/node_modules/typed-function": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.2.tgz", + "integrity": "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, "node_modules/@perses-dev/datasource-variable-plugin": { "resolved": "datasourcevariable", "link": true @@ -4099,6 +4235,62 @@ "zod": "^3.21.4" } }, + "node_modules/@perses-dev/plugin-system/node_modules/@perses-dev/spec": { + "version": "0.2.0-beta.0", + "resolved": "https://registry.npmjs.org/@perses-dev/spec/-/spec-0.2.0-beta.0.tgz", + "integrity": "sha512-9qT3ofOjBcO7okudC9Rz8t8ugNcTscYincvvmyFtZ/9oHrkDTiJPcRHLZYoCPE6r70OEj27JpvCqWCCil5yA1Q==", + "license": "Apache-2.0", + "dependencies": { + "date-fns": "^4.1.0", + "mathjs": "^15.1.1", + "zod": "^3.21.4" + } + }, + "node_modules/@perses-dev/plugin-system/node_modules/@perses-dev/spec/node_modules/mathjs": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.2.0.tgz", + "integrity": "sha512-UAQzSVob9rNLdGpqcFMYmSu9dkuLYy7Lr2hBEQS5SHQdknA9VppJz3cy2KkpMzTODunad6V6cNv+5kOLsePLow==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.26.10", + "complex.js": "^2.2.5", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^5.2.1", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.2.1" + }, + "bin": { + "mathjs": "bin/cli.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@perses-dev/plugin-system/node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/@perses-dev/plugin-system/node_modules/typed-function": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.2.tgz", + "integrity": "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, "node_modules/@perses-dev/plugins-e2e": { "resolved": "e2e", "link": true @@ -4116,10 +4308,11 @@ "link": true }, "node_modules/@perses-dev/spec": { - "version": "0.2.0-beta.0", - "resolved": "https://registry.npmjs.org/@perses-dev/spec/-/spec-0.2.0-beta.0.tgz", - "integrity": "sha512-9qT3ofOjBcO7okudC9Rz8t8ugNcTscYincvvmyFtZ/9oHrkDTiJPcRHLZYoCPE6r70OEj27JpvCqWCCil5yA1Q==", + "version": "0.2.0-beta.1", + "resolved": "https://registry.npmjs.org/@perses-dev/spec/-/spec-0.2.0-beta.1.tgz", + "integrity": "sha512-MBdGQUWCMPZd+u9YgPfwO/lnXW72vrqvvCIgIeiuUobYi2DS6IHwavLPi8eTA0OcUGa1tJuTEnd660McghmW3w==", "license": "Apache-2.0", + "peer": true, "dependencies": { "date-fns": "^4.1.0", "mathjs": "^15.1.1", @@ -4131,6 +4324,7 @@ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "license": "MIT", + "peer": true, "engines": { "node": "*" }, @@ -4144,6 +4338,7 @@ "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.2.0.tgz", "integrity": "sha512-UAQzSVob9rNLdGpqcFMYmSu9dkuLYy7Lr2hBEQS5SHQdknA9VppJz3cy2KkpMzTODunad6V6cNv+5kOLsePLow==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@babel/runtime": "^7.26.10", "complex.js": "^2.2.5", @@ -4167,6 +4362,7 @@ "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.2.tgz", "integrity": "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==", "license": "MIT", + "peer": true, "engines": { "node": ">= 18" } diff --git a/package.json b/package.json index 39771d349..76f494596 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "type-check": "turbo run type-check" }, "workspaces": [ + "alertmanager", "barchart", "clickhouse", "datasourcevariable",