1 GB of raw JSON. */ namespace Modules\WecomCenter\Lib; use DB; use Throwable; class L2Store { public const HISTORY_TABLE = 'l2_topology_history'; public const CONFIG_TABLE = 'l2_topology_config'; public const CONFIG_ID = 1; /** Full default configuration. Every key can be overridden from the UI. */ public static function defaults(): array { return [ // Display page ------------------------------------------------------ 'refresh_seconds' => 300, // auto-refresh of the live view 'root_label' => 'COBCO', // outermost frame label // Physical networks. A switch belongs to the first network whose // keyword appears in its name or IP; adjacency is used as fallback. // key | label | keyword1,keyword2 | colour 'networks' => [ ['key' => 'SURVE', 'label' => 'SURVE 监控网', 'keywords' => ['SURVE', '10.169.'], 'color' => '#2E75B6'], ['key' => 'OFFICE', 'label' => 'OFFICE 办公网', 'keywords' => ['OFFICE', '10.160.'], 'color' => '#548235'] ], // Root/core switches. A network may have more than one core; every // match is drawn in the left-most core column. match = contains|regex. 'core_rules' => [ ['net' => 'SURVE', 'match' => 'contains', 'pattern' => 'core surve'], ['net' => 'OFFICE', 'match' => 'contains', 'pattern' => 'core office'] ], // Collection -------------------------------------------------------- 'templates' => ['Huawei VRP by SNMP (walk)'], 'full_interval_min' => 5, // full topology/port collection 'alarm_interval_min'=> 1, // alarm-only refresh 'history_interval_min' => 5, // one DB snapshot per N minutes 'history_days' => 7, // retention window, hard-pruned // Link colours (bits per second bucket) ----------------------------- 'colors' => [ '100' => '#F39C12', // 100M 橙色 '1000' => '#2ECC71', // 1G 绿色 '10000' => '#3498DB', // 10G 蓝色 '20000' => '#9B59B6', // 20G+ 紫色 'unknown' => '#E74C3C', // 未知 红色 'down' => '#C0392B' // 中断 红色 ], // Latency thresholds (ms) applied to switch nodes (host->switch ping) 'latency_warn' => 1.0, 'latency_bad' => 5.0, // Grouping rules: first matching rule wins, else "Other". // match = contains | regex ; label may contain $1..$9 backrefs. 'group_rules' => [ ['match' => 'contains', 'pattern' => 'DataCenter', 'label' => 'DataCenter', 'big' => true], ['match' => 'contains', 'pattern' => 'Core', 'label' => 'Core', 'big' => true], ['match' => 'regex', 'pattern' => 'COBCO-(\\d{4}[A-Za-z]?)', 'label' => '$1', 'big' => false], ['match' => 'regex', 'pattern' => '^(\\d{4}[A-Za-z]?)', 'label' => '$1', 'big' => false] ], 'other_label' => 'Other' ]; } /** Create tables if absent. Safe to call on every request (idempotent). */ public static function ensureSchema(): void { try { \DBexecute( 'CREATE TABLE IF NOT EXISTS '.self::HISTORY_TABLE.' ('. ' id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,'. ' ts BIGINT UNSIGNED NOT NULL,'. ' captured_at DATETIME NOT NULL,'. ' stats TEXT NOT NULL,'. ' snapshot MEDIUMBLOB NOT NULL,'. ' PRIMARY KEY (id),'. ' KEY idx_l2_ts (ts)'. ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin' ); \DBexecute( 'CREATE TABLE IF NOT EXISTS '.self::CONFIG_TABLE.' ('. ' id TINYINT UNSIGNED NOT NULL,'. ' config LONGTEXT NOT NULL,'. ' updated DATETIME NOT NULL,'. ' PRIMARY KEY (id)'. ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin' ); } catch (Throwable $e) { // Table may already exist or the DB user may lack DDL rights; the // compose init sidecar / install doc handles that case. Never fail a page. } } public static function getConfig(): array { self::ensureSchema(); $cfg = self::defaults(); $mirror = dirname(__DIR__).'/assets/l2topology/config.json'; try { // Module tables are not in Zabbix's table-schema cache, so use the // raw DBselect()/DBfetch() helpers rather than DB::select(). $cursor = \DBselect('SELECT config FROM '.self::CONFIG_TABLE.' WHERE id='.self::CONFIG_ID); $row = \DBfetch($cursor); $rows = $row ? [$row] : []; if ($rows && isset($rows[0]['config'])) { $saved = json_decode((string) $rows[0]['config'], true); if (is_array($saved)) { $cfg = self::mergeDeep($cfg, $saved); } } else if (is_readable($mirror)) { // Keep working when the table could not be created yet but the // collector mirror was written by an earlier save. $saved = json_decode((string) @file_get_contents($mirror), true); if (is_array($saved)) { $cfg = self::mergeDeep($cfg, $saved); } } } catch (Throwable $e) { // fall back to defaults } // Make sure the out-of-process collectors can always read the config // (write it once if the mirror file is still missing). if (!is_file($mirror)) { self::writeMirror($cfg); } return $cfg; } public static function saveConfig(array $cfg): void { self::ensureSchema(); $cfg = self::mergeDeep(self::defaults(), $cfg); $json = json_encode($cfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); try { \DBexecute( 'INSERT INTO '.self::CONFIG_TABLE.' (id, config, updated) VALUES ('. self::CONFIG_ID.', '.\zbx_dbstr($json).', UTC_TIMESTAMP())'. ' ON DUPLICATE KEY UPDATE config=VALUES(config), updated=VALUES(updated)' ); } catch (Throwable $e) { error_log('L2Store::saveConfig failed: '.$e->getMessage()); } self::writeMirror($cfg); } /** Mirror config to a JSON file so the out-of-process collectors can read it. */ public static function writeMirror(array $cfg): void { $file = dirname(__DIR__).'/assets/l2topology/config.json'; try { $tmp = $file.'.tmp'; file_put_contents($tmp, json_encode($cfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); @chmod($tmp, 0644); @rename($tmp, $file); } catch (Throwable $e) { } } /** Newest-first list of stored snapshots (for the time selector). */ public static function listHistory(int $limit = 500): array { $out = []; try { $cursor = \DBselect( 'SELECT ts, captured_at, stats FROM '.self::HISTORY_TABLE. ' ORDER BY ts DESC LIMIT '.max(1, $limit) ); while ($r = \DBfetch($cursor)) { $out[] = [ 'ts' => (int) $r['ts'], 'captured_at' => (string) $r['captured_at'], 'stats' => json_decode((string) $r['stats'], true) ]; } } catch (Throwable $e) { // No history yet / table not ready: the selector simply stays empty. } return $out; } /** * Snapshot JSON at the exact recorded $ts. When $ts is null the newest * stored snapshot is used, falling back to the live snapshot.json file. */ public static function snapshotAt(?int $ts): array { $json = null; $used = $ts; try { if ($ts === null) { $cursor = \DBselect( 'SELECT ts, snapshot FROM '.self::HISTORY_TABLE.' ORDER BY ts DESC LIMIT 1' ); $row = \DBfetch($cursor); $rows = $row ? [$row] : []; } else { // The UI only offers exact recorded timestamps. Querying by // exact ts prevents an arbitrary URL from silently showing // the nearest (wrong) snapshot. $cursor = \DBselect( 'SELECT ts, snapshot FROM '.self::HISTORY_TABLE. ' WHERE ts='.(int) $ts.' LIMIT 1' ); $row = \DBfetch($cursor); $rows = $row ? [$row] : []; } if ($rows) { $blob = $rows[0]['snapshot']; $raw = @gzdecode($blob); if ($raw !== false && $raw !== '') { $json = $raw; $used = (int) $rows[0]['ts']; } } } catch (Throwable $e) { } if ($json === null && $ts === null) { $file = dirname(__DIR__).'/assets/l2topology/snapshot.json'; if (is_readable($file)) { $raw = file_get_contents($file); if ($raw !== false && $raw !== '') { $json = $raw; } } } return ['json' => $json, 'ts' => ($json !== null ? $used : null)]; } private static function mergeDeep(array $base, array $over): array { foreach ($over as $k => $v) { if (is_array($v) && isset($base[$k]) && is_array($base[$k]) && self::isAssoc($base[$k])) { $base[$k] = self::mergeDeep($base[$k], $v); } else { $base[$k] = $v; } } return $base; } private static function isAssoc(array $a): bool { return array_keys($a) !== range(0, count($a) - 1); } }