zabbix_manager 5.1.6 → 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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -1
  3. data/README.md +69 -46
  4. data/lib/zabbix_manager/classes/actions.rb +3 -11
  5. data/lib/zabbix_manager/classes/applications.rb +2 -8
  6. data/lib/zabbix_manager/classes/configurations.rb +4 -9
  7. data/lib/zabbix_manager/classes/{drules.rb → discovery_rules.rb} +2 -8
  8. data/lib/zabbix_manager/classes/errors.rb +3 -0
  9. data/lib/zabbix_manager/classes/events.rb +2 -1
  10. data/lib/zabbix_manager/classes/graphs.rb +19 -15
  11. data/lib/zabbix_manager/classes/{hostgroups.rb → host_groups.rb} +5 -11
  12. data/lib/zabbix_manager/classes/host_interfaces.rb +261 -0
  13. data/lib/zabbix_manager/classes/hosts.rb +135 -89
  14. data/lib/zabbix_manager/classes/{httptests.rb → http_tests.rb} +2 -8
  15. data/lib/zabbix_manager/classes/items.rb +91 -87
  16. data/lib/zabbix_manager/classes/maintenance.rb +2 -1
  17. data/lib/zabbix_manager/classes/{mediatypes.rb → media_types.rb} +2 -7
  18. data/lib/zabbix_manager/classes/problems.rb +11 -40
  19. data/lib/zabbix_manager/classes/proxies.rb +5 -15
  20. data/lib/zabbix_manager/classes/proxy_groups.rb +16 -0
  21. data/lib/zabbix_manager/classes/roles.rb +13 -19
  22. data/lib/zabbix_manager/classes/screens.rb +11 -30
  23. data/lib/zabbix_manager/classes/scripts.rb +2 -8
  24. data/lib/zabbix_manager/classes/server.rb +1 -0
  25. data/lib/zabbix_manager/classes/templates.rb +2 -65
  26. data/lib/zabbix_manager/classes/triggers.rb +42 -45
  27. data/lib/zabbix_manager/classes/user_groups.rb +63 -0
  28. data/lib/zabbix_manager/classes/user_macros.rb +110 -0
  29. data/lib/zabbix_manager/classes/users.rb +15 -54
  30. data/lib/zabbix_manager/classes/value_maps.rb +52 -0
  31. data/lib/zabbix_manager/client.rb +148 -277
  32. data/lib/zabbix_manager/configuration.rb +107 -0
  33. data/lib/zabbix_manager/http_transport.rb +145 -97
  34. data/lib/zabbix_manager/log_sanitizer.rb +9 -19
  35. data/lib/zabbix_manager/monitoring/device.rb +393 -0
  36. data/lib/zabbix_manager/monitoring/expressions.rb +36 -0
  37. data/lib/zabbix_manager/monitoring/line.rb +175 -0
  38. data/lib/zabbix_manager/monitoring/line_items.rb +110 -0
  39. data/lib/zabbix_manager/monitoring/line_lifecycle.rb +169 -0
  40. data/lib/zabbix_manager/monitoring/line_plan.rb +113 -0
  41. data/lib/zabbix_manager/monitoring/thresholds.rb +182 -0
  42. data/lib/zabbix_manager/monitoring/traffic_items.rb +132 -0
  43. data/lib/zabbix_manager/monitoring/validation.rb +92 -0
  44. data/lib/zabbix_manager/monitoring.rb +216 -642
  45. data/lib/zabbix_manager/resource.rb +235 -0
  46. data/lib/zabbix_manager/traffic.rb +238 -0
  47. data/lib/zabbix_manager/version.rb +1 -1
  48. data/lib/zabbix_manager.rb +66 -179
  49. data/zabbix_manager.gemspec +6 -22
  50. metadata +41 -190
  51. data/lib/zabbix_manager/basic/basic_alias.rb +0 -42
  52. data/lib/zabbix_manager/basic/basic_func.rb +0 -55
  53. data/lib/zabbix_manager/basic/basic_init.rb +0 -50
  54. data/lib/zabbix_manager/basic/basic_logic.rb +0 -239
  55. data/lib/zabbix_manager/classes/hostinterfaces.rb +0 -189
  56. data/lib/zabbix_manager/classes/usergroups.rb +0 -75
  57. data/lib/zabbix_manager/classes/usermacros.rb +0 -222
  58. data/lib/zabbix_manager/classes/valuemaps.rb +0 -26
@@ -1,691 +1,265 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "zabbix_manager/monitoring/validation"
4
+ require "zabbix_manager/monitoring/traffic_items"
5
+ require "zabbix_manager/monitoring/thresholds"
6
+ require "zabbix_manager/monitoring/line"
7
+ require "zabbix_manager/monitoring/line_items"
8
+ require "zabbix_manager/monitoring/line_plan"
9
+ require "zabbix_manager/monitoring/line_lifecycle"
10
+ require "zabbix_manager/monitoring/device"
11
+
3
12
  class ZabbixManager
4
- # 封装网络设备、接口和线路监控的高频幂等业务流程。
13
+ # 网络设备、接口和线路监控编排。先验证整批输入,再按稳定身份执行远端操作。
5
14
  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
15
+ # @param manager [ZabbixManager] 共享认证客户端和资源对象
14
16
  def initialize(manager)
15
17
  @manager = manager
18
+ @thresholds = Thresholds.new(manager.client)
19
+ @workflow_mutex = Mutex.new
16
20
  end
17
21
 
18
- # 创建或更新受监控的网络设备。
19
- # @return [Integer] Zabbix 主机 ID
20
- # @api public
21
- def reconcile_device(data)
22
- @manager.hosts.reconcile(data)
22
+ # 按技术主机名装配设备监控;名称引用在写入前通过 API 解析。
23
+ # 同一实例内串行执行工作流,远端写入没有数据库事务或跨进程原子性。
24
+ # @param device [Hash] 原生主机属性及 groups/templates、snmp、proxy_group、managed 等业务选项
25
+ # @return [Hash] hostid、enabled 和下次更新可传回的 managed 集合归属回执
26
+ # @raise [Invalid, Conflict] 输入无效、引用不唯一或不支持当前服务端版本
27
+ # @raise [ApiError, TransportError] 远端操作失败;超时后应先核查远端状态
28
+ def reconcile_device(device)
29
+ definition = Device.new(device)
30
+ @workflow_mutex.synchronize { definition.reconcile(@manager) }
23
31
  end
24
32
 
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
33
+ # 先校验整批本地定义,再逐台对账;单台失败不掩盖其他设备的结果。
34
+ # @param devices [Array<Hash>] 设备定义,技术主机名必须唯一
35
+ # @param fail_fast [Boolean] true 时首个远端错误向上抛出
36
+ # @return [Array<Hash>] 每台设备的身份、status 和 result,或脱敏 error
37
+ # @raise [Invalid] 本地定义无效,此时整批尚未写入
38
+ def reconcile_devices(devices, fail_fast: false)
39
+ Validation.boolean!(fail_fast, "fail_fast")
40
+ reconcile_device_definitions(validate_devices!(devices), fail_fast: fail_fast)
39
41
  end
40
42
 
41
- # 分设备和线路两个阶段执行一次网络监控批次,并返回稳定的汇总结果。
42
- # 线路依赖模板或自动发现产生的流量项;尚未生成时会记录为线路失败,后续可安全重跑。
43
- # @return [Hash]
43
+ # 线路预检在设备写入之前完成;线路发现依赖设备或模板产生的监控项。
44
+ # 模板/LLD 异步产生监控项,设备成功不代表对应线路已就绪。
45
+ # @param devices [Array<Hash>] 设备定义
46
+ # @param lines [Array<Hash>] 单端点线路定义;双端使用独立 line_id
47
+ # @param fail_fast [Boolean] true 时首个远端错误向上抛出
48
+ # @return [Hash] devices、lines 逐项结果和成功/失败/未知数量 summary
44
49
  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 }
50
+ Validation.boolean!(fail_fast, "fail_fast")
51
+ line_definitions = validate_lines!(lines)
52
+ device_definitions = validate_devices!(devices)
53
+ results = {
54
+ devices: reconcile_device_definitions(device_definitions, fail_fast: fail_fast),
55
+ lines: reconcile_line_definitions(line_definitions, fail_fast: fail_fast)
56
+ }
49
57
  results.merge(summary: batch_summary(results))
50
58
  end
51
59
 
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)
60
+ # 只读解析主机和监控项,预览实际触发器属性及按类型引用的依赖。
61
+ # ICMP 是 Zabbix server/proxy 对目标的检查,不能证明专线两端的转发路径。
62
+ # @param attributes [Hash] 单端点 line_id、host、interface_name、capacity_mbps 及可选监控配置
63
+ # @return [Hash] hostid、line_id、itemids、triggers、dependencies 和可选 icmp_item
64
+ # @raise [Invalid, Conflict] 阈值、量纲、项目选择或管理归属无法确定
65
+ # @raise [ApiError, TransportError] 远端发现失败,此方法从不写入
66
+ def plan_line(attributes)
67
+ prepare_line(Line.new(attributes), {})
58
68
  end
59
69
 
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
70
+ # 对账一个端点,确认所需触发器后停用该线路不再需要的受管类型。
71
+ # 同一实例串行执行;多步远端写入不具备事务回滚或跨进程原子性。
72
+ # @param attributes [Hash] 与 plan_line 相同的线路定义
73
+ # @return [Hash] 主机、接口、line_id、按指标分类的 itemids 和 triggerids
74
+ # @raise [Invalid, Conflict] 预检失败,此时没有线路写入
75
+ # @raise [ApiError, TransportError] 远端失败;已确认的步骤保留,传输失败可能已提交
76
+ def reconcile_line(attributes)
77
+ line = Line.new(attributes)
78
+ @workflow_mutex.synchronize { apply_line(prepare_line(line, {})) }
74
79
  end
75
80
 
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
- }
81
+ # 先预检整批本地输入和远端发现,再逐端点执行,保留失败与结果未知的区别。
82
+ # @param lines [Array<Hash>] line_id 唯一的端点定义;双端线路使用两个不同 line_id
83
+ # @param fail_fast [Boolean] 遇到首个远端失败时向上抛出
84
+ # @return [Array<Hash>] 每个端点的 status、result 或脱敏 error
85
+ # @raise [Invalid] 本地定义或解析后的目标重复
86
+ def reconcile_lines(lines, fail_fast: false)
87
+ Validation.boolean!(fail_fast, "fail_fast")
88
+ reconcile_line_definitions(validate_lines!(lines), fail_fast: fail_fast)
110
89
  end
111
90
 
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?
91
+ # 查询端点的受管触发器;人工触发器及其他管理方的同名对象不在结果中。
92
+ # @param hostid [Integer, String] 目标主机 ID
93
+ # @param line_id [String, Integer] 端点的稳定身份,或 plan_line 返回的自动身份
94
+ # @return [Array<Hash>] 原生 trigger.get 对象,包括 tags、items、状态及表达式
95
+ # @raise [Invalid, Conflict, ProtocolError] 输入无效或远端管理身份不唯一
96
+ def line_triggers(hostid:, line_id:)
97
+ LineLifecycle.new(@manager, hostid, line_id).owned
98
+ end
124
99
 
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)
100
+ # 停用一个端点的全部受管触发器;不删除触发器或共享 ICMP 监控项。
101
+ # @param hostid [Integer, String] 目标主机 ID
102
+ # @param line_id [String, Integer] 端点的稳定身份
103
+ # @return [Array<Integer>] 已确认停用的触发器 ID;没有受管对象时为空
104
+ # @raise [ApiError, TransportError] 写入失败或结果尚未确认
105
+ def disable_line(hostid:, line_id:)
106
+ lifecycle = LineLifecycle.new(@manager, hostid, line_id)
107
+ @workflow_mutex.synchronize { lifecycle.disable }
108
+ end
136
109
 
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
110
+ # 按该端点的触发器 ID 在服务端过滤问题,再应用数量上限。
111
+ # Zabbix problem.get 的 recent 范围受服务器问题保留配置影响,不代表完整事件归档。
112
+ # @param hostid [Integer, String] 目标主机 ID
113
+ # @param line_id [String, Integer] 端点的稳定身份
114
+ # @param time_from [Integer, Time] 查询起点(Unix 秒或 Time)
115
+ # @param time_till [Integer, Time] 查询终点,默认为当前时间
116
+ # @param limit [Integer] 最大记录数,1..1000;多读取一条以判定截断
117
+ # @return [Hash] problems、truncated、time_from、time_till 和 limit
118
+ # @raise [Invalid, ProtocolError] 范围无效或响应超出目标线路
119
+ def line_problems(hostid:, line_id:, time_from:, time_till: Time.now, limit: 100)
120
+ LineLifecycle.new(@manager, hostid, line_id).problems(time_from: time_from, time_till: time_till, limit: limit)
121
+ end
145
122
 
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
- }
123
+ # 接口更新只增改,不自动删除远端对象;所有本地校验在首个写入之前完成。
124
+ # @param attributes [Hash] host、interface、按名称分组的原生 items 和 thresholds
125
+ # @return [Hash] hostid、interface、itemids 和 triggerids
126
+ # @raise [Invalid, Conflict] 输入、量纲或资源归属无效
127
+ # @raise [ApiError, TransportError] 远端失败;已经确认的早期步骤不会被回滚
128
+ def reconcile_interface(attributes)
129
+ input = Validation.hash!(attributes, "interface monitoring")
130
+ input.assert_valid_keys(:host, :interface, :items, :thresholds)
131
+ supplied_host = Validation.hash!(input[:host], "host")
132
+ Validation.host!(supplied_host)
133
+ interface = Validation.hash!(input[:interface], "interface")
134
+ interface.assert_valid_keys(:name, :interfaceid)
135
+ Validation.text!(interface[:name], "interface.name")
136
+ Validation.positive_id!(interface[:interfaceid], "interface.interfaceid") if interface.key?(:interfaceid)
137
+ items = @thresholds.prepare_items(supplied_host, interface, input.fetch(:items, {}))
138
+ triggers = @thresholds.prepare_triggers(
139
+ host: supplied_host, interface: interface, items: items, thresholds: input.fetch(:thresholds, {}),
140
+ validate_items: false
141
+ )
142
+ host = resolve_host(supplied_host, {})
143
+ ids = @manager.items.upsert_many(items.values) do |effective|
144
+ # 读取已有配置后再次校验最终量纲和数值类型,仍在首个 item 写入之前。
145
+ triggers = @thresholds.prepare_triggers(
146
+ host: supplied_host, interface: interface, items: items.keys.zip(effective).to_h,
147
+ thresholds: input.fetch(:thresholds, {})
164
148
  )
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
149
  end
150
+ itemids = items.keys.zip(ids).to_h
151
+ triggerids = triggers.transform_values { |trigger| @manager.triggers.upsert_for_host(trigger) }
152
+ { hostid: host[:hostid].to_i, interface: interface[:name], itemids: itemids, triggerids: triggerids }
153
+ end
590
154
 
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
155
+ private
596
156
 
597
- ratio
598
- end
157
+ def validate_devices!(devices)
158
+ definitions = Validation.array!(devices, "devices").map { |device| Device.new(device) }
159
+ validate_unique!(definitions.map(&:identity), "device host")
160
+ definitions
161
+ end
599
162
 
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
163
+ def validate_lines!(lines)
164
+ definitions = Validation.array!(lines, "lines").map { |attributes| Line.new(attributes) }
165
+ validate_unique!(definitions.map(&:identity), "line_id or host/interface")
166
+ definitions
167
+ end
605
168
 
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
169
+ def validate_unique!(identities, name)
170
+ duplicate = identities.tally.find { |_identity, count| count > 1 }
171
+ raise Invalid, "duplicate #{name}" if duplicate
172
+ end
616
173
 
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
174
+ def reconcile_device_definitions(devices, fail_fast:)
175
+ devices.map do |device|
176
+ identity = device.safe_identity
177
+ result = @workflow_mutex.synchronize { device.reconcile(@manager) }
178
+ { status: :ok, device: identity, result: result }
179
+ rescue ApiError, TransportError, Invalid => error
180
+ raise if fail_fast
622
181
 
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?("/")
182
+ failure_result(:device, identity, error, writing: true)
628
183
  end
184
+ end
629
185
 
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
186
+ # 先完成线路发现,防止两个不同候选主机名解析到同一触发器后互相覆盖。
187
+ def reconcile_line_definitions(lines, fail_fast:)
188
+ cache = {}
189
+ prepared = lines.map do |line|
190
+ { line: line, plan: prepare_line(line, cache) }
191
+ rescue ApiError, TransportError, Invalid => error
192
+ raise if fail_fast
641
193
 
642
- resolved[:hostid] = supplied[:hostid]
643
- resolved
194
+ { line: line, failure: failure_result(:line, line.safe_identity, error) }
644
195
  end
645
-
646
- # 校验接口对象包含可显示名称。
647
- def validate_interface!(interface)
648
- raise Invalid, "interface.name is required" if interface[:name].blank?
196
+ identities = prepared.filter_map do |entry|
197
+ plan = entry[:plan]
198
+ [plan[:hostid], plan[:line_id]] if plan
649
199
  end
200
+ validate_unique!(identities, "line target")
201
+ prepared.map do |entry|
202
+ next entry[:failure] if entry[:failure]
650
203
 
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/)
204
+ { status: :ok, result: @workflow_mutex.synchronize { apply_line(entry[:plan]) } }
205
+ rescue ApiError, TransportError, Invalid => error
206
+ raise if fail_fast
655
207
 
656
- value
208
+ failure_result(:line, entry[:line].safe_identity, error, writing: true)
657
209
  end
210
+ end
658
211
 
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
212
+ def prepare_line(line, cache)
213
+ host = resolve_host(line.host_reference, cache)
214
+ source_items = cache[[:items, host[:hostid].to_s]] ||=
215
+ @manager.items.monitored_traffic_candidates(host[:hostid])
216
+ resolver = LineItems.new(@manager.items)
217
+ selected = resolver.resolve(line, source_items, hostid: host[:hostid])
218
+ icmp_item = resolver.icmp_plan(host[:hostid], line.reachability_target) if line.reachability_target
219
+ plan = LinePlan.new(@manager.client, line, host, selected, icmp_item).to_h
220
+ lifecycle = LineLifecycle.new(@manager, plan[:hostid], plan[:line_id])
221
+ lifecycle.validate_ownership!(lifecycle.inventory, plan[:triggers].values.pluck(:managed_key))
222
+ plan
223
+ end
668
224
 
669
- # 校验 Zabbix 严重级别范围。
670
- def validate_priority!(priority)
671
- value = Integer(priority)
672
- raise Invalid unless value.between?(0, 5)
225
+ def apply_line(plan)
226
+ lifecycle = LineLifecycle.new(@manager, plan[:hostid], plan[:line_id])
227
+ existing = lifecycle.inventory
228
+ desired_keys = plan[:triggers].values.pluck(:managed_key)
229
+ lifecycle.validate_ownership!(existing, desired_keys)
230
+ itemids = plan[:itemids].dup
231
+ if plan[:icmp_item]
232
+ itemids[:reachability] = LineItems.new(@manager.items).ensure_icmp(plan[:icmp_item])
233
+ end
234
+ triggerids = {}
235
+ plan[:triggers].each do |kind, attributes|
236
+ dependencies = plan[:dependencies].fetch(kind).map { |dependency| { triggerid: triggerids.fetch(dependency) } }
237
+ triggerids[kind] = @manager.triggers.upsert_for_host(attributes.merge(dependencies: dependencies))
238
+ end
239
+ lifecycle.retire(existing, desired_keys)
240
+ plan.slice(:hostid, :host, :interface, :line_id).merge(itemids: itemids, triggerids: triggerids)
241
+ end
673
242
 
674
- value
675
- rescue ArgumentError, TypeError
676
- raise Invalid, "priority must be an integer between 0 and 5"
677
- end
243
+ def resolve_host(reference, cache)
244
+ host = cache[[:host, reference]] ||= @manager.hosts.resolve(reference)
245
+ reference.is_a?(Hash) ? host.merge(hostid: reference[:hostid]) : host
246
+ end
678
247
 
679
- # 把业务输入转换为浮点数并统一错误消息。
680
- def numeric!(value, name)
681
- Float(value)
682
- rescue ArgumentError, TypeError
683
- raise Invalid, "#{name} must be numeric"
684
- end
248
+ def failure_result(kind, identity, error, writing: false)
249
+ unknown = error.is_a?(ResultUnknown) || (writing && error.is_a?(TransportError))
250
+ {
251
+ status: unknown ? :unknown : :error,
252
+ kind => identity,
253
+ error: { class: error.class.name, message: LogSanitizer.sanitize(error.message) }
254
+ }
255
+ end
685
256
 
686
- # 用稳定精度格式化表达式中的数值。
687
- def format_number(number)
688
- number.to_i == number ? number.to_i.to_s : number.round(6).to_s
257
+ def batch_summary(results)
258
+ results.transform_values do |entries|
259
+ statuses = entries.map { |entry| entry[:status] }.tally
260
+ { total: entries.length, succeeded: statuses.fetch(:ok, 0),
261
+ failed: statuses.fetch(:error, 0), unknown: statuses.fetch(:unknown, 0) }
689
262
  end
263
+ end
690
264
  end
691
265
  end