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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -9
  3. data/LICENSE +1 -1
  4. data/README.md +202 -8
  5. data/lib/zabbix_manager/basic/basic_alias.rb +25 -3
  6. data/lib/zabbix_manager/basic/basic_func.rb +32 -61
  7. data/lib/zabbix_manager/basic/basic_init.rb +28 -13
  8. data/lib/zabbix_manager/basic/basic_logic.rb +172 -128
  9. data/lib/zabbix_manager/classes/actions.rb +21 -11
  10. data/lib/zabbix_manager/classes/applications.rb +12 -17
  11. data/lib/zabbix_manager/classes/configurations.rb +20 -5
  12. data/lib/zabbix_manager/classes/drules.rb +11 -23
  13. data/lib/zabbix_manager/classes/errors.rb +16 -28
  14. data/lib/zabbix_manager/classes/events.rb +6 -2
  15. data/lib/zabbix_manager/classes/graphs.rb +41 -34
  16. data/lib/zabbix_manager/classes/hostgroups.rb +53 -3
  17. data/lib/zabbix_manager/classes/hostinterfaces.rb +172 -12
  18. data/lib/zabbix_manager/classes/hosts.rb +197 -68
  19. data/lib/zabbix_manager/classes/httptests.rb +18 -20
  20. data/lib/zabbix_manager/classes/items.rb +199 -72
  21. data/lib/zabbix_manager/classes/maintenance.rb +6 -2
  22. data/lib/zabbix_manager/classes/mediatypes.rb +7 -63
  23. data/lib/zabbix_manager/classes/problems.rb +75 -57
  24. data/lib/zabbix_manager/classes/proxies.rb +48 -7
  25. data/lib/zabbix_manager/classes/roles.rb +47 -36
  26. data/lib/zabbix_manager/classes/screens.rb +45 -14
  27. data/lib/zabbix_manager/classes/scripts.rb +17 -7
  28. data/lib/zabbix_manager/classes/server.rb +7 -1
  29. data/lib/zabbix_manager/classes/templates.rb +59 -21
  30. data/lib/zabbix_manager/classes/triggers.rb +253 -66
  31. data/lib/zabbix_manager/classes/usergroups.rb +28 -6
  32. data/lib/zabbix_manager/classes/usermacros.rb +119 -33
  33. data/lib/zabbix_manager/classes/users.rb +39 -7
  34. data/lib/zabbix_manager/classes/valuemaps.rb +13 -19
  35. data/lib/zabbix_manager/client.rb +311 -135
  36. data/lib/zabbix_manager/http_transport.rb +192 -0
  37. data/lib/zabbix_manager/log_sanitizer.rb +68 -0
  38. data/lib/zabbix_manager/monitoring.rb +691 -0
  39. data/lib/zabbix_manager/version.rb +1 -1
  40. data/lib/zabbix_manager.rb +118 -40
  41. data/zabbix_manager.gemspec +21 -8
  42. metadata +208 -22
  43. 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ZabbixManager
4
- VERSION = "5.1.4"
4
+ VERSION = "5.1.6"
5
5
  end