├── .github ├── dependabot.yml └── workflows │ ├── auto-merge-deps.yml │ ├── build.yml │ ├── golangci-lint.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── README.md ├── assets ├── .gitkeep ├── alertmanager-1.png ├── alertmanager-2.png ├── alertmanager-3.png ├── alertmanager-logo.png └── alertmanager-logo.svg ├── build ├── .gitignore ├── go.mod ├── go.sum ├── legacy.mk ├── manifest │ ├── .gitignore │ └── main.go ├── pluginctl │ └── main.go └── setup.mk ├── go.mod ├── go.sum ├── hack ├── config.yml ├── docker-compose.yaml └── sample.sh ├── plugin.go ├── plugin.json ├── server ├── .gitignore ├── action_context.go ├── actions.go ├── alertmanager │ ├── alerts.go │ ├── retry.go │ ├── silences.go │ ├── silences_test.go │ └── status.go ├── colors.go ├── commands.go ├── configuration.go ├── main.go ├── manifest.go ├── plugin.go └── webhook.go └── webapp ├── .eslintrc.json ├── .gitignore ├── .npmrc ├── package-lock.json ├── package.json ├── src ├── components │ ├── admin_settings │ │ ├── AMAttribute.jsx │ │ └── CustomAttributeSettings.jsx │ └── widgets │ │ └── confirmation_modal.tsx ├── index.jsx ├── manifest.js └── styles │ └── main.css └── webpack.config.js /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: gomod 5 | directory: "/" 6 | schedule: 7 | interval: weekly 8 | open-pull-requests-limit: 10 9 | reviewers: 10 | - "cpanato" 11 | 12 | - package-ecosystem: npm 13 | directory: "/webapp" 14 | schedule: 15 | interval: weekly 16 | open-pull-requests-limit: 10 17 | reviewers: 18 | - "cpanato" 19 | 20 | - package-ecosystem: "github-actions" 21 | directory: "/" 22 | schedule: 23 | interval: weekly 24 | reviewers: 25 | - "cpanato" 26 | open-pull-requests-limit: 10 27 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge-deps.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | 3 | on: 4 | pull_request: 5 | 6 | permissions: read-all 7 | 8 | jobs: 9 | dependabot: 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | 14 | runs-on: ubuntu-latest 15 | if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }} 16 | 17 | steps: 18 | - name: Dependabot metadata 19 | id: metadata 20 | uses: dependabot/fetch-metadata@c9c4182bf1b97f5224aee3906fd373f6b61b4526 # v1.6.0 21 | with: 22 | github-token: ${{ secrets.GITHUB_TOKEN }} 23 | 24 | - if: ${{ steps.metadata.outputs.update-type == 'version-update:semver-patch' }} 25 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 26 | 27 | - name: Approve a PR if not already approved 28 | if: ${{ steps.metadata.outputs.update-type == 'version-update:semver-patch' }} 29 | run: | 30 | gh pr checkout "$PR_URL" # sets the upstream metadata for `gh pr status` 31 | if [ "$(gh pr status --json reviews -q '[.currentBranch.reviews[]| select(type=="object" and has("state"))| .state | select(match("APPROVED"))] | unique | .[0]')" != "APPROVED" ]; 32 | then gh pr review --approve "$PR_URL" 33 | else echo "PR already approved, skipping additional approvals to minimize emails/notification noise."; 34 | fi 35 | env: 36 | PR_URL: ${{github.event.pull_request.html_url}} 37 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 38 | 39 | - name: Enable auto-merge for Dependabot PRs 40 | if: ${{ steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch' }} 41 | run: gh pr merge --auto --squash "$PR_URL" 42 | env: 43 | PR_URL: ${{ github.event.pull_request.html_url }} 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 16 | 17 | - name: Install Go 18 | uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 19 | with: 20 | go-version: '1.20' 21 | check-latest: true 22 | cache: true 23 | 24 | - name: Dist 25 | run: make dist 26 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | golangci: 11 | name: lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 16 | 17 | - name: Install Go 18 | uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 19 | with: 20 | go-version: '1.20' 21 | check-latest: true 22 | cache: true 23 | 24 | - name: golangci-lint 25 | uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1 26 | with: 27 | version: latest 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | concurrency: cut-release 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | 14 | outputs: 15 | hashes: ${{ steps.hash.outputs.hashes }} 16 | tag_name: ${{ steps.tag.outputs.tag_name }} 17 | 18 | permissions: 19 | id-token: write 20 | contents: write 21 | 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 25 | with: 26 | fetch-depth: 0 27 | 28 | - name: Install Go 29 | uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 30 | with: 31 | go-version: '1.20' 32 | check-latest: true 33 | 34 | - name: Set tag output 35 | id: tag 36 | run: echo "tag_name=${GITHUB_REF#refs/*/}" >> "$GITHUB_OUTPUT" 37 | 38 | - name: Install Cosign 39 | uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2 40 | 41 | - name: Run GoReleaser 42 | id: run-goreleaser 43 | uses: goreleaser/goreleaser-action@7ec5c2b0c6cdda6e8bbb49444bc797dd33d74dd8 # v5.0.0 44 | with: 45 | distribution: goreleaser 46 | version: latest 47 | args: release --rm-dist 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | COSIGN_EXPERIMENTAL: "true" 51 | 52 | - name: Generate subject 53 | id: hash 54 | env: 55 | ARTIFACTS: "${{ steps.run-goreleaser.outputs.artifacts }}" 56 | run: | 57 | set -euo pipefail 58 | checksum_file=$(echo "$ARTIFACTS" | jq -r '.[] | select (.type=="Checksum") | .path') 59 | echo "hashes=$(cat $checksum_file | base64 -w0)" >> "$GITHUB_OUTPUT" 60 | 61 | provenance: 62 | needs: 63 | - release 64 | permissions: 65 | actions: read # To read the workflow path. 66 | id-token: write # To sign the provenance. 67 | contents: write # To add assets to a release. 68 | uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.9.0 69 | with: 70 | base64-subjects: "${{ needs.release.outputs.hashes }}" 71 | upload-assets: true # upload to a new release 72 | upload-tag-name: "${{ needs.release.outputs.tag_name }}" 73 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 16 | 17 | - name: Install Go 18 | uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 19 | with: 20 | go-version: 1.19 21 | check-latest: true 22 | cache: true 23 | 24 | - name: Test 25 | run: go test ./... 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | goreleaser/* 3 | node_modules 4 | .vscode/* 5 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 5m 3 | modules-download-mode: readonly 4 | 5 | linters-settings: 6 | goconst: 7 | min-len: 2 8 | min-occurrences: 2 9 | gofmt: 10 | simplify: true 11 | goimports: 12 | local-prefixes: github.com/mattermost/mattermost-plugin-nps 13 | golint: 14 | min-confidence: 0 15 | govet: 16 | check-shadowing: true 17 | enable-all: true 18 | misspell: 19 | locale: US 20 | 21 | linters: 22 | disable-all: true 23 | enable: 24 | - bodyclose 25 | - errcheck 26 | - goconst 27 | - gocritic 28 | - gofmt 29 | - goimports 30 | - gosec 31 | - gosimple 32 | - govet 33 | - ineffassign 34 | - misspell 35 | - nakedret 36 | - revive 37 | - staticcheck 38 | - stylecheck 39 | - typecheck 40 | - unconvert 41 | - unused 42 | - whitespace 43 | 44 | issues: 45 | exclude-rules: 46 | - path: server/manifest.go 47 | linters: 48 | - deadcode 49 | - unused 50 | - varcheck 51 | - path: server/configuration.go 52 | linters: 53 | - unused 54 | - path: _test\.go 55 | linters: 56 | - bodyclose 57 | - goconst 58 | - scopelint # https://github.com/kyoh86/scopelint/issues/4 59 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: mattermost-plugin-alertmanager 2 | 3 | dist: goreleaser 4 | 5 | builds: 6 | - skip: true 7 | 8 | before: 9 | hooks: 10 | - make clean 11 | - make dist 12 | - sh -c "cosign sign-blob --yes --timeout 360s --output-signature dist/alertmanager-{{ .Version }}.sig --output-certificate dist/alertmanager-{{ .Version }}.pem dist/alertmanager-{{ .Version }}.tar.gz" 13 | 14 | checksum: 15 | extra_files: 16 | - glob: ./dist/alertmanager-{{ .Version }}.tar.gz 17 | 18 | release: 19 | github: 20 | owner: cpanato 21 | name: mattermost-plugin-alertmanager 22 | 23 | extra_files: 24 | - glob: ./dist/alertmanager-{{ .Version }}.tar.gz 25 | - glob: ./dist/alertmanager-{{ .Version }}.sig 26 | - glob: ./dist/alertmanager-{{ .Version }}.pem 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GO ?= $(shell command -v go 2> /dev/null) 2 | NPM ?= $(shell command -v npm 2> /dev/null) 3 | CURL ?= $(shell command -v curl 2> /dev/null) 4 | MM_DEBUG ?= 5 | MANIFEST_FILE ?= plugin.json 6 | GOPATH ?= $(shell go env GOPATH) 7 | GO_TEST_FLAGS ?= -race 8 | GO_BUILD_FLAGS ?= 9 | MM_UTILITIES_DIR ?= ../mattermost-utilities 10 | DLV_DEBUG_PORT := 2346 11 | 12 | export GO111MODULE=on 13 | 14 | # You can include assets this directory into the bundle. This can be e.g. used to include profile pictures. 15 | ASSETS_DIR ?= assets 16 | 17 | ## Define the default target (make all) 18 | .PHONY: default 19 | default: all 20 | 21 | # Verify environment, and define PLUGIN_ID, PLUGIN_VERSION, HAS_SERVER and HAS_WEBAPP as needed. 22 | include build/setup.mk 23 | include build/legacy.mk 24 | 25 | BUNDLE_NAME ?= $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz 26 | 27 | # Include custom makefile, if present 28 | ifneq ($(wildcard build/custom.mk),) 29 | include build/custom.mk 30 | endif 31 | 32 | ## Checks the code style, tests, builds and bundles the plugin. 33 | .PHONY: all 34 | all: check-style test dist 35 | 36 | ## Runs eslint and golangci-lint 37 | .PHONY: check-style 38 | check-style: webapp/node_modules 39 | @echo Checking for style guide compliance 40 | 41 | ifneq ($(HAS_WEBAPP),) 42 | cd webapp && npm run lint 43 | cd webapp && npm run check-types 44 | endif 45 | 46 | ifneq ($(HAS_SERVER),) 47 | @if ! [ -x "$$(command -v golangci-lint)" ]; then \ 48 | echo "golangci-lint is not installed. Please see https://github.com/golangci/golangci-lint#install for installation instructions."; \ 49 | exit 1; \ 50 | fi; \ 51 | 52 | @echo Running golangci-lint 53 | golangci-lint run ./... 54 | endif 55 | 56 | ## Builds the server, if it exists, for all supported architectures. 57 | .PHONY: server 58 | server: 59 | ifneq ($(HAS_SERVER),) 60 | mkdir -p server/dist; 61 | ifeq ($(MM_DEBUG),) 62 | cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-linux-amd64; 63 | cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-linux-arm64; 64 | cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-darwin-amd64; 65 | cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-darwin-arm64; 66 | cd server && env CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-windows-amd64.exe; 67 | else 68 | $(info DEBUG mode is on; to disable, unset MM_DEBUG) 69 | 70 | cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -gcflags "all=-N -l" -o dist/plugin-linux-amd64; 71 | cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -gcflags "all=-N -l" -o dist/plugin-linux-arm64; 72 | cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -gcflags "all=-N -l" -o dist/plugin-darwin-amd64; 73 | cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -gcflags "all=-N -l" -o dist/plugin-darwin-arm64; 74 | cd server && env CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -gcflags "all=-N -l" -o dist/plugin-windows-amd64.exe; 75 | endif 76 | endif 77 | 78 | ## Ensures NPM dependencies are installed without having to run this all the time. 79 | webapp/node_modules: $(wildcard webapp/package.json) 80 | ifneq ($(HAS_WEBAPP),) 81 | cd webapp && $(NPM) install 82 | touch $@ 83 | endif 84 | 85 | ## Builds the webapp, if it exists. 86 | .PHONY: webapp 87 | webapp: webapp/node_modules 88 | ifneq ($(HAS_WEBAPP),) 89 | ifeq ($(MM_DEBUG),) 90 | cd webapp && $(NPM) run build; 91 | else 92 | cd webapp && $(NPM) run debug; 93 | endif 94 | endif 95 | 96 | ## Generates a tar bundle of the plugin for install. 97 | .PHONY: bundle 98 | bundle: 99 | rm -rf dist/ 100 | mkdir -p dist/$(PLUGIN_ID) 101 | cp $(MANIFEST_FILE) dist/$(PLUGIN_ID)/ 102 | ifneq ($(wildcard $(ASSETS_DIR)/.),) 103 | cp -r $(ASSETS_DIR) dist/$(PLUGIN_ID)/ 104 | endif 105 | ifneq ($(HAS_PUBLIC),) 106 | cp -r public dist/$(PLUGIN_ID)/ 107 | endif 108 | ifneq ($(HAS_SERVER),) 109 | mkdir -p dist/$(PLUGIN_ID)/server 110 | cp -r server/dist dist/$(PLUGIN_ID)/server/ 111 | endif 112 | ifneq ($(HAS_WEBAPP),) 113 | mkdir -p dist/$(PLUGIN_ID)/webapp 114 | cp -r webapp/dist dist/$(PLUGIN_ID)/webapp/ 115 | endif 116 | cd dist && tar -cvzf $(BUNDLE_NAME) $(PLUGIN_ID) 117 | 118 | @echo plugin built at: dist/$(BUNDLE_NAME) 119 | 120 | ## Builds and bundles the plugin. 121 | .PHONY: dist 122 | dist: server webapp bundle 123 | rm -rf dist/alertmanager 124 | 125 | ## Builds and installs the plugin to a server. 126 | .PHONY: deploy 127 | deploy: dist 128 | ./build/bin/pluginctl deploy $(PLUGIN_ID) dist/$(BUNDLE_NAME) 129 | 130 | ## Builds and installs the plugin to a server, updating the webapp automatically when changed. 131 | .PHONY: watch 132 | watch: server bundle 133 | ifeq ($(MM_DEBUG),) 134 | cd webapp && $(NPM) run build:watch 135 | else 136 | cd webapp && $(NPM) run debug:watch 137 | endif 138 | 139 | ## Installs a previous built plugin with updated webpack assets to a server. 140 | .PHONY: deploy-from-watch 141 | deploy-from-watch: bundle 142 | ./build/bin/pluginctl deploy $(PLUGIN_ID) dist/$(BUNDLE_NAME) 143 | 144 | ## Setup dlv for attaching, identifying the plugin PID for other targets. 145 | .PHONY: setup-attach 146 | setup-attach: 147 | $(eval PLUGIN_PID := $(shell ps aux | grep "plugins/${PLUGIN_ID}" | grep -v "grep" | awk -F " " '{print $$2}')) 148 | $(eval NUM_PID := $(shell echo -n ${PLUGIN_PID} | wc -w)) 149 | 150 | @if [ ${NUM_PID} -gt 2 ]; then \ 151 | echo "** There is more than 1 plugin process running. Run 'make kill reset' to restart just one."; \ 152 | exit 1; \ 153 | fi 154 | 155 | ## Check if setup-attach succeeded. 156 | .PHONY: check-attach 157 | check-attach: 158 | @if [ -z ${PLUGIN_PID} ]; then \ 159 | echo "Could not find plugin PID; the plugin is not running. Exiting."; \ 160 | exit 1; \ 161 | else \ 162 | echo "Located Plugin running with PID: ${PLUGIN_PID}"; \ 163 | fi 164 | 165 | ## Attach dlv to an existing plugin instance. 166 | .PHONY: attach 167 | attach: setup-attach check-attach 168 | dlv attach ${PLUGIN_PID} 169 | 170 | ## Attach dlv to an existing plugin instance, exposing a headless instance on $DLV_DEBUG_PORT. 171 | .PHONY: attach-headless 172 | attach-headless: setup-attach check-attach 173 | dlv attach ${PLUGIN_PID} --listen :$(DLV_DEBUG_PORT) --headless=true --api-version=2 --accept-multiclient 174 | 175 | ## Detach dlv from an existing plugin instance, if previously attached. 176 | .PHONY: detach 177 | detach: setup-attach 178 | @DELVE_PID=$(shell ps aux | grep "dlv attach ${PLUGIN_PID}" | grep -v "grep" | awk -F " " '{print $$2}') && \ 179 | if [ "$$DELVE_PID" -gt 0 ] > /dev/null 2>&1 ; then \ 180 | echo "Located existing delve process running with PID: $$DELVE_PID. Killing." ; \ 181 | kill -9 $$DELVE_PID ; \ 182 | fi 183 | 184 | ## Runs any lints and unit tests defined for the server and webapp, if they exist. 185 | .PHONY: test 186 | test: webapp/node_modules 187 | ifneq ($(HAS_SERVER),) 188 | $(GO) test -v $(GO_TEST_FLAGS) ./server/... 189 | endif 190 | ifneq ($(HAS_WEBAPP),) 191 | cd webapp && $(NPM) run test; 192 | endif 193 | ifneq ($(wildcard ./build/sync/plan/.),) 194 | cd ./build/sync && $(GO) test -v $(GO_TEST_FLAGS) ./... 195 | endif 196 | 197 | ## Creates a coverage report for the server code. 198 | .PHONY: coverage 199 | coverage: webapp/node_modules 200 | ifneq ($(HAS_SERVER),) 201 | $(GO) test $(GO_TEST_FLAGS) -coverprofile=server/coverage.txt ./server/... 202 | $(GO) tool cover -html=server/coverage.txt 203 | endif 204 | 205 | ## Extract strings for translation from the source code. 206 | .PHONY: i18n-extract 207 | i18n-extract: 208 | ifneq ($(HAS_WEBAPP),) 209 | ifeq ($(HAS_MM_UTILITIES),) 210 | @echo "You must clone github.com/mattermost/mattermost-utilities repo in .. to use this command" 211 | else 212 | cd $(MM_UTILITIES_DIR) && npm install && npm run babel && node mmjstool/build/index.js i18n extract-webapp --webapp-dir $(PWD)/webapp 213 | endif 214 | endif 215 | 216 | ## Disable the plugin. 217 | .PHONY: disable 218 | disable: detach 219 | ./build/bin/pluginctl disable $(PLUGIN_ID) 220 | 221 | ## Enable the plugin. 222 | .PHONY: enable 223 | enable: 224 | ./build/bin/pluginctl enable $(PLUGIN_ID) 225 | 226 | ## Reset the plugin, effectively disabling and re-enabling it on the server. 227 | .PHONY: reset 228 | reset: detach 229 | ./build/bin/pluginctl reset $(PLUGIN_ID) 230 | 231 | ## Kill all instances of the plugin, detaching any existing dlv instance. 232 | .PHONY: kill 233 | kill: detach 234 | $(eval PLUGIN_PID := $(shell ps aux | grep "plugins/${PLUGIN_ID}" | grep -v "grep" | awk -F " " '{print $$2}')) 235 | 236 | @for PID in ${PLUGIN_PID}; do \ 237 | echo "Killing plugin pid $$PID"; \ 238 | kill -9 $$PID; \ 239 | done; \ 240 | 241 | ## Clean removes all build artifacts. 242 | .PHONY: clean 243 | clean: 244 | rm -fr dist/ 245 | ifneq ($(HAS_SERVER),) 246 | rm -fr server/coverage.txt 247 | rm -fr server/dist 248 | endif 249 | ifneq ($(HAS_WEBAPP),) 250 | rm -fr webapp/junit.xml 251 | rm -fr webapp/dist 252 | rm -fr webapp/node_modules 253 | endif 254 | rm -fr build/bin/ 255 | 256 | ## Sync directory with a starter template 257 | sync: 258 | ifndef STARTERTEMPLATE_PATH 259 | @echo STARTERTEMPLATE_PATH is not set. 260 | @echo Set STARTERTEMPLATE_PATH to a local clone of https://github.com/mattermost/mattermost-plugin-starter-template and retry. 261 | @exit 1 262 | endif 263 | cd ${STARTERTEMPLATE_PATH} && go run ./build/sync/main.go ./build/sync/plan.yml $(PWD) 264 | 265 | # Help documentation à la https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html 266 | help: 267 | @cat Makefile build/*.mk | grep -v '\.PHONY' | grep -v '\help:' | grep -B1 -E '^[a-zA-Z0-9_.-]+:.*' | sed -e "s/:.*//" | sed -e "s/^## //" | grep -v '\-\-' | sed '1!G;h;$$!d' | awk 'NR%2{printf "\033[36m%-30s\033[0m",$$0;next;}1' | sort 268 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AlertManager Plugin 2 | 3 | This plugin is the [AlertManager](https://github.com/prometheus/alertmanager) bot for Mattermost. 4 | 5 | Forked and inspired on https://github.com/metalmatze/alertmanager-bot the alertmanager for Telegram. Thanks so much [@metalmatze](https://github.com/metalmatze/) 6 | 7 | Some features: 8 | -------------- 9 | - Receive the Alerts via webhook 10 | - Can list existing alerts 11 | - Can list existing silences 12 | - Can expire a silence 13 | 14 | TODO: 15 | ----- 16 | - Create silences 17 | - Create alerts 18 | - List expired silences 19 | - Create and use a bot account 20 | - Allow multiple webhooks/channels 21 | 22 | 23 | **Supported Mattermost Server Versions: 5.37+** 24 | 25 | ## Installation 26 | 27 | 1. Go to the [releases page of this GitHub repository](https://github.com/cpanato/mattermost-plugin-alertmanager/releases) and download the latest release for your Mattermost server. 28 | 2. Upload this file in the Mattermost **System Console > Plugins > Management** page to install the plugin, and enable it. To learn more about how to upload a plugin, [see the documentation](https://docs.mattermost.com/administration/plugins.html#plugin-uploads). 29 | 30 | Next, to configure the plugin, follow these steps: 31 | 32 | 3. After you've uploaded the plugin in **System Console > Plugins > Management**, go to the plugin's settings page at **System Console > Plugins > AlertManager**. 33 | 4. Specify the team and channel to send messages to. For each, use the URL of the team or channel instead of their respective display names. 34 | 5. Specify the AlertManager Server URL. 35 | 6. Generate the Token that will be use to validate the requests. 36 | 7. Hit **Save**. 37 | 8. Next, copy the **Token** above the **Save** button, which is used to configure the plugin for your AlertManager account. 38 | 9. Go to your Alermanager configuration, paste the following webhook URL and specfiy the name of the service and the token you copied in step 9. 39 | 10. Invite the `@alertmanagerbot` user to your target team and channel. 40 | 41 | ``` 42 | https://SITEURL/plugins/alertmanager/api/webhook?token=TOKEN 43 | ``` 44 | Sometimes the token has to be quoted. 45 | 46 | Example alertmanager config: 47 | 48 | ```yaml 49 | webhook_configs: 50 | - send_resolved: true 51 | url: "https://mattermost.example.org/plugins/alertmanager/api/webhook?token='xxxxxxxxxxxxxxxxxxx-yyyyyyy'" 52 | ``` 53 | 54 | 55 | ## Plugin in Action 56 | 57 | # ![alertmanager-bot-1](assets/alertmanager-1.png) 58 | # ![alertmanager-bot-2](assets/alertmanager-2.png) 59 | # ![alertmanager-bot-3](assets/alertmanager-3.png) 60 | -------------------------------------------------------------------------------- /assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanato/mattermost-plugin-alertmanager/a9b9610f044b63b92cb99a2a575094c645436e96/assets/.gitkeep -------------------------------------------------------------------------------- /assets/alertmanager-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanato/mattermost-plugin-alertmanager/a9b9610f044b63b92cb99a2a575094c645436e96/assets/alertmanager-1.png -------------------------------------------------------------------------------- /assets/alertmanager-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanato/mattermost-plugin-alertmanager/a9b9610f044b63b92cb99a2a575094c645436e96/assets/alertmanager-2.png -------------------------------------------------------------------------------- /assets/alertmanager-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanato/mattermost-plugin-alertmanager/a9b9610f044b63b92cb99a2a575094c645436e96/assets/alertmanager-3.png -------------------------------------------------------------------------------- /assets/alertmanager-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanato/mattermost-plugin-alertmanager/a9b9610f044b63b92cb99a2a575094c645436e96/assets/alertmanager-logo.png -------------------------------------------------------------------------------- /assets/alertmanager-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /build/.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | -------------------------------------------------------------------------------- /build/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cpanato/mattermost-plugin-statuspage/build 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/mattermost/mattermost-server/v6 v6.5.2 7 | github.com/pkg/errors v0.9.1 8 | ) 9 | 10 | require ( 11 | github.com/blang/semver v3.5.1+incompatible // indirect 12 | github.com/dustin/go-humanize v1.0.0 // indirect 13 | github.com/dyatlov/go-opengraph v0.0.0-20210112100619-dae8665a5b09 // indirect 14 | github.com/francoispqt/gojay v1.2.13 // indirect 15 | github.com/go-asn1-ber/asn1-ber v1.5.3 // indirect 16 | github.com/google/uuid v1.3.0 // indirect 17 | github.com/gorilla/websocket v1.4.2 // indirect 18 | github.com/graph-gophers/graphql-go v1.3.0 // indirect 19 | github.com/json-iterator/go v1.1.12 // indirect 20 | github.com/klauspost/compress v1.14.2 // indirect 21 | github.com/klauspost/cpuid/v2 v2.0.11 // indirect 22 | github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect 23 | github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d // indirect 24 | github.com/mattermost/logr/v2 v2.0.15 // indirect 25 | github.com/minio/md5-simd v1.1.2 // indirect 26 | github.com/minio/minio-go/v7 v7.0.21 // indirect 27 | github.com/minio/sha256-simd v1.0.0 // indirect 28 | github.com/mitchellh/go-homedir v1.1.0 // indirect 29 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 30 | github.com/modern-go/reflect2 v1.0.2 // indirect 31 | github.com/opentracing/opentracing-go v1.2.0 // indirect 32 | github.com/pborman/uuid v1.2.1 // indirect 33 | github.com/pelletier/go-toml v1.9.4 // indirect 34 | github.com/philhofer/fwd v1.1.1 // indirect 35 | github.com/rs/xid v1.3.0 // indirect 36 | github.com/sirupsen/logrus v1.8.1 // indirect 37 | github.com/tinylib/msgp v1.1.6 // indirect 38 | github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect 39 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 40 | github.com/wiggin77/merror v1.0.3 // indirect 41 | github.com/wiggin77/srslog v1.0.1 // indirect 42 | golang.org/x/crypto v0.17.0 // indirect 43 | golang.org/x/net v0.17.0 // indirect 44 | golang.org/x/sys v0.15.0 // indirect 45 | golang.org/x/text v0.14.0 // indirect 46 | gopkg.in/ini.v1 v1.66.3 // indirect 47 | gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect 48 | gopkg.in/yaml.v2 v2.4.0 // indirect 49 | ) 50 | -------------------------------------------------------------------------------- /build/legacy.mk: -------------------------------------------------------------------------------- 1 | .PHONY: apply 2 | apply: 3 | @echo make apply is deprecated and has no effect. 4 | -------------------------------------------------------------------------------- /build/manifest/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanato/mattermost-plugin-alertmanager/a9b9610f044b63b92cb99a2a575094c645436e96/build/manifest/.gitignore -------------------------------------------------------------------------------- /build/manifest/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/mattermost/mattermost-server/v6/model" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | func main() { 13 | if len(os.Args) <= 1 { 14 | panic("no cmd specified") 15 | } 16 | 17 | manifest, err := findManifest() 18 | if err != nil { 19 | panic("failed to find manifest: " + err.Error()) 20 | } 21 | 22 | cmd := os.Args[1] 23 | switch cmd { 24 | case "id": 25 | dumpPluginID(manifest) 26 | 27 | case "version": 28 | dumpPluginVersion(manifest) 29 | 30 | case "has_server": 31 | if manifest.HasServer() { 32 | fmt.Printf("true") 33 | } 34 | 35 | case "has_webapp": 36 | if manifest.HasWebapp() { 37 | fmt.Printf("true") 38 | } 39 | 40 | default: 41 | panic("unrecognized command: " + cmd) 42 | } 43 | } 44 | 45 | func findManifest() (*model.Manifest, error) { 46 | _, manifestFilePath, err := model.FindManifest(".") 47 | if err != nil { 48 | return nil, errors.Wrap(err, "failed to find manifest in current working directory") 49 | } 50 | manifestFile, err := os.Open(manifestFilePath) 51 | if err != nil { 52 | return nil, errors.Wrapf(err, "failed to open %s", manifestFilePath) 53 | } 54 | defer manifestFile.Close() 55 | 56 | // Re-decode the manifest, disallowing unknown fields. When we write the manifest back out, 57 | // we don't want to accidentally clobber anything we won't preserve. 58 | var manifest model.Manifest 59 | decoder := json.NewDecoder(manifestFile) 60 | decoder.DisallowUnknownFields() 61 | if err = decoder.Decode(&manifest); err != nil { 62 | return nil, errors.Wrap(err, "failed to parse manifest") 63 | } 64 | 65 | return &manifest, nil 66 | } 67 | 68 | // dumpPluginId writes the plugin id from the given manifest to standard out 69 | func dumpPluginID(manifest *model.Manifest) { 70 | fmt.Printf("%s", manifest.Id) 71 | } 72 | 73 | // dumpPluginVersion writes the plugin version from the given manifest to standard out 74 | func dumpPluginVersion(manifest *model.Manifest) { 75 | fmt.Printf("%s", manifest.Version) 76 | } 77 | -------------------------------------------------------------------------------- /build/pluginctl/main.go: -------------------------------------------------------------------------------- 1 | // main handles deployment of the plugin to a development server using the Client4 API. 2 | package main 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net" 9 | "os" 10 | 11 | "github.com/mattermost/mattermost-server/v6/model" 12 | ) 13 | 14 | const helpText = ` 15 | Usage: 16 | pluginctl deploy 17 | pluginctl disable 18 | pluginctl enable 19 | pluginctl reset 20 | ` 21 | 22 | func main() { 23 | err := pluginctl() 24 | if err != nil { 25 | fmt.Printf("Failed: %s\n", err.Error()) 26 | fmt.Print(helpText) 27 | os.Exit(1) 28 | } 29 | } 30 | 31 | func pluginctl() error { 32 | if len(os.Args) < 3 { 33 | return errors.New("invalid number of arguments") 34 | } 35 | 36 | client, err := getClient() 37 | if err != nil { 38 | return err 39 | } 40 | 41 | switch os.Args[1] { 42 | case "deploy": 43 | if len(os.Args) < 4 { 44 | return errors.New("invalid number of arguments") 45 | } 46 | return deploy(client, os.Args[2], os.Args[3]) 47 | case "disable": 48 | return disablePlugin(client, os.Args[2]) 49 | case "enable": 50 | return enablePlugin(client, os.Args[2]) 51 | case "reset": 52 | return resetPlugin(client, os.Args[2]) 53 | default: 54 | return errors.New("invalid second argument") 55 | } 56 | } 57 | 58 | func getClient() (*model.Client4, error) { 59 | socketPath := os.Getenv("MM_LOCALSOCKETPATH") 60 | if socketPath == "" { 61 | socketPath = model.LocalModeSocketPath 62 | } 63 | 64 | client, connected := getUnixClient(socketPath) 65 | if connected { 66 | log.Printf("Connecting using local mode over %s", socketPath) 67 | return client, nil 68 | } 69 | 70 | if os.Getenv("MM_LOCALSOCKETPATH") != "" { 71 | log.Printf("No socket found at %s for local mode deployment. Attempting to authenticate with credentials.", socketPath) 72 | } 73 | 74 | siteURL := os.Getenv("MM_SERVICESETTINGS_SITEURL") 75 | adminToken := os.Getenv("MM_ADMIN_TOKEN") 76 | adminUsername := os.Getenv("MM_ADMIN_USERNAME") 77 | adminPassword := os.Getenv("MM_ADMIN_PASSWORD") 78 | 79 | if siteURL == "" { 80 | return nil, errors.New("MM_SERVICESETTINGS_SITEURL is not set") 81 | } 82 | 83 | client = model.NewAPIv4Client(siteURL) 84 | 85 | if adminToken != "" { 86 | log.Printf("Authenticating using token against %s.", siteURL) 87 | client.SetToken(adminToken) 88 | return client, nil 89 | } 90 | 91 | if adminUsername != "" && adminPassword != "" { 92 | client := model.NewAPIv4Client(siteURL) 93 | log.Printf("Authenticating as %s against %s.", adminUsername, siteURL) 94 | _, _, err := client.Login(adminUsername, adminPassword) 95 | if err != nil { 96 | return nil, fmt.Errorf("failed to login as %s: %w", adminUsername, err) 97 | } 98 | 99 | return client, nil 100 | } 101 | 102 | return nil, errors.New("one of MM_ADMIN_TOKEN or MM_ADMIN_USERNAME/MM_ADMIN_PASSWORD must be defined") 103 | } 104 | 105 | func getUnixClient(socketPath string) (*model.Client4, bool) { 106 | _, err := net.Dial("unix", socketPath) 107 | if err != nil { 108 | return nil, false 109 | } 110 | 111 | return model.NewAPIv4SocketClient(socketPath), true 112 | } 113 | 114 | // deploy attempts to upload and enable a plugin via the Client4 API. 115 | // It will fail if plugin uploads are disabled. 116 | func deploy(client *model.Client4, pluginID, bundlePath string) error { 117 | pluginBundle, err := os.Open(bundlePath) 118 | if err != nil { 119 | return fmt.Errorf("failed to open %s: %w", bundlePath, err) 120 | } 121 | defer pluginBundle.Close() 122 | 123 | log.Print("Uploading plugin via API.") 124 | _, _, err = client.UploadPluginForced(pluginBundle) 125 | if err != nil { 126 | return fmt.Errorf("failed to upload plugin bundle: %s", err.Error()) 127 | } 128 | 129 | log.Print("Enabling plugin.") 130 | _, err = client.EnablePlugin(pluginID) 131 | if err != nil { 132 | return fmt.Errorf("failed to enable plugin: %s", err.Error()) 133 | } 134 | 135 | return nil 136 | } 137 | 138 | // disablePlugin attempts to disable the plugin via the Client4 API. 139 | func disablePlugin(client *model.Client4, pluginID string) error { 140 | log.Print("Disabling plugin.") 141 | _, err := client.DisablePlugin(pluginID) 142 | if err != nil { 143 | return fmt.Errorf("failed to disable plugin: %w", err) 144 | } 145 | 146 | return nil 147 | } 148 | 149 | // enablePlugin attempts to enable the plugin via the Client4 API. 150 | func enablePlugin(client *model.Client4, pluginID string) error { 151 | log.Print("Enabling plugin.") 152 | _, err := client.EnablePlugin(pluginID) 153 | if err != nil { 154 | return fmt.Errorf("failed to enable plugin: %w", err) 155 | } 156 | 157 | return nil 158 | } 159 | 160 | // resetPlugin attempts to reset the plugin via the Client4 API. 161 | func resetPlugin(client *model.Client4, pluginID string) error { 162 | err := disablePlugin(client, pluginID) 163 | if err != nil { 164 | return err 165 | } 166 | 167 | err = enablePlugin(client, pluginID) 168 | if err != nil { 169 | return err 170 | } 171 | 172 | return nil 173 | } 174 | -------------------------------------------------------------------------------- /build/setup.mk: -------------------------------------------------------------------------------- 1 | # Ensure that go is installed. Note that this is independent of whether or not a server is being 2 | # built, since the build script itself uses go. 3 | ifeq ($(GO),) 4 | $(error "go is not available: see https://golang.org/doc/install") 5 | endif 6 | 7 | # Ensure that the build tools are compiled. Go's caching makes this quick. 8 | $(shell cd build/manifest && $(GO) build -o ../bin/manifest) 9 | 10 | # Ensure that the deployment tools are compiled. Go's caching makes this quick. 11 | $(shell cd build/pluginctl && $(GO) build -o ../bin/pluginctl) 12 | 13 | # Extract the plugin id from the manifest. 14 | PLUGIN_ID ?= $(shell build/bin/manifest id) 15 | ifeq ($(PLUGIN_ID),) 16 | $(error "Cannot parse id from $(MANIFEST_FILE)") 17 | endif 18 | 19 | # Extract the plugin version from the manifest. 20 | PLUGIN_VERSION ?= $(shell build/bin/manifest version) 21 | ifeq ($(PLUGIN_VERSION),) 22 | $(error "Cannot parse version from $(MANIFEST_FILE)") 23 | endif 24 | 25 | # Determine if a server is defined in the manifest. 26 | HAS_SERVER ?= $(shell build/bin/manifest has_server) 27 | 28 | # Determine if a webapp is defined in the manifest. 29 | HAS_WEBAPP ?= $(shell build/bin/manifest has_webapp) 30 | 31 | # Determine if a /public folder is in use 32 | HAS_PUBLIC ?= $(wildcard public/.) 33 | 34 | # Determine if the mattermost-utilities repo is present 35 | HAS_MM_UTILITIES ?= $(wildcard $(MM_UTILITIES_DIR)/.) 36 | 37 | # Store the current path for later use 38 | PWD ?= $(shell pwd) 39 | 40 | # Ensure that npm (and thus node) is installed. 41 | ifneq ($(HAS_WEBAPP),) 42 | ifeq ($(NPM),) 43 | $(error "npm is not available: see https://www.npmjs.com/get-npm") 44 | endif 45 | endif 46 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cpanato/mattermost-plugin-alertmanager 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/cenkalti/backoff v2.2.1+incompatible 7 | github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b 8 | github.com/mattermost/mattermost-plugin-api v0.1.4 9 | // mmgoget: github.com/mattermost/mattermost-server/v6@v7.4.0 is replaced by -> github.com/mattermost/mattermost-server/v6@8cb6718a9b 10 | github.com/mattermost/mattermost-server/v6 v6.0.0-20221109191448-21aec2741bfe 11 | github.com/prometheus/alertmanager v0.26.0 12 | github.com/stretchr/testify v1.9.0 13 | golang.org/x/text v0.19.0 14 | ) 15 | 16 | require ( 17 | github.com/armon/go-metrics v0.3.10 // indirect 18 | github.com/aws/aws-sdk-go v1.44.317 // indirect 19 | github.com/benbjohnson/clock v1.3.5 // indirect 20 | github.com/beorn7/perks v1.0.1 // indirect 21 | github.com/blang/semver v3.5.1+incompatible // indirect 22 | github.com/blang/semver/v4 v4.0.0 // indirect 23 | github.com/cenkalti/backoff/v4 v4.2.1 // indirect 24 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 25 | github.com/coreos/go-systemd/v22 v22.5.0 // indirect 26 | github.com/davecgh/go-spew v1.1.1 // indirect 27 | github.com/dustin/go-humanize v1.0.0 // indirect 28 | github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a // indirect 29 | github.com/fatih/color v1.13.0 // indirect 30 | github.com/francoispqt/gojay v1.2.13 // indirect 31 | github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect 32 | github.com/go-kit/log v0.2.1 // indirect 33 | github.com/go-logfmt/logfmt v0.5.1 // indirect 34 | github.com/go-sql-driver/mysql v1.6.0 // indirect 35 | github.com/gofrs/uuid v4.4.0+incompatible // indirect 36 | github.com/gogo/protobuf v1.3.2 // indirect 37 | github.com/golang/protobuf v1.5.3 // indirect 38 | github.com/google/btree v1.0.0 // indirect 39 | github.com/google/uuid v1.3.0 // indirect 40 | github.com/gorilla/websocket v1.5.0 // indirect 41 | github.com/graph-gophers/graphql-go v1.4.0 // indirect 42 | github.com/hashicorp/errwrap v1.0.0 // indirect 43 | github.com/hashicorp/go-hclog v1.2.2 // indirect 44 | github.com/hashicorp/go-immutable-radix v1.3.1 // indirect 45 | github.com/hashicorp/go-msgpack v0.5.3 // indirect 46 | github.com/hashicorp/go-multierror v1.1.0 // indirect 47 | github.com/hashicorp/go-plugin v1.4.4 // indirect 48 | github.com/hashicorp/go-sockaddr v1.0.2 // indirect 49 | github.com/hashicorp/golang-lru v0.6.0 // indirect 50 | github.com/hashicorp/golang-lru/v2 v2.0.2 // indirect 51 | github.com/hashicorp/memberlist v0.5.0 // indirect 52 | github.com/hashicorp/yamux v0.1.1 // indirect 53 | github.com/jmespath/go-jmespath v0.4.0 // indirect 54 | github.com/jpillora/backoff v1.0.0 // indirect 55 | github.com/json-iterator/go v1.1.12 // indirect 56 | github.com/klauspost/compress v1.15.9 // indirect 57 | github.com/klauspost/cpuid/v2 v2.1.0 // indirect 58 | github.com/lib/pq v1.10.6 // indirect 59 | github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect 60 | github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d // indirect 61 | github.com/mattermost/logr/v2 v2.0.15 // indirect 62 | github.com/mattn/go-colorable v0.1.13 // indirect 63 | github.com/mattn/go-isatty v0.0.16 // indirect 64 | github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect 65 | github.com/miekg/dns v1.1.41 // indirect 66 | github.com/minio/md5-simd v1.1.2 // indirect 67 | github.com/minio/minio-go/v7 v7.0.34 // indirect 68 | github.com/minio/sha256-simd v1.0.0 // indirect 69 | github.com/mitchellh/go-testing-interface v1.14.1 // indirect 70 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 71 | github.com/modern-go/reflect2 v1.0.2 // indirect 72 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect 73 | github.com/oklog/run v1.1.0 // indirect 74 | github.com/oklog/ulid v1.3.1 // indirect 75 | github.com/pborman/uuid v1.2.1 // indirect 76 | github.com/pelletier/go-toml v1.9.5 // indirect 77 | github.com/philhofer/fwd v1.1.1 // indirect 78 | github.com/pkg/errors v0.9.1 // indirect 79 | github.com/pmezard/go-difflib v1.0.0 // indirect 80 | github.com/prometheus/client_golang v1.15.1 // indirect 81 | github.com/prometheus/client_model v0.4.0 // indirect 82 | github.com/prometheus/common v0.44.0 // indirect 83 | github.com/prometheus/common/sigv4 v0.1.0 // indirect 84 | github.com/prometheus/exporter-toolkit v0.10.0 // indirect 85 | github.com/prometheus/procfs v0.9.0 // indirect 86 | github.com/rs/xid v1.4.0 // indirect 87 | github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect 88 | github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect 89 | github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 // indirect 90 | github.com/sirupsen/logrus v1.9.0 // indirect 91 | github.com/tinylib/msgp v1.1.6 // indirect 92 | github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect 93 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 94 | github.com/wiggin77/merror v1.0.4 // indirect 95 | github.com/wiggin77/srslog v1.0.1 // indirect 96 | github.com/yuin/goldmark v1.4.13 // indirect 97 | golang.org/x/crypto v0.17.0 // indirect 98 | golang.org/x/net v0.17.0 // indirect 99 | golang.org/x/oauth2 v0.8.0 // indirect 100 | golang.org/x/sync v0.8.0 // indirect 101 | golang.org/x/sys v0.15.0 // indirect 102 | google.golang.org/appengine v1.6.7 // indirect 103 | google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect 104 | google.golang.org/grpc v1.56.3 // indirect 105 | google.golang.org/protobuf v1.33.0 // indirect 106 | gopkg.in/ini.v1 v1.67.0 // indirect 107 | gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect 108 | gopkg.in/yaml.v2 v2.4.0 // indirect 109 | gopkg.in/yaml.v3 v3.0.1 // indirect 110 | ) 111 | -------------------------------------------------------------------------------- /hack/config.yml: -------------------------------------------------------------------------------- 1 | global: 2 | resolve_timeout: 5m 3 | http_config: {} 4 | smtp_hello: localhost 5 | smtp_require_tls: true 6 | route: 7 | receiver: mattermost-alertmananger 8 | group_by: 9 | - alertname 10 | group_wait: 10s 11 | group_interval: 10s 12 | repeat_interval: 30m 13 | inhibit_rules: 14 | - source_match: 15 | severity: critical 16 | target_match: 17 | severity: warning 18 | equal: 19 | - alertname 20 | - dev 21 | - instance 22 | templates: [] 23 | receivers: 24 | - name: 'mattermost-alertmananger' 25 | webhook_configs: 26 | - send_resolved: true 27 | url: 'https://cpanato-outrider.eu.ngrok.io/plugins/alertmanager/api/webhook?token=FR2kDopHGgydR7Zhtyrxvz-8a6Pjavai' 28 | -------------------------------------------------------------------------------- /hack/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | alertmanager: 5 | ports: 6 | - 9093:9093 7 | image: bitnami/alertmanager:latest 8 | volumes: 9 | - ./config.yml:/opt/bitnami/alertmanager/conf/config.yml 10 | -------------------------------------------------------------------------------- /hack/sample.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | name=$RANDOM 4 | url='http://localhost:9093/api/v1/alerts' 5 | 6 | echo "firing up alert $name" 7 | 8 | # change url o 9 | curl -XPOST $url -d "[{ 10 | \"status\": \"firing\", 11 | \"labels\": { 12 | \"alertname\": \"$name\", 13 | \"service\": \"my-service\", 14 | \"severity\":\"warning\", 15 | \"instance\": \"$name.example.net\" 16 | }, 17 | \"annotations\": { 18 | \"summary\": \"Run to the montains!\" 19 | }, 20 | \"generatorURL\": \"http://prometheus.int.example.net/\" 21 | }]" 22 | 23 | echo "" 24 | 25 | echo "press enter to resolve alert" 26 | read 27 | 28 | echo "sending resolve" 29 | curl -XPOST $url -d "[{ 30 | \"status\": \"resolved\", 31 | \"labels\": { 32 | \"alertname\": \"$name\", 33 | \"service\": \"my-service\", 34 | \"severity\":\"warning\", 35 | \"instance\": \"$name.example.net\" 36 | }, 37 | \"annotations\": { 38 | \"summary\": \"Run to the montains!\" 39 | }, 40 | \"generatorURL\": \"http://prometheus.int.example.net/\" 41 | }]" 42 | 43 | echo "" 44 | -------------------------------------------------------------------------------- /plugin.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | _ "embed" // Need to embed manifest file 5 | "encoding/json" 6 | "strings" 7 | 8 | "github.com/mattermost/mattermost-server/v6/model" 9 | ) 10 | 11 | //go:embed plugin.json 12 | var manifestString string 13 | 14 | var Manifest model.Manifest 15 | 16 | func init() { 17 | _ = json.NewDecoder(strings.NewReader(manifestString)).Decode(&Manifest) 18 | } 19 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "alertmanager", 3 | "name": "AlertManager", 4 | "description": "Alermanager plugin for Mattermost, you can receive alerts and interact with alertmanager.", 5 | "homepage_url": "https://github.com/cpanato/mattermost-plugin-alertmanager", 6 | "support_url": "https://github.com/cpanato/mattermost-plugin-alertmanager/issues", 7 | "release_notes_url": "https://github.com/cpanato/mattermost-plugin-alertmanager/releases/tag/v0.4.0", 8 | "icon_path": "assets/alertmanager-logo.svg", 9 | "version": "0.4.1", 10 | "min_server_version": "7.5.0", 11 | "server": { 12 | "executables": { 13 | "linux-amd64": "server/dist/plugin-linux-amd64", 14 | "linux-arm64": "server/dist/plugin-linux-arm64", 15 | "darwin-amd64": "server/dist/plugin-darwin-amd64", 16 | "darwin-arm64": "server/dist/plugin-darwin-arm64", 17 | "windows-amd64": "server/dist/plugin-windows-amd64.exe" 18 | } 19 | }, 20 | "webapp": { 21 | "bundle_path": "webapp/dist/main.js" 22 | }, 23 | "settings_schema": { 24 | "settings": [{ 25 | "key": "alertConfigs", 26 | "type": "custom", 27 | "display_name": "Alert manager settings:" 28 | }] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | coverage.txt 2 | vendor 3 | .depensure 4 | -------------------------------------------------------------------------------- /server/action_context.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // ActionContext passed from action buttons 4 | type ActionContext struct { 5 | SilenceID string `json:"silence_id"` 6 | UserID string `json:"user_id"` 7 | Action string `json:"action"` 8 | } 9 | 10 | // Action type for decoding action buttons 11 | type Action struct { 12 | Context *ActionContext `json:"context"` 13 | UserID string `json:"user_id"` 14 | PostID string `json:"post_id"` 15 | } 16 | -------------------------------------------------------------------------------- /server/actions.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/mattermost/mattermost-server/v6/model" 9 | 10 | "github.com/cpanato/mattermost-plugin-alertmanager/server/alertmanager" 11 | ) 12 | 13 | func (p *Plugin) handleExpireAction(w http.ResponseWriter, r *http.Request, alertConfig alertConfig) { 14 | p.API.LogInfo("Received expire silence action") 15 | 16 | var action *Action 17 | _ = json.NewDecoder(r.Body).Decode(&action) 18 | 19 | if action == nil { 20 | encodeEphermalMessage(w, "We could not decode the action") 21 | return 22 | } 23 | 24 | if action.Context.SilenceID == "" { 25 | encodeEphermalMessage(w, "Silence ID cannot be empty") 26 | return 27 | } 28 | 29 | silenceDeletedMsg := fmt.Sprintf("Silence %s expired.", action.Context.SilenceID) 30 | 31 | err := alertmanager.ExpireSilence(action.Context.SilenceID, alertConfig.AlertManagerURL) 32 | if err != nil { 33 | msg := fmt.Sprintf("failed to expire the silence: %v", err) 34 | encodeEphermalMessage(w, msg) 35 | } 36 | 37 | updatePost := &model.Post{} 38 | 39 | attachments := []*model.SlackAttachment{} 40 | actionPost, errPost := p.API.GetPost(action.PostID) 41 | if errPost != nil { 42 | p.API.LogError("AlerManager Update Post Error", "err=", errPost.Error()) 43 | } else { 44 | for _, attachment := range actionPost.Attachments() { 45 | if attachment.Actions == nil { 46 | attachments = append(attachments, attachment) 47 | continue 48 | } 49 | for _, actionItem := range attachment.Actions { 50 | if actionItem.Integration.Context["silence_id"] == action.Context.SilenceID { 51 | updateAttachment := attachment 52 | updateAttachment.Actions = nil 53 | updateAttachment.Color = colorExpired 54 | var silenceMsg string 55 | userName, errUser := p.API.GetUser(action.UserID) 56 | if errUser != nil { 57 | silenceMsg = "Silence expired" 58 | } else { 59 | silenceMsg = fmt.Sprintf("Silence expired by %s", userName.Username) 60 | } 61 | 62 | field := &model.SlackAttachmentField{ 63 | Title: "Expired by", 64 | Value: silenceMsg, 65 | Short: false, 66 | } 67 | updateAttachment.Fields = append(updateAttachment.Fields, field) 68 | attachments = append(attachments, updateAttachment) 69 | } else { 70 | attachments = append(attachments, attachment) 71 | } 72 | } 73 | } 74 | retainedProps := []string{"override_username", "override_icon_url"} 75 | updatePost.AddProp("from_webhook", "true") 76 | 77 | for _, prop := range retainedProps { 78 | if value, ok := actionPost.Props[prop]; ok { 79 | updatePost.AddProp(prop, value) 80 | } 81 | } 82 | 83 | model.ParseSlackAttachment(updatePost, attachments) 84 | updatePost.Id = actionPost.Id 85 | updatePost.ChannelId = actionPost.ChannelId 86 | updatePost.UserId = actionPost.UserId 87 | if _, err := p.API.UpdatePost(updatePost); err != nil { 88 | encodeEphermalMessage(w, silenceDeletedMsg) 89 | return 90 | } 91 | } 92 | 93 | encodeEphermalMessage(w, silenceDeletedMsg) 94 | } 95 | 96 | func encodeEphermalMessage(w http.ResponseWriter, message string) { 97 | w.Header().Set("Content-Type", "application/json") 98 | payload := map[string]interface{}{ 99 | "ephemeral_text": message, 100 | } 101 | 102 | _ = json.NewEncoder(w).Encode(payload) 103 | } 104 | -------------------------------------------------------------------------------- /server/alertmanager/alerts.go: -------------------------------------------------------------------------------- 1 | package alertmanager 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/prometheus/alertmanager/types" 8 | ) 9 | 10 | // ListAlerts returns a slice of Alert and an error. 11 | func ListAlerts(alertmanagerURL string) ([]*types.Alert, error) { 12 | resp, err := httpRetry(http.MethodGet, alertmanagerURL+"/api/v2/alerts") 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | var alertResponse []*types.Alert 18 | dec := json.NewDecoder(resp.Body) 19 | defer resp.Body.Close() 20 | if errDec := dec.Decode(&alertResponse); errDec != nil { 21 | return nil, errDec 22 | } 23 | 24 | return alertResponse, err 25 | } 26 | -------------------------------------------------------------------------------- /server/alertmanager/retry.go: -------------------------------------------------------------------------------- 1 | package alertmanager 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/cenkalti/backoff" 10 | ) 11 | 12 | func httpBackoff() *backoff.ExponentialBackOff { 13 | b := backoff.NewExponentialBackOff() 14 | b.InitialInterval = 200 * time.Millisecond 15 | b.MaxInterval = 15 * time.Second 16 | b.MaxElapsedTime = 30 * time.Second 17 | return b 18 | } 19 | 20 | func httpRetry(method string, url string) (*http.Response, error) { 21 | var resp *http.Response 22 | var err error 23 | 24 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) 25 | defer cancel() 26 | 27 | fn := func() error { 28 | req, errReq := http.NewRequest(method, url, nil) 29 | if errReq != nil { 30 | return errReq 31 | } 32 | 33 | req = req.WithContext(ctx) 34 | resp, err = http.DefaultClient.Do(req) // nolint: bodyclose 35 | if err != nil { 36 | return err 37 | } 38 | 39 | switch method { 40 | case http.MethodGet: 41 | if resp.StatusCode != http.StatusOK { 42 | return fmt.Errorf("status code is %d not 200", resp.StatusCode) 43 | } 44 | case http.MethodPost: 45 | if resp.StatusCode == http.StatusBadRequest { 46 | return fmt.Errorf("status code is %d not 3xx", resp.StatusCode) 47 | } 48 | } 49 | 50 | return nil 51 | } 52 | 53 | if errRetry := backoff.Retry(fn, httpBackoff()); errRetry != nil { 54 | return nil, errRetry 55 | } 56 | 57 | return resp, err 58 | } 59 | -------------------------------------------------------------------------------- /server/alertmanager/silences.go: -------------------------------------------------------------------------------- 1 | package alertmanager 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "sort" 10 | "time" 11 | 12 | "github.com/prometheus/alertmanager/types" 13 | ) 14 | 15 | // ListSilences returns a slice of Silence and an error. 16 | func ListSilences(alertmanagerURL string) ([]types.Silence, error) { 17 | resp, err := httpRetry(http.MethodGet, alertmanagerURL+"/api/v2/silences") 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | var silencesResponse []types.Silence 23 | dec := json.NewDecoder(resp.Body) 24 | defer resp.Body.Close() 25 | if errDec := dec.Decode(&silencesResponse); errDec != nil { 26 | return nil, errDec 27 | } 28 | 29 | silences := silencesResponse 30 | sort.Slice(silences, func(i, j int) bool { 31 | return silences[i].EndsAt.After(silences[j].EndsAt) 32 | }) 33 | 34 | return silences, err 35 | } 36 | 37 | // DeleteSilence delete a silence by ID. 38 | func ExpireSilence(silenceID, alertmanagerURL string) error { 39 | if silenceID == "" { 40 | return fmt.Errorf("silence ID cannot be empty") 41 | } 42 | 43 | expireSilence := fmt.Sprintf("%s/api/v2/silence/%s", alertmanagerURL, silenceID) 44 | resp, err := httpRetry(http.MethodDelete, expireSilence) 45 | if err != nil { 46 | return err 47 | } 48 | defer resp.Body.Close() 49 | 50 | body, err := io.ReadAll(resp.Body) 51 | if err != nil { 52 | return err 53 | } 54 | if resp.StatusCode != 200 { 55 | return errors.New(string(body)) 56 | } 57 | 58 | return nil 59 | } 60 | 61 | // Resolved returns if a silence is reolved by EndsAt 62 | func Resolved(s types.Silence) bool { 63 | if s.EndsAt.IsZero() { 64 | return false 65 | } 66 | return !s.EndsAt.After(time.Now()) 67 | } 68 | -------------------------------------------------------------------------------- /server/alertmanager/silences_test.go: -------------------------------------------------------------------------------- 1 | package alertmanager 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/prometheus/alertmanager/types" 10 | ) 11 | 12 | func TestResolved(t *testing.T) { 13 | s := types.Silence{} 14 | assert.False(t, Resolved(s)) 15 | 16 | s.EndsAt = time.Now().Add(time.Minute) 17 | assert.False(t, Resolved(s)) 18 | 19 | s.EndsAt = time.Now().Add(-1 * time.Minute) 20 | assert.True(t, Resolved(s)) 21 | } 22 | -------------------------------------------------------------------------------- /server/alertmanager/status.go: -------------------------------------------------------------------------------- 1 | package alertmanager 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | // StatusResponse is the data returned by Alertmanager about its current status. 10 | type StatusResponse struct { 11 | Uptime time.Time `json:"uptime"` 12 | VersionInfo struct { 13 | Branch string `json:"branch"` 14 | BuildDate string `json:"buildDate"` 15 | BuildUser string `json:"buildUser"` 16 | GoVersion string `json:"goVersion"` 17 | Revision string `json:"revision"` 18 | Version string `json:"version"` 19 | } `json:"versionInfo"` 20 | } 21 | 22 | // Status returns a StatusResponse or an error. 23 | func Status(alertmanagerURL string) (StatusResponse, error) { 24 | var statusResponse StatusResponse 25 | 26 | resp, err := httpRetry(http.MethodGet, alertmanagerURL+"/api/v2/status") 27 | if err != nil { 28 | return statusResponse, err 29 | } 30 | 31 | dec := json.NewDecoder(resp.Body) 32 | defer resp.Body.Close() 33 | if err := dec.Decode(&statusResponse); err != nil { 34 | return statusResponse, err 35 | } 36 | 37 | return statusResponse, nil 38 | } 39 | -------------------------------------------------------------------------------- /server/colors.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const ( 4 | colorFiring = "#FF0000" // green 5 | colorResolved = "#008000" // gray 6 | colorExpired = "#F0F8FF" // aliceBlue 7 | ) 8 | -------------------------------------------------------------------------------- /server/commands.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "time" 8 | 9 | "github.com/hako/durafmt" 10 | "github.com/prometheus/alertmanager/types" 11 | 12 | "github.com/mattermost/mattermost-plugin-api/experimental/command" 13 | "github.com/mattermost/mattermost-server/v6/model" 14 | "github.com/mattermost/mattermost-server/v6/plugin" 15 | 16 | "github.com/cpanato/mattermost-plugin-alertmanager/server/alertmanager" 17 | ) 18 | 19 | const ( 20 | actionHelp = "help" 21 | actionAbout = "about" 22 | 23 | helpMsg = `run: 24 | /alertmanager alerts - to list the existing alerts 25 | /alertmanager silences - to list the existing silences 26 | /alertmanager expire_silence - to expire a silence 27 | /alertmanager status - to list the version and uptime of the Alertmanager instance 28 | /alertmanager help - display Slash Command help text" 29 | /alertmanager about - display build information 30 | ` 31 | ) 32 | 33 | func (p *Plugin) getCommand() (*model.Command, error) { 34 | iconData, err := command.GetIconData(p.API, "assets/alertmanager-logo.svg") 35 | if err != nil { 36 | return nil, fmt.Errorf("failed to get icon data %w", err) 37 | } 38 | 39 | return &model.Command{ 40 | Trigger: "alertmanager", 41 | AutoComplete: true, 42 | AutoCompleteDesc: fmt.Sprintf("Available commands: status, alerts, silences, expire_silence, %s, %s", actionHelp, actionAbout), 43 | AutoCompleteHint: "[command]", 44 | AutocompleteData: getAutocompleteData(), 45 | AutocompleteIconData: iconData, 46 | }, nil 47 | } 48 | 49 | func getAutocompleteData() *model.AutocompleteData { 50 | root := model.NewAutocompleteData("alertmanager", "[command]", fmt.Sprintf("Available commands: status, alerts, silences, expire_silence, %s, %s", actionHelp, actionAbout)) 51 | 52 | alerts := model.NewAutocompleteData("alerts", "", "List the existing alerts") 53 | root.AddCommand(alerts) 54 | 55 | silences := model.NewAutocompleteData("silences", "", "List the existing silences") 56 | root.AddCommand(silences) 57 | 58 | expireSilence := model.NewAutocompleteData("expire_silence", "[AlertManager Config ID] [Silence ID]", "Expire an existing silence") 59 | expireSilence.AddTextArgument("The number of the alert configuration", "[AlertManager Config ID]", "") 60 | expireSilence.AddTextArgument("The ID of the silence to expire", "[Silence ID]", "") 61 | root.AddCommand(expireSilence) 62 | 63 | status := model.NewAutocompleteData("status", "", "List the version and uptime of the Alertmanager instance") 64 | root.AddCommand(status) 65 | 66 | help := model.NewAutocompleteData(actionHelp, "", "Display Slash Command help text") 67 | root.AddCommand(help) 68 | 69 | info := command.BuildInfoAutocomplete(actionAbout) 70 | root.AddCommand(info) 71 | 72 | return root 73 | } 74 | 75 | func (p *Plugin) postCommandResponse(args *model.CommandArgs, text string) { 76 | post := &model.Post{ 77 | UserId: p.BotUserID, 78 | ChannelId: args.ChannelId, 79 | RootId: args.RootId, 80 | Message: text, 81 | } 82 | _ = p.API.SendEphemeralPost(args.UserId, post) 83 | } 84 | 85 | func (p *Plugin) ExecuteCommand(_ *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { 86 | msg := p.executeCommand(args) 87 | if msg != "" { 88 | p.postCommandResponse(args, msg) 89 | } 90 | 91 | return &model.CommandResponse{}, nil 92 | } 93 | 94 | func (p *Plugin) executeCommand(args *model.CommandArgs) string { 95 | split := strings.Fields(args.Command) 96 | cmd := split[0] 97 | action := "" 98 | if len(split) > 1 { 99 | action = strings.TrimSpace(split[1]) 100 | } 101 | 102 | if cmd != "/alertmanager" { 103 | return "" 104 | } 105 | 106 | if action == "" { 107 | return "Missing command, please run `/alertmanager help` to check all commands available." 108 | } 109 | 110 | var msg string 111 | var err error 112 | switch action { 113 | case "alerts": 114 | msg, err = p.handleAlert(args) 115 | case "status": 116 | msg, err = p.handleStatus(args) 117 | case "silences": 118 | msg, err = p.handleListSilences(args) 119 | case "expire_silence": 120 | msg, err = p.handleExpireSilence(args) 121 | case actionAbout: 122 | msg, err = command.BuildInfo(Manifest) 123 | case actionHelp: 124 | msg = helpMsg 125 | default: 126 | msg = helpMsg 127 | } 128 | 129 | if err != nil { 130 | return err.Error() 131 | } 132 | 133 | return msg 134 | } 135 | 136 | func (p *Plugin) handleAlert(args *model.CommandArgs) (string, error) { 137 | configuration := p.getConfiguration() 138 | var alertsCount = 0 139 | var errors []string 140 | 141 | for _, alertConfig := range configuration.AlertConfigs { 142 | alerts, err := alertmanager.ListAlerts(alertConfig.AlertManagerURL) 143 | if err != nil { 144 | errors = append(errors, fmt.Sprintf("AlertManagerURL %q: failed to list alerts... %v", alertConfig.AlertManagerURL, err)) 145 | continue 146 | } 147 | if len(alerts) == 0 { 148 | continue 149 | } 150 | alertsCount += len(alerts) 151 | 152 | attachments := make([]*model.SlackAttachment, 0) 153 | for _, alert := range alerts { 154 | var fields []*model.SlackAttachmentField 155 | fields = addFields(fields, "Status", string(alert.Status()), false) 156 | for k, v := range alert.Annotations { 157 | fields = addFields(fields, string(k), string(v), true) 158 | } 159 | for k, v := range alert.Labels { 160 | fields = addFields(fields, string(k), string(v), true) 161 | } 162 | fields = addFields(fields, "Resolved", strconv.FormatBool(alert.Resolved()), false) 163 | fields = addFields(fields, "Start At", alert.StartsAt.String(), true) 164 | fields = addFields(fields, "Ended At", alert.EndsAt.String(), true) 165 | fields = addFields(fields, "AlertManager Config ID", alertConfig.ID, true) 166 | attachment := &model.SlackAttachment{ 167 | Title: fmt.Sprintf("Alert Name: %s", alert.Name()), 168 | Fields: fields, 169 | Color: setColor(string(alert.Status())), 170 | } 171 | attachments = append(attachments, attachment) 172 | } 173 | 174 | post := &model.Post{ 175 | ChannelId: p.AlertConfigIDChannelID[alertConfig.ID], 176 | UserId: p.BotUserID, 177 | RootId: args.RootId, 178 | } 179 | 180 | model.ParseSlackAttachment(post, attachments) 181 | if _, appErr := p.API.CreatePost(post); appErr != nil { 182 | errors = append(errors, fmt.Sprintf("Channel %q: Error creating the Alert post", alertConfig.Channel)) 183 | continue 184 | } 185 | } 186 | 187 | if len(errors) > 0 { 188 | return strings.Join(errors, "\n"), nil 189 | } 190 | 191 | if alertsCount == 0 { 192 | return "No alerts right now! :tada:", nil 193 | } 194 | 195 | return "", nil 196 | } 197 | 198 | func (p *Plugin) handleStatus(args *model.CommandArgs) (string, error) { 199 | configuration := p.getConfiguration() 200 | 201 | var errors []string 202 | for _, alertConfig := range configuration.AlertConfigs { 203 | status, err := alertmanager.Status(alertConfig.AlertManagerURL) 204 | if err != nil { 205 | errors = append(errors, fmt.Sprintf("AlertManagerURL %q: failed to get status... %v", alertConfig.AlertManagerURL, err)) 206 | continue 207 | } 208 | 209 | uptime := durafmt.Parse(time.Since(status.Uptime)).String() 210 | var fields []*model.SlackAttachmentField 211 | fields = addFields(fields, "AlertManager Version ", status.VersionInfo.Version, false) 212 | fields = addFields(fields, "AlertManager Uptime", uptime, false) 213 | 214 | attachment := &model.SlackAttachment{ 215 | Fields: fields, 216 | } 217 | 218 | post := &model.Post{ 219 | ChannelId: p.AlertConfigIDChannelID[alertConfig.ID], 220 | UserId: p.BotUserID, 221 | RootId: args.RootId, 222 | } 223 | 224 | model.ParseSlackAttachment(post, []*model.SlackAttachment{attachment}) 225 | if _, appErr := p.API.CreatePost(post); appErr != nil { 226 | errors = append(errors, fmt.Sprintf("Channel %q: Error creating the Status post", alertConfig.Channel)) 227 | continue 228 | } 229 | } 230 | 231 | if len(errors) > 0 { 232 | return strings.Join(errors, "\n"), nil 233 | } 234 | 235 | if len(configuration.AlertConfigs) == 0 { 236 | return "No alert managers are configured!", nil 237 | } 238 | 239 | return "", nil 240 | } 241 | 242 | func (p *Plugin) handleListSilences(args *model.CommandArgs) (string, error) { 243 | configuration := p.getConfiguration() 244 | var errors []string 245 | var silencesCount = 0 246 | var pendingSilencesCount = 0 247 | 248 | config := p.API.GetConfig() 249 | siteURLPort := *config.ServiceSettings.ListenAddress 250 | 251 | for _, alertConfig := range configuration.AlertConfigs { 252 | silences, err := alertmanager.ListSilences(alertConfig.AlertManagerURL) 253 | if err != nil { 254 | errors = append(errors, fmt.Sprintf("AlertManagerURL %q: failed to get silences... %v", alertConfig.AlertManagerURL, err)) 255 | continue 256 | } 257 | if len(silences) == 0 { 258 | continue 259 | } 260 | silencesCount += len(silences) 261 | 262 | attachments := make([]*model.SlackAttachment, 0) 263 | for _, silence := range silences { 264 | attachment := ConvertSilenceToSlackAttachment(silence, alertConfig, args.UserId, siteURLPort) 265 | if attachment != nil { 266 | attachments = append(attachments, attachment) 267 | } 268 | } 269 | 270 | if len(attachments) == 0 { 271 | continue 272 | } 273 | pendingSilencesCount += len(attachments) 274 | 275 | post := &model.Post{ 276 | ChannelId: p.AlertConfigIDChannelID[alertConfig.ID], 277 | UserId: p.BotUserID, 278 | RootId: args.RootId, 279 | } 280 | 281 | model.ParseSlackAttachment(post, attachments) 282 | if _, appErr := p.API.CreatePost(post); appErr != nil { 283 | errors = append(errors, fmt.Sprintf("Channel %q: Error creating the Alert post", alertConfig.Channel)) 284 | continue 285 | } 286 | } 287 | 288 | if silencesCount == 0 { 289 | return "No silences right now.", nil 290 | } 291 | 292 | if pendingSilencesCount == 0 { 293 | return "No active or pending silences right now.", nil 294 | } 295 | 296 | if len(errors) > 0 { 297 | return strings.Join(errors, "\n"), nil 298 | } 299 | 300 | return "", nil 301 | } 302 | 303 | func (p *Plugin) handleExpireSilence(args *model.CommandArgs) (string, error) { 304 | split := strings.Fields(args.Command) 305 | var parameters []string 306 | if len(split) > 2 { 307 | parameters = split[2:] 308 | } 309 | 310 | if len(parameters) != 2 { 311 | return "Command requires 2 parameters: alert configuration number and silence ID", nil 312 | } 313 | 314 | configuration := p.getConfiguration() 315 | 316 | if config, ok := configuration.AlertConfigs[parameters[0]]; ok { 317 | err := alertmanager.ExpireSilence(parameters[1], config.AlertManagerURL) 318 | if err != nil { 319 | return "", fmt.Errorf("failed to expire the silence: %w", err) 320 | } 321 | } else { 322 | return fmt.Sprintf("Alert configuration %s not found", parameters[0]), nil 323 | } 324 | 325 | return fmt.Sprintf("Silence %s expired.", parameters[1]), nil 326 | } 327 | 328 | func ConvertSilenceToSlackAttachment(silence types.Silence, config alertConfig, userID, siteURLPort string) *model.SlackAttachment { 329 | if string(silence.Status.State) == "expired" { 330 | return nil 331 | } 332 | var fields []*model.SlackAttachmentField 333 | var emoji, matchers, duration string 334 | for _, m := range silence.Matchers { 335 | if m.Name == "alertname" { 336 | fields = addFields(fields, "Alert Name", m.Value, false) 337 | } else { 338 | matchers += fmt.Sprintf(`%s="%s"`, m.Name, m.Value) 339 | } 340 | } 341 | fields = addFields(fields, "State", string(silence.Status.State), true) 342 | fields = addFields(fields, "Matchers", matchers, false) 343 | resolved := alertmanager.Resolved(silence) 344 | if !resolved { 345 | emoji = "🔕" 346 | duration = fmt.Sprintf( 347 | "**Started**: %s ago\n**Ends:** %s\n", 348 | durafmt.Parse(time.Since(silence.StartsAt)), 349 | durafmt.Parse(time.Since(silence.EndsAt)), 350 | ) 351 | fields = addFields(fields, emoji, duration, false) 352 | } else { 353 | duration = fmt.Sprintf( 354 | "**Ended**: %s ago\n**Duration**: %s", 355 | durafmt.Parse(time.Since(silence.EndsAt)), 356 | durafmt.Parse(silence.EndsAt.Sub(silence.StartsAt)), 357 | ) 358 | fields = addFields(fields, "", duration, false) 359 | } 360 | fields = addFields(fields, "Comments", silence.Comment, false) 361 | fields = addFields(fields, "Created by", silence.CreatedBy, true) 362 | fields = addFields(fields, "AlertManager Config ID", config.ID, true) 363 | 364 | color := colorResolved 365 | if string(silence.Status.State) == "active" { 366 | color = colorFiring 367 | } 368 | 369 | expireSilenceAction := &model.PostAction{ 370 | Name: "Expire Silence", 371 | Type: model.PostActionTypeButton, 372 | Integration: &model.PostActionIntegration{ 373 | Context: map[string]interface{}{ 374 | "action": "expire", 375 | "silence_id": silence.ID, 376 | "user_id": userID, 377 | }, 378 | URL: fmt.Sprintf("http://localhost%v/plugins/%v/api/expire?token=%s", siteURLPort, manifest.ID, config.Token), 379 | }, 380 | } 381 | attachment := &model.SlackAttachment{ 382 | Title: fmt.Sprintf("Silence ID: %s", silence.ID), 383 | Fields: fields, 384 | Color: color, 385 | Actions: []*model.PostAction{ 386 | expireSilenceAction, 387 | }, 388 | } 389 | 390 | return attachment 391 | } 392 | -------------------------------------------------------------------------------- /server/configuration.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | ) 9 | 10 | // configuration captures the plugin's external configuration as exposed in the Mattermost server 11 | // configuration, as well as values computed from the configuration. Any public fields will be 12 | // deserialized from the Mattermost server configuration in OnConfigurationChange. 13 | // 14 | // As plugins are inherently concurrent (hooks being called asynchronously), and the plugin 15 | // configuration can change at any time, access to the configuration must be synchronized. The 16 | // strategy used in this plugin is to guard a pointer to the configuration, and clone the entire 17 | // struct whenever it changes. You may replace this with whatever strategy you choose. 18 | // 19 | // If you add non-reference types to your configuration struct, be sure to rewrite Clone as a deep 20 | // copy appropriate for your types. 21 | type configuration struct { 22 | AlertConfigs map[string]alertConfig 23 | } 24 | 25 | type alertConfig struct { 26 | ID string 27 | Token string 28 | Channel string 29 | Team string 30 | AlertManagerURL string 31 | } 32 | 33 | func (ac *alertConfig) IsValid() error { 34 | if ac.Team == "" { 35 | return errors.New("must set a Team") 36 | } 37 | 38 | if ac.Channel == "" { 39 | return errors.New("must set a Channel") 40 | } 41 | 42 | if ac.Token == "" { 43 | return errors.New("must set a Token") 44 | } 45 | 46 | if ac.AlertManagerURL == "" { 47 | return errors.New("must set the AlertManager URL") 48 | } 49 | 50 | return nil 51 | } 52 | 53 | // Clone shallow copies the configuration. Your implementation may require a deep copy if 54 | // your configuration has reference types. 55 | func (c *configuration) Clone() *configuration { 56 | var clone configuration 57 | for k, v := range c.AlertConfigs { 58 | clone.AlertConfigs[k] = v 59 | } 60 | return &clone 61 | } 62 | 63 | // getConfiguration retrieves the active configuration under lock, making it safe to use 64 | // concurrently. The active configuration may change underneath the client of this method, but 65 | // the struct returned by this API call is considered immutable. 66 | func (p *Plugin) getConfiguration() *configuration { 67 | p.configurationLock.RLock() 68 | defer p.configurationLock.RUnlock() 69 | 70 | if p.configuration == nil { 71 | return &configuration{ 72 | AlertConfigs: make(map[string]alertConfig), 73 | } 74 | } 75 | 76 | return p.configuration 77 | } 78 | 79 | // setConfiguration replaces the active configuration under lock. 80 | // 81 | // Do not call setConfiguration while holding the configurationLock, as sync.Mutex is not 82 | // reentrant. In particular, avoid using the plugin API entirely, as this may in turn trigger a 83 | // hook back into the plugin. If that hook attempts to acquire this lock, a deadlock may occur. 84 | // 85 | // This method panics if setConfiguration is called with the existing configuration. This almost 86 | // certainly means that the configuration was modified without being cloned and may result in 87 | // an unsafe access. 88 | func (p *Plugin) setConfiguration(configuration *configuration) { 89 | p.configurationLock.Lock() 90 | defer p.configurationLock.Unlock() 91 | 92 | if configuration != nil && p.configuration == configuration { 93 | // Ignore assignment if the configuration struct is empty. Go will optimize the 94 | // allocation for same to point at the same memory address, breaking the check 95 | // above. 96 | if reflect.ValueOf(*configuration).NumField() == 0 { 97 | return 98 | } 99 | 100 | panic("setConfiguration called with the existing configuration") 101 | } 102 | 103 | p.configuration = configuration 104 | } 105 | 106 | // OnConfigurationChange is invoked when configuration changes may have been made. 107 | func (p *Plugin) OnConfigurationChange() error { 108 | var configurationInstance = configuration{ 109 | AlertConfigs: make(map[string]alertConfig), 110 | } 111 | 112 | // Load the public configuration fields from the Mattermost server configuration. 113 | if err := p.API.LoadPluginConfiguration(&configurationInstance); err != nil { 114 | return fmt.Errorf("failed to load plugin configuration: %w", err) 115 | } 116 | 117 | for id, alertConfigInstance := range configurationInstance.AlertConfigs { 118 | alertConfigInstance.ID = id 119 | alertConfigInstance.AlertManagerURL = strings.TrimRight(alertConfigInstance.AlertManagerURL, `/`) 120 | configurationInstance.AlertConfigs[id] = alertConfigInstance 121 | } 122 | 123 | p.setConfiguration(&configurationInstance) 124 | 125 | return p.OnActivate() 126 | } 127 | -------------------------------------------------------------------------------- /server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/mattermost/mattermost-server/v6/plugin" 5 | ) 6 | 7 | func main() { 8 | plugin.ClientMain(&Plugin{}) 9 | } 10 | -------------------------------------------------------------------------------- /server/manifest.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | var manifest = struct { 4 | ID string 5 | Version string 6 | }{ 7 | ID: "alertmanager", 8 | Version: "0.4.1", 9 | } 10 | -------------------------------------------------------------------------------- /server/plugin.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/subtle" 5 | "fmt" 6 | "net/http" 7 | "path/filepath" 8 | "sync" 9 | 10 | pluginapi "github.com/mattermost/mattermost-plugin-api" 11 | "github.com/mattermost/mattermost-server/v6/model" 12 | "github.com/mattermost/mattermost-server/v6/plugin" 13 | 14 | root "github.com/cpanato/mattermost-plugin-alertmanager" 15 | ) 16 | 17 | var ( 18 | Manifest model.Manifest = root.Manifest 19 | ) 20 | 21 | type Plugin struct { 22 | plugin.MattermostPlugin 23 | client *pluginapi.Client 24 | 25 | // configuration is the active plugin configuration. Consult getConfiguration and 26 | // setConfiguration for usage. 27 | configuration *configuration 28 | 29 | // key - alert config id, value - existing or created channel id received from api 30 | AlertConfigIDChannelID map[string]string 31 | BotUserID string 32 | 33 | // configurationLock synchronizes access to the configuration. 34 | configurationLock sync.RWMutex 35 | } 36 | 37 | func (p *Plugin) OnDeactivate() error { 38 | return nil 39 | } 40 | 41 | func (p *Plugin) OnActivate() error { 42 | p.client = pluginapi.NewClient(p.API, p.Driver) 43 | botID, err := p.client.Bot.EnsureBot(&model.Bot{ 44 | Username: "alertmanagerbot", 45 | DisplayName: "AlertManager Bot", 46 | Description: "Created by the AlertManager plugin.", 47 | }, pluginapi.ProfileImagePath(filepath.Join("assets", "alertmanager-logo.png"))) 48 | if err != nil { 49 | return fmt.Errorf("failed to ensure bot account: %w", err) 50 | } 51 | p.BotUserID = botID 52 | 53 | configuration := p.getConfiguration() 54 | p.AlertConfigIDChannelID = make(map[string]string) 55 | for k, alertConfig := range configuration.AlertConfigs { 56 | var channelID string 57 | channelID, err = p.ensureAlertChannelExists(alertConfig) 58 | if err != nil { 59 | p.API.LogWarn(fmt.Sprintf("Failed to ensure alert channel %v", k), "error", err.Error()) 60 | } else { 61 | p.AlertConfigIDChannelID[alertConfig.ID] = channelID 62 | } 63 | } 64 | 65 | command, err := p.getCommand() 66 | if err != nil { 67 | return fmt.Errorf("failed to get command: %w", err) 68 | } 69 | 70 | err = p.API.RegisterCommand(command) 71 | if err != nil { 72 | return fmt.Errorf("failed to register command: %w", err) 73 | } 74 | 75 | return nil 76 | } 77 | 78 | func (p *Plugin) ensureAlertChannelExists(alertConfig alertConfig) (string, error) { 79 | if err := alertConfig.IsValid(); err != nil { 80 | return "", fmt.Errorf("alert Configuration is invalid: %w", err) 81 | } 82 | 83 | team, appErr := p.API.GetTeamByName(alertConfig.Team) 84 | if appErr != nil { 85 | return "", fmt.Errorf("failed to get team: %w", appErr) 86 | } 87 | 88 | channel, appErr := p.API.GetChannelByName(team.Id, alertConfig.Channel, false) 89 | if appErr != nil { 90 | if appErr.StatusCode == http.StatusNotFound { 91 | channelToCreate := &model.Channel{ 92 | Name: alertConfig.Channel, 93 | DisplayName: alertConfig.Channel, 94 | Type: model.ChannelTypeOpen, 95 | TeamId: team.Id, 96 | CreatorId: p.BotUserID, 97 | } 98 | 99 | newChannel, errChannel := p.API.CreateChannel(channelToCreate) 100 | if errChannel != nil { 101 | return "", fmt.Errorf("failed to create alert channel: %w", errChannel) 102 | } 103 | 104 | return newChannel.Id, nil 105 | } 106 | return "", fmt.Errorf("failed to get existing alert channel: %w", appErr) 107 | } 108 | 109 | return channel.Id, nil 110 | } 111 | 112 | func (p *Plugin) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Request) { 113 | if r.Method == http.MethodGet { 114 | w.WriteHeader(http.StatusOK) 115 | _, _ = w.Write([]byte("Mattermost AlertManager Plugin")) 116 | return 117 | } 118 | 119 | invalidOrMissingTokenErr := "Invalid or missing token" 120 | token := r.URL.Query().Get("token") 121 | if token == "" { 122 | http.Error(w, invalidOrMissingTokenErr, http.StatusBadRequest) 123 | return 124 | } 125 | 126 | configuration := p.getConfiguration() 127 | for _, alertConfig := range configuration.AlertConfigs { 128 | if subtle.ConstantTimeCompare([]byte(token), []byte(alertConfig.Token)) == 1 { 129 | switch r.URL.Path { 130 | case "/api/webhook": 131 | p.handleWebhook(w, r, alertConfig) 132 | case "/api/expire": 133 | p.handleExpireAction(w, r, alertConfig) 134 | default: 135 | http.NotFound(w, r) 136 | } 137 | return 138 | } 139 | } 140 | 141 | http.Error(w, invalidOrMissingTokenErr, http.StatusBadRequest) 142 | } 143 | -------------------------------------------------------------------------------- /server/webhook.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "sort" 8 | "strings" 9 | "time" 10 | 11 | "golang.org/x/text/cases" 12 | "golang.org/x/text/language" 13 | 14 | "github.com/hako/durafmt" 15 | "github.com/prometheus/alertmanager/notify/webhook" 16 | "github.com/prometheus/alertmanager/template" 17 | 18 | "github.com/mattermost/mattermost-server/v6/model" 19 | ) 20 | 21 | func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request, alertConfig alertConfig) { 22 | p.API.LogInfo("Received alertmanager notification") 23 | 24 | var message webhook.Message 25 | err := json.NewDecoder(r.Body).Decode(&message) 26 | if err != nil { 27 | p.API.LogError("failed to decode webhook message", "err", err.Error()) 28 | w.WriteHeader(http.StatusBadRequest) 29 | return 30 | } 31 | 32 | if message == (webhook.Message{}) { 33 | w.WriteHeader(http.StatusBadRequest) 34 | return 35 | } 36 | 37 | var fields []*model.SlackAttachmentField 38 | for _, alert := range message.Alerts { 39 | fields = append(fields, ConvertAlertToFields(alertConfig, alert, message.ExternalURL, message.Receiver)...) 40 | } 41 | 42 | attachment := &model.SlackAttachment{ 43 | Fields: fields, 44 | Color: setColor(message.Status), 45 | } 46 | 47 | post := &model.Post{ 48 | ChannelId: p.AlertConfigIDChannelID[alertConfig.ID], 49 | UserId: p.BotUserID, 50 | } 51 | 52 | model.ParseSlackAttachment(post, []*model.SlackAttachment{attachment}) 53 | if _, appErr := p.API.CreatePost(post); appErr != nil { 54 | return 55 | } 56 | } 57 | 58 | func addFields(fields []*model.SlackAttachmentField, title, msg string, short bool) []*model.SlackAttachmentField { 59 | return append(fields, &model.SlackAttachmentField{ 60 | Title: title, 61 | Value: msg, 62 | Short: model.SlackCompatibleBool(short), 63 | }) 64 | } 65 | 66 | func setColor(impact string) string { 67 | mapImpactColor := map[string]string{ 68 | "firing": colorFiring, 69 | "resolved": colorResolved, 70 | } 71 | 72 | if val, ok := mapImpactColor[impact]; ok { 73 | return val 74 | } 75 | 76 | return colorExpired 77 | } 78 | 79 | func ConvertAlertToFields(config alertConfig, alert template.Alert, externalURL, receiver string) []*model.SlackAttachmentField { 80 | var fields []*model.SlackAttachmentField 81 | 82 | statusMsg := strings.ToUpper(alert.Status) 83 | if alert.Status == "firing" { 84 | statusMsg = fmt.Sprintf(":fire: %s :fire:", strings.ToUpper(alert.Status)) 85 | } 86 | 87 | /* first field: Annotations, Start/End, Source */ 88 | var msg string 89 | annotations := make([]string, 0, len(alert.Annotations)) 90 | for k := range alert.Annotations { 91 | annotations = append(annotations, k) 92 | } 93 | sort.Strings(annotations) 94 | for _, k := range annotations { 95 | msg = fmt.Sprintf("%s**%s:** %s\n", msg, cases.Title(language.Und, cases.NoLower).String(k), alert.Annotations[k]) 96 | } 97 | msg = fmt.Sprintf("%s \n", msg) 98 | msg = fmt.Sprintf("%s**Started at:** %s (%s ago)\n", msg, 99 | (alert.StartsAt).Format(time.RFC1123), 100 | durafmt.Parse(time.Since(alert.StartsAt)).LimitFirstN(2).String(), 101 | ) 102 | if alert.Status == "resolved" { 103 | msg = fmt.Sprintf("%s**Ended at:** %s (%s ago)\n", msg, 104 | (alert.EndsAt).Format(time.RFC1123), 105 | durafmt.Parse(time.Since(alert.EndsAt)).LimitFirstN(2).String(), 106 | ) 107 | } 108 | msg = fmt.Sprintf("%s \n", msg) 109 | msg = fmt.Sprintf("%sGenerated by a [Prometheus Alert](%s) and sent to the [Alertmanager](%s) '%s' receiver.", msg, alert.GeneratorURL, externalURL, receiver) 110 | fields = addFields(fields, statusMsg, msg, true) 111 | 112 | /* second field: Labels only */ 113 | msg = "" 114 | alert.Labels["AlertManager Config ID"] = config.ID 115 | labels := make([]string, 0, len(alert.Labels)) 116 | for k := range alert.Labels { 117 | labels = append(labels, k) 118 | } 119 | sort.Strings(labels) 120 | for _, k := range labels { 121 | msg = fmt.Sprintf("%s**%s:** %s\n", msg, cases.Title(language.Und, cases.NoLower).String(k), alert.Labels[k]) 122 | } 123 | 124 | fields = addFields(fields, "", msg, true) 125 | 126 | return fields 127 | } 128 | -------------------------------------------------------------------------------- /webapp/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "parserOptions": { 4 | "ecmaVersion": 8, 5 | "sourceType": "module", 6 | "ecmaFeatures": { 7 | "jsx": true, 8 | "impliedStrict": true, 9 | "modules": true, 10 | "experimentalObjectRestSpread": true 11 | } 12 | }, 13 | "parser": "babel-eslint", 14 | "plugins": [ 15 | "react", 16 | "import" 17 | ], 18 | "env": { 19 | "browser": true, 20 | "node": true, 21 | "jquery": true, 22 | "es6": true, 23 | "jest": true 24 | }, 25 | "globals": { 26 | "jest": true, 27 | "describe": true, 28 | "it": true, 29 | "expect": true, 30 | "before": true, 31 | "after": true, 32 | "beforeEach": true 33 | }, 34 | "settings": { 35 | "import/resolver": "webpack", 36 | "react": { 37 | "version": "detect" 38 | } 39 | }, 40 | "rules": { 41 | "array-bracket-spacing": [ 42 | 2, 43 | "never" 44 | ], 45 | "array-callback-return": 2, 46 | "arrow-body-style": 0, 47 | "arrow-parens": [ 48 | 2, 49 | "always" 50 | ], 51 | "arrow-spacing": [ 52 | 2, 53 | { 54 | "before": true, 55 | "after": true 56 | } 57 | ], 58 | "block-scoped-var": 2, 59 | "brace-style": [ 60 | 2, 61 | "1tbs", 62 | { 63 | "allowSingleLine": false 64 | } 65 | ], 66 | "camelcase": [ 67 | 2, 68 | { 69 | "properties": "never" 70 | } 71 | ], 72 | "capitalized-comments": 0, 73 | "class-methods-use-this": 0, 74 | "comma-dangle": [ 75 | 2, 76 | "always-multiline" 77 | ], 78 | "comma-spacing": [ 79 | 2, 80 | { 81 | "before": false, 82 | "after": true 83 | } 84 | ], 85 | "comma-style": [ 86 | 2, 87 | "last" 88 | ], 89 | "complexity": [ 90 | 0, 91 | 10 92 | ], 93 | "computed-property-spacing": [ 94 | 2, 95 | "never" 96 | ], 97 | "consistent-return": 2, 98 | "consistent-this": [ 99 | 2, 100 | "self" 101 | ], 102 | "constructor-super": 2, 103 | "curly": [ 104 | 2, 105 | "all" 106 | ], 107 | "dot-location": [ 108 | 2, 109 | "object" 110 | ], 111 | "dot-notation": 2, 112 | "eqeqeq": [ 113 | 2, 114 | "smart" 115 | ], 116 | "func-call-spacing": [ 117 | 2, 118 | "never" 119 | ], 120 | "func-name-matching": 0, 121 | "func-names": 2, 122 | "func-style": [ 123 | 2, 124 | "declaration", 125 | { 126 | "allowArrowFunctions": true 127 | } 128 | ], 129 | "generator-star-spacing": [ 130 | 2, 131 | { 132 | "before": false, 133 | "after": true 134 | } 135 | ], 136 | "global-require": 2, 137 | "guard-for-in": 2, 138 | "id-blacklist": 0, 139 | "import/no-unresolved": 2, 140 | "import/order": [ 141 | "error", 142 | { 143 | "newlines-between": "always-and-inside-groups", 144 | "groups": [ 145 | "builtin", 146 | "external", 147 | [ 148 | "internal", 149 | "parent" 150 | ], 151 | "sibling", 152 | "index" 153 | ] 154 | } 155 | ], 156 | "indent": [ 157 | 2, 158 | 4, 159 | { 160 | "SwitchCase": 0 161 | } 162 | ], 163 | "jsx-quotes": [ 164 | 2, 165 | "prefer-single" 166 | ], 167 | "key-spacing": [ 168 | 2, 169 | { 170 | "beforeColon": false, 171 | "afterColon": true, 172 | "mode": "strict" 173 | } 174 | ], 175 | "keyword-spacing": [ 176 | 2, 177 | { 178 | "before": true, 179 | "after": true, 180 | "overrides": {} 181 | } 182 | ], 183 | "line-comment-position": 0, 184 | "linebreak-style": 2, 185 | "lines-around-comment": [ 186 | 2, 187 | { 188 | "beforeBlockComment": true, 189 | "beforeLineComment": true, 190 | "allowBlockStart": true, 191 | "allowBlockEnd": true 192 | } 193 | ], 194 | "max-lines": [ 195 | 1, 196 | { 197 | "max": 450, 198 | "skipBlankLines": true, 199 | "skipComments": false 200 | } 201 | ], 202 | "max-nested-callbacks": [ 203 | 2, 204 | { 205 | "max": 2 206 | } 207 | ], 208 | "max-statements-per-line": [ 209 | 2, 210 | { 211 | "max": 1 212 | } 213 | ], 214 | "multiline-ternary": [ 215 | 1, 216 | "never" 217 | ], 218 | "new-cap": 2, 219 | "new-parens": 2, 220 | "newline-before-return": 0, 221 | "newline-per-chained-call": 0, 222 | "no-alert": 2, 223 | "no-array-constructor": 2, 224 | "no-await-in-loop": 2, 225 | "no-caller": 2, 226 | "no-case-declarations": 2, 227 | "no-class-assign": 2, 228 | "no-compare-neg-zero": 2, 229 | "no-cond-assign": [ 230 | 2, 231 | "except-parens" 232 | ], 233 | "no-confusing-arrow": 2, 234 | "no-console": 2, 235 | "no-const-assign": 2, 236 | "no-constant-condition": 2, 237 | "no-debugger": 2, 238 | "no-div-regex": 2, 239 | "no-dupe-args": 2, 240 | "no-dupe-class-members": 2, 241 | "no-dupe-keys": 2, 242 | "no-duplicate-case": 2, 243 | "no-duplicate-imports": [ 244 | 2, 245 | { 246 | "includeExports": true 247 | } 248 | ], 249 | "no-else-return": 2, 250 | "no-empty": 2, 251 | "no-empty-function": 2, 252 | "no-empty-pattern": 2, 253 | "no-eval": 2, 254 | "no-ex-assign": 2, 255 | "no-extend-native": 2, 256 | "no-extra-bind": 2, 257 | "no-extra-label": 2, 258 | "no-extra-parens": 0, 259 | "no-extra-semi": 2, 260 | "no-fallthrough": 2, 261 | "no-floating-decimal": 2, 262 | "no-func-assign": 2, 263 | "no-global-assign": 2, 264 | "no-implicit-coercion": 2, 265 | "no-implicit-globals": 0, 266 | "no-implied-eval": 2, 267 | "no-inner-declarations": 0, 268 | "no-invalid-regexp": 2, 269 | "no-irregular-whitespace": 2, 270 | "no-iterator": 2, 271 | "no-labels": 2, 272 | "no-lone-blocks": 2, 273 | "no-lonely-if": 2, 274 | "no-loop-func": 2, 275 | "no-magic-numbers": [ 276 | 1, 277 | { 278 | "ignore": [ 279 | -1, 280 | 0, 281 | 1, 282 | 2 283 | ], 284 | "enforceConst": true, 285 | "detectObjects": true 286 | } 287 | ], 288 | "no-mixed-operators": [ 289 | 2, 290 | { 291 | "allowSamePrecedence": false 292 | } 293 | ], 294 | "no-mixed-spaces-and-tabs": 2, 295 | "no-multi-assign": 2, 296 | "no-multi-spaces": [ 297 | 2, 298 | { 299 | "exceptions": { 300 | "Property": false 301 | } 302 | } 303 | ], 304 | "no-multi-str": 0, 305 | "no-multiple-empty-lines": [ 306 | 2, 307 | { 308 | "max": 1 309 | } 310 | ], 311 | "no-native-reassign": 2, 312 | "no-negated-condition": 2, 313 | "no-nested-ternary": 2, 314 | "no-new": 2, 315 | "no-new-func": 2, 316 | "no-new-object": 2, 317 | "no-new-symbol": 2, 318 | "no-new-wrappers": 2, 319 | "no-octal-escape": 2, 320 | "no-param-reassign": 2, 321 | "no-process-env": 2, 322 | "no-process-exit": 2, 323 | "no-proto": 2, 324 | "no-redeclare": 2, 325 | "no-return-assign": [ 326 | 2, 327 | "always" 328 | ], 329 | "no-return-await": 2, 330 | "no-script-url": 2, 331 | "no-self-assign": [ 332 | 2, 333 | { 334 | "props": true 335 | } 336 | ], 337 | "no-self-compare": 2, 338 | "no-sequences": 2, 339 | "no-shadow": [ 340 | 2, 341 | { 342 | "hoist": "functions" 343 | } 344 | ], 345 | "no-shadow-restricted-names": 2, 346 | "no-spaced-func": 2, 347 | "no-tabs": 0, 348 | "no-template-curly-in-string": 2, 349 | "no-ternary": 0, 350 | "no-this-before-super": 2, 351 | "no-throw-literal": 2, 352 | "no-trailing-spaces": [ 353 | 2, 354 | { 355 | "skipBlankLines": false 356 | } 357 | ], 358 | "no-undef-init": 2, 359 | "no-undefined": 2, 360 | "no-underscore-dangle": 2, 361 | "no-unexpected-multiline": 2, 362 | "no-unmodified-loop-condition": 2, 363 | "no-unneeded-ternary": [ 364 | 2, 365 | { 366 | "defaultAssignment": false 367 | } 368 | ], 369 | "no-unreachable": 2, 370 | "no-unsafe-finally": 2, 371 | "no-unsafe-negation": 2, 372 | "no-unused-expressions": 2, 373 | "no-unused-vars": [ 374 | 2, 375 | { 376 | "vars": "all", 377 | "args": "after-used" 378 | } 379 | ], 380 | "no-use-before-define": [ 381 | 2, 382 | { 383 | "classes": false, 384 | "functions": false, 385 | "variables": false 386 | } 387 | ], 388 | "no-useless-computed-key": 2, 389 | "no-useless-concat": 2, 390 | "no-useless-constructor": 2, 391 | "no-useless-escape": 2, 392 | "no-useless-rename": 2, 393 | "no-useless-return": 2, 394 | "no-var": 0, 395 | "no-void": 2, 396 | "no-warning-comments": 1, 397 | "no-whitespace-before-property": 2, 398 | "no-with": 2, 399 | "object-curly-newline": 0, 400 | "object-curly-spacing": [ 401 | 2, 402 | "never" 403 | ], 404 | "object-property-newline": [ 405 | 2, 406 | { 407 | "allowMultiplePropertiesPerLine": true 408 | } 409 | ], 410 | "object-shorthand": [ 411 | 2, 412 | "always" 413 | ], 414 | "one-var": [ 415 | 2, 416 | "never" 417 | ], 418 | "one-var-declaration-per-line": 0, 419 | "operator-assignment": [ 420 | 2, 421 | "always" 422 | ], 423 | "operator-linebreak": [ 424 | 2, 425 | "after" 426 | ], 427 | "padded-blocks": [ 428 | 2, 429 | "never" 430 | ], 431 | "prefer-arrow-callback": 2, 432 | "prefer-const": 2, 433 | "prefer-destructuring": 0, 434 | "prefer-numeric-literals": 2, 435 | "prefer-promise-reject-errors": 2, 436 | "prefer-rest-params": 2, 437 | "prefer-spread": 2, 438 | "prefer-template": 0, 439 | "quote-props": [ 440 | 2, 441 | "as-needed" 442 | ], 443 | "quotes": [ 444 | 2, 445 | "single", 446 | "avoid-escape" 447 | ], 448 | "radix": 2, 449 | "react/display-name": [ 450 | 0, 451 | { 452 | "ignoreTranspilerName": false 453 | } 454 | ], 455 | "react/forbid-component-props": 0, 456 | "react/forbid-elements": [ 457 | 2, 458 | { 459 | "forbid": [ 460 | "embed" 461 | ] 462 | } 463 | ], 464 | "react/jsx-boolean-value": [ 465 | 2, 466 | "always" 467 | ], 468 | "react/jsx-closing-bracket-location": [ 469 | 2, 470 | { 471 | "location": "tag-aligned" 472 | } 473 | ], 474 | "react/jsx-curly-spacing": [ 475 | 2, 476 | "never" 477 | ], 478 | "react/jsx-equals-spacing": [ 479 | 2, 480 | "never" 481 | ], 482 | "react/jsx-filename-extension": [2, { "extensions": [".js", ".jsx", ".ts", ".tsx"] }], 483 | "react/jsx-first-prop-new-line": [ 484 | 2, 485 | "multiline" 486 | ], 487 | "react/jsx-handler-names": 0, 488 | "react/jsx-indent": [ 489 | 2, 490 | 4 491 | ], 492 | "react/jsx-indent-props": [ 493 | 2, 494 | 4 495 | ], 496 | "react/jsx-key": 2, 497 | "react/jsx-max-props-per-line": [ 498 | 2, 499 | { 500 | "maximum": 1 501 | } 502 | ], 503 | "react/jsx-no-bind": 0, 504 | "react/jsx-no-comment-textnodes": 2, 505 | "react/jsx-no-duplicate-props": [ 506 | 2, 507 | { 508 | "ignoreCase": false 509 | } 510 | ], 511 | "react/jsx-no-literals": 2, 512 | "react/jsx-no-target-blank": 2, 513 | "react/jsx-no-undef": 2, 514 | "react/jsx-pascal-case": 2, 515 | "react/jsx-tag-spacing": [ 516 | 2, 517 | { 518 | "closingSlash": "never", 519 | "beforeSelfClosing": "never", 520 | "afterOpening": "never" 521 | } 522 | ], 523 | "react/jsx-uses-react": 2, 524 | "react/jsx-uses-vars": 2, 525 | "react/jsx-wrap-multilines": 2, 526 | "react/no-array-index-key": 1, 527 | "react/no-children-prop": 2, 528 | "react/no-danger": 0, 529 | "react/no-danger-with-children": 2, 530 | "react/no-deprecated": 1, 531 | "react/no-did-mount-set-state": 2, 532 | "react/no-did-update-set-state": 2, 533 | "react/no-direct-mutation-state": 2, 534 | "react/no-find-dom-node": 1, 535 | "react/no-is-mounted": 2, 536 | "react/no-multi-comp": [ 537 | 2, 538 | { 539 | "ignoreStateless": true 540 | } 541 | ], 542 | "react/no-render-return-value": 2, 543 | "react/no-set-state": 0, 544 | "react/no-string-refs": 0, 545 | "react/no-unescaped-entities": 2, 546 | "react/no-unknown-property": 2, 547 | "react/no-unused-prop-types": [ 548 | 1, 549 | { 550 | "skipShapeProps": true 551 | } 552 | ], 553 | "react/prefer-es6-class": 2, 554 | "react/prefer-stateless-function": 0, 555 | "react/prop-types": [ 556 | 2, 557 | { 558 | "ignore": [ 559 | "location", 560 | "history", 561 | "component" 562 | ] 563 | } 564 | ], 565 | "react/require-default-props": 0, 566 | "react/require-optimization": 1, 567 | "react/require-render-return": 2, 568 | "react/self-closing-comp": 2, 569 | "react/sort-comp": 0, 570 | "react/style-prop-object": 2, 571 | "require-yield": 2, 572 | "rest-spread-spacing": [ 573 | 2, 574 | "never" 575 | ], 576 | "semi": [ 577 | 2, 578 | "always" 579 | ], 580 | "semi-spacing": [ 581 | 2, 582 | { 583 | "before": false, 584 | "after": true 585 | } 586 | ], 587 | "sort-imports": 0, 588 | "sort-keys": 0, 589 | "space-before-blocks": [ 590 | 2, 591 | "always" 592 | ], 593 | "space-before-function-paren": [ 594 | 2, 595 | { 596 | "anonymous": "never", 597 | "named": "never", 598 | "asyncArrow": "always" 599 | } 600 | ], 601 | "space-in-parens": [ 602 | 2, 603 | "never" 604 | ], 605 | "space-infix-ops": 2, 606 | "space-unary-ops": [ 607 | 2, 608 | { 609 | "words": true, 610 | "nonwords": false 611 | } 612 | ], 613 | "symbol-description": 2, 614 | "template-curly-spacing": [ 615 | 2, 616 | "never" 617 | ], 618 | "valid-typeof": [ 619 | 2, 620 | { 621 | "requireStringLiterals": false 622 | } 623 | ], 624 | "vars-on-top": 0, 625 | "wrap-iife": [ 626 | 2, 627 | "outside" 628 | ], 629 | "wrap-regex": 2, 630 | "yoda": [ 631 | 2, 632 | "never", 633 | { 634 | "exceptRange": false, 635 | "onlyEquality": false 636 | } 637 | ] 638 | } 639 | } 640 | -------------------------------------------------------------------------------- /webapp/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .npminstall 3 | dist 4 | goreleaser/* -------------------------------------------------------------------------------- /webapp/.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /webapp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webapp", 3 | "version": "1.0.0", 4 | "description": "This plugin serves as a starting point for writing a Mattermost plugin.", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "build": "webpack --mode=production", 8 | "build:watch": "webpack --mode=production --watch", 9 | "debug": "webpack --mode=none", 10 | "debug:watch": "webpack --mode=development --watch", 11 | "lint": "eslint --ignore-pattern node_modules --ignore-pattern dist --ext .js --ext .jsx . --quiet", 12 | "fix": "eslint --ignore-pattern node_modules --ignore-pattern dist --ext .js --ext .jsx . --quiet --fix", 13 | "test": "echo 'No tests for this plugin'; exit 0; jest --forceExit --detectOpenHandles --verbose", 14 | "test:watch": "echo 'No tests for this plugin'; exit 0; jest --watch", 15 | "test-ci": "echo 'No tests for this plugin'; exit 0; jest --forceExit --detectOpenHandles --maxWorkers=2" 16 | }, 17 | "author": "", 18 | "license": "", 19 | "devDependencies": { 20 | "@babel/cli": "7.23.9", 21 | "@babel/core": "7.24.4", 22 | "@babel/plugin-proposal-class-properties": "7.18.6", 23 | "@babel/plugin-proposal-object-rest-spread": "7.20.7", 24 | "@babel/plugin-syntax-dynamic-import": "7.8.3", 25 | "@babel/polyfill": "7.12.1", 26 | "@babel/preset-env": "7.24.4", 27 | "@babel/preset-react": "7.23.3", 28 | "@babel/preset-typescript": "7.23.3", 29 | "@babel/runtime": "7.24.4", 30 | "babel-eslint": "10.1.0", 31 | "babel-loader": "^9.1.3", 32 | "eslint": "8.57.0", 33 | "eslint-import-resolver-webpack": "0.11.1", 34 | "eslint-plugin-import": "2.29.1", 35 | "eslint-plugin-react": "7.34.1", 36 | "webpack": "^5.90.3", 37 | "webpack-cli": "^5.1.4" 38 | }, 39 | "dependencies": { 40 | "core-js": "3.36.1", 41 | "lodash": "^4.17.21", 42 | "mattermost-redux": "5.33.1", 43 | "react": "18.2.0", 44 | "react-dom": "^18.2.0", 45 | "react-redux": "9.1.1", 46 | "react-select": "^5.8.0", 47 | "redux": "5.0.1", 48 | "superagent": "8.1.2", 49 | "style-loader": "^3.3.4", 50 | "css-loader": "^7.0.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /webapp/src/components/admin_settings/AMAttribute.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect} from 'react'; 2 | import crypto from 'crypto'; 3 | 4 | const AMAttribute = (props) => { 5 | const initialSettings = props.attributes === undefined || Object.keys(props.attributes).length === 0 ? { 6 | alertmanagerurl: "", 7 | channel: "", 8 | team: "", 9 | token: "", 10 | 11 | } : { 12 | alertmanagerurl: props.attributes.alertmanagerurl? props.attributes.alertmanagerurl: "", 13 | channel: props.attributes.channel? props.attributes.channel : "", 14 | team: props.attributes.team ? props.attributes.team: "", 15 | token: props.attributes.token? props.attributes.token: "", 16 | 17 | }; 18 | 19 | const initErrors = { 20 | teamError: false, 21 | channelError: false, 22 | urlError: false 23 | }; 24 | 25 | const [ settings, setSettings ] = useState(initialSettings); 26 | const [ hasError, setHasError ] = useState(initErrors); 27 | 28 | const handleTeamNameInput = (e) => { 29 | let newSettings = {...settings}; 30 | 31 | if (!e.target.value || e.target.value.trim() === '') { 32 | setHasError({...hasError, teamError: true}); 33 | } else { 34 | setHasError({...hasError, teamError: false}); 35 | } 36 | 37 | newSettings = {...newSettings, team: e.target.value}; 38 | 39 | setSettings(newSettings); 40 | props.onChange({id: props.id, attributes: newSettings}); 41 | } 42 | 43 | const handleChannelNameInput = (e) => { 44 | let newSettings = {...settings}; 45 | 46 | if (!e.target.value || e.target.value.trim() === '') { 47 | setHasError({...hasError, channelError: true}); 48 | } else { 49 | setHasError({...hasError, channelError: false}); 50 | } 51 | 52 | newSettings = {...newSettings, channel: e.target.value}; 53 | 54 | setSettings(newSettings); 55 | props.onChange({id: props.id, attributes: newSettings}); 56 | } 57 | 58 | const handleURLInput = (e) => { 59 | let newSettings = {...settings}; 60 | 61 | if (!e.target.value || e.target.value.trim() === '') { 62 | setHasError({...hasError, urlError: true}); 63 | } else { 64 | setHasError({...hasError, urlError: false}); 65 | } 66 | 67 | newSettings = {...newSettings, alertmanagerurl: e.target.value}; 68 | 69 | setSettings(newSettings); 70 | props.onChange({id: props.id, attributes: newSettings}); 71 | } 72 | 73 | const handleDelete = (e) => { 74 | props.onDelete(props.id); 75 | } 76 | 77 | const regenerateToken = (e) => { 78 | e.preventDefault(); 79 | 80 | // Generate a 32 byte tokes. It must not include '*' and '/'. 81 | // Copied from https://github.com/mattermost/mattermost-webapp/blob/33661c60bd05d708bcf85a49dad4d9fb3a39a75b/components/admin_console/generated_setting.tsx#L41 82 | const token = crypto.randomBytes(256).toString('base64').substring(0, 32).replaceAll('+', '-').replaceAll('/', '_'); 83 | 84 | let newSettings = {...settings}; 85 | newSettings = {...newSettings, token: token}; 86 | 87 | setSettings(newSettings); 88 | props.onChange({id: props.id, attributes:newSettings}); 89 | } 90 | 91 | const generateSimpleStringInputSetting = ( title, settingName, onChangeFunction, helpTextJSX) => { 92 | return ( 93 |
94 | 97 |
98 | 105 |
106 | {helpTextJSX} 107 |
108 |
109 |
110 | ); 111 | } 112 | 113 | const generateGeneratedFieldSetting = ( title, settingName, regenerateFunction, regenerateText, helpTextJSX) => { 114 | return (
115 | 118 |
119 |
123 | {settings[settingName] !== undefined && settings[settingName] !== ""? settings[settingName] : } 124 |
125 |
126 | {helpTextJSX} 127 |
128 |
129 | 136 |
137 |
138 |
); 139 | } 140 | 141 | const hasAnyError = () => { 142 | return Object.values(hasError).findIndex(item => item) !== -1; 143 | } 144 | 145 | return ( 146 |
147 |
148 |
{`#${props.id}`}
149 |
{` X `}
150 |
151 | { hasAnyError() &&
{`Attribute cannot be empty.`}
} 152 |
153 |
154 | { generateSimpleStringInputSetting( 155 | "Team Name:", 156 | "team", 157 | handleTeamNameInput, 158 | ({"Team you want to send messages to. Use the team name such as \'my-team\', instead of the display name."}) 159 | ) 160 | } 161 | 162 | { generateSimpleStringInputSetting( 163 | "Channel Name:", 164 | "channel", 165 | handleChannelNameInput, 166 | ({"Channel you want to send messages to. Use the channel name such as 'town-square', instead of the display name. If you specify a channel that does not exist, this plugin creates a new channel with that name."}) 167 | ) 168 | } 169 | 170 | { generateGeneratedFieldSetting( 171 | "Token:", 172 | "token", 173 | regenerateToken, 174 | "Regenerate", 175 | ({"The token used to configure the webhook for AlertManager. The token is validates for each webhook request by the Mattermost server."}) 176 | ) 177 | } 178 | 179 | { generateSimpleStringInputSetting( 180 | "AlertManager URL:", 181 | "alertmanagerurl", 182 | handleURLInput, 183 | ({"The URL of your AlertManager instance, e.g. \'"}{"http://alertmanager.example.com/"}{"\'"}) 184 | ) 185 | } 186 |
187 |
188 |
189 | ); 190 | } 191 | 192 | AMAttribute.propTypes = { 193 | id: PropTypes.string.isRequired, 194 | orderNumber: PropTypes.number.isRequired, 195 | attributes: PropTypes.object, 196 | onChange: PropTypes.func.isRequired 197 | } 198 | 199 | export default AMAttribute; 200 | -------------------------------------------------------------------------------- /webapp/src/components/admin_settings/CustomAttributeSettings.jsx: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. 2 | // See LICENSE.txt for license information. 3 | 4 | import PropTypes from 'prop-types'; 5 | import React, { useState, useEffect } from 'react'; 6 | 7 | import AMAttribute from './AMAttribute'; 8 | import ConfirmModal from '../widgets/confirmation_modal'; 9 | import '../../styles/main.css'; 10 | 11 | const CustomAttributesSettings = (props) => { 12 | const [ settings, setSettings ] = useState(new Map()); 13 | const [ isDeleteModalShown, setIsDeleteModalShown ] = useState(false); 14 | const [ settingIdToDelete, setSettingIdToDelete ] = useState(); 15 | 16 | useEffect(() => { 17 | setSettings(initSettings(props.value)); 18 | },[]); 19 | 20 | const initSettings = (newSettings) => { 21 | if(!!newSettings) { 22 | if(Object.keys(newSettings).length != 0) { 23 | const newEntries = Object.entries(newSettings); 24 | 25 | return new Map(newEntries); 26 | } 27 | } 28 | 29 | const emptySetting = { '0' : { 30 | alertmanagerurl: '', 31 | channel: '', 32 | team: '', 33 | token: '' 34 | } 35 | }; 36 | 37 | return new Map(Object.entries(emptySetting)); 38 | } 39 | 40 | const handleChange = ( { id, attributes } ) => { 41 | let newSettings = settings; 42 | newSettings.set(id, attributes); 43 | 44 | setSettings(newSettings); 45 | 46 | props.onChange(props.id, Object.fromEntries(newSettings)); 47 | props.setSaveNeeded(); 48 | } 49 | 50 | const handleAddButtonClick = (e) => { 51 | e.preventDefault(); 52 | 53 | const nextKey = settings.size === 0 ? '0' :(parseInt([...settings.keys()].pop()) + 1).toString(); 54 | 55 | let newSettings = settings; 56 | newSettings.set(nextKey, {}); 57 | 58 | setSettings(newSettings); 59 | 60 | props.onChange(props.id, Object.fromEntries(newSettings)); 61 | props.setSaveNeeded(); 62 | } 63 | 64 | const handleDelete = (id) => { 65 | let newSettings = settings; 66 | newSettings.delete(id); 67 | 68 | setSettings(newSettings); 69 | setIsDeleteModalShown(false); 70 | 71 | props.onChange(props.id, Object.fromEntries(newSettings)); 72 | props.setSaveNeeded(); 73 | } 74 | 75 | const triggerDeleteModal = (id) => { 76 | setIsDeleteModalShown(true); 77 | setSettingIdToDelete(id); 78 | }; 79 | 80 | const renderSettings = () => { 81 | if(settings.size === 0) { 82 | return ( 83 |
{`No alert managers have been created`}
84 | ); 85 | } 86 | 87 | return Array.from(settings, ([key, value], index) => { 88 | return ( 89 | 102 | ); 103 | }); 104 | } 105 | 106 | return ( 107 |
108 | {renderSettings()} 109 |
110 | 116 |
117 | { 125 | handleDelete(settingIdToDelete); 126 | }} 127 | onCancel={() => setIsDeleteModalShown(false)} 128 | /> 129 |
130 | ); 131 | } 132 | 133 | CustomAttributesSettings.propTypes = { 134 | id: PropTypes.string.isRequired, 135 | label: PropTypes.string.isRequired, 136 | helpText: PropTypes.node, 137 | value: PropTypes.any, 138 | disabled: PropTypes.bool.isRequired, 139 | config: PropTypes.object.isRequired, 140 | currentState: PropTypes.object, 141 | license: PropTypes.object.isRequired, 142 | setByEnv: PropTypes.bool.isRequired, 143 | onChange: PropTypes.func.isRequired, 144 | registerSaveAction: PropTypes.func.isRequired, 145 | setSaveNeeded: PropTypes.func.isRequired, 146 | unRegisterSaveAction: PropTypes.func.isRequired, 147 | } 148 | 149 | export default CustomAttributesSettings; -------------------------------------------------------------------------------- /webapp/src/components/widgets/confirmation_modal.tsx: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. 2 | // See LICENSE.txt for license information. 3 | 4 | import React from 'react'; 5 | import {Modal} from 'react-bootstrap'; 6 | 7 | type Props = { 8 | 9 | /* 10 | * Set to show modal 11 | */ 12 | show: boolean; 13 | 14 | /* 15 | * Title to use for the modal 16 | */ 17 | title?: React.ReactNode; 18 | 19 | /* 20 | * Message to display in the body of the modal 21 | */ 22 | message?: React.ReactNode; 23 | 24 | /* 25 | * The CSS class to apply to the confirm button 26 | */ 27 | confirmButtonClass?: string; 28 | 29 | /* 30 | * The CSS class to apply to the modal 31 | */ 32 | modalClass?: string; 33 | 34 | /* 35 | * Text/jsx element on the confirm button 36 | */ 37 | confirmButtonText?: React.ReactNode; 38 | 39 | /* 40 | * Text/jsx element on the cancel button 41 | */ 42 | cancelButtonText?: React.ReactNode; 43 | 44 | /* 45 | * Set to show checkbox 46 | */ 47 | showCheckbox?: boolean; 48 | 49 | /* 50 | * Text/jsx element to display with the checkbox 51 | */ 52 | checkboxText?: React.ReactNode; 53 | 54 | /* 55 | * Function called when the confirm button or ENTER is pressed. Passes `true` if the checkbox is checked 56 | */ 57 | onConfirm: (checked: boolean) => void; 58 | 59 | /* 60 | * Function called when the cancel button is pressed or the modal is hidden. Passes `true` if the checkbox is checked 61 | */ 62 | onCancel: (checked: boolean) => void; 63 | 64 | /** 65 | * Function called when modal is dismissed 66 | */ 67 | onExited?: () => void; 68 | 69 | /* 70 | * Set to hide the cancel button 71 | */ 72 | hideCancel?: boolean; 73 | }; 74 | 75 | type State = { 76 | checked: boolean; 77 | }; 78 | 79 | export default class ConfirmModal extends React.Component { 80 | static defaultProps = { 81 | title: '', 82 | message: '', 83 | confirmButtonClass: 'btn btn-primary', 84 | confirmButtonText: '', 85 | modalClass: '', 86 | }; 87 | 88 | constructor(props: Props) { 89 | super(props); 90 | 91 | this.state = { 92 | checked: false, 93 | }; 94 | } 95 | 96 | componentDidMount() { 97 | if (this.props.show) { 98 | document.addEventListener('keydown', this.handleKeypress); 99 | } 100 | } 101 | 102 | componentWillUnmount() { 103 | document.removeEventListener('keydown', this.handleKeypress); 104 | } 105 | 106 | shouldComponentUpdate(nextProps: Props, nextState: State) { 107 | return ( 108 | nextProps.show !== this.props.show || 109 | nextState.checked !== this.state.checked 110 | ); 111 | } 112 | 113 | componentDidUpdate(prevProps: Props) { 114 | if (prevProps.show && !this.props.show) { 115 | document.removeEventListener('keydown', this.handleKeypress); 116 | } else if (!prevProps.show && this.props.show) { 117 | document.addEventListener('keydown', this.handleKeypress); 118 | } 119 | } 120 | 121 | handleKeypress = (e: KeyboardEvent) => { 122 | if (e.key === 'Enter' && this.props.show) { 123 | const cancelButton = document.getElementById('cancelModalButton'); 124 | if (cancelButton && cancelButton === document.activeElement) { 125 | this.handleCancel(); 126 | } else { 127 | this.handleConfirm(); 128 | } 129 | } 130 | }; 131 | 132 | handleCheckboxChange = (e: React.ChangeEvent) => { 133 | this.setState({checked: e.target.checked}); 134 | }; 135 | 136 | handleConfirm = () => { 137 | this.props.onConfirm(this.state.checked); 138 | }; 139 | 140 | handleCancel = () => { 141 | this.props.onCancel(this.state.checked); 142 | }; 143 | 144 | render() { 145 | let checkbox; 146 | if (this.props.showCheckbox) { 147 | checkbox = ( 148 |
149 | 157 |
158 | ); 159 | } 160 | 161 | let cancelText = 'Cancel' 162 | if (this.props.cancelButtonText) { 163 | cancelText = this.props.cancelButtonText; 164 | } 165 | 166 | let cancelButton; 167 | if (!this.props.hideCancel) { 168 | cancelButton = ( 169 | 177 | ); 178 | } 179 | 180 | return ( 181 | 193 | 194 | {this.props.title} 195 | 196 | 197 | {this.props.message} 198 | {checkbox} 199 | 200 | 201 | {cancelButton} 202 | 211 | 212 | 213 | ); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /webapp/src/index.jsx: -------------------------------------------------------------------------------- 1 | import {id as pluginId} from './manifest'; 2 | 3 | import CustomAttributesSettings from './components/admin_settings/CustomAttributeSettings.jsx'; 4 | 5 | export default class Plugin { 6 | initialize(registry, store) { 7 | 8 | const CustomAttributesSettingsWrapper = (props) => { 9 | 10 | return ( 11 | 12 | ); 13 | } 14 | 15 | registry.registerAdminConsoleCustomSetting('alertConfigs', CustomAttributesSettingsWrapper); 16 | } 17 | } 18 | 19 | window.registerPlugin(pluginId, new Plugin()); 20 | -------------------------------------------------------------------------------- /webapp/src/manifest.js: -------------------------------------------------------------------------------- 1 | import manifest from '../../plugin.json'; 2 | 3 | export const id = manifest.id; 4 | export const version = manifest.version; 5 | -------------------------------------------------------------------------------- /webapp/src/styles/main.css: -------------------------------------------------------------------------------- 1 | .alert-setting__wrapper { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | .setting-row { 6 | display: flex; 7 | flex-direction: row; 8 | width: 100%; 9 | } 10 | .delete-setting { 11 | cursor: pointer; 12 | height: 29px; 13 | width: 30px; 14 | margin-bottom: 15px; 15 | display: flex; 16 | flex-direction: row; 17 | justify-content: center; 18 | align-items: center; 19 | } 20 | .alert-setting { 21 | display: flex; 22 | flex-direction: column; 23 | border: 1px solid rgba(0, 0, 0, 0.15); 24 | padding: 15px; 25 | margin-bottom: 15px; 26 | } 27 | .alert-setting__controls { 28 | display: flex; 29 | flex-direction: row; 30 | justify-content: space-between; 31 | margin-bottom: 15px; 32 | } 33 | .alert-setting--with-error { 34 | border: 1px solid red; 35 | } 36 | .alert-setting__error-text { 37 | color: red; 38 | font-weight: 600; 39 | } 40 | .alert-setting__content { 41 | display: flex; 42 | flex-direction: column; 43 | } 44 | .no-settings-alert { 45 | margin-bottom: 15px; 46 | } 47 | .alert-setting__order-number { 48 | font-weight: 600; 49 | font-size: 16px; 50 | } -------------------------------------------------------------------------------- /webapp/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | module.exports = { 5 | entry: ['./src/index.jsx'], 6 | resolve: { 7 | modules: ['src', 'node_modules', path.resolve(__dirname)], 8 | extensions: ['*', '.js', '.jsx', '.tsx'], 9 | fallback: { 10 | crypto: require.resolve('crypto-browserify'), 11 | stream: require.resolve('stream-browserify'), 12 | buffer: require.resolve('buffer/'), 13 | } 14 | }, 15 | plugins: [ 16 | // Work around for Buffer is undefined: 17 | // https://github.com/webpack/changelog-v5/issues/10 18 | new webpack.ProvidePlugin({ 19 | Buffer: ['buffer', 'Buffer'], 20 | }), 21 | new webpack.ProvidePlugin({ 22 | process: 'process/browser', 23 | }), 24 | ], 25 | optimization: { minimize: false }, 26 | module: { 27 | rules: [ 28 | { 29 | test: /\.(js|jsx|ts|tsx)?$/, 30 | exclude: /node_modules/, 31 | use: { 32 | loader: 'babel-loader', 33 | options: { 34 | plugins: [ 35 | '@babel/plugin-proposal-class-properties', 36 | '@babel/plugin-syntax-dynamic-import', 37 | '@babel/plugin-proposal-object-rest-spread', 38 | ], 39 | presets: [ 40 | [ 41 | '@babel/preset-env', 42 | { 43 | targets: { 44 | chrome: 66, 45 | firefox: 60, 46 | edge: 42, 47 | safari: 12, 48 | }, 49 | corejs: 3, 50 | modules: false, 51 | debug: false, 52 | useBuiltIns: 'usage', 53 | shippedProposals: true, 54 | }, 55 | ], 56 | [ 57 | '@babel/preset-react', 58 | { 59 | useBuiltIns: true, 60 | }, 61 | ], 62 | ['@babel/preset-typescript', {allowNamespaces: true}], 63 | ], 64 | }, 65 | }, 66 | }, 67 | { 68 | test: /\.css$/i, 69 | use: [ 'style-loader', 'css-loader' ] 70 | }, 71 | ], 72 | }, 73 | externals: { 74 | react: 'React', 75 | redux: 'Redux', 76 | 'react-redux': 'ReactRedux', 77 | 'prop-types': 'PropTypes', 78 | 'react-bootstrap': 'ReactBootstrap', 79 | }, 80 | output: { 81 | path: path.join(__dirname, '/dist'), 82 | publicPath: '/', 83 | filename: 'main.js', 84 | }, 85 | }; 86 | --------------------------------------------------------------------------------