L2Store.php 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  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 collection, kept 7 days
  9. ** l2_topology_config : single row (id=1) holding every display/collection knob
  10. **
  11. ** Snapshots are gzip-compressed MEDIUMBLOBs so a 5-minute cadence for a week
  12. ** stays around ~100-150 MB instead of >1 GB of raw JSON.
  13. */
  14. namespace Modules\WecomCenter\Lib;
  15. use DB;
  16. use Throwable;
  17. class L2Store {
  18. public const HISTORY_TABLE = 'l2_topology_history';
  19. public const CONFIG_TABLE = 'l2_topology_config';
  20. public const CONFIG_ID = 1;
  21. /** Full default configuration. Every key can be overridden from the UI. */
  22. public static function defaults(): array {
  23. return [
  24. // Display page ------------------------------------------------------
  25. 'refresh_seconds' => 300, // auto-refresh of the live view
  26. 'dark_default' => false, // start in dark mode
  27. 'root_label' => 'COBCO', // outermost frame label
  28. // Collection --------------------------------------------------------
  29. 'templates' => ['Huawei VRP by SNMP (walk)'],
  30. 'full_interval_min' => 5, // full topology/port collection
  31. 'alarm_interval_min'=> 1, // alarm-only refresh
  32. 'history_days' => 7,
  33. // Link colours (bits per second bucket) -----------------------------
  34. 'colors' => [
  35. '100' => '#F39C12', // 100M 橙色
  36. '1000' => '#2ECC71', // 1G 绿色
  37. '10000' => '#3498DB', // 10G 蓝色
  38. '20000' => '#9B59B6', // 20G+ 紫色
  39. 'unknown' => '#E74C3C', // 未知 红色
  40. 'down' => '#C0392B' // 中断 红色
  41. ],
  42. // Latency thresholds (ms) applied to switch nodes (host->switch ping)
  43. 'latency_warn' => 3.0,
  44. 'latency_bad' => 5.0,
  45. // Grouping rules: first matching rule wins, else "Other".
  46. // match = contains | regex ; label may contain $1..$9 backrefs.
  47. 'group_rules' => [
  48. ['match' => 'contains', 'pattern' => 'DataCenter', 'label' => 'DataCenter', 'big' => true],
  49. ['match' => 'contains', 'pattern' => 'Core', 'label' => 'Core', 'big' => true],
  50. ['match' => 'regex', 'pattern' => 'COBCO-(\\d{4})-', 'label' => '$1', 'big' => false],
  51. ['match' => 'regex', 'pattern' => '^(\\d{4})-', 'label' => '$1', 'big' => false]
  52. ],
  53. 'other_label' => 'Other'
  54. ];
  55. }
  56. /** Create tables if absent. Safe to call on every request (idempotent). */
  57. public static function ensureSchema(): void {
  58. try {
  59. DB::execute(
  60. 'CREATE TABLE IF NOT EXISTS '.self::HISTORY_TABLE.' ('.
  61. ' id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,'.
  62. ' ts BIGINT UNSIGNED NOT NULL,'.
  63. ' captured_at DATETIME NOT NULL,'.
  64. ' stats TEXT NOT NULL,'.
  65. ' snapshot MEDIUMBLOB NOT NULL,'.
  66. ' PRIMARY KEY (id),'.
  67. ' KEY idx_l2_ts (ts)'.
  68. ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin'
  69. );
  70. DB::execute(
  71. 'CREATE TABLE IF NOT EXISTS '.self::CONFIG_TABLE.' ('.
  72. ' id TINYINT UNSIGNED NOT NULL,'.
  73. ' config LONGTEXT NOT NULL,'.
  74. ' updated DATETIME NOT NULL,'.
  75. ' PRIMARY KEY (id)'.
  76. ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin'
  77. );
  78. }
  79. catch (Throwable $e) {
  80. // Table may already exist or the DB user may lack DDL rights; the
  81. // compose init sidecar / install doc handles that case. Never fail a page.
  82. }
  83. }
  84. public static function getConfig(): array {
  85. $cfg = self::defaults();
  86. try {
  87. $rows = DB::select('SELECT config FROM '.self::CONFIG_TABLE.' WHERE id='.self::CONFIG_ID);
  88. if ($rows && isset($rows[0]['config'])) {
  89. $saved = json_decode((string) $rows[0]['config'], true);
  90. if (is_array($saved)) {
  91. $cfg = self::mergeDeep($cfg, $saved);
  92. }
  93. }
  94. }
  95. catch (Throwable $e) {
  96. // fall back to defaults
  97. }
  98. // Make sure the out-of-process collectors can always read the config
  99. // (write it once if the mirror file is still missing).
  100. $mirror = dirname(__DIR__).'/assets/l2topology/config.json';
  101. if (!is_file($mirror)) {
  102. self::writeMirror($cfg);
  103. }
  104. return $cfg;
  105. }
  106. public static function saveConfig(array $cfg): void {
  107. $cfg = self::mergeDeep(self::defaults(), $cfg);
  108. $json = json_encode($cfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  109. try {
  110. DB::execute(
  111. 'INSERT INTO '.self::CONFIG_TABLE.' (id, config, updated) VALUES ('.self::CONFIG_ID.', ?, UTC_TIMESTAMP())'.
  112. ' ON DUPLICATE KEY UPDATE config=VALUES(config), updated=VALUES(updated)',
  113. [$json]
  114. );
  115. }
  116. catch (Throwable $e) {
  117. }
  118. self::writeMirror($cfg);
  119. }
  120. /** Mirror config to a JSON file so the out-of-process collectors can read it. */
  121. public static function writeMirror(array $cfg): void {
  122. $file = dirname(__DIR__).'/assets/l2topology/config.json';
  123. try {
  124. $tmp = $file.'.tmp';
  125. file_put_contents($tmp, json_encode($cfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
  126. @chmod($tmp, 0644);
  127. @rename($tmp, $file);
  128. }
  129. catch (Throwable $e) {
  130. }
  131. }
  132. /** Newest-first list of stored snapshots (for the time selector). */
  133. public static function listHistory(int $limit = 500): array {
  134. $out = [];
  135. try {
  136. $rows = DB::select(
  137. 'SELECT ts, captured_at, stats FROM '.self::HISTORY_TABLE.' ORDER BY ts DESC LIMIT '.max(1, $limit)
  138. );
  139. foreach ($rows as $r) {
  140. $out[] = [
  141. 'ts' => (int) $r['ts'],
  142. 'captured_at' => (string) $r['captured_at'],
  143. 'stats' => json_decode((string) $r['stats'], true)
  144. ];
  145. }
  146. }
  147. catch (Throwable $e) {
  148. }
  149. return $out;
  150. }
  151. /**
  152. * Snapshot JSON at (or just before) $ts. When $ts is null the newest stored
  153. * snapshot is used, falling back to the live snapshot.json file.
  154. */
  155. public static function snapshotAt(?int $ts): array {
  156. $json = null;
  157. $used = $ts;
  158. try {
  159. if ($ts === null) {
  160. $rows = DB::select('SELECT ts, snapshot FROM '.self::HISTORY_TABLE.' ORDER BY ts DESC LIMIT 1');
  161. }
  162. else {
  163. $rows = DB::select(
  164. 'SELECT ts, snapshot FROM '.self::HISTORY_TABLE.' WHERE ts<=? ORDER BY ts DESC LIMIT 1',
  165. [$ts]
  166. );
  167. if (!$rows) {
  168. $rows = DB::select('SELECT ts, snapshot FROM '.self::HISTORY_TABLE.' ORDER BY ts ASC LIMIT 1');
  169. }
  170. }
  171. if ($rows) {
  172. $blob = $rows[0]['snapshot'];
  173. $raw = @gzdecode($blob);
  174. if ($raw !== false && $raw !== '') {
  175. $json = $raw;
  176. $used = (int) $rows[0]['ts'];
  177. }
  178. }
  179. }
  180. catch (Throwable $e) {
  181. }
  182. if ($json === null) {
  183. $file = dirname(__DIR__).'/assets/l2topology/snapshot.json';
  184. if (is_readable($file)) {
  185. $raw = file_get_contents($file);
  186. if ($raw !== false && $raw !== '') {
  187. $json = $raw;
  188. }
  189. }
  190. }
  191. return ['json' => $json, 'ts' => $used];
  192. }
  193. private static function mergeDeep(array $base, array $over): array {
  194. foreach ($over as $k => $v) {
  195. if (is_array($v) && isset($base[$k]) && is_array($base[$k]) && self::isAssoc($base[$k])) {
  196. $base[$k] = self::mergeDeep($base[$k], $v);
  197. }
  198. else {
  199. $base[$k] = $v;
  200. }
  201. }
  202. return $base;
  203. }
  204. private static function isAssoc(array $a): bool {
  205. return array_keys($a) !== range(0, count($a) - 1);
  206. }
  207. }