├── frontend ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── src │ ├── components │ │ ├── button │ │ │ ├── styles.css │ │ │ └── index.tsx │ │ ├── alertlist │ │ │ ├── styles.css │ │ │ └── index.tsx │ │ ├── spinner │ │ │ ├── index.tsx │ │ │ └── styles.css │ │ ├── labelcard │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── stats │ │ │ └── single_stat_panel │ │ │ │ ├── styles.css │ │ │ │ └── index.tsx │ │ ├── labelmatchercard │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── alertcard │ │ │ ├── styles.css │ │ │ ├── labels.tsx │ │ │ └── index.tsx │ │ ├── silencecard │ │ │ ├── styles.css │ │ │ ├── labels.tsx │ │ │ └── index.tsx │ │ ├── header │ │ │ ├── index.tsx │ │ │ └── styles.css │ │ ├── loader │ │ │ └── index.tsx │ │ └── app.tsx │ ├── declaration.d.ts │ ├── routes │ │ ├── home │ │ │ ├── styles.css │ │ │ └── index.tsx │ │ ├── alert │ │ │ ├── styles.css │ │ │ └── index.tsx │ │ ├── new-silence │ │ │ ├── styles.css │ │ │ ├── index.tsx │ │ │ ├── utils.tsx │ │ │ └── preview.tsx │ │ ├── silences │ │ │ └── index.tsx │ │ └── view-silence │ │ │ └── index.tsx │ ├── api │ │ ├── models │ │ │ ├── StatsResult.ts │ │ │ ├── AlertAcknowledgement.ts │ │ │ ├── Matcher.ts │ │ │ ├── Silence.ts │ │ │ └── Alert.ts │ │ ├── core │ │ │ ├── ApiResult.ts │ │ │ ├── ApiRequestOptions.ts │ │ │ ├── ApiError.ts │ │ │ ├── OpenAPI.ts │ │ │ └── CancelablePromise.ts │ │ └── index.ts │ ├── index.tsx │ ├── style │ │ └── index.css │ └── utils │ │ └── date.tsx ├── tsconfig.json └── package.json ├── examples ├── basic_flow.dot ├── slack.dot ├── ratelimit.dot ├── sends_alerts_to_console.dot ├── silence_validation.dot ├── multiple_validation_flows.dot ├── split_alerts_to_files.dot ├── validate_alert_acknowledgements.dot └── configure_grouping.dot ├── cmd ├── tuku │ ├── commands │ │ ├── silences │ │ │ ├── silences.go │ │ │ └── post.go │ │ ├── alerts │ │ │ ├── alerts.go │ │ │ ├── get.go │ │ │ ├── post.go │ │ │ └── tests.go │ │ └── commands.go │ ├── main.go │ └── kiora │ │ └── interface.go └── kiora │ ├── config │ ├── import.go │ ├── graph_utils.go │ └── graph.go │ └── main.go ├── testdata ├── prometheus │ ├── run-prometheus.sh │ ├── rules.yml │ └── prometheus.yml ├── kiora.dot └── run-cluster.sh ├── .gitignore ├── lib └── kiora │ ├── config │ ├── unmarshal │ │ ├── secret.go │ │ ├── maybe_file.go │ │ └── unmarshal_test.go │ ├── anchor_node.go │ ├── filters │ │ ├── nop │ │ │ └── filter.go │ │ ├── regex │ │ │ ├── filter.go │ │ │ └── filter_test.go │ │ ├── duration │ │ │ ├── filter.go │ │ │ └── filter_test.go │ │ └── ratelimit │ │ │ ├── filter_test.go │ │ │ └── filter.go │ ├── node.go │ ├── notifiers │ │ ├── filenotifier │ │ │ ├── notifier_node_test.go │ │ │ └── notifier.go │ │ └── slack │ │ │ └── notifier.go │ ├── filters.go │ ├── conf_nodes.go │ └── globals.go │ ├── model │ ├── ack.go │ ├── labels.go │ ├── matcher_test.go │ ├── silence.go │ └── matcher.go │ └── kioradb │ ├── db.go │ ├── query │ ├── query.go │ ├── sort.go │ ├── sort_test.go │ ├── stats_test.go │ └── stats.go │ ├── inmemory_test.go │ └── inmemory.go ├── internal ├── stubs │ ├── file.go │ └── time.go ├── clustering │ ├── serf │ │ ├── messages │ │ │ ├── silence.go │ │ │ ├── ack.go │ │ │ ├── alert.go │ │ │ └── registry.go │ │ ├── delegate.go │ │ └── hclog.go │ ├── broadcaster.go │ ├── ring_clusterer_test.go │ ├── clusterer.go │ └── ring_clusterer.go ├── server │ ├── metrics │ │ ├── metrics.go │ │ └── tenantcount.go │ ├── frontend │ │ └── frontend.go │ ├── config.go │ └── api │ │ ├── promcompat │ │ └── api.go │ │ └── api_impl.go ├── encoding │ └── encoding.go ├── services │ ├── notify │ │ └── notify_config │ │ │ └── config.go │ ├── timeout │ │ ├── service.go │ │ └── service_test.go │ ├── bus.go │ └── services.go ├── pipeline │ └── buffer_db_test.go ├── testutils │ └── alerts.go └── tracing │ └── tracing.go ├── mocks ├── mock_config │ └── utils.go ├── mock_clustering │ ├── utils.go │ ├── clusterer.go │ └── broadcaster.go ├── mock_kioradb │ ├── utils.go │ └── db.go └── mock_services │ └── bus.go ├── integration ├── frontend_test.go ├── test_helpers.go ├── ha_test.go ├── group_test.go └── single_node_test.go ├── Makefile └── .golangci.yml /frontend/.nvmrc: -------------------------------------------------------------------------------- 1 | v18.15.0 -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | src/api 2 | -------------------------------------------------------------------------------- /frontend/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "useTabs": true 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/components/button/styles.css: -------------------------------------------------------------------------------- 1 | .button { 2 | padding: 10px; 3 | font-size: 1em; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/components/alertlist/styles.css: -------------------------------------------------------------------------------- 1 | .success { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/declaration.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.css" { 2 | const mapping: Record; 3 | export default mapping; 4 | } 5 | -------------------------------------------------------------------------------- /examples/basic_flow.dot: -------------------------------------------------------------------------------- 1 | digraph config { 2 | // This is an empty config. Alerts go no where, and there's no validation of anything. 3 | } 4 | -------------------------------------------------------------------------------- /examples/slack.dot: -------------------------------------------------------------------------------- 1 | digraph config { 2 | slack [type="slack" api_url="https://hooks.slack.com/services/xxx/xxx"]; 3 | 4 | alerts -> slack; 5 | } -------------------------------------------------------------------------------- /cmd/tuku/commands/silences/silences.go: -------------------------------------------------------------------------------- 1 | package silences 2 | 3 | type SilencesCmd struct { 4 | Post SilencePostCmd `cmd:"" help:"Add a silence."` 5 | } 6 | -------------------------------------------------------------------------------- /testdata/prometheus/run-prometheus.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | docker run --name kiora-prometheus --net host -d -v $(pwd)/testdata/prometheus:/etc/prometheus prom/prometheus 4 | -------------------------------------------------------------------------------- /frontend/src/routes/home/styles.css: -------------------------------------------------------------------------------- 1 | .row { 2 | display: flex; 3 | flex-direction: row; 4 | justify-content: space-evenly; 5 | align-items: center; 6 | margin: 20px; 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /artifacts/ 2 | .vscode/ 3 | /kiora 4 | /tuku 5 | /frontend/build 6 | /frontend/size-plugin.json 7 | /frontend/node_modules 8 | /internal/server/frontend/assets 9 | /kiora.db 10 | -------------------------------------------------------------------------------- /testdata/kiora.dot: -------------------------------------------------------------------------------- 1 | digraph config { 2 | tenant_key = "{{ .service }}"; 3 | 4 | dont_group [type="group_wait" duration="0s"]; 5 | console [type="stdout"]; 6 | alerts -> dont_group -> console; 7 | } 8 | -------------------------------------------------------------------------------- /examples/ratelimit.dot: -------------------------------------------------------------------------------- 1 | digraph { 2 | tenant_key = "{{ .alertname }}"; 3 | // Ratelimit alerts to 300 per 30 seconds per tenant. 4 | ratelimit -> alerts [type="ratelimit" rate="300" interval="30s"]; 5 | } 6 | -------------------------------------------------------------------------------- /examples/sends_alerts_to_console.dot: -------------------------------------------------------------------------------- 1 | digraph config { 2 | // This uses the special "alerts" node that all alerts start at, and sends every alert to stdout. 3 | console [type="stdout"]; 4 | alerts -> console; 5 | } -------------------------------------------------------------------------------- /frontend/src/components/spinner/index.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import styles from "./styles.css"; 3 | 4 | const Spinner = () => { 5 | return
; 6 | }; 7 | 8 | export default Spinner; 9 | -------------------------------------------------------------------------------- /lib/kiora/config/unmarshal/secret.go: -------------------------------------------------------------------------------- 1 | package unmarshal 2 | 3 | // Secret wraps a string in a stringer that redacts the value. 4 | type Secret string 5 | 6 | func (s Secret) String() string { 7 | return "" 8 | } 9 | -------------------------------------------------------------------------------- /testdata/prometheus/rules.yml: -------------------------------------------------------------------------------- 1 | groups: 2 | - name: test 3 | rules: 4 | - alert: TestAlert 5 | expr: up == 0 6 | labels: 7 | severity: page 8 | annotations: 9 | summary: High request latency 10 | -------------------------------------------------------------------------------- /cmd/tuku/commands/alerts/alerts.go: -------------------------------------------------------------------------------- 1 | package alerts 2 | 3 | type AlertsCmd struct { 4 | Get AlertsGetCmd `cmd:"" help:"Get alerts."` 5 | Post AlertsPostCmd `cmd:"" help:"Post alerts."` 6 | Test AlertsTestCmd `cmd:"" help:"Test alerts."` 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/api/models/StatsResult.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | export type StatsResult = { 6 | labels: Record; 7 | frames: Array>; 8 | }; 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/api/models/AlertAcknowledgement.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | export type AlertAcknowledgement = { 6 | alertID: string; 7 | creator: string; 8 | comment: string; 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/api/models/Matcher.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | export type Matcher = { 6 | label: string; 7 | value: string; 8 | isRegex: boolean; 9 | isNegative: boolean; 10 | }; 11 | 12 | -------------------------------------------------------------------------------- /frontend/src/routes/alert/styles.css: -------------------------------------------------------------------------------- 1 | .alert-view { 2 | display: flex; 3 | flex-direction: column; 4 | flex-wrap: wrap; 5 | } 6 | 7 | .alert-row { 8 | display: flex; 9 | flex-direction: row; 10 | align-items: center; 11 | padding-bottom: 5px; 12 | } 13 | -------------------------------------------------------------------------------- /internal/stubs/file.go: -------------------------------------------------------------------------------- 1 | package stubs 2 | 3 | import ( 4 | "io/fs" 5 | "os" 6 | ) 7 | 8 | type stubOS struct { 9 | OpenFile func(name string, flags int, perm fs.FileMode) (*os.File, error) 10 | } 11 | 12 | var OS = stubOS{ 13 | OpenFile: os.OpenFile, 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/components/labelcard/style.css: -------------------------------------------------------------------------------- 1 | .label { 2 | border: solid var(--border); 3 | margin: 5px 10px 5px -5px; 4 | padding: 5px 5px 5px 5px; 5 | border-radius: 5px; 6 | white-space: nowrap; 7 | } 8 | 9 | .label:hover { 10 | background: var(--border); 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/components/stats/single_stat_panel/styles.css: -------------------------------------------------------------------------------- 1 | .card { 2 | border: 2px solid var(--border); 3 | padding: 10px; 4 | text-align: center; 5 | border-radius: 5px; 6 | } 7 | 8 | .value { 9 | font-size: 4em; 10 | } 11 | 12 | .title { 13 | font-weight: bold; 14 | } 15 | -------------------------------------------------------------------------------- /cmd/tuku/commands/commands.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/sinkingpoint/kiora/cmd/tuku/kiora" 5 | "github.com/sinkingpoint/kiora/internal/encoding" 6 | ) 7 | 8 | type Context struct { 9 | Kiora *kiora.KioraInstance 10 | Formatter encoding.Encoder 11 | } 12 | -------------------------------------------------------------------------------- /internal/stubs/time.go: -------------------------------------------------------------------------------- 1 | package stubs 2 | 3 | import "time" 4 | 5 | // stubTime provides a stub over the `time` package so we can control return values in tests. 6 | type stubTime struct { 7 | Now func() time.Time 8 | } 9 | 10 | var Time = stubTime{ 11 | Now: time.Now, 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/api/core/ApiResult.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | export type ApiResult = { 5 | readonly url: string; 6 | readonly ok: boolean; 7 | readonly status: number; 8 | readonly statusText: string; 9 | readonly body: any; 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/src/components/labelmatchercard/style.css: -------------------------------------------------------------------------------- 1 | .label-matcher { 2 | padding: 5px; 3 | border: solid var(--border); 4 | word-wrap: none; 5 | white-space: nowrap; 6 | margin-right: 5px; 7 | margin-top: 5px; 8 | } 9 | 10 | .delete-label-button { 11 | height: 100%; 12 | background: inherit; 13 | border: none; 14 | } 15 | -------------------------------------------------------------------------------- /internal/clustering/serf/messages/silence.go: -------------------------------------------------------------------------------- 1 | package messages 2 | 3 | import "github.com/sinkingpoint/kiora/lib/kiora/model" 4 | 5 | func init() { 6 | registerMessage(func() Message { return &Silence{} }) 7 | } 8 | 9 | type Silence struct { 10 | Silence model.Silence 11 | } 12 | 13 | func (s *Silence) Name() string { 14 | return "silence" 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/api/models/Silence.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { Matcher } from './Matcher'; 6 | 7 | export type Silence = { 8 | readonly id: string; 9 | creator: string; 10 | comment: string; 11 | startsAt: string; 12 | endsAt: string; 13 | matchers: Array; 14 | }; 15 | 16 | -------------------------------------------------------------------------------- /frontend/src/routes/new-silence/styles.css: -------------------------------------------------------------------------------- 1 | .silence-form { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | 6 | .silence-form > div { 7 | display: flex; 8 | flex-direction: row; 9 | padding: 10px; 10 | } 11 | 12 | .silence-form > div > label { 13 | font-weight: bold; 14 | font-size: 24px; 15 | } 16 | 17 | .silence-form > * > input[type="text"] { 18 | font-size: 20px; 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/components/button/index.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import style from "./styles.css"; 3 | 4 | interface ButtonProps { 5 | label: string; 6 | onClick?: () => void; 7 | } 8 | 9 | const Button = ({ label, onClick }: ButtonProps) => { 10 | return ( 11 | 14 | ); 15 | }; 16 | 17 | export default Button; 18 | -------------------------------------------------------------------------------- /frontend/src/components/labelcard/index.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import style from "./style.css"; 3 | 4 | interface LabelProps { 5 | labelName: string; 6 | labelValue: string; 7 | } 8 | 9 | const LabelCard = ({ labelName, labelValue }: LabelProps) => { 10 | return ( 11 | 12 | {labelName}="{labelValue}" 13 | 14 | ); 15 | }; 16 | 17 | export default LabelCard; 18 | -------------------------------------------------------------------------------- /internal/clustering/serf/messages/ack.go: -------------------------------------------------------------------------------- 1 | package messages 2 | 3 | import "github.com/sinkingpoint/kiora/lib/kiora/model" 4 | 5 | func init() { 6 | registerMessage(func() Message { return &Acknowledgement{} }) 7 | } 8 | 9 | type Acknowledgement struct { 10 | AlertID string 11 | Acknowledgement model.AlertAcknowledgement 12 | } 13 | 14 | func (a *Acknowledgement) Name() string { 15 | return "ack" 16 | } 17 | -------------------------------------------------------------------------------- /testdata/prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | evaluation_interval: 5s 3 | rule_files: 4 | - /etc/prometheus/rules.yml 5 | alerting: 6 | alertmanagers: 7 | - path_prefix: api/prom-compat 8 | static_configs: 9 | - targets: 10 | - 0.0.0.0:4278 11 | scrape_configs: 12 | - job_name: test 13 | scrape_interval: 5s 14 | static_configs: 15 | - targets: 16 | - localhost:8123 17 | -------------------------------------------------------------------------------- /internal/clustering/serf/messages/alert.go: -------------------------------------------------------------------------------- 1 | package messages 2 | 3 | import "github.com/sinkingpoint/kiora/lib/kiora/model" 4 | 5 | var _ = Message(&Alert{}) 6 | 7 | func init() { 8 | registerMessage(func() Message { return &Alert{} }) 9 | } 10 | 11 | // Alert is a message representing an update to an alert. 12 | type Alert struct { 13 | Alert model.Alert 14 | } 15 | 16 | func (a *Alert) Name() string { 17 | return "alert" 18 | } 19 | -------------------------------------------------------------------------------- /mocks/mock_config/utils.go: -------------------------------------------------------------------------------- 1 | package mock_config 2 | 3 | import gomock "github.com/golang/mock/gomock" 4 | 5 | // NewMockConfigAllowingEverything returns a MockConfig set up with a `ValidateData` method that returns nil. 6 | func NewMockConfigAllowingEverything(ctrl *gomock.Controller) *MockConfig { 7 | conf := NewMockConfig(ctrl) 8 | conf.EXPECT().ValidateData(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() 9 | 10 | return conf 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/components/alertcard/styles.css: -------------------------------------------------------------------------------- 1 | .single { 2 | padding: 10px; 3 | border-radius: 5px; 4 | border: solid var(--border); 5 | margin: 10px; 6 | } 7 | 8 | .single-top { 9 | font-size: 1em; 10 | font-weight: bold; 11 | color: var(--text); 12 | padding-left: 10px; 13 | } 14 | 15 | .labels { 16 | margin-top: 5px; 17 | display: flex; 18 | flex-wrap: wrap; 19 | } 20 | 21 | .alert-link { 22 | color: inherit; 23 | text-decoration: inherit; 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/components/silencecard/styles.css: -------------------------------------------------------------------------------- 1 | .single { 2 | padding: 10px; 3 | border-radius: 5px; 4 | border: solid var(--border); 5 | margin: 10px; 6 | } 7 | 8 | .single-top { 9 | font-size: 1em; 10 | font-weight: bold; 11 | color: var(--text); 12 | padding-left: 10px; 13 | } 14 | 15 | .labels { 16 | margin-top: 5px; 17 | display: flex; 18 | flex-wrap: wrap; 19 | } 20 | 21 | .alert-link { 22 | color: inherit; 23 | text-decoration: inherit; 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/components/header/index.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import style from "./styles.css"; 3 | import { Link } from "preact-router/match"; 4 | 5 | const Header = () => { 6 | return ( 7 |
8 | 9 |

Kiora

10 |
11 | 12 | 17 |
18 | ); 19 | }; 20 | 21 | export default Header; 22 | -------------------------------------------------------------------------------- /frontend/src/components/stats/single_stat_panel/index.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import styles from "./styles.css"; 3 | 4 | interface SingleStatPanelProps { 5 | title: string; 6 | value: string; 7 | color?: string; 8 | } 9 | 10 | const SingleStatPanel = ({ title, value }: SingleStatPanelProps) => { 11 | return ( 12 |
13 |
{value}
14 | 15 | 16 |
17 | ); 18 | }; 19 | 20 | export default SingleStatPanel; 21 | -------------------------------------------------------------------------------- /lib/kiora/config/anchor_node.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | func init() { 4 | RegisterNode("", func(name string, globals *Globals, attrs map[string]string) (Node, error) { 5 | return &AnchorNode{}, nil 6 | }) 7 | } 8 | 9 | // AnchorNode is the default node type, if nothing else is specified. They do nothing except 10 | // act as anchor points for Links to allow splitting one or more incoming links into one or more outgoing ones. 11 | type AnchorNode struct{} 12 | 13 | func (a *AnchorNode) Type() string { 14 | return "anchor" 15 | } 16 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "esModuleInterop": true, 7 | "jsx": "react", 8 | "jsxFactory": "h", 9 | "jsxFragmentFactory": "Fragment", 10 | "noEmit": true, 11 | "allowJs": true, 12 | "checkJs": true, 13 | "skipLibCheck": true, 14 | "baseUrl": "./", 15 | "paths": { 16 | "react": ["./node_modules/preact/compat"], 17 | "react-dom": ["./node_modules/preact/compat"] 18 | } 19 | }, 20 | "include": ["src/**/*"] 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/components/silencecard/labels.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import style from "./styles.css"; 3 | import { Silence } from "../../api"; 4 | import LabelMatcherCard from "../labelmatchercard"; 5 | 6 | interface LabelViewProps { 7 | silence: Silence; 8 | } 9 | 10 | const Labels = ({ silence }: LabelViewProps) => { 11 | return ( 12 |
13 | {silence.matchers.map((matcher) => { 14 | return ; 15 | })} 16 |
17 | ); 18 | }; 19 | 20 | export default Labels; 21 | -------------------------------------------------------------------------------- /cmd/tuku/commands/alerts/get.go: -------------------------------------------------------------------------------- 1 | package alerts 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pkg/errors" 7 | "github.com/sinkingpoint/kiora/cmd/tuku/commands" 8 | ) 9 | 10 | type AlertsGetCmd struct{} 11 | 12 | func (a *AlertsGetCmd) Run(ctx *commands.Context) error { 13 | alerts, err := ctx.Kiora.GetAlerts() 14 | if err != nil { 15 | return err 16 | } 17 | 18 | out, err := ctx.Formatter.Marshal(alerts) 19 | if err != nil { 20 | return errors.Wrap(err, "failed to marshal alerts") 21 | } 22 | 23 | fmt.Println(string(out)) 24 | 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /internal/server/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "github.com/gorilla/mux" 5 | "github.com/prometheus/client_golang/prometheus" 6 | "github.com/prometheus/client_golang/prometheus/promhttp" 7 | "github.com/sinkingpoint/kiora/lib/kiora/config" 8 | "github.com/sinkingpoint/kiora/lib/kiora/kioradb" 9 | ) 10 | 11 | func Register(router *mux.Router) { 12 | router.Handle("/metrics", promhttp.Handler()) 13 | } 14 | 15 | func RegisterMetricsCollectors(globals *config.Globals, db kioradb.DB) { 16 | prometheus.MustRegister(NewTenantCountCollector(globals, db)) 17 | } 18 | -------------------------------------------------------------------------------- /integration/frontend_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | // TestFrontendIsEmbedded tests that the frontend is embedded and can be accessed via the HTTP server. 12 | func TestFrontendIsEmbedded(t *testing.T) { 13 | initT(t) 14 | 15 | kiora := NewKioraInstance(t).Start() 16 | time.Sleep(1 * time.Second) 17 | 18 | resp, err := http.Get(kiora.GetHTTPURL("/")) 19 | require.NoError(t, err) 20 | resp.Body.Close() 21 | 22 | require.Equal(t, http.StatusOK, resp.StatusCode) 23 | } 24 | -------------------------------------------------------------------------------- /mocks/mock_clustering/utils.go: -------------------------------------------------------------------------------- 1 | package mock_clustering 2 | 3 | import ( 4 | "github.com/golang/mock/gomock" 5 | "github.com/sinkingpoint/kiora/lib/kiora/model" 6 | ) 7 | 8 | // MockBroadcasterExpectingAlerts contsructs a MockBroadcaster that expects a number of calls to BroadcastAlerts. 9 | func MockBroadcasterExpectingAlerts(ctrl *gomock.Controller, alerts ...[]model.Alert) *MockBroadcaster { 10 | broadcaster := NewMockBroadcaster(ctrl) 11 | for _, alerts := range alerts { 12 | broadcaster.EXPECT().BroadcastAlerts(gomock.Any(), alerts).Times(1) 13 | } 14 | 15 | return broadcaster 16 | } 17 | -------------------------------------------------------------------------------- /lib/kiora/config/filters/nop/filter.go: -------------------------------------------------------------------------------- 1 | package nop 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sinkingpoint/kiora/lib/kiora/config" 7 | ) 8 | 9 | // NopFilter is the default filter when a type is not specified. It does nothing and lets everything through. 10 | type NopFilter struct{} 11 | 12 | func NewFilter(globals *config.Globals, attrs map[string]string) (config.Filter, error) { 13 | return &NopFilter{}, nil 14 | } 15 | 16 | func (n *NopFilter) Type() string { 17 | return "nop" 18 | } 19 | 20 | func (n *NopFilter) Filter(ctx context.Context, _ config.Fielder) error { 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/components/alertcard/labels.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import Label from "../labelcard"; 3 | import style from "./styles.css"; 4 | import { Alert } from "../../api"; 5 | 6 | interface LabelViewProps { 7 | alert: Alert; 8 | } 9 | 10 | const Labels = ({ alert }: LabelViewProps) => { 11 | return ( 12 |
13 | {Object.keys(alert.labels).map((key) => { 14 | if (key === "alertname") { 15 | return; 16 | } 17 | return
20 | ); 21 | }; 22 | 23 | export default Labels; 24 | -------------------------------------------------------------------------------- /internal/clustering/serf/messages/registry.go: -------------------------------------------------------------------------------- 1 | package messages 2 | 3 | // Message is a message that can be sent through the Serf gossip channel. 4 | type Message interface { 5 | Name() string 6 | } 7 | 8 | type messageConstructor = func() Message 9 | 10 | var messageRegistry = map[string]messageConstructor{} 11 | 12 | func registerMessage(cons messageConstructor) { 13 | messageRegistry[cons().Name()] = cons 14 | } 15 | 16 | // GetMessage returns a blank message with the given name, or nil if there isn't one registered. 17 | func GetMessage(name string) Message { 18 | if cons, ok := messageRegistry[name]; ok { 19 | return cons() 20 | } 21 | 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/api/core/ApiRequestOptions.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | export type ApiRequestOptions = { 5 | readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; 6 | readonly url: string; 7 | readonly path?: Record; 8 | readonly cookies?: Record; 9 | readonly headers?: Record; 10 | readonly query?: Record; 11 | readonly formData?: Record; 12 | readonly body?: any; 13 | readonly mediaType?: string; 14 | readonly responseHeader?: string; 15 | readonly errors?: Record; 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/src/api/index.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | export { ApiError } from './core/ApiError'; 5 | export { CancelablePromise, CancelError } from './core/CancelablePromise'; 6 | export { OpenAPI } from './core/OpenAPI'; 7 | export type { OpenAPIConfig } from './core/OpenAPI'; 8 | 9 | export { Alert } from './models/Alert'; 10 | export type { AlertAcknowledgement } from './models/AlertAcknowledgement'; 11 | export type { Matcher } from './models/Matcher'; 12 | export type { Silence } from './models/Silence'; 13 | export type { StatsResult } from './models/StatsResult'; 14 | 15 | export { DefaultService } from './services/DefaultService'; 16 | -------------------------------------------------------------------------------- /frontend/src/components/loader/index.tsx: -------------------------------------------------------------------------------- 1 | import { Inputs, useEffect, useState } from "preact/hooks"; 2 | import Spinner from "../spinner"; 3 | import { h } from "preact"; 4 | 5 | interface LoaderProps { 6 | loader: () => void; 7 | inputs?: Inputs; 8 | children?: JSX.Element; 9 | } 10 | 11 | const Loader = ({ loader, inputs, children }: LoaderProps) => { 12 | const [loaded, setLoaded] = useState(false); 13 | 14 | if (!inputs) { 15 | inputs = []; 16 | } 17 | 18 | useEffect(() => { 19 | if (!loaded) { 20 | loader(); 21 | setLoaded(true); 22 | } 23 | }, [loader, loaded, setLoaded, ...inputs]); 24 | 25 | return loaded ? children : ; 26 | }; 27 | 28 | export default Loader; 29 | -------------------------------------------------------------------------------- /frontend/src/routes/new-silence/index.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { useState } from "preact/hooks"; 3 | import style from "./styles.css"; 4 | import CreatePage from "./create"; 5 | import PreviewPage, { PreviewPageProps } from "./preview"; 6 | 7 | const NewSilence = () => { 8 | const [preview, setPreview] = useState(null); 9 | 10 | let page: JSX.Element; 11 | if (preview === null) { 12 | page = ; 13 | } else { 14 | page = ; 15 | } 16 | 17 | return ( 18 |
19 |

New Silence

20 | 21 | {page} 22 |
23 | ); 24 | }; 25 | 26 | export default NewSilence; 27 | -------------------------------------------------------------------------------- /lib/kiora/model/ack.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "fmt" 4 | 5 | // AlertAcknowledgement is the metadata provided when an operator acknowledges an alert. 6 | type AlertAcknowledgement struct { 7 | Creator string `json:"creator"` 8 | Comment string `json:"comment"` 9 | } 10 | 11 | func (a *AlertAcknowledgement) Fields() map[string]any { 12 | return map[string]any{ 13 | "__creator__": a.Creator, 14 | "__comment__": a.Comment, 15 | } 16 | } 17 | 18 | func (a *AlertAcknowledgement) Field(name string) (any, error) { 19 | switch name { 20 | case "__creator__": 21 | return a.Creator, nil 22 | case "__comment__": 23 | return a.Comment, nil 24 | default: 25 | return "", fmt.Errorf("field %q doesn't exist", name) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/silence_validation.dot: -------------------------------------------------------------------------------- 1 | digraph config { 2 | // Silences support a `duration` filter, that allows you to make decisions based on how long the silence it. 3 | 4 | // e.g. we have this path that will only let silences through that have a JIRA ticket if they are longer 8h. 5 | if_duration_longer_than_one_shift -> test_ticket [type="duration" field="duration" min="8h"]; // Only enter this branch for silences with a minimum duration of 8h. 6 | test_ticket -> silences [type="regex" field="comment" regex="[A-Z]+-[0-9]+"]; // If they're longer than 8h, enforce a JIRA ticket. 7 | 8 | // Alternatively, if they're a maximum of 8h long, let them through. 9 | short_silences -> silences [type="duration" field="duration" max="8h"]; 10 | } -------------------------------------------------------------------------------- /cmd/tuku/commands/alerts/post.go: -------------------------------------------------------------------------------- 1 | package alerts 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/pkg/errors" 7 | "github.com/sinkingpoint/kiora/cmd/tuku/commands" 8 | "github.com/sinkingpoint/kiora/lib/kiora/model" 9 | ) 10 | 11 | type AlertsPostCmd struct { 12 | Alerts []string `arg:"" help:"Alerts to post."` 13 | } 14 | 15 | func (a *AlertsPostCmd) Run(ctx *commands.Context) error { 16 | alerts := []model.Alert{} 17 | 18 | for _, alertJSON := range a.Alerts { 19 | alert := model.Alert{} 20 | if err := json.Unmarshal([]byte(alertJSON), &alert); err != nil { 21 | return errors.Wrap(err, "failed to unmarshal alert") 22 | } 23 | 24 | alerts = append(alerts, alert) 25 | } 26 | 27 | return ctx.Kiora.PostAlerts(alerts) 28 | } 29 | -------------------------------------------------------------------------------- /internal/clustering/broadcaster.go: -------------------------------------------------------------------------------- 1 | package clustering 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sinkingpoint/kiora/lib/kiora/model" 7 | ) 8 | 9 | // Broadcaster defines something that can tell other things about data. 10 | type Broadcaster interface { 11 | // BroadcastAlerts broadcasts a group of alerts to a cluster. 12 | BroadcastAlerts(ctx context.Context, alerts ...model.Alert) error 13 | 14 | // BroadcastAlertAcknowledgement broadcasts an AlertAcknowledgement of the given alert. 15 | BroadcastAlertAcknowledgement(ctx context.Context, alertID string, ack model.AlertAcknowledgement) error 16 | 17 | // BroadcastSilences broadcasts a group of silences to a cluster. 18 | BroadcastSilences(ctx context.Context, silences ...model.Silence) error 19 | } 20 | -------------------------------------------------------------------------------- /internal/encoding/encoding.go: -------------------------------------------------------------------------------- 1 | package encoding 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | type Encoder interface { 8 | Marshal(o any) ([]byte, error) 9 | } 10 | 11 | var encodingTable = map[string]Encoder{ 12 | "json": NewJSONEncoder(false), 13 | "json-pretty": NewJSONEncoder(true), 14 | } 15 | 16 | func LookupEncoding(name string) Encoder { 17 | return encodingTable[name] 18 | } 19 | 20 | type JSONEncoder struct { 21 | Pretty bool 22 | } 23 | 24 | func NewJSONEncoder(pretty bool) JSONEncoder { 25 | return JSONEncoder{ 26 | Pretty: pretty, 27 | } 28 | } 29 | 30 | func (j JSONEncoder) Marshal(o any) ([]byte, error) { 31 | if j.Pretty { 32 | return json.MarshalIndent(o, "", " ") 33 | } else { 34 | return json.Marshal(o) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /examples/multiple_validation_flows.dot: -------------------------------------------------------------------------------- 1 | digraph config { 2 | // Sometimes it's useful to have multiple potential validation paths. For example, we might have a bot account 3 | // that should also be allowed to acknowledge alerts. To do this, we can specify multiple paths into the acks pseudonode. 4 | 5 | // First, the regular human path, which must have an email and a comment. 6 | test_email -> test_comment [type="regex" field="creator" regex=".+@example.com"]; // First check the email 7 | test_comment -> acks [type="regex" field="comment" regex=".+"]; // Then check the comment. 8 | 9 | // And then a bot path where we don't need a comment, if the `from` is `RespectTables`: 10 | test_respect_tables -> acks [type="regex" field="creator" regex="RespectTables"]; 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/api/models/Alert.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { AlertAcknowledgement } from './AlertAcknowledgement'; 6 | 7 | export type Alert = { 8 | readonly id: string; 9 | labels: Record; 10 | annotations: Record; 11 | status: Alert.status; 12 | readonly acknowledgement?: AlertAcknowledgement; 13 | startsAt: string; 14 | endsAt?: string; 15 | readonly timeoutDeadline: string; 16 | }; 17 | 18 | export namespace Alert { 19 | 20 | export enum status { 21 | FIRING = 'firing', 22 | ACKED = 'acked', 23 | RESOLVED = 'resolved', 24 | TIMED_OUT = 'timed out', 25 | SILENCED = 'silenced', 26 | } 27 | 28 | 29 | } 30 | 31 | -------------------------------------------------------------------------------- /cmd/kiora/config/import.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/sinkingpoint/kiora/lib/kiora/config" 5 | "github.com/sinkingpoint/kiora/lib/kiora/config/filters/duration" 6 | "github.com/sinkingpoint/kiora/lib/kiora/config/filters/nop" 7 | "github.com/sinkingpoint/kiora/lib/kiora/config/filters/ratelimit" 8 | "github.com/sinkingpoint/kiora/lib/kiora/config/filters/regex" 9 | _ "github.com/sinkingpoint/kiora/lib/kiora/config/notifiers/filenotifier" 10 | _ "github.com/sinkingpoint/kiora/lib/kiora/config/notifiers/slack" 11 | ) 12 | 13 | func RegisterNodes() { 14 | config.RegisterFilter("", nop.NewFilter) 15 | config.RegisterFilter("regex", regex.NewFilter) 16 | config.RegisterFilter("duration", duration.NewFilter) 17 | config.RegisterFilter("ratelimit", ratelimit.NewFilter) 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import "./style"; 2 | import App from "./components/app"; 3 | import { OpenAPI } from "./api"; 4 | 5 | let apiHost = ""; 6 | // When running in dev, we're running in `preact-cli` which allows us to set 7 | // environment variables because it's a full Javascript runtime. When running 8 | // in a full Kiora deployment, we're running embedded in the Go app, which can't set them. 9 | // The reason we have to do this try-catch fudge is that when preact sets `process.env.PREACT_APP_API_HOST` 10 | // it _doesn't_ set `process` or `process.env`, so checking if those exist will throw an error. 11 | try { 12 | apiHost = process.env.PREACT_APP_API_HOST; 13 | } catch {} 14 | 15 | OpenAPI.VERSION = "v1"; 16 | OpenAPI.BASE = `${apiHost}/api/${OpenAPI.VERSION}`; 17 | 18 | export default App; 19 | -------------------------------------------------------------------------------- /lib/kiora/config/node.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Node interface { 4 | // Type returns a static name of the type of the node for debugging purposes. 5 | Type() string 6 | } 7 | 8 | // NodeConstructor is a function that takes a raw graph node and turns it into a node that can actually process things. 9 | type NodeConstructor = func(name string, globals *Globals, attrs map[string]string) (Node, error) 10 | 11 | var nodeRegistry = map[string]NodeConstructor{} 12 | 13 | func RegisterNode(name string, constructor NodeConstructor) { 14 | nodeRegistry[name] = constructor 15 | } 16 | 17 | // LookupNode takes a node type name and returns a constructor that can be used to make nodes of that name. 18 | func LookupNode(name string) (NodeConstructor, bool) { 19 | cons, ok := nodeRegistry[name] 20 | return cons, ok 21 | } 22 | -------------------------------------------------------------------------------- /testdata/run-cluster.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | make build-unchecked 6 | 7 | mkdir -p artifacts/logs 8 | 9 | echo 'Starting Kiora' 10 | ./artifacts/kiora -c ./testdata/kiora.dot --web.listen-url localhost:4278 --cluster.listen-url localhost:4281 --storage.path artifacts/kiora-1.db > artifacts/logs/kiora-1.log 2>&1 & 11 | sleep 1 12 | ./artifacts/kiora -c ./testdata/kiora.dot --web.listen-url localhost:4279 --cluster.listen-url localhost:4282 --storage.path artifacts/kiora-2.db --cluster.bootstrap-peers localhost:4281 > artifacts/logs/kiora-2.log 2>&1 & 13 | ./artifacts/kiora -c ./testdata/kiora.dot --web.listen-url localhost:4280 --cluster.listen-url localhost:4283 --storage.path artifacts/kiora-3.db --cluster.bootstrap-peers localhost:4281 > artifacts/logs/kiora-3.log 2>&1 & 14 | 15 | read -r -d '' _ 16 | -------------------------------------------------------------------------------- /examples/split_alerts_to_files.dot: -------------------------------------------------------------------------------- 1 | digraph config { 2 | // This demonstrates how to use filters on links to split alerts. 3 | // Note the "label" field on the links - you can use arbitrary GraphViz fields to mark up your configuration 4 | // to make it as easy to follow as possible. 5 | 6 | // First, we define two sinks, each with a different path. 7 | sink_a [type="file" path="/tmp/sink_a.log"]; 8 | sink_b [type="file" path="/tmp/sink_b.log"]; 9 | 10 | // Only send alerts to sink_a if the destination matches the regex "sink_a". 11 | alerts -> sink_a [label="destination is sink a" type="regex" field="dest" regex="sink_a"]; 12 | 13 | // Only send alerts to sink_b if the destination matches the regex "sink_b". 14 | alerts -> sink_b [label="destination is sink b" type="regex" field="dest" regex="sink_b"]; 15 | } -------------------------------------------------------------------------------- /frontend/src/api/core/ApiError.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | import type { ApiRequestOptions } from './ApiRequestOptions'; 5 | import type { ApiResult } from './ApiResult'; 6 | 7 | export class ApiError extends Error { 8 | public readonly url: string; 9 | public readonly status: number; 10 | public readonly statusText: string; 11 | public readonly body: any; 12 | public readonly request: ApiRequestOptions; 13 | 14 | constructor(request: ApiRequestOptions, response: ApiResult, message: string) { 15 | super(message); 16 | 17 | this.name = 'ApiError'; 18 | this.url = response.url; 19 | this.status = response.status; 20 | this.statusText = response.statusText; 21 | this.body = response.body; 22 | this.request = request; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/components/app.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import Router, { Route } from "preact-router"; 3 | import Home from "../routes/home"; 4 | import Header from "./header"; 5 | import Alert from "../routes/alert"; 6 | import NewSilence from "../routes/new-silence"; 7 | import ViewSilence from "../routes/view-silence"; 8 | import AllSilencesView from "../routes/silences"; 9 | 10 | const App = () => { 11 | return ( 12 |
13 |
14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 |
24 | ); 25 | }; 26 | 27 | export default App; 28 | -------------------------------------------------------------------------------- /examples/validate_alert_acknowledgements.dot: -------------------------------------------------------------------------------- 1 | digraph config { 2 | // We can use the same configurations to validate data as it comes into the system, and reject 3 | // it if it doesn't match some arbitrary criteria. 4 | // To do this, we can specify flows _into_ a model pseudonode, for example the `acks` pseudonode 5 | // which validates alert acknowledgements. 6 | 7 | // For example, if we want to enforce that all alert acknowlegments have a valid @example.com email, 8 | // and have a comment, we can set up a flow like this: 9 | test_email -> test_comment [type="regex" field="creator" regex=".+@example.com"]; // First check the email 10 | test_comment -> acks [type="regex" field="comment" regex=".+"]; // Then check the comment. 11 | 12 | // If there is any flow into the `acks` pseudonode that the acknowledgment can pass through, then it is accepted. 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/style/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: "Helvetica Neue", arial, sans-serif; 3 | font-weight: 400; 4 | -webkit-font-smoothing: antialiased; 5 | -moz-osx-font-smoothing: grayscale; 6 | 7 | color-scheme: light dark; 8 | 9 | --text: #444; 10 | --background: #fafafa; 11 | --border: #cacaca; 12 | color: var(--text); 13 | background: var(--background); 14 | } 15 | 16 | @media (prefers-color-scheme: dark) { 17 | :root { 18 | --text: #fff; 19 | --background: #1c1c1c; 20 | --border: #555555; 21 | } 22 | } 23 | 24 | body { 25 | margin: 0; 26 | padding: 0; 27 | min-height: 100vh; 28 | } 29 | 30 | #app > main { 31 | display: flex; 32 | padding-top: 3.5rem; 33 | margin: 0 auto; 34 | min-height: calc(100vh - 3.5rem); 35 | max-width: 1000px; 36 | flex-direction: column; 37 | } 38 | 39 | @media (max-width: 639px) { 40 | #app > main { 41 | margin: 0 2rem; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/components/header/styles.css: -------------------------------------------------------------------------------- 1 | header { 2 | position: fixed; 3 | left: 0; 4 | top: 0; 5 | 6 | display: flex; 7 | 8 | width: 100%; 9 | height: 3.5rem; 10 | 11 | background: var(--border); 12 | box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); 13 | z-index: 50; 14 | } 15 | 16 | header a { 17 | display: inline-block; 18 | padding: 0 1rem; 19 | color: var(--text); 20 | text-decoration: none; 21 | line-height: 3.5rem; 22 | } 23 | 24 | 25 | .header a:hover, 26 | .header a:active { 27 | background: rgba(0, 0, 0, 0.2); 28 | } 29 | 30 | .header a.logo { 31 | display: flex; 32 | align-items: center; 33 | padding: 0.5rem 1rem; 34 | } 35 | 36 | .logo h1 { 37 | padding: 0 0.5rem; 38 | font-size: 1.5rem; 39 | line-height: 2rem; 40 | font-weight: 400; 41 | } 42 | 43 | @media (max-width: 639px) { 44 | .logo h1 { 45 | display: none; 46 | } 47 | } 48 | 49 | .header nav a.active { 50 | background: rgba(0, 0, 0, 0.4); 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/components/alertcard/index.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { Alert } from "../../api"; 3 | import Labels from "./labels"; 4 | import style from "./styles.css"; 5 | 6 | interface CardProps { 7 | alert: Alert; 8 | } 9 | 10 | const AlertCard = ({ alert }: CardProps) => { 11 | const startTime = new Date(alert.startsAt).toLocaleString(); 12 | 13 | return ( 14 | 15 |
16 |
17 | {startTime} 18 | 19 | {(alert.labels["alertname"] && `alertname="${alert.labels["alertname"]}"' `) || ( 20 | No Alert Name 21 | )} 22 | 23 | 24 |
25 | 26 |
27 |
28 |
29 |
30 | ); 31 | }; 32 | 33 | export default AlertCard; 34 | -------------------------------------------------------------------------------- /mocks/mock_kioradb/utils.go: -------------------------------------------------------------------------------- 1 | package mock_kioradb 2 | 3 | import ( 4 | context "context" 5 | 6 | "github.com/golang/mock/gomock" 7 | "github.com/sinkingpoint/kiora/lib/kiora/kioradb/query" 8 | "github.com/sinkingpoint/kiora/lib/kiora/model" 9 | ) 10 | 11 | // MockDBWithAlerts returns a MockDB that expects a number of calls to QueryAlerts. The returned 12 | // values are the result of the given query applied to the given alerts. 13 | func MockDBWithAlerts(ctrl *gomock.Controller, alerts ...[]model.Alert) *MockDB { 14 | db := NewMockDB(ctrl) 15 | for _, alerts := range alerts { 16 | db.EXPECT().QueryAlerts(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, query query.AlertQuery) []model.Alert { 17 | ret := []model.Alert{} 18 | for _, alert := range alerts { 19 | if query.Filter.MatchesAlert(ctx, &alert) { 20 | ret = append(ret, alert) 21 | } 22 | } 23 | 24 | return ret 25 | }).MinTimes(1) 26 | } 27 | 28 | return db 29 | } 30 | -------------------------------------------------------------------------------- /internal/services/notify/notify_config/config.go: -------------------------------------------------------------------------------- 1 | package notify_config 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sinkingpoint/kiora/internal/clustering" 7 | "github.com/sinkingpoint/kiora/lib/kiora/config" 8 | "github.com/sinkingpoint/kiora/lib/kiora/model" 9 | ) 10 | 11 | // ClusterNotifier binds a notifier and a clusterer, returning no notifiers if this node isn't responsible for the alert. 12 | type ClusterNotifier struct { 13 | config.Config 14 | clusterer clustering.Clusterer 15 | } 16 | 17 | func NewClusterNotifier(clusterer clustering.Clusterer, config config.Config) *ClusterNotifier { 18 | return &ClusterNotifier{ 19 | Config: config, 20 | clusterer: clusterer, 21 | } 22 | } 23 | 24 | func (c *ClusterNotifier) GetNotifiersForAlert(ctx context.Context, alert *model.Alert) []config.NotifierSettings { 25 | if !c.clusterer.IsAuthoritativeFor(ctx, alert) { 26 | return nil 27 | } 28 | 29 | return c.Config.GetNotifiersForAlert(ctx, alert) 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/utils/date.tsx: -------------------------------------------------------------------------------- 1 | // formatDate formats the given date into a string consistent across the UI. 2 | export const formatDate = (d: Date): string => { 3 | return d.toLocaleString([], { 4 | day: "numeric", 5 | month: "short", 6 | year: "numeric", 7 | hour: "2-digit", 8 | minute: "2-digit", 9 | }); 10 | }; 11 | 12 | // formatDuration formats the given duration in seconds into a human readable string. 13 | export const formatDuration = (seconds: number): string => { 14 | if (seconds < 60) { 15 | return `${seconds}s`; 16 | } 17 | 18 | const minutes = Math.floor(seconds / 60); 19 | 20 | if (minutes < 60) { 21 | return `${minutes}m`; 22 | } 23 | 24 | const hours = Math.floor(minutes / 60); 25 | const remainingMinutes = minutes % 60; 26 | 27 | if (hours < 24) { 28 | return `${hours}h ${remainingMinutes}m`; 29 | } 30 | 31 | const days = Math.floor(hours / 24); 32 | const remainingHours = hours % 24; 33 | 34 | return `${days}d ${remainingHours}h ${remainingMinutes}m`; 35 | }; 36 | -------------------------------------------------------------------------------- /examples/configure_grouping.dot: -------------------------------------------------------------------------------- 1 | digraph config { 2 | // By default, all alerts are delayed by 10s in order to be group by their alertname. That is, 3 | // every 10s we fire off a batch of all the alerts that fired in the last 10s with the same alertname. 4 | // We can override this default using the group_wait node. 5 | 6 | // The group_wait node is a special node that delays the alerts by a given duration. If it's set to 0s, 7 | // then alerts are not grouped at all, and are sent as soon as they are triggered in batches of 1. This has the tendancy 8 | // to flood the backing service with alerts, so in high throughput environments it's recommended to set this to some small number (e.g. 100ms) instead. 9 | dont_group [type="group_wait" duration="0s"]; 10 | 11 | // Otherwise this is the same as before - send alerts through the dont_group node to set the group_wait, and then send alerts into the console. 12 | console [type="stdout"]; 13 | alerts -> dont_group -> console; 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/api/core/OpenAPI.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | import type { ApiRequestOptions } from './ApiRequestOptions'; 5 | 6 | type Resolver = (options: ApiRequestOptions) => Promise; 7 | type Headers = Record; 8 | 9 | export type OpenAPIConfig = { 10 | BASE: string; 11 | VERSION: string; 12 | WITH_CREDENTIALS: boolean; 13 | CREDENTIALS: 'include' | 'omit' | 'same-origin'; 14 | TOKEN?: string | Resolver; 15 | USERNAME?: string | Resolver; 16 | PASSWORD?: string | Resolver; 17 | HEADERS?: Headers | Resolver; 18 | ENCODE_PATH?: (path: string) => string; 19 | }; 20 | 21 | export const OpenAPI: OpenAPIConfig = { 22 | BASE: '', 23 | VERSION: '1.0.0', 24 | WITH_CREDENTIALS: false, 25 | CREDENTIALS: 'include', 26 | TOKEN: undefined, 27 | USERNAME: undefined, 28 | PASSWORD: undefined, 29 | HEADERS: undefined, 30 | ENCODE_PATH: undefined, 31 | }; 32 | -------------------------------------------------------------------------------- /integration/test_helpers.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/sinkingpoint/kiora/lib/kiora/model" 8 | ) 9 | 10 | // initT sets up a T with the basic settings for an integration test, 11 | // skipping if we're in a short test, and running in parallel. 12 | func initT(t *testing.T) { 13 | t.Helper() 14 | if testing.Short() { 15 | t.SkipNow() 16 | } 17 | 18 | t.Parallel() 19 | } 20 | 21 | // dummyAlert returns a basic alert to be used in tests. 22 | func dummyAlert() model.Alert { 23 | return model.Alert{ 24 | Labels: model.Labels{ 25 | "foo": "bar", 26 | }, 27 | Annotations: map[string]string{}, 28 | Status: model.AlertStatusFiring, 29 | StartTime: time.Now(), 30 | } 31 | } 32 | 33 | func dummySilence() model.Silence { 34 | return model.Silence{ 35 | StartTime: time.Now(), 36 | EndTime: time.Now().Add(time.Hour), 37 | Matchers: []model.Matcher{ 38 | { 39 | Label: "foo", 40 | Value: "bar", 41 | }, 42 | }, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/routes/new-silence/utils.tsx: -------------------------------------------------------------------------------- 1 | // getSilenceEnd takes a raw duration string and returns a Date object 2 | // representing the end of the silence, if the duration is valid. Otherwise, it returns null. 3 | export const getSilenceEnd = (rawDuration: string): Date => { 4 | const durationMatches = rawDuration.match(/^([0-9]+)([mhdw])$/); 5 | if (durationMatches === null) { 6 | return null; 7 | } 8 | 9 | const durationAmt = parseInt(durationMatches[1], 10); 10 | 11 | // durationUnit is the unit of the duration, e.g. m for minutes, h for hours, d for days. 12 | const durationUnit = durationMatches[2]; 13 | 14 | const now = new Date(); 15 | switch (durationUnit) { 16 | case "m": 17 | now.setMinutes(now.getMinutes() + durationAmt); 18 | break; 19 | case "h": 20 | now.setHours(now.getHours() + durationAmt); 21 | break; 22 | case "d": 23 | now.setDate(now.getDate() + durationAmt); 24 | break; 25 | case "w": 26 | now.setDate(now.getDate() + durationAmt * 7); 27 | break; 28 | default: 29 | return null; 30 | } 31 | 32 | return now; 33 | }; 34 | -------------------------------------------------------------------------------- /internal/server/frontend/frontend.go: -------------------------------------------------------------------------------- 1 | package frontend 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/gorilla/mux" 10 | ) 11 | 12 | //go:embed assets/* 13 | var content embed.FS 14 | 15 | // Registers a router that will serve the frontend assets created and embedded by the above go:generate directives. 16 | func Register(router *mux.Router) { 17 | sub, err := fs.Sub(content, "assets") 18 | if err != nil { 19 | panic("BUG: failed to embed frontend assets: " + err.Error()) 20 | } 21 | 22 | serveReactApp := func(w http.ResponseWriter, r *http.Request) { 23 | idx, err := fs.ReadFile(sub, "index.html") 24 | if err != nil { 25 | panic("BUG: failed to read embedded index.html: " + err.Error()) 26 | } 27 | 28 | w.Header().Set("Content-Type", "text/html") 29 | w.Write(idx) 30 | } 31 | 32 | router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 | if strings.Contains(r.URL.Path, ".") { 34 | http.FileServer(http.FS(sub)).ServeHTTP(w, r) 35 | } else { 36 | serveReactApp(w, r) 37 | } 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/components/spinner/styles.css: -------------------------------------------------------------------------------- 1 | .loader, 2 | .loader:before, 3 | .loader:after { 4 | background: var(--text); 5 | -webkit-animation: load1 1s infinite ease-in-out; 6 | animation: load1 1s infinite ease-in-out; 7 | width: 1em; 8 | height: 4em; 9 | } 10 | .loader { 11 | color: var(--text); 12 | text-indent: -9999em; 13 | margin: 88px auto; 14 | position: relative; 15 | font-size: 11px; 16 | -webkit-animation-delay: -0.16s; 17 | animation-delay: -0.16s; 18 | } 19 | .loader:before, 20 | .loader:after { 21 | position: absolute; 22 | top: 0; 23 | content: ""; 24 | } 25 | .loader:before { 26 | left: -1.5em; 27 | -webkit-animation-delay: -0.32s; 28 | animation-delay: -0.32s; 29 | } 30 | .loader:after { 31 | left: 1.5em; 32 | } 33 | @-webkit-keyframes load1 { 34 | 0%, 35 | 80%, 36 | 100% { 37 | box-shadow: 0 0; 38 | height: 4em; 39 | } 40 | 40% { 41 | box-shadow: 0 -2em; 42 | height: 5em; 43 | } 44 | } 45 | @keyframes load1 { 46 | 0%, 47 | 80%, 48 | 100% { 49 | box-shadow: 0 0; 50 | height: 4em; 51 | } 52 | 40% { 53 | box-shadow: 0 -2em; 54 | height: 5em; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/kiora/config/notifiers/filenotifier/notifier_node_test.go: -------------------------------------------------------------------------------- 1 | package filenotifier_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/sinkingpoint/kiora/lib/kiora/config" 9 | "github.com/sinkingpoint/kiora/lib/kiora/config/notifiers/filenotifier" 10 | "github.com/sinkingpoint/kiora/lib/kiora/model" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestFileNotifierNode(t *testing.T) { 15 | file, err := os.CreateTemp("", "") 16 | require.NoError(t, err) 17 | defer file.Close() 18 | 19 | node, err := filenotifier.New("", nil, 20 | map[string]string{ 21 | "type": "file", 22 | "path": file.Name(), 23 | }) 24 | 25 | require.NoError(t, err) 26 | 27 | processor := node.(config.Notifier) 28 | 29 | require.Nil(t, processor.Notify(context.Background(), model.Alert{ 30 | Labels: model.Labels{ 31 | "alertname": "foo", 32 | }, 33 | })) 34 | 35 | fileContents, err := os.ReadFile(file.Name()) 36 | require.NoError(t, err) 37 | 38 | require.Contains(t, string(fileContents), "alertname") 39 | require.Contains(t, string(fileContents), "foo") 40 | } 41 | -------------------------------------------------------------------------------- /cmd/tuku/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/alecthomas/kong" 5 | "github.com/sinkingpoint/kiora/cmd/tuku/commands" 6 | "github.com/sinkingpoint/kiora/cmd/tuku/commands/alerts" 7 | "github.com/sinkingpoint/kiora/cmd/tuku/commands/silences" 8 | "github.com/sinkingpoint/kiora/cmd/tuku/kiora" 9 | "github.com/sinkingpoint/kiora/internal/encoding" 10 | ) 11 | 12 | var CLI struct { 13 | Formatter string `help:"the format to output the data in" name:"fmt" default:"json"` 14 | KioraURL string `help:"the URL of the Kiora instance to connect to" default:"http://localhost:4278"` 15 | Alerts alerts.AlertsCmd `cmd:"" help:"Manage alerts."` 16 | Silences silences.SilencesCmd `cmd:"" help:"Manage silences."` 17 | } 18 | 19 | func main() { 20 | ctx := kong.Parse(&CLI, kong.Name("tuku"), kong.Description("A CLI for interacting with Kiora"), kong.UsageOnError(), kong.ConfigureHelp(kong.HelpOptions{ 21 | Compact: true, 22 | })) 23 | 24 | runContext := &commands.Context{ 25 | Formatter: encoding.LookupEncoding(CLI.Formatter), 26 | Kiora: kiora.NewKioraInstance(CLI.KioraURL, "v1"), 27 | } 28 | 29 | if err := ctx.Run(runContext); err != nil { 30 | ctx.FatalIfErrorf(err) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/kiora/config/filters.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Filter defines something that can filter models. 8 | type Filter interface { 9 | // Type returns a string representation of the type of the filter. 10 | Type() string 11 | 12 | // Filter returns an error if the given model should be filtered. 13 | Filter(ctx context.Context, f Fielder) error 14 | } 15 | 16 | // Fielder is a thing that has fields that can be filtered. 17 | type Fielder interface { 18 | // Field returns the value of a field. 19 | Field(name string) (any, error) 20 | 21 | // Fields returns a map of all the fields that can be filtered. 22 | Fields() map[string]any 23 | } 24 | 25 | // FilterConstructor is a function that can construct a filter from a set of attributes. 26 | type FilterConstructor = func(globals *Globals, attrs map[string]string) (Filter, error) 27 | 28 | var filterRegistry = map[string]FilterConstructor{} 29 | 30 | // LookupFilter looks up a filter constructor by name. 31 | func LookupFilter(name string) (FilterConstructor, bool) { 32 | cons, ok := filterRegistry[name] 33 | return cons, ok 34 | } 35 | 36 | // RegisterFilter registers a filter constructor by name. 37 | func RegisterFilter(name string, cons FilterConstructor) { 38 | filterRegistry[name] = cons 39 | } 40 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "kiora", 4 | "version": "0.0.0", 5 | "license": "Apache-2.0", 6 | "scripts": { 7 | "build": "NODE_OPTIONS=--openssl-legacy-provider preact build", 8 | "serve": "sirv build --cors --single", 9 | "dev": "PREACT_APP_API_HOST=http://localhost:4278 NODE_OPTIONS=--openssl-legacy-provider preact watch", 10 | "lint": "eslint src", 11 | "prettier": "prettier ./src --write" 12 | }, 13 | "eslintConfig": { 14 | "parser": "@typescript-eslint/parser", 15 | "extends": [ 16 | "preact", 17 | "plugin:@typescript-eslint/recommended" 18 | ], 19 | "ignorePatterns": [ 20 | "build/" 21 | ] 22 | }, 23 | "dependencies": { 24 | "preact": "^10.10.0", 25 | "preact-render-to-string": "^5.2.1", 26 | "preact-router": "^3.2.1" 27 | }, 28 | "devDependencies": { 29 | "@types/enzyme": "^3.10.12", 30 | "@typescript-eslint/eslint-plugin": "^5.30.6", 31 | "@typescript-eslint/parser": "^5.30.6", 32 | "enzyme": "^3.11.0", 33 | "enzyme-adapter-preact-pure": "^4.0.1", 34 | "eslint": "^8.20.0", 35 | "eslint-config-preact": "^1.3.0", 36 | "jest": "^27.5.1", 37 | "openapi-typescript-codegen": "^0.23.0", 38 | "preact-cli": "^3.4.0", 39 | "prettier": "2.8.6", 40 | "sirv-cli": "^2.0.2", 41 | "typescript": "^4.5.2" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /internal/pipeline/buffer_db_test.go: -------------------------------------------------------------------------------- 1 | package pipeline_test 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "testing" 7 | "time" 8 | 9 | "github.com/sinkingpoint/kiora/internal/pipeline" 10 | "github.com/sinkingpoint/kiora/internal/testutils" 11 | "github.com/sinkingpoint/kiora/lib/kiora/kioradb" 12 | "github.com/sinkingpoint/kiora/lib/kiora/kioradb/query" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestBufferDBAlerts(t *testing.T) { 17 | ctx, cancel := context.WithCancel(context.Background()) 18 | 19 | // Generate 1000 alerts, with 100 possible alert names, 10 labels per alert, and 100 possible values per label. 20 | alerts := testutils.GenerateDummyAlerts(1000, 100, 10, 100) 21 | db := kioradb.NewInMemoryDB() 22 | 23 | wg := sync.WaitGroup{} 24 | buffer := pipeline.NewBufferDB(db, 1000, 1000, 10000, 1*time.Millisecond) 25 | wg.Add(1) 26 | go func() { 27 | require.NoError(t, buffer.Run(ctx)) 28 | wg.Done() 29 | }() 30 | 31 | require.NoError(t, buffer.StoreAlerts(ctx, alerts...)) 32 | 33 | cancel() 34 | wg.Wait() 35 | 36 | storedAlerts := db.QueryAlerts(context.TODO(), query.NewAlertQuery(query.MatchAll())) 37 | require.Len(t, storedAlerts, len(alerts), "stored alerts should match the number of alerts we generated (%d != %d)", len(storedAlerts), len(alerts)) 38 | } 39 | -------------------------------------------------------------------------------- /internal/clustering/ring_clusterer_test.go: -------------------------------------------------------------------------------- 1 | package clustering_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/sinkingpoint/kiora/internal/clustering" 8 | "github.com/sinkingpoint/kiora/lib/kiora/model" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestRingClustererSharding(t *testing.T) { 13 | clusterer := clustering.NewRingClusterer("a", "a") 14 | // Add a bunch of nodes to decrease the likelihood that we get proper sharding just by chance. 15 | for i := 'b'; i < 'z'; i++ { 16 | clusterer.AddNode(string(i), string(i)) 17 | } 18 | 19 | for i := 'A'; i < 'Z'; i++ { 20 | clusterer.AddNode(string(i), string(i)) 21 | } 22 | 23 | clusterer.SetShardLabels([]string{"foo"}) 24 | authA := clusterer.GetAuthoritativeNode(context.TODO(), &model.Alert{ 25 | Labels: model.Labels{ 26 | "foo": "bar", 27 | "bar": "baz", 28 | }, 29 | }) 30 | 31 | authB := clusterer.GetAuthoritativeNode(context.TODO(), &model.Alert{ 32 | Labels: model.Labels{ 33 | "foo": "bar", 34 | "bar": "foo", 35 | }, 36 | }) 37 | 38 | authC := clusterer.GetAuthoritativeNode(context.TODO(), &model.Alert{ 39 | Labels: model.Labels{ 40 | "foo": "baz", 41 | "bar": "baz", 42 | }, 43 | }) 44 | 45 | require.Equal(t, authA, authB) 46 | require.NotEqual(t, authA, authC) 47 | } 48 | -------------------------------------------------------------------------------- /internal/services/timeout/service.go: -------------------------------------------------------------------------------- 1 | package timeout 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/sinkingpoint/kiora/internal/services" 8 | "github.com/sinkingpoint/kiora/lib/kiora/kioradb/query" 9 | "github.com/sinkingpoint/kiora/lib/kiora/model" 10 | ) 11 | 12 | type TimeoutService struct { 13 | bus services.Bus 14 | } 15 | 16 | func NewTimeoutService(bus services.Bus) *TimeoutService { 17 | return &TimeoutService{ 18 | bus: bus, 19 | } 20 | } 21 | 22 | func (t *TimeoutService) Name() string { 23 | return "timeout" 24 | } 25 | 26 | func (t *TimeoutService) Run(ctx context.Context) error { 27 | ticker := time.NewTicker(1 * time.Second) 28 | 29 | for { 30 | select { 31 | case <-ticker.C: 32 | t.timeoutAlerts(ctx) 33 | case <-ctx.Done(): 34 | return nil 35 | } 36 | } 37 | } 38 | 39 | func (t *TimeoutService) timeoutAlerts(ctx context.Context) { 40 | query := query.NewAlertQuery(query.Status(model.AlertStatusFiring)) 41 | changed := []model.Alert{} 42 | for _, a := range t.bus.DB().QueryAlerts(ctx, query) { 43 | if a.TimeOutDeadline.Before(time.Now()) { 44 | a.Status = model.AlertStatusTimedOut 45 | changed = append(changed, a) 46 | } 47 | } 48 | 49 | if err := t.bus.Broadcaster().BroadcastAlerts(ctx, changed...); err != nil { 50 | t.bus.Logger("timeout").Warn().Err(err).Msg("failed to broadcast alerts") 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /lib/kiora/model/labels.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "bytes" 5 | "sort" 6 | 7 | "github.com/cespare/xxhash" 8 | ) 9 | 10 | var hashSep = []byte{'\xff'} 11 | 12 | type LabelsHash = uint64 13 | 14 | // Labels is a utility type encapsulating a map[string]string that can be hashed. 15 | type Labels map[string]string 16 | 17 | // Hash takes an xxhash64 across all the labels in the map. 18 | func (s Labels) Hash() LabelsHash { 19 | hash := xxhash.New() 20 | hash.Write(s.Bytes()) 21 | return hash.Sum64() 22 | } 23 | 24 | // Subset returns a new Labels object with only the keys specified in labelNames. 25 | func (s Labels) Subset(labelNames ...string) Labels { 26 | labels := Labels{} 27 | for _, key := range labelNames { 28 | labels[key] = s[key] 29 | } 30 | 31 | return labels 32 | } 33 | 34 | func (s Labels) Bytes() []byte { 35 | keys := []string{} 36 | for k := range s { 37 | keys = append(keys, k) 38 | } 39 | 40 | sort.Strings(keys) 41 | 42 | buf := bytes.Buffer{} 43 | for _, k := range keys { 44 | buf.WriteString(k) 45 | buf.Write(hashSep) 46 | buf.WriteString(s[k]) 47 | } 48 | 49 | return buf.Bytes() 50 | } 51 | 52 | func (s Labels) Equal(other Labels) bool { 53 | if len(s) != len(other) { 54 | return false 55 | } 56 | 57 | for k, v := range s { 58 | if other[k] != v { 59 | return false 60 | } 61 | } 62 | 63 | return true 64 | } 65 | -------------------------------------------------------------------------------- /internal/services/bus.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "github.com/rs/zerolog" 5 | "github.com/sinkingpoint/kiora/internal/clustering" 6 | "github.com/sinkingpoint/kiora/lib/kiora/config" 7 | "github.com/sinkingpoint/kiora/lib/kiora/kioradb" 8 | ) 9 | 10 | // A bus wraps up all the things that a service might need to function. 11 | type Bus interface { 12 | DB() kioradb.DB 13 | Broadcaster() clustering.Broadcaster 14 | Logger(serviceName string) *zerolog.Logger 15 | Config() config.Config 16 | } 17 | 18 | type KioraBus struct { 19 | db kioradb.DB 20 | broadcaster clustering.Broadcaster 21 | logger zerolog.Logger 22 | config config.Config 23 | } 24 | 25 | func NewKioraBus(db kioradb.DB, broadcaster clustering.Broadcaster, logger zerolog.Logger, config config.Config) *KioraBus { 26 | return &KioraBus{ 27 | db: db, 28 | broadcaster: broadcaster, 29 | logger: logger, 30 | config: config, 31 | } 32 | } 33 | 34 | func (k *KioraBus) DB() kioradb.DB { 35 | return k.db 36 | } 37 | 38 | func (k *KioraBus) Broadcaster() clustering.Broadcaster { 39 | return k.broadcaster 40 | } 41 | 42 | func (k *KioraBus) Logger(serviceName string) *zerolog.Logger { 43 | logger := k.logger.With().Str("service_name", serviceName).Logger() 44 | return &logger 45 | } 46 | 47 | func (k *KioraBus) Config() config.Config { 48 | return k.config 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/components/silencecard/index.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { Silence } from "../../api"; 3 | import style from "./styles.css"; 4 | import Labels from "./labels"; 5 | import { formatDate, formatDuration } from "../../utils/date"; 6 | 7 | interface SilenceCardProps { 8 | silence: Silence; 9 | } 10 | 11 | const SilenceCard = ({silence}: SilenceCardProps) => { 12 | const startDate = new Date(silence.startsAt); 13 | const endDate = new Date(silence.endsAt); 14 | 15 | return 16 |
17 |
18 |
19 | {silence.id} created by {silence.creator} 20 |
21 | 22 |
23 | {startDate > new Date() ? "Starts" : "Started"} at {formatDate(startDate)} 24 |
25 | 26 |
27 | {endDate > new Date() ? "Ends" : "Ended"} at {formatDate(endDate)} 28 |
29 | 30 |
31 | 32 |
33 |
34 |
35 |
; 36 | }; 37 | 38 | export default SilenceCard; 39 | -------------------------------------------------------------------------------- /lib/kiora/kioradb/db.go: -------------------------------------------------------------------------------- 1 | package kioradb 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sinkingpoint/kiora/lib/kiora/kioradb/query" 7 | "github.com/sinkingpoint/kiora/lib/kiora/model" 8 | ) 9 | 10 | // DB defines an interface that is able to process alerts, silences etc and store them (for some definition of store). 11 | type DB interface { 12 | // StoreAlerts stores the given alerts in the database, updating any existing alerts with the same labels. 13 | StoreAlerts(ctx context.Context, alerts ...model.Alert) error 14 | 15 | // QueryAlerts queries the database for alerts matching the given query. 16 | QueryAlerts(ctx context.Context, query query.AlertQuery) []model.Alert 17 | 18 | // StoreSilences stores the given silences in the database, updating any existing silences with the same ID. 19 | StoreSilences(ctx context.Context, silences ...model.Silence) error 20 | 21 | // QuerySilences queries the database for silences matching the given query. 22 | QuerySilences(ctx context.Context, query query.SilenceQuery) []model.Silence 23 | 24 | Close() error 25 | } 26 | 27 | func QueryAlertStats(ctx context.Context, db DB, q query.AlertStatsQuery) ([]query.StatsResult, error) { 28 | alerts := db.QueryAlerts(ctx, query.NewAlertQuery(q.Filter())) 29 | for i := range alerts { 30 | if err := q.Process(ctx, &alerts[i]); err != nil { 31 | return nil, err 32 | } 33 | } 34 | 35 | return q.Gather(ctx), nil 36 | } 37 | -------------------------------------------------------------------------------- /cmd/tuku/commands/alerts/tests.go: -------------------------------------------------------------------------------- 1 | package alerts 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/sinkingpoint/kiora/cmd/tuku/commands" 8 | "github.com/sinkingpoint/kiora/internal/testutils" 9 | ) 10 | 11 | type AlertsTestCmd struct { 12 | NumPossibleAlerts int `help:"Number of possible alerts to generate." default:"100"` 13 | NumAlerts int `help:"Number of alerts to generate." default:"1000"` 14 | MaximumLabels int `help:"Maximum number of labels per alert." default:"10"` 15 | MaximumCardinality int `help:"Maximum cardinality of each label." default:"100"` 16 | BatchSize int `help:"Number of alerts to send in each batch." default:"100"` 17 | } 18 | 19 | func (a *AlertsTestCmd) Run(ctx *commands.Context) error { 20 | alerts := testutils.GenerateDummyAlerts(a.NumAlerts, a.NumPossibleAlerts, a.MaximumLabels, a.MaximumCardinality) 21 | 22 | startTime := time.Now() 23 | 24 | numBatches := len(alerts) / a.BatchSize 25 | 26 | for i := 0; i < len(alerts); i += a.BatchSize { 27 | end := i + a.BatchSize 28 | if end > len(alerts) { 29 | end = len(alerts) 30 | } 31 | 32 | for j := i; j < end; j++ { 33 | alerts[j].StartTime = startTime.Add(time.Duration(i+j) * time.Second) 34 | } 35 | 36 | fmt.Printf("Sending batch %d/%d\n", i/a.BatchSize+1, numBatches) 37 | if err := ctx.Kiora.PostAlerts(alerts[i:end]); err != nil { 38 | return err 39 | } 40 | } 41 | 42 | return ctx.Kiora.PostAlerts(alerts) 43 | } 44 | -------------------------------------------------------------------------------- /lib/kiora/config/filters/regex/filter.go: -------------------------------------------------------------------------------- 1 | package regex 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/grafana/regexp" 8 | 9 | "github.com/pkg/errors" 10 | "github.com/sinkingpoint/kiora/lib/kiora/config" 11 | "github.com/sinkingpoint/kiora/lib/kiora/config/unmarshal" 12 | ) 13 | 14 | // RegexFilter is a filter that matches if the given alert a) has the given label and b) that label matches a regex. 15 | type RegexFilter struct { 16 | Label string `config:"field" required:"true"` 17 | Regex *regexp.Regexp `config:"regex" required:"true"` 18 | } 19 | 20 | func NewFilter(globals *config.Globals, attrs map[string]string) (config.Filter, error) { 21 | delete(attrs, "type") 22 | var regexFilter RegexFilter 23 | if err := unmarshal.UnmarshalConfig(attrs, ®exFilter, unmarshal.UnmarshalOpts{DisallowUnknownFields: true}); err != nil { 24 | return nil, errors.Wrap(err, "failed to unmarshal regex filter") 25 | } 26 | 27 | return ®exFilter, nil 28 | } 29 | 30 | func (r *RegexFilter) Type() string { 31 | return "regex" 32 | } 33 | 34 | func (r *RegexFilter) Filter(ctx context.Context, f config.Fielder) error { 35 | value, err := f.Field(r.Label) 36 | if err != nil { 37 | return fmt.Errorf("failed to get field %q: %w", r.Label, err) 38 | } 39 | 40 | if label, ok := value.(string); ok { 41 | if r.Regex.MatchString(label) { 42 | return nil 43 | } 44 | 45 | return fmt.Errorf("label %q does not match regex %q", label, r.Regex.String()) 46 | } 47 | 48 | return fmt.Errorf("label %q is not a string", r.Label) 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/routes/silences/index.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import Loader from "../../components/loader"; 3 | import { useState } from "preact/hooks"; 4 | import { DefaultService, Silence } from "../../api"; 5 | import SilenceCard from "../../components/silencecard"; 6 | 7 | const silencesView = (silences: Silence[]) => { 8 | if (silences.length === 0) { 9 | return ( 10 |
11 |

No silences found

12 |
13 | ); 14 | } 15 | 16 | return ( 17 |
18 | {silences.map((silence) => {return })} 19 |
20 | ); 21 | }; 22 | 23 | const errorView = (error: string) => { 24 | return ( 25 |
26 |

{error}

27 |
28 | ); 29 | }; 30 | 31 | interface AllSilencesViewState { 32 | silences?: Silence[]; 33 | error?: string; 34 | } 35 | 36 | const AllSilencesView = () => { 37 | const [silences, setSilences] = useState({}); 38 | 39 | const fetchSilences = () => { 40 | DefaultService.getSilences({ limit: 10, sort: ["__starts_at__"], order: "DESC" }) 41 | .then((response) => { 42 | setSilences({ 43 | silences: response, 44 | error: "", 45 | }); 46 | }) 47 | .catch((error) => { 48 | setSilences({ 49 | silences: [], 50 | error: error, 51 | }); 52 | }); 53 | }; 54 | 55 | return ( 56 |
57 | 58 |
{silences.silences ? silencesView(silences.silences) : errorView(silences.error)}
59 |
60 |
61 | ); 62 | }; 63 | 64 | export default AllSilencesView; 65 | -------------------------------------------------------------------------------- /lib/kiora/config/filters/regex/filter_test.go: -------------------------------------------------------------------------------- 1 | package regex_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/sinkingpoint/kiora/lib/kiora/config/filters/regex" 8 | "github.com/sinkingpoint/kiora/lib/kiora/model" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestRegexFilter(t *testing.T) { 13 | tests := []struct { 14 | Name string 15 | Label string 16 | Regex string 17 | Alert model.Alert 18 | ShouldMatch bool 19 | }{ 20 | { 21 | Name: "standard match", 22 | Label: "test", 23 | Regex: "test", 24 | Alert: model.Alert{ 25 | Labels: model.Labels{ 26 | "test": "test", 27 | }, 28 | }, 29 | ShouldMatch: true, 30 | }, 31 | { 32 | Name: "non existent label", 33 | Label: "some_weird_non_existent_label", 34 | Regex: "test", 35 | Alert: model.Alert{ 36 | Labels: model.Labels{ 37 | "test": "test", 38 | }, 39 | }, 40 | ShouldMatch: false, 41 | }, 42 | { 43 | Name: "non regex match", 44 | Label: "test", 45 | Regex: "^test$", 46 | Alert: model.Alert{ 47 | Labels: model.Labels{ 48 | "test": "not test", 49 | }, 50 | }, 51 | ShouldMatch: false, 52 | }, 53 | } 54 | 55 | for _, tt := range tests { 56 | t.Run(tt.Name, func(t *testing.T) { 57 | filter, err := regex.NewFilter(nil, map[string]string{ 58 | "field": tt.Label, 59 | "regex": tt.Regex, 60 | }) 61 | 62 | require.NoError(t, err) 63 | 64 | err = filter.Filter(context.TODO(), &tt.Alert) 65 | if tt.ShouldMatch { 66 | require.NoError(t, err) 67 | } else { 68 | require.Error(t, err) 69 | } 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test-backend lint-frontend lint-backend fmt-backend fmt-frontend lint fmt integration ci build build-unchecked run run-cluster generate clean 2 | test: 3 | mkdir -p artifacts/ 4 | go test -short -race -cover -coverprofile=artifacts/cover.out ./... 5 | 6 | lint-backend: 7 | golangci-lint run ./... 8 | 9 | lint-frontend: 10 | cd frontend && npm run lint 11 | 12 | lint: lint-backend lint-frontend 13 | 14 | fmt-backend: 15 | go fmt ./... 16 | 17 | fmt-frontend: 18 | cd frontend && npm run prettier --write ./src 19 | 20 | fmt: fmt-backend fmt-frontend 21 | 22 | integration: 23 | go test -count=1 ./integration 24 | 25 | ci: fmt lint test 26 | 27 | build-backend: 28 | go build -o ./artifacts/tuku ./cmd/tuku 29 | go build -o ./artifacts/kiora ./cmd/kiora 30 | 31 | build-frontend: 32 | cd frontend && npm run build 33 | rm -r ./internal/server/frontend/assets 34 | cp -r ./frontend/build ./internal/server/frontend/assets 35 | 36 | build: build-frontend build-backend 37 | 38 | generate: 39 | mockgen -source ./lib/kiora/kioradb/db.go > mocks/mock_kioradb/db.go 40 | mockgen -source ./lib/kiora/config/provider.go > mocks/mock_config/provider.go 41 | mockgen -source ./internal/clustering/broadcaster.go > mocks/mock_clustering/broadcaster.go 42 | mockgen -source ./internal/services/bus.go > mocks/mock_services/bus.go 43 | oapi-codegen -generate gorilla,spec,types -package apiv1 ./internal/server/api/apiv1/api.yaml > ./internal/server/api/apiv1/apiv1.gen.go 44 | cd frontend && npm exec openapi -- --useOptions -i ../internal/server/api/apiv1/api.yaml -o src/api 45 | 46 | clean: 47 | rm -rf ./artifacts 48 | rm -rf ./frontend/build 49 | rm -rf ./internal/server/frontend/assets 50 | -------------------------------------------------------------------------------- /integration/ha_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/sinkingpoint/kiora/lib/kiora/model" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestFailover(t *testing.T) { 14 | initT(t) 15 | 16 | // The general flow of this test is: 17 | // - Spin up a cluster, with 3 nodes 18 | // While we still have nodes in the cluster: 19 | // - Send an alert into the cluster 20 | // - Observe that the alert gets sent 21 | // - Shutdown the node that sends the alert 22 | 23 | alert := dummyAlert() 24 | resolvedAlert := dummyAlert() 25 | resolvedAlert.Status = model.AlertStatusResolved 26 | 27 | nodes := StartKioraCluster(t, 3) 28 | 29 | for len(nodes) > 0 { 30 | for i := range nodes { 31 | nodes[i].SendAlert(context.TODO(), alert) 32 | } 33 | 34 | // wait a bit for the gossip to settle. 35 | time.Sleep(10 * time.Second) 36 | 37 | foundNodeIndex := -1 38 | for i, n := range nodes { 39 | if strings.Contains(n.stdout.String(), "foo") { 40 | require.NoError(t, n.Stop()) 41 | foundNodeIndex = i 42 | break 43 | } 44 | } 45 | 46 | found := foundNodeIndex >= 0 47 | nodeNames := []string{} 48 | for _, node := range nodes { 49 | nodeNames = append(nodeNames, node.name) 50 | } 51 | 52 | require.True(t, found, "failed to find the firing node (still have nodes: %+v)", nodeNames) 53 | require.NoError(t, nodes[foundNodeIndex].Stop()) 54 | 55 | nodes = append(nodes[:foundNodeIndex], nodes[foundNodeIndex+1:]...) 56 | 57 | // resolve the alert so it can fire again. 58 | for i := range nodes { 59 | nodes[i].SendAlert(context.TODO(), resolvedAlert) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /internal/clustering/clusterer.go: -------------------------------------------------------------------------------- 1 | package clustering 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sinkingpoint/kiora/lib/kiora/model" 7 | ) 8 | 9 | // Clusterer is used to determine if this node is authoritative (and thus should send a notification for) a given alert. 10 | type Clusterer interface { 11 | // IsAuthoritativeFor returns true if this is the node that should send notifications for the given alert. 12 | IsAuthoritativeFor(ctx context.Context, a *model.Alert) bool 13 | 14 | // Nodes returns a list of the nodes in the cluster. 15 | Nodes() []any 16 | } 17 | 18 | // ClustererDelegates receive cluster updates (node additions and removals). 19 | type ClustererDelegate interface { 20 | // AddNode is called when a node is added to the cluster. 21 | AddNode(name, address string) 22 | 23 | // RemoveNode is called when a node is removed, or fails. 24 | RemoveNode(name string) 25 | } 26 | 27 | // EventDelegate provides a delegate that can handle events as they come in from a cluster channel. 28 | type EventDelegate interface { 29 | // ProcessAlert is called when a new alert comes in. There are no guarantees that this alert isn't one 30 | // we haven't seen before - it might be an update on status etc. 31 | ProcessAlert(ctx context.Context, alert model.Alert) 32 | 33 | // ProcessAlertAcknowledgement is called when a new alert acknowledgement comes in. 34 | ProcessAlertAcknowledgement(ctx context.Context, alertID string, ack model.AlertAcknowledgement) 35 | 36 | // ProcessSilence is called when a new silence comes in. There are no guarantees that this silence isn't one 37 | // we haven't seen before - it might be an update on status etc. 38 | ProcessSilence(ctx context.Context, silence model.Silence) 39 | } 40 | -------------------------------------------------------------------------------- /mocks/mock_clustering/clusterer.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: ./internal/clustering/clusterer.go 3 | 4 | // Package mock_clustering is a generated GoMock package. 5 | package mock_clustering 6 | 7 | import ( 8 | reflect "reflect" 9 | 10 | gomock "github.com/golang/mock/gomock" 11 | model "github.com/sinkingpoint/kiora/lib/kiora/model" 12 | ) 13 | 14 | // MockClusterer is a mock of Clusterer interface. 15 | type MockClusterer struct { 16 | ctrl *gomock.Controller 17 | recorder *MockClustererMockRecorder 18 | } 19 | 20 | // MockClustererMockRecorder is the mock recorder for MockClusterer. 21 | type MockClustererMockRecorder struct { 22 | mock *MockClusterer 23 | } 24 | 25 | // NewMockClusterer creates a new mock instance. 26 | func NewMockClusterer(ctrl *gomock.Controller) *MockClusterer { 27 | mock := &MockClusterer{ctrl: ctrl} 28 | mock.recorder = &MockClustererMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use. 33 | func (m *MockClusterer) EXPECT() *MockClustererMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // AmIAuthoritativeFor mocks base method. 38 | func (m *MockClusterer) AmIAuthoritativeFor(a *model.Alert) bool { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "AmIAuthoritativeFor", a) 41 | ret0, _ := ret[0].(bool) 42 | return ret0 43 | } 44 | 45 | // AmIAuthoritativeFor indicates an expected call of AmIAuthoritativeFor. 46 | func (mr *MockClustererMockRecorder) AmIAuthoritativeFor(a interface{}) *gomock.Call { 47 | mr.mock.ctrl.T.Helper() 48 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AmIAuthoritativeFor", reflect.TypeOf((*MockClusterer)(nil).AmIAuthoritativeFor), a) 49 | } 50 | -------------------------------------------------------------------------------- /lib/kiora/model/matcher_test.go: -------------------------------------------------------------------------------- 1 | package model_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sinkingpoint/kiora/lib/kiora/model" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func MustRegexMatcher(t *testing.T, label, regex string) *model.Matcher { 11 | t.Helper() 12 | m, err := model.LabelValueRegexMatcher(label, regex) 13 | require.NoError(t, err) 14 | return &m 15 | } 16 | 17 | func TestMatchers(t *testing.T) { 18 | testCases := []struct { 19 | name string 20 | matcher model.Matcher 21 | labels model.Labels 22 | expectedMatch bool 23 | }{ 24 | { 25 | name: "exact match", 26 | matcher: model.LabelValueEqualMatcher("foo", "bar"), 27 | labels: model.Labels{ 28 | "foo": "bar", 29 | }, 30 | expectedMatch: true, 31 | }, 32 | { 33 | name: "exact match without label", 34 | matcher: model.LabelValueEqualMatcher("foo", ""), 35 | labels: model.Labels{}, 36 | expectedMatch: false, 37 | }, 38 | { 39 | name: "regex match", 40 | matcher: *MustRegexMatcher(t, "foo", "bar"), 41 | labels: model.Labels{ 42 | "foo": "barrington", 43 | }, 44 | expectedMatch: true, 45 | }, 46 | { 47 | name: "negative match", 48 | matcher: *MustRegexMatcher(t, "foo", "bar").Negate(), 49 | labels: model.Labels{ 50 | "foo": "barrington", 51 | }, 52 | expectedMatch: false, 53 | }, 54 | } 55 | 56 | for _, tt := range testCases { 57 | t.Run(tt.name, func(t *testing.T) { 58 | match := tt.matcher.Matches(tt.labels) 59 | if tt.expectedMatch && !match { 60 | t.Errorf("expected match, but it didn't") 61 | } else if !tt.expectedMatch && match { 62 | t.Errorf("expected a non match, but it did") 63 | } 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lib/kiora/config/filters/duration/filter.go: -------------------------------------------------------------------------------- 1 | package duration 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/sinkingpoint/kiora/lib/kiora/config" 9 | "github.com/sinkingpoint/kiora/lib/kiora/config/unmarshal" 10 | ) 11 | 12 | type DurationFilter struct { 13 | Field string `config:"field" required:"true"` 14 | Max *time.Duration `config:"max"` 15 | Min *time.Duration `config:"min"` 16 | } 17 | 18 | func NewFilter(globals *config.Globals, attrs map[string]string) (config.Filter, error) { 19 | delete(attrs, "type") 20 | var durationFilter DurationFilter 21 | 22 | if err := unmarshal.UnmarshalConfig(attrs, &durationFilter, unmarshal.UnmarshalOpts{DisallowUnknownFields: true}); err != nil { 23 | return nil, fmt.Errorf("failed to unmarshal duration filter: %w", err) 24 | } 25 | 26 | if durationFilter.Max == nil && durationFilter.Min == nil { 27 | return nil, fmt.Errorf("duration filter must have at least one of max or min") 28 | } 29 | 30 | return &durationFilter, nil 31 | } 32 | 33 | func (d *DurationFilter) Type() string { 34 | return "duration" 35 | } 36 | 37 | func (d *DurationFilter) Filter(ctx context.Context, fielder config.Fielder) error { 38 | field, err := fielder.Field(d.Field) 39 | if err != nil { 40 | return fmt.Errorf("failed to get field %q: %w", d.Field, err) 41 | } 42 | 43 | duration, ok := field.(time.Duration) 44 | if !ok { 45 | return fmt.Errorf("field %q is not a duration", d.Field) 46 | } 47 | 48 | if d.Max != nil && duration > *d.Max { 49 | return fmt.Errorf("field %q is greater than %s", d.Field, d.Max.String()) 50 | } 51 | 52 | if d.Min != nil && duration < *d.Min { 53 | return fmt.Errorf("field %q is less than %s", d.Field, d.Min.String()) 54 | } 55 | 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /internal/services/services.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | 8 | "github.com/pkg/errors" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | // Service is a service that runs in the background of Kiora, performing some task. 13 | type Service interface { 14 | // Name returns the human readable name of the service. 15 | Name() string 16 | 17 | // Run runs the service until the given context is done. 18 | Run(ctx context.Context) error 19 | } 20 | 21 | // BackgroundServices wraps a number of services and provides a way to cancel all of them. 22 | type BackgroundServices struct { 23 | services []Service 24 | 25 | ctx context.Context 26 | cancel context.CancelFunc 27 | } 28 | 29 | func NewBackgroundServices() *BackgroundServices { 30 | return &BackgroundServices{} 31 | } 32 | 33 | func (b *BackgroundServices) RegisterService(s Service) { 34 | b.services = append(b.services, s) 35 | } 36 | 37 | func (b *BackgroundServices) Run(ctx context.Context) error { 38 | b.ctx, b.cancel = context.WithCancel(ctx) 39 | wg := sync.WaitGroup{} 40 | var err error 41 | for _, s := range b.services { 42 | wg.Add(1) 43 | go func(s Service) { 44 | if err = s.Run(b.ctx); err != nil { 45 | err = errors.Wrapf(err, "service %q failed", s.Name()) 46 | } 47 | 48 | // The underlying context is still open, but the service has exitted. Stop the world. 49 | if b.ctx.Err() == nil { 50 | b.Shutdown(b.ctx) 51 | err = fmt.Errorf("service %q failed without an error", s.Name()) 52 | } 53 | 54 | wg.Done() 55 | }(s) 56 | } 57 | 58 | wg.Wait() 59 | 60 | log.Info().Msg("Background Services Shut Down") 61 | 62 | return err 63 | } 64 | 65 | func (b *BackgroundServices) Shutdown(ctx context.Context) { 66 | b.cancel() 67 | } 68 | -------------------------------------------------------------------------------- /lib/kiora/config/unmarshal/maybe_file.go: -------------------------------------------------------------------------------- 1 | package unmarshal 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | // MaybeFile is a value that can be loaded from an external file, or specified directly. 10 | type MaybeFile struct { 11 | // path is the path to the file. 12 | path string 13 | 14 | // value is the contents of the file, or the literal value. 15 | value string 16 | } 17 | 18 | // NewMaybeFile creates a new MaybeFile, loading the value from the file if path is not empty. 19 | func NewMaybeFile(path, value string) (*MaybeFile, error) { 20 | if path != "" && value != "" { 21 | return nil, errors.New("cannot specify both path and value") 22 | } 23 | 24 | if path != "" { 25 | bytes, err := os.ReadFile(path) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | value = string(bytes) 31 | } 32 | 33 | return &MaybeFile{ 34 | path: path, 35 | value: value, 36 | }, nil 37 | } 38 | 39 | func (m *MaybeFile) Value() string { 40 | return m.value 41 | } 42 | 43 | // MaybeSecretFile is a MaybeFile that redacts the value. 44 | type MaybeSecretFile struct { 45 | // path is the path to the file. 46 | path string 47 | 48 | // value is the contents of the file, or the literal value. 49 | value Secret 50 | } 51 | 52 | func NewMaybeSecretFile(path string, value Secret) (*MaybeSecretFile, error) { 53 | if path != "" && value != "" { 54 | return nil, errors.New("cannot specify both path and value") 55 | } 56 | 57 | if path != "" { 58 | bytes, err := os.ReadFile(path) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | value = Secret(string(bytes)) 64 | } 65 | 66 | return &MaybeSecretFile{ 67 | path: path, 68 | value: value, 69 | }, nil 70 | } 71 | 72 | func (m *MaybeSecretFile) Value() Secret { 73 | return m.value 74 | } 75 | -------------------------------------------------------------------------------- /frontend/src/components/alertlist/index.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { useEffect, useState } from "preact/hooks"; 3 | import { Alert, DefaultService } from "../../api"; 4 | import Single from "../alertcard"; 5 | import styles from "./styles.css"; 6 | import Loader from "../loader"; 7 | 8 | interface AlertViewState { 9 | alerts: Alert[]; 10 | error?: string; 11 | } 12 | 13 | interface ErrorViewProps { 14 | error: string; 15 | } 16 | 17 | const ErrorView = ({ error }: ErrorViewProps) => { 18 | return
{error}
; 19 | }; 20 | 21 | interface SuccessViewProps { 22 | alerts: Alert[]; 23 | } 24 | 25 | const SuccessView = ({ alerts }: SuccessViewProps) => { 26 | return ( 27 |
28 | {(alerts.length > 0 && 29 | alerts.map((alert) => { 30 | return ; 31 | })) ||
No alerts
} 32 |
33 | ); 34 | }; 35 | 36 | const AlertList = () => { 37 | const [alerts, setAlerts] = useState({ 38 | alerts: [], 39 | }); 40 | 41 | const fetchAlerts = async () => { 42 | await DefaultService.getAlerts({ sort: ["__starts_at__"], order: "DESC", limit: 100 }) 43 | .then((newAlerts) => { 44 | setAlerts({ 45 | alerts: newAlerts, 46 | error: "", 47 | }); 48 | }) 49 | .catch((error) => { 50 | setAlerts({ 51 | alerts: [], 52 | error: error.toString(), 53 | }); 54 | }); 55 | }; 56 | 57 | let contents: JSX.Element; 58 | 59 | if (alerts.error) { 60 | contents = ; 61 | } else { 62 | contents = ; 63 | } 64 | 65 | return ( 66 | 67 | {contents} 68 | 69 | ); 70 | }; 71 | 72 | export default AlertList; 73 | -------------------------------------------------------------------------------- /internal/clustering/serf/delegate.go: -------------------------------------------------------------------------------- 1 | package serf 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/hashicorp/serf/serf" 7 | "github.com/rs/zerolog" 8 | "github.com/sinkingpoint/kiora/lib/kiora/kioradb" 9 | "github.com/sinkingpoint/kiora/lib/kiora/kioradb/query" 10 | "github.com/sinkingpoint/kiora/lib/kiora/model" 11 | "github.com/sinkingpoint/msgpack/v5" 12 | ) 13 | 14 | var _ = serf.UserDelegate(&DBDelegate{}) 15 | 16 | type DBDump struct { 17 | Alerts []model.Alert 18 | Silences []model.Silence 19 | } 20 | 21 | type DBDelegate struct { 22 | db kioradb.DB 23 | logger zerolog.Logger 24 | } 25 | 26 | func NewDBDelegate(db kioradb.DB, logger zerolog.Logger) *DBDelegate { 27 | return &DBDelegate{ 28 | db: db, 29 | logger: logger.With().Str("component", "db-delegate").Logger(), 30 | } 31 | } 32 | 33 | func (d *DBDelegate) LocalState(join bool) []byte { 34 | dump := DBDump{} 35 | dump.Alerts = d.db.QueryAlerts(context.Background(), query.NewAlertQuery(query.MatchAll())) 36 | dump.Silences = d.db.QuerySilences(context.Background(), query.NewSilenceQuery(query.MatchAll())) 37 | 38 | bytes, _ := msgpack.Marshal(dump) 39 | return bytes 40 | } 41 | 42 | func (d *DBDelegate) MergeRemoteState(buf []byte, join bool) { 43 | dump := DBDump{} 44 | if err := msgpack.Unmarshal(buf, &dump); err != nil { 45 | d.logger.Err(err).Msg("failed to unmarshal DB dump") 46 | return 47 | } 48 | 49 | if err := d.db.StoreSilences(context.Background(), dump.Silences...); err != nil { 50 | d.logger.Err(err).Msg("failed to store silences") 51 | 52 | // We return here because if we failed to store silences, then submitting alerts may cause false positives. 53 | return 54 | } 55 | 56 | if err := d.db.StoreAlerts(context.Background(), dump.Alerts...); err != nil { 57 | d.logger.Err(err).Msg("failed to store alerts") 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /cmd/kiora/config/graph_utils.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/hashicorp/go-multierror" 7 | "github.com/sinkingpoint/kiora/lib/kiora/config" 8 | ) 9 | 10 | // calculateRootsFrom starts at the given node name and walks back up the 11 | // tree, returning the name of all the nodes that have no parents. 12 | func calculateRootsFrom(graph *ConfigFile, nodeName string) HashSet { 13 | roots := HashSet{} 14 | visited := HashSet{} 15 | 16 | stack := []string{nodeName} 17 | for len(stack) > 0 { 18 | nodeName := stack[len(stack)-1] 19 | stack = stack[:len(stack)-1] 20 | if _, ok := visited[nodeName]; ok { 21 | continue 22 | } 23 | 24 | visited[nodeName] = struct{}{} 25 | 26 | if len(graph.reverseLinks[nodeName]) == 0 { 27 | roots[nodeName] = struct{}{} 28 | } else { 29 | for _, link := range graph.reverseLinks[nodeName] { 30 | stack = append(stack, link.to) 31 | } 32 | } 33 | } 34 | 35 | return roots 36 | } 37 | 38 | // searchForAckNode starts at the given fromNode and does a depth-first search across the graph, 39 | // checking the filters on each link and trying to find a path to the given destinationNode, 40 | // returning whether or not it was able to find it. 41 | func searchForNode(ctx context.Context, graph *ConfigFile, fromNode, destinationNode string, data config.Fielder) error { 42 | if fromNode == destinationNode { 43 | return nil 44 | } 45 | 46 | var allErrs error 47 | for _, link := range graph.links[fromNode] { 48 | if err := link.incomingFilter.Filter(ctx, data); err != nil { 49 | allErrs = multierror.Append(allErrs, err) 50 | continue 51 | } 52 | 53 | if err := searchForNode(ctx, graph, link.to, destinationNode, data); err == nil { 54 | return nil 55 | } else { 56 | allErrs = multierror.Append(allErrs, err) 57 | } 58 | } 59 | 60 | return allErrs 61 | } 62 | -------------------------------------------------------------------------------- /integration/group_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/sinkingpoint/kiora/lib/kiora/model" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestGrouping(t *testing.T) { 14 | initT(t) 15 | 16 | k := NewKioraInstance(t).WithConfig(`digraph config { 17 | wait_1s [type="group_wait" duration="1s"]; 18 | group_by_alertname [type="group_labels" labels="alertname"]; 19 | console [type="stdout"]; 20 | 21 | alerts -> group_by_alertname -> wait_1s -> console; 22 | }`).Start() 23 | 24 | defer func() { 25 | require.NoError(t, k.Stop()) 26 | }() 27 | 28 | alert1 := model.Alert{ 29 | Labels: model.Labels{ 30 | "alertname": "test", 31 | "foo": "bar", 32 | }, 33 | Annotations: make(map[string]string), 34 | Status: model.AlertStatusFiring, 35 | } 36 | 37 | require.NoError(t, alert1.Materialise()) 38 | 39 | alert2 := model.Alert{ 40 | Labels: model.Labels{ 41 | "alertname": "test", 42 | "foo": "baz", 43 | }, 44 | Annotations: make(map[string]string), 45 | Status: model.AlertStatusFiring, 46 | } 47 | 48 | require.NoError(t, alert2.Materialise()) 49 | 50 | k.SendAlert(context.TODO(), alert1) 51 | k.SendAlert(context.TODO(), alert2) 52 | 53 | // The alert should be delayed by the group_wait, so neither alert should have come through yet. 54 | require.NotContains(t, k.Stdout(), "bar") 55 | require.NotContains(t, k.Stdout(), "baz") 56 | 57 | // 2s is the group wait. 58 | time.Sleep(2 * time.Second) 59 | require.Contains(t, k.Stdout(), "bar") 60 | require.Contains(t, k.Stdout(), "baz") 61 | 62 | // Wait another group to make sure it doesn't fire again. 63 | time.Sleep(2 * time.Second) 64 | require.Equal(t, 1, strings.Count(k.Stdout(), "bar")) 65 | require.Equal(t, 1, strings.Count(k.Stdout(), "baz")) 66 | } 67 | -------------------------------------------------------------------------------- /lib/kiora/kioradb/query/query.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | type Order string 4 | 5 | const ( 6 | OrderAsc Order = "ASC" 7 | OrderDesc Order = "DESC" 8 | ) 9 | 10 | // Query represents a generic query based on SQL semantics. 11 | type Query struct { 12 | // OrderBy is a direction to order the results by. 13 | Order Order 14 | 15 | // OrderBy is a list of fields to order the results by. 16 | OrderBy []string 17 | 18 | // Offset is the number of results to skip before returning results. 19 | Offset int 20 | 21 | // Limit is the maximum number of results to return. 22 | Limit int 23 | } 24 | 25 | // QueryOption represents an option that can be applied to a query. 26 | type QueryOption interface { 27 | Apply(*Query) 28 | } 29 | 30 | type QueryOpFunc func(*Query) 31 | 32 | func (f QueryOpFunc) Apply(q *Query) { 33 | f(q) 34 | } 35 | 36 | func OrderBy(fields []string, order Order) QueryOption { 37 | return QueryOpFunc(func(q *Query) { 38 | q.OrderBy = fields 39 | q.Order = order 40 | }) 41 | } 42 | 43 | func Limit(limit int) QueryOption { 44 | return QueryOpFunc(func(q *Query) { 45 | q.Limit = limit 46 | }) 47 | } 48 | 49 | func Offset(offset int) QueryOption { 50 | return QueryOpFunc(func(q *Query) { 51 | q.Offset = offset 52 | }) 53 | } 54 | 55 | type AlertQuery struct { 56 | Query 57 | Filter AlertFilter 58 | } 59 | 60 | func NewAlertQuery(filter AlertFilter, ops ...QueryOption) AlertQuery { 61 | q := AlertQuery{ 62 | Filter: filter, 63 | } 64 | 65 | for _, op := range ops { 66 | op.Apply(&q.Query) 67 | } 68 | 69 | return q 70 | } 71 | 72 | type SilenceQuery struct { 73 | Query 74 | Filter SilenceFilter 75 | } 76 | 77 | func NewSilenceQuery(filter SilenceFilter, ops ...QueryOption) SilenceQuery { 78 | q := SilenceQuery{ 79 | Filter: filter, 80 | } 81 | 82 | for _, op := range ops { 83 | op.Apply(&q.Query) 84 | } 85 | 86 | return q 87 | } 88 | -------------------------------------------------------------------------------- /lib/kiora/config/filters/duration/filter_test.go: -------------------------------------------------------------------------------- 1 | package duration_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/sinkingpoint/kiora/internal/stubs" 9 | "github.com/sinkingpoint/kiora/lib/kiora/config/filters/duration" 10 | "github.com/sinkingpoint/kiora/lib/kiora/model" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestDurationFilter(t *testing.T) { 15 | tests := []struct { 16 | name string 17 | attrs map[string]string 18 | silence model.Silence 19 | expectedSuccess bool 20 | }{ 21 | { 22 | name: "test max duration", 23 | attrs: map[string]string{ 24 | "field": "__duration__", 25 | "max": "1s", 26 | }, 27 | silence: model.Silence{ 28 | StartTime: stubs.Time.Now(), 29 | EndTime: stubs.Time.Now().Add(2 * time.Minute), 30 | }, 31 | expectedSuccess: false, 32 | }, 33 | { 34 | name: "test min duration", 35 | attrs: map[string]string{ 36 | "field": "__duration__", 37 | "min": "1s", 38 | }, 39 | silence: model.Silence{ 40 | StartTime: stubs.Time.Now(), 41 | EndTime: stubs.Time.Now().Add(2 * time.Minute), 42 | }, 43 | expectedSuccess: true, 44 | }, 45 | { 46 | name: "test both", 47 | attrs: map[string]string{ 48 | "field": "__duration__", 49 | "min": "1s", 50 | "max": "5h", 51 | }, 52 | silence: model.Silence{ 53 | StartTime: stubs.Time.Now(), 54 | EndTime: stubs.Time.Now().Add(2 * time.Minute), 55 | }, 56 | expectedSuccess: true, 57 | }, 58 | } 59 | 60 | for _, tt := range tests { 61 | t.Run(tt.name, func(t *testing.T) { 62 | filter, err := duration.NewFilter(nil, tt.attrs) 63 | require.NoError(t, err) 64 | 65 | matchesFilter := filter.Filter(context.Background(), &tt.silence) == nil 66 | if tt.expectedSuccess { 67 | require.True(t, matchesFilter) 68 | } else { 69 | require.False(t, matchesFilter) 70 | } 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lib/kiora/config/conf_nodes.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | func init() { 11 | RegisterNode("group_wait", func(name string, globals *Globals, attrs map[string]string) (Node, error) { 12 | rawDuration, ok := attrs["duration"] 13 | if !ok { 14 | return nil, errors.New("missing duration attribute for group_wait node") 15 | } 16 | duration, err := time.ParseDuration(rawDuration) 17 | if err != nil { 18 | return nil, errors.Wrap(err, "failed to parse duration in group_wait node") 19 | } 20 | 21 | return NotifierGroupWait(duration), nil 22 | }) 23 | 24 | RegisterNode("group_labels", func(name string, globals *Globals, attrs map[string]string) (Node, error) { 25 | rawLabels, ok := attrs["labels"] 26 | if !ok { 27 | return nil, errors.New("missing labels attribute for group_labels node") 28 | } 29 | 30 | labels := strings.Split(rawLabels, ",") 31 | 32 | return NotifierGroupLabels(labels), nil 33 | }) 34 | } 35 | 36 | // NotifierSettingsNode is an interface that can be implemented by config nodes that can be used to configure a NotifierSettings. 37 | type NotifierSettingsNode interface { 38 | Apply(*NotifierSettings) error 39 | } 40 | 41 | // NotifierGroupWait is a NotifierSettingsNode that can be used to set the GroupWait field of a NotifierSettings. 42 | type NotifierGroupWait time.Duration 43 | 44 | func (n NotifierGroupWait) Type() string { 45 | return "group_wait" 46 | } 47 | 48 | // Apply sets the GroupWait field of the given NotifierSettings. 49 | func (n NotifierGroupWait) Apply(ns *NotifierSettings) error { 50 | ns.GroupWait = time.Duration(n) 51 | return nil 52 | } 53 | 54 | // NotifierGroupLabels is a NotifierSettingsNode that can be used to set the GroupLabels field of a NotifierSettings. 55 | type NotifierGroupLabels []string 56 | 57 | func (n NotifierGroupLabels) Type() string { 58 | return "group_labels" 59 | } 60 | 61 | // Apply sets the GroupLabels field of the given NotifierSettings. 62 | func (n NotifierGroupLabels) Apply(ns *NotifierSettings) error { 63 | ns.GroupLabels = n 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /internal/services/timeout/service_test.go: -------------------------------------------------------------------------------- 1 | package timeout 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/golang/mock/gomock" 9 | "github.com/sinkingpoint/kiora/lib/kiora/model" 10 | "github.com/sinkingpoint/kiora/mocks/mock_clustering" 11 | "github.com/sinkingpoint/kiora/mocks/mock_kioradb" 12 | "github.com/sinkingpoint/kiora/mocks/mock_services" 13 | ) 14 | 15 | func TestTimeoutService(t *testing.T) { 16 | type test struct { 17 | Name string 18 | Alerts []model.Alert 19 | ExpectedBroadcast []int 20 | } 21 | 22 | tests := []test{ 23 | { 24 | Name: "test_no_timed_out", 25 | Alerts: []model.Alert{ 26 | { 27 | TimeOutDeadline: time.Now().Add(1 * time.Hour), 28 | Status: model.AlertStatusFiring, 29 | }, 30 | }, 31 | ExpectedBroadcast: []int{}, 32 | }, 33 | { 34 | Name: "test_timed_out", 35 | Alerts: []model.Alert{ 36 | { 37 | TimeOutDeadline: time.Now().Add(-1 * time.Hour), 38 | Status: model.AlertStatusFiring, 39 | }, 40 | }, 41 | ExpectedBroadcast: []int{0}, 42 | }, 43 | 44 | { 45 | Name: "test_resolved_doesn't_time_out", 46 | Alerts: []model.Alert{ 47 | { 48 | TimeOutDeadline: time.Now().Add(-1 * time.Hour), 49 | Status: model.AlertStatusResolved, 50 | }, 51 | }, 52 | ExpectedBroadcast: []int{}, 53 | }, 54 | } 55 | 56 | ctrl := gomock.NewController(t) 57 | 58 | for _, tt := range tests { 59 | t.Run(tt.Name, func(t *testing.T) { 60 | bus := mock_services.NewMockBus(ctrl) 61 | bus.EXPECT().DB().Return(mock_kioradb.MockDBWithAlerts(ctrl, tt.Alerts)) 62 | 63 | expectedBroadcast := []model.Alert{} 64 | for _, idx := range tt.ExpectedBroadcast { 65 | alert := tt.Alerts[idx] 66 | alert.Status = model.AlertStatusTimedOut 67 | expectedBroadcast = append(expectedBroadcast, alert) 68 | } 69 | bus.EXPECT().Broadcaster().Return(mock_clustering.MockBroadcasterExpectingAlerts(ctrl, expectedBroadcast)) 70 | 71 | timeoutService := NewTimeoutService(bus) 72 | timeoutService.timeoutAlerts(context.TODO()) 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /internal/server/config.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/rs/zerolog" 7 | "github.com/sinkingpoint/kiora/lib/kiora/config" 8 | ) 9 | 10 | // TLSPair is a pair of paths representing the path to a certificate and private key. 11 | type TLSPair struct { 12 | // CertPath is the path to the certificate. 13 | CertPath string 14 | 15 | // KeyPath is the path to the private key. 16 | KeyPath string 17 | } 18 | 19 | type serverConfig struct { 20 | // HTTPListenAddress is the address for the server to listen on. Defaults to localhost:4278. 21 | HTTPListenAddress string 22 | 23 | ClusterListenAddress string 24 | 25 | // ClusterShardLabels is the set of labels that will be used to determine which node in a cluster will send a given alert. 26 | // Defaults to an empty list which will shard by every label, effectively causing a random assignment across the cluster. Setting this can improve alert grouping, 27 | // at the cost of a potentially unbalanced cluster. 28 | ClusterShardLabels []string 29 | 30 | // BootstrapPeers is the set of peers to bootstrap the cluster with. Defaults to an empty list which means that the node will not join a cluster. 31 | BootstrapPeers []string 32 | 33 | // ServiceConfig is the config that will determine how data flows through the kiora instance. 34 | ServiceConfig config.Config 35 | 36 | // ReadTimeout is the maximum amount of time the server will spend reading requests from clients. Defaults to 5 seconds. 37 | ReadTimeout time.Duration 38 | 39 | // WriteTimeout is the maximum amount of time the server will spend writing requests to clients. Defaults to 60 seconds. 40 | WriteTimeout time.Duration 41 | 42 | // TLS is an optional pair of cert and key files that will be used to serve TLS connections. 43 | TLS *TLSPair 44 | 45 | Logger zerolog.Logger 46 | } 47 | 48 | // NewServerConfig constructs a serverConfig with all the defaults set. 49 | func NewServerConfig() serverConfig { 50 | return serverConfig{ 51 | HTTPListenAddress: "localhost:4278", 52 | ReadTimeout: 5 * time.Second, 53 | WriteTimeout: 60 * time.Second, 54 | TLS: nil, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/kiora/kioradb/query/sort.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "sort" 5 | "time" 6 | 7 | "github.com/rs/zerolog/log" 8 | "github.com/sinkingpoint/kiora/lib/kiora/model" 9 | ) 10 | 11 | // alertsByFields implements sort.Interface for sorting a number of alerts 12 | // by a number of fields. If a field is not present on an alert, it is considered 13 | // to be less than the other alert. 14 | type alertsByFields struct { 15 | Alerts []model.Alert 16 | Fields []string 17 | Order Order 18 | } 19 | 20 | func SortAlertsByFields(alerts []model.Alert, fields []string, order Order) sort.Interface { 21 | return alertsByFields{ 22 | Alerts: alerts, 23 | Fields: fields, 24 | Order: order, 25 | } 26 | } 27 | 28 | func (a alertsByFields) Len() int { 29 | return len(a.Alerts) 30 | } 31 | 32 | func (a alertsByFields) Swap(i, j int) { 33 | a.Alerts[i], a.Alerts[j] = a.Alerts[j], a.Alerts[i] 34 | } 35 | 36 | func (a alertsByFields) lessVal() bool { 37 | return a.Order == OrderAsc 38 | } 39 | 40 | func (a alertsByFields) Less(i, j int) bool { 41 | for _, field := range a.Fields { 42 | iVal, iErr := a.Alerts[i].Field(field) 43 | jVal, jErr := a.Alerts[j].Field(field) 44 | 45 | if iErr != nil && jErr != nil { 46 | continue 47 | } 48 | 49 | if iErr != nil { 50 | return !a.lessVal() 51 | } 52 | 53 | if jErr != nil { 54 | return a.lessVal() 55 | } 56 | 57 | if iVal == jVal { 58 | continue 59 | } 60 | 61 | switch val := iVal.(type) { 62 | case string: 63 | if a.Order == OrderDesc { 64 | return (val > jVal.(string)) 65 | } 66 | 67 | return val < jVal.(string) 68 | case int: 69 | if a.Order == OrderDesc { 70 | return (val > jVal.(int)) 71 | } 72 | 73 | return val < jVal.(int) 74 | case float64: 75 | if a.Order == OrderDesc { 76 | return (val > jVal.(float64)) 77 | } 78 | 79 | return val < jVal.(float64) 80 | case time.Time: 81 | if a.Order == OrderDesc { 82 | return (val.After(jVal.(time.Time))) 83 | } 84 | 85 | return val.Before(jVal.(time.Time)) 86 | default: 87 | log.Warn().Str("field", field).Interface("value", iVal).Msg("unknown field type") 88 | continue 89 | } 90 | } 91 | 92 | return true 93 | } 94 | -------------------------------------------------------------------------------- /cmd/tuku/commands/silences/post.go: -------------------------------------------------------------------------------- 1 | package silences 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/pkg/errors" 8 | "github.com/sinkingpoint/kiora/cmd/tuku/commands" 9 | "github.com/sinkingpoint/kiora/internal/stubs" 10 | "github.com/sinkingpoint/kiora/lib/kiora/model" 11 | ) 12 | 13 | type SilencePostCmd struct { 14 | Comment string `help:"The comment describing the silence."` 15 | Author string `help:"The author of the silence."` 16 | StartTime string `help:"When the silence should start."` 17 | Duration string `help:"How long after the StartTime the silence should last for." short:"d" required:""` 18 | Matchers []string `arg:"" help:"The matchers for the silence." required:""` 19 | } 20 | 21 | func (a *SilencePostCmd) Run(ctx *commands.Context) error { 22 | var startTime time.Time 23 | var err error 24 | 25 | if a.StartTime != "" { 26 | startTime, err = time.Parse(time.RFC3339, a.StartTime) 27 | if err != nil { 28 | return errors.Wrap(err, "failed to parse start time") 29 | } 30 | } else { 31 | startTime = stubs.Time.Now() 32 | } 33 | 34 | duration, err := time.ParseDuration(a.Duration) 35 | if err != nil { 36 | return errors.Wrap(err, "failed to parse duration") 37 | } 38 | 39 | endTime := startTime.Add(duration) 40 | 41 | matchers, err := parseMatchers(a.Matchers) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | silence, err := ctx.Kiora.PostSilence(model.Silence{ 47 | Comment: a.Comment, 48 | Creator: a.Author, 49 | StartTime: startTime, 50 | EndTime: endTime, 51 | Matchers: matchers, 52 | }) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | out, err := ctx.Formatter.Marshal(silence) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | fmt.Println(string(out)) 63 | 64 | return nil 65 | } 66 | 67 | func parseMatchers(rawMatchers []string) ([]model.Matcher, error) { 68 | matchers := []model.Matcher{} 69 | for _, m := range rawMatchers { 70 | matcher := model.Matcher{} 71 | if err := matcher.UnmarshalText(m); err != nil { 72 | return nil, errors.Wrap(err, fmt.Sprintf("failed to parse matcher %q", m)) 73 | } 74 | 75 | matchers = append(matchers, matcher) 76 | } 77 | 78 | return matchers, nil 79 | } 80 | -------------------------------------------------------------------------------- /internal/server/metrics/tenantcount.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/rs/zerolog/log" 7 | "github.com/sinkingpoint/kiora/lib/kiora/config" 8 | "github.com/sinkingpoint/kiora/lib/kiora/kioradb" 9 | "github.com/sinkingpoint/kiora/lib/kiora/kioradb/query" 10 | "github.com/sinkingpoint/kiora/lib/kiora/model" 11 | 12 | "github.com/prometheus/client_golang/prometheus" 13 | ) 14 | 15 | var _ = prometheus.Collector(&TenantCountCollector{}) 16 | 17 | var alertCountDesc = prometheus.NewDesc("kiora_alert_state", "Number of alerts in the system", []string{ 18 | "tenant", 19 | "state", 20 | }, nil) 21 | 22 | // TenantCountCollector is a prometheus.Collector that collects the number of alerts in the system, 23 | // broken down by tenant and state. 24 | type TenantCountCollector struct { 25 | globals *config.Globals 26 | db kioradb.DB 27 | } 28 | 29 | func NewTenantCountCollector(globals *config.Globals, db kioradb.DB) *TenantCountCollector { 30 | return &TenantCountCollector{ 31 | globals: globals, 32 | db: db, 33 | } 34 | } 35 | 36 | // Collect implements prometheus.Collector. 37 | func (t *TenantCountCollector) Collect(ch chan<- prometheus.Metric) { 38 | type state struct { 39 | tenant config.Tenant 40 | state model.AlertStatus 41 | } 42 | 43 | states := map[state]int64{} 44 | 45 | alerts := t.db.QueryAlerts(context.Background(), query.NewAlertQuery(query.MatchAll())) 46 | for _, alert := range alerts { 47 | tenant, err := t.globals.Tenanter.GetTenant(context.Background(), &alert) 48 | if err != nil { 49 | log.Debug().Err(err).Interface("alert", alert).Msg("Failed to get tenant") 50 | tenant = "error" 51 | } 52 | 53 | state := state{ 54 | tenant: tenant, 55 | state: alert.Status, 56 | } 57 | 58 | if count, ok := states[state]; ok { 59 | states[state] = count + 1 60 | } else { 61 | states[state] = 1 62 | } 63 | } 64 | 65 | for state, count := range states { 66 | ch <- prometheus.MustNewConstMetric(alertCountDesc, 67 | prometheus.GaugeValue, 68 | float64(count), 69 | string(state.tenant), 70 | string(state.state), 71 | ) 72 | } 73 | } 74 | 75 | // Describe implements prometheus.Collector. 76 | func (*TenantCountCollector) Describe(c chan<- *prometheus.Desc) { 77 | c <- alertCountDesc 78 | } 79 | -------------------------------------------------------------------------------- /frontend/src/components/labelmatchercard/index.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import style from "./style.css"; 3 | import { Matcher } from "../../api"; 4 | 5 | // parseMatcher takes a matcher string and returns a Matcher object if the string is valid. 6 | export const parseMatcher = (matcher: string): Matcher | null => { 7 | const matcherMatches = matcher.match(/([a-zA-Z0-9_]+)(!=|!~|=~|=)"(.*)"/); 8 | if (matcherMatches === null) { 9 | console.log("Invalid matcher", matcher); 10 | return null; 11 | } 12 | 13 | const validOperators = ["=", "!=", "=~", "!~"]; 14 | const operator = matcherMatches[2]; 15 | if (!validOperators.includes(operator)) { 16 | console.log("Invalid operator for matcher", matcher); 17 | return null; 18 | } 19 | 20 | // If the operator is a regex operator, check that the regex is valid. 21 | if (operator.includes("~")) { 22 | try { 23 | new RegExp(matcherMatches[3]); 24 | } catch { 25 | console.log("Invalid regex for matcher", matcher); 26 | return null; 27 | } 28 | } 29 | 30 | const isRegex = operator.includes("~"); 31 | const isNegative = operator.includes("!"); 32 | 33 | return { 34 | label: matcherMatches[1], 35 | value: matcherMatches[3], 36 | isRegex, 37 | isNegative, 38 | }; 39 | }; 40 | 41 | export interface LabelMatcherCardProps { 42 | matcher: string | Matcher; 43 | onDelete?: () => void; 44 | } 45 | 46 | // LabelMatcher takes a matcher string and returns a span element that displays the matcher. 47 | const LabelMatcherCard = ({ matcher, onDelete }: LabelMatcherCardProps) => { 48 | if(typeof matcher === "string") { 49 | matcher = parseMatcher(matcher); 50 | if(matcher === null) { 51 | return Invalid matcher; 52 | } 53 | } 54 | 55 | const { label, value, isRegex, isNegative } = matcher; 56 | 57 | let operator = ""; 58 | 59 | if (isRegex) { 60 | operator = isNegative ? "!~" : "=~"; 61 | } else { 62 | operator = isNegative ? "!=" : "="; 63 | } 64 | 65 | const canBeEdited = onDelete !== undefined; 66 | 67 | return ( 68 | 69 | {label} {operator} {value} 70 | {canBeEdited && ( 71 | 74 | )} 75 | 76 | ); 77 | }; 78 | 79 | export default LabelMatcherCard; 80 | -------------------------------------------------------------------------------- /internal/testutils/alerts.go: -------------------------------------------------------------------------------- 1 | package testutils 2 | 3 | import ( 4 | "math/rand" 5 | "strconv" 6 | 7 | "github.com/sinkingpoint/kiora/lib/kiora/model" 8 | ) 9 | 10 | type alertTemplates struct { 11 | Name string 12 | LabelNames []string 13 | LabelCardinalities []int 14 | } 15 | 16 | func generateAlertTemplates(numPossibleAlerts, maximumLabels, maximumCardinality int) []alertTemplates { 17 | potentialAlerts := make([]alertTemplates, numPossibleAlerts) 18 | 19 | for i := 0; i < numPossibleAlerts; i++ { 20 | numLabels := rand.Intn(maximumLabels) + 1 21 | labelNames := make([]string, numLabels) 22 | labelCardinalities := make([]int, numLabels) 23 | for j := 0; j < numLabels; j++ { 24 | labelNames[j] = "Label_" + strconv.Itoa(rand.Intn(maximumLabels)+1) 25 | labelCardinalities[j] = rand.Intn(maximumCardinality) + 1 26 | } 27 | 28 | potentialAlerts[i] = alertTemplates{ 29 | Name: "Alert_" + strconv.Itoa(i+1), 30 | LabelNames: labelNames, 31 | LabelCardinalities: labelCardinalities, 32 | } 33 | } 34 | 35 | return potentialAlerts 36 | } 37 | 38 | func GenerateDummyAlerts(num, numPossibleAlerts, maximumLabels, maximumCardinality int) []model.Alert { 39 | // Generate a bunch of alerts with random labels. 40 | alerts := make([]model.Alert, num) 41 | templates := generateAlertTemplates(numPossibleAlerts, maximumLabels, maximumCardinality) 42 | 43 | existingAlerts := make(map[model.LabelsHash]bool) 44 | 45 | for i := 0; i < len(alerts); i++ { 46 | potentialIndex := rand.Intn(len(templates)) 47 | potential := templates[potentialIndex] 48 | alert := model.Alert{ 49 | Labels: make(map[string]string), 50 | Annotations: make(map[string]string), 51 | Status: model.AlertStatusFiring, 52 | } 53 | 54 | alert.Labels["alertname"] = potential.Name 55 | 56 | for j := 0; j < len(potential.LabelNames); j++ { 57 | labelName := potential.LabelNames[j] 58 | labelCardinality := potential.LabelCardinalities[j] 59 | k := rand.Intn(labelCardinality) 60 | labelValue := strconv.Itoa(k) 61 | alert.Labels[labelName] = labelValue 62 | } 63 | 64 | if _, ok := existingAlerts[alert.Labels.Hash()]; ok { 65 | i-- 66 | continue 67 | } 68 | 69 | alerts[i] = alert 70 | existingAlerts[alert.Labels.Hash()] = true 71 | } 72 | 73 | return alerts 74 | } 75 | -------------------------------------------------------------------------------- /lib/kiora/config/filters/ratelimit/filter_test.go: -------------------------------------------------------------------------------- 1 | package ratelimit_test 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "testing" 7 | "time" 8 | 9 | "go.uber.org/atomic" 10 | 11 | "github.com/sinkingpoint/kiora/lib/kiora/config" 12 | "github.com/sinkingpoint/kiora/lib/kiora/config/filters/ratelimit" 13 | "github.com/sinkingpoint/kiora/lib/kiora/model" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | func TestRateLimit(t *testing.T) { 18 | globals := config.NewGlobals(config.WithTenanter(config.NewStaticTenanter(""))) 19 | filter, err := ratelimit.NewFilter(globals, map[string]string{ 20 | "interval": "1s", 21 | "rate": "1", 22 | "burst": "2", 23 | }) 24 | 25 | require.NoError(t, err) 26 | 27 | alert := model.Alert{ 28 | Status: model.AlertStatusFiring, 29 | Labels: model.Labels{}, 30 | } 31 | 32 | require.NoError(t, alert.Materialise()) 33 | 34 | // Assert that we can send one alert, but the second exceeds rate limits and fails. 35 | require.NoError(t, filter.Filter(context.Background(), &alert)) 36 | require.Error(t, filter.Filter(context.Background(), &alert)) 37 | 38 | // Sleep for a bit to allow the interval to pass. 39 | time.Sleep(2 * time.Second) 40 | 41 | // Assert that we can send two alerts after the interval has passed, because of the burst capacity. 42 | require.NoError(t, filter.Filter(context.Background(), &alert)) 43 | require.NoError(t, filter.Filter(context.Background(), &alert)) 44 | } 45 | 46 | // TestRatelimitConcurrency tests that the ratelimit filter is safe to use concurrently, 47 | // and that it correctly enforces the rate limit. 48 | func TestRatelimitConcurrency(t *testing.T) { 49 | numSuccess := atomic.Int32{} 50 | 51 | globals := config.NewGlobals(config.WithTenanter(config.NewStaticTenanter(""))) 52 | filter, err := ratelimit.NewFilter(globals, map[string]string{ 53 | "interval": "30s", 54 | "rate": "200", 55 | }) 56 | 57 | require.NoError(t, err) 58 | 59 | alert := model.Alert{ 60 | Status: model.AlertStatusFiring, 61 | Labels: model.Labels{}, 62 | } 63 | 64 | require.NoError(t, alert.Materialise()) 65 | 66 | wg := sync.WaitGroup{} 67 | for i := 0; i < 10000; i++ { 68 | wg.Add(1) 69 | go func() { 70 | err := filter.Filter(context.Background(), &alert) 71 | if err == nil { 72 | numSuccess.Add(1) 73 | } 74 | 75 | wg.Done() 76 | }() 77 | } 78 | 79 | wg.Wait() 80 | 81 | require.Equal(t, 200, int(numSuccess.Load())) 82 | } 83 | -------------------------------------------------------------------------------- /integration/single_node_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/sinkingpoint/kiora/lib/kiora/model" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | // Test that Kiora doesn't immediately exit. 15 | func TestKioraStart(t *testing.T) { 16 | initT(t) 17 | kiora := NewKioraInstance(t).Start() 18 | time.Sleep(1 * time.Second) 19 | 20 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 21 | defer cancel() 22 | 23 | require.Equal(t, context.DeadlineExceeded, kiora.WaitForExit(ctx), "StdErr: %q", kiora.Stderr()) 24 | } 25 | 26 | // Test that a post to a Kiora instance stores the alert. 27 | func TestKioraAlertPost(t *testing.T) { 28 | initT(t) 29 | 30 | kiora := NewKioraInstance(t).Start() 31 | 32 | // Send a bunch of the same alert. 33 | for i := 0; i < 50; i++ { 34 | kiora.SendAlert(context.TODO(), dummyAlert()) 35 | } 36 | 37 | // Sleep a bit to apply the alert. 38 | time.Sleep(1 * time.Second) 39 | 40 | require.Contains(t, kiora.stdout.String(), "foo") 41 | 42 | // It should only have fired once. 43 | require.Equal(t, 1, strings.Count(kiora.stdout.String(), "foo")) 44 | } 45 | 46 | // Test that an alert refires if it fires, resolves, and then refires. 47 | func TestKioraResolveResends(t *testing.T) { 48 | initT(t) 49 | 50 | kiora := NewKioraInstance(t).Start() 51 | 52 | alert := dummyAlert() 53 | resolved := dummyAlert() 54 | resolved.Status = model.AlertStatusResolved 55 | 56 | kiora.SendAlert(context.Background(), alert) 57 | time.Sleep(1 * time.Second) 58 | require.Equal(t, 1, strings.Count(kiora.Stdout(), "foo")) 59 | 60 | kiora.SendAlert(context.Background(), resolved) 61 | time.Sleep(1 * time.Second) 62 | require.Contains(t, kiora.stdout.String(), "resolved") 63 | require.Equal(t, 2, strings.Count(kiora.Stdout(), "foo")) 64 | 65 | kiora.SendAlert(context.Background(), alert) 66 | time.Sleep(1 * time.Second) 67 | require.Equal(t, 3, strings.Count(kiora.Stdout(), "foo")) 68 | } 69 | 70 | func TestGetSilence(t *testing.T) { 71 | initT(t) 72 | instance := NewKioraInstance(t).Start() 73 | 74 | silence := instance.SendSilence(context.Background(), dummySilence()) 75 | time.Sleep(1 * time.Second) 76 | 77 | require.Len(t, instance.GetSilences(context.Background(), []string{}), 1) 78 | require.Len(t, instance.GetSilences(context.Background(), []string{fmt.Sprintf("__id__=%s", silence.ID)}), 1) 79 | } 80 | -------------------------------------------------------------------------------- /internal/tracing/tracing.go: -------------------------------------------------------------------------------- 1 | package tracing 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/pkg/errors" 8 | "go.opentelemetry.io/otel" 9 | "go.opentelemetry.io/otel/exporters/jaeger" 10 | "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" 11 | "go.opentelemetry.io/otel/sdk/resource" 12 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 13 | semconv "go.opentelemetry.io/otel/semconv/v1.12.0" 14 | "go.opentelemetry.io/otel/trace" 15 | ) 16 | 17 | const name = "kiora" 18 | 19 | func Tracer() trace.Tracer { 20 | return otel.Tracer(name) 21 | } 22 | 23 | type TracingConfiguration struct { 24 | ServiceName string 25 | ExporterType string 26 | DestinationURL string 27 | } 28 | 29 | func DefaultTracingConfiguration() TracingConfiguration { 30 | return TracingConfiguration{ 31 | ServiceName: "kiora", 32 | ExporterType: "noop", 33 | } 34 | } 35 | 36 | func newTracerProvider(config TracingConfiguration, exp sdktrace.SpanExporter) (*sdktrace.TracerProvider, error) { 37 | if exp == nil { 38 | return nil, nil 39 | } 40 | 41 | r, err := resource.Merge( 42 | resource.Default(), 43 | resource.NewSchemaless( 44 | semconv.ServiceNameKey.String(config.ServiceName), 45 | ), 46 | ) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | return sdktrace.NewTracerProvider( 52 | sdktrace.WithBatcher(exp), 53 | sdktrace.WithResource(r), 54 | ), nil 55 | } 56 | 57 | func newSpanExporter(config TracingConfiguration) (sdktrace.SpanExporter, error) { 58 | switch config.ExporterType { 59 | case "noop": 60 | return nil, nil 61 | case "console": 62 | return stdouttrace.New( 63 | stdouttrace.WithWriter(os.Stdout), 64 | ) 65 | case "jaeger": 66 | if config.DestinationURL != "" { 67 | return jaeger.New(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint(config.DestinationURL))) 68 | } 69 | 70 | return jaeger.New(jaeger.WithCollectorEndpoint()) 71 | default: 72 | return nil, fmt.Errorf("invalid exporter: %q", config.ExporterType) 73 | } 74 | } 75 | 76 | func InitTracing(config TracingConfiguration) (*sdktrace.TracerProvider, error) { 77 | exporter, err := newSpanExporter(config) 78 | if err != nil { 79 | return nil, errors.Wrap(err, "failed to create span exporter") 80 | } 81 | 82 | provider, err := newTracerProvider(config, exporter) 83 | if err != nil { 84 | return nil, errors.Wrap(err, "failed to create tracer provider") 85 | } 86 | 87 | if provider != nil { 88 | otel.SetTracerProvider(provider) 89 | } 90 | 91 | return provider, nil 92 | } 93 | -------------------------------------------------------------------------------- /internal/clustering/ring_clusterer.go: -------------------------------------------------------------------------------- 1 | package clustering 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/buraksezer/consistent" 7 | "github.com/cespare/xxhash" 8 | "github.com/sinkingpoint/kiora/lib/kiora/model" 9 | ) 10 | 11 | type hasher struct{} 12 | 13 | func (h hasher) Sum64(bytes []byte) uint64 { 14 | return xxhash.Sum64(bytes) 15 | } 16 | 17 | type kioraMember struct { 18 | Name string `json:"name"` 19 | Address string `json:"address"` 20 | } 21 | 22 | func (k *kioraMember) String() string { 23 | return k.Name 24 | } 25 | 26 | var ( 27 | _ = Clusterer(&RingClusterer{}) 28 | _ = ClustererDelegate(&RingClusterer{}) 29 | ) 30 | 31 | // RingClusterer is a clusterer that keeps track of nodes in a consistent hash ring. 32 | type RingClusterer struct { 33 | me consistent.Member 34 | ring *consistent.Consistent 35 | shardKeys []string 36 | } 37 | 38 | // NewRingClusterer constructs a new RingClusterer, with the given name and address. 39 | // This name and address _must_ be the same as the node in the underlying Cluster in order to properly shard alerts. 40 | func NewRingClusterer(myName, myAddress string) *RingClusterer { 41 | me := &kioraMember{ 42 | Name: myName, 43 | Address: myAddress, 44 | } 45 | 46 | config := consistent.Config{ 47 | Hasher: hasher{}, 48 | } 49 | 50 | return &RingClusterer{ 51 | me: me, 52 | ring: consistent.New([]consistent.Member{me}, config), 53 | } 54 | } 55 | 56 | func (r *RingClusterer) SetShardLabels(keys []string) { 57 | r.shardKeys = keys 58 | } 59 | 60 | func (r *RingClusterer) IsAuthoritativeFor(ctx context.Context, a *model.Alert) bool { 61 | return r.GetAuthoritativeNode(ctx, a) == r.me 62 | } 63 | 64 | // GetAuthoritativeNode returns the node that is authoritative for the given alert. 65 | func (r *RingClusterer) GetAuthoritativeNode(ctx context.Context, a *model.Alert) consistent.Member { 66 | if len(r.shardKeys) == 0 { 67 | return r.ring.LocateKey(a.Labels.Bytes()) 68 | } 69 | 70 | labels := a.Labels.Subset(r.shardKeys...) 71 | return r.ring.LocateKey(labels.Bytes()) 72 | } 73 | 74 | func (r *RingClusterer) AddNode(name, address string) { 75 | r.ring.Add(&kioraMember{ 76 | Name: name, 77 | Address: address, 78 | }) 79 | } 80 | 81 | func (r *RingClusterer) RemoveNode(name string) { 82 | r.ring.Remove(name) 83 | } 84 | 85 | func (r *RingClusterer) Nodes() []any { 86 | members := r.ring.GetMembers() 87 | nodes := make([]any, 0, len(members)) 88 | 89 | for _, node := range members { 90 | nodes = append(nodes, node) 91 | } 92 | 93 | return nodes 94 | } 95 | -------------------------------------------------------------------------------- /lib/kiora/kioradb/query/sort_test.go: -------------------------------------------------------------------------------- 1 | package query_test 2 | 3 | import ( 4 | "sort" 5 | "testing" 6 | "time" 7 | 8 | "github.com/sinkingpoint/kiora/lib/kiora/kioradb/query" 9 | "github.com/sinkingpoint/kiora/lib/kiora/model" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestSortAlertsByFields(t *testing.T) { 14 | a := model.Alert{ 15 | StartTime: time.Unix(1, 0), 16 | Labels: model.Labels{ 17 | "foo": "bar", 18 | }, 19 | EndTime: time.Unix(2, 0), 20 | Status: model.AlertStatusFiring, 21 | } 22 | 23 | b := model.Alert{ 24 | StartTime: time.Unix(2, 0), 25 | Labels: model.Labels{ 26 | "foo": "baz", 27 | }, 28 | EndTime: time.Unix(2, 0), 29 | Status: model.AlertStatusFiring, 30 | } 31 | 32 | c := model.Alert{ 33 | StartTime: time.Unix(3, 0), 34 | Labels: model.Labels{ 35 | "foo": "qux", 36 | }, 37 | EndTime: time.Unix(2, 0), 38 | Status: model.AlertStatusFiring, 39 | } 40 | 41 | tests := []struct { 42 | Name string 43 | Alerts []model.Alert 44 | Fields []string 45 | Order query.Order 46 | ExpectedAlerts []model.Alert 47 | }{ 48 | { 49 | Name: "test_sort_by_start_time", 50 | Alerts: []model.Alert{ 51 | a, c, b, 52 | }, 53 | Fields: []string{"__starts_at__"}, 54 | Order: query.OrderAsc, 55 | ExpectedAlerts: []model.Alert{ 56 | a, b, c, 57 | }, 58 | }, 59 | { 60 | Name: "test_sort_by_start_time_desc", 61 | Alerts: []model.Alert{ 62 | a, c, b, 63 | }, 64 | Fields: []string{"__starts_at__"}, 65 | Order: query.OrderDesc, 66 | ExpectedAlerts: []model.Alert{ 67 | c, b, a, 68 | }, 69 | }, 70 | { 71 | Name: "test_sort_by_label", 72 | Alerts: []model.Alert{ 73 | a, c, b, 74 | }, 75 | Fields: []string{"foo"}, 76 | Order: query.OrderAsc, 77 | ExpectedAlerts: []model.Alert{ 78 | a, b, c, 79 | }, 80 | }, 81 | { 82 | Name: "test_sort_by_multiple_values", 83 | Alerts: []model.Alert{ 84 | a, c, b, 85 | }, 86 | Fields: []string{"__ends_at__", "foo"}, 87 | Order: query.OrderDesc, 88 | ExpectedAlerts: []model.Alert{ 89 | c, b, a, 90 | }, 91 | }, 92 | } 93 | 94 | for _, tt := range tests { 95 | t.Run(tt.Name, func(t *testing.T) { 96 | sort.Sort(query.SortAlertsByFields(tt.Alerts, tt.Fields, tt.Order)) 97 | for i, alert := range tt.Alerts { 98 | require.Equal(t, tt.ExpectedAlerts[i], alert, "expected alert %d to be %v, got %v", i, tt.ExpectedAlerts[i], alert) 99 | } 100 | }) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /lib/kiora/kioradb/inmemory_test.go: -------------------------------------------------------------------------------- 1 | package kioradb_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/sinkingpoint/kiora/lib/kiora/kioradb" 8 | "github.com/sinkingpoint/kiora/lib/kiora/kioradb/query" 9 | "github.com/sinkingpoint/kiora/lib/kiora/model" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestInMemoryDB(t *testing.T) { 14 | db := kioradb.NewInMemoryDB() 15 | 16 | // Add an alert 17 | require.NoError(t, db.StoreAlerts(context.Background(), []model.Alert{ 18 | { 19 | Labels: model.Labels{ 20 | "foo": "bar", 21 | }, 22 | Status: model.AlertStatusFiring, 23 | }, 24 | }...)) 25 | 26 | alerts := db.QueryAlerts(context.TODO(), query.NewAlertQuery(query.MatchAll())) 27 | 28 | require.Len(t, alerts, 1) 29 | alert := alerts[0] 30 | require.Equal(t, "bar", alert.Labels["foo"]) 31 | require.Equal(t, model.AlertStatusFiring, alert.Status) 32 | 33 | // Resolve the above alert, add another 34 | require.NoError(t, db.StoreAlerts(context.Background(), []model.Alert{ 35 | { 36 | Labels: model.Labels{ 37 | "foo": "bar", 38 | }, 39 | Status: model.AlertStatusResolved, 40 | }, 41 | { 42 | Labels: model.Labels{ 43 | "bar": "baz", 44 | }, 45 | Status: model.AlertStatusFiring, 46 | }, 47 | }...)) 48 | 49 | alerts = db.QueryAlerts(context.TODO(), query.NewAlertQuery(query.MatchAll())) 50 | require.Len(t, alerts, 2) 51 | 52 | for _, alert := range alerts { 53 | if _, hasFoo := alert.Labels["foo"]; hasFoo { 54 | require.Equal(t, "bar", alert.Labels["foo"]) 55 | require.Equal(t, model.AlertStatusResolved, alert.Status) 56 | } else if _, hasBar := alert.Labels["bar"]; hasBar { 57 | require.Equal(t, "baz", alert.Labels["bar"]) 58 | require.Equal(t, model.AlertStatusFiring, alert.Status) 59 | } else { 60 | t.Errorf("unexpected alert: %v", alert) 61 | } 62 | } 63 | 64 | // Timeout the second alert 65 | require.NoError(t, db.StoreAlerts(context.Background(), []model.Alert{ 66 | { 67 | Labels: model.Labels{ 68 | "bar": "baz", 69 | }, 70 | Status: model.AlertStatusResolved, 71 | }, 72 | }...)) 73 | 74 | alerts = db.QueryAlerts(context.TODO(), query.NewAlertQuery(query.MatchAll())) 75 | require.Len(t, alerts, 2) 76 | 77 | for _, alert := range alerts { 78 | if _, hasFoo := alert.Labels["foo"]; hasFoo { 79 | require.Equal(t, "bar", alert.Labels["foo"]) 80 | require.Equal(t, model.AlertStatusResolved, alert.Status) 81 | } else if _, hasBar := alert.Labels["bar"]; hasBar { 82 | require.Equal(t, "baz", alert.Labels["bar"]) 83 | require.Equal(t, model.AlertStatusResolved, alert.Status) 84 | } else { 85 | t.Errorf("unexpected alert: %v", alert) 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 15m 3 | skip-files: 4 | # Skip autogenerated files. 5 | - ^.*\.(pb|y)\.go$ 6 | 7 | output: 8 | sort-results: true 9 | 10 | linters: 11 | enable: 12 | - depguard 13 | - gocritic 14 | - gofumpt 15 | - goimports 16 | - misspell 17 | - predeclared 18 | - revive 19 | - unconvert 20 | - unused 21 | - bodyclose 22 | - contextcheck 23 | - errcheck 24 | - errname 25 | - exhaustive 26 | - goconst 27 | - godot 28 | - goimports 29 | - gosmopolitan 30 | - grouper 31 | - importas 32 | - maintidx 33 | - makezero 34 | - mirror 35 | - nilerr 36 | - prealloc 37 | - promlinter 38 | - thelper 39 | - tparallel 40 | - wastedassign 41 | 42 | issues: 43 | max-same-issues: 0 44 | exclude-rules: 45 | - linters: 46 | - gocritic 47 | text: "appendAssign" 48 | - path: _test.go 49 | linters: 50 | - errcheck 51 | 52 | linters-settings: 53 | depguard: 54 | rules: 55 | main: 56 | deny: 57 | - pkg: "sync/atomic" 58 | desc: "Use go.uber.org/atomic instead of sync/atomic" 59 | - pkg: "github.com/stretchr/testify/assert" 60 | desc: "Use github.com/stretchr/testify/require instead of github.com/stretchr/testify/assert" 61 | - pkg: "github.com/go-kit/kit/log" 62 | desc: "Use github.com/go-kit/log instead of github.com/go-kit/kit/log" 63 | - pkg: "io/ioutil" 64 | desc: "Use corresponding 'os' or 'io' functions instead." 65 | - pkg: "regexp" 66 | desc: "Use github.com/grafana/regexp instead of regexp" 67 | errcheck: 68 | exclude-functions: 69 | # Don't flag lines such as "io.Copy(io.Discard, resp.Body)". 70 | - io.Copy 71 | # The next two are used in HTTP handlers, any error is handled by the server itself. 72 | - io.WriteString 73 | - (net/http.ResponseWriter).Write 74 | # No need to check for errors on server's shutdown. 75 | - (*net/http.Server).Shutdown 76 | # Never check for logger errors. 77 | - (github.com/go-kit/log.Logger).Log 78 | # Never check for rollback errors as Rollback() is called when a previous error was detected. 79 | - (github.com/prometheus/prometheus/storage.Appender).Rollback 80 | exhaustive: 81 | default-signifies-exhaustive: true 82 | goimports: 83 | local-prefixes: github.com/prometheus/prometheus 84 | gofumpt: 85 | extra-rules: true 86 | revive: 87 | rules: 88 | # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#unused-parameter 89 | - name: unused-parameter 90 | severity: warning 91 | disabled: true -------------------------------------------------------------------------------- /internal/server/api/promcompat/api.go: -------------------------------------------------------------------------------- 1 | package promcompat 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | "github.com/prometheus/common/model" 9 | "github.com/rs/zerolog" 10 | "github.com/sinkingpoint/kiora/internal/server/api" 11 | kmodel "github.com/sinkingpoint/kiora/lib/kiora/model" 12 | ) 13 | 14 | func Register(router *mux.Router, api api.API, logger zerolog.Logger) { 15 | promCompat := New(api, logger) 16 | 17 | subRouter := router.PathPrefix("/api/prom-compat").Subrouter() 18 | 19 | subRouter.Path("/api/v2/alerts").Methods(http.MethodPost).HandlerFunc(promCompat.PostAlerts) 20 | } 21 | 22 | // promCompat provides an API that is able to ingest alerts from Prometheus. 23 | type promCompat struct { 24 | api api.API 25 | logger zerolog.Logger 26 | } 27 | 28 | func New(api api.API, logger zerolog.Logger) *promCompat { 29 | return &promCompat{ 30 | api: api, 31 | logger: logger.With().Str("component", "promcompat").Logger(), 32 | } 33 | } 34 | 35 | // PostAlerts handles the POST /api/v2/alerts request, decoding a list of Prometheus alerts, 36 | // converting them to Kiora alerts, and forwarding them to the db. 37 | func (p *promCompat) PostAlerts(w http.ResponseWriter, r *http.Request) { 38 | promAlerts := []model.Alert{} 39 | decoder := json.NewDecoder(r.Body) 40 | decoder.DisallowUnknownFields() 41 | if err := decoder.Decode(&promAlerts); err != nil { 42 | http.Error(w, "failed to decode body", http.StatusBadRequest) 43 | return 44 | } 45 | 46 | alerts := make([]kmodel.Alert, len(promAlerts)) 47 | for i, promAlert := range promAlerts { 48 | alert, err := marshalPromAlertToKioraAlert(promAlert) 49 | if err != nil { 50 | http.Error(w, "failed to unmarshal prometheus alert", http.StatusBadRequest) 51 | return 52 | } 53 | alerts[i] = alert 54 | } 55 | 56 | if err := p.api.PostAlerts(r.Context(), alerts); err != nil { 57 | http.Error(w, "failed to post alerts", http.StatusInternalServerError) 58 | p.logger.Error().Err(err).Msg("failed to post alerts") 59 | return 60 | } 61 | 62 | w.WriteHeader(http.StatusAccepted) 63 | } 64 | 65 | // marshalPromAlertToKioraAlert converts a Prometheus alert to a Kiora alert. 66 | func marshalPromAlertToKioraAlert(p model.Alert) (kmodel.Alert, error) { 67 | labels := kmodel.Labels{} 68 | for k, v := range p.Labels { 69 | labels[string(k)] = string(v) 70 | } 71 | 72 | annotations := map[string]string{} 73 | for k, v := range p.Annotations { 74 | annotations[string(k)] = string(v) 75 | } 76 | 77 | alert := kmodel.Alert{ 78 | Status: kmodel.AlertStatus(p.Status()), 79 | Labels: labels, 80 | Annotations: annotations, 81 | StartTime: p.StartsAt, 82 | EndTime: p.EndsAt, 83 | } 84 | 85 | return alert, alert.Materialise() 86 | } 87 | -------------------------------------------------------------------------------- /lib/kiora/config/globals.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "net/http" 5 | "text/template" 6 | 7 | "github.com/rs/zerolog" 8 | ) 9 | 10 | type HTTPClientOpt interface{} 11 | 12 | // Globals is the interface that nodes get passed to give them access to 13 | // things they may need to function, like a logger, or an HTTP client. 14 | type Globals struct { 15 | httpClient *http.Client 16 | logger zerolog.Logger 17 | templates *template.Template 18 | 19 | Tenanter Tenanter 20 | } 21 | 22 | // GlobalsOpt is the interface that can be passed to NewGlobals to configure 23 | // the globals object. 24 | type GlobalsOpt func(*Globals) 25 | 26 | // WithHTTPClient sets the HTTP client that will be returned by HTTPClient. 27 | func WithHTTPClient(client *http.Client) GlobalsOpt { 28 | return func(g *Globals) { 29 | g.httpClient = client 30 | } 31 | } 32 | 33 | // WithLogger sets the logger that will be returned by Logger. 34 | func WithLogger(logger zerolog.Logger) GlobalsOpt { 35 | return func(g *Globals) { 36 | g.logger = logger 37 | } 38 | } 39 | 40 | // WithTemplates sets the templates that will be returned by Template. 41 | func WithTemplates(templates *template.Template) GlobalsOpt { 42 | return func(g *Globals) { 43 | g.templates = templates 44 | } 45 | } 46 | 47 | // WithTenanter sets the tenanter that will be returned by Tenanter. 48 | func WithTenanter(t Tenanter) GlobalsOpt { 49 | return func(g *Globals) { 50 | g.Tenanter = t 51 | } 52 | } 53 | 54 | // NewGlobals creates a new Globals object with the given options. 55 | func NewGlobals(opts ...GlobalsOpt) *Globals { 56 | g := &Globals{ 57 | httpClient: http.DefaultClient, 58 | logger: zerolog.Nop(), 59 | templates: template.New(""), 60 | } 61 | for _, opt := range opts { 62 | opt(g) 63 | } 64 | 65 | if g.Tenanter == nil { 66 | g.Tenanter = NewStaticTenanter("") 67 | } 68 | 69 | return g 70 | } 71 | 72 | // HTTPClient returns an HTTP client that can be used to make HTTP requests. 73 | // The returned client should be configured with the given options. 74 | func (g *Globals) HTTPClient(opts ...HTTPClientOpt) *http.Client { 75 | return g.httpClient 76 | } 77 | 78 | // Logger returns a logger that can be used to log messages. 79 | func (g *Globals) Logger(component string) zerolog.Logger { 80 | return g.logger.With().Str("component", component).Logger() 81 | } 82 | 83 | // Template returns a template that can be used to render templates. 84 | func (g *Globals) Template(name string) *template.Template { 85 | return g.templates.Lookup(name) 86 | } 87 | 88 | // RegisterTemplate registers a template with the bus that other nodes can reference. 89 | func (g *Globals) RegisterTemplate(name string, tmpl *template.Template) error { 90 | _, err := g.templates.AddParseTree(name, tmpl.Tree) 91 | return err 92 | } 93 | -------------------------------------------------------------------------------- /frontend/src/routes/view-silence/index.tsx: -------------------------------------------------------------------------------- 1 | import { h, Fragment } from "preact"; 2 | import { DefaultService, Silence } from "../../api"; 3 | import { useState } from "preact/hooks"; 4 | import Loader from "../../components/loader"; 5 | import { formatDate, formatDuration } from "../../utils/date"; 6 | 7 | export interface ViewSilenceProps { 8 | id: string; 9 | } 10 | 11 | export interface ViewSilenceState { 12 | silence?: Silence; 13 | error?: string; 14 | } 15 | 16 | const silenceView = (silence: Silence) => { 17 | const startTime = new Date(silence.startsAt); 18 | let secondsSinceStart = Math.floor((Date.now() - startTime.getTime()) / 1000); 19 | const hasntStartedYet = secondsSinceStart < 0; 20 | if (hasntStartedYet) { 21 | secondsSinceStart *= -1; 22 | } 23 | 24 | const endTime = new Date(silence.endsAt); 25 | let secondsTillEnd = Math.floor((endTime.getTime() - Date.now()) / 1000); 26 | const hasEnded = secondsTillEnd < 0; 27 | if (hasEnded) { 28 | secondsTillEnd *= -1; 29 | } 30 | 31 | const startString = hasntStartedYet 32 | ? `In ${formatDuration(secondsSinceStart)}` 33 | : `${formatDuration(secondsSinceStart)} Ago`; 34 | const endString = hasEnded 35 | ? `${formatDuration(secondsTillEnd)} Ago` 36 | : `In ${formatDuration(secondsTillEnd)}`; 37 | 38 | return ( 39 |
40 |

Silence {silence.id}

41 |
42 |

Creator

43 |

{silence.creator}

44 |
45 | 46 |
47 |

Comment

48 |

{silence.comment}

49 |
50 | 51 |
52 |

Started at

53 |

54 | {formatDate(startTime)} ({startString}) 55 |

56 |
57 | 58 |
59 |

Ends at

60 |

61 | {formatDate(endTime)} ({endString}) 62 |

63 |
64 |
65 | ); 66 | }; 67 | 68 | const errorView = (error: string) => { 69 | return ( 70 |
71 |

Error

72 |

{error}

73 |
74 | ); 75 | }; 76 | 77 | const ViewSilence = ({ id }: ViewSilenceProps) => { 78 | const [silence, setSilence] = useState({}); 79 | 80 | const fetchSilence = () => { 81 | DefaultService.getSilences({ matchers: [`__id__=${id}`] }).then((response) => { 82 | if (response.length == 0) { 83 | setSilence({ 84 | error: "Silence not found", 85 | }); 86 | 87 | return; 88 | } 89 | 90 | setSilence({ 91 | silence: response[0], 92 | }); 93 | }); 94 | }; 95 | 96 | return ( 97 |
98 | 99 | <>{silence?.silence ? silenceView(silence.silence) : errorView(silence.error)} 100 | 101 |
102 | ); 103 | }; 104 | 105 | export default ViewSilence; 106 | -------------------------------------------------------------------------------- /frontend/src/routes/home/index.tsx: -------------------------------------------------------------------------------- 1 | import { h, Fragment } from "preact"; 2 | import { useEffect, useState } from "preact/hooks"; 3 | import { DefaultService } from "../../api"; 4 | import AlertList from "../../components/alertlist"; 5 | import SingleStatPanel from "../../components/stats/single_stat_panel"; 6 | import styles from "./styles.css"; 7 | import Loader from "../../components/loader"; 8 | 9 | interface StatsState { 10 | firingAlerts: number; 11 | silencedAlerts: number; 12 | ackedAlerts: number; 13 | resolvedAlerts: number; 14 | timedOutAlerts: number; 15 | loading: boolean; 16 | error?: string; 17 | } 18 | 19 | // StatsRow is a component that displays a row of stats about the alerts in the system, breaking down alerts by their state. 20 | const StatsRow = () => { 21 | const [stats, setStats] = useState({ 22 | firingAlerts: 0, 23 | silencedAlerts: 0, 24 | ackedAlerts: 0, 25 | resolvedAlerts: 0, 26 | timedOutAlerts: 0, 27 | loading: true, 28 | }); 29 | 30 | const fetchStats = async () => { 31 | await DefaultService.getAlertsStats({ type: "status_count" }) 32 | .then((result) => { 33 | const newStats = { 34 | ...stats, 35 | loading: false, 36 | }; 37 | 38 | result.forEach((stat) => { 39 | if (stat.labels.status === "firing") { 40 | newStats.firingAlerts = stat.frames[0][0]; 41 | } else if (stat.labels.status === "silenced") { 42 | newStats.silencedAlerts = stat.frames[0][0]; 43 | } else if (stat.labels.status === "acked") { 44 | newStats.ackedAlerts = stat.frames[0][0]; 45 | } else if (stat.labels.status === "resolved") { 46 | newStats.resolvedAlerts = stat.frames[0][0]; 47 | } else if (stat.labels.status === "timed out") { 48 | newStats.timedOutAlerts = stat.frames[0][0]; 49 | } 50 | }); 51 | 52 | setStats(newStats); 53 | }) 54 | .catch((error) => { 55 | setStats({ 56 | ...stats, 57 | error: error.toString(), 58 | loading: false, 59 | }); 60 | }); 61 | }; 62 | 63 | let content: JSX.Element; 64 | 65 | if (stats.error) { 66 | content =
{stats.error}
; 67 | } else { 68 | content = ( 69 |
70 | 71 | 72 | 73 | 74 | 75 |
76 | ); 77 | } 78 | 79 | return {content}; 80 | }; 81 | 82 | const Home = () => { 83 | return ( 84 | <> 85 | 86 |
87 | 88 |
89 | 90 | ); 91 | }; 92 | 93 | export default Home; 94 | -------------------------------------------------------------------------------- /mocks/mock_services/bus.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: ./internal/services/bus.go 3 | 4 | // Package mock_services is a generated GoMock package. 5 | package mock_services 6 | 7 | import ( 8 | reflect "reflect" 9 | 10 | gomock "github.com/golang/mock/gomock" 11 | zerolog "github.com/rs/zerolog" 12 | clustering "github.com/sinkingpoint/kiora/internal/clustering" 13 | config "github.com/sinkingpoint/kiora/lib/kiora/config" 14 | kioradb "github.com/sinkingpoint/kiora/lib/kiora/kioradb" 15 | ) 16 | 17 | // MockBus is a mock of Bus interface. 18 | type MockBus struct { 19 | ctrl *gomock.Controller 20 | recorder *MockBusMockRecorder 21 | } 22 | 23 | // MockBusMockRecorder is the mock recorder for MockBus. 24 | type MockBusMockRecorder struct { 25 | mock *MockBus 26 | } 27 | 28 | // NewMockBus creates a new mock instance. 29 | func NewMockBus(ctrl *gomock.Controller) *MockBus { 30 | mock := &MockBus{ctrl: ctrl} 31 | mock.recorder = &MockBusMockRecorder{mock} 32 | return mock 33 | } 34 | 35 | // EXPECT returns an object that allows the caller to indicate expected use. 36 | func (m *MockBus) EXPECT() *MockBusMockRecorder { 37 | return m.recorder 38 | } 39 | 40 | // Broadcaster mocks base method. 41 | func (m *MockBus) Broadcaster() clustering.Broadcaster { 42 | m.ctrl.T.Helper() 43 | ret := m.ctrl.Call(m, "Broadcaster") 44 | ret0, _ := ret[0].(clustering.Broadcaster) 45 | return ret0 46 | } 47 | 48 | // Broadcaster indicates an expected call of Broadcaster. 49 | func (mr *MockBusMockRecorder) Broadcaster() *gomock.Call { 50 | mr.mock.ctrl.T.Helper() 51 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Broadcaster", reflect.TypeOf((*MockBus)(nil).Broadcaster)) 52 | } 53 | 54 | // Config mocks base method. 55 | func (m *MockBus) Config() config.Config { 56 | m.ctrl.T.Helper() 57 | ret := m.ctrl.Call(m, "Config") 58 | ret0, _ := ret[0].(config.Config) 59 | return ret0 60 | } 61 | 62 | // Config indicates an expected call of Config. 63 | func (mr *MockBusMockRecorder) Config() *gomock.Call { 64 | mr.mock.ctrl.T.Helper() 65 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Config", reflect.TypeOf((*MockBus)(nil).Config)) 66 | } 67 | 68 | // DB mocks base method. 69 | func (m *MockBus) DB() kioradb.DB { 70 | m.ctrl.T.Helper() 71 | ret := m.ctrl.Call(m, "DB") 72 | ret0, _ := ret[0].(kioradb.DB) 73 | return ret0 74 | } 75 | 76 | // DB indicates an expected call of DB. 77 | func (mr *MockBusMockRecorder) DB() *gomock.Call { 78 | mr.mock.ctrl.T.Helper() 79 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DB", reflect.TypeOf((*MockBus)(nil).DB)) 80 | } 81 | 82 | // Logger mocks base method. 83 | func (m *MockBus) Logger(serviceName string) *zerolog.Logger { 84 | m.ctrl.T.Helper() 85 | ret := m.ctrl.Call(m, "Logger", serviceName) 86 | ret0, _ := ret[0].(*zerolog.Logger) 87 | return ret0 88 | } 89 | 90 | // Logger indicates an expected call of Logger. 91 | func (mr *MockBusMockRecorder) Logger(serviceName interface{}) *gomock.Call { 92 | mr.mock.ctrl.T.Helper() 93 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Logger", reflect.TypeOf((*MockBus)(nil).Logger), serviceName) 94 | } 95 | -------------------------------------------------------------------------------- /lib/kiora/kioradb/inmemory.go: -------------------------------------------------------------------------------- 1 | package kioradb 2 | 3 | import ( 4 | "context" 5 | "sort" 6 | "sync" 7 | 8 | "github.com/sinkingpoint/kiora/lib/kiora/kioradb/query" 9 | "github.com/sinkingpoint/kiora/lib/kiora/model" 10 | ) 11 | 12 | var _ DB = &inMemoryDB{} 13 | 14 | // inMemoryDB is a DB that does not persist anything, just storing all data in memory. 15 | type inMemoryDB struct { 16 | aLock sync.RWMutex 17 | alerts map[model.LabelsHash]model.Alert 18 | 19 | sLock sync.RWMutex 20 | silences map[string]model.Silence 21 | } 22 | 23 | func NewInMemoryDB() *inMemoryDB { 24 | return &inMemoryDB{ 25 | aLock: sync.RWMutex{}, 26 | alerts: make(map[model.LabelsHash]model.Alert), 27 | 28 | sLock: sync.RWMutex{}, 29 | silences: make(map[string]model.Silence), 30 | } 31 | } 32 | 33 | func (m *inMemoryDB) Clear() { 34 | m.aLock.Lock() 35 | defer m.aLock.Unlock() 36 | m.alerts = make(map[model.LabelsHash]model.Alert) 37 | 38 | m.sLock.Lock() 39 | defer m.sLock.Unlock() 40 | m.silences = make(map[string]model.Silence) 41 | } 42 | 43 | func (m *inMemoryDB) storeAlert(alert model.Alert) { 44 | labelsHash := alert.Labels.Hash() 45 | 46 | m.aLock.Lock() 47 | defer m.aLock.Unlock() 48 | m.alerts[labelsHash] = alert 49 | } 50 | 51 | func (m *inMemoryDB) StoreAlerts(ctx context.Context, alerts ...model.Alert) error { 52 | for i := range alerts { 53 | m.storeAlert(alerts[i]) 54 | } 55 | 56 | return nil 57 | } 58 | 59 | func (m *inMemoryDB) QueryAlerts(ctx context.Context, q query.AlertQuery) []model.Alert { 60 | m.aLock.RLock() 61 | defer m.aLock.RUnlock() 62 | switch filter := q.Filter.(type) { 63 | // Short circuit exact matches because we can process them more efficiently by just looking up the hash. 64 | case *query.ExactLabelMatchFilter: 65 | if existingAlert, ok := m.alerts[filter.Labels.Hash()]; ok { 66 | return []model.Alert{existingAlert} 67 | } 68 | 69 | return []model.Alert{} 70 | default: 71 | alerts := []model.Alert{} 72 | for _, alert := range m.alerts { 73 | if filter == nil || filter.MatchesAlert(ctx, &alert) { 74 | alerts = append(alerts, alert) 75 | } 76 | } 77 | 78 | sort.Stable(query.SortAlertsByFields(alerts, q.OrderBy, q.Order)) 79 | if q.Limit > 0 && len(alerts) > q.Limit { 80 | alerts = alerts[:q.Limit] 81 | } 82 | 83 | return alerts 84 | } 85 | } 86 | 87 | func (m *inMemoryDB) StoreSilences(ctx context.Context, silences ...model.Silence) error { 88 | m.sLock.Lock() 89 | defer m.sLock.Unlock() 90 | for i := range silences { 91 | m.silences[silences[i].ID] = silences[i] 92 | } 93 | 94 | return nil 95 | } 96 | 97 | func (m *inMemoryDB) QuerySilences(ctx context.Context, query query.SilenceQuery) []model.Silence { 98 | m.sLock.RLock() 99 | defer m.sLock.RUnlock() 100 | silences := []model.Silence{} 101 | for _, silence := range m.silences { 102 | if query.Filter.MatchesSilence(ctx, &silence) { 103 | silences = append(silences, silence) 104 | } 105 | } 106 | 107 | return silences 108 | } 109 | 110 | func (m *inMemoryDB) Close() error { 111 | return nil 112 | } 113 | -------------------------------------------------------------------------------- /lib/kiora/model/silence.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "time" 7 | 8 | "github.com/google/uuid" 9 | "github.com/pkg/errors" 10 | "github.com/sinkingpoint/kiora/internal/stubs" 11 | ) 12 | 13 | type Silence struct { 14 | // ID is the unique identifier of the silence. 15 | ID string `json:"id"` 16 | 17 | // By is the user that created the silence. 18 | Creator string `json:"creator"` 19 | 20 | // Comment is a comment about the silence. 21 | Comment string `json:"comment"` 22 | 23 | // StartTime is the time at which the silence starts. 24 | StartTime time.Time `json:"startsAt"` 25 | 26 | // EndTime is the time at which the silence ends. 27 | EndTime time.Time `json:"endsAt"` 28 | 29 | // Matchers is a list of matchers that must all match an alert for it to be silenced. 30 | Matchers []Matcher `json:"matchers"` 31 | } 32 | 33 | func (s *Silence) validate() error { 34 | if s.StartTime.IsZero() { 35 | return errors.New("silence is missing a start time") 36 | } 37 | 38 | if !s.EndTime.IsZero() && s.EndTime.Before(s.StartTime) { 39 | return errors.New("end time is before start time") 40 | } 41 | 42 | // NOTE: this precludes the ability for a silence to match all alerts, which might be a valid use case. 43 | // But if you get here trying to do that, please don't. 44 | if len(s.Matchers) == 0 { 45 | return errors.New("silence must have at least one matcher") 46 | } 47 | 48 | return nil 49 | } 50 | 51 | func NewSilence(creator, comment string, matchers []Matcher, startTime, endTime time.Time) (Silence, error) { 52 | silence := Silence{ 53 | ID: uuid.New().String(), 54 | Creator: creator, 55 | Comment: comment, 56 | Matchers: matchers, 57 | StartTime: startTime, 58 | EndTime: endTime, 59 | } 60 | 61 | return silence, silence.validate() 62 | } 63 | 64 | func (s *Silence) IsActive() bool { 65 | return s.StartTime.Before(stubs.Time.Now()) && (s.EndTime.IsZero() || s.EndTime.After(stubs.Time.Now())) 66 | } 67 | 68 | func (s *Silence) Matches(l Labels) bool { 69 | for _, matcher := range s.Matchers { 70 | if !matcher.Matches(l) { 71 | return false 72 | } 73 | } 74 | 75 | return true 76 | } 77 | 78 | func (s *Silence) Fields() map[string]any { 79 | return map[string]any{ 80 | "__id__": s.ID, 81 | "__creator__": s.Creator, 82 | "__comment__": s.Comment, 83 | "__starts_at__": s.StartTime, 84 | "__ends_at__": s.EndTime, 85 | "__duration__": s.EndTime.Sub(s.StartTime), 86 | } 87 | } 88 | 89 | func (s *Silence) Field(name string) (any, error) { 90 | switch name { 91 | case "__id__": 92 | return s.ID, nil 93 | case "__creator__": 94 | return s.Creator, nil 95 | case "__comment__": 96 | return s.Comment, nil 97 | case "__starts_at__": 98 | return s.StartTime, nil 99 | case "__ends_at__": 100 | return s.EndTime, nil 101 | case "__duration__": 102 | if s.EndTime.IsZero() { 103 | return time.Duration(math.MaxInt64), nil 104 | } 105 | 106 | return s.EndTime.Sub(s.StartTime), nil 107 | } 108 | 109 | return "", fmt.Errorf("silence %q doesn't exist", name) 110 | } 111 | -------------------------------------------------------------------------------- /lib/kiora/config/notifiers/filenotifier/notifier.go: -------------------------------------------------------------------------------- 1 | package filenotifier 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | 9 | "github.com/pkg/errors" 10 | 11 | "github.com/hashicorp/go-multierror" 12 | "github.com/sinkingpoint/kiora/internal/encoding" 13 | "github.com/sinkingpoint/kiora/lib/kiora/config" 14 | "github.com/sinkingpoint/kiora/lib/kiora/model" 15 | "go.opentelemetry.io/otel" 16 | ) 17 | 18 | func init() { 19 | config.RegisterNode(STDOUT_NODE_NAME, New) 20 | config.RegisterNode(STDERR_NODE_NAME, New) 21 | config.RegisterNode(FILE_NODE_NAME, New) 22 | } 23 | 24 | const ( 25 | STDOUT_NODE_NAME = "stdout" 26 | STDERR_NODE_NAME = "stderr" 27 | FILE_NODE_NAME = "file" 28 | DEFAULT_ENCODING = "json" 29 | ) 30 | 31 | var _ = config.Notifier(&FileNotifier{}) 32 | 33 | // FileNotifier represents a node that can output alerts to a Writer. 34 | type FileNotifier struct { 35 | name config.NotifierName 36 | encoder encoding.Encoder 37 | file io.WriteCloser 38 | } 39 | 40 | func New(name string, globals *config.Globals, attrs map[string]string) (config.Node, error) { 41 | encodingName := DEFAULT_ENCODING 42 | if enc, ok := attrs["encoding"]; ok { 43 | encodingName = enc 44 | } 45 | 46 | encoder := encoding.LookupEncoding(encodingName) 47 | if encoder == nil { 48 | return nil, fmt.Errorf("invalid encoding: %q", encodingName) 49 | } 50 | 51 | switch attrs["type"] { 52 | case "stdout": 53 | return &FileNotifier{ 54 | name: config.NotifierName(name), 55 | encoder: encoder, 56 | file: os.Stdout, 57 | }, nil 58 | case "stderr": 59 | return &FileNotifier{ 60 | name: config.NotifierName(name), 61 | encoder: encoder, 62 | file: os.Stderr, 63 | }, nil 64 | case "", "file": 65 | fileName := attrs["path"] 66 | if fileName == "" { 67 | return nil, errors.New("missing `path` in file node") 68 | } 69 | 70 | file, err := os.OpenFile(fileName, os.O_CREATE|os.O_APPEND|os.O_RDWR, 0o644) 71 | if err != nil { 72 | return nil, fmt.Errorf("failed to open file %q in file node: %w", fileName, err) 73 | } 74 | 75 | return &FileNotifier{ 76 | name: config.NotifierName(name), 77 | encoder: encoder, 78 | file: file, 79 | }, nil 80 | default: 81 | return nil, fmt.Errorf("invalid type for file node: %q", attrs["type"]) 82 | } 83 | } 84 | 85 | func (f *FileNotifier) Name() config.NotifierName { 86 | return f.name 87 | } 88 | 89 | func (f *FileNotifier) Type() string { 90 | return "file" 91 | } 92 | 93 | func (f *FileNotifier) Notify(ctx context.Context, alerts ...model.Alert) *config.NotificationError { 94 | _, span := otel.Tracer("").Start(ctx, "FileNotifierNode.Notify") 95 | defer span.End() 96 | 97 | var lastError error 98 | for _, alert := range alerts { 99 | bytes, err := f.encoder.Marshal(alert) 100 | if err != nil { 101 | lastError = multierror.Append(lastError, err) 102 | continue 103 | } 104 | 105 | bytes = append(bytes, '\n') 106 | 107 | if _, err := f.file.Write(bytes); err != nil { 108 | lastError = multierror.Append(lastError, err) 109 | } 110 | } 111 | 112 | if lastError != nil { 113 | return config.NewNotificationError(lastError, false) 114 | } 115 | 116 | return nil 117 | } 118 | -------------------------------------------------------------------------------- /lib/kiora/config/unmarshal/unmarshal_test.go: -------------------------------------------------------------------------------- 1 | package unmarshal_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sinkingpoint/kiora/lib/kiora/config/unmarshal" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestUnmarshalConfig(t *testing.T) { 11 | data := map[string]string{ 12 | "field1": "value1", 13 | "field2": "42", 14 | "field3": "3.14", 15 | "field4": "true", 16 | "field5": "value1,value2,value3", 17 | "field6": "foo", 18 | } 19 | 20 | type Config struct { 21 | Field1 string `config:"field1"` 22 | Field2 int `config:"field2"` 23 | Field3 float64 `config:"field3"` 24 | Field4 bool `config:"field4"` 25 | Field5 []string `config:"field5"` 26 | Field6 unmarshal.MaybeSecretFile `config:"field6"` 27 | Field7 *unmarshal.MaybeFile `config:"field6"` 28 | } 29 | 30 | file, err := unmarshal.NewMaybeFile("", "foo") 31 | require.NoError(t, err) 32 | 33 | secretFile, err := unmarshal.NewMaybeSecretFile("", "foo") 34 | require.NoError(t, err) 35 | 36 | expected := Config{ 37 | Field1: "value1", 38 | Field2: 42, 39 | Field3: 3.14, 40 | Field4: true, 41 | Field5: []string{"value1", "value2", "value3"}, 42 | Field6: *secretFile, 43 | Field7: file, 44 | } 45 | 46 | var config Config 47 | require.NoError(t, unmarshal.UnmarshalConfig(data, &config, unmarshal.UnmarshalOpts{}), "Unexpected error") 48 | 49 | require.Equal(t, expected, config, "Incorrect unmarshalled config") 50 | } 51 | 52 | func TestUnmarshalConfig_MissingRequiredField(t *testing.T) { 53 | data := map[string]string{ 54 | "field1": "value1", 55 | "field2": "42", 56 | } 57 | 58 | type Config struct { 59 | Field1 string `config:"field1" required:"true"` 60 | Field2 int `config:"field2" required:"true"` 61 | Field3 string `config:"field3" required:"true"` 62 | } 63 | 64 | var config Config 65 | err := unmarshal.UnmarshalConfig(data, &config, unmarshal.UnmarshalOpts{}) 66 | require.Error(t, err, "Expected error") 67 | expectedError := "UnmarshalConfig: field Field3 is required but not found in the config" 68 | require.EqualError(t, err, expectedError, "Incorrect error message") 69 | } 70 | 71 | func TestUnmarshalConfig_DisallowUnknownFields(t *testing.T) { 72 | data := map[string]string{ 73 | "field1": "value1", 74 | "field2": "42", 75 | "unexpected": "true", 76 | } 77 | 78 | type Config struct { 79 | Field1 string `config:"field1" required:"true"` 80 | Field2 int `config:"field2" required:"true"` 81 | } 82 | 83 | var config Config 84 | err := unmarshal.UnmarshalConfig(data, &config, unmarshal.UnmarshalOpts{ 85 | DisallowUnknownFields: true, 86 | }) 87 | require.Error(t, err, "Expected error") 88 | } 89 | 90 | func TestUnmarshalConfig_DisallowBothFileAndLiteral(t *testing.T) { 91 | data := map[string]string{ 92 | "field1": "value1", 93 | "field1_file": "./foo.txt", 94 | } 95 | 96 | type Config struct { 97 | Field1 *unmarshal.MaybeFile `config:"field1" required:"true"` 98 | } 99 | 100 | var config Config 101 | err := unmarshal.UnmarshalConfig(data, &config, unmarshal.UnmarshalOpts{}) 102 | require.Error(t, err, "Expected error") 103 | } 104 | -------------------------------------------------------------------------------- /frontend/src/routes/new-silence/preview.tsx: -------------------------------------------------------------------------------- 1 | import { h, Fragment } from "preact"; 2 | import Loader from "../../components/loader"; 3 | import { Alert, DefaultService } from "../../api"; 4 | import { useState } from "react"; 5 | import AlertCard from "../../components/alertcard"; 6 | import Button from "../../components/button"; 7 | import { getSilenceEnd } from "./utils"; 8 | import { formatDate } from "../../utils/date"; 9 | import LabelMatcherCard, { parseMatcher } from "../../components/labelmatchercard"; 10 | 11 | const MaxAlertsToDisplay = 20; 12 | 13 | export interface PreviewPageProps { 14 | duration: string; 15 | matchers: string[]; 16 | creator: string; 17 | comment: string; 18 | } 19 | 20 | const CreateSilence = ({ duration, creator, comment, matchers }: PreviewPageProps) => { 21 | const startsAt = new Date().toISOString(); 22 | const endsAt = getSilenceEnd(duration).toISOString(); 23 | 24 | const modelMatchers = matchers.map((matcher) => parseMatcher(matcher)); 25 | 26 | DefaultService.postSilences({ 27 | requestBody: { 28 | id: "", 29 | startsAt, 30 | endsAt, 31 | matchers: modelMatchers, 32 | creator, 33 | comment, 34 | }, 35 | }).then((response) => { 36 | window.location.href = `/silences/${response.id}`; 37 | }); 38 | }; 39 | 40 | const PreviewPage = ({ duration, creator, comment, matchers }: PreviewPageProps) => { 41 | const [alerts, setAlerts] = useState([]); 42 | const fetchAffectedAlerts = () => { 43 | DefaultService.getAlerts({ matchers }).then((alerts) => { 44 | setAlerts(alerts); 45 | }); 46 | }; 47 | 48 | const endDate = getSilenceEnd(duration); 49 | const end = 50 | endDate !== null ? Ends at {formatDate(endDate)} : Invalid duration; 51 | 52 | const filterSpans = matchers.map((filter) => { 53 | return ; 54 | }); 55 | 56 | return ( 57 | <> 58 |
59 | 60 |
61 | 62 |
63 | {duration} {end} 64 |
65 | 66 |
67 | 68 |
69 |
{filterSpans}
70 | 71 |
72 | 73 |
74 |
{creator}
75 | 76 |
77 | 78 |
79 |
{creator}
80 | 81 |
82 |
89 | 90 | 91 | <> 92 |
93 |

94 | {alerts.length} affected alert{alerts.length != 1 && "s"} 95 |

96 |
97 |
98 | {alerts.slice(0, MaxAlertsToDisplay).map((alert) => ( 99 | 100 | ))} 101 | {alerts.length > MaxAlertsToDisplay && ( 102 |
...and {alerts.length - MaxAlertsToDisplay} more
103 | )} 104 |
105 | 106 |
107 | 108 | ); 109 | }; 110 | 111 | export default PreviewPage; 112 | -------------------------------------------------------------------------------- /lib/kiora/config/notifiers/slack/notifier.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "net/http" 8 | "strings" 9 | "text/template" 10 | 11 | "github.com/pkg/errors" 12 | "github.com/rs/zerolog/log" 13 | "github.com/sinkingpoint/kiora/lib/kiora/config" 14 | "github.com/sinkingpoint/kiora/lib/kiora/config/unmarshal" 15 | "github.com/sinkingpoint/kiora/lib/kiora/model" 16 | ) 17 | 18 | var DefaultSlackTemplate *template.Template 19 | 20 | func init() { 21 | tmpl, err := template.New("slack").Parse(`[FIRING: {{ len . }}] {{ (index . 0).Labels.alertname }}`) 22 | if err != nil { 23 | log.Fatal().Err(err).Msg("failed to parse default slack template") 24 | } 25 | 26 | DefaultSlackTemplate = tmpl 27 | 28 | config.RegisterNode("slack", New) 29 | } 30 | 31 | type slackPayload struct { 32 | Text string `json:"text"` 33 | } 34 | 35 | // SlackNotifier is a notifier that sends alerts to a slack channel. 36 | type SlackNotifier struct { 37 | name config.NotifierName 38 | globals *config.Globals 39 | client *http.Client 40 | 41 | apiURL *unmarshal.MaybeSecretFile 42 | } 43 | 44 | func New(name string, globals *config.Globals, attrs map[string]string) (config.Node, error) { 45 | rawNode := struct { 46 | ApiURL *unmarshal.MaybeSecretFile `config:"api_url" required:"true"` 47 | TemplateFile *unmarshal.MaybeFile `config:"template_file"` 48 | }{} 49 | 50 | if err := unmarshal.UnmarshalConfig(attrs, rawNode, unmarshal.UnmarshalOpts{ 51 | DisallowUnknownFields: true, 52 | }); err != nil { 53 | return nil, errors.Wrap(err, "failed to unmarshal config") 54 | } 55 | 56 | if err := globals.RegisterTemplate("slack", DefaultSlackTemplate); err != nil { 57 | return nil, err 58 | } 59 | 60 | return &SlackNotifier{ 61 | name: config.NotifierName(name), 62 | globals: globals, 63 | client: globals.HTTPClient(), 64 | 65 | apiURL: rawNode.ApiURL, 66 | }, nil 67 | } 68 | 69 | func (s *SlackNotifier) Name() config.NotifierName { 70 | return s.name 71 | } 72 | 73 | func (s *SlackNotifier) Type() string { 74 | return "slack" 75 | } 76 | 77 | func (s *SlackNotifier) Notify(ctx context.Context, alerts ...model.Alert) *config.NotificationError { 78 | tmpl := s.globals.Template("slack") 79 | writer := strings.Builder{} 80 | if err := tmpl.Execute(&writer, alerts); err != nil { 81 | return &config.NotificationError{ 82 | Err: err, 83 | Retryable: false, 84 | } 85 | } 86 | 87 | payload := slackPayload{ 88 | Text: writer.String(), 89 | } 90 | 91 | payloadBytes, err := json.Marshal(payload) 92 | if err != nil { 93 | return &config.NotificationError{ 94 | Err: err, 95 | Retryable: false, 96 | } 97 | } 98 | 99 | request, err := http.NewRequest(http.MethodPost, string(s.apiURL.Value()), bytes.NewBuffer(payloadBytes)) 100 | if err != nil { 101 | return &config.NotificationError{ 102 | Err: err, 103 | Retryable: false, 104 | } 105 | } 106 | 107 | resp, err := s.client.Do(request) 108 | if err != nil { 109 | return &config.NotificationError{ 110 | Err: err, 111 | Retryable: true, 112 | } 113 | } 114 | 115 | defer resp.Body.Close() 116 | 117 | if resp.StatusCode != http.StatusOK { 118 | return &config.NotificationError{ 119 | Err: errors.Errorf("unexpected status code: %d", resp.StatusCode), 120 | Retryable: true, 121 | } 122 | } 123 | 124 | return nil 125 | } 126 | -------------------------------------------------------------------------------- /lib/kiora/config/filters/ratelimit/filter.go: -------------------------------------------------------------------------------- 1 | package ratelimit 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "time" 8 | 9 | "github.com/pkg/errors" 10 | "github.com/sinkingpoint/kiora/internal/stubs" 11 | "github.com/sinkingpoint/kiora/lib/kiora/config" 12 | "github.com/sinkingpoint/kiora/lib/kiora/config/unmarshal" 13 | ) 14 | 15 | // NewFilter implements config.FilterFactory. 16 | func NewFilter(globals *config.Globals, attrs map[string]string) (config.Filter, error) { 17 | delete(attrs, "type") 18 | rateLimitFilter := RateLimitFilter{ 19 | globals: globals, 20 | } 21 | 22 | if err := unmarshal.UnmarshalConfig(attrs, &rateLimitFilter, unmarshal.UnmarshalOpts{DisallowUnknownFields: true}); err != nil { 23 | return nil, errors.Wrap(err, "failed to unmarshal rate limit filter") 24 | } 25 | 26 | // If burst is not set, default to rate. 27 | if rateLimitFilter.Burst == 0 { 28 | rateLimitFilter.Burst = rateLimitFilter.Rate 29 | } 30 | 31 | return &rateLimitFilter, nil 32 | } 33 | 34 | // RateLimitFilter implements a tenant-aware rate limit filter. 35 | type RateLimitFilter struct { 36 | globals *config.Globals 37 | 38 | // Interval is the time interval over which the rate limit applies. 39 | Interval time.Duration `config:"interval" required:"true"` 40 | 41 | // Rate is the number of alerts allowed per interval. 42 | Rate int `config:"rate" required:"true"` 43 | 44 | // Burst is the number of alerts allowed to exceed the rate limit. 45 | Burst int `config:"burst"` 46 | 47 | buckets sync.Map 48 | } 49 | 50 | func (r *RateLimitFilter) newBucket() *ratelimitBucket { 51 | return &ratelimitBucket{ 52 | lock: sync.Mutex{}, 53 | interval: r.Interval, 54 | rate: r.Rate, 55 | burst: r.Burst, 56 | tokenCount: r.Rate, 57 | lastUpdate: time.Now(), 58 | } 59 | } 60 | 61 | // Filter implements config.Filter. 62 | func (r *RateLimitFilter) Filter(ctx context.Context, f config.Fielder) error { 63 | tenant, err := r.globals.Tenanter.GetTenant(ctx, f) 64 | if err != nil { 65 | return fmt.Errorf("failed to get tenant: %w", err) 66 | } 67 | 68 | bucket, _ := r.buckets.LoadOrStore(tenant, r.newBucket()) 69 | 70 | if !bucket.(*ratelimitBucket).consumeToken() { 71 | return fmt.Errorf("rate limit of %d per %s exceeded for tenant %s", r.Rate, r.Interval, tenant) 72 | } 73 | 74 | return nil 75 | } 76 | 77 | // Type implements config.Filter. 78 | func (*RateLimitFilter) Type() string { 79 | return "rate_limit" 80 | } 81 | 82 | // ratelimitBucket implements a token bucket rate limiter. 83 | type ratelimitBucket struct { 84 | lock sync.Mutex 85 | tokenCount int 86 | lastUpdate time.Time 87 | 88 | interval time.Duration 89 | rate int 90 | burst int 91 | } 92 | 93 | // updateCount updates the token count based on the time since the last update. 94 | func (b *ratelimitBucket) updateCount() { 95 | timeSinceLastUpdate := time.Since(b.lastUpdate) 96 | newTokens := float64(timeSinceLastUpdate) / float64(b.interval) * float64(b.rate) 97 | if newTokens > 0 { 98 | b.tokenCount += int(newTokens) 99 | b.lastUpdate = stubs.Time.Now() 100 | if b.tokenCount > b.burst { 101 | b.tokenCount = b.burst 102 | } 103 | } 104 | } 105 | 106 | func (b *ratelimitBucket) consumeToken() bool { 107 | b.lock.Lock() 108 | defer b.lock.Unlock() 109 | 110 | b.updateCount() 111 | 112 | if b.tokenCount > 0 { 113 | b.tokenCount-- 114 | return true 115 | } 116 | 117 | return false 118 | } 119 | -------------------------------------------------------------------------------- /mocks/mock_clustering/broadcaster.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: ./internal/clustering/broadcaster.go 3 | 4 | // Package mock_clustering is a generated GoMock package. 5 | package mock_clustering 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | 11 | gomock "github.com/golang/mock/gomock" 12 | model "github.com/sinkingpoint/kiora/lib/kiora/model" 13 | ) 14 | 15 | // MockBroadcaster is a mock of Broadcaster interface. 16 | type MockBroadcaster struct { 17 | ctrl *gomock.Controller 18 | recorder *MockBroadcasterMockRecorder 19 | } 20 | 21 | // MockBroadcasterMockRecorder is the mock recorder for MockBroadcaster. 22 | type MockBroadcasterMockRecorder struct { 23 | mock *MockBroadcaster 24 | } 25 | 26 | // NewMockBroadcaster creates a new mock instance. 27 | func NewMockBroadcaster(ctrl *gomock.Controller) *MockBroadcaster { 28 | mock := &MockBroadcaster{ctrl: ctrl} 29 | mock.recorder = &MockBroadcasterMockRecorder{mock} 30 | return mock 31 | } 32 | 33 | // EXPECT returns an object that allows the caller to indicate expected use. 34 | func (m *MockBroadcaster) EXPECT() *MockBroadcasterMockRecorder { 35 | return m.recorder 36 | } 37 | 38 | // BroadcastAlertAcknowledgement mocks base method. 39 | func (m *MockBroadcaster) BroadcastAlertAcknowledgement(ctx context.Context, alertID string, ack model.AlertAcknowledgement) error { 40 | m.ctrl.T.Helper() 41 | ret := m.ctrl.Call(m, "BroadcastAlertAcknowledgement", ctx, alertID, ack) 42 | ret0, _ := ret[0].(error) 43 | return ret0 44 | } 45 | 46 | // BroadcastAlertAcknowledgement indicates an expected call of BroadcastAlertAcknowledgement. 47 | func (mr *MockBroadcasterMockRecorder) BroadcastAlertAcknowledgement(ctx, alertID, ack interface{}) *gomock.Call { 48 | mr.mock.ctrl.T.Helper() 49 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BroadcastAlertAcknowledgement", reflect.TypeOf((*MockBroadcaster)(nil).BroadcastAlertAcknowledgement), ctx, alertID, ack) 50 | } 51 | 52 | // BroadcastAlerts mocks base method. 53 | func (m *MockBroadcaster) BroadcastAlerts(ctx context.Context, alerts ...model.Alert) error { 54 | m.ctrl.T.Helper() 55 | varargs := []interface{}{ctx} 56 | for _, a := range alerts { 57 | varargs = append(varargs, a) 58 | } 59 | ret := m.ctrl.Call(m, "BroadcastAlerts", varargs...) 60 | ret0, _ := ret[0].(error) 61 | return ret0 62 | } 63 | 64 | // BroadcastAlerts indicates an expected call of BroadcastAlerts. 65 | func (mr *MockBroadcasterMockRecorder) BroadcastAlerts(ctx interface{}, alerts ...interface{}) *gomock.Call { 66 | mr.mock.ctrl.T.Helper() 67 | varargs := append([]interface{}{ctx}, alerts...) 68 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BroadcastAlerts", reflect.TypeOf((*MockBroadcaster)(nil).BroadcastAlerts), varargs...) 69 | } 70 | 71 | // BroadcastSilences mocks base method. 72 | func (m *MockBroadcaster) BroadcastSilences(ctx context.Context, silences ...model.Silence) error { 73 | m.ctrl.T.Helper() 74 | varargs := []interface{}{ctx} 75 | for _, a := range silences { 76 | varargs = append(varargs, a) 77 | } 78 | ret := m.ctrl.Call(m, "BroadcastSilences", varargs...) 79 | ret0, _ := ret[0].(error) 80 | return ret0 81 | } 82 | 83 | // BroadcastSilences indicates an expected call of BroadcastSilences. 84 | func (mr *MockBroadcasterMockRecorder) BroadcastSilences(ctx interface{}, silences ...interface{}) *gomock.Call { 85 | mr.mock.ctrl.T.Helper() 86 | varargs := append([]interface{}{ctx}, silences...) 87 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BroadcastSilences", reflect.TypeOf((*MockBroadcaster)(nil).BroadcastSilences), varargs...) 88 | } 89 | -------------------------------------------------------------------------------- /lib/kiora/model/matcher.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "strings" 7 | 8 | "github.com/grafana/regexp" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | type Matcher struct { 13 | Label string `json:"label"` 14 | Value string `json:"value"` 15 | IsRegex bool `json:"isRegex"` 16 | IsNegative bool `json:"isNegative"` 17 | regex *regexp.Regexp 18 | } 19 | 20 | func LabelValueRegexMatcher(label, regex string) (Matcher, error) { 21 | r, err := regexp.Compile(regex) 22 | if err != nil { 23 | return Matcher{}, errors.Wrap(err, "failed to compile matcher regexp") 24 | } 25 | 26 | return Matcher{ 27 | Label: label, 28 | Value: regex, 29 | IsRegex: true, 30 | regex: r, 31 | }, nil 32 | } 33 | 34 | func LabelValueEqualMatcher(label, value string) Matcher { 35 | return Matcher{ 36 | Label: label, 37 | Value: value, 38 | } 39 | } 40 | 41 | func (m *Matcher) Negate() *Matcher { 42 | m.IsNegative = !m.IsNegative 43 | return m 44 | } 45 | 46 | func (m *Matcher) UnmarshalText(raw string) error { 47 | var parts []string 48 | 49 | switch { 50 | case strings.Contains(raw, "=~"): 51 | parts = strings.SplitN(raw, "=~", 2) 52 | m.IsRegex = true 53 | m.IsNegative = false 54 | case strings.Contains(raw, "!~"): 55 | parts = strings.SplitN(raw, "!~", 2) 56 | m.IsRegex = true 57 | m.IsNegative = true 58 | case strings.Contains(raw, "!="): 59 | parts = strings.SplitN(raw, "!=", 2) 60 | m.IsRegex = false 61 | m.IsNegative = true 62 | default: 63 | parts = strings.SplitN(raw, "=", 2) 64 | m.IsRegex = false 65 | m.IsNegative = false 66 | } 67 | 68 | if len(parts) != 2 { 69 | return errors.New("invalid matcher") 70 | } 71 | 72 | // Matchers can be optionally quoted. If they are, we need to remove the quotes and unescape the string. 73 | if strings.HasPrefix(parts[1], "\"") && strings.HasSuffix(parts[1], "\"") { 74 | parts[1] = parts[1][1 : len(parts[1])-1] 75 | parts[1] = strings.ReplaceAll(parts[1], "\\\"", "\"") 76 | } 77 | 78 | m.Label = parts[0] 79 | m.Value = parts[1] 80 | if m.IsRegex { 81 | regex, err := regexp.Compile(m.Value) 82 | if err != nil { 83 | return errors.Wrap(err, "failed to compile matcher regexp") 84 | } 85 | 86 | m.regex = regex 87 | } 88 | 89 | return nil 90 | } 91 | 92 | func (m *Matcher) UnmarshalJSON(b []byte) error { 93 | raw := struct { 94 | Label string `json:"label"` 95 | Value string `json:"value"` 96 | IsRegex bool `json:"isRegex"` 97 | IsNegative bool `json:"isNegative"` 98 | }{} 99 | 100 | d := json.NewDecoder(bytes.NewReader(b)) 101 | d.DisallowUnknownFields() 102 | if err := d.Decode(&raw); err != nil { 103 | return errors.Wrap(err, "failed to decode matcher") 104 | } 105 | 106 | m.Label = raw.Label 107 | m.Value = raw.Value 108 | m.IsRegex = raw.IsRegex 109 | m.IsNegative = raw.IsNegative 110 | 111 | if m.IsRegex { 112 | regex, err := regexp.Compile(m.Value) 113 | if err != nil { 114 | return errors.Wrap(err, "failed to compile matcher regexp") 115 | } 116 | 117 | m.regex = regex 118 | } 119 | 120 | return nil 121 | } 122 | 123 | func (m *Matcher) Matches(labels Labels) bool { 124 | if _, ok := labels[m.Label]; !ok { 125 | return false 126 | } 127 | 128 | var result bool 129 | if m.IsRegex { 130 | result = m.regex.MatchString(labels[m.Label]) 131 | } else { 132 | result = labels[m.Label] == m.Value 133 | } 134 | 135 | if m.IsNegative { 136 | return !result 137 | } else { 138 | return result 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /frontend/src/routes/alert/index.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { useEffect, useState } from "preact/hooks"; 3 | import LabelList from "../../components/alertcard/labels"; 4 | import { Alert, DefaultService } from "../../api"; 5 | import style from "./styles.css"; 6 | import Button from "../../components/button"; 7 | import Spinner from "../../components/spinner"; 8 | 9 | interface AlertState { 10 | loading: boolean; 11 | alert?: Alert; 12 | error?: string; 13 | } 14 | 15 | interface AlertProps { 16 | id: string; 17 | } 18 | 19 | interface SuccessViewProps { 20 | alert: Alert; 21 | } 22 | 23 | const SuccessView = ({ alert }: SuccessViewProps) => { 24 | const startTime = new Date(alert.startsAt); 25 | const endTime = new Date(alert.endsAt); 26 | 27 | const silenceFilters = new URLSearchParams(); 28 | for (const key of Object.keys(alert.labels)) { 29 | silenceFilters.append("filter", `${key}="${alert.labels[key]}"`); 30 | } 31 | 32 | const silenceLink = new URL("/silences/new", window.location.origin); 33 | silenceLink.search = silenceFilters.toString(); 34 | 35 | return ( 36 |
37 | 38 |

{alert.labels["alertname"] || No Alert Name}

39 |
40 | 41 | {alert.acknowledgement !== undefined && ( 42 | 43 | Acknowledged by {alert.acknowledgement.creator} 44 | 45 | )} 46 | 47 |
48 | 49 |
52 | 53 |
54 | 55 |
56 | 57 |
58 | 59 | 60 | {" "} 63 | 64 | 65 | 66 | 69 | 70 | 71 | 72 | 75 | 76 | 77 |
61 | 62 | {alert.status}
67 | 68 | {alert.id}
73 | 74 | {startTime.toLocaleString()}
78 |
79 | 80 | {endTime.getTime() > 0 && ( 81 | 82 | {endTime.toLocaleString()} 83 | 84 | )} 85 | 86 | 87 |

Annotations:

88 |
89 | {Object.keys(alert.annotations).map((key) => { 90 | return ( 91 | 92 | {alert.annotations[key]} 93 | 94 | ); 95 | })} 96 |
97 | ); 98 | }; 99 | 100 | const AlertView = ({ id }: AlertProps) => { 101 | const [state, setState] = useState({ 102 | loading: true, 103 | }); 104 | 105 | useEffect(() => { 106 | if (!state.loading) { 107 | return; 108 | } 109 | 110 | DefaultService.getAlerts({ matchers: [`__id__=${id}`] }) 111 | .then((alerts) => { 112 | if (alerts.length === 0) { 113 | return; 114 | } 115 | 116 | setState({ 117 | loading: false, 118 | alert: alerts[0], 119 | }); 120 | }) 121 | .catch((error) => { 122 | setState({ 123 | loading: false, 124 | error: error.toString(), 125 | }); 126 | }); 127 | }, [state, id]); 128 | 129 | if (state.loading) { 130 | return ; 131 | } else if (state.error) { 132 | return
{state.error}
; 133 | } else if (state.alert) { 134 | return ; 135 | } 136 | }; 137 | 138 | export default AlertView; 139 | -------------------------------------------------------------------------------- /cmd/tuku/kiora/interface.go: -------------------------------------------------------------------------------- 1 | package kiora 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | 10 | "github.com/pkg/errors" 11 | "github.com/sinkingpoint/kiora/lib/kiora/model" 12 | ) 13 | 14 | type KioraInstance struct { 15 | HTTPClient *http.Client 16 | APIVersion string 17 | URL string 18 | } 19 | 20 | func NewKioraInstance(url, apiVersion string) *KioraInstance { 21 | return &KioraInstance{ 22 | HTTPClient: http.DefaultClient, 23 | APIVersion: apiVersion, 24 | URL: url, 25 | } 26 | } 27 | 28 | func (k *KioraInstance) getRequest(method, uri string, body io.Reader) (*http.Request, error) { 29 | url := fmt.Sprintf("%s/api/%s/%s", k.URL, k.APIVersion, uri) 30 | return http.NewRequest(method, url, body) 31 | } 32 | 33 | func (k *KioraInstance) GetAlerts() ([]model.Alert, error) { 34 | req, err := k.getRequest(http.MethodGet, "alerts", nil) 35 | if err != nil { 36 | return nil, errors.Wrap(err, "failed to create request") 37 | } 38 | 39 | resp, err := k.HTTPClient.Do(req) 40 | if err != nil { 41 | return nil, errors.Wrap(err, "failed to execute request") 42 | } 43 | 44 | defer resp.Body.Close() 45 | 46 | if resp.StatusCode != http.StatusOK { 47 | return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 48 | } 49 | 50 | alerts := []model.Alert{} 51 | decoder := json.NewDecoder(resp.Body) 52 | if err := decoder.Decode(&alerts); err != nil { 53 | return nil, errors.Wrap(err, "failed to decode response") 54 | } 55 | 56 | return alerts, nil 57 | } 58 | 59 | func (k *KioraInstance) PostAlerts(alerts []model.Alert) error { 60 | body, err := json.Marshal(alerts) 61 | if err != nil { 62 | return errors.Wrap(err, "failed to marshal alerts") 63 | } 64 | 65 | req, err := k.getRequest(http.MethodPost, "alerts", bytes.NewBuffer(body)) 66 | if err != nil { 67 | return errors.Wrap(err, "failed to create request") 68 | } 69 | 70 | req.Header.Set("Content-Type", "application/json") 71 | 72 | resp, err := k.HTTPClient.Do(req) 73 | if err != nil { 74 | return errors.Wrap(err, "failed to execute request") 75 | } 76 | 77 | defer resp.Body.Close() 78 | 79 | body, err = io.ReadAll(resp.Body) 80 | if err != nil { 81 | return errors.Wrap(err, "failed to read response body") 82 | } 83 | 84 | if resp.StatusCode != http.StatusAccepted { 85 | return fmt.Errorf("unexpected status code: %d (%q)", resp.StatusCode, string(body)) 86 | } 87 | 88 | return nil 89 | } 90 | 91 | func (k *KioraInstance) PostSilence(silence model.Silence) (*model.Silence, error) { 92 | body, err := json.Marshal(silence) 93 | if err != nil { 94 | return nil, errors.Wrap(err, "failed to marshal silence") 95 | } 96 | 97 | req, err := k.getRequest(http.MethodPost, "silences", bytes.NewBuffer(body)) 98 | if err != nil { 99 | return nil, errors.Wrap(err, "failed to create request") 100 | } 101 | 102 | req.Header.Set("Content-Type", "application/json") 103 | 104 | resp, err := k.HTTPClient.Do(req) 105 | if err != nil { 106 | return nil, errors.Wrap(err, "failed to execute request") 107 | } 108 | 109 | defer resp.Body.Close() 110 | 111 | body, err = io.ReadAll(resp.Body) 112 | if err != nil { 113 | return nil, errors.Wrap(err, "failed to read response body") 114 | } 115 | 116 | if resp.StatusCode != http.StatusCreated { 117 | return nil, fmt.Errorf("unexpected status code: %d (%q)", resp.StatusCode, string(body)) 118 | } 119 | 120 | silence = model.Silence{} 121 | if err := json.Unmarshal(body, &silence); err != nil { 122 | return nil, errors.Wrap(err, "failed to unmarshal response") 123 | } 124 | 125 | return &silence, nil 126 | } 127 | -------------------------------------------------------------------------------- /lib/kiora/kioradb/query/stats_test.go: -------------------------------------------------------------------------------- 1 | package query_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/sinkingpoint/kiora/lib/kiora/kioradb/query" 8 | "github.com/sinkingpoint/kiora/lib/kiora/model" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestAlertCountQuery(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | ty string 16 | args map[string]string 17 | alerts []model.Alert 18 | want float64 19 | }{ 20 | { 21 | name: "test alert count query", 22 | ty: "count", 23 | args: map[string]string{}, 24 | alerts: []model.Alert{ 25 | { 26 | Labels: model.Labels{ 27 | "foo": "bar", 28 | }, 29 | }, 30 | }, 31 | want: 1, 32 | }, 33 | { 34 | name: "test alert count query with filter", 35 | ty: "count", 36 | args: map[string]string{ 37 | "filter_type": "status", 38 | "status": "firing", 39 | }, 40 | alerts: []model.Alert{ 41 | { 42 | Labels: model.Labels{ 43 | "foo": "bar", 44 | }, 45 | Status: model.AlertStatusFiring, 46 | }, 47 | { 48 | Labels: model.Labels{ 49 | "foo": "baz", 50 | }, 51 | Status: model.AlertStatusResolved, 52 | }, 53 | }, 54 | want: 1, 55 | }, 56 | } 57 | 58 | for _, tt := range tests { 59 | t.Run(tt.name, func(t *testing.T) { 60 | q, err := query.UnmarshalAlertStatsQuery(tt.ty, tt.args) 61 | require.NoError(t, err, "unmarshal alert stats query failed") 62 | require.NotNil(t, q, "unmarshal alert stats query failed") 63 | 64 | filter := q.Filter() 65 | for _, alert := range tt.alerts { 66 | if filter == nil || filter.MatchesAlert(context.TODO(), &alert) { 67 | require.NoError(t, q.Process(context.TODO(), &alert), "process alert failed") 68 | } 69 | } 70 | 71 | got := q.Gather(context.TODO()) 72 | require.Len(t, got, 1, "gather returned wrong number of results") 73 | require.Equal(t, tt.want, got[0].Frames[0][0], "gather returned wrong value") 74 | }) 75 | } 76 | } 77 | 78 | func TestAlertStatusCountQuery(t *testing.T) { 79 | tests := []struct { 80 | name string 81 | ty string 82 | args map[string]string 83 | alerts []model.Alert 84 | want map[string]float64 85 | }{ 86 | { 87 | name: "test alert status count query", 88 | ty: "status_count", 89 | args: map[string]string{}, 90 | alerts: []model.Alert{ 91 | { 92 | Labels: model.Labels{ 93 | "foo": "bar", 94 | }, 95 | Status: model.AlertStatusFiring, 96 | }, 97 | { 98 | Labels: model.Labels{ 99 | "foo": "baz", 100 | }, 101 | Status: model.AlertStatusFiring, 102 | }, 103 | { 104 | Labels: model.Labels{ 105 | "foo": "qux", 106 | }, 107 | Status: model.AlertStatusResolved, 108 | }, 109 | }, 110 | want: map[string]float64{ 111 | "firing": 2, 112 | "resolved": 1, 113 | }, 114 | }, 115 | } 116 | 117 | for _, tt := range tests { 118 | t.Run(tt.name, func(t *testing.T) { 119 | q, err := query.UnmarshalAlertStatsQuery(tt.ty, tt.args) 120 | require.NoError(t, err, "unmarshal alert stats query failed") 121 | require.NotNil(t, q, "unmarshal alert stats query failed") 122 | 123 | for _, alert := range tt.alerts { 124 | require.NoError(t, q.Process(context.TODO(), &alert), "process alert failed") 125 | } 126 | 127 | got := q.Gather(context.TODO()) 128 | require.Len(t, got, len(tt.want), "gather returned wrong number of results") 129 | for _, result := range got { 130 | require.Equal(t, tt.want[result.Labels["status"]], result.Frames[0][0], "gather returned wrong value") 131 | } 132 | }) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /cmd/kiora/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/signal" 7 | 8 | "github.com/alecthomas/kong" 9 | "github.com/rs/zerolog" 10 | "github.com/rs/zerolog/log" 11 | "github.com/sinkingpoint/kiora/cmd/kiora/config" 12 | "github.com/sinkingpoint/kiora/internal/server" 13 | "github.com/sinkingpoint/kiora/internal/tracing" 14 | "github.com/sinkingpoint/kiora/lib/kiora/kioradb" 15 | ) 16 | 17 | var CLI struct { 18 | tracing.TracingConfiguration ` prefix:"tracing."` 19 | HTTPListenAddress string `name:"web.listen-url" help:"the address to listen on" default:"localhost:4278"` 20 | ConfigFile string `name:"config.file" short:"c" help:"the config file to load config from" default:"./kiora.dot"` 21 | 22 | NodeName string `name:"cluster.node-name" help:"the name to join the cluster with"` 23 | ClusterListenAddress string `name:"cluster.listen-url" help:"the address to run cluster activities on" default:"localhost:4279"` 24 | ClusterShardLabels []string `name:"cluster.shard-labels" help:"the labels that determine which node in a cluster will send a given alert"` 25 | BootstrapPeers []string `name:"cluster.bootstrap-peers" help:"the peers to bootstrap with"` 26 | 27 | StorageBackend string `name:"storage.backend" help:"the storage backend to use" default:"boltdb"` 28 | StoragePath string `name:"storage.path" help:"the path to store data in" default:"./kiora.db"` 29 | } 30 | 31 | func main() { 32 | CLI.TracingConfiguration = tracing.DefaultTracingConfiguration() 33 | kong.Parse(&CLI, kong.Name("kiora"), kong.Description("An experimental Alertmanager"), kong.UsageOnError(), kong.ConfigureHelp(kong.HelpOptions{ 34 | Compact: true, 35 | })) 36 | 37 | logger := zerolog.New(os.Stderr).Level(zerolog.DebugLevel) 38 | log.Logger = logger 39 | 40 | config.RegisterNodes() 41 | config, err := config.LoadConfigFile(CLI.ConfigFile, logger) 42 | if err != nil { 43 | logger.Fatal().Err(err).Msg("failed to load config") 44 | } 45 | 46 | serverConfig := server.NewServerConfig() 47 | serverConfig.HTTPListenAddress = CLI.HTTPListenAddress 48 | serverConfig.ClusterListenAddress = CLI.ClusterListenAddress 49 | serverConfig.ClusterShardLabels = CLI.ClusterShardLabels 50 | serverConfig.BootstrapPeers = CLI.BootstrapPeers 51 | serverConfig.ServiceConfig = config 52 | serverConfig.Logger = logger 53 | 54 | tp, err := tracing.InitTracing(CLI.TracingConfiguration) 55 | if err != nil { 56 | logger.Warn().Err(err).Msg("failed to start tracing") 57 | } 58 | 59 | if tp != nil { 60 | defer func() { 61 | if err := tp.Shutdown(context.Background()); err != nil { 62 | logger.Warn().Err(err).Msg("failed to shutdown tracing. Spans may have been lost") 63 | } 64 | }() 65 | } 66 | 67 | var db kioradb.DB 68 | switch CLI.StorageBackend { 69 | case "boltdb": 70 | db, err = kioradb.NewBoltDB(CLI.StoragePath, logger) 71 | if err != nil { 72 | logger.Fatal().Err(err).Msg("failed to create bolt db") 73 | } 74 | case "inmemory": 75 | db = kioradb.NewInMemoryDB() 76 | default: 77 | logger.Fatal().Msgf("unknown storage backend %s", CLI.StorageBackend) 78 | } 79 | 80 | server, err := server.NewKioraServer(serverConfig, db) 81 | if err != nil { 82 | logger.Err(err).Msg("failed to create server") 83 | return 84 | } 85 | 86 | // Setup a SIGINT handler, so that we can shutdown gracefully. 87 | c := make(chan os.Signal, 1) 88 | signal.Notify(c, os.Interrupt) 89 | go func() { 90 | for range c { 91 | logger.Info().Msg("Received signal, shutting down") 92 | server.Shutdown() 93 | break 94 | } 95 | }() 96 | 97 | if err := server.ListenAndServe(); err != nil { 98 | logger.Err(err).Msg("failed to listen and serve") 99 | } 100 | 101 | logger.Info().Msg("Kiora Shut Down") 102 | } 103 | -------------------------------------------------------------------------------- /internal/clustering/serf/hclog.go: -------------------------------------------------------------------------------- 1 | package serf 2 | 3 | // Adapted from https://github.com/sylr/rafty/blob/master/logger/zerolog/hclogger.go 4 | // BSD License. 5 | 6 | import ( 7 | "io" 8 | "log" 9 | 10 | "github.com/hashicorp/go-hclog" 11 | "github.com/rs/zerolog" 12 | ) 13 | 14 | // Logger is a wrapper around zerolog.Logger that implements hclog.Logger. 15 | type HCLogger struct { 16 | zerolog.Logger 17 | } 18 | 19 | var _ hclog.Logger = (*HCLogger)(nil) 20 | 21 | func (l *HCLogger) GetLevel() hclog.Level { 22 | switch l.Logger.GetLevel() { 23 | case zerolog.TraceLevel: 24 | return hclog.Trace 25 | case zerolog.DebugLevel: 26 | return hclog.Debug 27 | case zerolog.InfoLevel: 28 | return hclog.Info 29 | case zerolog.WarnLevel: 30 | return hclog.Warn 31 | case zerolog.ErrorLevel: 32 | return hclog.Error 33 | default: 34 | return hclog.NoLevel 35 | } 36 | } 37 | 38 | func (l *HCLogger) IsTrace() bool { 39 | return l.Logger.GetLevel() == zerolog.TraceLevel 40 | } 41 | 42 | func (l *HCLogger) IsDebug() bool { 43 | return l.Logger.GetLevel() == zerolog.DebugLevel 44 | } 45 | 46 | func (l *HCLogger) IsInfo() bool { 47 | return l.Logger.GetLevel() == zerolog.InfoLevel 48 | } 49 | 50 | func (l *HCLogger) IsWarn() bool { 51 | return l.Logger.GetLevel() == zerolog.WarnLevel 52 | } 53 | 54 | func (l *HCLogger) IsError() bool { 55 | return l.Logger.GetLevel() == zerolog.ErrorLevel 56 | } 57 | 58 | func (l *HCLogger) Trace(format string, args ...interface{}) { 59 | l.Logger.Trace().Fields(args).Msg(format) 60 | } 61 | 62 | func (l *HCLogger) Debug(format string, args ...interface{}) { 63 | l.Logger.Debug().Fields(args).Msg(format) 64 | } 65 | 66 | func (l *HCLogger) Info(format string, args ...interface{}) { 67 | l.Logger.Info().Fields(args).Msg(format) 68 | } 69 | 70 | func (l *HCLogger) Warn(format string, args ...interface{}) { 71 | l.Logger.Warn().Fields(args).Msg(format) 72 | } 73 | 74 | func (l *HCLogger) Error(format string, args ...interface{}) { 75 | l.Logger.Error().Fields(args).Msg(format) 76 | } 77 | 78 | func (l *HCLogger) Log(level hclog.Level, format string, args ...interface{}) { 79 | switch level { 80 | case hclog.Trace: 81 | l.Logger.Trace().Fields(args).Msg(format) 82 | case hclog.Debug: 83 | l.Logger.Debug().Fields(args).Msg(format) 84 | case hclog.Info: 85 | l.Logger.Info().Fields(args).Msg(format) 86 | case hclog.Warn: 87 | l.Logger.Warn().Fields(args).Msg(format) 88 | case hclog.Error: 89 | l.Logger.Error().Fields(args).Msg(format) 90 | default: 91 | log.Fatalf("unknown level %d", level) 92 | } 93 | } 94 | 95 | func (l *HCLogger) SetLevel(level hclog.Level) { 96 | switch level { 97 | case hclog.Trace: 98 | l.Logger = l.Logger.Level(zerolog.TraceLevel) 99 | case hclog.Debug: 100 | l.Logger = l.Logger.Level(zerolog.DebugLevel) 101 | case hclog.Info: 102 | l.Logger = l.Logger.Level(zerolog.InfoLevel) 103 | case hclog.Warn: 104 | l.Logger = l.Logger.Level(zerolog.WarnLevel) 105 | case hclog.Error: 106 | l.Logger = l.Logger.Level(zerolog.ErrorLevel) 107 | default: 108 | log.Fatalf("unknown level %d", level) 109 | } 110 | } 111 | 112 | func (l *HCLogger) Name() string { 113 | return "" 114 | } 115 | 116 | func (l *HCLogger) Named(name string) hclog.Logger { 117 | return &HCLogger{l.Logger.With().Str("name", name).Logger()} 118 | } 119 | 120 | func (l *HCLogger) ResetNamed(name string) hclog.Logger { 121 | return &HCLogger{l.Logger.With().Str("name", name).Logger()} 122 | } 123 | 124 | func (l *HCLogger) With(args ...interface{}) hclog.Logger { 125 | return &HCLogger{l.Logger.With().Fields(args).Logger()} 126 | } 127 | 128 | func (l *HCLogger) StandardLogger(opts *hclog.StandardLoggerOptions) *log.Logger { 129 | return log.New(l.Logger, "", 0) 130 | } 131 | 132 | func (l *HCLogger) StandardWriter(opts *hclog.StandardLoggerOptions) io.Writer { 133 | return l.Logger 134 | } 135 | 136 | func (l *HCLogger) ImpliedArgs() []interface{} { 137 | return nil 138 | } 139 | -------------------------------------------------------------------------------- /internal/server/api/api_impl.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/hashicorp/go-multierror" 8 | "github.com/pkg/errors" 9 | 10 | "github.com/sinkingpoint/kiora/internal/clustering" 11 | "github.com/sinkingpoint/kiora/internal/services" 12 | "github.com/sinkingpoint/kiora/lib/kiora/kioradb" 13 | "github.com/sinkingpoint/kiora/lib/kiora/kioradb/query" 14 | "github.com/sinkingpoint/kiora/lib/kiora/model" 15 | ) 16 | 17 | var _ = API(&APIImpl{}) 18 | 19 | // API defines an interface that represents all the operations that can be performed on the kiora API. 20 | type API interface { 21 | // GetAlerts returns a list of alerts matching the given query. 22 | GetAlerts(ctx context.Context, q query.AlertQuery) ([]model.Alert, error) 23 | 24 | // PostAlerts stores the given alerts in the database, updating any existing alerts with the same labels. 25 | PostAlerts(ctx context.Context, alerts []model.Alert) error 26 | 27 | // QueryAlertStats executes the given stats query, returning the resulting frames. 28 | QueryAlertStats(ctx context.Context, q query.AlertStatsQuery) ([]query.StatsResult, error) 29 | 30 | // GetSilences returns a list of silences matching the given query. 31 | GetSilences(ctx context.Context, query query.SilenceQuery) ([]model.Silence, error) 32 | 33 | // PostSilences stores the given silences in the database, updating any existing silences with the same ID. 34 | PostSilence(ctx context.Context, silences model.Silence) error 35 | 36 | // AckAlert acknowledges the given alert with the given acknowledgement. 37 | AckAlert(ctx context.Context, alertID string, alertAck model.AlertAcknowledgement) error 38 | 39 | // GetClusterStatus returns the status of the nodes in the cluster. 40 | GetClusterStatus(ctx context.Context) ([]any, error) 41 | } 42 | 43 | type APIImpl struct { 44 | bus services.Bus 45 | clusterer clustering.Clusterer 46 | } 47 | 48 | func NewAPIImpl(bus services.Bus, clusterer clustering.Clusterer) *APIImpl { 49 | return &APIImpl{ 50 | bus: bus, 51 | clusterer: clusterer, 52 | } 53 | } 54 | 55 | func (a *APIImpl) GetAlerts(ctx context.Context, q query.AlertQuery) ([]model.Alert, error) { 56 | return a.bus.DB().QueryAlerts(ctx, q), nil 57 | } 58 | 59 | func (a *APIImpl) PostAlerts(ctx context.Context, alerts []model.Alert) error { 60 | var postErr error 61 | for i := range alerts { 62 | if err := a.bus.Config().ValidateData(ctx, &alerts[i]); err != nil { 63 | postErr = multierror.Append(postErr, err) 64 | } 65 | } 66 | 67 | if postErr != nil { 68 | return postErr 69 | } 70 | 71 | return a.bus.Broadcaster().BroadcastAlerts(ctx, alerts...) 72 | } 73 | 74 | func (a *APIImpl) QueryAlertStats(ctx context.Context, q query.AlertStatsQuery) ([]query.StatsResult, error) { 75 | return kioradb.QueryAlertStats(ctx, a.bus.DB(), q) 76 | } 77 | 78 | func (a *APIImpl) GetSilences(ctx context.Context, q query.SilenceQuery) ([]model.Silence, error) { 79 | return a.bus.DB().QuerySilences(ctx, q), nil 80 | } 81 | 82 | func (a *APIImpl) PostSilence(ctx context.Context, silence model.Silence) error { 83 | if err := a.bus.Config().ValidateData(ctx, &silence); err != nil { 84 | return err 85 | } 86 | 87 | return a.bus.Broadcaster().BroadcastSilences(ctx, silence) 88 | } 89 | 90 | func (a *APIImpl) AckAlert(ctx context.Context, alertID string, alertAck model.AlertAcknowledgement) error { 91 | if err := a.bus.Config().ValidateData(ctx, &alertAck); err != nil { 92 | return err 93 | } 94 | 95 | if len(a.bus.DB().QueryAlerts(ctx, query.NewAlertQuery(query.ID(alertID)))) == 0 { 96 | return fmt.Errorf("alert %q not found", alertID) 97 | } 98 | 99 | return a.bus.Broadcaster().BroadcastAlertAcknowledgement(ctx, alertID, alertAck) 100 | } 101 | 102 | func (a *APIImpl) GetClusterStatus(ctx context.Context) ([]any, error) { 103 | if a.clusterer == nil { 104 | return nil, errors.New("no clusterer configured") 105 | } 106 | 107 | return a.clusterer.Nodes(), nil 108 | } 109 | -------------------------------------------------------------------------------- /cmd/kiora/config/graph.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // Defines a custom Graph for gographviz that accepts all extra attributes. 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | // node is a node in the config graph that defines a filter, or a receiver. 13 | type node struct { 14 | name string 15 | attrs map[string]string 16 | } 17 | 18 | // edge defines an edge between two nodes in the graph. 19 | type edge struct { 20 | from string 21 | to string 22 | attrs map[string]string 23 | } 24 | 25 | // configGraph is the raw graphviz graph, as loaded from the config file. 26 | type configGraph struct { 27 | name string 28 | attrs map[string]string 29 | subGraphs map[string]configGraph 30 | nodes map[string]node 31 | edges []edge 32 | } 33 | 34 | // newConfigGraph constructs a new configGraph, initializing all the maps. 35 | func newConfigGraph() configGraph { 36 | return configGraph{ 37 | name: "", 38 | attrs: make(map[string]string), 39 | subGraphs: make(map[string]configGraph), 40 | nodes: make(map[string]node), 41 | edges: []edge{}, 42 | } 43 | } 44 | 45 | func (c *configGraph) SetStrict(strict bool) error { 46 | return nil 47 | } 48 | 49 | func (c *configGraph) SetDir(directed bool) error { 50 | return nil 51 | } 52 | 53 | func (c *configGraph) SetName(name string) error { 54 | c.name = name 55 | return nil 56 | } 57 | 58 | func (c *configGraph) AddPortEdge(src, srcPort, dst, dstPort string, directed bool, attrs map[string]string) error { 59 | return c.AddEdge(src, dst, directed, attrs) 60 | } 61 | 62 | func (c *configGraph) AddEdge(src, dst string, directed bool, attrs map[string]string) error { 63 | if !directed { 64 | return errors.New("edges in the Config Graph must be directed") 65 | } 66 | 67 | for i := range attrs { 68 | attrs[i] = strings.Trim(attrs[i], "\"") 69 | } 70 | 71 | c.edges = append(c.edges, edge{ 72 | from: src, 73 | to: dst, 74 | attrs: attrs, 75 | }) 76 | 77 | return nil 78 | } 79 | 80 | func (c *configGraph) AddNode(parentGraph, name string, attrs map[string]string) error { 81 | if parentGraph == c.name { 82 | if _, ok := c.nodes[name]; ok { 83 | return fmt.Errorf("config graph already contains a node called %q", name) 84 | } 85 | 86 | for i := range attrs { 87 | attrs[i] = strings.Trim(attrs[i], "\"") 88 | } 89 | 90 | c.nodes[name] = node{ 91 | name, 92 | attrs, 93 | } 94 | 95 | return nil 96 | } else { 97 | if sub, ok := c.subGraphs[parentGraph]; ok { 98 | return sub.AddNode(parentGraph, name, attrs) 99 | } else { 100 | return fmt.Errorf("failed to find subgraph %q to add node", parentGraph) 101 | } 102 | } 103 | } 104 | 105 | func (c *configGraph) AddAttr(parentGraph, field, value string) error { 106 | if parentGraph == c.name { 107 | if _, ok := c.attrs[field]; ok { 108 | return fmt.Errorf("graph already has an attribute %q", field) 109 | } 110 | 111 | value := strings.Trim(value, "\"") 112 | 113 | c.attrs[field] = value 114 | return nil 115 | } else { 116 | if sub, ok := c.subGraphs[parentGraph]; ok { 117 | return sub.AddAttr(parentGraph, field, value) 118 | } else { 119 | return fmt.Errorf("failed to find subgraph %q to add node", parentGraph) 120 | } 121 | } 122 | } 123 | 124 | func (c *configGraph) AddSubGraph(parentGraph, name string, attrs map[string]string) error { 125 | if parentGraph == c.name { 126 | if _, ok := c.attrs[name]; ok { 127 | return fmt.Errorf("graph already has an subgraph %q", name) 128 | } 129 | 130 | graph := newConfigGraph() 131 | graph.name = name 132 | graph.attrs = attrs 133 | 134 | c.subGraphs[name] = graph 135 | return nil 136 | } else { 137 | // We only support one level of nesting for now, error if we're trying to add a subgraph to a subgraph. 138 | return errors.New("config only supports one layer of nesting") 139 | } 140 | } 141 | 142 | func (c *configGraph) String() string { 143 | return "" 144 | } 145 | -------------------------------------------------------------------------------- /lib/kiora/kioradb/query/stats.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/sinkingpoint/kiora/lib/kiora/model" 8 | ) 9 | 10 | // StatsResult is a single result from a StatsQuery. 11 | type StatsResult struct { 12 | // Labels are the labels that apply to this result. 13 | Labels map[string]string `json:"labels"` 14 | 15 | // Frames are the data frames that apply to this result. 16 | Frames [][]float64 `json:"frames"` 17 | } 18 | 19 | // AlertStatsQuery is a query that can be run against a DB to pull aggregated numbers out of it. 20 | type AlertStatsQuery interface { 21 | // Filter returns the filter that this filters the data that gets passed to Process(). 22 | Filter() AlertFilter 23 | 24 | // Process is called for each alert that matches the filter. 25 | Process(ctx context.Context, alert *model.Alert) error 26 | 27 | // Gather is called after all alerts have been processed. It returns the results of the query. 28 | Gather(ctx context.Context) []StatsResult 29 | } 30 | 31 | type alertStatsQueryConstructor func(args map[string]string) (AlertStatsQuery, error) 32 | 33 | var alertStatsQueryRegistry = map[string]alertStatsQueryConstructor{} 34 | 35 | // RegisterAlertStatsQuery registers a new AlertStatsQuery. 36 | func RegisterAlertStatsQuery(name string, constructor alertStatsQueryConstructor) { 37 | alertStatsQueryRegistry[name] = constructor 38 | } 39 | 40 | // UnmarshalAlertStatsQuery unmarshals an AlertStatsQuery from a set of arguments. 41 | func UnmarshalAlertStatsQuery(ty string, args map[string]string) (AlertStatsQuery, error) { 42 | if constructor, ok := alertStatsQueryRegistry[ty]; ok { 43 | return constructor(args) 44 | } 45 | 46 | return nil, fmt.Errorf("unknown stats query type %q", ty) 47 | } 48 | 49 | func init() { 50 | RegisterAlertStatsQuery("count", NewAlertCountQuery) 51 | RegisterAlertStatsQuery("status_count", NewAlertStatusCountQuery) 52 | } 53 | 54 | // AlertCountQuery counts the number of alerts that match the filter. 55 | type AlertCountQuery struct { 56 | filter AlertFilter 57 | count int 58 | } 59 | 60 | func NewAlertCountQuery(args map[string]string) (AlertStatsQuery, error) { 61 | query := &AlertCountQuery{} 62 | 63 | if _, ok := args["filter_type"]; ok { 64 | filter, err := UnmarshalAlertFilter(args) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | query.filter = filter 70 | } 71 | 72 | return query, nil 73 | } 74 | 75 | func (q *AlertCountQuery) Filter() AlertFilter { 76 | return q.filter 77 | } 78 | 79 | func (q *AlertCountQuery) Process(ctx context.Context, alert *model.Alert) error { 80 | q.count++ 81 | return nil 82 | } 83 | 84 | func (q *AlertCountQuery) Gather(ctx context.Context) []StatsResult { 85 | return []StatsResult{ 86 | { 87 | Labels: map[string]string{}, 88 | Frames: [][]float64{{float64(q.count)}}, 89 | }, 90 | } 91 | } 92 | 93 | // AlertStatusCountQuery counts the number of alerts, grouped by status. 94 | type AlertStatusCountQuery struct { 95 | filter AlertFilter 96 | counts map[model.AlertStatus]int 97 | } 98 | 99 | func NewAlertStatusCountQuery(args map[string]string) (AlertStatsQuery, error) { 100 | query := &AlertStatusCountQuery{ 101 | counts: map[model.AlertStatus]int{}, 102 | } 103 | 104 | if _, ok := args["filter_type"]; ok { 105 | filter, err := UnmarshalAlertFilter(args) 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | query.filter = filter 111 | } 112 | 113 | return query, nil 114 | } 115 | 116 | func (q *AlertStatusCountQuery) Filter() AlertFilter { 117 | return q.filter 118 | } 119 | 120 | func (q *AlertStatusCountQuery) Process(ctx context.Context, alert *model.Alert) error { 121 | q.counts[alert.Status] += 1 122 | return nil 123 | } 124 | 125 | func (q *AlertStatusCountQuery) Gather(ctx context.Context) []StatsResult { 126 | results := make([]StatsResult, 0, len(q.counts)) 127 | for status, count := range q.counts { 128 | results = append(results, StatsResult{ 129 | Labels: map[string]string{"status": string(status)}, 130 | Frames: [][]float64{{float64(count)}}, 131 | }) 132 | } 133 | return results 134 | } 135 | -------------------------------------------------------------------------------- /mocks/mock_kioradb/db.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: ./lib/kiora/kioradb/db.go 3 | 4 | // Package mock_kioradb is a generated GoMock package. 5 | package mock_kioradb 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | 11 | gomock "github.com/golang/mock/gomock" 12 | query "github.com/sinkingpoint/kiora/lib/kiora/kioradb/query" 13 | model "github.com/sinkingpoint/kiora/lib/kiora/model" 14 | ) 15 | 16 | // MockDB is a mock of DB interface. 17 | type MockDB struct { 18 | ctrl *gomock.Controller 19 | recorder *MockDBMockRecorder 20 | } 21 | 22 | // MockDBMockRecorder is the mock recorder for MockDB. 23 | type MockDBMockRecorder struct { 24 | mock *MockDB 25 | } 26 | 27 | // NewMockDB creates a new mock instance. 28 | func NewMockDB(ctrl *gomock.Controller) *MockDB { 29 | mock := &MockDB{ctrl: ctrl} 30 | mock.recorder = &MockDBMockRecorder{mock} 31 | return mock 32 | } 33 | 34 | // EXPECT returns an object that allows the caller to indicate expected use. 35 | func (m *MockDB) EXPECT() *MockDBMockRecorder { 36 | return m.recorder 37 | } 38 | 39 | // Close mocks base method. 40 | func (m *MockDB) Close() error { 41 | m.ctrl.T.Helper() 42 | ret := m.ctrl.Call(m, "Close") 43 | ret0, _ := ret[0].(error) 44 | return ret0 45 | } 46 | 47 | // Close indicates an expected call of Close. 48 | func (mr *MockDBMockRecorder) Close() *gomock.Call { 49 | mr.mock.ctrl.T.Helper() 50 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockDB)(nil).Close)) 51 | } 52 | 53 | // QueryAlerts mocks base method. 54 | func (m *MockDB) QueryAlerts(ctx context.Context, query query.AlertQuery) []model.Alert { 55 | m.ctrl.T.Helper() 56 | ret := m.ctrl.Call(m, "QueryAlerts", ctx, query) 57 | ret0, _ := ret[0].([]model.Alert) 58 | return ret0 59 | } 60 | 61 | // QueryAlerts indicates an expected call of QueryAlerts. 62 | func (mr *MockDBMockRecorder) QueryAlerts(ctx, query interface{}) *gomock.Call { 63 | mr.mock.ctrl.T.Helper() 64 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryAlerts", reflect.TypeOf((*MockDB)(nil).QueryAlerts), ctx, query) 65 | } 66 | 67 | // QuerySilences mocks base method. 68 | func (m *MockDB) QuerySilences(ctx context.Context, query query.SilenceQuery) []model.Silence { 69 | m.ctrl.T.Helper() 70 | ret := m.ctrl.Call(m, "QuerySilences", ctx, query) 71 | ret0, _ := ret[0].([]model.Silence) 72 | return ret0 73 | } 74 | 75 | // QuerySilences indicates an expected call of QuerySilences. 76 | func (mr *MockDBMockRecorder) QuerySilences(ctx, query interface{}) *gomock.Call { 77 | mr.mock.ctrl.T.Helper() 78 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QuerySilences", reflect.TypeOf((*MockDB)(nil).QuerySilences), ctx, query) 79 | } 80 | 81 | // StoreAlerts mocks base method. 82 | func (m *MockDB) StoreAlerts(ctx context.Context, alerts ...model.Alert) error { 83 | m.ctrl.T.Helper() 84 | varargs := []interface{}{ctx} 85 | for _, a := range alerts { 86 | varargs = append(varargs, a) 87 | } 88 | ret := m.ctrl.Call(m, "StoreAlerts", varargs...) 89 | ret0, _ := ret[0].(error) 90 | return ret0 91 | } 92 | 93 | // StoreAlerts indicates an expected call of StoreAlerts. 94 | func (mr *MockDBMockRecorder) StoreAlerts(ctx interface{}, alerts ...interface{}) *gomock.Call { 95 | mr.mock.ctrl.T.Helper() 96 | varargs := append([]interface{}{ctx}, alerts...) 97 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreAlerts", reflect.TypeOf((*MockDB)(nil).StoreAlerts), varargs...) 98 | } 99 | 100 | // StoreSilences mocks base method. 101 | func (m *MockDB) StoreSilences(ctx context.Context, silences ...model.Silence) error { 102 | m.ctrl.T.Helper() 103 | varargs := []interface{}{ctx} 104 | for _, a := range silences { 105 | varargs = append(varargs, a) 106 | } 107 | ret := m.ctrl.Call(m, "StoreSilences", varargs...) 108 | ret0, _ := ret[0].(error) 109 | return ret0 110 | } 111 | 112 | // StoreSilences indicates an expected call of StoreSilences. 113 | func (mr *MockDBMockRecorder) StoreSilences(ctx interface{}, silences ...interface{}) *gomock.Call { 114 | mr.mock.ctrl.T.Helper() 115 | varargs := append([]interface{}{ctx}, silences...) 116 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreSilences", reflect.TypeOf((*MockDB)(nil).StoreSilences), varargs...) 117 | } 118 | -------------------------------------------------------------------------------- /frontend/src/api/core/CancelablePromise.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | export class CancelError extends Error { 5 | 6 | constructor(message: string) { 7 | super(message); 8 | this.name = 'CancelError'; 9 | } 10 | 11 | public get isCancelled(): boolean { 12 | return true; 13 | } 14 | } 15 | 16 | export interface OnCancel { 17 | readonly isResolved: boolean; 18 | readonly isRejected: boolean; 19 | readonly isCancelled: boolean; 20 | 21 | (cancelHandler: () => void): void; 22 | } 23 | 24 | export class CancelablePromise implements Promise { 25 | readonly [Symbol.toStringTag]!: string; 26 | 27 | private _isResolved: boolean; 28 | private _isRejected: boolean; 29 | private _isCancelled: boolean; 30 | private readonly _cancelHandlers: (() => void)[]; 31 | private readonly _promise: Promise; 32 | private _resolve?: (value: T | PromiseLike) => void; 33 | private _reject?: (reason?: any) => void; 34 | 35 | constructor( 36 | executor: ( 37 | resolve: (value: T | PromiseLike) => void, 38 | reject: (reason?: any) => void, 39 | onCancel: OnCancel 40 | ) => void 41 | ) { 42 | this._isResolved = false; 43 | this._isRejected = false; 44 | this._isCancelled = false; 45 | this._cancelHandlers = []; 46 | this._promise = new Promise((resolve, reject) => { 47 | this._resolve = resolve; 48 | this._reject = reject; 49 | 50 | const onResolve = (value: T | PromiseLike): void => { 51 | if (this._isResolved || this._isRejected || this._isCancelled) { 52 | return; 53 | } 54 | this._isResolved = true; 55 | this._resolve?.(value); 56 | }; 57 | 58 | const onReject = (reason?: any): void => { 59 | if (this._isResolved || this._isRejected || this._isCancelled) { 60 | return; 61 | } 62 | this._isRejected = true; 63 | this._reject?.(reason); 64 | }; 65 | 66 | const onCancel = (cancelHandler: () => void): void => { 67 | if (this._isResolved || this._isRejected || this._isCancelled) { 68 | return; 69 | } 70 | this._cancelHandlers.push(cancelHandler); 71 | }; 72 | 73 | Object.defineProperty(onCancel, 'isResolved', { 74 | get: (): boolean => this._isResolved, 75 | }); 76 | 77 | Object.defineProperty(onCancel, 'isRejected', { 78 | get: (): boolean => this._isRejected, 79 | }); 80 | 81 | Object.defineProperty(onCancel, 'isCancelled', { 82 | get: (): boolean => this._isCancelled, 83 | }); 84 | 85 | return executor(onResolve, onReject, onCancel as OnCancel); 86 | }); 87 | } 88 | 89 | public then( 90 | onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, 91 | onRejected?: ((reason: any) => TResult2 | PromiseLike) | null 92 | ): Promise { 93 | return this._promise.then(onFulfilled, onRejected); 94 | } 95 | 96 | public catch( 97 | onRejected?: ((reason: any) => TResult | PromiseLike) | null 98 | ): Promise { 99 | return this._promise.catch(onRejected); 100 | } 101 | 102 | public finally(onFinally?: (() => void) | null): Promise { 103 | return this._promise.finally(onFinally); 104 | } 105 | 106 | public cancel(): void { 107 | if (this._isResolved || this._isRejected || this._isCancelled) { 108 | return; 109 | } 110 | this._isCancelled = true; 111 | if (this._cancelHandlers.length) { 112 | try { 113 | for (const cancelHandler of this._cancelHandlers) { 114 | cancelHandler(); 115 | } 116 | } catch (error) { 117 | console.warn('Cancellation threw an error', error); 118 | return; 119 | } 120 | } 121 | this._cancelHandlers.length = 0; 122 | this._reject?.(new CancelError('Request aborted')); 123 | } 124 | 125 | public get isCancelled(): boolean { 126 | return this._isCancelled; 127 | } 128 | } 129 | --------------------------------------------------------------------------------