zabbix_manager 5.1.5 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -9
  3. data/LICENSE +1 -1
  4. data/README.md +241 -24
  5. data/lib/zabbix_manager/classes/actions.rb +19 -17
  6. data/lib/zabbix_manager/classes/applications.rb +11 -22
  7. data/lib/zabbix_manager/classes/configurations.rb +20 -10
  8. data/lib/zabbix_manager/classes/discovery_rules.rb +23 -0
  9. data/lib/zabbix_manager/classes/errors.rb +18 -27
  10. data/lib/zabbix_manager/classes/events.rb +8 -3
  11. data/lib/zabbix_manager/classes/graphs.rb +48 -37
  12. data/lib/zabbix_manager/classes/host_groups.rb +64 -0
  13. data/lib/zabbix_manager/classes/host_interfaces.rb +261 -0
  14. data/lib/zabbix_manager/classes/hosts.rb +232 -76
  15. data/lib/zabbix_manager/classes/http_tests.rb +32 -0
  16. data/lib/zabbix_manager/classes/items.rb +206 -72
  17. data/lib/zabbix_manager/classes/maintenance.rb +8 -3
  18. data/lib/zabbix_manager/classes/media_types.rb +18 -0
  19. data/lib/zabbix_manager/classes/problems.rb +51 -62
  20. data/lib/zabbix_manager/classes/proxies.rb +45 -14
  21. data/lib/zabbix_manager/classes/proxy_groups.rb +16 -0
  22. data/lib/zabbix_manager/classes/roles.rb +46 -41
  23. data/lib/zabbix_manager/classes/screens.rb +36 -24
  24. data/lib/zabbix_manager/classes/scripts.rb +16 -12
  25. data/lib/zabbix_manager/classes/server.rb +8 -1
  26. data/lib/zabbix_manager/classes/templates.rb +26 -51
  27. data/lib/zabbix_manager/classes/triggers.rb +251 -67
  28. data/lib/zabbix_manager/classes/user_groups.rb +63 -0
  29. data/lib/zabbix_manager/classes/user_macros.rb +110 -0
  30. data/lib/zabbix_manager/classes/users.rb +16 -23
  31. data/lib/zabbix_manager/classes/value_maps.rb +52 -0
  32. data/lib/zabbix_manager/client.rb +183 -137
  33. data/lib/zabbix_manager/configuration.rb +107 -0
  34. data/lib/zabbix_manager/http_transport.rb +240 -0
  35. data/lib/zabbix_manager/log_sanitizer.rb +58 -0
  36. data/lib/zabbix_manager/monitoring/device.rb +393 -0
  37. data/lib/zabbix_manager/monitoring/expressions.rb +36 -0
  38. data/lib/zabbix_manager/monitoring/line.rb +175 -0
  39. data/lib/zabbix_manager/monitoring/line_items.rb +110 -0
  40. data/lib/zabbix_manager/monitoring/line_lifecycle.rb +169 -0
  41. data/lib/zabbix_manager/monitoring/line_plan.rb +113 -0
  42. data/lib/zabbix_manager/monitoring/thresholds.rb +182 -0
  43. data/lib/zabbix_manager/monitoring/traffic_items.rb +132 -0
  44. data/lib/zabbix_manager/monitoring/validation.rb +92 -0
  45. data/lib/zabbix_manager/monitoring.rb +265 -0
  46. data/lib/zabbix_manager/resource.rb +235 -0
  47. data/lib/zabbix_manager/traffic.rb +238 -0
  48. data/lib/zabbix_manager/version.rb +1 -1
  49. data/lib/zabbix_manager.rb +110 -145
  50. data/zabbix_manager.gemspec +12 -15
  51. metadata +67 -30
  52. data/lib/zabbix_manager/basic/basic_alias.rb +0 -20
  53. data/lib/zabbix_manager/basic/basic_func.rb +0 -84
  54. data/lib/zabbix_manager/basic/basic_init.rb +0 -35
  55. data/lib/zabbix_manager/basic/basic_logic.rb +0 -195
  56. data/lib/zabbix_manager/classes/drules.rb +0 -41
  57. data/lib/zabbix_manager/classes/hostgroups.rb +0 -20
  58. data/lib/zabbix_manager/classes/hostinterfaces.rb +0 -29
  59. data/lib/zabbix_manager/classes/httptests.rb +0 -40
  60. data/lib/zabbix_manager/classes/mediatypes.rb +0 -79
  61. data/lib/zabbix_manager/classes/unusable.rb +0 -11
  62. data/lib/zabbix_manager/classes/usergroups.rb +0 -53
  63. data/lib/zabbix_manager/classes/usermacros.rb +0 -136
  64. data/lib/zabbix_manager/classes/valuemaps.rb +0 -32
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ZabbixManager
4
+ class Monitoring
5
+ # 根据 API 版本,将已校验的主机、监控项键和数值转换为触发器表达式。
6
+ # @api private
7
+ class Expressions
8
+ attr_reader :version
9
+
10
+ def initialize(client, host)
11
+ version = client.api_version.to_s
12
+ unless version.match?(/\A\d+\.\d+(?:\.\d+)?\z/)
13
+ raise Invalid, "Zabbix API version must be a numeric release version"
14
+ end
15
+
16
+ @host = host
17
+ @version = Gem::Version.new(version)
18
+ @modern = @version >= Gem::Version.new("5.4")
19
+ end
20
+
21
+ # 只接受上层已校验的函数、运算符、窗口和主机;这里再次校验嵌入表达式的 item key。
22
+ # last 不带窗口;其他函数按 5.4 前后语法生成,数值不使用科学计数法。
23
+ # @return [String] 一个可组合的比较表达式
24
+ def compare(key, function, window, operator, value)
25
+ Validation.item_key!(key)
26
+ call = if @modern
27
+ source = "/#{@host}/#{key}"
28
+ function == "last" ? "last(#{source})" : "#{function}(#{source},#{window})"
29
+ else
30
+ "{#{@host}:#{key}.#{function}(#{function == 'last' ? '' : window})}"
31
+ end
32
+ "#{call}#{operator}#{Validation.format_number(value)}"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ class ZabbixManager
6
+ class Monitoring
7
+ # 线路清单使用一套明确字段;外部文件列名应由导入方转换。
8
+ # @api private
9
+ class Line
10
+ FIELDS = %i[line_id host host_candidates interface_name capacity_mbps high_water recovery_water
11
+ problem_window recovery_window severity device isp description low_traffic status speed
12
+ reachability_target tags comments event_name opdata].freeze
13
+ METADATA_FIELDS = %i[comments event_name opdata].freeze
14
+ attr_reader :host_reference, :interface_name, :threshold, :low_traffic, :status, :speed,
15
+ :reachability_target, :metadata
16
+
17
+ # 仅解析本地定义,不访问服务器;status/speed 为开关或显式 itemid 选择器。
18
+ # @param attributes [Hash] 规范化后的单端点清单字段
19
+ # @raise [Invalid] 字段、阈值或目标格式不合法
20
+ def initialize(attributes)
21
+ @attributes = Validation.hash!(attributes, "line")
22
+ @attributes.assert_valid_keys(*FIELDS)
23
+ @interface_name = Validation.text!(@attributes[:interface_name], "line interface_name")
24
+ validate_identity!
25
+ @host_reference = build_host_reference
26
+ @threshold = build_threshold
27
+ Thresholds.validate_config!(:bandwidth, @threshold)
28
+ @low_traffic = build_low_traffic if @attributes.key?(:low_traffic)
29
+ @status = item_selection(:status)
30
+ @speed = item_selection(:speed)
31
+ @reachability_target = build_reachability_target if @attributes.key?(:reachability_target)
32
+ @metadata = @attributes.slice(*METADATA_FIELDS)
33
+ @metadata.each do |key, value|
34
+ raise Invalid, "line #{key} must be a string" unless value.is_a?(String)
35
+ end
36
+ rescue ArgumentError => error
37
+ raise Invalid, error.message
38
+ end
39
+
40
+ def identity
41
+ @attributes[:line_id].presence ||
42
+ [host_identity, TrafficItems.interface_identity(interface_name)]
43
+ end
44
+
45
+ def interface(hostid:)
46
+ { name: interface_name, line_id: resolved_id(hostid) }
47
+ end
48
+
49
+ # 显式 line_id 优先;缺省身份由已解析 hostid 和规范接口名组成。
50
+ # @return [String] 用于受管标签的端点身份
51
+ def resolved_id(hostid)
52
+ @attributes[:line_id].presence || "#{hostid}:#{TrafficItems.interface_identity(interface_name)}"
53
+ end
54
+
55
+ def safe_identity
56
+ @attributes.slice(:line_id, :device).merge(interface: interface_name).compact
57
+ end
58
+
59
+ private
60
+
61
+ def host_identity
62
+ host_reference.is_a?(Hash) ? host_reference[:hostid].to_s : host_reference.sort
63
+ end
64
+
65
+ def validate_identity!
66
+ line_id = @attributes[:line_id]
67
+ unless line_id.nil? || line_id.is_a?(String) || line_id.is_a?(Integer)
68
+ raise Invalid, "line_id must be a string or integer"
69
+ end
70
+
71
+ @attributes[:line_id] = line_id.to_s if line_id
72
+ %i[device isp description].each do |name|
73
+ next unless @attributes.key?(name)
74
+
75
+ Validation.text!(@attributes[name], "line #{name}")
76
+ end
77
+ end
78
+
79
+ def build_host_reference
80
+ if @attributes.key?(:host) && @attributes.key?(:host_candidates)
81
+ raise Invalid, "line must supply either host or host_candidates"
82
+ end
83
+
84
+ host = @attributes[:host]
85
+ if host.is_a?(Hash)
86
+ Validation.host!(host)
87
+ return host.slice(:hostid, :host)
88
+ end
89
+
90
+ candidates = @attributes.key?(:host_candidates) ?
91
+ Validation.array!(@attributes[:host_candidates], "host_candidates") : [host]
92
+ candidates = candidates.map { |value| Validation.text!(value, "line host candidate").strip }.uniq
93
+ raise Invalid, "line host candidate is required" if candidates.empty?
94
+
95
+ candidates
96
+ end
97
+
98
+ def build_threshold
99
+ capacity = Validation.number!(@attributes[:capacity_mbps], "line capacity_mbps")
100
+ raise Invalid, "line capacity_mbps must be positive" unless capacity.positive?
101
+
102
+ high = water_ratio(@attributes.fetch(:high_water, 0.9), "high_water")
103
+ recovery = water_ratio(@attributes.fetch(:recovery_water, 0.8), "recovery_water")
104
+ raise Invalid, "recovery_water must be lower than high_water" unless recovery < high
105
+
106
+ {
107
+ capacity_bps: capacity * 1_000_000,
108
+ high_percent: high * 100, recovery_percent: recovery * 100,
109
+ window: @attributes.fetch(:problem_window, Thresholds::DEFAULT_WINDOW),
110
+ recovery_window: @attributes.fetch(:recovery_window, "15m"),
111
+ priority: @attributes.fetch(:severity, Thresholds::DEFAULT_PRIORITY),
112
+ description: ["专线流量超阈值", @attributes[:isp], @attributes[:description],
113
+ @attributes[:device], interface_name].compact.join(" | "),
114
+ tags: line_tags
115
+ }
116
+ end
117
+
118
+ def line_tags
119
+ extra = Validation.array!(@attributes.fetch(:tags, []), "line tags").map do |tag|
120
+ tag = Validation.hash!(tag, "line tag")
121
+ tag.assert_valid_keys(:tag, :value)
122
+ Validation.text!(tag[:tag], "line tag name")
123
+ if (Thresholds::MANAGED_TAGS + ["line_id"]).include?(tag[:tag])
124
+ raise Invalid, "line tag #{tag[:tag]} is reserved"
125
+ end
126
+ raise Invalid, "line tag value must be a string" unless tag.fetch(:value, "").is_a?(String)
127
+
128
+ { tag: tag[:tag], value: tag.fetch(:value, "") }
129
+ end
130
+ [{ tag: "category", value: "line_bandwidth" }] +
131
+ @attributes.slice(:isp, :description, :device).map { |name, value| { tag: name.to_s, value: value } } + extra
132
+ end
133
+
134
+ def build_low_traffic
135
+ config = Validation.hash!(@attributes[:low_traffic], "low_traffic")
136
+ config.assert_valid_keys(:below_bps, :recovery_bps, :window, :recovery_window)
137
+ below = Validation.number!(config.fetch(:below_bps, 50), "low_traffic below_bps")
138
+ recovery = Validation.number!(config.fetch(:recovery_bps, 1000), "low_traffic recovery_bps")
139
+ unless below.positive? && recovery > below
140
+ raise Invalid, "low_traffic recovery_bps must be greater than positive below_bps"
141
+ end
142
+
143
+ window = Validation.window!(config.fetch(:window, "5m"))
144
+ { below_bps: below, recovery_bps: recovery, window: window,
145
+ recovery_window: Validation.window!(config.fetch(:recovery_window, window)) }
146
+ end
147
+
148
+ def item_selection(name)
149
+ value = @attributes.fetch(name, false)
150
+ return value if value == true || value == false
151
+
152
+ selection = Validation.hash!(value, name.to_s)
153
+ selection.assert_valid_keys(:itemid)
154
+ Validation.positive_id!(selection[:itemid], "#{name} itemid")
155
+ selection
156
+ end
157
+
158
+ def build_reachability_target
159
+ value = Validation.text!(@attributes[:reachability_target], "reachability_target")
160
+ raise Invalid, "reachability_target must be an IP address without a prefix" if value.include?("/")
161
+
162
+ IPAddr.new(value).to_s
163
+ rescue IPAddr::InvalidAddressError
164
+ raise Invalid, "reachability_target must be an IPv4 or IPv6 address"
165
+ end
166
+
167
+ def water_ratio(value, name)
168
+ ratio = Validation.number!(value, name)
169
+ raise Invalid, "#{name} must be greater than 0 and at most 1" unless ratio.positive? && ratio <= 1
170
+
171
+ ratio
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ZabbixManager
4
+ class Monitoring
5
+ # 解析端点的数值监控项,并复用主机已有的共享 ICMP 检查。
6
+ # 不重命名、启用、删除或接管已有 ICMP 监控项。
7
+ # @api private
8
+ class LineItems
9
+ PATTERNS = {
10
+ speed: /net\.if\.speed|ifhighspeed|interface speed|接口速率|1\.3\.6\.1\.2\.1\.31\.1\.1\.1\.15\./i,
11
+ status: /net\.if\.(?:status|oper)|ifoperstatus|operational status|接口状态|1\.3\.6\.1\.2\.1\.2\.2\.1\.8\./i
12
+ }.freeze
13
+
14
+ def initialize(items)
15
+ @items = items
16
+ end
17
+
18
+ # 两个流量方向必须唯一、不同且属于目标主机;附加指标只在选择器启用时解析。
19
+ # @return [Hash{Symbol => Hash}] 流量及可选状态/速率监控项
20
+ # @raise [Invalid, Conflict, ProtocolError] 量纲、身份、归属或响应结构无法确认
21
+ def resolve(line, candidates, hostid:)
22
+ unless candidates.is_a?(Array) && candidates.all? { |item| item.is_a?(Hash) }
23
+ raise ProtocolError, "item.get must return an array of item objects"
24
+ end
25
+
26
+ selected = TrafficItems.new(candidates).for_interface(line.interface_name)
27
+ %i[status speed].each do |kind|
28
+ selector = line.public_send(kind)
29
+ next unless selector
30
+
31
+ matches = candidates.select do |item|
32
+ if selector.is_a?(Hash)
33
+ item["itemid"].to_s == selector[:itemid].to_s
34
+ else
35
+ TrafficItems.interface_matches?(item, line.interface_name) &&
36
+ [item["key_"], item["name"], item["snmp_oid"]].join(" ").match?(PATTERNS.fetch(kind))
37
+ end
38
+ end
39
+ raise Conflict,
40
+ "expected one #{kind} item for #{line.interface_name}, found #{matches.length}" unless matches.one?
41
+
42
+ selected[kind] = matches.first.deep_symbolize_keys
43
+ end
44
+ selected.each do |kind, item|
45
+ validate_numeric!(item, kind)
46
+ raise ProtocolError, "#{kind} item belongs to another host" unless item[:hostid].to_s == hostid.to_s
47
+ end
48
+ unless selected.values.map { |item| item[:itemid].to_s }.uniq.length == selected.length
49
+ raise Conflict, "line metrics must refer to distinct items"
50
+ end
51
+
52
+ selected
53
+ end
54
+
55
+ # 查询稳定 ICMP key 并生成候选属性;缺失对象在执行阶段才创建。
56
+ # @return [Hash] attributes 及已有 itemid(不存在时为 nil)
57
+ def icmp_plan(hostid, target)
58
+ attributes = {
59
+ hostid: hostid, name: "ICMP #{target}", key_: "icmpping[#{target}]", type: 3,
60
+ value_type: 3, delay: "1m", history: "30d", trends: "365d", status: 0
61
+ }
62
+ current = find_icmp(attributes)
63
+ { attributes: attributes, itemid: current&.fetch(:itemid) }
64
+ end
65
+
66
+ # 在调用方工作流互斥锁内再次查询,不覆盖已有共享监控项。
67
+ def ensure_icmp(plan)
68
+ current = find_icmp(plan.fetch(:attributes))
69
+ return current.fetch(:itemid).to_i if current
70
+
71
+ @items.create(plan.fetch(:attributes))
72
+ end
73
+
74
+ private
75
+
76
+ def find_icmp(attributes)
77
+ matches = @items.for_host(attributes[:hostid], keys: attributes[:key_])
78
+ unless matches.is_a?(Array) && matches.all? { |item| item.is_a?(Hash) }
79
+ raise ProtocolError, "item.get must return an array of item objects"
80
+ end
81
+ raise Conflict, "multiple items use ICMP key #{attributes[:key_]}" if matches.length > 1
82
+ return if matches.empty?
83
+
84
+ item = matches.first.deep_symbolize_keys
85
+ unless item[:hostid].to_s == attributes[:hostid].to_s && item[:key_] == attributes[:key_]
86
+ raise ProtocolError, "item.get returned an ICMP item outside the requested host and key"
87
+ end
88
+
89
+ validate_numeric!(item, :reachability)
90
+ unless item[:type].to_s == "3" && item[:value_type].to_s == "3"
91
+ raise Conflict, "existing ICMP item must be an unsigned simple check"
92
+ end
93
+
94
+ item
95
+ end
96
+
97
+ def validate_numeric!(item, kind)
98
+ Validation.positive_id!(item[:itemid], "#{kind} itemid")
99
+ Validation.item_key!(item[:key_])
100
+ unless %w[0 3].include?(item[:value_type].to_s)
101
+ raise Invalid, "#{kind} item must have a numeric value_type"
102
+ end
103
+ raise Conflict, "#{kind} item must be enabled" unless item[:status].to_s == "0"
104
+ return unless kind == :status && item[:value_type].to_s != "3"
105
+
106
+ raise Invalid, "status item must use unsigned integer values with 1 meaning up"
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ZabbixManager
4
+ class Monitoring
5
+ # 通过原生 API 核对端点触发器归属、停用旧检查并读取问题。
6
+ # 不保存持久状态;以已确认的标签和 ID 界定管理范围。
7
+ # @api private
8
+ class LineLifecycle
9
+ KINDS = %i[interface_status bandwidth low_traffic reachability].freeze
10
+
11
+ def initialize(manager, hostid, line_id)
12
+ Validation.positive_id!(hostid, "hostid")
13
+ unless line_id.is_a?(String) || line_id.is_a?(Integer)
14
+ raise Invalid, "line_id must be a string or integer"
15
+ end
16
+
17
+ @line_id = Validation.text!(line_id.to_s, "line_id")
18
+ @prefix = "interface:#{@line_id}:"
19
+ @keys = KINDS.map { |kind| "#{@prefix}#{kind}" }
20
+ if @keys.any? { |key| key.length > Triggers::MAX_MANAGED_KEY_LENGTH }
21
+ raise Invalid, "managed_key is too long"
22
+ end
23
+
24
+ @manager, @hostid = manager, hostid
25
+ end
26
+
27
+ # 服务端前缀过滤后,再以完整受管键筛选并拒绝重复身份。
28
+ # @return [Array<Hash>] 该端点已知类型的原生触发器;所有者由 owned 继续筛选
29
+ def inventory
30
+ records = @manager.client.api_request(
31
+ method: "trigger.get",
32
+ params: {
33
+ hostids: @hostid, tags: [{ tag: "zabbix_manager_id", value: @prefix, operator: 0 }],
34
+ output: "extend", selectTags: "extend", selectItems: ["itemid", "name"], expandExpression: true
35
+ }
36
+ )
37
+ unless records.is_a?(Array) && records.all? { |record| record.is_a?(Hash) }
38
+ raise ProtocolError, "trigger.get must return an array of trigger objects"
39
+ end
40
+
41
+ records.each do |record|
42
+ tags = record["tags"]
43
+ valid_tags = tags.is_a?(Array) && tags.all? do |tag|
44
+ tag.is_a?(Hash) && tag["tag"].is_a?(String) && tag["value"].is_a?(String)
45
+ end
46
+ raise ProtocolError, "trigger.get must include valid tags for ownership verification" unless valid_tags
47
+ end
48
+ selected = records.select { |record| managed_key(record) }
49
+ selected.each { |record| validate_receipt!(record) }
50
+ ids = selected.map { |record| record["triggerid"].to_s }
51
+ raise ProtocolError, "trigger.get returned duplicate trigger IDs" unless ids.uniq == ids
52
+
53
+ identities = selected.map { |record| managed_key(record) }
54
+ raise Conflict, "multiple triggers use the same line managed key" unless identities.uniq == identities
55
+
56
+ selected
57
+ end
58
+
59
+ # 兼容缺少 line_id 的既有受管标签,但拒绝其他管理方或矛盾的端点标签。
60
+ # @return [Array<Hash>] 当前库拥有的触发器
61
+ def owned(records = inventory)
62
+ records.select do |record|
63
+ owner = tag_values(record, "managed_by")
64
+ ids = tag_values(record, "line_id")
65
+ owner == ["zabbix_manager"] && (ids.empty? || ids == [@line_id])
66
+ end
67
+ end
68
+
69
+ def validate_ownership!(records, desired_keys)
70
+ foreign = records - owned(records)
71
+ if foreign.any? { |record| desired_keys.include?(managed_key(record)) }
72
+ raise Conflict, "line managed key belongs to another owner"
73
+ end
74
+ end
75
+
76
+ def disable(records = owned)
77
+ return [] if records.empty?
78
+
79
+ @manager.triggers.set_status(hostid: @hostid, triggerids: records.map { |record| record.fetch("triggerid") },
80
+ enabled: false)
81
+ end
82
+
83
+ # 仅停用本轮期望之外的受管类型;调用方必须先确认所有期望触发器写入成功。
84
+ # @return [Array<Integer>] 已确认停用的 ID;不删除对象
85
+ def retire(records, desired_keys)
86
+ disable(owned(records).reject { |record| desired_keys.include?(managed_key(record)) })
87
+ end
88
+
89
+ def problems(time_from:, time_till:, limit:)
90
+ from = timestamp(time_from, "time_from")
91
+ till = timestamp(time_till, "time_till")
92
+ raise Invalid, "time_from must not be after time_till" if from > till
93
+ unless limit.is_a?(Integer) && limit.between?(1, 1000)
94
+ raise Invalid, "limit must be an integer between 1 and 1000"
95
+ end
96
+
97
+ ids = owned.map { |record| record.fetch("triggerid").to_s }
98
+ records = if ids.empty?
99
+ []
100
+ else
101
+ @manager.client.api_request(
102
+ method: "problem.get",
103
+ params: {
104
+ hostids: @hostid, objectids: ids, source: 0, object: 0, recent: true,
105
+ time_from: from, time_till: till, output: "extend", selectTags: "extend",
106
+ sortfield: "eventid", sortorder: "DESC", limit: limit + 1
107
+ }
108
+ )
109
+ end
110
+ unless records.is_a?(Array) && records.all? { |record| record.is_a?(Hash) }
111
+ raise ProtocolError, "problem.get must return an array of problem objects"
112
+ end
113
+ unless records.all? { |record| ids.include?(record["objectid"].to_s) }
114
+ raise ProtocolError, "problem.get returned a trigger outside the requested line"
115
+ end
116
+ raise ProtocolError, "problem.get exceeded the requested limit" if records.length > limit + 1
117
+
118
+ validate_problems!(records, from, till)
119
+
120
+ { problems: records.first(limit), truncated: records.length > limit, time_from: from, time_till: till,
121
+ limit: limit }
122
+ end
123
+
124
+ private
125
+
126
+ def tag_values(record, name)
127
+ Array(record["tags"]).filter_map do |tag|
128
+ tag["value"] if tag.is_a?(Hash) && tag["tag"] == name
129
+ end.uniq
130
+ end
131
+
132
+ def managed_key(record)
133
+ values = tag_values(record, "zabbix_manager_id")
134
+ matches = values & @keys
135
+ raise Conflict, "trigger has multiple line managed keys" if matches.any? && values.length > 1
136
+
137
+ matches.first
138
+ end
139
+
140
+ def validate_receipt!(record)
141
+ Validation.positive_id!(record["triggerid"], "triggerid")
142
+ rescue Invalid => error
143
+ raise ProtocolError, error.message
144
+ end
145
+
146
+ def timestamp(value, name)
147
+ value = value.to_i if value.is_a?(Time)
148
+ raise Invalid, "#{name} must be a non-negative Integer or Time" unless value.is_a?(Integer) && value >= 0
149
+
150
+ value
151
+ end
152
+
153
+ def validate_problems!(records, from, till)
154
+ records.each do |record|
155
+ Validation.positive_id!(record["eventid"], "eventid")
156
+ clock = record["clock"]
157
+ unless (clock.is_a?(Integer) || clock.is_a?(String)) && clock.to_s.match?(/\A\d+\z/) &&
158
+ clock.to_i.between?(from, till)
159
+ raise ProtocolError, "problem.get returned an invalid or out-of-range clock"
160
+ end
161
+ end
162
+ ids = records.map { |record| record["eventid"].to_s }
163
+ raise ProtocolError, "problem.get returned duplicate event IDs" unless ids.uniq == ids
164
+ rescue Invalid => error
165
+ raise ProtocolError, error.message
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ZabbixManager
4
+ class Monitoring
5
+ # 统一生成触发器属性,供预览和实际执行复用。
6
+ # 状态触发器 ID 确认之前,依赖以指标类型名称表示。
7
+ # @api private
8
+ class LinePlan
9
+ def initialize(client, line, host, items, icmp_item)
10
+ @client, @line, @host, @items, @icmp_item = client, line, host, items, icmp_item
11
+ @expressions = Expressions.new(client, host[:host])
12
+ @line_id = line.resolved_id(host[:hostid])
13
+ validate_metadata_version!
14
+ end
15
+
16
+ # 纯本地构造计划,不写远端;依赖保留为指标名称,执行时转换为已确认 triggerid。
17
+ # @return [Hash] 端点身份、监控项 ID、触发器属性及依赖计划
18
+ def to_h
19
+ triggers = {}
20
+ triggers[:interface_status] = status_trigger if @items[:status]
21
+ triggers[:bandwidth] = bandwidth_trigger
22
+ triggers[:low_traffic] = low_trigger if @line.low_traffic
23
+ triggers[:reachability] = reachability_trigger if @icmp_item
24
+ dependencies = triggers.keys.to_h do |kind|
25
+ [kind, @items[:status] && kind != :interface_status ? [:interface_status] : []]
26
+ end
27
+ itemids = @items.transform_values { |item| item.fetch(:itemid).to_i }
28
+ itemids[:reachability] = @icmp_item[:itemid].to_i if @icmp_item&.dig(:itemid)
29
+ {
30
+ hostid: @host[:hostid].to_i, host: @host[:host], interface: @line.interface_name, line_id: @line_id,
31
+ itemids: itemids, triggers: triggers, dependencies: dependencies, icmp_item: @icmp_item
32
+ }
33
+ end
34
+
35
+ private
36
+
37
+ def validate_metadata_version!
38
+ { opdata: "4.4", event_name: "5.2" }.each do |field, minimum|
39
+ next unless @line.metadata.key?(field) && @expressions.version < Gem::Version.new(minimum)
40
+
41
+ raise Invalid, "#{field} requires Zabbix #{minimum} or newer"
42
+ end
43
+ end
44
+
45
+ def bandwidth_trigger
46
+ attributes = Thresholds.new(@client).prepare_triggers(
47
+ host: @host, interface: @line.interface(hostid: @host[:hostid]),
48
+ items: { inbound_bps: @items[:inbound], outbound_bps: @items[:outbound] },
49
+ thresholds: { bandwidth: @line.threshold }
50
+ ).fetch(:bandwidth)
51
+ attributes[:expression] = guarded(attributes[:expression])
52
+ attributes[:recovery_expression] = guarded(attributes[:recovery_expression])
53
+ decorate(attributes)
54
+ end
55
+
56
+ def low_trigger
57
+ config = @line.low_traffic
58
+ problem = traffic_expression(config[:window], "<", config[:below_bps], "and")
59
+ recovery = traffic_expression(config[:recovery_window], ">=", config[:recovery_bps], "or")
60
+ trigger(:low_traffic, guarded(problem), guarded(recovery), "low traffic")
61
+ end
62
+
63
+ def status_trigger
64
+ key = @items.fetch(:status).fetch(:key_)
65
+ trigger(:interface_status, compare(key, "last", nil, "<>", 1),
66
+ compare(key, "last", nil, "=", 1), "interface down")
67
+ end
68
+
69
+ def reachability_trigger
70
+ key = @icmp_item.fetch(:attributes).fetch(:key_)
71
+ trigger(:reachability, compare(key, "max", "5m", "=", 0),
72
+ compare(key, "min", "2m", "=", 1), "ICMP unreachable")
73
+ end
74
+
75
+ def trigger(kind, problem, recovery, label)
76
+ key = "interface:#{@line_id}:#{kind}"
77
+ raise Invalid, "managed_key is too long" if key.length > Triggers::MAX_MANAGED_KEY_LENGTH
78
+
79
+ decorate(
80
+ hostid: @host[:hostid], managed_key: key,
81
+ description: "#{@line.interface_name} #{label}", expression: problem,
82
+ recovery_mode: 1, recovery_expression: recovery, priority: @line.threshold[:priority], manual_close: 1,
83
+ tags: [
84
+ { tag: "managed_by", value: "zabbix_manager" }, { tag: "interface", value: @line.interface_name },
85
+ { tag: "metric", value: kind.to_s }
86
+ ] + @line.threshold[:tags]
87
+ )
88
+ end
89
+
90
+ def decorate(attributes)
91
+ attributes.merge(@line.metadata).merge(
92
+ status: 0, tags: (attributes[:tags] + [{ tag: "line_id", value: @line_id }]).uniq
93
+ )
94
+ end
95
+
96
+ def guarded(expression)
97
+ return expression unless @items[:speed]
98
+
99
+ "(#{expression}) and #{compare(@items[:speed][:key_], 'last', nil, '>', 0)}"
100
+ end
101
+
102
+ def traffic_expression(window, operator, value, joiner)
103
+ %i[inbound outbound].map do |direction|
104
+ compare(@items.fetch(direction).fetch(:key_), "avg", window, operator, value)
105
+ end.join(" #{joiner} ")
106
+ end
107
+
108
+ def compare(...)
109
+ @expressions.compare(...)
110
+ end
111
+ end
112
+ end
113
+ end