Skip to content

Commit 9a9e375

Browse files
authored
feat: [PPT-2077] Added alert & dashboard models (#296)
1 parent 9771a93 commit 9a9e375

7 files changed

Lines changed: 419 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- +micrate Up
2+
-- SQL in section 'Up' is executed when this migration is applied
3+
4+
CREATE TABLE IF NOT EXISTS "alert_dashboard"(
5+
created_at TIMESTAMPTZ NOT NULL,
6+
updated_at TIMESTAMPTZ NOT NULL,
7+
name TEXT NOT NULL,
8+
description TEXT NOT NULL,
9+
enabled BOOLEAN NOT NULL,
10+
authority_id TEXT NOT NULL,
11+
id TEXT NOT NULL PRIMARY KEY,
12+
FOREIGN KEY (authority_id) REFERENCES authority(id) ON DELETE CASCADE
13+
);
14+
15+
CREATE INDEX IF NOT EXISTS alert_dashboard_authority_id_index ON "alert_dashboard" USING BTREE (authority_id);
16+
17+
-- +micrate Down
18+
-- SQL section 'Down' is executed when this migration is rolled back
19+
DROP TABLE IF EXISTS "alert_dashboard"
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
-- +micrate Up
2+
-- SQL in section 'Up' is executed when this migration is applied
3+
4+
-- +micrate StatementBegin
5+
DO
6+
$$
7+
BEGIN
8+
IF NOT EXISTS (SELECT *
9+
FROM pg_type typ
10+
INNER JOIN pg_namespace nsp
11+
ON nsp.oid = typ.typnamespace
12+
WHERE nsp.nspname = current_schema()
13+
AND typ.typname = 'alert_severity') THEN
14+
CREATE TYPE alert_severity AS ENUM (
15+
'LOW',
16+
'MEDIUM',
17+
'HIGH',
18+
'CRITICAL'
19+
);
20+
END IF;
21+
END;
22+
$$
23+
LANGUAGE plpgsql;
24+
-- +micrate StatementEnd
25+
26+
-- +micrate StatementBegin
27+
DO
28+
$$
29+
BEGIN
30+
IF NOT EXISTS (SELECT *
31+
FROM pg_type typ
32+
INNER JOIN pg_namespace nsp
33+
ON nsp.oid = typ.typnamespace
34+
WHERE nsp.nspname = current_schema()
35+
AND typ.typname = 'alert_type') THEN
36+
CREATE TYPE alert_type AS ENUM (
37+
'THRESHOLD',
38+
'STATUS',
39+
'CUSTOM'
40+
);
41+
END IF;
42+
END;
43+
$$
44+
LANGUAGE plpgsql;
45+
-- +micrate StatementEnd
46+
47+
CREATE TABLE IF NOT EXISTS "alert"(
48+
created_at TIMESTAMPTZ NOT NULL,
49+
updated_at TIMESTAMPTZ NOT NULL,
50+
name TEXT NOT NULL,
51+
description TEXT NOT NULL,
52+
enabled BOOLEAN NOT NULL,
53+
conditions JSONB NOT NULL,
54+
severity public.alert_severity NOT NULL DEFAULT 'MEDIUM'::public.alert_severity,
55+
alert_type public.alert_type NOT NULL DEFAULT 'THRESHOLD'::public.alert_type,
56+
debounce_period INTEGER NOT NULL,
57+
alert_dashboard_id TEXT NOT NULL,
58+
id TEXT NOT NULL PRIMARY KEY,
59+
FOREIGN KEY (alert_dashboard_id) REFERENCES alert_dashboard(id) ON DELETE CASCADE
60+
);
61+
62+
CREATE INDEX IF NOT EXISTS alert_alert_dashboard_id_index ON "alert" USING BTREE (alert_dashboard_id);
63+
CREATE INDEX IF NOT EXISTS alert_enabled_index ON "alert" USING BTREE (enabled);
64+
CREATE INDEX IF NOT EXISTS alert_severity_index ON "alert" USING BTREE (severity);
65+
66+
-- +micrate Down
67+
-- SQL section 'Down' is executed when this migration is rolled back
68+
DROP TABLE IF EXISTS "alert";
69+
DROP TYPE IF EXISTS public.alert_type;
70+
DROP TYPE IF EXISTS public.alert_severity;

spec/alert_dashboard_spec.cr

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
require "./helper"
2+
3+
module PlaceOS::Model
4+
describe AlertDashboard do
5+
test_round_trip(AlertDashboard)
6+
7+
it "saves an alert dashboard" do
8+
authority = Generator.authority.save!
9+
inst = Generator.alert_dashboard(authority_id: authority.id).save!
10+
AlertDashboard.find!(inst.id.as(String)).id.should eq inst.id
11+
end
12+
13+
it "validates required fields" do
14+
invalid_model = Generator.alert_dashboard
15+
invalid_model.name = ""
16+
invalid_model.authority_id = nil
17+
18+
invalid_model.valid?.should be_false
19+
invalid_model.errors.size.should eq 2
20+
invalid_model.errors.map(&.field).should contain(:name)
21+
invalid_model.errors.map(&.field).should contain(:authority_id)
22+
end
23+
24+
it "belongs to authority" do
25+
authority = Generator.authority.save!
26+
dashboard = Generator.alert_dashboard(authority_id: authority.id).save!
27+
28+
dashboard.authority.should_not be_nil
29+
dashboard.authority.try(&.id).should eq authority.id
30+
end
31+
32+
it "has many alerts" do
33+
authority = Generator.authority.save!
34+
dashboard = Generator.alert_dashboard(authority_id: authority.id).save!
35+
alert1 = Generator.alert(alert_dashboard_id: dashboard.id).save!
36+
alert2 = Generator.alert(alert_dashboard_id: dashboard.id).save!
37+
38+
dashboard.alerts.size.should eq 2
39+
dashboard.alerts.map(&.id).should contain(alert1.id)
40+
dashboard.alerts.map(&.id).should contain(alert2.id)
41+
end
42+
43+
it "counts alerts" do
44+
authority = Generator.authority.save!
45+
dashboard = Generator.alert_dashboard(authority_id: authority.id).save!
46+
Generator.alert(alert_dashboard_id: dashboard.id).save!
47+
Generator.alert(alert_dashboard_id: dashboard.id).save!
48+
49+
dashboard.alerts.count.should eq 2
50+
end
51+
52+
it "filters active alerts" do
53+
authority = Generator.authority.save!
54+
dashboard = Generator.alert_dashboard(authority_id: authority.id).save!
55+
active_alert = Generator.alert(alert_dashboard_id: dashboard.id, enabled: true).save!
56+
Generator.alert(alert_dashboard_id: dashboard.id, enabled: false).save!
57+
58+
active_alerts = dashboard.active_alerts
59+
active_alerts.size.should eq 1
60+
active_alerts.first.id.should eq active_alert.id
61+
end
62+
end
63+
end

spec/alert_spec.cr

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
require "./helper"
2+
3+
module PlaceOS::Model
4+
describe Alert do
5+
test_round_trip(Alert)
6+
7+
it "saves an alert" do
8+
authority = Generator.authority.save!
9+
dashboard = Generator.alert_dashboard(authority_id: authority.id).save!
10+
inst = Generator.alert(alert_dashboard_id: dashboard.id).save!
11+
Alert.find!(inst.id.as(String)).id.should eq inst.id
12+
end
13+
14+
it "validates required fields" do
15+
invalid_model = Generator.alert
16+
invalid_model.name = ""
17+
invalid_model.alert_dashboard_id = nil
18+
19+
invalid_model.valid?.should be_false
20+
invalid_model.errors.size.should eq 2
21+
invalid_model.errors.map(&.field).should contain(:name)
22+
invalid_model.errors.map(&.field).should contain(:alert_dashboard_id)
23+
end
24+
25+
it "belongs to alert dashboard" do
26+
authority = Generator.authority.save!
27+
dashboard = Generator.alert_dashboard(authority_id: authority.id).save!
28+
alert = Generator.alert(alert_dashboard_id: dashboard.id).save!
29+
30+
alert.alert_dashboard.should_not be_nil
31+
alert.alert_dashboard.try(&.id).should eq dashboard.id
32+
end
33+
34+
it "has default values" do
35+
alert = Generator.alert
36+
alert.enabled.should be_true
37+
alert.severity.should eq Alert::Severity::MEDIUM
38+
alert.alert_type.should eq Alert::AlertType::THRESHOLD
39+
alert.debounce_period.should eq 60000
40+
end
41+
42+
it "validates conditions" do
43+
authority = Generator.authority.save!
44+
dashboard = Generator.alert_dashboard(authority_id: authority.id).save!
45+
model = Generator.alert(alert_dashboard_id: dashboard.id)
46+
47+
valid = Trigger::Conditions::TimeDependent.new(
48+
type: Trigger::Conditions::TimeDependent::Type::At,
49+
time: Time.utc,
50+
)
51+
52+
invalid = Trigger::Conditions::TimeDependent.new(
53+
cron: "5 * * * *",
54+
)
55+
model.conditions.try &.time_dependents = [valid, invalid]
56+
57+
model.valid?.should be_false
58+
model.errors.size.should eq 1
59+
model.errors.first.to_s.should end_with "type should not be nil"
60+
end
61+
62+
describe "severity helpers" do
63+
it "identifies critical alerts" do
64+
alert = Generator.alert
65+
alert.severity = Alert::Severity::CRITICAL
66+
alert.critical?.should be_true
67+
68+
alert.severity = Alert::Severity::HIGH
69+
alert.critical?.should be_false
70+
end
71+
72+
it "identifies high priority alerts" do
73+
alert = Generator.alert
74+
75+
alert.severity = Alert::Severity::CRITICAL
76+
alert.high_priority?.should be_true
77+
78+
alert.severity = Alert::Severity::HIGH
79+
alert.high_priority?.should be_true
80+
81+
alert.severity = Alert::Severity::MEDIUM
82+
alert.high_priority?.should be_false
83+
84+
alert.severity = Alert::Severity::LOW
85+
alert.high_priority?.should be_false
86+
end
87+
end
88+
89+
describe "enum validation" do
90+
it "works with valid severity values" do
91+
authority = Generator.authority.save!
92+
dashboard = Generator.alert_dashboard(authority_id: authority.id).save!
93+
94+
Alert::Severity.values.each do |severity|
95+
alert = Generator.alert(alert_dashboard_id: dashboard.id)
96+
alert.severity = severity
97+
alert.valid?.should be_true
98+
end
99+
end
100+
101+
it "works with valid alert type values" do
102+
authority = Generator.authority.save!
103+
dashboard = Generator.alert_dashboard(authority_id: authority.id).save!
104+
105+
Alert::AlertType.values.each do |alert_type|
106+
alert = Generator.alert(alert_dashboard_id: dashboard.id)
107+
alert.alert_type = alert_type
108+
alert.valid?.should be_true
109+
end
110+
end
111+
end
112+
end
113+
end

spec/generator.cr

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,5 +694,52 @@ module PlaceOS::Model
694694
sent: sent,
695695
)
696696
end
697+
698+
def self.alert_dashboard(
699+
name : String = Faker::Lorem.word,
700+
description : String = Faker::Lorem.sentence,
701+
enabled : Bool = true,
702+
authority_id : String? = nil,
703+
)
704+
unless authority_id
705+
# look up an existing authority
706+
existing = Authority.find_by_domain("localhost")
707+
authority = existing || self.authority.save!
708+
authority_id = authority.id
709+
end
710+
711+
AlertDashboard.new(
712+
name: name,
713+
description: description,
714+
enabled: enabled,
715+
authority_id: authority_id
716+
)
717+
end
718+
719+
def self.alert(
720+
name : String = Faker::Lorem.word,
721+
description : String = Faker::Lorem.sentence,
722+
enabled : Bool = true,
723+
severity : Alert::Severity = Alert::Severity::MEDIUM,
724+
alert_type : Alert::AlertType = Alert::AlertType::THRESHOLD,
725+
debounce_period : Int32 = 60000,
726+
alert_dashboard_id : String? = nil,
727+
)
728+
unless alert_dashboard_id
729+
# generate a dashboard if none provided
730+
dashboard = self.alert_dashboard.save!
731+
alert_dashboard_id = dashboard.id
732+
end
733+
734+
Alert.new(
735+
name: name,
736+
description: description,
737+
enabled: enabled,
738+
severity: severity,
739+
alert_type: alert_type,
740+
debounce_period: debounce_period,
741+
alert_dashboard_id: alert_dashboard_id
742+
)
743+
end
697744
end
698745
end

src/placeos-models/alert.cr

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
require "json"
2+
require "./base/model"
3+
require "./trigger/conditions"
4+
5+
module PlaceOS::Model
6+
class Alert < ModelBase
7+
include PlaceOS::Model::Timestamps
8+
9+
table :alert
10+
11+
enum Severity
12+
LOW
13+
MEDIUM
14+
HIGH
15+
CRITICAL
16+
end
17+
18+
enum AlertType
19+
THRESHOLD
20+
STATUS
21+
CUSTOM
22+
end
23+
24+
attribute name : String, es_subfield: "keyword"
25+
attribute description : String = ""
26+
attribute enabled : Bool = true
27+
28+
# Reuse the same conditions structure as Trigger
29+
attribute conditions : PlaceOS::Model::Trigger::Conditions = -> { PlaceOS::Model::Trigger::Conditions.new }, es_ignore: true
30+
31+
attribute severity : Severity = Severity::MEDIUM, converter: PlaceOS::Model::PGEnumConverter(PlaceOS::Model::Alert::Severity)
32+
attribute alert_type : AlertType = AlertType::THRESHOLD, converter: PlaceOS::Model::PGEnumConverter(PlaceOS::Model::Alert::AlertType)
33+
34+
# In milliseconds - delay before showing notification to prevent flapping
35+
attribute debounce_period : Int32 = 15000 # 15 seconds default
36+
37+
# Association
38+
###############################################################################################
39+
40+
belongs_to AlertDashboard, foreign_key: "alert_dashboard_id"
41+
42+
# Validation
43+
###############################################################################################
44+
45+
validates :name, presence: true
46+
validates :alert_dashboard_id, presence: true
47+
48+
# Validation of conditions
49+
validate ->(this : Alert) do
50+
if !this.conditions.valid?
51+
this.conditions.errors.each do |e|
52+
this.validation_error(:condition, e.to_s)
53+
end
54+
end
55+
end
56+
57+
# Helpers
58+
###############################################################################################
59+
60+
def critical?
61+
severity == Severity::CRITICAL
62+
end
63+
64+
def high_priority?
65+
severity.in?([Severity::HIGH, Severity::CRITICAL])
66+
end
67+
end
68+
end

0 commit comments

Comments
 (0)