Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- +micrate Up
-- SQL in section 'Up' is executed when this migration is applied

CREATE TABLE IF NOT EXISTS "alert_dashboard"(
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
enabled BOOLEAN NOT NULL,
authority_id TEXT NOT NULL,
id TEXT NOT NULL PRIMARY KEY,
FOREIGN KEY (authority_id) REFERENCES authority(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS alert_dashboard_authority_id_index ON "alert_dashboard" USING BTREE (authority_id);

-- +micrate Down
-- SQL section 'Down' is executed when this migration is rolled back
DROP TABLE IF EXISTS "alert_dashboard"
70 changes: 70 additions & 0 deletions migration/db/migrations/20250917000000002_add_alert_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
-- +micrate Up
-- SQL in section 'Up' is executed when this migration is applied

-- +micrate StatementBegin
DO
$$
BEGIN
IF NOT EXISTS (SELECT *
FROM pg_type typ
INNER JOIN pg_namespace nsp
ON nsp.oid = typ.typnamespace
WHERE nsp.nspname = current_schema()
AND typ.typname = 'alert_severity') THEN
CREATE TYPE alert_severity AS ENUM (
'LOW',
'MEDIUM',
'HIGH',
'CRITICAL'
);
END IF;
END;
$$
LANGUAGE plpgsql;
-- +micrate StatementEnd

-- +micrate StatementBegin
DO
$$
BEGIN
IF NOT EXISTS (SELECT *
FROM pg_type typ
INNER JOIN pg_namespace nsp
ON nsp.oid = typ.typnamespace
WHERE nsp.nspname = current_schema()
AND typ.typname = 'alert_type') THEN
CREATE TYPE alert_type AS ENUM (
'THRESHOLD',
'STATUS',
'CUSTOM'
);
END IF;
END;
$$
LANGUAGE plpgsql;
-- +micrate StatementEnd

CREATE TABLE IF NOT EXISTS "alert"(
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
enabled BOOLEAN NOT NULL,
conditions JSONB NOT NULL,
severity public.alert_severity NOT NULL DEFAULT 'MEDIUM'::public.alert_severity,
alert_type public.alert_type NOT NULL DEFAULT 'THRESHOLD'::public.alert_type,
debounce_period INTEGER NOT NULL,
alert_dashboard_id TEXT NOT NULL,
id TEXT NOT NULL PRIMARY KEY,
FOREIGN KEY (alert_dashboard_id) REFERENCES alert_dashboard(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS alert_alert_dashboard_id_index ON "alert" USING BTREE (alert_dashboard_id);
CREATE INDEX IF NOT EXISTS alert_enabled_index ON "alert" USING BTREE (enabled);
CREATE INDEX IF NOT EXISTS alert_severity_index ON "alert" USING BTREE (severity);

-- +micrate Down
-- SQL section 'Down' is executed when this migration is rolled back
DROP TABLE IF EXISTS "alert";
DROP TYPE IF EXISTS public.alert_type;
DROP TYPE IF EXISTS public.alert_severity;
63 changes: 63 additions & 0 deletions spec/alert_dashboard_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
require "./helper"

module PlaceOS::Model
describe AlertDashboard do
test_round_trip(AlertDashboard)

it "saves an alert dashboard" do
authority = Generator.authority.save!
inst = Generator.alert_dashboard(authority_id: authority.id).save!
AlertDashboard.find!(inst.id.as(String)).id.should eq inst.id
end

it "validates required fields" do
invalid_model = Generator.alert_dashboard
invalid_model.name = ""
invalid_model.authority_id = nil

invalid_model.valid?.should be_false
invalid_model.errors.size.should eq 2
invalid_model.errors.map(&.field).should contain(:name)
invalid_model.errors.map(&.field).should contain(:authority_id)
end

it "belongs to authority" do
authority = Generator.authority.save!
dashboard = Generator.alert_dashboard(authority_id: authority.id).save!

dashboard.authority.should_not be_nil
dashboard.authority.try(&.id).should eq authority.id
end

it "has many alerts" do
authority = Generator.authority.save!
dashboard = Generator.alert_dashboard(authority_id: authority.id).save!
alert1 = Generator.alert(alert_dashboard_id: dashboard.id).save!
alert2 = Generator.alert(alert_dashboard_id: dashboard.id).save!

dashboard.alerts.size.should eq 2
dashboard.alerts.map(&.id).should contain(alert1.id)
dashboard.alerts.map(&.id).should contain(alert2.id)
end

it "counts alerts" do
authority = Generator.authority.save!
dashboard = Generator.alert_dashboard(authority_id: authority.id).save!
Generator.alert(alert_dashboard_id: dashboard.id).save!
Generator.alert(alert_dashboard_id: dashboard.id).save!

dashboard.alerts.count.should eq 2
end

it "filters active alerts" do
authority = Generator.authority.save!
dashboard = Generator.alert_dashboard(authority_id: authority.id).save!
active_alert = Generator.alert(alert_dashboard_id: dashboard.id, enabled: true).save!
Generator.alert(alert_dashboard_id: dashboard.id, enabled: false).save!

active_alerts = dashboard.active_alerts
active_alerts.size.should eq 1
active_alerts.first.id.should eq active_alert.id
end
end
end
113 changes: 113 additions & 0 deletions spec/alert_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
require "./helper"

module PlaceOS::Model
describe Alert do
test_round_trip(Alert)

it "saves an alert" do
authority = Generator.authority.save!
dashboard = Generator.alert_dashboard(authority_id: authority.id).save!
inst = Generator.alert(alert_dashboard_id: dashboard.id).save!
Alert.find!(inst.id.as(String)).id.should eq inst.id
end

it "validates required fields" do
invalid_model = Generator.alert
invalid_model.name = ""
invalid_model.alert_dashboard_id = nil

invalid_model.valid?.should be_false
invalid_model.errors.size.should eq 2
invalid_model.errors.map(&.field).should contain(:name)
invalid_model.errors.map(&.field).should contain(:alert_dashboard_id)
end

it "belongs to alert dashboard" do
authority = Generator.authority.save!
dashboard = Generator.alert_dashboard(authority_id: authority.id).save!
alert = Generator.alert(alert_dashboard_id: dashboard.id).save!

alert.alert_dashboard.should_not be_nil
alert.alert_dashboard.try(&.id).should eq dashboard.id
end

it "has default values" do
alert = Generator.alert
alert.enabled.should be_true
alert.severity.should eq Alert::Severity::MEDIUM
alert.alert_type.should eq Alert::AlertType::THRESHOLD
alert.debounce_period.should eq 60000
end

it "validates conditions" do
authority = Generator.authority.save!
dashboard = Generator.alert_dashboard(authority_id: authority.id).save!
model = Generator.alert(alert_dashboard_id: dashboard.id)

valid = Trigger::Conditions::TimeDependent.new(
type: Trigger::Conditions::TimeDependent::Type::At,
time: Time.utc,
)

invalid = Trigger::Conditions::TimeDependent.new(
cron: "5 * * * *",
)
model.conditions.try &.time_dependents = [valid, invalid]

model.valid?.should be_false
model.errors.size.should eq 1
model.errors.first.to_s.should end_with "type should not be nil"
end

describe "severity helpers" do
it "identifies critical alerts" do
alert = Generator.alert
alert.severity = Alert::Severity::CRITICAL
alert.critical?.should be_true

alert.severity = Alert::Severity::HIGH
alert.critical?.should be_false
end

it "identifies high priority alerts" do
alert = Generator.alert

alert.severity = Alert::Severity::CRITICAL
alert.high_priority?.should be_true

alert.severity = Alert::Severity::HIGH
alert.high_priority?.should be_true

alert.severity = Alert::Severity::MEDIUM
alert.high_priority?.should be_false

alert.severity = Alert::Severity::LOW
alert.high_priority?.should be_false
end
end

describe "enum validation" do
it "works with valid severity values" do
authority = Generator.authority.save!
dashboard = Generator.alert_dashboard(authority_id: authority.id).save!

Alert::Severity.values.each do |severity|
alert = Generator.alert(alert_dashboard_id: dashboard.id)
alert.severity = severity
alert.valid?.should be_true
end
end

it "works with valid alert type values" do
authority = Generator.authority.save!
dashboard = Generator.alert_dashboard(authority_id: authority.id).save!

Alert::AlertType.values.each do |alert_type|
alert = Generator.alert(alert_dashboard_id: dashboard.id)
alert.alert_type = alert_type
alert.valid?.should be_true
end
end
end
end
end
47 changes: 47 additions & 0 deletions spec/generator.cr
Original file line number Diff line number Diff line change
Expand Up @@ -694,5 +694,52 @@ module PlaceOS::Model
sent: sent,
)
end

def self.alert_dashboard(
name : String = Faker::Lorem.word,
description : String = Faker::Lorem.sentence,
enabled : Bool = true,
authority_id : String? = nil,
)
unless authority_id
# look up an existing authority
existing = Authority.find_by_domain("localhost")
authority = existing || self.authority.save!
authority_id = authority.id
end

AlertDashboard.new(
name: name,
description: description,
enabled: enabled,
authority_id: authority_id
)
end

def self.alert(
name : String = Faker::Lorem.word,
description : String = Faker::Lorem.sentence,
enabled : Bool = true,
severity : Alert::Severity = Alert::Severity::MEDIUM,
alert_type : Alert::AlertType = Alert::AlertType::THRESHOLD,
debounce_period : Int32 = 60000,
alert_dashboard_id : String? = nil,
)
unless alert_dashboard_id
# generate a dashboard if none provided
dashboard = self.alert_dashboard.save!
alert_dashboard_id = dashboard.id
end

Alert.new(
name: name,
description: description,
enabled: enabled,
severity: severity,
alert_type: alert_type,
debounce_period: debounce_period,
alert_dashboard_id: alert_dashboard_id
)
end
end
end
68 changes: 68 additions & 0 deletions src/placeos-models/alert.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
require "json"
require "./base/model"
require "./trigger/conditions"

module PlaceOS::Model
class Alert < ModelBase
include PlaceOS::Model::Timestamps

table :alert

enum Severity
LOW
MEDIUM
HIGH
CRITICAL
end

enum AlertType
THRESHOLD
STATUS
CUSTOM
end

attribute name : String, es_subfield: "keyword"
attribute description : String = ""
attribute enabled : Bool = true

# Reuse the same conditions structure as Trigger
attribute conditions : PlaceOS::Model::Trigger::Conditions = -> { PlaceOS::Model::Trigger::Conditions.new }, es_ignore: true

attribute severity : Severity = Severity::MEDIUM, converter: PlaceOS::Model::PGEnumConverter(PlaceOS::Model::Alert::Severity)
attribute alert_type : AlertType = AlertType::THRESHOLD, converter: PlaceOS::Model::PGEnumConverter(PlaceOS::Model::Alert::AlertType)

# In milliseconds - delay before showing notification to prevent flapping
attribute debounce_period : Int32 = 15000 # 15 seconds default

# Association
###############################################################################################

belongs_to AlertDashboard, foreign_key: "alert_dashboard_id"

# Validation
###############################################################################################

validates :name, presence: true
validates :alert_dashboard_id, presence: true

# Validation of conditions
validate ->(this : Alert) do
if !this.conditions.valid?
this.conditions.errors.each do |e|
this.validation_error(:condition, e.to_s)
end
end
end

# Helpers
###############################################################################################

def critical?
severity == Severity::CRITICAL
end

def high_priority?
severity.in?([Severity::HIGH, Severity::CRITICAL])
end
end
end
Loading