L2Store.php 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. <?php declare(strict_types = 0);
  2. /*
  3. ** L2 Topology - persistence & configuration layer.
  4. **
  5. ** Two tables are auto-created (portable: works on any Zabbix DB the web user
  6. ** can write to; the compose init sidecar also creates them as root):
  7. **
  8. ** l2_topology_history : one compressed snapshot per configured interval
  9. ** l2_topology_config : single row (id=1) holding every display/collection knob
  10. **
  11. ** Snapshots are gzip-compressed MEDIUMBLOBs. At the default 5-minute
  12. ** interval a full week is ~70-100 MB (measured ~33 KB per compressed
  13. ** snapshot), instead of >1 GB of raw JSON.
  14. */
  15. namespace Modules\WecomCenter\Lib;
  16. use DB;
  17. use Throwable;
  18. class L2Store {
  19. public const HISTORY_TABLE = 'l2_topology_history';
  20. public const CONFIG_TABLE = 'l2_topology_config';
  21. public const CONFIG_ID = 1;
  22. /** Full default configuration. Every key can be overridden from the UI. */
  23. public static function defaults(): array {
  24. return [
  25. // Display page ------------------------------------------------------
  26. 'refresh_seconds' => 300, // auto-refresh of the live view
  27. 'root_label' => 'COBCO', // outermost frame label
  28. // Physical networks. A switch belongs to the first network whose
  29. // keyword appears in its name or IP; adjacency is used as fallback.
  30. // key | label | keyword1,keyword2 | colour
  31. 'networks' => [
  32. ['key' => 'SURVE', 'label' => 'SURVE 监控网', 'keywords' => ['SURVE', '10.169.'], 'color' => '#2E75B6'],
  33. ['key' => 'OFFICE', 'label' => 'OFFICE 办公网', 'keywords' => ['OFFICE', '10.160.'], 'color' => '#548235']
  34. ],
  35. // Root/core switches. A network may have more than one core; every
  36. // match is drawn in the left-most core column. match = contains|regex.
  37. 'core_rules' => [
  38. ['net' => 'SURVE', 'match' => 'contains', 'pattern' => 'core surve'],
  39. ['net' => 'OFFICE', 'match' => 'contains', 'pattern' => 'core office']
  40. ],
  41. // Optional physical hierarchy above the sub-cores. The display
  42. // order is firewall -> behavior -> total core -> sub-core. Every
  43. // pattern may be empty; an empty role is not drawn and reserves no
  44. // space in the topology. sub_cores feed core_rules for backwards
  45. // compatibility with older collectors/frontends.
  46. 'core_hierarchy' => [
  47. 'firewall' => ['match' => 'contains', 'pattern' => ''],
  48. 'behavior' => ['match' => 'contains', 'pattern' => ''],
  49. 'total_core' => ['match' => 'contains', 'pattern' => ''],
  50. 'sub_cores' => [
  51. ['key' => 'monitor', 'label' => '监控核心', 'net' => 'SURVE', 'match' => 'contains', 'pattern' => 'core surve'],
  52. ['key' => 'office', 'label' => '办公核心', 'net' => 'OFFICE', 'match' => 'contains', 'pattern' => 'core office'],
  53. ['key' => 'production', 'label' => '生产核心', 'net' => '', 'match' => 'contains', 'pattern' => '']
  54. ]
  55. ],
  56. // Collection --------------------------------------------------------
  57. 'templates' => ['Huawei VRP by SNMP (walk)'],
  58. 'full_interval_min' => 5, // full topology/port collection
  59. 'alarm_interval_min'=> 1, // alarm-only refresh
  60. 'history_interval_min' => 5, // one DB snapshot per N minutes
  61. 'history_days' => 7, // retention window, hard-pruned
  62. // Link colours (bits per second bucket) -----------------------------
  63. 'colors' => [
  64. '100' => '#F39C12', // 100M 橙色
  65. '1000' => '#2ECC71', // 1G 绿色
  66. '2500' => '#17C3B2', // 2.5G 青色
  67. '10000' => '#3498DB', // 10G 蓝色
  68. '20000' => '#9B59B6', // 20G+ 紫色
  69. 'unknown' => '#E74C3C', // 未知 红色
  70. 'down' => '#C0392B' // 中断 红色
  71. ],
  72. // Latency thresholds (ms) applied to switch nodes (host->switch ping)
  73. 'latency_warn' => 1.0,
  74. 'latency_bad' => 5.0,
  75. // Grouping rules: first matching rule wins, else "Other".
  76. // match = contains | regex ; label may contain $1..$9 backrefs.
  77. 'group_rules' => [
  78. ['match' => 'contains', 'pattern' => 'DataCenter', 'label' => 'DataCenter', 'big' => true],
  79. ['match' => 'contains', 'pattern' => 'Core', 'label' => 'Core', 'big' => true],
  80. ['match' => 'regex', 'pattern' => 'COBCO-(\\d{4}[A-Za-z]?)', 'label' => '$1', 'big' => false],
  81. ['match' => 'regex', 'pattern' => '^(\\d{4}[A-Za-z]?)', 'label' => '$1', 'big' => false]
  82. ],
  83. 'other_label' => 'Other'
  84. ];
  85. }
  86. /** Create tables if absent. Safe to call on every request (idempotent). */
  87. public static function ensureSchema(): void {
  88. try {
  89. \DBexecute(
  90. 'CREATE TABLE IF NOT EXISTS '.self::HISTORY_TABLE.' ('.
  91. ' id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,'.
  92. ' ts BIGINT UNSIGNED NOT NULL,'.
  93. ' captured_at DATETIME NOT NULL,'.
  94. ' stats TEXT NOT NULL,'.
  95. ' snapshot MEDIUMBLOB NOT NULL,'.
  96. ' PRIMARY KEY (id),'.
  97. ' KEY idx_l2_ts (ts)'.
  98. ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin'
  99. );
  100. \DBexecute(
  101. 'CREATE TABLE IF NOT EXISTS '.self::CONFIG_TABLE.' ('.
  102. ' id TINYINT UNSIGNED NOT NULL,'.
  103. ' config LONGTEXT NOT NULL,'.
  104. ' updated DATETIME NOT NULL,'.
  105. ' PRIMARY KEY (id)'.
  106. ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin'
  107. );
  108. }
  109. catch (Throwable $e) {
  110. // Table may already exist or the DB user may lack DDL rights; the
  111. // compose init sidecar / install doc handles that case. Never fail a page.
  112. }
  113. }
  114. public static function getConfig(): array {
  115. self::ensureSchema();
  116. $cfg = self::defaults();
  117. $mirror = dirname(__DIR__).'/assets/l2topology/config.json';
  118. try {
  119. // Module tables are not in Zabbix's table-schema cache, so use the
  120. // raw DBselect()/DBfetch() helpers rather than DB::select().
  121. $cursor = \DBselect('SELECT config FROM '.self::CONFIG_TABLE.' WHERE id='.self::CONFIG_ID);
  122. $row = \DBfetch($cursor);
  123. $rows = $row ? [$row] : [];
  124. if ($rows && isset($rows[0]['config'])) {
  125. $saved = json_decode((string) $rows[0]['config'], true);
  126. if (is_array($saved)) {
  127. $cfg = self::mergeDeep($cfg, $saved);
  128. }
  129. }
  130. else if (is_readable($mirror)) {
  131. // Keep working when the table could not be created yet but the
  132. // collector mirror was written by an earlier save.
  133. $saved = json_decode((string) @file_get_contents($mirror), true);
  134. if (is_array($saved)) {
  135. $cfg = self::mergeDeep($cfg, $saved);
  136. }
  137. }
  138. }
  139. catch (Throwable $e) {
  140. // fall back to defaults
  141. }
  142. // Make sure the out-of-process collectors can always read the config
  143. // (write it once if the mirror file is still missing).
  144. if (!is_file($mirror)) {
  145. self::writeMirror($cfg);
  146. }
  147. return $cfg;
  148. }
  149. public static function saveConfig(array $cfg): void {
  150. self::ensureSchema();
  151. $cfg = self::mergeDeep(self::defaults(), $cfg);
  152. $json = json_encode($cfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  153. try {
  154. \DBexecute(
  155. 'INSERT INTO '.self::CONFIG_TABLE.' (id, config, updated) VALUES ('.
  156. self::CONFIG_ID.', '.\zbx_dbstr($json).', UTC_TIMESTAMP())'.
  157. ' ON DUPLICATE KEY UPDATE config=VALUES(config), updated=VALUES(updated)'
  158. );
  159. }
  160. catch (Throwable $e) {
  161. error_log('L2Store::saveConfig failed: '.$e->getMessage());
  162. }
  163. self::writeMirror($cfg);
  164. }
  165. /** Mirror config to a JSON file so the out-of-process collectors can read it. */
  166. public static function writeMirror(array $cfg): void {
  167. $file = dirname(__DIR__).'/assets/l2topology/config.json';
  168. try {
  169. $tmp = $file.'.tmp';
  170. file_put_contents($tmp, json_encode($cfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
  171. @chmod($tmp, 0644);
  172. @rename($tmp, $file);
  173. }
  174. catch (Throwable $e) {
  175. }
  176. }
  177. /** Newest-first list of stored snapshots (for the time selector). */
  178. public static function listHistory(int $limit = 500): array {
  179. $out = [];
  180. try {
  181. $cursor = \DBselect(
  182. 'SELECT ts, captured_at, stats FROM '.self::HISTORY_TABLE.
  183. ' ORDER BY ts DESC LIMIT '.max(1, $limit)
  184. );
  185. while ($r = \DBfetch($cursor)) {
  186. $out[] = [
  187. 'ts' => (int) $r['ts'],
  188. 'captured_at' => (string) $r['captured_at'],
  189. 'stats' => json_decode((string) $r['stats'], true)
  190. ];
  191. }
  192. }
  193. catch (Throwable $e) {
  194. // No history yet / table not ready: the selector simply stays empty.
  195. }
  196. return $out;
  197. }
  198. /**
  199. * Snapshot JSON at the exact recorded $ts. When $ts is null the newest
  200. * stored snapshot is used, falling back to the live snapshot.json file.
  201. */
  202. public static function snapshotAt(?int $ts): array {
  203. $json = null;
  204. $used = $ts;
  205. try {
  206. if ($ts === null) {
  207. $cursor = \DBselect(
  208. 'SELECT ts, snapshot FROM '.self::HISTORY_TABLE.' ORDER BY ts DESC LIMIT 1'
  209. );
  210. $row = \DBfetch($cursor);
  211. $rows = $row ? [$row] : [];
  212. }
  213. else {
  214. // The UI only offers exact recorded timestamps. Querying by
  215. // exact ts prevents an arbitrary URL from silently showing
  216. // the nearest (wrong) snapshot.
  217. $cursor = \DBselect(
  218. 'SELECT ts, snapshot FROM '.self::HISTORY_TABLE.
  219. ' WHERE ts='.(int) $ts.' LIMIT 1'
  220. );
  221. $row = \DBfetch($cursor);
  222. $rows = $row ? [$row] : [];
  223. }
  224. if ($rows) {
  225. $blob = $rows[0]['snapshot'];
  226. $raw = @gzdecode($blob);
  227. if ($raw !== false && $raw !== '') {
  228. $json = $raw;
  229. $used = (int) $rows[0]['ts'];
  230. }
  231. }
  232. }
  233. catch (Throwable $e) {
  234. }
  235. if ($json === null && $ts === null) {
  236. $file = dirname(__DIR__).'/assets/l2topology/snapshot.json';
  237. if (is_readable($file)) {
  238. $raw = file_get_contents($file);
  239. if ($raw !== false && $raw !== '') {
  240. $json = $raw;
  241. }
  242. }
  243. }
  244. return ['json' => $json, 'ts' => ($json !== null ? $used : null)];
  245. }
  246. private static function mergeDeep(array $base, array $over): array {
  247. foreach ($over as $k => $v) {
  248. if (is_array($v) && isset($base[$k]) && is_array($base[$k]) && self::isAssoc($base[$k])) {
  249. $base[$k] = self::mergeDeep($base[$k], $v);
  250. }
  251. else {
  252. $base[$k] = $v;
  253. }
  254. }
  255. return $base;
  256. }
  257. private static function isAssoc(array $a): bool {
  258. return array_keys($a) !== range(0, count($a) - 1);
  259. }
  260. }