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 'dark_default' => false, // start in dark mode 'root_label' => 'COBCO', // outermost frame label // Collection -------------------------------------------------------- 'templates' => ['Huawei VRP by SNMP (walk)'], 'full_interval_min' => 5, // full topology/port collection 'alarm_interval_min'=> 1, // alarm-only refresh 'history_days' => 7, // 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' => 3.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 { DB::execute( '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' ); DB::execute( '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(); try { $rows = DB::select('SELECT config FROM '.self::CONFIG_TABLE.' WHERE id='.self::CONFIG_ID); if ($rows && isset($rows[0]['config'])) { $saved = json_decode((string) $rows[0]['config'], 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). $mirror = dirname(__DIR__).'/assets/l2topology/config.json'; 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 { DB::execute( 'INSERT INTO '.self::CONFIG_TABLE.' (id, config, updated) VALUES ('.self::CONFIG_ID.', ?, UTC_TIMESTAMP())'. ' ON DUPLICATE KEY UPDATE config=VALUES(config), updated=VALUES(updated)', [$json] ); } catch (Throwable $e) { } 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 { $rows = DB::select( 'SELECT ts, captured_at, stats FROM '.self::HISTORY_TABLE.' ORDER BY ts DESC LIMIT '.max(1, $limit) ); foreach ($rows as $r) { $out[] = [ 'ts' => (int) $r['ts'], 'captured_at' => (string) $r['captured_at'], 'stats' => json_decode((string) $r['stats'], true) ]; } } catch (Throwable $e) { } return $out; } /** * Snapshot JSON at (or just before) $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) { $rows = DB::select('SELECT ts, snapshot FROM '.self::HISTORY_TABLE.' ORDER BY ts DESC LIMIT 1'); } else { $rows = DB::select( 'SELECT ts, snapshot FROM '.self::HISTORY_TABLE.' WHERE ts<=? ORDER BY ts DESC LIMIT 1', [$ts] ); if (!$rows) { $rows = DB::select('SELECT ts, snapshot FROM '.self::HISTORY_TABLE.' ORDER BY ts ASC LIMIT 1'); } } 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) { $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' => $used]; } 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); } }