zabbix_manager 5.1.4 → 5.1.6
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -9
- data/LICENSE +1 -1
- data/README.md +202 -8
- data/lib/zabbix_manager/basic/basic_alias.rb +25 -3
- data/lib/zabbix_manager/basic/basic_func.rb +32 -61
- data/lib/zabbix_manager/basic/basic_init.rb +28 -13
- data/lib/zabbix_manager/basic/basic_logic.rb +172 -128
- data/lib/zabbix_manager/classes/actions.rb +21 -11
- data/lib/zabbix_manager/classes/applications.rb +12 -17
- data/lib/zabbix_manager/classes/configurations.rb +20 -5
- data/lib/zabbix_manager/classes/drules.rb +11 -23
- data/lib/zabbix_manager/classes/errors.rb +16 -28
- data/lib/zabbix_manager/classes/events.rb +6 -2
- data/lib/zabbix_manager/classes/graphs.rb +41 -34
- data/lib/zabbix_manager/classes/hostgroups.rb +53 -3
- data/lib/zabbix_manager/classes/hostinterfaces.rb +172 -12
- data/lib/zabbix_manager/classes/hosts.rb +197 -68
- data/lib/zabbix_manager/classes/httptests.rb +18 -20
- data/lib/zabbix_manager/classes/items.rb +199 -72
- data/lib/zabbix_manager/classes/maintenance.rb +6 -2
- data/lib/zabbix_manager/classes/mediatypes.rb +7 -63
- data/lib/zabbix_manager/classes/problems.rb +75 -57
- data/lib/zabbix_manager/classes/proxies.rb +48 -7
- data/lib/zabbix_manager/classes/roles.rb +47 -36
- data/lib/zabbix_manager/classes/screens.rb +45 -14
- data/lib/zabbix_manager/classes/scripts.rb +17 -7
- data/lib/zabbix_manager/classes/server.rb +7 -1
- data/lib/zabbix_manager/classes/templates.rb +59 -21
- data/lib/zabbix_manager/classes/triggers.rb +253 -66
- data/lib/zabbix_manager/classes/usergroups.rb +28 -6
- data/lib/zabbix_manager/classes/usermacros.rb +119 -33
- data/lib/zabbix_manager/classes/users.rb +39 -7
- data/lib/zabbix_manager/classes/valuemaps.rb +13 -19
- data/lib/zabbix_manager/client.rb +311 -135
- data/lib/zabbix_manager/http_transport.rb +192 -0
- data/lib/zabbix_manager/log_sanitizer.rb +68 -0
- data/lib/zabbix_manager/monitoring.rb +691 -0
- data/lib/zabbix_manager/version.rb +1 -1
- data/lib/zabbix_manager.rb +118 -40
- data/zabbix_manager.gemspec +21 -8
- metadata +208 -22
- data/lib/zabbix_manager/classes/unusable.rb +0 -11
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class ZabbixManager
|
|
4
|
+
# 封装网络设备、接口和线路监控的高频幂等业务流程。
|
|
5
|
+
class Monitoring
|
|
6
|
+
DEFAULT_WINDOW = "5m"
|
|
7
|
+
DEFAULT_PRIORITY = 3
|
|
8
|
+
SUPPORTED_FUNCTIONS = %w[avg max min last].freeze
|
|
9
|
+
EXPRESSION_ITEM_KEY = /\A[A-Za-z0-9_.-]+(?:\[[A-Za-z0-9_.:,\/\-\s{}\#\$"']*\])?\z/.freeze
|
|
10
|
+
|
|
11
|
+
# 使用同一个 Manager 复用 API 会话和资源模块。
|
|
12
|
+
# @return [Monitoring] 监控业务对象
|
|
13
|
+
# @api public
|
|
14
|
+
def initialize(manager)
|
|
15
|
+
@manager = manager
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# 创建或更新受监控的网络设备。
|
|
19
|
+
# @return [Integer] Zabbix 主机 ID
|
|
20
|
+
# @api public
|
|
21
|
+
def reconcile_device(data)
|
|
22
|
+
@manager.hosts.reconcile(data)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# 先校验整批设备,再按技术主机名逐台幂等创建或更新。
|
|
26
|
+
# 默认保留逐台结果;fail_fast 为 true 时遇到首个运行期错误即停止。
|
|
27
|
+
# @return [Array<Hash>]
|
|
28
|
+
def reconcile_devices(collection, fail_fast: false)
|
|
29
|
+
devices = Array(collection).map { |device| @manager.hosts.validate(device) }
|
|
30
|
+
validate_device_batch!(devices)
|
|
31
|
+
devices.map do |device|
|
|
32
|
+
hostid = reconcile_device(device)
|
|
33
|
+
{ status: :ok, device: safe_device_identity(device), hostid: hostid }
|
|
34
|
+
rescue ApiError, TransportError, ArgumentError => e
|
|
35
|
+
raise if fail_fast
|
|
36
|
+
|
|
37
|
+
failure_result(:device, safe_device_identity(device), e)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# 分设备和线路两个阶段执行一次网络监控批次,并返回稳定的汇总结果。
|
|
42
|
+
# 线路依赖模板或自动发现产生的流量项;尚未生成时会记录为线路失败,后续可安全重跑。
|
|
43
|
+
# @return [Hash]
|
|
44
|
+
def reconcile_network(devices:, lines:, fail_fast: false)
|
|
45
|
+
validate_line_collection!(lines)
|
|
46
|
+
device_results = reconcile_devices(devices, fail_fast: fail_fast)
|
|
47
|
+
line_results = reconcile_lines(lines, fail_fast: fail_fast)
|
|
48
|
+
results = { devices: device_results, lines: line_results }
|
|
49
|
+
results.merge(summary: batch_summary(results))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# 使用 Zabbix 已发现的流量项创建或更新线路阈值触发器。
|
|
53
|
+
# 兼容 add_line_monitors.rb 的历史字段名。
|
|
54
|
+
# @return [Hash] 主机、接口、监控项和触发器结果
|
|
55
|
+
# @api public
|
|
56
|
+
def reconcile_line(data = nil, **attributes)
|
|
57
|
+
reconcile_line_with_cache(data || attributes, nil)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# 批量同步线路并复用主机和监控项查询缓存。
|
|
61
|
+
# 默认逐条记录成功或失败;fail_fast 为 true 时立即抛出错误。
|
|
62
|
+
# @return [Array<Hash>] 每条线路的状态和结果
|
|
63
|
+
# @api public
|
|
64
|
+
def reconcile_lines(collection, fail_fast: false)
|
|
65
|
+
lines = validate_line_collection!(collection)
|
|
66
|
+
cache = { items: {}, hosts: {} }
|
|
67
|
+
lines.map do |line|
|
|
68
|
+
{ status: :ok, result: reconcile_line_with_cache(line, cache) }
|
|
69
|
+
rescue ApiError, TransportError, ArgumentError => e
|
|
70
|
+
raise if fail_fast
|
|
71
|
+
|
|
72
|
+
failure_result(:line, safe_line_identity(line), e)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# 创建或更新一个设备接口的监控项和阈值触发器。
|
|
77
|
+
#
|
|
78
|
+
# 必需输入:
|
|
79
|
+
# host: { hostid: 10101, host: "router-01" }
|
|
80
|
+
# interface: { name: "GigabitEthernet1/0/1", interfaceid: 12 }
|
|
81
|
+
# items: { inbound_bps: { key_: "...", name: "...", type: 4, value_type: 3 }, ... }
|
|
82
|
+
#
|
|
83
|
+
# 阈值支持 bandwidth、errors 和 packet_loss,只增改不自动删除远端对象。
|
|
84
|
+
# @return [Hash] 监控项和触发器 ID
|
|
85
|
+
# @api public
|
|
86
|
+
def reconcile_interface(data)
|
|
87
|
+
input = data.deep_symbolize_keys
|
|
88
|
+
host = resolve_explicit_host(input.fetch(:host))
|
|
89
|
+
interface = input.fetch(:interface)
|
|
90
|
+
validate_interface!(interface)
|
|
91
|
+
|
|
92
|
+
prepared_items, item_keys = prepare_items(host, interface, input.fetch(:items, {}))
|
|
93
|
+
thresholds = input.fetch(:thresholds, {})
|
|
94
|
+
validate_threshold_items!(thresholds, prepared_items)
|
|
95
|
+
prepared_triggers = prepare_thresholds(
|
|
96
|
+
host: host,
|
|
97
|
+
interface: interface,
|
|
98
|
+
item_keys: item_keys,
|
|
99
|
+
thresholds: thresholds
|
|
100
|
+
)
|
|
101
|
+
itemids = reconcile_items(prepared_items)
|
|
102
|
+
triggerids = reconcile_thresholds(prepared_triggers)
|
|
103
|
+
|
|
104
|
+
{
|
|
105
|
+
hostid: host[:hostid].to_i,
|
|
106
|
+
interface: interface[:name],
|
|
107
|
+
itemids: itemids,
|
|
108
|
+
triggerids: triggerids
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# 解析单条线路、发现流量项并同步阈值触发器。
|
|
115
|
+
def reconcile_line_with_cache(data, cache)
|
|
116
|
+
input = data.deep_symbolize_keys
|
|
117
|
+
host = resolve_line_host(input, cache: cache)
|
|
118
|
+
interface_name = input[:interface_name] || input[:iface] || input[:iface1]
|
|
119
|
+
raise Invalid, "line interface_name is required" if interface_name.blank?
|
|
120
|
+
|
|
121
|
+
traffic = find_line_traffic_items(host.fetch(:hostid), interface_name, cache: cache)
|
|
122
|
+
capacity_mbps = numeric!(input[:capacity_mbps] || input[:capacity], "line capacity_mbps")
|
|
123
|
+
raise Invalid, "line capacity_mbps must be positive" unless capacity_mbps.positive?
|
|
124
|
+
|
|
125
|
+
high_water = water_ratio!(input.fetch(:high_water, 0.90), "high_water")
|
|
126
|
+
recovery_water = water_ratio!(input.fetch(:recovery_water, 0.80), "recovery_water")
|
|
127
|
+
line_identity = input[:line_id].presence || input[:id].presence ||
|
|
128
|
+
"#{host.fetch(:hostid)}:#{canonical_interface_identity(interface_name)}"
|
|
129
|
+
interface = { name: interface_name, line_id: line_identity }
|
|
130
|
+
item_keys = {
|
|
131
|
+
inbound_bps: traffic.fetch(:inbound).fetch("key_"),
|
|
132
|
+
outbound_bps: traffic.fetch(:outbound).fetch("key_")
|
|
133
|
+
}
|
|
134
|
+
prepared = line_thresholds(input, host, interface, item_keys, capacity_mbps, high_water, recovery_water)
|
|
135
|
+
triggerid = reconcile_thresholds(prepared).fetch(:bandwidth)
|
|
136
|
+
|
|
137
|
+
{
|
|
138
|
+
hostid: host.fetch(:hostid).to_i,
|
|
139
|
+
host: host.fetch(:host),
|
|
140
|
+
interface: interface_name,
|
|
141
|
+
itemids: { inbound: traffic[:inbound]["itemid"].to_i, outbound: traffic[:outbound]["itemid"].to_i },
|
|
142
|
+
triggerid: triggerid
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# 根据线路字段组装带宽阈值和历史迁移标签。
|
|
147
|
+
def line_thresholds(input, host, interface, item_keys, capacity_mbps, high_water, recovery_water)
|
|
148
|
+
prepared = prepare_thresholds(
|
|
149
|
+
host: host,
|
|
150
|
+
interface: interface,
|
|
151
|
+
item_keys: item_keys,
|
|
152
|
+
thresholds: {
|
|
153
|
+
bandwidth: {
|
|
154
|
+
capacity_bps: capacity_mbps * 1_000_000,
|
|
155
|
+
high_percent: high_water * 100,
|
|
156
|
+
recovery_percent: recovery_water * 100,
|
|
157
|
+
window: input.fetch(:problem_window, DEFAULT_WINDOW),
|
|
158
|
+
recovery_window: input.fetch(:recovery_window, "15m"),
|
|
159
|
+
priority: input.fetch(:severity, DEFAULT_PRIORITY),
|
|
160
|
+
description: line_trigger_description(input, interface[:name]),
|
|
161
|
+
tags: line_tags(input, interface[:name])
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
prepared[:bandwidth][:legacy_identity_tags] = [
|
|
166
|
+
{ tag: "category", value: "line_bandwidth", operator: 1 }
|
|
167
|
+
]
|
|
168
|
+
prepared[:bandwidth][:comments] = line_comments(input, capacity_mbps, high_water, recovery_water)
|
|
169
|
+
prepared
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# 补齐监控项的主机、接口和名称,并检查 key 唯一性。
|
|
173
|
+
def prepare_items(host, interface, items)
|
|
174
|
+
prepared = items.each_with_object({}) do |(metric, raw_attributes), result|
|
|
175
|
+
attributes = raw_attributes.deep_symbolize_keys
|
|
176
|
+
attributes[:hostid] = host[:hostid]
|
|
177
|
+
attributes[:interfaceid] ||= interface[:interfaceid] if interface[:interfaceid]
|
|
178
|
+
attributes[:name] ||= "#{interface[:name]} #{metric.to_s.tr("_", " ")}"
|
|
179
|
+
raise Invalid, "item #{metric} key_ is required" if attributes[:key_].blank?
|
|
180
|
+
unless attributes[:key_].to_s.match?(EXPRESSION_ITEM_KEY)
|
|
181
|
+
raise Invalid, "item #{metric} key_ contains unsupported expression characters"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
result[metric.to_sym] = attributes
|
|
185
|
+
end
|
|
186
|
+
keys = prepared.transform_values { |attributes| attributes.fetch(:key_) }
|
|
187
|
+
if keys.values.uniq.length != keys.length
|
|
188
|
+
raise Invalid, "item key_ values must be unique within an interface"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
[prepared, keys]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# 把业务阈值转换为 Zabbix 问题和恢复表达式。
|
|
195
|
+
def prepare_thresholds(host:, interface:, item_keys:, thresholds:)
|
|
196
|
+
thresholds.each_with_object({}) do |(metric, raw_config), result|
|
|
197
|
+
config = raw_config.deep_symbolize_keys
|
|
198
|
+
keys = threshold_item_keys(metric, config, item_keys)
|
|
199
|
+
high, recovery = threshold_values(metric, config)
|
|
200
|
+
validate_threshold!(metric, high, recovery)
|
|
201
|
+
window = validate_window!(config.fetch(:window, DEFAULT_WINDOW))
|
|
202
|
+
recovery_window = validate_window!(config.fetch(:recovery_window, window))
|
|
203
|
+
function = validate_function!(config.fetch(:function, "avg"))
|
|
204
|
+
description = config[:description] || "#{interface[:name]} #{metric.to_s.tr("_", " ")} high"
|
|
205
|
+
raise Invalid, "threshold #{metric} description is required" if description.blank?
|
|
206
|
+
|
|
207
|
+
priority = validate_priority!(config.fetch(:priority, DEFAULT_PRIORITY))
|
|
208
|
+
|
|
209
|
+
result[metric.to_sym] = {
|
|
210
|
+
hostid: host[:hostid],
|
|
211
|
+
description: description,
|
|
212
|
+
expression: combine_expressions(host[:host], keys, function, window, ">", high, "or"),
|
|
213
|
+
recovery_mode: 1,
|
|
214
|
+
recovery_expression: combine_expressions(
|
|
215
|
+
host[:host], keys, function, recovery_window, "<", recovery, "and"
|
|
216
|
+
),
|
|
217
|
+
priority: priority,
|
|
218
|
+
manual_close: 1,
|
|
219
|
+
tags: managed_tags(interface[:name], metric, config[:tags])
|
|
220
|
+
}
|
|
221
|
+
result[metric.to_sym][:managed_key] = managed_key(interface, metric)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# 在远端写入前校验各指标的单位和速率语义。
|
|
226
|
+
def validate_threshold_items!(thresholds, items)
|
|
227
|
+
thresholds.each do |metric, raw_config|
|
|
228
|
+
config = raw_config.deep_symbolize_keys
|
|
229
|
+
names = Array(config[:metrics] || default_metrics(metric)).map(&:to_sym)
|
|
230
|
+
selected = names.map do |name|
|
|
231
|
+
items.fetch(name) { raise Invalid, "threshold #{metric} requires item metric #{name}" }
|
|
232
|
+
end
|
|
233
|
+
case metric.to_sym
|
|
234
|
+
when :bandwidth
|
|
235
|
+
selected.each { |item| validate_bps_item!(item) }
|
|
236
|
+
when :errors
|
|
237
|
+
selected.each { |item| validate_error_rate_item!(item) }
|
|
238
|
+
when :packet_loss
|
|
239
|
+
selected.each { |item| validate_packet_loss_item!(item) }
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# 确认误码指标表示每秒速率而不是累计计数器。
|
|
245
|
+
def validate_error_rate_item!(item)
|
|
246
|
+
values = item.with_indifferent_access
|
|
247
|
+
preprocessing = Array(values[:preprocessing])
|
|
248
|
+
rate_step = preprocessing.any? { |step| (step["type"] || step[:type]).to_i == 10 }
|
|
249
|
+
explicit_rate = [values[:key_], values[:name]].join(" ").match?(/rate|per[._ -]?second/i)
|
|
250
|
+
return if rate_step || explicit_rate
|
|
251
|
+
|
|
252
|
+
raise Invalid, "error metric #{values[:key_]} must represent a per-second rate"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# 确认丢包指标使用百分比单位。
|
|
256
|
+
def validate_packet_loss_item!(item)
|
|
257
|
+
values = item.with_indifferent_access
|
|
258
|
+
return if values[:units].to_s == "%"
|
|
259
|
+
|
|
260
|
+
raise Invalid, "packet loss metric #{values[:key_]} must use % units"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# 按稳定 key 写入已准备好的监控项。
|
|
264
|
+
def reconcile_items(prepared_items)
|
|
265
|
+
prepared_items.transform_values { |attributes| @manager.items.upsert_by_key(attributes) }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# 按稳定管理标签写入已准备好的触发器。
|
|
269
|
+
def reconcile_thresholds(prepared_triggers)
|
|
270
|
+
prepared_triggers.transform_values { |attributes| @manager.triggers.upsert_for_host(attributes) }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# 在批量写入前拒绝重复线路 ID。
|
|
274
|
+
def validate_line_batch!(lines)
|
|
275
|
+
identities = lines.filter_map { |line| line[:line_id].presence || line[:id].presence }.map(&:to_s)
|
|
276
|
+
duplicate = identities.tally.find { |_identity, count| count > 1 }&.first
|
|
277
|
+
raise Invalid, "duplicate line_id #{duplicate}" if duplicate
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# 规范化并完整预检线路集合,保证校验阶段不执行远端写入。
|
|
281
|
+
def validate_line_collection!(collection)
|
|
282
|
+
lines = Array(collection).map(&:deep_symbolize_keys)
|
|
283
|
+
validate_line_batch!(lines)
|
|
284
|
+
lines.each { |line| validate_line_definition!(line) }
|
|
285
|
+
lines
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# 在任何远端写入前校验线路必填字段和阈值格式。
|
|
289
|
+
def validate_line_definition!(line)
|
|
290
|
+
validate_line_host_reference!(line)
|
|
291
|
+
interface_name = line[:interface_name] || line[:iface] || line[:iface1]
|
|
292
|
+
raise Invalid, "line interface_name is required" if interface_name.blank?
|
|
293
|
+
|
|
294
|
+
capacity = numeric!(line[:capacity_mbps] || line[:capacity], "line capacity_mbps")
|
|
295
|
+
raise Invalid, "line capacity_mbps must be positive" unless capacity.positive?
|
|
296
|
+
|
|
297
|
+
high_water = water_ratio!(line.fetch(:high_water, 0.90), "high_water")
|
|
298
|
+
recovery_water = water_ratio!(line.fetch(:recovery_water, 0.80), "recovery_water")
|
|
299
|
+
raise Invalid, "recovery_water must be lower than high_water" unless recovery_water < high_water
|
|
300
|
+
|
|
301
|
+
validate_window!(line.fetch(:problem_window, DEFAULT_WINDOW))
|
|
302
|
+
validate_window!(line.fetch(:recovery_window, "15m"))
|
|
303
|
+
validate_priority!(line.fetch(:severity, DEFAULT_PRIORITY))
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# 校验线路提供显式主机对象或至少一个可解析的设备候选值。
|
|
307
|
+
def validate_line_host_reference!(line)
|
|
308
|
+
supplied = line[:host]
|
|
309
|
+
if supplied.respond_to?(:deep_symbolize_keys)
|
|
310
|
+
validate_host!(supplied.deep_symbolize_keys)
|
|
311
|
+
return
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
candidates = Array(line[:host_candidates])
|
|
315
|
+
candidates.concat([supplied, line[:device], line[:device1], line[:ip], line[:ipaddr1], line[:serial]])
|
|
316
|
+
candidates = candidates.filter_map { |value| value.to_s.strip.presence }
|
|
317
|
+
candidates.delete("IP地址")
|
|
318
|
+
raise Invalid, "line host candidate is required" if candidates.empty?
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# 在任何远端写入前拒绝同一技术主机名的重复设备定义。
|
|
322
|
+
def validate_device_batch!(devices)
|
|
323
|
+
identities = devices.map { |device| device.fetch(:host).to_s }
|
|
324
|
+
duplicate = identities.tally.find { |_identity, count| count > 1 }&.first
|
|
325
|
+
raise Invalid, "duplicate device host #{duplicate}" if duplicate
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# 只返回可安全记录的设备身份字段。
|
|
329
|
+
def safe_device_identity(device)
|
|
330
|
+
{ host: device[:host], name: device[:name] }.compact
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# 构造设备或线路的脱敏失败结果。
|
|
334
|
+
def failure_result(kind, identity, error)
|
|
335
|
+
{
|
|
336
|
+
status: :error,
|
|
337
|
+
kind => identity,
|
|
338
|
+
error: { class: error.class.name, message: LogSanitizer.sanitize(error.message) }
|
|
339
|
+
}
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# 汇总批次两个阶段的成功与失败数量。
|
|
343
|
+
def batch_summary(results)
|
|
344
|
+
results.each_with_object({}) do |(kind, entries), summary|
|
|
345
|
+
summary[kind] = {
|
|
346
|
+
total: entries.length,
|
|
347
|
+
succeeded: entries.count { |entry| entry[:status] == :ok },
|
|
348
|
+
failed: entries.count { |entry| entry[:status] == :error }
|
|
349
|
+
}
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# 只返回可安全记录的线路身份字段。
|
|
354
|
+
def safe_line_identity(line)
|
|
355
|
+
{
|
|
356
|
+
line_id: line[:line_id].presence || line[:id].presence,
|
|
357
|
+
device: line[:device] || line[:device1],
|
|
358
|
+
interface: line[:interface_name] || line[:iface] || line[:iface1]
|
|
359
|
+
}.compact
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# 解析一个阈值引用的监控项 key 集合。
|
|
363
|
+
def threshold_item_keys(metric, config, item_keys)
|
|
364
|
+
metric_names = Array(config[:metrics] || default_metrics(metric))
|
|
365
|
+
keys = metric_names.map do |name|
|
|
366
|
+
item_keys.fetch(name.to_sym) do
|
|
367
|
+
raise Invalid, "threshold #{metric} requires item metric #{name}"
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
raise Invalid, "threshold #{metric} requires at least one item" if keys.empty?
|
|
371
|
+
|
|
372
|
+
keys
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# 返回常用指标默认依赖的监控项名称。
|
|
376
|
+
def default_metrics(metric)
|
|
377
|
+
case metric.to_sym
|
|
378
|
+
when :bandwidth then %i[inbound_bps outbound_bps]
|
|
379
|
+
when :errors then %i[in_errors out_errors]
|
|
380
|
+
when :packet_loss then [:packet_loss]
|
|
381
|
+
else [metric.to_sym]
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# 计算带宽百分比或普通指标的高低阈值。
|
|
386
|
+
def threshold_values(metric, config)
|
|
387
|
+
if metric.to_sym == :bandwidth
|
|
388
|
+
capacity = numeric!(config[:capacity_bps], "bandwidth capacity_bps")
|
|
389
|
+
high_percent = numeric!(config[:high_percent], "bandwidth high_percent")
|
|
390
|
+
recovery_percent = numeric!(config[:recovery_percent], "bandwidth recovery_percent")
|
|
391
|
+
unless capacity.positive? && high_percent.between?(0, 100) && recovery_percent.between?(0, 100)
|
|
392
|
+
raise Invalid, "bandwidth capacity must be positive and percentages must be between 0 and 100"
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
[capacity * high_percent / 100.0, capacity * recovery_percent / 100.0]
|
|
396
|
+
else
|
|
397
|
+
[numeric!(config[:high], "#{metric} high"), numeric!(config[:recovery], "#{metric} recovery")]
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# 校验恢复阈值低于问题阈值,形成滞回区间。
|
|
402
|
+
def validate_threshold!(metric, high, recovery)
|
|
403
|
+
return if high.positive? && recovery >= 0 && recovery < high
|
|
404
|
+
|
|
405
|
+
raise Invalid, "#{metric} recovery threshold must be non-negative and lower than high threshold"
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# 组合多个方向的触发器表达式。
|
|
409
|
+
def combine_expressions(host, keys, function, window, operator, threshold, joiner)
|
|
410
|
+
keys.map do |key|
|
|
411
|
+
function_call = expression_function(host, key, function, window)
|
|
412
|
+
"#{function_call}#{operator}#{format_number(threshold)}"
|
|
413
|
+
end.join(" #{joiner} ")
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# 按 Zabbix 版本生成新式或旧式函数表达式。
|
|
417
|
+
def expression_function(host, key, function, window)
|
|
418
|
+
if modern_trigger_expression?
|
|
419
|
+
source = "/#{host}/#{key}"
|
|
420
|
+
function == "last" ? "last(#{source})" : "#{function}(#{source},#{window})"
|
|
421
|
+
else
|
|
422
|
+
parameter = function == "last" ? "" : window
|
|
423
|
+
"{#{host}:#{key}.#{function}(#{parameter})}"
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# 判断服务端是否支持 Zabbix 5.4 起的新表达式语法。
|
|
428
|
+
def modern_trigger_expression?
|
|
429
|
+
version = @manager.client.api_version.to_s.split(".").first(2).map(&:to_i)
|
|
430
|
+
(version <=> [5, 4]) >= 0
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# 合并系统管理标签和调用方扩展标签。
|
|
434
|
+
def managed_tags(interface_name, metric, extra_tags)
|
|
435
|
+
tags = [
|
|
436
|
+
{ tag: "managed_by", value: "zabbix_manager" },
|
|
437
|
+
{ tag: "interface", value: interface_name.to_s },
|
|
438
|
+
{ tag: "metric", value: metric.to_s }
|
|
439
|
+
]
|
|
440
|
+
tags.concat(Array(extra_tags).map do |tag|
|
|
441
|
+
unless tag.respond_to?(:deep_symbolize_keys)
|
|
442
|
+
raise Invalid, "threshold tags must contain a non-empty tag name"
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
normalized = tag.deep_symbolize_keys
|
|
446
|
+
raise Invalid, "threshold tags must contain a non-empty tag name" if normalized[:tag].blank?
|
|
447
|
+
|
|
448
|
+
normalized
|
|
449
|
+
end)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# 生成不受描述和数组顺序影响的触发器管理键。
|
|
453
|
+
def managed_key(interface, metric)
|
|
454
|
+
identity = interface[:line_id].presence || interface[:interfaceid].presence ||
|
|
455
|
+
canonical_interface_identity(interface[:name])
|
|
456
|
+
"interface:#{identity}:#{metric}"
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# 从显式主机或历史线路字段中解析唯一主机。
|
|
460
|
+
def resolve_line_host(input, cache: nil)
|
|
461
|
+
supplied = input[:host]
|
|
462
|
+
if supplied.respond_to?(:deep_symbolize_keys)
|
|
463
|
+
return resolve_explicit_host(supplied)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
candidates = Array(input[:host_candidates])
|
|
467
|
+
candidates.concat([supplied, input[:device], input[:device1], input[:ip], input[:ipaddr1], input[:serial]])
|
|
468
|
+
candidates = candidates.filter_map { |value| value.to_s.strip.presence }
|
|
469
|
+
candidates.delete("IP地址")
|
|
470
|
+
raise Invalid, "line host candidate is required" if candidates.empty?
|
|
471
|
+
|
|
472
|
+
cache_key = candidates.join("\0")
|
|
473
|
+
found = if cache
|
|
474
|
+
cache.fetch(:hosts)[cache_key] ||= @manager.hosts.find_by_candidates(candidates)
|
|
475
|
+
else
|
|
476
|
+
@manager.hosts.find_by_candidates(candidates)
|
|
477
|
+
end
|
|
478
|
+
raise ApiError, "Zabbix host not found for #{candidates.join(", ")}" unless found
|
|
479
|
+
|
|
480
|
+
found.deep_symbolize_keys
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# 查询并精确选择接口的入向和出向流量项。
|
|
484
|
+
def find_line_traffic_items(hostid, interface_name, cache: nil)
|
|
485
|
+
aliases = interface_aliases(interface_name)
|
|
486
|
+
source_items = if cache
|
|
487
|
+
cache.fetch(:items)[hostid.to_s] ||= @manager.items.monitored_traffic_candidates(hostid)
|
|
488
|
+
else
|
|
489
|
+
@manager.items.monitored_traffic_candidates(hostid)
|
|
490
|
+
end
|
|
491
|
+
candidates = source_items.select do |item|
|
|
492
|
+
traffic_item?(item) && interface_item?(item, aliases)
|
|
493
|
+
end
|
|
494
|
+
selected = {
|
|
495
|
+
inbound: unique_direction_item!(candidates, :inbound, interface_name),
|
|
496
|
+
outbound: unique_direction_item!(candidates, :outbound, interface_name)
|
|
497
|
+
}
|
|
498
|
+
selected.each_value { |item| validate_bps_item!(item) }
|
|
499
|
+
selected
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# 把接口名称转换为统一身份集合。
|
|
503
|
+
def interface_aliases(interface_name)
|
|
504
|
+
[canonical_interface_identity(interface_name)]
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# 统一全称、缩写和分隔符,生成稳定接口身份。
|
|
508
|
+
def canonical_interface_identity(value)
|
|
509
|
+
value.to_s.downcase.gsub(/\s+|[-_]/, "")
|
|
510
|
+
.sub(/^tengigabitethernet/, "te")
|
|
511
|
+
.sub(/^gigabitethernet/, "gi")
|
|
512
|
+
.sub(/^ethernet/, "eth")
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# 判断监控项字段是否包含目标接口的精确 token。
|
|
516
|
+
def interface_item?(item, aliases)
|
|
517
|
+
fields = [item["name"], item["key_"], item["snmp_oid"]].compact.map(&:to_s)
|
|
518
|
+
discovered = fields.flat_map { |field| interface_tokens(field) }.map do |name|
|
|
519
|
+
canonical_interface_identity(name)
|
|
520
|
+
end
|
|
521
|
+
aliases.any? { |name| discovered.include?(name) }
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# 从名称、key 和 OID 文本中抽取完整接口 token。
|
|
525
|
+
def interface_tokens(value)
|
|
526
|
+
prefix = "(?:Ten-?GigabitEthernet|GigabitEthernet|Ethernet|Te|Gi|Eth)"
|
|
527
|
+
pattern = /(?:#{prefix})?\d+(?:\/\d+)+(?:[.:]\d+)*(?![A-Za-z0-9\/._-]|:\d)/i
|
|
528
|
+
value.to_s.scan(pattern)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# 排除误码、丢包和状态项,只保留可能的流量项。
|
|
532
|
+
def traffic_item?(item)
|
|
533
|
+
oid = item["snmp_oid"].to_s
|
|
534
|
+
blob = [item["key_"], item["name"]].join(" ").downcase
|
|
535
|
+
return false if blob.match?(/error|discard|drop|packet|status|utilization|percentage|percent/)
|
|
536
|
+
|
|
537
|
+
oid.match?(/1\.3\.6\.1\.2\.1\.31\.1\.1\.1\.(6|10)(\.|$)/) ||
|
|
538
|
+
blob.match?(/net\.if\.(in|out)(?:\[[^,\]]+\]|\b)|ifhc(in|out)octets|inbound|outbound/)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# 确保一个方向只匹配一个监控项。
|
|
542
|
+
def unique_direction_item!(candidates, direction, interface_name)
|
|
543
|
+
matches = candidates.select { |item| direction_item?(item, direction) }
|
|
544
|
+
if matches.length != 1
|
|
545
|
+
raise Conflict, "expected one #{direction} traffic item for #{interface_name}, found #{matches.length}"
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
matches.first
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# 根据 OID、key 和名称判断监控项方向。
|
|
552
|
+
def direction_item?(item, direction)
|
|
553
|
+
oid = item["snmp_oid"].to_s
|
|
554
|
+
blob = [item["key_"], item["name"]].join(" ").downcase
|
|
555
|
+
if direction == :inbound
|
|
556
|
+
oid.match?(/1\.3\.6\.1\.2\.1\.31\.1\.1\.1\.6(\.|$)/) || blob.match?(/net\.if\.in|ifhcin|inbound/)
|
|
557
|
+
else
|
|
558
|
+
oid.match?(/1\.3\.6\.1\.2\.1\.31\.1\.1\.1\.10(\.|$)/) || blob.match?(/net\.if\.out|ifhcout|outbound/)
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# 校验流量项单位及原始八位组预处理链。
|
|
563
|
+
def validate_bps_item!(item)
|
|
564
|
+
values = item.with_indifferent_access
|
|
565
|
+
unless values[:units].to_s.casecmp("bps").zero?
|
|
566
|
+
raise Invalid, "traffic item #{values[:itemid] || values[:key_]} must use bps units"
|
|
567
|
+
end
|
|
568
|
+
return unless raw_octet_counter?(values)
|
|
569
|
+
|
|
570
|
+
preprocessing = Array(values[:preprocessing])
|
|
571
|
+
change_per_second = preprocessing.any? { |step| (step["type"] || step[:type]).to_i == 10 }
|
|
572
|
+
bits_multiplier = preprocessing.any? do |step|
|
|
573
|
+
type = step["type"] || step[:type]
|
|
574
|
+
params = step["params"] || step[:params]
|
|
575
|
+
type.to_i == 1 && numeric!(params, "traffic preprocessing multiplier") == 8
|
|
576
|
+
end
|
|
577
|
+
return if change_per_second && bits_multiplier
|
|
578
|
+
|
|
579
|
+
identity = values[:itemid] || values[:key_]
|
|
580
|
+
raise Invalid, "raw octet item #{identity} requires change-per-second and multiplier 8 preprocessing"
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# 判断监控项是否直接读取 HC Octets 累计计数器。
|
|
584
|
+
def raw_octet_counter?(item)
|
|
585
|
+
values = item.with_indifferent_access
|
|
586
|
+
oid = values[:snmp_oid].to_s
|
|
587
|
+
key = values[:key_].to_s.downcase
|
|
588
|
+
oid.match?(/1\.3\.6\.1\.2\.1\.31\.1\.1\.1\.(6|10)(\.|$)/) || key.match?(/ifhc(in|out)octets/)
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# 把百分数或小数形式的水位转换为 0 到 1 的比例。
|
|
592
|
+
def water_ratio!(value, name)
|
|
593
|
+
ratio = numeric!(value, name)
|
|
594
|
+
ratio /= 100.0 if ratio > 1
|
|
595
|
+
raise Invalid, "#{name} must be greater than 0 and at most 1" unless ratio.positive? && ratio <= 1
|
|
596
|
+
|
|
597
|
+
ratio
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# 组合线路触发器的可读描述。
|
|
601
|
+
def line_trigger_description(input, interface_name)
|
|
602
|
+
["专线流量超阈值", input[:isp], input[:description], input[:device] || input[:device1], interface_name]
|
|
603
|
+
.compact.map(&:to_s).reject(&:blank?).join(" | ")
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# 从脱敏的线路业务字段生成触发器标签。
|
|
607
|
+
def line_tags(input, interface_name)
|
|
608
|
+
{
|
|
609
|
+
category: "line_bandwidth",
|
|
610
|
+
isp: input[:isp],
|
|
611
|
+
description: input[:description],
|
|
612
|
+
device: input[:device] || input[:device1],
|
|
613
|
+
iface: interface_name
|
|
614
|
+
}.filter_map { |tag, value| { tag: tag.to_s, value: value.to_s } if value.present? }
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# 生成线路阈值参数说明,便于 Zabbix 页面审计。
|
|
618
|
+
def line_comments(input, capacity_mbps, high_water, recovery_water)
|
|
619
|
+
"Managed line bandwidth monitor: ISP=#{input[:isp]}, description=#{input[:description]}, " \
|
|
620
|
+
"capacity_mbps=#{format_number(capacity_mbps)}, high_water=#{high_water}, recovery_water=#{recovery_water}"
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# 校验主机对象包含 ID 和技术名。
|
|
624
|
+
def validate_host!(host)
|
|
625
|
+
raise Invalid, "host.hostid is required" if host[:hostid].blank?
|
|
626
|
+
raise Invalid, "host.host is required" if host[:host].blank?
|
|
627
|
+
raise Invalid, "host.host cannot contain /" if host[:host].to_s.include?("/")
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
# 回读主机并核对 hostid 与技术名属于同一对象。
|
|
631
|
+
def resolve_explicit_host(raw_host)
|
|
632
|
+
supplied = raw_host.deep_symbolize_keys
|
|
633
|
+
validate_host!(supplied)
|
|
634
|
+
remote = @manager.hosts.find_by_id(supplied[:hostid])
|
|
635
|
+
raise ApiError, "Zabbix host #{supplied[:hostid]} was not found" unless remote
|
|
636
|
+
|
|
637
|
+
resolved = remote.deep_symbolize_keys
|
|
638
|
+
if resolved[:host].to_s != supplied[:host].to_s
|
|
639
|
+
raise Conflict, "hostid and technical host name do not refer to the same Zabbix host"
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
resolved[:hostid] = supplied[:hostid]
|
|
643
|
+
resolved
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
# 校验接口对象包含可显示名称。
|
|
647
|
+
def validate_interface!(interface)
|
|
648
|
+
raise Invalid, "interface.name is required" if interface[:name].blank?
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
# 校验 Zabbix 时间窗口格式。
|
|
652
|
+
def validate_window!(window)
|
|
653
|
+
value = window.to_s
|
|
654
|
+
raise Invalid, "window must use a Zabbix duration such as 5m or 1h" unless value.match?(/\A\d+[smhdw]\z/)
|
|
655
|
+
|
|
656
|
+
value
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
# 限制触发器函数为受支持的安全集合。
|
|
660
|
+
def validate_function!(function)
|
|
661
|
+
value = function.to_s
|
|
662
|
+
unless SUPPORTED_FUNCTIONS.include?(value)
|
|
663
|
+
raise Invalid, "function must be one of #{SUPPORTED_FUNCTIONS.join(", ")}"
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
value
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# 校验 Zabbix 严重级别范围。
|
|
670
|
+
def validate_priority!(priority)
|
|
671
|
+
value = Integer(priority)
|
|
672
|
+
raise Invalid unless value.between?(0, 5)
|
|
673
|
+
|
|
674
|
+
value
|
|
675
|
+
rescue ArgumentError, TypeError
|
|
676
|
+
raise Invalid, "priority must be an integer between 0 and 5"
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# 把业务输入转换为浮点数并统一错误消息。
|
|
680
|
+
def numeric!(value, name)
|
|
681
|
+
Float(value)
|
|
682
|
+
rescue ArgumentError, TypeError
|
|
683
|
+
raise Invalid, "#{name} must be numeric"
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
# 用稳定精度格式化表达式中的数值。
|
|
687
|
+
def format_number(number)
|
|
688
|
+
number.to_i == number ? number.to_i.to_s : number.round(6).to_s
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
end
|