From ca876a8951387b092399e869d3a67555d0e6b835 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Sat, 29 Nov 2025 19:30:47 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0Swagger=20API=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/index.html | 1767 ++++++++++++++++++ config.json | 38 - css/style.css | 1078 +++++++++++ css/vendor/all.min.css | 9 + css/webfonts/fa-solid-900.woff2 | Bin 0 -> 150124 bytes dns/server.go | 276 ++- go.mod | 1 + go.sum | 2 + http/server.go | 389 +++- index.html | 2633 +++++++++++---------------- js/api.js | 298 +++ js/app.js | 317 ++++ js/colors.config.js | 53 + js/config.js | 284 +++ js/dashboard.js | 3012 +++++++++++++++++++++++++++++++ js/hosts.js | 202 +++ js/main.js | 173 ++ js/modules/blacklists.js | 255 +++ js/modules/config.js | 125 ++ js/modules/dashboard.js | 1220 +++++++++++++ js/modules/hosts.js | 308 ++++ js/modules/query.js | 294 +++ js/modules/rules.js | 422 +++++ js/query.js | 301 +++ js/server-status.js | 305 ++++ js/shield.js | 1302 +++++++++++++ js/vendor/chart.umd.min.js | 1 + main.go | 69 +- package.json | 18 + shield/manager.go | 109 +- static/css/style.css | 34 + static/index.html | 470 ++++- static/js/api.js | 168 +- static/js/colors.config.js | 53 + static/js/dashboard.js | 2165 ++++++++++++++++++++-- static/js/server-status.js | 292 +++ static/js/shield.js | 103 +- tailwind.config.js | 24 + test_console.sh | 45 + 39 files changed, 16612 insertions(+), 2003 deletions(-) create mode 100644 api/index.html delete mode 100644 config.json create mode 100644 css/style.css create mode 100644 css/vendor/all.min.css create mode 100644 css/webfonts/fa-solid-900.woff2 create mode 100644 js/api.js create mode 100644 js/app.js create mode 100644 js/colors.config.js create mode 100644 js/config.js create mode 100644 js/dashboard.js create mode 100644 js/hosts.js create mode 100644 js/main.js create mode 100644 js/modules/blacklists.js create mode 100644 js/modules/config.js create mode 100644 js/modules/dashboard.js create mode 100644 js/modules/hosts.js create mode 100644 js/modules/query.js create mode 100644 js/modules/rules.js create mode 100644 js/query.js create mode 100644 js/server-status.js create mode 100644 js/shield.js create mode 100644 js/vendor/chart.umd.min.js create mode 100644 package.json create mode 100644 static/js/colors.config.js create mode 100644 static/js/server-status.js create mode 100644 tailwind.config.js create mode 100755 test_console.sh diff --git a/api/index.html b/api/index.html new file mode 100644 index 0000000..3360221 --- /dev/null +++ b/api/index.html @@ -0,0 +1,1767 @@ + + + + + DNS Server API 文档 + + + + + +
+ + + + + + \ No newline at end of file diff --git a/config.json b/config.json deleted file mode 100644 index 016f1b7..0000000 --- a/config.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "dns": { - "port": 53, - "upstreamDNS": [ - "223.5.5.5:53", - "223.6.6.6:53" - ], - "timeout": 5000, - "statsFile": "./data/stats.json", - "saveInterval": 300 - }, - "http": { - "port": 8080, - "host": "0.0.0.0", - "enableAPI": true - }, - "shield": { - "localRulesFile": "data/rules.txt", - "remoteRules": [ - "https://example.com/rules.txt", - "https://gitea.amazehome.xyz/AMAZEHOME/hosts-and-filters/raw/branch/main/rules/costomize.txt" - ], - "updateInterval": 60, - "hostsFile": "data/hosts.txt", - "blockMethod": "NXDOMAIN", - "customBlockIP": "", - "statsFile": "./data/shield_stats.json", - "statsSaveInterval": 60, - "remoteRulesCacheDir": "./data/remote_rules" - }, - "log": { - "file": "dns-server.log", - "level": "debug", - "maxSize": 100, - "maxBackups": 10, - "maxAge": 30 - } -} diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..c61d7f4 --- /dev/null +++ b/css/style.css @@ -0,0 +1,1078 @@ +/* 全局样式重置 */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: #f5f7fa; + color: #333; + line-height: 1.6; + width: 100%; + height: 100%; + overflow-x: hidden; +} + +body { + position: relative; +} + +/* 基础响应式变量 */ +:root { + --sidebar-width: 250px; + --sidebar-mobile-width: 70px; + --header-height: 130px; + --content-padding: 1rem; + --card-min-width: 300px; +} + +/* 主容器样式 */ +.container { + display: flex; + flex-direction: column; + min-height: 100vh; + width: 100%; + max-width: 100%; + background-color: #fff; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.05); +} + +/* 头部样式 */ +header.header-container { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 1.5rem; + width: 100%; + text-align: center; + box-sizing: border-box; + position: relative; + z-index: 10; +} + +.logo { + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1rem; +} + +.logo i { + margin-right: 1rem; + color: white; +} + +.logo h1 { + font-size: 1.8rem; + margin: 0; + font-weight: 600; +} + +header p { + font-size: 1rem; + opacity: 0.9; +} + +/* 主体布局容器 */ +.main-layout { + display: flex; + flex: 1; + min-height: 0; + transition: all 0.3s ease; +} + +/* 侧边栏样式 */ +.sidebar { + width: var(--sidebar-width); + background-color: #2c3e50; + color: white; + padding: 1rem 0; + flex-shrink: 0; + overflow-y: auto; + height: calc(100vh - var(--header-height)); /* 减去header的高度 */ + transition: width 0.3s ease; + position: relative; +} + +/* 移动设备侧边栏切换按钮 */ +.sidebar-toggle { + position: fixed; + top: calc(var(--header-height) + 10px); + left: 10px; + z-index: 100; + background-color: #2c3e50; + color: white; + border: none; + border-radius: 4px; + padding: 8px 12px; + cursor: pointer; + display: none; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); +} + +/* 响应式布局 - 平板设备 */ +@media (max-width: 992px) { + .sidebar { + width: var(--sidebar-mobile-width); + } + + .nav-item span { + display: none; + } + + .nav-item i { + margin-right: 0; + } + + .sidebar-toggle { + display: block; + } +} + +/* 响应式布局 - 移动设备 */ +@media (max-width: 768px) { + /* 这些样式已经通过Tailwind CSS类在HTML中实现,这里移除避免冲突 */ +} + +.nav-menu { + list-style: none; +} + +.nav-item { + padding: 1rem 1.5rem; + display: flex; + align-items: center; + cursor: pointer; + transition: all 0.3s ease; +} + +.nav-item:hover { + background-color: #34495e; + padding-left: 1.75rem; +} + +.nav-item.active { + background-color: #3498db; + border-left: 4px solid #fff; +} + +.nav-item i { + margin-right: 1rem; + width: 20px; + text-align: center; +} + +/* 主内容区域样式 */ +.content { + flex: 1; + padding: var(--content-padding); + overflow-y: auto; + background-color: #f8f9fa; + min-width: 0; /* 防止flex子元素溢出 */ + height: calc(100vh - var(--header-height)); /* 减去header的高度 */ + transition: padding-left 0.3s ease; +} + +/* Tooltip趋势信息颜色类 - 替代内联style */ +.tooltip-trend { + font-weight: 500; +} + +/* 注意:这些颜色值与colors.config.js中的COLOR_CONFIG.colorClassMap保持同步 */ +.tooltip-trend.blue { + color: #1890ff; +} + +.tooltip-trend.green { + color: #52c41a; +} + +.tooltip-trend.orange { + color: #fa8c16; +} + +.tooltip-trend.red { + color: #f5222d; +} + +.tooltip-trend.purple { + color: #722ed1; +} + +.tooltip-trend.cyan { + color: #13c2c2; +} + +.tooltip-trend.teal { + color: #36cfc9; +} + +/* 平板设备适配 - 侧边栏折叠时调整内容区域 */ +@media (max-width: 992px) { + .content { + padding-left: calc(var(--content-padding) + 10px); + } +} + +/* 移动设备适配 - 侧边栏隐藏时的内容区域 */ +@media (max-width: 768px) { + .content { + padding-left: var(--content-padding); + } + + /* 响应式头部样式 */ + header.header-container { + padding: 1rem; + } + + .logo h1 { + font-size: 1.5rem; + } + + header p { + font-size: 0.9rem; + } +} + +/* 面板样式 */ +.panel { + display: none; + background-color: white; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); + box-sizing: border-box; + overflow: hidden; +} + +.panel.active { + display: block; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid #e9ecef; +} + +.panel-header h2 { + font-size: 1.5rem; + color: #2c3e50; +} + +/* 状态指示器 */ +.status-indicator { + display: flex; + align-items: center; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: #e74c3c; + margin-right: 8px; + animation: pulse 2s infinite; +} + +.status-dot.connected { + background-color: #2ecc71; +} + +@keyframes pulse { + 0% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.1); + opacity: 0.7; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +/* 按钮样式 */ +.btn { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; +} + +.btn i { + margin-right: 0.5rem; +} + +.btn-primary { + background-color: #3498db; + color: white; +} + +.btn-primary:hover { + background-color: #2980b9; +} + +.btn-secondary { + background-color: #7f8c8d; + color: white; +} + +.btn-secondary:hover { + background-color: #6c757d; +} + +.btn-success { + background-color: #2ecc71; + color: white; +} + +.btn-success:hover { + background-color: #27ae60; +} + +.btn-danger { + background-color: #e74c3c; + color: white; +} + +.btn-danger:hover { + background-color: #c0392b; +} + +.btn-warning { + background-color: #f39c12; + color: white; +} + +.btn-warning:hover { + background-color: #e67e22; +} + +.btn-sm { + padding: 0.375rem 0.75rem; + font-size: 0.8rem; +} + +.btn-block { + width: 100%; +} + +/* 统计卡片网格 */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(250px, 100%), 1fr)); + gap: clamp(1rem, 3vw, 1.5rem); /* 根据屏幕宽度动态调整间距 */ + margin-bottom: 2rem; +} + +/* 图表容器 */ +.charts-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); + gap: clamp(1rem, 3vw, 1.5rem); /* 根据屏幕宽度动态调整间距 */ + margin-bottom: 1.5rem; +} + +.stat-card { + background-color: white; + border-radius: 8px; + padding: clamp(1rem, 3vw, 1.5rem); /* 根据屏幕宽度动态调整内边距 */ + text-align: center; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); + transition: transform 0.3s ease, box-shadow 0.3s ease; + min-width: 0; /* 防止内容溢出 */ + display: flex; + flex-direction: column; + justify-content: center; +} + +.stat-card:hover { + transform: translateY(-5px); + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); +} + +/* 卡片布局的响应式优化 */ +@media (max-width: 640px) { + /* 在极小屏幕上,调整卡片网格为单列显示 */ + .stats-grid, + .charts-container, + .tables-container { + grid-template-columns: 1fr; + } + + /* 卡片更紧凑的内边距 */ + .stat-card, + .chart-card, + .table-card { + padding: 1rem; + min-height: 120px; + } + + /* 优化统计卡片的图标大小 */ + .stat-card i { + font-size: 1.5rem; + margin-bottom: 0.5rem; + } + + /* 优化统计卡片的数值和标签 */ + .stat-value { + font-size: clamp(1.2rem, 5vw, 1.5rem); + } + + .stat-label { + font-size: clamp(0.7rem, 3vw, 0.8rem); + } + + /* 优化图表卡片标题 */ + .chart-card h3 { + font-size: clamp(1rem, 4vw, 1.1rem); + } + + /* 优化面板标题 */ + .panel-header h2 { + font-size: clamp(1.2rem, 5vw, 1.3rem); + } +} + +.stat-card i { + font-size: 2rem; + margin-bottom: 1rem; + color: #3498db; +} + +.stat-value { + font-size: 2rem; + font-weight: bold; + margin-bottom: 0.5rem; + color: #2c3e50; +} + +.stat-label { + font-size: 0.9rem; + color: #7f8c8d; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* 图表容器 */ +.charts-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.chart-card { + background-color: white; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); +} + +.chart-card h3 { + margin-bottom: 1rem; + font-size: 1.2rem; + color: #2c3e50; +} + +/* 表格容器 */ +.tables-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); + gap: 1.5rem; +} + +/* 表格卡片样式 */ +.table-card { + background-color: white; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); + min-width: 0; /* 防止子元素溢出 */ +} + +/* 表格响应式样式 */ +@media (max-width: 768px) { + /* 调整卡片内边距 */ + .table-card, + .stat-card, + .chart-card { + padding: 1rem; + } + + /* 调整表格单元格内边距 */ + th, td { + padding: 0.5rem; + font-size: 0.9rem; + } + + /* 调整表格卡片标题 */ + .table-card h3, + .chart-card h3 { + font-size: 1.1rem; + } + + /* 调整统计卡片数值和标签 */ + .stat-value { + font-size: 1.5rem; + } + + .stat-label { + font-size: 0.8rem; + } + + /* 调整面板标题 */ + .panel-header h2 { + font-size: 1.3rem; + } + + /* 调整按钮大小 */ + .btn { + padding: 0.4rem 0.8rem; + font-size: 0.85rem; + } +} + +.table-card { + background-color: white; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); +} + +.table-card h3 { + margin-bottom: 1rem; + font-size: 1.2rem; + color: #2c3e50; +} +/* 表格样式 */ +.table-wrapper { + overflow-x: auto; + border-radius: 8px; + background-color: #ffffff; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin-bottom: 16px; + display: block; + width: 100%; + -webkit-overflow-scrolling: touch; /* iOS平滑滚动 */ +} + +/* 最常屏蔽和最常解析域名表格的特殊样式 */ +#top-blocked-table, #top-resolved-table { + font-size: 0.85rem; +} + +/* 限制域名表格高度,只显示5条内容 */ +.table-card .table-wrapper { + max-height: 220px; + overflow-y: auto; + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + background-color: #ffffff; + margin: 0; + table-layout: fixed; /* 固定布局,有助于响应式设计 */ +} + +th, td { + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid #e9ecef; + word-break: break-word; /* 长文本自动换行 */ +} + +/* 缩小最常屏蔽和最常解析域名表格的单元格内边距 */ +#top-blocked-table th, #top-blocked-table td, +#top-resolved-table th, #top-resolved-table td { + padding: 0.5rem 0.75rem; + font-size: 0.85rem; +} + +/* 移动设备上表格的优化 */ +@media (max-width: 768px) { + /* 确保表格可以水平滚动 */ + .table-wrapper { + max-width: 100%; + margin-left: -1rem; + margin-right: -1rem; + border-radius: 0; + } + + /* 表格单元格内容截断处理 */ + td { + font-size: 0.85rem; + max-width: 150px; /* 限制单元格最大宽度 */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + /* 当用户触摸单元格时显示完整内容 */ + td:active { + white-space: normal; + word-break: break-word; + } + + /* 优化百分比条在小屏幕上的显示 */ + .count-cell { + position: relative; + padding-right: 50px; /* 为百分比文本留出空间 */ + } + + .percentage-text { + font-size: 10px; + right: 5px; + } +} + +th { + background-color: #f8f9fa; + font-weight: 600; + color: #2c3e50; +} + +td.loading { + text-align: center; + color: #7f8c8d; + font-style: italic; +} + +tr:hover { + background-color: #f8f9fa; +} + +/* 百分比条样式 */ +.count-cell { + position: relative; +} + +.count-number { + position: relative; + z-index: 2; + display: inline-block; +} + +.percentage-text { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + z-index: 2; + font-size: 12px; + color: #bdc3c7; +} + +.percentage-bar-container { + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 100%; + z-index: 1; + overflow: hidden; + border-radius: 4px; + opacity: 0.2; +} + +.percentage-bar { + height: 100%; + transition: width 0.5s ease; + border-radius: 4px; +} + +/* 分页控件样式 */ +.pagination-controls { + background-color: #ffffff; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 16px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 16px; +} + +.pagination-info { + font-size: 14px; + color: #666; +} + +.pagination-buttons { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.items-per-page { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; +} + +.items-per-page select { + padding: 6px 12px; + border: 1px solid #ddd; + border-radius: 4px; + background-color: #fff; + font-size: 14px; + cursor: pointer; +} + +.nav-buttons { + display: flex; + gap: 8px; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 规则内容样式优化 */ +.rule-content { + max-width: 600px; +} + +.rule-content pre { + margin: 0; + font-family: inherit; + white-space: pre-wrap; + word-break: break-all; + font-size: 14px; +} + +/* 表单样式 */ +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #2c3e50; +} + +.form-group input, +.form-group select { + width: 100%; + padding: 0.75rem; + border: 1px solid #ced4da; + border-radius: 4px; + font-size: 1rem; + transition: border-color 0.3s ease; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: #3498db; +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1rem; +} + +/* 管理区域样式 */ +.rules-management, +.hosts-management, +.blacklists-management { + margin-top: 1rem; +} + +.rules-input, +.rules-filter, +.hosts-filter { + display: grid; + grid-template-columns: 1fr auto; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.rules-input { + grid-template-columns: 1fr auto auto; +} + +/* 查询表单 */ +.query-form .form-group { + display: grid; + grid-template-columns: 1fr auto; + gap: 1rem; +} + +/* 查询结果样式 */ +.query-result { + margin-top: 2rem; +} + +#query-result-container { + background-color: #f8f9fa; + border-radius: 8px; + padding: 1.5rem; +} + +#query-result-container.hidden { + display: none; +} + +.result-header { + margin-bottom: 1rem; + border-bottom: 1px solid #e9ecef; + padding-bottom: 0.5rem; +} + +.result-header h3 { + font-size: 1.2rem; + color: #2c3e50; +} + +.result-item { + padding: 0.5rem 0; + border-bottom: 1px solid #e9ecef; +} + +.result-item:last-child { + border-bottom: none; +} + +/* 配置表单样式 */ +.config-form { + margin-top: 1rem; +} + +.config-section { + background-color: #f8f9fa; + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 2rem; +} + +.config-section h3 { + margin-bottom: 1.5rem; + font-size: 1.2rem; + color: #2c3e50; +} + +.config-actions { + text-align: center; + margin-top: 2rem; +} + +/* 通知组件 */ +.notification { + position: fixed; + bottom: 20px; + right: 20px; + background-color: #3498db; + color: white; + padding: 1rem 1.5rem; + border-radius: 4px; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); + z-index: 1000; + transform: translateX(100%); + transition: transform 0.3s ease; +} + +.notification.show { + transform: translateX(0); +} + +.notification.success { + background-color: #2ecc71; +} + +.notification.error { + background-color: #e74c3c; +} + +.notification.warning { + background-color: #f39c12; +} + +.notification-content { + display: flex; + align-items: center; +} + +.notification-content i { + margin-right: 1rem; +} + +/* 大屏幕优化 */ +@media (min-width: 1200px) { + .container { + max-width: 1400px; + margin: 0 auto; + } +} + +/* 平板设备 */ +@media (max-width: 1024px) { + .content { + padding: 1rem; + } + + .stats-grid, + .charts-container { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + } + + .tables-container { + grid-template-columns: 1fr; + } +} + +/* 移动设备 */ +@media (max-width: 768px) { + .container { + flex-direction: column; + } + + .sidebar { + width: 100%; + height: auto; + max-height: 120px; + } + + .nav-menu { + display: flex; + overflow-x: auto; + padding-bottom: 0.5rem; + } + + .nav-item { + white-space: nowrap; + padding: 0.75rem 1rem; + } + + .stats-grid, + .charts-container, + .tables-container { + grid-template-columns: 1fr; + } + + .rules-input, + .rules-filter, + .hosts-filter { + grid-template-columns: 1fr; + } + + .form-row { + grid-template-columns: 1fr; + } + + .query-form .form-group { + grid-template-columns: 1fr; + } + + .panel { + padding: 1rem; + } + + .pagination-controls { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .pagination-buttons { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .nav-buttons { + justify-content: center; + } +} + +/* 小屏幕移动设备 */ +@media (max-width: 480px) { + header { + padding: 1.5rem 1rem; + } + + .logo h1 { + font-size: 1.5rem; + } + + .content { + padding: 0.75rem; + } + + .panel-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .stat-card { + padding: 1rem; + } + + .stat-value { + font-size: 1.5rem; + } + + .chart-card { + padding: 1rem; + } + + th, td { + padding: 0.5rem; + font-size: 0.9rem; + } +} + +/* 加载动画 */ +.loading { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid #f3f3f3; + border-top: 3px solid #3498db; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* 确保按钮在不同容器中保持一致宽度 */ +.w-full { + width: 100%; +} + +/* 确保输入和按钮在表单组中有合适的高度对齐 */ +.form-group button { + height: auto; + align-self: flex-end; +} + +/* 优化表格中的操作按钮间距 */ +.actions-cell { + display: flex; + gap: 0.5rem; +} \ No newline at end of file diff --git a/css/vendor/all.min.css b/css/vendor/all.min.css new file mode 100644 index 0000000..1f367c1 --- /dev/null +++ b/css/vendor/all.min.css @@ -0,0 +1,9 @@ +/*! + * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + * Copyright 2023 Fonticons, Inc. + */ +.fa{font-family:var(--fa-style-family,"Font Awesome 6 Free");font-weight:var(--fa-style,900)}.fa,.fa-brands,.fa-classic,.fa-regular,.fa-sharp,.fa-solid,.fab,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:var(--fa-display,inline-block);font-style:normal;font-variant:normal;line-height:1;text-rendering:auto}.fa-classic,.fa-regular,.fa-solid,.far,.fas{font-family:"Font Awesome 6 Free"}.fa-brands,.fab{font-family:"Font Awesome 6 Brands"}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-2xs{font-size:.625em;line-height:.1em;vertical-align:.225em}.fa-xs{font-size:.75em;line-height:.08333em;vertical-align:.125em}.fa-sm{font-size:.875em;line-height:.07143em;vertical-align:.05357em}.fa-lg{font-size:1.25em;line-height:.05em;vertical-align:-.075em}.fa-xl{font-size:1.5em;line-height:.04167em;vertical-align:-.125em}.fa-2xl{font-size:2em;line-height:.03125em;vertical-align:-.1875em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:var(--fa-li-margin,2.5em);padding-left:0}.fa-ul>li{position:relative}.fa-li{left:calc(var(--fa-li-width, 2em)*-1);position:absolute;text-align:center;width:var(--fa-li-width,2em);line-height:inherit}.fa-border{border-radius:var(--fa-border-radius,.1em);border:var(--fa-border-width,.08em) var(--fa-border-style,solid) var(--fa-border-color,#eee);padding:var(--fa-border-padding,.2em .25em .15em)}.fa-pull-left{float:left;margin-right:var(--fa-pull-margin,.3em)}.fa-pull-right{float:right;margin-left:var(--fa-pull-margin,.3em)}.fa-beat{-webkit-animation-name:fa-beat;animation-name:fa-beat;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-bounce{-webkit-animation-name:fa-bounce;animation-name:fa-bounce;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1))}.fa-fade{-webkit-animation-name:fa-fade;animation-name:fa-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-beat-fade,.fa-fade{-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s)}.fa-beat-fade{-webkit-animation-name:fa-beat-fade;animation-name:fa-beat-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-flip{-webkit-animation-name:fa-flip;animation-name:fa-flip;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-shake{-webkit-animation-name:fa-shake;animation-name:fa-shake;-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-shake,.fa-spin{-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal)}.fa-spin{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-duration:var(--fa-animation-duration,2s);animation-duration:var(--fa-animation-duration,2s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-spin-reverse{--fa-animation-direction:reverse}.fa-pulse,.fa-spin-pulse{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,steps(8));animation-timing-function:var(--fa-animation-timing,steps(8))}@media (prefers-reduced-motion:reduce){.fa-beat,.fa-beat-fade,.fa-bounce,.fa-fade,.fa-flip,.fa-pulse,.fa-shake,.fa-spin,.fa-spin-pulse{-webkit-animation-delay:-1ms;animation-delay:-1ms;-webkit-animation-duration:1ms;animation-duration:1ms;-webkit-animation-iteration-count:1;animation-iteration-count:1;-webkit-transition-delay:0s;transition-delay:0s;-webkit-transition-duration:0s;transition-duration:0s}}@-webkit-keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@-webkit-keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@-webkit-keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@-webkit-keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@-webkit-keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@-webkit-keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.fa-rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1);transform:scale(-1)}.fa-rotate-by{-webkit-transform:rotate(var(--fa-rotate-angle,none));transform:rotate(var(--fa-rotate-angle,none))}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%;z-index:var(--fa-stack-z-index,auto)}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:var(--fa-inverse,#fff)} + +.fa-0:before{content:"\30"}.fa-1:before{content:"\31"}.fa-2:before{content:"\32"}.fa-3:before{content:"\33"}.fa-4:before{content:"\34"}.fa-5:before{content:"\35"}.fa-6:before{content:"\36"}.fa-7:before{content:"\37"}.fa-8:before{content:"\38"}.fa-9:before{content:"\39"}.fa-fill-drip:before{content:"\f576"}.fa-arrows-to-circle:before{content:"\e4bd"}.fa-chevron-circle-right:before,.fa-circle-chevron-right:before{content:"\f138"}.fa-at:before{content:"\40"}.fa-trash-alt:before,.fa-trash-can:before{content:"\f2ed"}.fa-text-height:before{content:"\f034"}.fa-user-times:before,.fa-user-xmark:before{content:"\f235"}.fa-stethoscope:before{content:"\f0f1"}.fa-comment-alt:before,.fa-message:before{content:"\f27a"}.fa-info:before{content:"\f129"}.fa-compress-alt:before,.fa-down-left-and-up-right-to-center:before{content:"\f422"}.fa-explosion:before{content:"\e4e9"}.fa-file-alt:before,.fa-file-lines:before,.fa-file-text:before{content:"\f15c"}.fa-wave-square:before{content:"\f83e"}.fa-ring:before{content:"\f70b"}.fa-building-un:before{content:"\e4d9"}.fa-dice-three:before{content:"\f527"}.fa-calendar-alt:before,.fa-calendar-days:before{content:"\f073"}.fa-anchor-circle-check:before{content:"\e4aa"}.fa-building-circle-arrow-right:before{content:"\e4d1"}.fa-volleyball-ball:before,.fa-volleyball:before{content:"\f45f"}.fa-arrows-up-to-line:before{content:"\e4c2"}.fa-sort-desc:before,.fa-sort-down:before{content:"\f0dd"}.fa-circle-minus:before,.fa-minus-circle:before{content:"\f056"}.fa-door-open:before{content:"\f52b"}.fa-right-from-bracket:before,.fa-sign-out-alt:before{content:"\f2f5"}.fa-atom:before{content:"\f5d2"}.fa-soap:before{content:"\e06e"}.fa-heart-music-camera-bolt:before,.fa-icons:before{content:"\f86d"}.fa-microphone-alt-slash:before,.fa-microphone-lines-slash:before{content:"\f539"}.fa-bridge-circle-check:before{content:"\e4c9"}.fa-pump-medical:before{content:"\e06a"}.fa-fingerprint:before{content:"\f577"}.fa-hand-point-right:before{content:"\f0a4"}.fa-magnifying-glass-location:before,.fa-search-location:before{content:"\f689"}.fa-forward-step:before,.fa-step-forward:before{content:"\f051"}.fa-face-smile-beam:before,.fa-smile-beam:before{content:"\f5b8"}.fa-flag-checkered:before{content:"\f11e"}.fa-football-ball:before,.fa-football:before{content:"\f44e"}.fa-school-circle-exclamation:before{content:"\e56c"}.fa-crop:before{content:"\f125"}.fa-angle-double-down:before,.fa-angles-down:before{content:"\f103"}.fa-users-rectangle:before{content:"\e594"}.fa-people-roof:before{content:"\e537"}.fa-people-line:before{content:"\e534"}.fa-beer-mug-empty:before,.fa-beer:before{content:"\f0fc"}.fa-diagram-predecessor:before{content:"\e477"}.fa-arrow-up-long:before,.fa-long-arrow-up:before{content:"\f176"}.fa-burn:before,.fa-fire-flame-simple:before{content:"\f46a"}.fa-male:before,.fa-person:before{content:"\f183"}.fa-laptop:before{content:"\f109"}.fa-file-csv:before{content:"\f6dd"}.fa-menorah:before{content:"\f676"}.fa-truck-plane:before{content:"\e58f"}.fa-record-vinyl:before{content:"\f8d9"}.fa-face-grin-stars:before,.fa-grin-stars:before{content:"\f587"}.fa-bong:before{content:"\f55c"}.fa-pastafarianism:before,.fa-spaghetti-monster-flying:before{content:"\f67b"}.fa-arrow-down-up-across-line:before{content:"\e4af"}.fa-spoon:before,.fa-utensil-spoon:before{content:"\f2e5"}.fa-jar-wheat:before{content:"\e517"}.fa-envelopes-bulk:before,.fa-mail-bulk:before{content:"\f674"}.fa-file-circle-exclamation:before{content:"\e4eb"}.fa-circle-h:before,.fa-hospital-symbol:before{content:"\f47e"}.fa-pager:before{content:"\f815"}.fa-address-book:before,.fa-contact-book:before{content:"\f2b9"}.fa-strikethrough:before{content:"\f0cc"}.fa-k:before{content:"\4b"}.fa-landmark-flag:before{content:"\e51c"}.fa-pencil-alt:before,.fa-pencil:before{content:"\f303"}.fa-backward:before{content:"\f04a"}.fa-caret-right:before{content:"\f0da"}.fa-comments:before{content:"\f086"}.fa-file-clipboard:before,.fa-paste:before{content:"\f0ea"}.fa-code-pull-request:before{content:"\e13c"}.fa-clipboard-list:before{content:"\f46d"}.fa-truck-loading:before,.fa-truck-ramp-box:before{content:"\f4de"}.fa-user-check:before{content:"\f4fc"}.fa-vial-virus:before{content:"\e597"}.fa-sheet-plastic:before{content:"\e571"}.fa-blog:before{content:"\f781"}.fa-user-ninja:before{content:"\f504"}.fa-person-arrow-up-from-line:before{content:"\e539"}.fa-scroll-torah:before,.fa-torah:before{content:"\f6a0"}.fa-broom-ball:before,.fa-quidditch-broom-ball:before,.fa-quidditch:before{content:"\f458"}.fa-toggle-off:before{content:"\f204"}.fa-archive:before,.fa-box-archive:before{content:"\f187"}.fa-person-drowning:before{content:"\e545"}.fa-arrow-down-9-1:before,.fa-sort-numeric-desc:before,.fa-sort-numeric-down-alt:before{content:"\f886"}.fa-face-grin-tongue-squint:before,.fa-grin-tongue-squint:before{content:"\f58a"}.fa-spray-can:before{content:"\f5bd"}.fa-truck-monster:before{content:"\f63b"}.fa-w:before{content:"\57"}.fa-earth-africa:before,.fa-globe-africa:before{content:"\f57c"}.fa-rainbow:before{content:"\f75b"}.fa-circle-notch:before{content:"\f1ce"}.fa-tablet-alt:before,.fa-tablet-screen-button:before{content:"\f3fa"}.fa-paw:before{content:"\f1b0"}.fa-cloud:before{content:"\f0c2"}.fa-trowel-bricks:before{content:"\e58a"}.fa-face-flushed:before,.fa-flushed:before{content:"\f579"}.fa-hospital-user:before{content:"\f80d"}.fa-tent-arrow-left-right:before{content:"\e57f"}.fa-gavel:before,.fa-legal:before{content:"\f0e3"}.fa-binoculars:before{content:"\f1e5"}.fa-microphone-slash:before{content:"\f131"}.fa-box-tissue:before{content:"\e05b"}.fa-motorcycle:before{content:"\f21c"}.fa-bell-concierge:before,.fa-concierge-bell:before{content:"\f562"}.fa-pen-ruler:before,.fa-pencil-ruler:before{content:"\f5ae"}.fa-people-arrows-left-right:before,.fa-people-arrows:before{content:"\e068"}.fa-mars-and-venus-burst:before{content:"\e523"}.fa-caret-square-right:before,.fa-square-caret-right:before{content:"\f152"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-sun-plant-wilt:before{content:"\e57a"}.fa-toilets-portable:before{content:"\e584"}.fa-hockey-puck:before{content:"\f453"}.fa-table:before{content:"\f0ce"}.fa-magnifying-glass-arrow-right:before{content:"\e521"}.fa-digital-tachograph:before,.fa-tachograph-digital:before{content:"\f566"}.fa-users-slash:before{content:"\e073"}.fa-clover:before{content:"\e139"}.fa-mail-reply:before,.fa-reply:before{content:"\f3e5"}.fa-star-and-crescent:before{content:"\f699"}.fa-house-fire:before{content:"\e50c"}.fa-minus-square:before,.fa-square-minus:before{content:"\f146"}.fa-helicopter:before{content:"\f533"}.fa-compass:before{content:"\f14e"}.fa-caret-square-down:before,.fa-square-caret-down:before{content:"\f150"}.fa-file-circle-question:before{content:"\e4ef"}.fa-laptop-code:before{content:"\f5fc"}.fa-swatchbook:before{content:"\f5c3"}.fa-prescription-bottle:before{content:"\f485"}.fa-bars:before,.fa-navicon:before{content:"\f0c9"}.fa-people-group:before{content:"\e533"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-heart-broken:before,.fa-heart-crack:before{content:"\f7a9"}.fa-external-link-square-alt:before,.fa-square-up-right:before{content:"\f360"}.fa-face-kiss-beam:before,.fa-kiss-beam:before{content:"\f597"}.fa-film:before{content:"\f008"}.fa-ruler-horizontal:before{content:"\f547"}.fa-people-robbery:before{content:"\e536"}.fa-lightbulb:before{content:"\f0eb"}.fa-caret-left:before{content:"\f0d9"}.fa-circle-exclamation:before,.fa-exclamation-circle:before{content:"\f06a"}.fa-school-circle-xmark:before{content:"\e56d"}.fa-arrow-right-from-bracket:before,.fa-sign-out:before{content:"\f08b"}.fa-chevron-circle-down:before,.fa-circle-chevron-down:before{content:"\f13a"}.fa-unlock-alt:before,.fa-unlock-keyhole:before{content:"\f13e"}.fa-cloud-showers-heavy:before{content:"\f740"}.fa-headphones-alt:before,.fa-headphones-simple:before{content:"\f58f"}.fa-sitemap:before{content:"\f0e8"}.fa-circle-dollar-to-slot:before,.fa-donate:before{content:"\f4b9"}.fa-memory:before{content:"\f538"}.fa-road-spikes:before{content:"\e568"}.fa-fire-burner:before{content:"\e4f1"}.fa-flag:before{content:"\f024"}.fa-hanukiah:before{content:"\f6e6"}.fa-feather:before{content:"\f52d"}.fa-volume-down:before,.fa-volume-low:before{content:"\f027"}.fa-comment-slash:before{content:"\f4b3"}.fa-cloud-sun-rain:before{content:"\f743"}.fa-compress:before{content:"\f066"}.fa-wheat-alt:before,.fa-wheat-awn:before{content:"\e2cd"}.fa-ankh:before{content:"\f644"}.fa-hands-holding-child:before{content:"\e4fa"}.fa-asterisk:before{content:"\2a"}.fa-check-square:before,.fa-square-check:before{content:"\f14a"}.fa-peseta-sign:before{content:"\e221"}.fa-header:before,.fa-heading:before{content:"\f1dc"}.fa-ghost:before{content:"\f6e2"}.fa-list-squares:before,.fa-list:before{content:"\f03a"}.fa-phone-square-alt:before,.fa-square-phone-flip:before{content:"\f87b"}.fa-cart-plus:before{content:"\f217"}.fa-gamepad:before{content:"\f11b"}.fa-circle-dot:before,.fa-dot-circle:before{content:"\f192"}.fa-dizzy:before,.fa-face-dizzy:before{content:"\f567"}.fa-egg:before{content:"\f7fb"}.fa-house-medical-circle-xmark:before{content:"\e513"}.fa-campground:before{content:"\f6bb"}.fa-folder-plus:before{content:"\f65e"}.fa-futbol-ball:before,.fa-futbol:before,.fa-soccer-ball:before{content:"\f1e3"}.fa-paint-brush:before,.fa-paintbrush:before{content:"\f1fc"}.fa-lock:before{content:"\f023"}.fa-gas-pump:before{content:"\f52f"}.fa-hot-tub-person:before,.fa-hot-tub:before{content:"\f593"}.fa-map-location:before,.fa-map-marked:before{content:"\f59f"}.fa-house-flood-water:before{content:"\e50e"}.fa-tree:before{content:"\f1bb"}.fa-bridge-lock:before{content:"\e4cc"}.fa-sack-dollar:before{content:"\f81d"}.fa-edit:before,.fa-pen-to-square:before{content:"\f044"}.fa-car-side:before{content:"\f5e4"}.fa-share-alt:before,.fa-share-nodes:before{content:"\f1e0"}.fa-heart-circle-minus:before{content:"\e4ff"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-microscope:before{content:"\f610"}.fa-sink:before{content:"\e06d"}.fa-bag-shopping:before,.fa-shopping-bag:before{content:"\f290"}.fa-arrow-down-z-a:before,.fa-sort-alpha-desc:before,.fa-sort-alpha-down-alt:before{content:"\f881"}.fa-mitten:before{content:"\f7b5"}.fa-person-rays:before{content:"\e54d"}.fa-users:before{content:"\f0c0"}.fa-eye-slash:before{content:"\f070"}.fa-flask-vial:before{content:"\e4f3"}.fa-hand-paper:before,.fa-hand:before{content:"\f256"}.fa-om:before{content:"\f679"}.fa-worm:before{content:"\e599"}.fa-house-circle-xmark:before{content:"\e50b"}.fa-plug:before{content:"\f1e6"}.fa-chevron-up:before{content:"\f077"}.fa-hand-spock:before{content:"\f259"}.fa-stopwatch:before{content:"\f2f2"}.fa-face-kiss:before,.fa-kiss:before{content:"\f596"}.fa-bridge-circle-xmark:before{content:"\e4cb"}.fa-face-grin-tongue:before,.fa-grin-tongue:before{content:"\f589"}.fa-chess-bishop:before{content:"\f43a"}.fa-face-grin-wink:before,.fa-grin-wink:before{content:"\f58c"}.fa-deaf:before,.fa-deafness:before,.fa-ear-deaf:before,.fa-hard-of-hearing:before{content:"\f2a4"}.fa-road-circle-check:before{content:"\e564"}.fa-dice-five:before{content:"\f523"}.fa-rss-square:before,.fa-square-rss:before{content:"\f143"}.fa-land-mine-on:before{content:"\e51b"}.fa-i-cursor:before{content:"\f246"}.fa-stamp:before{content:"\f5bf"}.fa-stairs:before{content:"\e289"}.fa-i:before{content:"\49"}.fa-hryvnia-sign:before,.fa-hryvnia:before{content:"\f6f2"}.fa-pills:before{content:"\f484"}.fa-face-grin-wide:before,.fa-grin-alt:before{content:"\f581"}.fa-tooth:before{content:"\f5c9"}.fa-v:before{content:"\56"}.fa-bangladeshi-taka-sign:before{content:"\e2e6"}.fa-bicycle:before{content:"\f206"}.fa-rod-asclepius:before,.fa-rod-snake:before,.fa-staff-aesculapius:before,.fa-staff-snake:before{content:"\e579"}.fa-head-side-cough-slash:before{content:"\e062"}.fa-ambulance:before,.fa-truck-medical:before{content:"\f0f9"}.fa-wheat-awn-circle-exclamation:before{content:"\e598"}.fa-snowman:before{content:"\f7d0"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-road-barrier:before{content:"\e562"}.fa-school:before{content:"\f549"}.fa-igloo:before{content:"\f7ae"}.fa-joint:before{content:"\f595"}.fa-angle-right:before{content:"\f105"}.fa-horse:before{content:"\f6f0"}.fa-q:before{content:"\51"}.fa-g:before{content:"\47"}.fa-notes-medical:before{content:"\f481"}.fa-temperature-2:before,.fa-temperature-half:before,.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"}.fa-dong-sign:before{content:"\e169"}.fa-capsules:before{content:"\f46b"}.fa-poo-bolt:before,.fa-poo-storm:before{content:"\f75a"}.fa-face-frown-open:before,.fa-frown-open:before{content:"\f57a"}.fa-hand-point-up:before{content:"\f0a6"}.fa-money-bill:before{content:"\f0d6"}.fa-bookmark:before{content:"\f02e"}.fa-align-justify:before{content:"\f039"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-helmet-un:before{content:"\e503"}.fa-bullseye:before{content:"\f140"}.fa-bacon:before{content:"\f7e5"}.fa-hand-point-down:before{content:"\f0a7"}.fa-arrow-up-from-bracket:before{content:"\e09a"}.fa-folder-blank:before,.fa-folder:before{content:"\f07b"}.fa-file-medical-alt:before,.fa-file-waveform:before{content:"\f478"}.fa-radiation:before{content:"\f7b9"}.fa-chart-simple:before{content:"\e473"}.fa-mars-stroke:before{content:"\f229"}.fa-vial:before{content:"\f492"}.fa-dashboard:before,.fa-gauge-med:before,.fa-gauge:before,.fa-tachometer-alt-average:before{content:"\f624"}.fa-magic-wand-sparkles:before,.fa-wand-magic-sparkles:before{content:"\e2ca"}.fa-e:before{content:"\45"}.fa-pen-alt:before,.fa-pen-clip:before{content:"\f305"}.fa-bridge-circle-exclamation:before{content:"\e4ca"}.fa-user:before{content:"\f007"}.fa-school-circle-check:before{content:"\e56b"}.fa-dumpster:before{content:"\f793"}.fa-shuttle-van:before,.fa-van-shuttle:before{content:"\f5b6"}.fa-building-user:before{content:"\e4da"}.fa-caret-square-left:before,.fa-square-caret-left:before{content:"\f191"}.fa-highlighter:before{content:"\f591"}.fa-key:before{content:"\f084"}.fa-bullhorn:before{content:"\f0a1"}.fa-globe:before{content:"\f0ac"}.fa-synagogue:before{content:"\f69b"}.fa-person-half-dress:before{content:"\e548"}.fa-road-bridge:before{content:"\e563"}.fa-location-arrow:before{content:"\f124"}.fa-c:before{content:"\43"}.fa-tablet-button:before{content:"\f10a"}.fa-building-lock:before{content:"\e4d6"}.fa-pizza-slice:before{content:"\f818"}.fa-money-bill-wave:before{content:"\f53a"}.fa-area-chart:before,.fa-chart-area:before{content:"\f1fe"}.fa-house-flag:before{content:"\e50d"}.fa-person-circle-minus:before{content:"\e540"}.fa-ban:before,.fa-cancel:before{content:"\f05e"}.fa-camera-rotate:before{content:"\e0d8"}.fa-air-freshener:before,.fa-spray-can-sparkles:before{content:"\f5d0"}.fa-star:before{content:"\f005"}.fa-repeat:before{content:"\f363"}.fa-cross:before{content:"\f654"}.fa-box:before{content:"\f466"}.fa-venus-mars:before{content:"\f228"}.fa-arrow-pointer:before,.fa-mouse-pointer:before{content:"\f245"}.fa-expand-arrows-alt:before,.fa-maximize:before{content:"\f31e"}.fa-charging-station:before{content:"\f5e7"}.fa-shapes:before,.fa-triangle-circle-square:before{content:"\f61f"}.fa-random:before,.fa-shuffle:before{content:"\f074"}.fa-person-running:before,.fa-running:before{content:"\f70c"}.fa-mobile-retro:before{content:"\e527"}.fa-grip-lines-vertical:before{content:"\f7a5"}.fa-spider:before{content:"\f717"}.fa-hands-bound:before{content:"\e4f9"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-plane-circle-exclamation:before{content:"\e556"}.fa-x-ray:before{content:"\f497"}.fa-spell-check:before{content:"\f891"}.fa-slash:before{content:"\f715"}.fa-computer-mouse:before,.fa-mouse:before{content:"\f8cc"}.fa-arrow-right-to-bracket:before,.fa-sign-in:before{content:"\f090"}.fa-shop-slash:before,.fa-store-alt-slash:before{content:"\e070"}.fa-server:before{content:"\f233"}.fa-virus-covid-slash:before{content:"\e4a9"}.fa-shop-lock:before{content:"\e4a5"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-blender-phone:before{content:"\f6b6"}.fa-building-wheat:before{content:"\e4db"}.fa-person-breastfeeding:before{content:"\e53a"}.fa-right-to-bracket:before,.fa-sign-in-alt:before{content:"\f2f6"}.fa-venus:before{content:"\f221"}.fa-passport:before{content:"\f5ab"}.fa-heart-pulse:before,.fa-heartbeat:before{content:"\f21e"}.fa-people-carry-box:before,.fa-people-carry:before{content:"\f4ce"}.fa-temperature-high:before{content:"\f769"}.fa-microchip:before{content:"\f2db"}.fa-crown:before{content:"\f521"}.fa-weight-hanging:before{content:"\f5cd"}.fa-xmarks-lines:before{content:"\e59a"}.fa-file-prescription:before{content:"\f572"}.fa-weight-scale:before,.fa-weight:before{content:"\f496"}.fa-user-friends:before,.fa-user-group:before{content:"\f500"}.fa-arrow-up-a-z:before,.fa-sort-alpha-up:before{content:"\f15e"}.fa-chess-knight:before{content:"\f441"}.fa-face-laugh-squint:before,.fa-laugh-squint:before{content:"\f59b"}.fa-wheelchair:before{content:"\f193"}.fa-arrow-circle-up:before,.fa-circle-arrow-up:before{content:"\f0aa"}.fa-toggle-on:before{content:"\f205"}.fa-person-walking:before,.fa-walking:before{content:"\f554"}.fa-l:before{content:"\4c"}.fa-fire:before{content:"\f06d"}.fa-bed-pulse:before,.fa-procedures:before{content:"\f487"}.fa-shuttle-space:before,.fa-space-shuttle:before{content:"\f197"}.fa-face-laugh:before,.fa-laugh:before{content:"\f599"}.fa-folder-open:before{content:"\f07c"}.fa-heart-circle-plus:before{content:"\e500"}.fa-code-fork:before{content:"\e13b"}.fa-city:before{content:"\f64f"}.fa-microphone-alt:before,.fa-microphone-lines:before{content:"\f3c9"}.fa-pepper-hot:before{content:"\f816"}.fa-unlock:before{content:"\f09c"}.fa-colon-sign:before{content:"\e140"}.fa-headset:before{content:"\f590"}.fa-store-slash:before{content:"\e071"}.fa-road-circle-xmark:before{content:"\e566"}.fa-user-minus:before{content:"\f503"}.fa-mars-stroke-up:before,.fa-mars-stroke-v:before{content:"\f22a"}.fa-champagne-glasses:before,.fa-glass-cheers:before{content:"\f79f"}.fa-clipboard:before{content:"\f328"}.fa-house-circle-exclamation:before{content:"\e50a"}.fa-file-arrow-up:before,.fa-file-upload:before{content:"\f574"}.fa-wifi-3:before,.fa-wifi-strong:before,.fa-wifi:before{content:"\f1eb"}.fa-bath:before,.fa-bathtub:before{content:"\f2cd"}.fa-underline:before{content:"\f0cd"}.fa-user-edit:before,.fa-user-pen:before{content:"\f4ff"}.fa-signature:before{content:"\f5b7"}.fa-stroopwafel:before{content:"\f551"}.fa-bold:before{content:"\f032"}.fa-anchor-lock:before{content:"\e4ad"}.fa-building-ngo:before{content:"\e4d7"}.fa-manat-sign:before{content:"\e1d5"}.fa-not-equal:before{content:"\f53e"}.fa-border-style:before,.fa-border-top-left:before{content:"\f853"}.fa-map-location-dot:before,.fa-map-marked-alt:before{content:"\f5a0"}.fa-jedi:before{content:"\f669"}.fa-poll:before,.fa-square-poll-vertical:before{content:"\f681"}.fa-mug-hot:before{content:"\f7b6"}.fa-battery-car:before,.fa-car-battery:before{content:"\f5df"}.fa-gift:before{content:"\f06b"}.fa-dice-two:before{content:"\f528"}.fa-chess-queen:before{content:"\f445"}.fa-glasses:before{content:"\f530"}.fa-chess-board:before{content:"\f43c"}.fa-building-circle-check:before{content:"\e4d2"}.fa-person-chalkboard:before{content:"\e53d"}.fa-mars-stroke-h:before,.fa-mars-stroke-right:before{content:"\f22b"}.fa-hand-back-fist:before,.fa-hand-rock:before{content:"\f255"}.fa-caret-square-up:before,.fa-square-caret-up:before{content:"\f151"}.fa-cloud-showers-water:before{content:"\e4e4"}.fa-bar-chart:before,.fa-chart-bar:before{content:"\f080"}.fa-hands-bubbles:before,.fa-hands-wash:before{content:"\e05e"}.fa-less-than-equal:before{content:"\f537"}.fa-train:before{content:"\f238"}.fa-eye-low-vision:before,.fa-low-vision:before{content:"\f2a8"}.fa-crow:before{content:"\f520"}.fa-sailboat:before{content:"\e445"}.fa-window-restore:before{content:"\f2d2"}.fa-plus-square:before,.fa-square-plus:before{content:"\f0fe"}.fa-torii-gate:before{content:"\f6a1"}.fa-frog:before{content:"\f52e"}.fa-bucket:before{content:"\e4cf"}.fa-image:before{content:"\f03e"}.fa-microphone:before{content:"\f130"}.fa-cow:before{content:"\f6c8"}.fa-caret-up:before{content:"\f0d8"}.fa-screwdriver:before{content:"\f54a"}.fa-folder-closed:before{content:"\e185"}.fa-house-tsunami:before{content:"\e515"}.fa-square-nfi:before{content:"\e576"}.fa-arrow-up-from-ground-water:before{content:"\e4b5"}.fa-glass-martini-alt:before,.fa-martini-glass:before{content:"\f57b"}.fa-rotate-back:before,.fa-rotate-backward:before,.fa-rotate-left:before,.fa-undo-alt:before{content:"\f2ea"}.fa-columns:before,.fa-table-columns:before{content:"\f0db"}.fa-lemon:before{content:"\f094"}.fa-head-side-mask:before{content:"\e063"}.fa-handshake:before{content:"\f2b5"}.fa-gem:before{content:"\f3a5"}.fa-dolly-box:before,.fa-dolly:before{content:"\f472"}.fa-smoking:before{content:"\f48d"}.fa-compress-arrows-alt:before,.fa-minimize:before{content:"\f78c"}.fa-monument:before{content:"\f5a6"}.fa-snowplow:before{content:"\f7d2"}.fa-angle-double-right:before,.fa-angles-right:before{content:"\f101"}.fa-cannabis:before{content:"\f55f"}.fa-circle-play:before,.fa-play-circle:before{content:"\f144"}.fa-tablets:before{content:"\f490"}.fa-ethernet:before{content:"\f796"}.fa-eur:before,.fa-euro-sign:before,.fa-euro:before{content:"\f153"}.fa-chair:before{content:"\f6c0"}.fa-check-circle:before,.fa-circle-check:before{content:"\f058"}.fa-circle-stop:before,.fa-stop-circle:before{content:"\f28d"}.fa-compass-drafting:before,.fa-drafting-compass:before{content:"\f568"}.fa-plate-wheat:before{content:"\e55a"}.fa-icicles:before{content:"\f7ad"}.fa-person-shelter:before{content:"\e54f"}.fa-neuter:before{content:"\f22c"}.fa-id-badge:before{content:"\f2c1"}.fa-marker:before{content:"\f5a1"}.fa-face-laugh-beam:before,.fa-laugh-beam:before{content:"\f59a"}.fa-helicopter-symbol:before{content:"\e502"}.fa-universal-access:before{content:"\f29a"}.fa-chevron-circle-up:before,.fa-circle-chevron-up:before{content:"\f139"}.fa-lari-sign:before{content:"\e1c8"}.fa-volcano:before{content:"\f770"}.fa-person-walking-dashed-line-arrow-right:before{content:"\e553"}.fa-gbp:before,.fa-pound-sign:before,.fa-sterling-sign:before{content:"\f154"}.fa-viruses:before{content:"\e076"}.fa-square-person-confined:before{content:"\e577"}.fa-user-tie:before{content:"\f508"}.fa-arrow-down-long:before,.fa-long-arrow-down:before{content:"\f175"}.fa-tent-arrow-down-to-line:before{content:"\e57e"}.fa-certificate:before{content:"\f0a3"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-suitcase:before{content:"\f0f2"}.fa-person-skating:before,.fa-skating:before{content:"\f7c5"}.fa-filter-circle-dollar:before,.fa-funnel-dollar:before{content:"\f662"}.fa-camera-retro:before{content:"\f083"}.fa-arrow-circle-down:before,.fa-circle-arrow-down:before{content:"\f0ab"}.fa-arrow-right-to-file:before,.fa-file-import:before{content:"\f56f"}.fa-external-link-square:before,.fa-square-arrow-up-right:before{content:"\f14c"}.fa-box-open:before{content:"\f49e"}.fa-scroll:before{content:"\f70e"}.fa-spa:before{content:"\f5bb"}.fa-location-pin-lock:before{content:"\e51f"}.fa-pause:before{content:"\f04c"}.fa-hill-avalanche:before{content:"\e507"}.fa-temperature-0:before,.fa-temperature-empty:before,.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"}.fa-bomb:before{content:"\f1e2"}.fa-registered:before{content:"\f25d"}.fa-address-card:before,.fa-contact-card:before,.fa-vcard:before{content:"\f2bb"}.fa-balance-scale-right:before,.fa-scale-unbalanced-flip:before{content:"\f516"}.fa-subscript:before{content:"\f12c"}.fa-diamond-turn-right:before,.fa-directions:before{content:"\f5eb"}.fa-burst:before{content:"\e4dc"}.fa-house-laptop:before,.fa-laptop-house:before{content:"\e066"}.fa-face-tired:before,.fa-tired:before{content:"\f5c8"}.fa-money-bills:before{content:"\e1f3"}.fa-smog:before{content:"\f75f"}.fa-crutch:before{content:"\f7f7"}.fa-cloud-arrow-up:before,.fa-cloud-upload-alt:before,.fa-cloud-upload:before{content:"\f0ee"}.fa-palette:before{content:"\f53f"}.fa-arrows-turn-right:before{content:"\e4c0"}.fa-vest:before{content:"\e085"}.fa-ferry:before{content:"\e4ea"}.fa-arrows-down-to-people:before{content:"\e4b9"}.fa-seedling:before,.fa-sprout:before{content:"\f4d8"}.fa-arrows-alt-h:before,.fa-left-right:before{content:"\f337"}.fa-boxes-packing:before{content:"\e4c7"}.fa-arrow-circle-left:before,.fa-circle-arrow-left:before{content:"\f0a8"}.fa-group-arrows-rotate:before{content:"\e4f6"}.fa-bowl-food:before{content:"\e4c6"}.fa-candy-cane:before{content:"\f786"}.fa-arrow-down-wide-short:before,.fa-sort-amount-asc:before,.fa-sort-amount-down:before{content:"\f160"}.fa-cloud-bolt:before,.fa-thunderstorm:before{content:"\f76c"}.fa-remove-format:before,.fa-text-slash:before{content:"\f87d"}.fa-face-smile-wink:before,.fa-smile-wink:before{content:"\f4da"}.fa-file-word:before{content:"\f1c2"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-arrows-h:before,.fa-arrows-left-right:before{content:"\f07e"}.fa-house-lock:before{content:"\e510"}.fa-cloud-arrow-down:before,.fa-cloud-download-alt:before,.fa-cloud-download:before{content:"\f0ed"}.fa-children:before{content:"\e4e1"}.fa-blackboard:before,.fa-chalkboard:before{content:"\f51b"}.fa-user-alt-slash:before,.fa-user-large-slash:before{content:"\f4fa"}.fa-envelope-open:before{content:"\f2b6"}.fa-handshake-alt-slash:before,.fa-handshake-simple-slash:before{content:"\e05f"}.fa-mattress-pillow:before{content:"\e525"}.fa-guarani-sign:before{content:"\e19a"}.fa-arrows-rotate:before,.fa-refresh:before,.fa-sync:before{content:"\f021"}.fa-fire-extinguisher:before{content:"\f134"}.fa-cruzeiro-sign:before{content:"\e152"}.fa-greater-than-equal:before{content:"\f532"}.fa-shield-alt:before,.fa-shield-halved:before{content:"\f3ed"}.fa-atlas:before,.fa-book-atlas:before{content:"\f558"}.fa-virus:before{content:"\e074"}.fa-envelope-circle-check:before{content:"\e4e8"}.fa-layer-group:before{content:"\f5fd"}.fa-arrows-to-dot:before{content:"\e4be"}.fa-archway:before{content:"\f557"}.fa-heart-circle-check:before{content:"\e4fd"}.fa-house-chimney-crack:before,.fa-house-damage:before{content:"\f6f1"}.fa-file-archive:before,.fa-file-zipper:before{content:"\f1c6"}.fa-square:before{content:"\f0c8"}.fa-glass-martini:before,.fa-martini-glass-empty:before{content:"\f000"}.fa-couch:before{content:"\f4b8"}.fa-cedi-sign:before{content:"\e0df"}.fa-italic:before{content:"\f033"}.fa-church:before{content:"\f51d"}.fa-comments-dollar:before{content:"\f653"}.fa-democrat:before{content:"\f747"}.fa-z:before{content:"\5a"}.fa-person-skiing:before,.fa-skiing:before{content:"\f7c9"}.fa-road-lock:before{content:"\e567"}.fa-a:before{content:"\41"}.fa-temperature-arrow-down:before,.fa-temperature-down:before{content:"\e03f"}.fa-feather-alt:before,.fa-feather-pointed:before{content:"\f56b"}.fa-p:before{content:"\50"}.fa-snowflake:before{content:"\f2dc"}.fa-newspaper:before{content:"\f1ea"}.fa-ad:before,.fa-rectangle-ad:before{content:"\f641"}.fa-arrow-circle-right:before,.fa-circle-arrow-right:before{content:"\f0a9"}.fa-filter-circle-xmark:before{content:"\e17b"}.fa-locust:before{content:"\e520"}.fa-sort:before,.fa-unsorted:before{content:"\f0dc"}.fa-list-1-2:before,.fa-list-numeric:before,.fa-list-ol:before{content:"\f0cb"}.fa-person-dress-burst:before{content:"\e544"}.fa-money-check-alt:before,.fa-money-check-dollar:before{content:"\f53d"}.fa-vector-square:before{content:"\f5cb"}.fa-bread-slice:before{content:"\f7ec"}.fa-language:before{content:"\f1ab"}.fa-face-kiss-wink-heart:before,.fa-kiss-wink-heart:before{content:"\f598"}.fa-filter:before{content:"\f0b0"}.fa-question:before{content:"\3f"}.fa-file-signature:before{content:"\f573"}.fa-arrows-alt:before,.fa-up-down-left-right:before{content:"\f0b2"}.fa-house-chimney-user:before{content:"\e065"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-puzzle-piece:before{content:"\f12e"}.fa-money-check:before{content:"\f53c"}.fa-star-half-alt:before,.fa-star-half-stroke:before{content:"\f5c0"}.fa-code:before{content:"\f121"}.fa-glass-whiskey:before,.fa-whiskey-glass:before{content:"\f7a0"}.fa-building-circle-exclamation:before{content:"\e4d3"}.fa-magnifying-glass-chart:before{content:"\e522"}.fa-arrow-up-right-from-square:before,.fa-external-link:before{content:"\f08e"}.fa-cubes-stacked:before{content:"\e4e6"}.fa-krw:before,.fa-won-sign:before,.fa-won:before{content:"\f159"}.fa-virus-covid:before{content:"\e4a8"}.fa-austral-sign:before{content:"\e0a9"}.fa-f:before{content:"\46"}.fa-leaf:before{content:"\f06c"}.fa-road:before{content:"\f018"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-person-circle-plus:before{content:"\e541"}.fa-chart-pie:before,.fa-pie-chart:before{content:"\f200"}.fa-bolt-lightning:before{content:"\e0b7"}.fa-sack-xmark:before{content:"\e56a"}.fa-file-excel:before{content:"\f1c3"}.fa-file-contract:before{content:"\f56c"}.fa-fish-fins:before{content:"\e4f2"}.fa-building-flag:before{content:"\e4d5"}.fa-face-grin-beam:before,.fa-grin-beam:before{content:"\f582"}.fa-object-ungroup:before{content:"\f248"}.fa-poop:before{content:"\f619"}.fa-location-pin:before,.fa-map-marker:before{content:"\f041"}.fa-kaaba:before{content:"\f66b"}.fa-toilet-paper:before{content:"\f71e"}.fa-hard-hat:before,.fa-hat-hard:before,.fa-helmet-safety:before{content:"\f807"}.fa-eject:before{content:"\f052"}.fa-arrow-alt-circle-right:before,.fa-circle-right:before{content:"\f35a"}.fa-plane-circle-check:before{content:"\e555"}.fa-face-rolling-eyes:before,.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-object-group:before{content:"\f247"}.fa-chart-line:before,.fa-line-chart:before{content:"\f201"}.fa-mask-ventilator:before{content:"\e524"}.fa-arrow-right:before{content:"\f061"}.fa-map-signs:before,.fa-signs-post:before{content:"\f277"}.fa-cash-register:before{content:"\f788"}.fa-person-circle-question:before{content:"\e542"}.fa-h:before{content:"\48"}.fa-tarp:before{content:"\e57b"}.fa-screwdriver-wrench:before,.fa-tools:before{content:"\f7d9"}.fa-arrows-to-eye:before{content:"\e4bf"}.fa-plug-circle-bolt:before{content:"\e55b"}.fa-heart:before{content:"\f004"}.fa-mars-and-venus:before{content:"\f224"}.fa-home-user:before,.fa-house-user:before{content:"\e1b0"}.fa-dumpster-fire:before{content:"\f794"}.fa-house-crack:before{content:"\e3b1"}.fa-cocktail:before,.fa-martini-glass-citrus:before{content:"\f561"}.fa-face-surprise:before,.fa-surprise:before{content:"\f5c2"}.fa-bottle-water:before{content:"\e4c5"}.fa-circle-pause:before,.fa-pause-circle:before{content:"\f28b"}.fa-toilet-paper-slash:before{content:"\e072"}.fa-apple-alt:before,.fa-apple-whole:before{content:"\f5d1"}.fa-kitchen-set:before{content:"\e51a"}.fa-r:before{content:"\52"}.fa-temperature-1:before,.fa-temperature-quarter:before,.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"}.fa-cube:before{content:"\f1b2"}.fa-bitcoin-sign:before{content:"\e0b4"}.fa-shield-dog:before{content:"\e573"}.fa-solar-panel:before{content:"\f5ba"}.fa-lock-open:before{content:"\f3c1"}.fa-elevator:before{content:"\e16d"}.fa-money-bill-transfer:before{content:"\e528"}.fa-money-bill-trend-up:before{content:"\e529"}.fa-house-flood-water-circle-arrow-right:before{content:"\e50f"}.fa-poll-h:before,.fa-square-poll-horizontal:before{content:"\f682"}.fa-circle:before{content:"\f111"}.fa-backward-fast:before,.fa-fast-backward:before{content:"\f049"}.fa-recycle:before{content:"\f1b8"}.fa-user-astronaut:before{content:"\f4fb"}.fa-plane-slash:before{content:"\e069"}.fa-trademark:before{content:"\f25c"}.fa-basketball-ball:before,.fa-basketball:before{content:"\f434"}.fa-satellite-dish:before{content:"\f7c0"}.fa-arrow-alt-circle-up:before,.fa-circle-up:before{content:"\f35b"}.fa-mobile-alt:before,.fa-mobile-screen-button:before{content:"\f3cd"}.fa-volume-high:before,.fa-volume-up:before{content:"\f028"}.fa-users-rays:before{content:"\e593"}.fa-wallet:before{content:"\f555"}.fa-clipboard-check:before{content:"\f46c"}.fa-file-audio:before{content:"\f1c7"}.fa-burger:before,.fa-hamburger:before{content:"\f805"}.fa-wrench:before{content:"\f0ad"}.fa-bugs:before{content:"\e4d0"}.fa-rupee-sign:before,.fa-rupee:before{content:"\f156"}.fa-file-image:before{content:"\f1c5"}.fa-circle-question:before,.fa-question-circle:before{content:"\f059"}.fa-plane-departure:before{content:"\f5b0"}.fa-handshake-slash:before{content:"\e060"}.fa-book-bookmark:before{content:"\e0bb"}.fa-code-branch:before{content:"\f126"}.fa-hat-cowboy:before{content:"\f8c0"}.fa-bridge:before{content:"\e4c8"}.fa-phone-alt:before,.fa-phone-flip:before{content:"\f879"}.fa-truck-front:before{content:"\e2b7"}.fa-cat:before{content:"\f6be"}.fa-anchor-circle-exclamation:before{content:"\e4ab"}.fa-truck-field:before{content:"\e58d"}.fa-route:before{content:"\f4d7"}.fa-clipboard-question:before{content:"\e4e3"}.fa-panorama:before{content:"\e209"}.fa-comment-medical:before{content:"\f7f5"}.fa-teeth-open:before{content:"\f62f"}.fa-file-circle-minus:before{content:"\e4ed"}.fa-tags:before{content:"\f02c"}.fa-wine-glass:before{content:"\f4e3"}.fa-fast-forward:before,.fa-forward-fast:before{content:"\f050"}.fa-face-meh-blank:before,.fa-meh-blank:before{content:"\f5a4"}.fa-parking:before,.fa-square-parking:before{content:"\f540"}.fa-house-signal:before{content:"\e012"}.fa-bars-progress:before,.fa-tasks-alt:before{content:"\f828"}.fa-faucet-drip:before{content:"\e006"}.fa-cart-flatbed:before,.fa-dolly-flatbed:before{content:"\f474"}.fa-ban-smoking:before,.fa-smoking-ban:before{content:"\f54d"}.fa-terminal:before{content:"\f120"}.fa-mobile-button:before{content:"\f10b"}.fa-house-medical-flag:before{content:"\e514"}.fa-basket-shopping:before,.fa-shopping-basket:before{content:"\f291"}.fa-tape:before{content:"\f4db"}.fa-bus-alt:before,.fa-bus-simple:before{content:"\f55e"}.fa-eye:before{content:"\f06e"}.fa-face-sad-cry:before,.fa-sad-cry:before{content:"\f5b3"}.fa-audio-description:before{content:"\f29e"}.fa-person-military-to-person:before{content:"\e54c"}.fa-file-shield:before{content:"\e4f0"}.fa-user-slash:before{content:"\f506"}.fa-pen:before{content:"\f304"}.fa-tower-observation:before{content:"\e586"}.fa-file-code:before{content:"\f1c9"}.fa-signal-5:before,.fa-signal-perfect:before,.fa-signal:before{content:"\f012"}.fa-bus:before{content:"\f207"}.fa-heart-circle-xmark:before{content:"\e501"}.fa-home-lg:before,.fa-house-chimney:before{content:"\e3af"}.fa-window-maximize:before{content:"\f2d0"}.fa-face-frown:before,.fa-frown:before{content:"\f119"}.fa-prescription:before{content:"\f5b1"}.fa-shop:before,.fa-store-alt:before{content:"\f54f"}.fa-floppy-disk:before,.fa-save:before{content:"\f0c7"}.fa-vihara:before{content:"\f6a7"}.fa-balance-scale-left:before,.fa-scale-unbalanced:before{content:"\f515"}.fa-sort-asc:before,.fa-sort-up:before{content:"\f0de"}.fa-comment-dots:before,.fa-commenting:before{content:"\f4ad"}.fa-plant-wilt:before{content:"\e5aa"}.fa-diamond:before{content:"\f219"}.fa-face-grin-squint:before,.fa-grin-squint:before{content:"\f585"}.fa-hand-holding-dollar:before,.fa-hand-holding-usd:before{content:"\f4c0"}.fa-bacterium:before{content:"\e05a"}.fa-hand-pointer:before{content:"\f25a"}.fa-drum-steelpan:before{content:"\f56a"}.fa-hand-scissors:before{content:"\f257"}.fa-hands-praying:before,.fa-praying-hands:before{content:"\f684"}.fa-arrow-right-rotate:before,.fa-arrow-rotate-forward:before,.fa-arrow-rotate-right:before,.fa-redo:before{content:"\f01e"}.fa-biohazard:before{content:"\f780"}.fa-location-crosshairs:before,.fa-location:before{content:"\f601"}.fa-mars-double:before{content:"\f227"}.fa-child-dress:before{content:"\e59c"}.fa-users-between-lines:before{content:"\e591"}.fa-lungs-virus:before{content:"\e067"}.fa-face-grin-tears:before,.fa-grin-tears:before{content:"\f588"}.fa-phone:before{content:"\f095"}.fa-calendar-times:before,.fa-calendar-xmark:before{content:"\f273"}.fa-child-reaching:before{content:"\e59d"}.fa-head-side-virus:before{content:"\e064"}.fa-user-cog:before,.fa-user-gear:before{content:"\f4fe"}.fa-arrow-up-1-9:before,.fa-sort-numeric-up:before{content:"\f163"}.fa-door-closed:before{content:"\f52a"}.fa-shield-virus:before{content:"\e06c"}.fa-dice-six:before{content:"\f526"}.fa-mosquito-net:before{content:"\e52c"}.fa-bridge-water:before{content:"\e4ce"}.fa-person-booth:before{content:"\f756"}.fa-text-width:before{content:"\f035"}.fa-hat-wizard:before{content:"\f6e8"}.fa-pen-fancy:before{content:"\f5ac"}.fa-digging:before,.fa-person-digging:before{content:"\f85e"}.fa-trash:before{content:"\f1f8"}.fa-gauge-simple-med:before,.fa-gauge-simple:before,.fa-tachometer-average:before{content:"\f629"}.fa-book-medical:before{content:"\f7e6"}.fa-poo:before{content:"\f2fe"}.fa-quote-right-alt:before,.fa-quote-right:before{content:"\f10e"}.fa-shirt:before,.fa-t-shirt:before,.fa-tshirt:before{content:"\f553"}.fa-cubes:before{content:"\f1b3"}.fa-divide:before{content:"\f529"}.fa-tenge-sign:before,.fa-tenge:before{content:"\f7d7"}.fa-headphones:before{content:"\f025"}.fa-hands-holding:before{content:"\f4c2"}.fa-hands-clapping:before{content:"\e1a8"}.fa-republican:before{content:"\f75e"}.fa-arrow-left:before{content:"\f060"}.fa-person-circle-xmark:before{content:"\e543"}.fa-ruler:before{content:"\f545"}.fa-align-left:before{content:"\f036"}.fa-dice-d6:before{content:"\f6d1"}.fa-restroom:before{content:"\f7bd"}.fa-j:before{content:"\4a"}.fa-users-viewfinder:before{content:"\e595"}.fa-file-video:before{content:"\f1c8"}.fa-external-link-alt:before,.fa-up-right-from-square:before{content:"\f35d"}.fa-table-cells:before,.fa-th:before{content:"\f00a"}.fa-file-pdf:before{content:"\f1c1"}.fa-bible:before,.fa-book-bible:before{content:"\f647"}.fa-o:before{content:"\4f"}.fa-medkit:before,.fa-suitcase-medical:before{content:"\f0fa"}.fa-user-secret:before{content:"\f21b"}.fa-otter:before{content:"\f700"}.fa-female:before,.fa-person-dress:before{content:"\f182"}.fa-comment-dollar:before{content:"\f651"}.fa-briefcase-clock:before,.fa-business-time:before{content:"\f64a"}.fa-table-cells-large:before,.fa-th-large:before{content:"\f009"}.fa-book-tanakh:before,.fa-tanakh:before{content:"\f827"}.fa-phone-volume:before,.fa-volume-control-phone:before{content:"\f2a0"}.fa-hat-cowboy-side:before{content:"\f8c1"}.fa-clipboard-user:before{content:"\f7f3"}.fa-child:before{content:"\f1ae"}.fa-lira-sign:before{content:"\f195"}.fa-satellite:before{content:"\f7bf"}.fa-plane-lock:before{content:"\e558"}.fa-tag:before{content:"\f02b"}.fa-comment:before{content:"\f075"}.fa-birthday-cake:before,.fa-cake-candles:before,.fa-cake:before{content:"\f1fd"}.fa-envelope:before{content:"\f0e0"}.fa-angle-double-up:before,.fa-angles-up:before{content:"\f102"}.fa-paperclip:before{content:"\f0c6"}.fa-arrow-right-to-city:before{content:"\e4b3"}.fa-ribbon:before{content:"\f4d6"}.fa-lungs:before{content:"\f604"}.fa-arrow-up-9-1:before,.fa-sort-numeric-up-alt:before{content:"\f887"}.fa-litecoin-sign:before{content:"\e1d3"}.fa-border-none:before{content:"\f850"}.fa-circle-nodes:before{content:"\e4e2"}.fa-parachute-box:before{content:"\f4cd"}.fa-indent:before{content:"\f03c"}.fa-truck-field-un:before{content:"\e58e"}.fa-hourglass-empty:before,.fa-hourglass:before{content:"\f254"}.fa-mountain:before{content:"\f6fc"}.fa-user-doctor:before,.fa-user-md:before{content:"\f0f0"}.fa-circle-info:before,.fa-info-circle:before{content:"\f05a"}.fa-cloud-meatball:before{content:"\f73b"}.fa-camera-alt:before,.fa-camera:before{content:"\f030"}.fa-square-virus:before{content:"\e578"}.fa-meteor:before{content:"\f753"}.fa-car-on:before{content:"\e4dd"}.fa-sleigh:before{content:"\f7cc"}.fa-arrow-down-1-9:before,.fa-sort-numeric-asc:before,.fa-sort-numeric-down:before{content:"\f162"}.fa-hand-holding-droplet:before,.fa-hand-holding-water:before{content:"\f4c1"}.fa-water:before{content:"\f773"}.fa-calendar-check:before{content:"\f274"}.fa-braille:before{content:"\f2a1"}.fa-prescription-bottle-alt:before,.fa-prescription-bottle-medical:before{content:"\f486"}.fa-landmark:before{content:"\f66f"}.fa-truck:before{content:"\f0d1"}.fa-crosshairs:before{content:"\f05b"}.fa-person-cane:before{content:"\e53c"}.fa-tent:before{content:"\e57d"}.fa-vest-patches:before{content:"\e086"}.fa-check-double:before{content:"\f560"}.fa-arrow-down-a-z:before,.fa-sort-alpha-asc:before,.fa-sort-alpha-down:before{content:"\f15d"}.fa-money-bill-wheat:before{content:"\e52a"}.fa-cookie:before{content:"\f563"}.fa-arrow-left-rotate:before,.fa-arrow-rotate-back:before,.fa-arrow-rotate-backward:before,.fa-arrow-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-hard-drive:before,.fa-hdd:before{content:"\f0a0"}.fa-face-grin-squint-tears:before,.fa-grin-squint-tears:before{content:"\f586"}.fa-dumbbell:before{content:"\f44b"}.fa-list-alt:before,.fa-rectangle-list:before{content:"\f022"}.fa-tarp-droplet:before{content:"\e57c"}.fa-house-medical-circle-check:before{content:"\e511"}.fa-person-skiing-nordic:before,.fa-skiing-nordic:before{content:"\f7ca"}.fa-calendar-plus:before{content:"\f271"}.fa-plane-arrival:before{content:"\f5af"}.fa-arrow-alt-circle-left:before,.fa-circle-left:before{content:"\f359"}.fa-subway:before,.fa-train-subway:before{content:"\f239"}.fa-chart-gantt:before{content:"\e0e4"}.fa-indian-rupee-sign:before,.fa-indian-rupee:before,.fa-inr:before{content:"\e1bc"}.fa-crop-alt:before,.fa-crop-simple:before{content:"\f565"}.fa-money-bill-1:before,.fa-money-bill-alt:before{content:"\f3d1"}.fa-left-long:before,.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-dna:before{content:"\f471"}.fa-virus-slash:before{content:"\e075"}.fa-minus:before,.fa-subtract:before{content:"\f068"}.fa-chess:before{content:"\f439"}.fa-arrow-left-long:before,.fa-long-arrow-left:before{content:"\f177"}.fa-plug-circle-check:before{content:"\e55c"}.fa-street-view:before{content:"\f21d"}.fa-franc-sign:before{content:"\e18f"}.fa-volume-off:before{content:"\f026"}.fa-american-sign-language-interpreting:before,.fa-asl-interpreting:before,.fa-hands-american-sign-language-interpreting:before,.fa-hands-asl-interpreting:before{content:"\f2a3"}.fa-cog:before,.fa-gear:before{content:"\f013"}.fa-droplet-slash:before,.fa-tint-slash:before{content:"\f5c7"}.fa-mosque:before{content:"\f678"}.fa-mosquito:before{content:"\e52b"}.fa-star-of-david:before{content:"\f69a"}.fa-person-military-rifle:before{content:"\e54b"}.fa-cart-shopping:before,.fa-shopping-cart:before{content:"\f07a"}.fa-vials:before{content:"\f493"}.fa-plug-circle-plus:before{content:"\e55f"}.fa-place-of-worship:before{content:"\f67f"}.fa-grip-vertical:before{content:"\f58e"}.fa-arrow-turn-up:before,.fa-level-up:before{content:"\f148"}.fa-u:before{content:"\55"}.fa-square-root-alt:before,.fa-square-root-variable:before{content:"\f698"}.fa-clock-four:before,.fa-clock:before{content:"\f017"}.fa-backward-step:before,.fa-step-backward:before{content:"\f048"}.fa-pallet:before{content:"\f482"}.fa-faucet:before{content:"\e005"}.fa-baseball-bat-ball:before{content:"\f432"}.fa-s:before{content:"\53"}.fa-timeline:before{content:"\e29c"}.fa-keyboard:before{content:"\f11c"}.fa-caret-down:before{content:"\f0d7"}.fa-clinic-medical:before,.fa-house-chimney-medical:before{content:"\f7f2"}.fa-temperature-3:before,.fa-temperature-three-quarters:before,.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-mobile-android-alt:before,.fa-mobile-screen:before{content:"\f3cf"}.fa-plane-up:before{content:"\e22d"}.fa-piggy-bank:before{content:"\f4d3"}.fa-battery-3:before,.fa-battery-half:before{content:"\f242"}.fa-mountain-city:before{content:"\e52e"}.fa-coins:before{content:"\f51e"}.fa-khanda:before{content:"\f66d"}.fa-sliders-h:before,.fa-sliders:before{content:"\f1de"}.fa-folder-tree:before{content:"\f802"}.fa-network-wired:before{content:"\f6ff"}.fa-map-pin:before{content:"\f276"}.fa-hamsa:before{content:"\f665"}.fa-cent-sign:before{content:"\e3f5"}.fa-flask:before{content:"\f0c3"}.fa-person-pregnant:before{content:"\e31e"}.fa-wand-sparkles:before{content:"\f72b"}.fa-ellipsis-v:before,.fa-ellipsis-vertical:before{content:"\f142"}.fa-ticket:before{content:"\f145"}.fa-power-off:before{content:"\f011"}.fa-long-arrow-alt-right:before,.fa-right-long:before{content:"\f30b"}.fa-flag-usa:before{content:"\f74d"}.fa-laptop-file:before{content:"\e51d"}.fa-teletype:before,.fa-tty:before{content:"\f1e4"}.fa-diagram-next:before{content:"\e476"}.fa-person-rifle:before{content:"\e54e"}.fa-house-medical-circle-exclamation:before{content:"\e512"}.fa-closed-captioning:before{content:"\f20a"}.fa-hiking:before,.fa-person-hiking:before{content:"\f6ec"}.fa-venus-double:before{content:"\f226"}.fa-images:before{content:"\f302"}.fa-calculator:before{content:"\f1ec"}.fa-people-pulling:before{content:"\e535"}.fa-n:before{content:"\4e"}.fa-cable-car:before,.fa-tram:before{content:"\f7da"}.fa-cloud-rain:before{content:"\f73d"}.fa-building-circle-xmark:before{content:"\e4d4"}.fa-ship:before{content:"\f21a"}.fa-arrows-down-to-line:before{content:"\e4b8"}.fa-download:before{content:"\f019"}.fa-face-grin:before,.fa-grin:before{content:"\f580"}.fa-backspace:before,.fa-delete-left:before{content:"\f55a"}.fa-eye-dropper-empty:before,.fa-eye-dropper:before,.fa-eyedropper:before{content:"\f1fb"}.fa-file-circle-check:before{content:"\e5a0"}.fa-forward:before{content:"\f04e"}.fa-mobile-android:before,.fa-mobile-phone:before,.fa-mobile:before{content:"\f3ce"}.fa-face-meh:before,.fa-meh:before{content:"\f11a"}.fa-align-center:before{content:"\f037"}.fa-book-dead:before,.fa-book-skull:before{content:"\f6b7"}.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-heart-circle-exclamation:before{content:"\e4fe"}.fa-home-alt:before,.fa-home-lg-alt:before,.fa-home:before,.fa-house:before{content:"\f015"}.fa-calendar-week:before{content:"\f784"}.fa-laptop-medical:before{content:"\f812"}.fa-b:before{content:"\42"}.fa-file-medical:before{content:"\f477"}.fa-dice-one:before{content:"\f525"}.fa-kiwi-bird:before{content:"\f535"}.fa-arrow-right-arrow-left:before,.fa-exchange:before{content:"\f0ec"}.fa-redo-alt:before,.fa-rotate-forward:before,.fa-rotate-right:before{content:"\f2f9"}.fa-cutlery:before,.fa-utensils:before{content:"\f2e7"}.fa-arrow-up-wide-short:before,.fa-sort-amount-up:before{content:"\f161"}.fa-mill-sign:before{content:"\e1ed"}.fa-bowl-rice:before{content:"\e2eb"}.fa-skull:before{content:"\f54c"}.fa-broadcast-tower:before,.fa-tower-broadcast:before{content:"\f519"}.fa-truck-pickup:before{content:"\f63c"}.fa-long-arrow-alt-up:before,.fa-up-long:before{content:"\f30c"}.fa-stop:before{content:"\f04d"}.fa-code-merge:before{content:"\f387"}.fa-upload:before{content:"\f093"}.fa-hurricane:before{content:"\f751"}.fa-mound:before{content:"\e52d"}.fa-toilet-portable:before{content:"\e583"}.fa-compact-disc:before{content:"\f51f"}.fa-file-arrow-down:before,.fa-file-download:before{content:"\f56d"}.fa-caravan:before{content:"\f8ff"}.fa-shield-cat:before{content:"\e572"}.fa-bolt:before,.fa-zap:before{content:"\f0e7"}.fa-glass-water:before{content:"\e4f4"}.fa-oil-well:before{content:"\e532"}.fa-vault:before{content:"\e2c5"}.fa-mars:before{content:"\f222"}.fa-toilet:before{content:"\f7d8"}.fa-plane-circle-xmark:before{content:"\e557"}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen-sign:before,.fa-yen:before{content:"\f157"}.fa-rouble:before,.fa-rub:before,.fa-ruble-sign:before,.fa-ruble:before{content:"\f158"}.fa-sun:before{content:"\f185"}.fa-guitar:before{content:"\f7a6"}.fa-face-laugh-wink:before,.fa-laugh-wink:before{content:"\f59c"}.fa-horse-head:before{content:"\f7ab"}.fa-bore-hole:before{content:"\e4c3"}.fa-industry:before{content:"\f275"}.fa-arrow-alt-circle-down:before,.fa-circle-down:before{content:"\f358"}.fa-arrows-turn-to-dots:before{content:"\e4c1"}.fa-florin-sign:before{content:"\e184"}.fa-arrow-down-short-wide:before,.fa-sort-amount-desc:before,.fa-sort-amount-down-alt:before{content:"\f884"}.fa-less-than:before{content:"\3c"}.fa-angle-down:before{content:"\f107"}.fa-car-tunnel:before{content:"\e4de"}.fa-head-side-cough:before{content:"\e061"}.fa-grip-lines:before{content:"\f7a4"}.fa-thumbs-down:before{content:"\f165"}.fa-user-lock:before{content:"\f502"}.fa-arrow-right-long:before,.fa-long-arrow-right:before{content:"\f178"}.fa-anchor-circle-xmark:before{content:"\e4ac"}.fa-ellipsis-h:before,.fa-ellipsis:before{content:"\f141"}.fa-chess-pawn:before{content:"\f443"}.fa-first-aid:before,.fa-kit-medical:before{content:"\f479"}.fa-person-through-window:before{content:"\e5a9"}.fa-toolbox:before{content:"\f552"}.fa-hands-holding-circle:before{content:"\e4fb"}.fa-bug:before{content:"\f188"}.fa-credit-card-alt:before,.fa-credit-card:before{content:"\f09d"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-hand-holding-hand:before{content:"\e4f7"}.fa-book-open-reader:before,.fa-book-reader:before{content:"\f5da"}.fa-mountain-sun:before{content:"\e52f"}.fa-arrows-left-right-to-line:before{content:"\e4ba"}.fa-dice-d20:before{content:"\f6cf"}.fa-truck-droplet:before{content:"\e58c"}.fa-file-circle-xmark:before{content:"\e5a1"}.fa-temperature-arrow-up:before,.fa-temperature-up:before{content:"\e040"}.fa-medal:before{content:"\f5a2"}.fa-bed:before{content:"\f236"}.fa-h-square:before,.fa-square-h:before{content:"\f0fd"}.fa-podcast:before{content:"\f2ce"}.fa-temperature-4:before,.fa-temperature-full:before,.fa-thermometer-4:before,.fa-thermometer-full:before{content:"\f2c7"}.fa-bell:before{content:"\f0f3"}.fa-superscript:before{content:"\f12b"}.fa-plug-circle-xmark:before{content:"\e560"}.fa-star-of-life:before{content:"\f621"}.fa-phone-slash:before{content:"\f3dd"}.fa-paint-roller:before{content:"\f5aa"}.fa-hands-helping:before,.fa-handshake-angle:before{content:"\f4c4"}.fa-location-dot:before,.fa-map-marker-alt:before{content:"\f3c5"}.fa-file:before{content:"\f15b"}.fa-greater-than:before{content:"\3e"}.fa-person-swimming:before,.fa-swimmer:before{content:"\f5c4"}.fa-arrow-down:before{content:"\f063"}.fa-droplet:before,.fa-tint:before{content:"\f043"}.fa-eraser:before{content:"\f12d"}.fa-earth-america:before,.fa-earth-americas:before,.fa-earth:before,.fa-globe-americas:before{content:"\f57d"}.fa-person-burst:before{content:"\e53b"}.fa-dove:before{content:"\f4ba"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-socks:before{content:"\f696"}.fa-inbox:before{content:"\f01c"}.fa-section:before{content:"\e447"}.fa-gauge-high:before,.fa-tachometer-alt-fast:before,.fa-tachometer-alt:before{content:"\f625"}.fa-envelope-open-text:before{content:"\f658"}.fa-hospital-alt:before,.fa-hospital-wide:before,.fa-hospital:before{content:"\f0f8"}.fa-wine-bottle:before{content:"\f72f"}.fa-chess-rook:before{content:"\f447"}.fa-bars-staggered:before,.fa-reorder:before,.fa-stream:before{content:"\f550"}.fa-dharmachakra:before{content:"\f655"}.fa-hotdog:before{content:"\f80f"}.fa-blind:before,.fa-person-walking-with-cane:before{content:"\f29d"}.fa-drum:before{content:"\f569"}.fa-ice-cream:before{content:"\f810"}.fa-heart-circle-bolt:before{content:"\e4fc"}.fa-fax:before{content:"\f1ac"}.fa-paragraph:before{content:"\f1dd"}.fa-check-to-slot:before,.fa-vote-yea:before{content:"\f772"}.fa-star-half:before{content:"\f089"}.fa-boxes-alt:before,.fa-boxes-stacked:before,.fa-boxes:before{content:"\f468"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-assistive-listening-systems:before,.fa-ear-listen:before{content:"\f2a2"}.fa-tree-city:before{content:"\e587"}.fa-play:before{content:"\f04b"}.fa-font:before{content:"\f031"}.fa-rupiah-sign:before{content:"\e23d"}.fa-magnifying-glass:before,.fa-search:before{content:"\f002"}.fa-ping-pong-paddle-ball:before,.fa-table-tennis-paddle-ball:before,.fa-table-tennis:before{content:"\f45d"}.fa-diagnoses:before,.fa-person-dots-from-line:before{content:"\f470"}.fa-trash-can-arrow-up:before,.fa-trash-restore-alt:before{content:"\f82a"}.fa-naira-sign:before{content:"\e1f6"}.fa-cart-arrow-down:before{content:"\f218"}.fa-walkie-talkie:before{content:"\f8ef"}.fa-file-edit:before,.fa-file-pen:before{content:"\f31c"}.fa-receipt:before{content:"\f543"}.fa-pen-square:before,.fa-pencil-square:before,.fa-square-pen:before{content:"\f14b"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-person-circle-exclamation:before{content:"\e53f"}.fa-chevron-down:before{content:"\f078"}.fa-battery-5:before,.fa-battery-full:before,.fa-battery:before{content:"\f240"}.fa-skull-crossbones:before{content:"\f714"}.fa-code-compare:before{content:"\e13a"}.fa-list-dots:before,.fa-list-ul:before{content:"\f0ca"}.fa-school-lock:before{content:"\e56f"}.fa-tower-cell:before{content:"\e585"}.fa-down-long:before,.fa-long-arrow-alt-down:before{content:"\f309"}.fa-ranking-star:before{content:"\e561"}.fa-chess-king:before{content:"\f43f"}.fa-person-harassing:before{content:"\e549"}.fa-brazilian-real-sign:before{content:"\e46c"}.fa-landmark-alt:before,.fa-landmark-dome:before{content:"\f752"}.fa-arrow-up:before{content:"\f062"}.fa-television:before,.fa-tv-alt:before,.fa-tv:before{content:"\f26c"}.fa-shrimp:before{content:"\e448"}.fa-list-check:before,.fa-tasks:before{content:"\f0ae"}.fa-jug-detergent:before{content:"\e519"}.fa-circle-user:before,.fa-user-circle:before{content:"\f2bd"}.fa-user-shield:before{content:"\f505"}.fa-wind:before{content:"\f72e"}.fa-car-burst:before,.fa-car-crash:before{content:"\f5e1"}.fa-y:before{content:"\59"}.fa-person-snowboarding:before,.fa-snowboarding:before{content:"\f7ce"}.fa-shipping-fast:before,.fa-truck-fast:before{content:"\f48b"}.fa-fish:before{content:"\f578"}.fa-user-graduate:before{content:"\f501"}.fa-adjust:before,.fa-circle-half-stroke:before{content:"\f042"}.fa-clapperboard:before{content:"\e131"}.fa-circle-radiation:before,.fa-radiation-alt:before{content:"\f7ba"}.fa-baseball-ball:before,.fa-baseball:before{content:"\f433"}.fa-jet-fighter-up:before{content:"\e518"}.fa-diagram-project:before,.fa-project-diagram:before{content:"\f542"}.fa-copy:before{content:"\f0c5"}.fa-volume-mute:before,.fa-volume-times:before,.fa-volume-xmark:before{content:"\f6a9"}.fa-hand-sparkles:before{content:"\e05d"}.fa-grip-horizontal:before,.fa-grip:before{content:"\f58d"}.fa-share-from-square:before,.fa-share-square:before{content:"\f14d"}.fa-child-combatant:before,.fa-child-rifle:before{content:"\e4e0"}.fa-gun:before{content:"\e19b"}.fa-phone-square:before,.fa-square-phone:before{content:"\f098"}.fa-add:before,.fa-plus:before{content:"\2b"}.fa-expand:before{content:"\f065"}.fa-computer:before{content:"\e4e5"}.fa-close:before,.fa-multiply:before,.fa-remove:before,.fa-times:before,.fa-xmark:before{content:"\f00d"}.fa-arrows-up-down-left-right:before,.fa-arrows:before{content:"\f047"}.fa-chalkboard-teacher:before,.fa-chalkboard-user:before{content:"\f51c"}.fa-peso-sign:before{content:"\e222"}.fa-building-shield:before{content:"\e4d8"}.fa-baby:before{content:"\f77c"}.fa-users-line:before{content:"\e592"}.fa-quote-left-alt:before,.fa-quote-left:before{content:"\f10d"}.fa-tractor:before{content:"\f722"}.fa-trash-arrow-up:before,.fa-trash-restore:before{content:"\f829"}.fa-arrow-down-up-lock:before{content:"\e4b0"}.fa-lines-leaning:before{content:"\e51e"}.fa-ruler-combined:before{content:"\f546"}.fa-copyright:before{content:"\f1f9"}.fa-equals:before{content:"\3d"}.fa-blender:before{content:"\f517"}.fa-teeth:before{content:"\f62e"}.fa-ils:before,.fa-shekel-sign:before,.fa-shekel:before,.fa-sheqel-sign:before,.fa-sheqel:before{content:"\f20b"}.fa-map:before{content:"\f279"}.fa-rocket:before{content:"\f135"}.fa-photo-film:before,.fa-photo-video:before{content:"\f87c"}.fa-folder-minus:before{content:"\f65d"}.fa-store:before{content:"\f54e"}.fa-arrow-trend-up:before{content:"\e098"}.fa-plug-circle-minus:before{content:"\e55e"}.fa-sign-hanging:before,.fa-sign:before{content:"\f4d9"}.fa-bezier-curve:before{content:"\f55b"}.fa-bell-slash:before{content:"\f1f6"}.fa-tablet-android:before,.fa-tablet:before{content:"\f3fb"}.fa-school-flag:before{content:"\e56e"}.fa-fill:before{content:"\f575"}.fa-angle-up:before{content:"\f106"}.fa-drumstick-bite:before{content:"\f6d7"}.fa-holly-berry:before{content:"\f7aa"}.fa-chevron-left:before{content:"\f053"}.fa-bacteria:before{content:"\e059"}.fa-hand-lizard:before{content:"\f258"}.fa-notdef:before{content:"\e1fe"}.fa-disease:before{content:"\f7fa"}.fa-briefcase-medical:before{content:"\f469"}.fa-genderless:before{content:"\f22d"}.fa-chevron-right:before{content:"\f054"}.fa-retweet:before{content:"\f079"}.fa-car-alt:before,.fa-car-rear:before{content:"\f5de"}.fa-pump-soap:before{content:"\e06b"}.fa-video-slash:before{content:"\f4e2"}.fa-battery-2:before,.fa-battery-quarter:before{content:"\f243"}.fa-radio:before{content:"\f8d7"}.fa-baby-carriage:before,.fa-carriage-baby:before{content:"\f77d"}.fa-traffic-light:before{content:"\f637"}.fa-thermometer:before{content:"\f491"}.fa-vr-cardboard:before{content:"\f729"}.fa-hand-middle-finger:before{content:"\f806"}.fa-percent:before,.fa-percentage:before{content:"\25"}.fa-truck-moving:before{content:"\f4df"}.fa-glass-water-droplet:before{content:"\e4f5"}.fa-display:before{content:"\e163"}.fa-face-smile:before,.fa-smile:before{content:"\f118"}.fa-thumb-tack:before,.fa-thumbtack:before{content:"\f08d"}.fa-trophy:before{content:"\f091"}.fa-person-praying:before,.fa-pray:before{content:"\f683"}.fa-hammer:before{content:"\f6e3"}.fa-hand-peace:before{content:"\f25b"}.fa-rotate:before,.fa-sync-alt:before{content:"\f2f1"}.fa-spinner:before{content:"\f110"}.fa-robot:before{content:"\f544"}.fa-peace:before{content:"\f67c"}.fa-cogs:before,.fa-gears:before{content:"\f085"}.fa-warehouse:before{content:"\f494"}.fa-arrow-up-right-dots:before{content:"\e4b7"}.fa-splotch:before{content:"\f5bc"}.fa-face-grin-hearts:before,.fa-grin-hearts:before{content:"\f584"}.fa-dice-four:before{content:"\f524"}.fa-sim-card:before{content:"\f7c4"}.fa-transgender-alt:before,.fa-transgender:before{content:"\f225"}.fa-mercury:before{content:"\f223"}.fa-arrow-turn-down:before,.fa-level-down:before{content:"\f149"}.fa-person-falling-burst:before{content:"\e547"}.fa-award:before{content:"\f559"}.fa-ticket-alt:before,.fa-ticket-simple:before{content:"\f3ff"}.fa-building:before{content:"\f1ad"}.fa-angle-double-left:before,.fa-angles-left:before{content:"\f100"}.fa-qrcode:before{content:"\f029"}.fa-clock-rotate-left:before,.fa-history:before{content:"\f1da"}.fa-face-grin-beam-sweat:before,.fa-grin-beam-sweat:before{content:"\f583"}.fa-arrow-right-from-file:before,.fa-file-export:before{content:"\f56e"}.fa-shield-blank:before,.fa-shield:before{content:"\f132"}.fa-arrow-up-short-wide:before,.fa-sort-amount-up-alt:before{content:"\f885"}.fa-house-medical:before{content:"\e3b2"}.fa-golf-ball-tee:before,.fa-golf-ball:before{content:"\f450"}.fa-chevron-circle-left:before,.fa-circle-chevron-left:before{content:"\f137"}.fa-house-chimney-window:before{content:"\e00d"}.fa-pen-nib:before{content:"\f5ad"}.fa-tent-arrow-turn-left:before{content:"\e580"}.fa-tents:before{content:"\e582"}.fa-magic:before,.fa-wand-magic:before{content:"\f0d0"}.fa-dog:before{content:"\f6d3"}.fa-carrot:before{content:"\f787"}.fa-moon:before{content:"\f186"}.fa-wine-glass-alt:before,.fa-wine-glass-empty:before{content:"\f5ce"}.fa-cheese:before{content:"\f7ef"}.fa-yin-yang:before{content:"\f6ad"}.fa-music:before{content:"\f001"}.fa-code-commit:before{content:"\f386"}.fa-temperature-low:before{content:"\f76b"}.fa-biking:before,.fa-person-biking:before{content:"\f84a"}.fa-broom:before{content:"\f51a"}.fa-shield-heart:before{content:"\e574"}.fa-gopuram:before{content:"\f664"}.fa-earth-oceania:before,.fa-globe-oceania:before{content:"\e47b"}.fa-square-xmark:before,.fa-times-square:before,.fa-xmark-square:before{content:"\f2d3"}.fa-hashtag:before{content:"\23"}.fa-expand-alt:before,.fa-up-right-and-down-left-from-center:before{content:"\f424"}.fa-oil-can:before{content:"\f613"}.fa-t:before{content:"\54"}.fa-hippo:before{content:"\f6ed"}.fa-chart-column:before{content:"\e0e3"}.fa-infinity:before{content:"\f534"}.fa-vial-circle-check:before{content:"\e596"}.fa-person-arrow-down-to-line:before{content:"\e538"}.fa-voicemail:before{content:"\f897"}.fa-fan:before{content:"\f863"}.fa-person-walking-luggage:before{content:"\e554"}.fa-arrows-alt-v:before,.fa-up-down:before{content:"\f338"}.fa-cloud-moon-rain:before{content:"\f73c"}.fa-calendar:before{content:"\f133"}.fa-trailer:before{content:"\e041"}.fa-bahai:before,.fa-haykal:before{content:"\f666"}.fa-sd-card:before{content:"\f7c2"}.fa-dragon:before{content:"\f6d5"}.fa-shoe-prints:before{content:"\f54b"}.fa-circle-plus:before,.fa-plus-circle:before{content:"\f055"}.fa-face-grin-tongue-wink:before,.fa-grin-tongue-wink:before{content:"\f58b"}.fa-hand-holding:before{content:"\f4bd"}.fa-plug-circle-exclamation:before{content:"\e55d"}.fa-chain-broken:before,.fa-chain-slash:before,.fa-link-slash:before,.fa-unlink:before{content:"\f127"}.fa-clone:before{content:"\f24d"}.fa-person-walking-arrow-loop-left:before{content:"\e551"}.fa-arrow-up-z-a:before,.fa-sort-alpha-up-alt:before{content:"\f882"}.fa-fire-alt:before,.fa-fire-flame-curved:before{content:"\f7e4"}.fa-tornado:before{content:"\f76f"}.fa-file-circle-plus:before{content:"\e494"}.fa-book-quran:before,.fa-quran:before{content:"\f687"}.fa-anchor:before{content:"\f13d"}.fa-border-all:before{content:"\f84c"}.fa-angry:before,.fa-face-angry:before{content:"\f556"}.fa-cookie-bite:before{content:"\f564"}.fa-arrow-trend-down:before{content:"\e097"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-draw-polygon:before{content:"\f5ee"}.fa-balance-scale:before,.fa-scale-balanced:before{content:"\f24e"}.fa-gauge-simple-high:before,.fa-tachometer-fast:before,.fa-tachometer:before{content:"\f62a"}.fa-shower:before{content:"\f2cc"}.fa-desktop-alt:before,.fa-desktop:before{content:"\f390"}.fa-m:before{content:"\4d"}.fa-table-list:before,.fa-th-list:before{content:"\f00b"}.fa-comment-sms:before,.fa-sms:before{content:"\f7cd"}.fa-book:before{content:"\f02d"}.fa-user-plus:before{content:"\f234"}.fa-check:before{content:"\f00c"}.fa-battery-4:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-house-circle-check:before{content:"\e509"}.fa-angle-left:before{content:"\f104"}.fa-diagram-successor:before{content:"\e47a"}.fa-truck-arrow-right:before{content:"\e58b"}.fa-arrows-split-up-and-left:before{content:"\e4bc"}.fa-fist-raised:before,.fa-hand-fist:before{content:"\f6de"}.fa-cloud-moon:before{content:"\f6c3"}.fa-briefcase:before{content:"\f0b1"}.fa-person-falling:before{content:"\e546"}.fa-image-portrait:before,.fa-portrait:before{content:"\f3e0"}.fa-user-tag:before{content:"\f507"}.fa-rug:before{content:"\e569"}.fa-earth-europe:before,.fa-globe-europe:before{content:"\f7a2"}.fa-cart-flatbed-suitcase:before,.fa-luggage-cart:before{content:"\f59d"}.fa-rectangle-times:before,.fa-rectangle-xmark:before,.fa-times-rectangle:before,.fa-window-close:before{content:"\f410"}.fa-baht-sign:before{content:"\e0ac"}.fa-book-open:before{content:"\f518"}.fa-book-journal-whills:before,.fa-journal-whills:before{content:"\f66a"}.fa-handcuffs:before{content:"\e4f8"}.fa-exclamation-triangle:before,.fa-triangle-exclamation:before,.fa-warning:before{content:"\f071"}.fa-database:before{content:"\f1c0"}.fa-arrow-turn-right:before,.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-bottle-droplet:before{content:"\e4c4"}.fa-mask-face:before{content:"\e1d7"}.fa-hill-rockslide:before{content:"\e508"}.fa-exchange-alt:before,.fa-right-left:before{content:"\f362"}.fa-paper-plane:before{content:"\f1d8"}.fa-road-circle-exclamation:before{content:"\e565"}.fa-dungeon:before{content:"\f6d9"}.fa-align-right:before{content:"\f038"}.fa-money-bill-1-wave:before,.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-life-ring:before{content:"\f1cd"}.fa-hands:before,.fa-sign-language:before,.fa-signing:before{content:"\f2a7"}.fa-calendar-day:before{content:"\f783"}.fa-ladder-water:before,.fa-swimming-pool:before,.fa-water-ladder:before{content:"\f5c5"}.fa-arrows-up-down:before,.fa-arrows-v:before{content:"\f07d"}.fa-face-grimace:before,.fa-grimace:before{content:"\f57f"}.fa-wheelchair-alt:before,.fa-wheelchair-move:before{content:"\e2ce"}.fa-level-down-alt:before,.fa-turn-down:before{content:"\f3be"}.fa-person-walking-arrow-right:before{content:"\e552"}.fa-envelope-square:before,.fa-square-envelope:before{content:"\f199"}.fa-dice:before{content:"\f522"}.fa-bowling-ball:before{content:"\f436"}.fa-brain:before{content:"\f5dc"}.fa-band-aid:before,.fa-bandage:before{content:"\f462"}.fa-calendar-minus:before{content:"\f272"}.fa-circle-xmark:before,.fa-times-circle:before,.fa-xmark-circle:before{content:"\f057"}.fa-gifts:before{content:"\f79c"}.fa-hotel:before{content:"\f594"}.fa-earth-asia:before,.fa-globe-asia:before{content:"\f57e"}.fa-id-card-alt:before,.fa-id-card-clip:before{content:"\f47f"}.fa-magnifying-glass-plus:before,.fa-search-plus:before{content:"\f00e"}.fa-thumbs-up:before{content:"\f164"}.fa-user-clock:before{content:"\f4fd"}.fa-allergies:before,.fa-hand-dots:before{content:"\f461"}.fa-file-invoice:before{content:"\f570"}.fa-window-minimize:before{content:"\f2d1"}.fa-coffee:before,.fa-mug-saucer:before{content:"\f0f4"}.fa-brush:before{content:"\f55d"}.fa-mask:before{content:"\f6fa"}.fa-magnifying-glass-minus:before,.fa-search-minus:before{content:"\f010"}.fa-ruler-vertical:before{content:"\f548"}.fa-user-alt:before,.fa-user-large:before{content:"\f406"}.fa-train-tram:before{content:"\e5b4"}.fa-user-nurse:before{content:"\f82f"}.fa-syringe:before{content:"\f48e"}.fa-cloud-sun:before{content:"\f6c4"}.fa-stopwatch-20:before{content:"\e06f"}.fa-square-full:before{content:"\f45c"}.fa-magnet:before{content:"\f076"}.fa-jar:before{content:"\e516"}.fa-note-sticky:before,.fa-sticky-note:before{content:"\f249"}.fa-bug-slash:before{content:"\e490"}.fa-arrow-up-from-water-pump:before{content:"\e4b6"}.fa-bone:before{content:"\f5d7"}.fa-user-injured:before{content:"\f728"}.fa-face-sad-tear:before,.fa-sad-tear:before{content:"\f5b4"}.fa-plane:before{content:"\f072"}.fa-tent-arrows-down:before{content:"\e581"}.fa-exclamation:before{content:"\21"}.fa-arrows-spin:before{content:"\e4bb"}.fa-print:before{content:"\f02f"}.fa-try:before,.fa-turkish-lira-sign:before,.fa-turkish-lira:before{content:"\e2bb"}.fa-dollar-sign:before,.fa-dollar:before,.fa-usd:before{content:"\24"}.fa-x:before{content:"\58"}.fa-magnifying-glass-dollar:before,.fa-search-dollar:before{content:"\f688"}.fa-users-cog:before,.fa-users-gear:before{content:"\f509"}.fa-person-military-pointing:before{content:"\e54a"}.fa-bank:before,.fa-building-columns:before,.fa-institution:before,.fa-museum:before,.fa-university:before{content:"\f19c"}.fa-umbrella:before{content:"\f0e9"}.fa-trowel:before{content:"\e589"}.fa-d:before{content:"\44"}.fa-stapler:before{content:"\e5af"}.fa-masks-theater:before,.fa-theater-masks:before{content:"\f630"}.fa-kip-sign:before{content:"\e1c4"}.fa-hand-point-left:before{content:"\f0a5"}.fa-handshake-alt:before,.fa-handshake-simple:before{content:"\f4c6"}.fa-fighter-jet:before,.fa-jet-fighter:before{content:"\f0fb"}.fa-share-alt-square:before,.fa-square-share-nodes:before{content:"\f1e1"}.fa-barcode:before{content:"\f02a"}.fa-plus-minus:before{content:"\e43c"}.fa-video-camera:before,.fa-video:before{content:"\f03d"}.fa-graduation-cap:before,.fa-mortar-board:before{content:"\f19d"}.fa-hand-holding-medical:before{content:"\e05c"}.fa-person-circle-check:before{content:"\e53e"}.fa-level-up-alt:before,.fa-turn-up:before{content:"\f3bf"} +.fa-sr-only,.fa-sr-only-focusable:not(:focus),.sr-only,.sr-only-focusable:not(:focus){position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}:host,:root{--fa-style-family-brands:"Font Awesome 6 Brands";--fa-font-brands:normal 400 1em/1 "Font Awesome 6 Brands"}@font-face{font-family:"Font Awesome 6 Brands";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}.fa-brands,.fab{font-weight:400}.fa-monero:before{content:"\f3d0"}.fa-hooli:before{content:"\f427"}.fa-yelp:before{content:"\f1e9"}.fa-cc-visa:before{content:"\f1f0"}.fa-lastfm:before{content:"\f202"}.fa-shopware:before{content:"\f5b5"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-aws:before{content:"\f375"}.fa-redhat:before{content:"\f7bc"}.fa-yoast:before{content:"\f2b1"}.fa-cloudflare:before{content:"\e07d"}.fa-ups:before{content:"\f7e0"}.fa-wpexplorer:before{content:"\f2de"}.fa-dyalog:before{content:"\f399"}.fa-bity:before{content:"\f37a"}.fa-stackpath:before{content:"\f842"}.fa-buysellads:before{content:"\f20d"}.fa-first-order:before{content:"\f2b0"}.fa-modx:before{content:"\f285"}.fa-guilded:before{content:"\e07e"}.fa-vnv:before{content:"\f40b"}.fa-js-square:before,.fa-square-js:before{content:"\f3b9"}.fa-microsoft:before{content:"\f3ca"}.fa-qq:before{content:"\f1d6"}.fa-orcid:before{content:"\f8d2"}.fa-java:before{content:"\f4e4"}.fa-invision:before{content:"\f7b0"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-centercode:before{content:"\f380"}.fa-glide-g:before{content:"\f2a6"}.fa-drupal:before{content:"\f1a9"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-unity:before{content:"\e049"}.fa-whmcs:before{content:"\f40d"}.fa-rocketchat:before{content:"\f3e8"}.fa-vk:before{content:"\f189"}.fa-untappd:before{content:"\f405"}.fa-mailchimp:before{content:"\f59e"}.fa-css3-alt:before{content:"\f38b"}.fa-reddit-square:before,.fa-square-reddit:before{content:"\f1a2"}.fa-vimeo-v:before{content:"\f27d"}.fa-contao:before{content:"\f26d"}.fa-square-font-awesome:before{content:"\e5ad"}.fa-deskpro:before{content:"\f38f"}.fa-sistrix:before{content:"\f3ee"}.fa-instagram-square:before,.fa-square-instagram:before{content:"\e055"}.fa-battle-net:before{content:"\f835"}.fa-the-red-yeti:before{content:"\f69d"}.fa-hacker-news-square:before,.fa-square-hacker-news:before{content:"\f3af"}.fa-edge:before{content:"\f282"}.fa-napster:before{content:"\f3d2"}.fa-snapchat-square:before,.fa-square-snapchat:before{content:"\f2ad"}.fa-google-plus-g:before{content:"\f0d5"}.fa-artstation:before{content:"\f77a"}.fa-markdown:before{content:"\f60f"}.fa-sourcetree:before{content:"\f7d3"}.fa-google-plus:before{content:"\f2b3"}.fa-diaspora:before{content:"\f791"}.fa-foursquare:before{content:"\f180"}.fa-stack-overflow:before{content:"\f16c"}.fa-github-alt:before{content:"\f113"}.fa-phoenix-squadron:before{content:"\f511"}.fa-pagelines:before{content:"\f18c"}.fa-algolia:before{content:"\f36c"}.fa-red-river:before{content:"\f3e3"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-safari:before{content:"\f267"}.fa-google:before{content:"\f1a0"}.fa-font-awesome-alt:before,.fa-square-font-awesome-stroke:before{content:"\f35c"}.fa-atlassian:before{content:"\f77b"}.fa-linkedin-in:before{content:"\f0e1"}.fa-digital-ocean:before{content:"\f391"}.fa-nimblr:before{content:"\f5a8"}.fa-chromecast:before{content:"\f838"}.fa-evernote:before{content:"\f839"}.fa-hacker-news:before{content:"\f1d4"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-adversal:before{content:"\f36a"}.fa-creative-commons:before{content:"\f25e"}.fa-watchman-monitoring:before{content:"\e087"}.fa-fonticons:before{content:"\f280"}.fa-weixin:before{content:"\f1d7"}.fa-shirtsinbulk:before{content:"\f214"}.fa-codepen:before{content:"\f1cb"}.fa-git-alt:before{content:"\f841"}.fa-lyft:before{content:"\f3c3"}.fa-rev:before{content:"\f5b2"}.fa-windows:before{content:"\f17a"}.fa-wizards-of-the-coast:before{content:"\f730"}.fa-square-viadeo:before,.fa-viadeo-square:before{content:"\f2aa"}.fa-meetup:before{content:"\f2e0"}.fa-centos:before{content:"\f789"}.fa-adn:before{content:"\f170"}.fa-cloudsmith:before{content:"\f384"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-dribbble-square:before,.fa-square-dribbble:before{content:"\f397"}.fa-codiepie:before{content:"\f284"}.fa-node:before{content:"\f419"}.fa-mix:before{content:"\f3cb"}.fa-steam:before{content:"\f1b6"}.fa-cc-apple-pay:before{content:"\f416"}.fa-scribd:before{content:"\f28a"}.fa-openid:before{content:"\f19b"}.fa-instalod:before{content:"\e081"}.fa-expeditedssl:before{content:"\f23e"}.fa-sellcast:before{content:"\f2da"}.fa-square-twitter:before,.fa-twitter-square:before{content:"\f081"}.fa-r-project:before{content:"\f4f7"}.fa-delicious:before{content:"\f1a5"}.fa-freebsd:before{content:"\f3a4"}.fa-vuejs:before{content:"\f41f"}.fa-accusoft:before{content:"\f369"}.fa-ioxhost:before{content:"\f208"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-app-store:before{content:"\f36f"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-itunes-note:before{content:"\f3b5"}.fa-golang:before{content:"\e40f"}.fa-kickstarter:before{content:"\f3bb"}.fa-grav:before{content:"\f2d6"}.fa-weibo:before{content:"\f18a"}.fa-uncharted:before{content:"\e084"}.fa-firstdraft:before{content:"\f3a1"}.fa-square-youtube:before,.fa-youtube-square:before{content:"\f431"}.fa-wikipedia-w:before{content:"\f266"}.fa-rendact:before,.fa-wpressr:before{content:"\f3e4"}.fa-angellist:before{content:"\f209"}.fa-galactic-republic:before{content:"\f50c"}.fa-nfc-directional:before{content:"\e530"}.fa-skype:before{content:"\f17e"}.fa-joget:before{content:"\f3b7"}.fa-fedora:before{content:"\f798"}.fa-stripe-s:before{content:"\f42a"}.fa-meta:before{content:"\e49b"}.fa-laravel:before{content:"\f3bd"}.fa-hotjar:before{content:"\f3b1"}.fa-bluetooth-b:before{content:"\f294"}.fa-sticker-mule:before{content:"\f3f7"}.fa-creative-commons-zero:before{content:"\f4f3"}.fa-hips:before{content:"\f452"}.fa-behance:before{content:"\f1b4"}.fa-reddit:before{content:"\f1a1"}.fa-discord:before{content:"\f392"}.fa-chrome:before{content:"\f268"}.fa-app-store-ios:before{content:"\f370"}.fa-cc-discover:before{content:"\f1f2"}.fa-wpbeginner:before{content:"\f297"}.fa-confluence:before{content:"\f78d"}.fa-mdb:before{content:"\f8ca"}.fa-dochub:before{content:"\f394"}.fa-accessible-icon:before{content:"\f368"}.fa-ebay:before{content:"\f4f4"}.fa-amazon:before{content:"\f270"}.fa-unsplash:before{content:"\e07c"}.fa-yarn:before{content:"\f7e3"}.fa-square-steam:before,.fa-steam-square:before{content:"\f1b7"}.fa-500px:before{content:"\f26e"}.fa-square-vimeo:before,.fa-vimeo-square:before{content:"\f194"}.fa-asymmetrik:before{content:"\f372"}.fa-font-awesome-flag:before,.fa-font-awesome-logo-full:before,.fa-font-awesome:before{content:"\f2b4"}.fa-gratipay:before{content:"\f184"}.fa-apple:before{content:"\f179"}.fa-hive:before{content:"\e07f"}.fa-gitkraken:before{content:"\f3a6"}.fa-keybase:before{content:"\f4f5"}.fa-apple-pay:before{content:"\f415"}.fa-padlet:before{content:"\e4a0"}.fa-amazon-pay:before{content:"\f42c"}.fa-github-square:before,.fa-square-github:before{content:"\f092"}.fa-stumbleupon:before{content:"\f1a4"}.fa-fedex:before{content:"\f797"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-shopify:before{content:"\e057"}.fa-neos:before{content:"\f612"}.fa-hackerrank:before{content:"\f5f7"}.fa-researchgate:before{content:"\f4f8"}.fa-swift:before{content:"\f8e1"}.fa-angular:before{content:"\f420"}.fa-speakap:before{content:"\f3f3"}.fa-angrycreative:before{content:"\f36e"}.fa-y-combinator:before{content:"\f23b"}.fa-empire:before{content:"\f1d1"}.fa-envira:before{content:"\f299"}.fa-gitlab-square:before,.fa-square-gitlab:before{content:"\e5ae"}.fa-studiovinari:before{content:"\f3f8"}.fa-pied-piper:before{content:"\f2ae"}.fa-wordpress:before{content:"\f19a"}.fa-product-hunt:before{content:"\f288"}.fa-firefox:before{content:"\f269"}.fa-linode:before{content:"\f2b8"}.fa-goodreads:before{content:"\f3a8"}.fa-odnoklassniki-square:before,.fa-square-odnoklassniki:before{content:"\f264"}.fa-jsfiddle:before{content:"\f1cc"}.fa-sith:before{content:"\f512"}.fa-themeisle:before{content:"\f2b2"}.fa-page4:before{content:"\f3d7"}.fa-hashnode:before{content:"\e499"}.fa-react:before{content:"\f41b"}.fa-cc-paypal:before{content:"\f1f4"}.fa-squarespace:before{content:"\f5be"}.fa-cc-stripe:before{content:"\f1f5"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-bitcoin:before{content:"\f379"}.fa-keycdn:before{content:"\f3ba"}.fa-opera:before{content:"\f26a"}.fa-itch-io:before{content:"\f83a"}.fa-umbraco:before{content:"\f8e8"}.fa-galactic-senate:before{content:"\f50d"}.fa-ubuntu:before{content:"\f7df"}.fa-draft2digital:before{content:"\f396"}.fa-stripe:before{content:"\f429"}.fa-houzz:before{content:"\f27c"}.fa-gg:before{content:"\f260"}.fa-dhl:before{content:"\f790"}.fa-pinterest-square:before,.fa-square-pinterest:before{content:"\f0d3"}.fa-xing:before{content:"\f168"}.fa-blackberry:before{content:"\f37b"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-playstation:before{content:"\f3df"}.fa-quinscape:before{content:"\f459"}.fa-less:before{content:"\f41d"}.fa-blogger-b:before{content:"\f37d"}.fa-opencart:before{content:"\f23d"}.fa-vine:before{content:"\f1ca"}.fa-paypal:before{content:"\f1ed"}.fa-gitlab:before{content:"\f296"}.fa-typo3:before{content:"\f42b"}.fa-reddit-alien:before{content:"\f281"}.fa-yahoo:before{content:"\f19e"}.fa-dailymotion:before{content:"\e052"}.fa-affiliatetheme:before{content:"\f36b"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-bootstrap:before{content:"\f836"}.fa-odnoklassniki:before{content:"\f263"}.fa-nfc-symbol:before{content:"\e531"}.fa-ethereum:before{content:"\f42e"}.fa-speaker-deck:before{content:"\f83c"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-patreon:before{content:"\f3d9"}.fa-avianex:before{content:"\f374"}.fa-ello:before{content:"\f5f1"}.fa-gofore:before{content:"\f3a7"}.fa-bimobject:before{content:"\f378"}.fa-facebook-f:before{content:"\f39e"}.fa-google-plus-square:before,.fa-square-google-plus:before{content:"\f0d4"}.fa-mandalorian:before{content:"\f50f"}.fa-first-order-alt:before{content:"\f50a"}.fa-osi:before{content:"\f41a"}.fa-google-wallet:before{content:"\f1ee"}.fa-d-and-d-beyond:before{content:"\f6ca"}.fa-periscope:before{content:"\f3da"}.fa-fulcrum:before{content:"\f50b"}.fa-cloudscale:before{content:"\f383"}.fa-forumbee:before{content:"\f211"}.fa-mizuni:before{content:"\f3cc"}.fa-schlix:before{content:"\f3ea"}.fa-square-xing:before,.fa-xing-square:before{content:"\f169"}.fa-bandcamp:before{content:"\f2d5"}.fa-wpforms:before{content:"\f298"}.fa-cloudversify:before{content:"\f385"}.fa-usps:before{content:"\f7e1"}.fa-megaport:before{content:"\f5a3"}.fa-magento:before{content:"\f3c4"}.fa-spotify:before{content:"\f1bc"}.fa-optin-monster:before{content:"\f23c"}.fa-fly:before{content:"\f417"}.fa-aviato:before{content:"\f421"}.fa-itunes:before{content:"\f3b4"}.fa-cuttlefish:before{content:"\f38c"}.fa-blogger:before{content:"\f37c"}.fa-flickr:before{content:"\f16e"}.fa-viber:before{content:"\f409"}.fa-soundcloud:before{content:"\f1be"}.fa-digg:before{content:"\f1a6"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-symfony:before{content:"\f83d"}.fa-maxcdn:before{content:"\f136"}.fa-etsy:before{content:"\f2d7"}.fa-facebook-messenger:before{content:"\f39f"}.fa-audible:before{content:"\f373"}.fa-think-peaks:before{content:"\f731"}.fa-bilibili:before{content:"\e3d9"}.fa-erlang:before{content:"\f39d"}.fa-cotton-bureau:before{content:"\f89e"}.fa-dashcube:before{content:"\f210"}.fa-42-group:before,.fa-innosoft:before{content:"\e080"}.fa-stack-exchange:before{content:"\f18d"}.fa-elementor:before{content:"\f430"}.fa-pied-piper-square:before,.fa-square-pied-piper:before{content:"\e01e"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-palfed:before{content:"\f3d8"}.fa-superpowers:before{content:"\f2dd"}.fa-resolving:before{content:"\f3e7"}.fa-xbox:before{content:"\f412"}.fa-searchengin:before{content:"\f3eb"}.fa-tiktok:before{content:"\e07b"}.fa-facebook-square:before,.fa-square-facebook:before{content:"\f082"}.fa-renren:before{content:"\f18b"}.fa-linux:before{content:"\f17c"}.fa-glide:before{content:"\f2a5"}.fa-linkedin:before{content:"\f08c"}.fa-hubspot:before{content:"\f3b2"}.fa-deploydog:before{content:"\f38e"}.fa-twitch:before{content:"\f1e8"}.fa-ravelry:before{content:"\f2d9"}.fa-mixer:before{content:"\e056"}.fa-lastfm-square:before,.fa-square-lastfm:before{content:"\f203"}.fa-vimeo:before{content:"\f40a"}.fa-mendeley:before{content:"\f7b3"}.fa-uniregistry:before{content:"\f404"}.fa-figma:before{content:"\f799"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-dropbox:before{content:"\f16b"}.fa-instagram:before{content:"\f16d"}.fa-cmplid:before{content:"\e360"}.fa-facebook:before{content:"\f09a"}.fa-gripfire:before{content:"\f3ac"}.fa-jedi-order:before{content:"\f50e"}.fa-uikit:before{content:"\f403"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-phabricator:before{content:"\f3db"}.fa-ussunnah:before{content:"\f407"}.fa-earlybirds:before{content:"\f39a"}.fa-trade-federation:before{content:"\f513"}.fa-autoprefixer:before{content:"\f41c"}.fa-whatsapp:before{content:"\f232"}.fa-slideshare:before{content:"\f1e7"}.fa-google-play:before{content:"\f3ab"}.fa-viadeo:before{content:"\f2a9"}.fa-line:before{content:"\f3c0"}.fa-google-drive:before{content:"\f3aa"}.fa-servicestack:before{content:"\f3ec"}.fa-simplybuilt:before{content:"\f215"}.fa-bitbucket:before{content:"\f171"}.fa-imdb:before{content:"\f2d8"}.fa-deezer:before{content:"\e077"}.fa-raspberry-pi:before{content:"\f7bb"}.fa-jira:before{content:"\f7b1"}.fa-docker:before{content:"\f395"}.fa-screenpal:before{content:"\e570"}.fa-bluetooth:before{content:"\f293"}.fa-gitter:before{content:"\f426"}.fa-d-and-d:before{content:"\f38d"}.fa-microblog:before{content:"\e01a"}.fa-cc-diners-club:before{content:"\f24c"}.fa-gg-circle:before{content:"\f261"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-yandex:before{content:"\f413"}.fa-readme:before{content:"\f4d5"}.fa-html5:before{content:"\f13b"}.fa-sellsy:before{content:"\f213"}.fa-sass:before{content:"\f41e"}.fa-wirsindhandwerk:before,.fa-wsh:before{content:"\e2d0"}.fa-buromobelexperte:before{content:"\f37f"}.fa-salesforce:before{content:"\f83b"}.fa-octopus-deploy:before{content:"\e082"}.fa-medapps:before{content:"\f3c6"}.fa-ns8:before{content:"\f3d5"}.fa-pinterest-p:before{content:"\f231"}.fa-apper:before{content:"\f371"}.fa-fort-awesome:before{content:"\f286"}.fa-waze:before{content:"\f83f"}.fa-cc-jcb:before{content:"\f24b"}.fa-snapchat-ghost:before,.fa-snapchat:before{content:"\f2ab"}.fa-fantasy-flight-games:before{content:"\f6dc"}.fa-rust:before{content:"\e07a"}.fa-wix:before{content:"\f5cf"}.fa-behance-square:before,.fa-square-behance:before{content:"\f1b5"}.fa-supple:before{content:"\f3f9"}.fa-rebel:before{content:"\f1d0"}.fa-css3:before{content:"\f13c"}.fa-staylinked:before{content:"\f3f5"}.fa-kaggle:before{content:"\f5fa"}.fa-space-awesome:before{content:"\e5ac"}.fa-deviantart:before{content:"\f1bd"}.fa-cpanel:before{content:"\f388"}.fa-goodreads-g:before{content:"\f3a9"}.fa-git-square:before,.fa-square-git:before{content:"\f1d2"}.fa-square-tumblr:before,.fa-tumblr-square:before{content:"\f174"}.fa-trello:before{content:"\f181"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-get-pocket:before{content:"\f265"}.fa-perbyte:before{content:"\e083"}.fa-grunt:before{content:"\f3ad"}.fa-weebly:before{content:"\f5cc"}.fa-connectdevelop:before{content:"\f20e"}.fa-leanpub:before{content:"\f212"}.fa-black-tie:before{content:"\f27e"}.fa-themeco:before{content:"\f5c6"}.fa-python:before{content:"\f3e2"}.fa-android:before{content:"\f17b"}.fa-bots:before{content:"\e340"}.fa-free-code-camp:before{content:"\f2c5"}.fa-hornbill:before{content:"\f592"}.fa-js:before{content:"\f3b8"}.fa-ideal:before{content:"\e013"}.fa-git:before{content:"\f1d3"}.fa-dev:before{content:"\f6cc"}.fa-sketch:before{content:"\f7c6"}.fa-yandex-international:before{content:"\f414"}.fa-cc-amex:before{content:"\f1f3"}.fa-uber:before{content:"\f402"}.fa-github:before{content:"\f09b"}.fa-php:before{content:"\f457"}.fa-alipay:before{content:"\f642"}.fa-youtube:before{content:"\f167"}.fa-skyatlas:before{content:"\f216"}.fa-firefox-browser:before{content:"\e007"}.fa-replyd:before{content:"\f3e6"}.fa-suse:before{content:"\f7d6"}.fa-jenkins:before{content:"\f3b6"}.fa-twitter:before{content:"\f099"}.fa-rockrms:before{content:"\f3e9"}.fa-pinterest:before{content:"\f0d2"}.fa-buffer:before{content:"\f837"}.fa-npm:before{content:"\f3d4"}.fa-yammer:before{content:"\f840"}.fa-btc:before{content:"\f15a"}.fa-dribbble:before{content:"\f17d"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-internet-explorer:before{content:"\f26b"}.fa-stubber:before{content:"\e5c7"}.fa-telegram-plane:before,.fa-telegram:before{content:"\f2c6"}.fa-old-republic:before{content:"\f510"}.fa-odysee:before{content:"\e5c6"}.fa-square-whatsapp:before,.fa-whatsapp-square:before{content:"\f40c"}.fa-node-js:before{content:"\f3d3"}.fa-edge-legacy:before{content:"\e078"}.fa-slack-hash:before,.fa-slack:before{content:"\f198"}.fa-medrt:before{content:"\f3c8"}.fa-usb:before{content:"\f287"}.fa-tumblr:before{content:"\f173"}.fa-vaadin:before{content:"\f408"}.fa-quora:before{content:"\f2c4"}.fa-reacteurope:before{content:"\f75d"}.fa-medium-m:before,.fa-medium:before{content:"\f23a"}.fa-amilia:before{content:"\f36d"}.fa-mixcloud:before{content:"\f289"}.fa-flipboard:before{content:"\f44d"}.fa-viacoin:before{content:"\f237"}.fa-critical-role:before{content:"\f6c9"}.fa-sitrox:before{content:"\e44a"}.fa-discourse:before{content:"\f393"}.fa-joomla:before{content:"\f1aa"}.fa-mastodon:before{content:"\f4f6"}.fa-airbnb:before{content:"\f834"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-buy-n-large:before{content:"\f8a6"}.fa-gulp:before{content:"\f3ae"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-strava:before{content:"\f428"}.fa-ember:before{content:"\f423"}.fa-canadian-maple-leaf:before{content:"\f785"}.fa-teamspeak:before{content:"\f4f9"}.fa-pushed:before{content:"\f3e1"}.fa-wordpress-simple:before{content:"\f411"}.fa-nutritionix:before{content:"\f3d6"}.fa-wodu:before{content:"\e088"}.fa-google-pay:before{content:"\e079"}.fa-intercom:before{content:"\f7af"}.fa-zhihu:before{content:"\f63f"}.fa-korvue:before{content:"\f42f"}.fa-pix:before{content:"\e43a"}.fa-steam-symbol:before{content:"\f3f6"}:host,:root{--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-weight:400}:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900}@font-face{font-family:"Font Awesome 5 Brands";font-display:block;font-weight:400;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:900;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:400;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype");unicode-range:u+f003,u+f006,u+f014,u+f016-f017,u+f01a-f01b,u+f01d,u+f022,u+f03e,u+f044,u+f046,u+f05c-f05d,u+f06e,u+f070,u+f087-f088,u+f08a,u+f094,u+f096-f097,u+f09d,u+f0a0,u+f0a2,u+f0a4-f0a7,u+f0c5,u+f0c7,u+f0e5-f0e6,u+f0eb,u+f0f6-f0f8,u+f10c,u+f114-f115,u+f118-f11a,u+f11c-f11d,u+f133,u+f147,u+f14e,u+f150-f152,u+f185-f186,u+f18e,u+f190-f192,u+f196,u+f1c1-f1c9,u+f1d9,u+f1db,u+f1e3,u+f1ea,u+f1f7,u+f1f9,u+f20a,u+f247-f248,u+f24a,u+f24d,u+f255-f25b,u+f25d,u+f271-f274,u+f278,u+f27b,u+f28c,u+f28e,u+f29c,u+f2b5,u+f2b7,u+f2ba,u+f2bc,u+f2be,u+f2c0-f2c1,u+f2c3,u+f2d0,u+f2d2,u+f2d4,u+f2dc}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-v4compatibility.woff2) format("woff2"),url(../webfonts/fa-v4compatibility.ttf) format("truetype");unicode-range:u+f041,u+f047,u+f065-f066,u+f07d-f07e,u+f080,u+f08b,u+f08e,u+f090,u+f09a,u+f0ac,u+f0ae,u+f0b2,u+f0d0,u+f0d6,u+f0e4,u+f0ec,u+f10a-f10b,u+f123,u+f13e,u+f148-f149,u+f14c,u+f156,u+f15e,u+f160-f161,u+f163,u+f175-f178,u+f195,u+f1f8,u+f219,u+f27a} \ No newline at end of file diff --git a/css/webfonts/fa-solid-900.woff2 b/css/webfonts/fa-solid-900.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..5c16cd3e8a008bdcbed97022c005278971f810c2 GIT binary patch literal 150124 zcmV)dK&QWVPew8T0RR910!nNE3IG5A1-J+R0!khO1OWg500000000000000000000 z00001I07UDAO>Iqt2_XKkp#+={v6AzKm~_z2Oy;b+>%isfb#$Vu+=*cBT_Vbc#7y* z?ZpG2s;a8083}r8Fv%V=o<1)JmZ?yZLhqOGCSs*mYnFIt zQXG~dsdlD5>jw%Ye@6kHh|c=gRb736lFVifviIz7kcUUSe>^YgK;2FRn*Izhfs6*| z0aa1^i6+#i1R||;@^T1@fOM9(KnNlXW`GX3npwL$RLI{`)ycet$OGYE|M@4JX8+y@ zMk5VKmNbgvICiX9%5IX4728siY#J{W4)@w?4=6{x*Za^O`e@tF+avS=u|E*!n9}CJ z`FXN({@-(}>fX8)ZiUpHtGlMVx~FHRLrr&0*x9Mgvs#5w+*NMnRRBgvC=t|T0R{#{ z5ZFNbVH*i-yiOpZ8b^!`UT2KodyF%-pZ(YNx8VhPf1Wp;{X4hR_wFlgroWk|nWsKY zGb34+HKy5s0nv;N4k$o^At7-ZAzew>l&wzdO8uLyq;)0PlI?6we)sNg+0K@q?E^#v zwN{Z?JPq_TJj(9AGnJN7zw!@8|9I-@wEvMVaCsO^u6ipnZ#>*C5P zdhKqjsOX}iqV_7MJu9xP;tp!7xQp6qt34@-f=&?mHq9h7@vl|F=}nJVMS2y*nF{V#tsD6~V2; zDXc{5)kud!OEf|_;Q9ZhCihviX@D(Dy)S_wED*gnBk*S=Fk=~%s>mr_yM1hroi^Gk zJGG$FS!F6|T5rlv7jr=iQ&=wm1_uU))Aj$~ueADfGLun5RQ!32`a@O^qs(9oIrcD& z@~G`|?iKEP_X*Di;hC8WX6_Ko;SkK+Aeg)3-us@LzxT}CY3A-^xC025JIIUzNM<$w zGGY;o=;*b|+#P}g;9B1SPyk2@Ao+)g%n~AhR`(1rn9(F7Dw|+L)n^QA{TTk#^Ni?` zJ!flKT7RuttJX^IUs4Z~qPHTn)MATbtNmJ*X7le9MdTLUehNLvwtvTdeP3_ey`+J)*@1f|*m1Y2E!CWflSiTbAW&47IwwR{s;X*nlZhG-6F; zT5Ya>X+Q`|jBQ+`(MWGHROy>vJ6uv#3T9O6ui!>(In zNZwX#;Q?tsv5mg1+aI+jAu1wFyvX@12)bT;c=_Va_4!@jnD&1Fc){2Gcb5LFbZAaB zU;?j_e;lwm)P@Gm;pgzvIhXuCZgijj4M+F8$`aluAt*QByX&4X0-e-7;7EUM-&nyD zFq0kA2deOI*?0c*DU$WWpvU=YgkEEUw(C7mK?%h0Qw1+qbQc>ejan90?Hag zQaEw(ZUv)YxE{=l4Q2 z3a#NCfz$Tnjb{{}nrzaUaRH%}@9+=w)AUbKZO^T%jHeXGyvT83&HaXkA1PniIEo#j zU!?izYM#8VDMp8-XOj;bQr5#cpEz($@#GnUoY*dM@&c~UvL-9EaZ1=Es%syv%%N`BckoeB(W_UTV zEINLLWO#8lddKU=JLjxm0_W_Dw9ENWj`{Gkq{CeZVfp5BRW+>BBaeOS%6XB4O>%3t zZ3uZD98}HREOPYN`mLCUg=&Gj2OBNVk>50KpX1x0pH~RhSzlD%7bz(+qof%DbprE_be-K&xV4&Lp@vPE$E^=hmO?_wxw!|%Nv!N=$4=5!?A z)S!t}zl)$BsxLZtwKc(K?R@m;Yxe(uu;aA)&C3I$&)_P1@+pJmw#s^u5$l{1d=Q4u zhZTBE4%QaN(fXzAePiUksTjs}D$GO9Fu8wZ_&wH~)gdo7>1$A zC4M*lkE?#yt%gv;=k*Lpa8j#8)=>V7ou5gZ&kPpjf#_Mo|7QkU?XbGtqxtwe(8w_M zJ=6qepw-pncGO_(d`RY+BH!Kpl1cx3E1VoRxDwi0tp%~-<>ir=Z-r{5_`+L_n;|AWRHyBOV!pUXOK z%6=4kEWx{LBGrh-0xka}FM_yGzrQ5wtzDkJ=F=2ogDYyDcz0fZZC^`lv;30$EF))Y z1s0B%vaO+G~@XW@sS#zONiS(A^idov0aqJR;{GO3l`L5o>%X{umK-vE*4FmHrOr z<(0>;%yGEBzR1ZozKLv$PyT)&HSmeS0{J7?>FM4jg-;GH`A#^(F)Wwz5MHi6g>qEl z7zb~$AyD(2em%x#ta9A*yt!Gwli9wzubeYx2pP?VkH{B4n)Cdj)~OaG=Zfopf%^W~ zkn>k688iODLC(jvMffIhF!nxGapHr%7o7FWL#g>U7=sY6LVhZP-nr-4nv&23dF-PM$gSh>fYG-ugVDr&~zxM$^fj3-FJrBaP77So+_&BzJ^w!ar(#fw}$Z)pkFJ z8=xKTZtd-vz+J$Mv-}J3?SKy0^qjZ+0o`@)vQ2bM;r`!ireA&uMz4C9%tM^@u!xW! zWzh~@(GA_v13l3Tz0n7K(GUGG00S`ygE0g{F$}{o0wXaBqcH|!F%Fxs1v{_{dvO$} z@DM*tI+NYxFl9|Sv)ODnJIqeA+Z;B>%^4fB^X&q=&@Qrz?Gn4pp0uazxmem*P8ZL` zciCJXm)GTU`P~Y4)4g)9-8=W*eR5yiPxs6HasP9liPlF)qnpvK=uh;QU>wF{e5PRr z=AdDWjuG>*01L4&%djjfvkI%R9ow@5JF*iyvnP9T7{_ruCvgg=avG;|24`{+mvRMH zaXmM1BR6p~w{R=BaXWW#CwK86&+shI@jNf^F<mjseTl1eg3E~zAq zq?L4%UNT5V$styXN)4$c4WyAYmlo1eT1yXEB1>gi-V%9B=1-A7TmJ0%bLVg36Z#ZB zt*`1w`B{FsU+H)F-TtsY=1=*X{*iy|pK4L9pjEV)cGtc-P{->`ou{jGyYAQ1dQLCt zUA?al^__mv@A^L^39(QxR19;&>ToQa3g^R>a3eeqZ=U3QvhT^kC*Ph{cv|ymojkZc zZm=8fM!N-WiQDS-x&!X8yW`%uFYX8Tll$36_E~*?U*EUz{rwO>-Ou-{{93=!@ACWo z5kx{{L`5{jLt-RBQY1r4q(W+>L0Y6kIaELuR7Ew^Lu<4_TeL$rbVm>LL_dtf1Wdzp z%)m^{!fedLLM+8HEXNA0!$xevJ{-b5Jj6SEz-Kske8JBM_!Yn5cl^ibjKSE9!+1={ zL`=e@OvAKH$4t!3?99QO%*A{x%2F)HYOKzBY{I5&$#(3@5uD5!oW})R%%xn$7HO$gX^qxti+1Rkj_agO>9o%1tj_6zF6pxF>9L;Zjl90IhpM4^s2%Es2BC3i5!&`4jD-m>1!ltnSOm*qHLQgV zuoZT~9ykO?;S8LI+wc%xz#H%YFYtj7h=fE)g?I2Cb74)ah4rvLHpV8{4%_2?JdGFd zD&EIO_zYj8HwIz|Mqn%^U=pTcIy&(Ke!=hfk1|nKDo91B6j@ZBDpL)rM@^{}wWm(h zm3mNL>Q94d7>%I`G=*l;Y?@1pXbCN+RkW5i(RMmQC+R$0rR#K?p3+NtLvG|neiTaK z6iLw(OYxLK@8~^!q;I6?H~nRU^KyPJ#wFO~3S6CQa$RoC?YJX%<=#Ayhww-q%hP!l z&*O!>n%DCt-p0H55Fg=Fe2y>h6~4)L_zAz@*X+g~?9Blj!eJc23H+YF@(=#W$jpCq zW}QRl)_HVZolh6kg>`XVQaf}FU01i!J#;VKPY={%^h7;dFW0N}2EA49*GILx4$`qY zUfcSIcDY=JVU#y28a0f1Mk`~yF`-KLvK_j%X~WYtrybq zp1j}dx^Rhe#mcWa?>ZkkA3H;wY0i)BYdC+{Iqa%-1G~RH!k%w0vA5g%?NjzS`+8fQ zA+m_9BD=^T3by_dGv!4)(SBi9(Qk6xLC+BL#5%E2?H2pQ!B2BaoD&zsMR8BKi7*i( z(nLmE_(6!(YiY=wGMCIN^T~p(S5lRe6=kKbS5wxLO=WA@PIi^uWIs7r4v{0}IJsDE zkel1|{`sfm&1E0RXVOgu&m_q-`T38j3xU%v^uA*sz+;cSDwmOg{df&*yc-xi!=YsHNWZ4VZ8~xEt7Bhg9rWIFAbmx zH2DKHJ@!6H}+t6>eShfS~(cEdh6(q^8)YjFE*f*}%OAO$kuJ^aRESPN@o zeQbbDuqn334tNmH;w8L+5Ag}Uz_;j&!5G&1QwIG#`bt!t>QGZ^xwe5cj7HN0nncsv z=9bY)T1)F`D;=R@bgoT48RR)XnKI^mC9xxNaURakMVGC{HMtJA{5t)42#??~Je_Cq zTwcJdcpY!#Exd~lwoiVRFYqP4&bRq7Kj+u{mOa>u{W+-3k7Z|1ZSz}a*4btX=)$_# zE?1pdsn_dG1Kj?u#+BwubtSrDUD3v`t(bB(%T+E{-a;$I3bWj-x7K~@u6512VqG*0 zYpb>1T4Ob^YFgE-5>^f?v*|LG`Poc2W6eO*%Y1G=Fz=d|%}eG*bC0>*+-j~fmzZPC z!Dbi7AIB$0s3XYX>Bz&mLT3u?Ewr=H`1CA2O9(; z9lzpd{D|-I4T!JtDL%&gco%QuO}vg*@iJb-v$!9(aho#$45As031Ki&|+})ks$sP0S ze7AGkoZEz3xh3EhfSbEnZvNzM0Jy&E0j>+U4&d6Z<(jVUYJjV{3gF7FR6WIf^4WoI^N}z1f3Z*oht3j;+{|E!d2WSfBM+hqYOY)mW8PSeX@Bf#q0& z#aWp7n3uU2#$X1Lbo8SaJ?TNgfBeI5{J=MS#V35gOT54{+`(;J!&RKe863wk96>6Q zk%%}%;t&pCFLq-SHex;2VHuWSF&1GyW?}{=VVIT&eKl-3IdZ8z} zq79m(DH@{@8lpaGqPmEP2r8i>Dxe(7q9lr;5V9Zu{_ugi-|oA6=N`L9?!LR{uDT2E zfZOKQxYZ(Vnj0=2A|fIpA{wC)8le#yp%EIP|9@_)V%4mRh1ncyVL^Jx?&uv0u@Eye zD`)wvkkzm(_Q6Lin|0F^P17Vzu}L<`X6ZTAzsMsjLhtAutDui`nJ%*(I!fp0G##T; zbeJ`<0`rhfu_@NWJ_}Q9j}5astdDK6P4KJFfYsqGs3(u z!FJel_JlnVDugPbN=Su_kYQps?W7&IqoH1?5UPX% zE<0p9?0_AxcGkxB*erW5Gzb}?f)xx!?Tb(;6tS3w#bg!XZDdl?vMJT zKGQz2Z+r(^VaxmuTi`qRcD|Oc;g|S&{tmlo>ui^6Y?48JkU8HEu7 z(nXjD$}smUJtT+V0Xin^xsu=DA*CJ9ME(G?9sk61)4)vuHwN4wI6`N#fddq-o&0s} z5EPFQ$VYw(P>@0trU*qTMsZ3|l2VkW3<;8?h{zyKOj*iNo(fc?5|yb!Rko7p*1HXE zqub`TyIpRN+v^Uwqwc)B;4Zp*+=K2R_pp1!J?b8FkGm(`Q|@W^oO|BA;9haBy4T$6 z?rryx`^W?v3{bT z>Sy}3e(wwTVScz@>Ua4)KGUD{r~FlayMM?(>>u+_`e*zL{!Rap|H}X7|FxW!VX;-V z8dl5dk`ths2LSyaNCHW~0TGY}1gHR11PrJK)CaNvjer3_K4AE-Dh!NJMSzj2C@@MD z14gUjz!+5m7^_MG<5VeNyebV$P-TFLDgjJVNno-{0aH{2OjQ}cG?fOXs~DJ}$^tW0 zIbfD556o5-fH|rnFjrLq=BdiSd{qTlu%s$*0oL>L`g_jNR)zfgG7BuJ4iHuw1>n=$Pq}Kg{*?aImlW_oQJ#t ziHndGkhlr|5hU(_)q=#mkTsA@L32QI8{8|9+z$5|BzM5Q3dx;tuS0Sdv>POML%TzA zFSIWt?}Ai;jundSxL9#%cfd2~O6fA}~g5`ub4W9{d1^9;`t^|J-;wtdB_aQM0 zn&*gl(OgB$hqNZK2+|O-C{iI7N9u|7k@q7uKt7Pz5cwctBjkOEjgj{!HbFjs*b0N= zh^>)6Aa+N(f!JTWvjfm!*}))xPaKNe6Ne#xN*s<{i6f8;aU^m_9EChYoPo3paW>Nb z#CaH;O_j<|a@72*DyPt5SIYU63+7x^ zxfa-iax3gfc@XxZJPCVKo`roVZ@|8kw_rcYJFq|HTR4F7GaN|y4GyCG2M5!S(7_?} zQ_xR|L+NJ{98SLw96^5^97%s697TT*98LcK97F#U983Rv2glLBME^35r~gcFBK_BJ z5<`>1$qe1n!6^*g#?b9Jm7!k+r&AY%GpI{-a3*yb>asYCx})G6>Kz-h@l3w@`1z)zmxG;#%rm)EDQub5viZevHSc9rZK3 zPW^)VHQu9s3w%WVPG9y`&i8zp@v(WxFfzl8_>o~|hPf%@G0exXHf3^#bs09HEXuGM z!`_r-8TMs3fU+^eK@2BTHfK1M;cUvD4CgXjMmd1t3Wh5wM=@N(a4qE+hFckKqa4R@ zH^beO6Br&~cz|*ufvA*|h(J7dJi#8{NmiE)VWDd!UtENDv+ z6QW#9Ow^&CLQG6dO1XrXOla#7lcQ}&Oo4JaF(vAO#8e&16~wf}Qj{x+Wz?aqL@Z0J zKzW>4QK&}}D|M`g5i1jGQJyB&AvT}ORQI6>o;Zn;#K}TEhd2fGT;f!epNZ3S*<`0PhvgUI zOyVrcuf#dTxs>0D^N9;7|0XUH>g2@5D7O)pbS(cPt{|?W{GYgnxb{ZHRb0mwP!}hz zC+?z-K-@#zM;(WFhIorQKJgCmE_FKMBjPLS48%8TsdEzF5#JvRRq+ED;UZ#CT3vyI)bm|)9 z7;35Okz`?wrPC`yWU7ws%DA$lvb*P(=(~#3q zHzlW6hw?Ky135Ew3vw301mvu!+mN#@e{6v{%-PAgsN0hBkPA|GBo`(ZqwY>FNiH>q z-b5}#u0-9JTzMI@9!#!6u0=hBT!-9%dNjGAI%?!b7xf169--bs-rKP)PToh}Pra3VgnaxMW{iA>e3g1P`6l@m^-=N@P#-5h z)$Z&wP@f<_*L7%az97G%K1qH<{(<@o`4{qU)R)O$Kz)t;Rh#zT$ls~2lmB81F(LVH z@_*Df>F9;}E`0?0$kg}gqtQpFeoUX3J{9#-`n2>pj~nNx&re^7`Vaad^hK$iz6^a; z>QD65=urPlUz5HT^?&qr)Y2M#UHXGWsm z&(UV5Kd&*{9Q2pzZ_(zYze9hYwjlijb!dCjKc{~|Ta-QsZE^Y^(Uze1;rEDz=>0Ti zEKKh=dOczhdW~L3EJp87A(o)`-Z*J=09s1^>9!))lSf6^T z5F1jjYKe`g4^tmG9;@nOcqn31>Qf>%qdw;mn^Rw+zCvt4eN7^^rhX)18|tSbwxxb0 zVmsegia3;9 zL&Ra^x*g(hazk>Xy2SzVF!FHXT=Gcr7~*{L1U4`(CQl+yCN3dQCC?--BhMz!A+9Db zmWXS~%XIsqn-Fmwd533QPu@%3PuxI0L_U0`xKBPxK2F?9K8c7s$frHxPVy!4W#TUK z4Q*iDL%vOZK-^1yOnyQgmyV)ZQ7NzYbfi} zuBY8b*_d_*?M}+pw7Y5dQnsbtPkV^61MLyoQ`i-#_A=!F+N-qJDF@Ns zAyE#cy)VjPv=2SX;k1uwpHPmVeI`+kqJ1gK(X?+wIfnLwD96%%_9(~E{-OO#Ii9Yc zL^+YJh;kC$C{a$P8$*;+=*AP}RJut-IgM_z4J@bAO-VNu8BljlvA#Nx4Bljon zBoESnaW{Dgc{p(oc@%jJ@c?6J`Jjjw$cH`RMe;H7apEQNX^D7+d`ZNs#Yov(R$u~s2LB1v8P4b-q z<1O+7@B9N;$8AfiFlvlaW{FV5a{7WJ}BmZfL&&mH!;rWO! zsNtyLiLa;;=@8#gqf=v?7fEX@M0`(8DB=fdQjhqNnwpx1_=%cM1I91ZjMPlTuhe|h z0>tms!V*!`Vj?=Vl!!m66+GfEY87fV;%{nAz1#SYT9;an_@CO4+QX36k1Jq&E;j|H{BdMclBU8uY(MF?AqfV!d zPMs;y#-z^oXk$?qQ5Vz3rY_Z|pp8piPF+bGkGh7sjy55619c;966y}>PTFMDJ=DFl zDX0hJ(Wasvq8_GAO+6~lHZAoy^#pA?>KW?Ua|wOwdFn;lOw`MWHY@dNhc+Aa7WMAA z)jst(^(Ad?>U%_+kNU}@%}@PG{YG1W`a_~EM3qNdnEH$Qo3;pjpgh`Q^hO^-Tbw?u z#DKHt!wDzRhu5%Z1U%YO^pWTz)0U=>Dv!1-eGK}TwB_hyOSBc}6L_>0=~L0CrL9Dt zNusSrUr@Bw>C1|?27P(a)}*g2+FJD0L|dD_p=j&SHxq4L`VOM4N8e`y+xqnV=m*d? zn1`TrIFycnVi}ZMX?b|?~7s+ zl)e_eGL(K5#bzk|k75h@{ZQinLZO^K;9wFeLW;5|R3_FNg(}4Qpiq@qUlghlR~d!+ z#GQ{qbK<+A(1utEg|@^p3hhZzpwNL7Wl-o$ig74(C;kBxdJuOD3Vld135AJ7|DZ61 z*dZuPHMd&_DaNC)fmkCHb`d`ag}uZ-j>3M#dZBPQ(Z?tpLENh-oJri3D4a|DeJGqy z{0bB~GzWhkCP{9+W(B1IDv&nG$_#S08~A%?pM3v-u4TrU)_B<=|muO)gI#p{V5 zh2l-bUytH#q?m%@9R!c!oqE6gBt8gnlTdt!xaufALRu zXB3|z?l=^mCT<;y&lv7Sh@Xn$OGNune3|%~D852meH33M{y!AoBDx;McZh!*#gB-6 zkK)I~Pe$<*qJL5RloS(D{EWD(QT&_~?NR)KIFI5t#2<&^Z^S)|;_t+-LNODajp9C{ z-%d(xXvgYr}Z^SCqVpIlujhJ45gEZdj+Ml zNiiCwbBHg8(pB2dlXNx2FGA@$q8m}Vp19{xxyNS^HUnixY&goE*Z`D`*g%vEVuMgFLu?4j<%xd?l2%bazkPxP;NwQ6v~Z>jYPQ_v8pJyAhs6eHpF_O z+>zK`lsggYhjM3PYf$b&YzN9ciSe;{HJS1fru+ zKAE_8P(F>=I+RZ*ZV1Zf5O*`m=Mp;x<@1QU3FQmOdq|Q39=@Jnejo7g%>=6gJbVj* z{14#aI|$}$0T16pFunnJ_+A43GvML-2*#TL58qEPZNS415ugA({0M>kE8yYB2-Z!2 zho2yr0`Tzj1ZxO*_(g)T33&L21fvgl_%{UdBf!JIBbW`q!+#){p9MVpCjx#4@bF6n z@_4|*uMqGxfQR29n2!NG{4T+KG~nS63DyeW;V%g0>jCdN(7rOc>r0s1eHjaLU%`y~ zDrVf*AQ&$KyzA>`+&3`ezKI$4EzG!YWAWT~PW7UQi8b$cyxkbjsTC&5X=tX(OH5u2Y7UjVATPS&J&Csz@z&RjAsHK-AJ&u0FQ1W zSOMVCg9+AIz@y(J7=HtJbcFz~1Uz~j!Tf)~qjwO@uLB-^j9`udk3LB-Zv{O1TLO6% z;L!oWd?Db`mn<*b*CCiwz@u*w$YTJHzD+RR3V8Hgf^h}#=z9e6JiwzL5Xch&kA6rn zJ;0+M6Yv{=M?WEuZNQ@=g7NQwN53GL-vd1QB?0aQcr+szzXbgGZ3N?Xz@I;hK(+vX zekZ~FJHVg6j$j&qKmP>5_!i*L|4^?F{Stx&a6H8^PQd^Q9w_Jn>|q`x{hU@wK&v#V zx@A?A)sjwh64=zHOIe=f!RWyc&OiF#2aO-}p#Dc6^x)aoQOF3}kraqRMze!R3f#VY zdG^!o%a^B@kzbx(zU)U)hW8^^f|I-VoTTHsj}PEFoInU|fN+o(S=ym?97KukZ&u|n zFv_aTv)rb3qLUG0QZi1KS&|ESDi)-dqrAg z`QI=b#}Zu?*yFMdEP63cMpahBK~?rAk}+anPf&*#o+~$ciwkI&#G5M&)X7|=CAaOM zR*&Yk{uF~4*5_kxnU>8x5yY`yH0p#HhFdQTJvSF=A*dCG^(bLX;*>GDjE>>CQcy}9 zD!8vKn<|c$<8V3OXg9;qb&Ms+7-KRuC}lS1f}55_ZQHOtujX4eRUX<76>`4OUI;_i zF;dAGoOr(yaE#+R+jkuR%V9Cdv$R9ej_Yj(X_jZyrdiUA9@*by%oW0&P3`k>bQiYT z?H0Z?gHQH)R|uD}+4MV$BeopYYT@$Biep&p_OgqP4{(eV*ezcxn>w`>mSoEYK3VOq zRplPqXRES>Mddw}j9u~sr_?a`Ezk4}!*;mvE|p95={)p8w{XLtbP${{Chs^$4TF1v zJLfM@F1TkH_PN`e!0_#7zqiv1jUG1)dhU+AT?4^C{vmz{ufPca>t;*%4N24bUeqKXiuGXC6c9~+GmZ1Kk)ZGVo*nLermLr>HfamXN2RBF?Zw$&OKt#*6e>W$-Y`} z|8m=nZ~`X*!j8gbT^kPeFbfKHZ!aJy z{}^IMgSv-m4#LCsDSMT5(hN1t3xlSiZa9cWT0fd99D0i?g)~^Gp zP18=EZD1#oO>_%&(l4v+DWqNTIs;?!hcTn-#d`#&N(HbdEZtP zAx2T_36W>tC>a|wJ_&}gx)t+mdppZ9@S7&*oLZ*I&3Y0|x@Q=xTN#sWEc+(d00_&x zXx+EZu&bzb4qkQ>)9Uk!52J*LbJ4oW6!=U~(>Cyx(28E+As+m>na*$;iCE=D=X z#hQJPFPEb$Ywwcb>*nU*P^Y$J*Qb{<@%H$ZpNrhsnyU+Ihw0UC?C@&kGO~o1=Sa0G zU;5eMt9f>o6F=PJ>O<}9?n|)q)l;EOqd1CtMXzXK9kM*jt0ba@JbEnPxb_74AFhx>J}oBW|=v=wr<_?JM%h4 zi=JPWX&-UDUhdjo(ut)nYFYOdoPIGl5C81|q1MaB9+%J*kuZWuNdoa05t zBp7+|W-T}k4+Ow0FS0zfhlf#{+Wm|c(sK^-EHA5~beU&kS@nBGudvJ;_O`HBq`h%g zBZNt|4tu!VE!$;x88Mxc5|_K>sO&DIlyfPL{YI^ZSgSQgjtRfrek~EM#TdqomN2I_u4>am-RXPVyoxtL`U>;@S~R zx(+!i+_?vySv}eP{)Wc#os0|5MRZ081$)iJT3L%R>g4-eM8>%A?%4;w2LOCN zJ|F4;sA*wSfurUB$-JB8Wz{Vco#1mV)8wjzqMY;DG(Ze+#5t$(93bq_!j7Xv_m?)SayZDdbSdu8!awS@%@p_B=&G}c zUo%dIo2Ea(er+4?9@~}7;sZBe|6~k~|U$|KPY7475?$)*5$pz)tX4}=~i*x=` zh27QcgbVXM%(ex>Y19@Lmdt8v8Fd@8>G440lmJfQo-ID0ciA zyE~Y7+r78ZQ-wsA79?cKR?p~`ka5S4@+GTv6B2J(RAxVzs$D15@ zdAktA2g(QXy=rH5l|OgxFLou864}*PKUg1YJaE@VWG)x(+8*w6h#D@ZS4rdfq)Z;T zwM`WCjY-9Xa9u>Uf;?~T1VGo?RtVZGF9FX|6y;uaamiz>IEFiqf-s$`D;`Hv;#pLs zdAcTwCp}qVho_Vr*yJh(x`_$M4Jpbh2=~UvV^m7>%#DR(J_xLbJ#jNBbR00WXn+rG zajsETWfcsnvX5=an0$ja1A-P3)ygqR3r%B4<8+kE?tv1|Z&5_TIZs!pjN~_|uwt&u zh5Ncwb9$3AFLAi=JYqP%`&;;=Enagc8=o#SHoN=QMEfV7n|cOu94@@S{ylsWx5p9J z<25aD5T=^#rz&U zmT!N%)Oxe-@)9=)+?S}Sm&P|)wY%@GSx&N87NGtMDgR>j?Vg7dM~7Xc^=l;utKF`SW8LkproQIrjym=R(`%y(_B~W=G`r~_)7OG8Q$kI z^gKguAC`!29y%1MlSm%%_zmf85a<3Y^*JOfCw4Dgx^$_NXhfZKd`5X{56dszmVd*o z!E$%)F3f&K$j^$e@-H9yCq#q>y7zjP6}IYR~N#$dSsadMF8PD@dRavQ5cD> zcAfvu?Y7*mE_ku#g4%}ZdBTk%*RrK@_5=4u8LfDo` z4790D9Qo-$%NivKN6T!vtDv@F7$#}893?9^=D@8G`j9}2hZs7J5E|G{ADK+tMTrnu zp?v$5Zd^O`3OPTq)N@?jmW&}tww4M6TdQ9xAAs@@yM$oAc;K5x$M`0mHB=5aP;u{r z8aB;xBdf|P;+)$rd=W_Av*FF{=x1M&BlO)bfPgB^xu4S25m`>>Z?&oODu9%i3@QtQriP?YQ zvPRUi|G`XHmT7TM%*HW^F`0cUCNYkM$1T7d1&rWCdHm~cb_>ZuxTG64u)^D%fXy0$ z-F`b0!u1fHKllTKp!b!y*Z<%T@X1!8=Ud+5Av#P5@4fHa{$p=_tNZru?)%<59%CwT zF^ar5u9o>2J8V0GbJrlwYgdZzYaL>^oD1jjWqg@y5XTXm|NAT5$hY^(f9G5{4l&%% zeGdA{#etTIj25a0WEMvebNK8#zie^wY#K3#WBkjY(A7cc&pz%uGeM~EUX00qkYo)Y zfMe*u4FF-BmCNXLUOgBX&^#jS+)N5pY{e0&+0e$!Y=p6kyrK>g#R8*YvAv5l&$Zwg9?p zr<^9It%YG425of9Wb{}DZFwd+^mXnbw5dz=zd=8hHZ+Eb2)eaLE2|G zdf{gxDX<+gs{Y{`R3n8KS2w#`6Qb`k7!aa0!B%T9IOAxZ^xxVhx?8VfiInWc zrErlP;rkblA+Fqx8I>bf2zMkMC&r5>_A_sv_u~ba^lP;c`}1$VgzZ-EZHT1X-#xKH z$jXV`ewX0ed#yIk_EE@aiT4$v>hq;Y3LGKd+GlovVd#$-8~I^~{|eHd(e2|YY0J>V zwOP#4C7bHwkEMN^=?Sk&SuD~eeRJPG|I|N^Ac#)S=w7`I;NS(qKzpBpEjSDJh6lo9 z;Wr~Fr9cPPw(3MDCyJ1lw-n#J+_0>Y4jq=2PU0+05@BBL<0DxF~M)R3ME zGB-1*%6@O^y9IQywYu6`q<`t0$G&PK6f&BP+6n`OYTt2CDRi8Rs1&N;l0sApQC+&+ zLE-El*5|aITYs09yZfqefUxYE-4zGc?V2UU4GyrDm zp^0aZXVh-twa9%HdW9X^G>Yvaild^5TiEGuCPf~_)K2ss>g2U;v%gs-n#(v}<=yvm zUX^*KFw3hlH}Yb$ultoE5WJqYX_kkbJhG~6{C(^d<*=*>G821d~ z+K%s-(nA9gEi-N^W!S#&#ipTr+d?uIhRA-{s3g>PEaf;0#P*dzY`Z3?5U$HSRK7ud zpGT&l{;=nG!uOYw9<4U@qGp$TL>F-sp+2=0V=sR}AVs~Sf8}_>?~MoZYatoKFB(dr zVHiSLj_UmtSc;2jZ#_UIk7e}Id8!?={vq?FqU(ogbwPn_n@h`M)SpV{l z>xy%6br&N3fs(qw1UUXfJl=oJ7sH+KS~k*)z31Dm_MM92UJ(~@(dp}?$n9@|-VRS_ zSXRR=V(z_3ERvkeQyiH0+kZVND}1LJX~$Hm7T9haQERC|Wf-_lBa$_r`^qpRb7R*I zIa&mf*mNOB6`76}LEALr*fG0JHwdKkq=7`lE{!~y5=Td->GDWHu`&$PE77vJY&vmVbEnzIENiu)I9H7; z?~Xx@Xu=B?G~g<@01yUDyoA(DE3&kKq~LNiNo}f={!+iJvOGO%43~z--W%vr447}^ zVb5$z;W$z>Z{pM;%k#(JZ=mA{>2LC=hp@OqQTO)UGe9Au)_ut}KbrWWrzm9@N{J5T zmi3U)=<|jl9OuAJz=Iuq=~5&GP90s)<2vmloWcU!lhJu8OlPRha9s`0{p(m(MdW9J z!ZgdvYKQODSB;K(?um%ryG=Itu-h*>MXxxxN)w{5BD$wZ^74357{qmbU;gH83Q_qF z16idBmZVW=j16Hd{Ct4u(N3WdzwTWyevA=>Fy&DTjj% zV@Zs$)6NFThJ!uA_6EQlHG2L+OuSeQ~r>#w)-k3GlfN#Zy}zO0Q0IXg@llmDO))rWYHy82KYLr>uS{9`9-F}KxnGsL|o zbKe)-+a`#J{t4&t50bIjr(XrVvfRJF<1_3zPH%RwX99r6)9-iyDfHms@I?3x_!I!9 zT=_{7X44gw0icf?412|JP?dXFrW=t$n-q4IJX5%Z=!ELV;(J$Rp3#KbbO(!m*x(hs zHUPFr1dH?Yr%nrbiUl#h4|SP!HLFG9 z{OVlz;{n&^VnMLUYLVDzplOMA!fKJ&qk{9(xz?l+1W5A!hcqGjp&p|598HLRj)&;I zUK65Uzx{%DJnWQb7!+N&y^kJWkzlpknPt0AjybFS z`aO#Y@v2>Wl17AVM%=B9Hkh*#306xy!=T;zN`A@_g2r)^v1V4gw&yJ$Zc@QH#<1TQ zr?3h40tho|C+OC8$~KVZRhejoxdD=a;ws6I3VF3iejzUIHd8i6Q$&5gSp+qwK zotnq-v)m8WtjGOOJt+{+uEnRRD_sArC}cE_q(HKv;_KtH)Sr9wd&iL!c%wvA%u^Ck zA!3&Z5dzp_ezWB;glpjG6R6CS;nlM*F9=1yGzlu=53!rle%k>h64vne^Fk`Va}BgU^J72%((7)NmG`)- z*Az~1ZT}zsD3wRjd^x}Xu7Uf)1L2YIBzPt~2cYT%ko1FM3yY9lMNDm~Tj+<|(%7bH z0m&h;j4?SvSe%(u%ZiGxxPe=VggRr>ET_kLKxFu#oV&iNq`^Q;qma=> zqE{x8G4xL%ng@L;jNtfy>bHwSNAs;7ZKOjFGT*3tB{MfG-?r{O$>M9 zXu_Dhz8>w~TlMwi67n_=-1@+~9+=a&ZWWl?zGIxiDqIKmg9pQ-0lIX}Z>1J)7Mq1m zQcdj~v#M<2GpKjV%6blc$S-Y4F+UgZcM_*-!deZi9I~Gy$)^BGv z!g<^=YDmJXS9wR^CGvzZ>Cxb%MDOymI)>LFs)d16KF5Dhq0d*=1p5~4$88sPqfv{9 z*=doVGE&sN6)8LmD;n=Z3TNT|0M(#MnBrpSsGb?pnwz9HP5MQjMv`Ljjkl@2io>$% z6=lD2g@@bhf9>(i>bUzW3&ZmK6#M~X_zz3TRe$D2uUClbW$te&MD={6eh}Y@N`yT* z4Lc8(T5p!YWOy^zzGFL0CDC*RYLlLFFr<-~Nbqg8m$)!H*@kQN=jxk=U-k*Q^ zVh(J__U&u11=j=gMTO-C!8=^V?`?UOX4GOFsJ`Evvs6}PmG`?PTw;l(s38R~KA*G& zV~Bic?8iRI^*nEO>@p_Xv@CUoyq44M^=P?-I_ZoG){|7jq(zB2dl<(3VVZOH5ypFcPFi7= z&?=(-@V~J8Dfh}^4O&l45d6u@(`k>dQfR9riu0*ZwE_CWOk$j2{6J?3sGpqu=anl_ zb8Uhu0rONZSzwRlVhV1CSHXY7SK)hk%3qVE9SX*=q8TLD;G2m~q9#_?-Pyn_*HxbQ zu49ISVcG8$B_*S^lXvpLa8Q>c&0=608pDNrXjLK4G)oXE3RS6jgcr0?JJE`eOvB;e znejG(1 zkJDxpq6`AA)J=<0{^(RH&{6vtHeO$+@JOg`^>a9Xj%4gl1QIsaj6!S%i>*9cTU#T9 zX7xH6HK%R_TFWSu1fdJY@v(lMsKQ8o`6W7ru*f0a@P(Rz^?F7LSzBAn^44OofEY&2 zIYv;(FuFUWXMc8v$|rT_y*A~+?SohlA5Y1Xa0(pOVH+L{x3Q=VFiDz<4qKreM==(3 z5Dhum?iom=$Q>wJ?hwb1smkGC18LkJwst9hJs;2YIJIqm@RH%X(sP0kVsO3cz_)Gs_{_aar@OZA+gU3N zO+wVnvNA;?=9VHg_(K$HwUp5iA>cuQdX)-7tTAyjP5de(4T)Qej?b6Tfo8nA4RLcg zN--{1tAbFimQR1()WW7)@H;KRUUS%ekI!3hQ`f|v>3JusD60{Q#|P}9-uQ}GOThFPiCeI z%?@~=Sso>{PpVo}Nk{R<6qlka)^rcR<6l} zcCdqOJO7b;LLXy4oX2vl4W{Ezs?(MW^}q?grzT*Ug@dx=0=Q23pkSKt)T#LD9N*8v zj!$X{HifTegVXP6Uio^0ZAX)2S<)Q)^Wfhsa>L-oLb=Qd;pK7xiO9J88*u5FHE&1v zqt{f%RUP^8Jljvn3}o9Zl;TJ#yki;u;3seU(97guyioa^HR1 zY4mKgT!n5y2$(s{fYHe}bSbN-ut~^q%?~^uYE?c3;Kb)_aE7(OZ3MzNT#$!%&%iKq zJs)GgN1-*(KVXMdc7fuvb8@Bu*=(M1iNPwi^HRYxo}T4$rh(mz&|K7C$WcV+7{kov zX7gFdOcwkLg?z^!nnxXU6M9%wVzbq@WS3mH)%-0&yp8xwMn5mD!6*Gvi@2pjkGX{$TE}-0+uQLk(0FKvm&!CIGc95%h*{aNse+OE$)KW@9fFK?7QSSDmk| z?Vj7*yj3OZ=gefokCgf`9lsCU@W)@U(y}^M0n~-RTQmliNNrrlz>NUA=90>>ZDVYk zVX!1gSOb(RwN7A$YN*qLZXj<9hmjW#BQLI5`T22;7!cDWo}&#Z8b(BZOuCU_j4|G5 znoY6}oHP}cT*(Xb#|vDJ@QwEwL{&^E&Z;ur0m;M!6JKHHTQA(ZitDOks_eBcfUA|h za*fYaQ&DHrDK%r$k!0cZ{~LDD5S=b=fx`*CxaliO{eV&4>_RiDA%{&r8Kkii&KZ)Z z9`~E^a89oc(l`PJU-h$u$jr8WMV0fdb}rv0WYILd4cVOklqm;mrwOt6L!xEXdX^*0 zgmmU?bBzyw2PD0P$E~*lfOPLqP*wscl>p>Mqj9UQFD!iz$o17iLHg;pO91dz*7KG-VWni61~6t9j(kJM>tNeOJr{ZHxjX0(I*o3^fxTqV3!*ypJwjs< z7%P5Y0?}bD6K`8{S6T`IR8*ges z=>mOiq@yj!DP%mlAswamk=`;um`-FJ8Jgkf09F#dwkd?wirf`5i^+LiZ}Lm8tTlB# zFEgg-n!?IqRXF3HJ}k?Mt|^N0;aL4SYJ5()QVoT@;*f}8L^;WEgOjg@!pm`gCVBCh znU~aS37~Bs{|z&A1$t_65@c}FdSZ1N2czbOUoDr$LF37#(>eQj@NsM!MRk-d0*wD! z(W^1f=)}9`mks|xndfc;Zn#QaemZFPTg(&xX28OkUm~D$my+}!eosG9KKL+a{QoXZ zEWLXd-HaYazkptOhacs=lLjhXuwzJy$cM<2IQz+GCfC}CZOVuw9U)aEPpKgeN)~s1 zY+wBmll-7>(;wdty!E!ue2fF%wl90nejKkE>x3nBc1bP_SPu-$h!hFn4z=i zZuBaI(%271DJ3>Z%W4;zTS7W=)a&;s87Xpby?AWc>)X>aW$YTThQc7_ql>eyk&Xf> z^mAe5R<+fA;pxwfMj-*}F*+5U^V zR)1=ZsooQb`zp89+iNrN)@!DjxNifF#i?nl>DQPqr=EP^=Q=Yi#O z2`3BPZXuNsyA`1<8-^^)%rIp}scBNmWYb{sB``wS$Jm!6?q7<}e5S7HFZ#CJ&Tj*B z?!G9~KQ=Ko=Wo&{>+{X0rOm12SKQ+HkcCf)!-=zVcR!6dz=k zwEa-gb!C#D@15Qn7Ue2TzPNFQvoALm3Ut%(MaCxWTDanmccSpeSFEfunZGnrXCAB| zZSg~?k1%%zQ*;8IMpvscqzyT&G#!kUYA{T)bo}A6L6inG38Hk)JWt-a^+^kQ@@uV} zr|MAfb8{9GeN%V3^%Ty+@YW~veO>Fp`}yOQzJF zM$V=3cQVGqUM~xT%w)Y@7Dh!>=tA!ms;c?SqW^@gX(AfqA*}W)`MgnT^r~SLa%M6f zkH=XAnQVl($FycJL#NS=d7=Wn+E9jsET>$yjRDgH-er`I2AlP`xh1L+c*DCB6f(AW zLFA3e_KwN?$oB!Z&HlE4Wm%P^*Sq<*J^u$T%bF;BjWOOY)RxgkJ_%y8bTbLlVeC?Q z`RAVZ{1wsxg-fUv%}r{^eya9d>>&$ZA9C!RA6BaQ4ZiWq{?SZs6s27gP+CX}ap> z^RB9Ei~?2{y4hl}08l6{k`8x?^knt)>D5G!#=S0bk?ESMVk{fL%ZW{nx0q&4!!+sIkwBH7$=Q%=+jHsj1zrnNC~ z$2oCWMjUzCp_hsbNVcIK1;f>tCh;gQ%5OV8*@j38Ozb~|*-h~5ZSbrhWc>US-@9$Z z?f-qzwt>L2Ct)^6SNDEXcov?WWPx>l0?)#;9Gxk6c6tDp1qZp^Gc%Z?H)o0w5Y%)j ziAW?4nG+N|#?}xRbYu|tp6}&EgKhY$N3PPY>1sMkMpf`PVT2Jes^P`m*5P{G95!)q z)r8CcvDJlU^a};c0_I@A7_d+^ZK+^cV1p!q4VG1qY%|0d0|sm>U|EHN4TOM`U-l1R z@a#{@{uEY)$#b#8z_JQ?=Y4IMu2{^n$PFq~g&&SpC*6?%VUxt^nlnUqqlXcaa5&h< zmB0_~<>9ayb*2zP;70aKo)hu&8c}^j7eLJsfgBl^K(ZvH0SiMR?RH;6Ap@OjyRy8i zaUHY(Yrd|hJRYO#J}fjwX0FDgH~-Nh*WWUz#CE8v(j6vIqXo8I{0wuQytc;0@{Y?s zGioeA3wW6;w6_wK*!m(ODcPVUI5*Um%R4TM`<2|d0namT7mILe z%9*F2SZvQP$*z?0P0nLhI{%2_8zJzi0km>tpw0NQK#9&(MO2Tr7s0lx_Q*pz=X8ea9mFceGShU`v`24IN(4RaJc3W7$ZOlS_?e;ndivRY@rQsxQ(q)33S$P>uctXsWI;fI};| zmY#?U54>D$HOdw1@Bb6T|1A*hs+lQF(G?8xv~Q)Q2p=^QG|KcAjM4$*sz&0qm8ec_ zK)`R1Osj7%DLQlVi6~p=w#C(M278n!nx@NCz^dXGZA;cTW1T_)goxf`Ee4<{y3SIy zwU;zz9u_GTu&QagObkjH<0-q|ZN>vWg34k{IlVky|;N&SJi1rbbo)p`2{uM=L$osd*CJh91m%#}yYQg8<8r z0Xgug+L0I}pyo=W5|BgOluR1VDyDwS;6>FbslwYcDoDyikp!Bl>IuI{I$WFPlQE4# z?V=8$%L5%xI_8YcMrJ}n#KxhjKFK!_zLHUefuhh%QC{zf*=!1Zf$GUgXSwFM%!J@E z@oS3m3>?2|&0vNC)Q=xiQ3%o7jcazHxdm}El?E!m6gMViOjdB#0dng^bbkOuH4hi^ zs$x966~|x;IMcsqDDd-ft>!w8TdT!Gcebf0s@iC%s-i3`d=25HyX5=gyjAaZkF*ys z^c3@&11I=h$Enww&3fH&e)V~H9kgy!>&Rcfc5Kki3bbtZI z#?Ca_86j-Wo|(U&HK{H-TmDqnO8}!hh8_ydFVWX^&QOB3(D7|df)vmn?PI_Oekj;= z&pQ|Oh?SUkiS521pV>^7Fm*NHanzb{r#R!?)rZeuy7D7vP! z)rd3pHl(2AN4M4UITnK=45*o)4Rk^q{49iewK~;$q8Mgz6HdLxHY9@~p#J-GU`V4h zp(KiHevlDeAEnqd0vTVvdc|Z+XbFs&;~XuVcQ_}-s;>CQW*KMx!ENS)9+v(cCR!MH z6vA^ckJpDWzQ-8@RWqlCu0e2KQLHm8V2tl_#^fJmo6?1N9s9fLH~Z$>Qjmvs(RuV3 zLLeRjNkNK+X&PTOkWk85F=DTYh8jTVD6Z{TK}qmqxwQaxx2)x!kg7U+F#CSXdV|c^ zW%bfMuhAck+V^s9SwmH2>~_vA>vmOT>~)-5*6YR}3yY0*!*w}cv_)2F$n=jTxFAQ= z1qSWujH>VR#+<)_ilS=JzZM_wG!}v~rSZL%#rcpiRlVJ^IKQ1SRehahasImTw>|%$ z50%Bm-rZodmT!eWVtP-d9}UhGUgP{P2Q8y51*tOv$)|CGuckZ~#*ve&NSz5tgM-_; zuBV_NAtjld)b_tpc*qtMsVAx~plI|ndZz|NARw}7AJf@FD4``x1Z~2szBAK|n;opk zR?N(9rMku`eAls{)M_gqP=4F#z#=r*%{wmp*euPDG1tjw&xw;Kex@noppe=jFLfcJ!O6ENdsow z*o^9BfJlEy9;!4xBzkmh&)MgU$+9fB=Oq?PB}JgJuB(cmMr~}&(XoPIp)tY}IsVvb zbum<`12nE$1E}6}xxO1MtmvvD2$b^Wvg%snPJlZXK6JdXJyLk2N9Qv+41i zby4N1Vi`DZz8^j`gpKvkhp8GcO#HshwoZs3s+t~{@uh=zP0>%DP#67(Dyo{U6C%(T zx2N>+c*1D?ty+uO^?RDlyh^32RgQa|Zr<5avs>3J&KMMmsz|lPVgbY?FdWb(P;PiloFx&umTy|yw{;NYBVwF^Rc0u$6iL$#?r?m-CZ29S<`MNL-04_sc5voF!X z^)56cA#TRaEeOa=H|QuGs>DTvHtcQi0dz0`+Krg<)dAUJy^$|7b0=pS0J(hok>T;< z!$;cr9Drfwc1%{zHS3FCE$0mbXbU3va5;h&1>i=0{uwY140xc;k8{8#-ZKE3yau97Ok8#I7+4qB})Y9KPOe8M~ zXK%{xOzQ-eIH|g zzE|!iH3rJXiq&)_NT>|6>AKW(y~BIU)Xll)e2o1|Nxd$~u+4R=mty&U^9XIA!$>-a zHKoA@v&?AY{_zgPPU3YT8V0ZDz7IErHSWD@{AJdE;tPiHkU`M>&pcGFF$Heeu}OHl`Hv3LPV2-!Xcb!j-TD#{SsZYT;eL?}QO4Qm4$ z79~s|bP6`gcf%-IadlnST}t&6Cm(s_s&v}SnXN0 z4RDW&meG(?Pks)&rNc>C{yKNW!ZqF%b#FFB!wjOC%NZMQb~>7{6$cWuPG@uSmtPr& zxt#iyuc*0PIF5YzkrvoZMOpl8LgOpFehlYSNFdD$=z=Cldk~0^2us< z_mgow_Ai-dM1)q#JF+5ZJp|$+qB^IVJMvHnITs)PZb#P9~K9GTfbXxYN-mnYAuUB3|E3 zFT5LJ2>PNdb#~XWD9P&?&wT479Vc4a+)Dl~D-fyI+XNW940GUbd-87AJ?hJr8crx{{q)Hvpm^d9VY zDEf#q-u&M=+Vm9C;}5Q}&~O-k5l`R(lF^Om4)g#sS*j|e`iW~B+`!LPQwgf0J%}8r zg=vefHcFSR)rJj;1}%s-NZNZ&7^7lPU==`S%cIU06*=$(S?tq^_x= zK#2aXPuG{NE-Z+kFs?J1j7C?FIb*@RsrI|{7jCC65TfgvD#qoX*ZXm(*N3vsWMB%H zuTB!o7=Mw@KBQLO)rb)hh?sJ-j26-He#*e<>q(HL{~LI{T`g>|A<(QPHopi(a@hw2 z8ILwI^T5o^@^@S3*>pMYCbss+1RzlOWyN0##t?oQhT(Urr<9#%-t@gx8)V^oA2R@q z$F!QwQ>tlkSruw(H&;-Cu8d~qP!&9da}XN>@%mieLJG`^uTfN|3}~DN(lTpem~cD) z+?1py$C8Bk&@Nx&JmRinz#BhZ?fk9q#Rqc_=H`(suX%zj3*I`V>+dICgYlHy53l;W zwJAx>`P6V6BjOz1XgDs9IG?{2)*j3~n5(}%Ls?$)E~9rl|F@=~hxI=ZOn@d`o99-6 zHX}anVeSKp+mOukw7SrY(+?zC7f^}a?k>Rp=G?N*J-Dgg(NwiuR;p4})l{`qQYuoY zs+w9VDJPi1FXe<*j++&gF}1!_)yvJetTCq5mqM*Fd_%Il!ntK_hE+x0;M}rSB&xzk zEsOJWAKnX`F|8ao%W7q@rZT2hn(@1LoUgtA39VF8HC3&Np;juXs`l&mmCJv0nXFhA z=Z7e()i&OyR%D{(X@>CCJmTmWx*e~w)Fg;ef|9_6Q5Adk5(JvSpbgs)F$G9QrJlyZ zKoiHV>j<&GO$lTfy;F}dxlOpcT)Vk+i!X_0^X`D_Lb+1*l#n~A*Od7}@$&L<%SacO z%Q}w0e#q+eIW0(48Dmw|bh%ozJ8IxN9k9&cy_`}(P*nYyO1<0+>P9~9$en+wx?~n% zG3pxiIMvUA9InDWdc!7o>8gG+2uQJ_oI%fQShs3e7JIJ+t|%B}S(gjdv6>+ii|xXE zQ&23nV{%-f#EQ7KLJ%}$n2MUVD+&gwSiOay0`Zx6G`cP%ql?msTnTEAYO_!vfRKjx zXiE9A{{}jNcG0cqe)K4M19~U=AZIoXk4e)(DvRL_cq2svBtEyq%{T>Zf(XAE>cZkH z6--IRgDn@ZZ~FLw&i&ee{r#`+-kL0{@GVuAp3OFgA^fwCICGw``B8#&WBZ5mSuI+Zachyae+JS_UL+JM;JX)aca5avC4CG-%C_9Dm+;pcFxShT@wc}%{LU(;5w@mz#OW~sHN~G?4 zz!*1MCzkM$MqqZe&z`@T=2BTL4*nMIiYW+|U98bUej&7$MOirSE}V?w8OuJWu?00} zy5#D~AQ^}|z)>MZDi0UUE+vD+n3GQ9L~g6Dsg|kTGYi6;rE0X+Z(2>Yay5(ro0h5p zgj!pBoxreXshXx*xzMGT8+V2&ic_?Y5ANFg1u%U&Zf-*y^zsdDPGj@)E-QrXdw-QE zHy)ylX-{a{wawQXJYrMx{z?gK{Hn%IU9T~j-mdA#P_dCIszIW{C{nVm6N+YlWIYeJ z^`q(@DhlqcD;aL9P%@i0KL8^%pa+-A!6aJ^^euOH0s2VTs<;-6diQC+VwPDQLe$YR zqZcCl3oAK$KnyR{mY6L=OErQxo$DrPz5%J5r^YWSV#YM(vPeMEH1#sbEX24hQB2?!#HgzCDgih>{lv%L{iaycEqT?8oS0!@{v1gcIXksFLj zSd=6TAWB5nRZRp85+Rt`&T2XD>t3^5$a7tmiKbH`>3;~SP9#y|j7fqh5kibW77#)N zq00w+_wE{6K|_?GGd{c~)m1^ODksB;p_f4VGmal5L;e2NV3fv$dH~4o)c0H(2$7cz zf;2Kf(v;H2n+syMdtxp3i}^D;g#C&hfA{Q7vA>gfisXtmo}}%YB$^t?onRw9%vKy}7$ z?^Vj6h03-z@WIorLr>pUJ$2s6|weGs$dkYyQy@CH0%oyXgC`(nu zuSSx=FoMYNc)8pV<8oQ^dZ5D&(GB@EE6g@k;M^1EJlrZ*bU#UD@kn7B#a&L}p$@80 zj(ElWkwyHIQ&t@|W<4@WN9k1IP7cJ4UJdrX9n!fA>2>A#bbM(%`z+rlrWYVcVgybs z&R4C8uB%gCj6A=L$q-ClFOJd|Yw}#4FpQE>I*NBNsLiDkErd$sC7!RB@WWp4#T82; z>PgEwsS?RrdGXo;hipN1J?H|ar=|q+6tc^mqFB)3`sXFv_igEUKI%@uxptEClN#r* z4M1M2&m+5xGQ+Zr2|E{U8w|@bGGdw}ZifB7JrU*6afBMfnpg97BdG0~C$x#28gAmf zb~O<$fYWC42~0NL$bzGy;G#3Rz`nU(4}54g$rsQBV>isQY{TqlG$7lj zztlwq838ryt5z6pf)^zraW3!0{8BiX;=47qacN4)3<Oq1PiMjgSChdBeIb_yH4_UyKA* z67bTVk5j_6pUqN?>YxmeO|eWl5S&Sxj+N;k1M;l|J_U4gV`HISU)b0?8Q4`}o4`jwbaEPjiR5a$_CluU;x92z!^c*K5-whQ-RYNTX=!Q3!&=EAwe=+op zMn=OBT4tmtqdbSoi>YFaSP1$^Ner!*@ye!diT%_EY64ZrHPR&ju)`cKwWH z0IKjw35vzGsL8S{Yht@tgsOa#jS%CxFS=bL?|&6HzE7H0JP{8gtocw4ZK61WpV-(U z^dhnipQaQItH5JMEjcPjLuzlNBB+L)=w+si@yXgk1g=|`q@>u#olGV@HuDA(43|ba zH@Ke1PdH<-xw3qCxzWg7e9sUj*<>=NGw1HEMH$|aagL~*YW4J4gq)gs8D-=_e~k|E zaiqxmQ{2MYs4B9~hN#Cx)dP(QDYm#<13pphcB=+ch&G$LQkHG_*6g#t&*0@(Gs3JQ z5q;VPP!*tx6~z#0Q?C_i8xE(RyLZEh$r$hTGWd-c-JuC*%;HERyDNGMy+q6M8q=<*VnC2d zX1@jqPQR=A!K|8Q-RwrWf=PRkZ{2^n9XQRk}`}%)_Ep#J#4SEwoPtHsc74C`hY*vxWH6t2+r|^Xr^dNJjy@WT%?f#*&>y+!pduVTzrjRf@8ssx3uP)$*3ARspH)mR3bV3#+9~nSBw8#WtZWj|(8A zq!N8C-`FKa#rE4T)KS$PMQN$3qLdCPNN(;^heX> zsQ{^G+UZrAWP>4V3$8$m+2rxympaWsnw+1hS@6HF#n`vy?iM}Ra??GoB9CHq#htF+ zX*VFHXWW@KL}`QuShRw;mjbuQSY$puIYQI5rA8nYFd?B}ttV@k76?J-uH`Udc?CI% z8kVa}a|HE~Z$91vMrf}2_!rvdpV?(>H**Sw$C9V;SRt(gK*&=2*aN{1`ZTPaOVjW` zH86G$|CbxE2bY7mHR{h_(%a2XFJC0QeDScyvUeV#bLb)TCWIP;v=3_MD(ZDN@Dp?k z;>aM}31}SXrHZkPuP|X;XP*MX!!N%*83leal!!`|y}p_y42Blxe3H)7zZG`WS&Tf# zFX@yDf~x9GZ)wo4GD=ysKUj8~x~dxtq9~dIq4OA?lJ12p#__RyXG6}TDz*Ci}{3F$^_*Q4ib)F7MNLOnM~B91>XnGDhq zqYsv;B&~<2(om5%Ku>cCB4t?3PfCP%X||wNuw2(FSH47qNMV&?a|r>J9^1)pI9a8@ zL0w(E>#CZ9Q*b|UG|hE2FmwrHMY|vSJfBiiZ>%d)KqA@v?G>Y@ujir$3Y zgI+*Ci{PK4)%}zk_=(tJQwOABd6q6njS#)F)exweWWT{0n_36?c|ju+ujRVDkbJA! z_I|hKN01Rm98+uTp6Lyt?NPnG>H6juzho{PWdtsKv0uOkPZ95Tv#t28$Nv+M8Ott!o#4COlhvKi2EOPkO$MM(7rV8nv;jp%2$76+g@M(&+A>E95okN+jE6 z_Gq{o<8W>d?PI%WFKB7Kc4KX|-zPPm&(=!JOZvm$rg{+&)NH*6SXtFjbX|#U)@@#% z;n%Y4OzV7id8qF&%{`9AKFzS5Mtu<Mkc?6Asj@RjXeb<1bV`OMkxa&kt5KyiF$mtZx zAZQ#$fCkDE`xJl9*y7*!E}e@<;9X@Lp^*K#PZ^9()akp!bzj6T&$f4EjKKZE%Li}( zQxu>sI)&~;t3qAsC2+lC3~0u6>Wvaw#EZiw5X%@s-5_ZWN2z43in~C2h%m4yAQlS< zZRoiq0REHYARUqj(B@)D1xeX6Kv8IY>n77QtT)uC!T^jtMG|Ozi185tBDm#<0MXjM z|MM7Yz894(toe#+Tk&0qQF|qdoX2> z^AIdAvo}B9s~%!6Rbu?>Fnn>3Q&5RWhwuBtC{C^u#Yf>Dl;gY}ZNKVebZ(PWB2vX* z;?5mgL?d)9dKG#$DkmG4qk4j_9xMR3BC}o46!2!!aVSFr_)tij>QZyqN2%N32VNck zziw4LU?+l0axqwIqWP2A*EL=W zPwAALv6N5>Z5pJn1BB6W1j(1biJyX(k;}+$`bP8+dY0iA4bX);s}MK+&V;ujTO%yo4|*`^Wl<=~LVfMHpjZ#NI|q(0vM@oecSEemI_^Ej3TzqIW;@ppx4#rFYx zzfvt|+LFwb;3lVpKIdn@#=LSp&a)|VK6V5iy;^3+Bckcr&2pAld%*`5Wx2nw2Pt7= z-l!TiYv7zeu4#w8)%RY*7?UjGz5ksq zqlM?u`K{4uh7VC4NyE~H%+p0)kLwVN3O^y4KO_ZU83 zD!>9K?|)5fx}&rilOdvhK%NDbuIrfLeiIiZ>_9VCuHd+znF_W=0H%29-I$LE?YLmZ z7@z;pZkS1ZfejlNXKZ4zH^CT>dg#yaQpjIJh4I5xCQFiLd1+#K!G=?S>?Q6KGwTpJ zb!wa>e5(^mqB^IQ0+@Nj;Is(yxfUaD0#LxN!k!$@AksATe{_**X5)GcJ>sZ#u~-Ya zIA>NX7H!oz5_j?%=5Nt;M|T~rY20yjN7uO-;TpPRw1sxi^@y#>K3+5&rF66nqfHo8 z`5QR@&!(2EjM6u{Y9SdG3-*6%*T)m=p94Q{I=47x-tMdbKs}ZIFHI(gJnsz_hjn9Y zz%SqY@v^`e!wmbamXBY^YpN`(THe=quE2TWrx4s)%V%<2^UndgJg_I~cCy7(i->cu zDvf$cF9(Z*KorPvxrn>L7tIe`T5AqHZzW_a5 z49Lg&d};u%Hwve)A-iZ~suaHyR!CEp|3j<2MzlNs(}Su^R|y&{N1k3#l+Ov?OW`gA zeJ0D-K&)e%89ITk@V+*riOK_sP6zT0fpF|vTpmV5B#((|k{1n==m9qm#nw(!loQr5 zVo_nIwQcJh!Hc`QlmGePxi^wcqQp3(#>{FLi!jNyrvHS9@TvL}bplf~L>p)u9Y=Sd zS6v^9@=2$Da8WA)rXPc3SN{bw7PQ;LBi+mH`+^eMY2}eDZJq~SDi#3Zog{h0^Gg;T z{b;(W2dtBp{!i~rI2g6DhsoDhzEZcjMo-f(-t;hmWk!Eg_CDB8reZR4jC&+06PjKu zeVQh;vWQLB+O=peicqRH+NC(o#7~x7NhBynqZtpA(efoFFBwL37~)!!|Xr7zQu&F{B&AQ80~d4g9%C)=gCHx#u9+lo32wr`zh2( zD4}I&D)S+804t%P{%D+xZN(ngPxyYRl$_zrC>;eTo%WJtt?W49TIGrX$~#plTP`?G zS(9Z*qDA%F&vBQ3p(Sss6WNXx#%(QVvJ8$>exvF_Snc1ICjhSc#DLs3LgO{5>{Hm+1pk4G?l`6T{?sZ8uxH#@ksB;Vh8u2Tv_TZRmwyiMULOF z?Pk+XJf;ms&`toJx?Hh%Z5M^%{5;FuO(G6u8J)mB!A5ml>qFPPXy1i)nDxz zj-@uXebL{=s_gnqa${RHT~*YUZBsR?(t>B%dec*gGc?qqYsz}nHg&_49Sb4E5N8m7 zp)a}c9J&v^7X1YJX@4u}rBn2L<6OGe$jmfr*@S#z!C>)Pdq3Rd+zHS60OXC7RDnO1 z%*Ej*?VTQ7u`HbMAW9j`Nz%K0NAYBX0efpc{zY^9_?GkCd9R|BZZ-gnn@ftKs%wg} zrm7v==Xg0EMeQpG7G*pj#BYGd`|}1?7$6WY7?=2IfF=$Mlc_q5yy3MT5m_?frVw=aiK5j0|lLc!Djf z;#`-&_{Pk*{ukDm3A`rkHQ>%3YHBu0F>~|c*5Jx^6y;xbxek|(YS|+_$Xs(D)|eO3 zgKq8mkdmK&`h6HpzxB|R`5w>Kz6WXP?l|B3AA8l?PI#aG&yt_b*<}6mpTz7<*qQ$c z&F6$80E3g&H*1KN(FQt&Za{aTSEIMo#xlf7vK+JT>K@#?{WlUf_&np8RTGpB8@~O8M;)|J`}a7@vobfiW=v?3(VihtB@Rdlr2HI-L*T@U2&d z@G&jopCxW)xB}H2TE@(;FTU3Na{;#}4Ljehe(=Mx_sy7z*H{MbqdyznfgVI}L?8M9 zzkE~SIyD@nfjb%?E5!I(Y9>l?pr0+1ZhVaRu1v4)tkSt&O6_3)_Cp_o@tpie_ez5( z72$70D<>3+MlVJf(RT=N72uq zUqW9;zl;6^eZMXv?OTtkFT2L-#ixSgy%0=Jl&3rA1tVqn;(LM`;!d6(J|FRhYst8X zN|U%tH~2xk#-#;xbQPCa&{BX2;!2_iYJy&p@J$T$B*>zHaP87I_Mlo#P{6Iaz*v*o zJ*x*zQhQHR*=2pqSpJGGVLu>l%XuGXp%3&;K}yAf46+mTdJJg7DzF8VQTT>}5wnV+ zw8^hR#RR|qlcvQrws(&SU_LOUs&3p@BbG&K_ZhlMhYy$l=0gQhQTeGlu`CjP8otUj zZhbo3(mKLFUHueS6|wM8^`1S%S91&;zziwKL5Ep8-gj#X{Q2uN@vCGoy_jEzR9vQN z%A~T;v{Xk}5lv9mNynxE44~SiHG%G-xJe)v<#gep209Z@|~4lx3&kDly}!;<2< zg`X(6o@^Bq{e#_G=dWCdN4Q}>ZX29Yx2Q|0KuK;}Q;TDE-Z3F2HPC@O?S}Gxlm`W-F?y*qK?|GZ+;pdbxLaj+oJE8SI}H z7YTVwvLODhn&D`o zA!B@>Gd4B=+qZ!j?N_u!?FIwDSd(7mu*a3QvZ*cvepm%I)D=%hhj1ukE9+k-`C@D~5>1Wb=Iq$AB;sm`ctID)8;y;P#^#qD zG+i!Nu_(!Q8govJz;i}*3{bn6>=`w|_^xXn?ouM(-hc!ht841jD*!83tC|i5I@7pa zCNzj;u$%hUG5m>F^rAA&8`~C99aw9jfkh5$CJP2AC@EfVDhLU2fM%RZqhaa?l+YOd zThX#ma5`&&k6-rdpg2>u`9V?ndNsJ#nExN~Wr;Kx{UFq-=asQ@Dqigh2%QD>zz2Dv z>X16ScY=#oDC!lG^aog+_@QDf-K?M%Wd-v3-3{cWtGoJ2rz;Hd~VV^Hr$_K0nI+NQvDsGmU%zhH4_|;SDmTScLh1-?bTc z9!%DBcXz>>wcRx<{b?O{iJ-V-N{kkL``4n|(5n#wuQseT+RcOkyfM!4+TCRuhQ4^ErRXON|S<#o!L0wlaFYG;(!mCA|v41%v26jc&*I_;a`2#gO7vJhsO zut*2tsyslYWS@Bw80lTPkt6JOK;oG-U9^ZY4svI{86n3LM5a2-{RGS(wZV<10cl7m z*FOD&HGS!!`^THR=xFTpDoqebNHtwcpPi;_TKes6o`f-;Ws6)#$(d|143n>?VvN84 z_;{rp<3w_%=QSE;N687dpgL9^`1>^j)JDhH(4c5~zKbWY5P_zmY93!+7r5s0vDS=N zQ`%6J?qYw>aW?F@neejEI{q)q^Q`aH2hX$>0~FyFZG}_a32vTabNpvCt+P_DNGiu* z_{PouMb|%lv1r@SJt8w#h;=b;b)|`O67|q2w9{Fmz^8%_&ZZ+huovO&&Ss@>xm+0La?0+N234H9Bxzgb zhIu~#U|n=wQUAsHl`y-%gl&8&Jm`!5u)(jHvg*$NKoC=zTgKV4W!qMHRdY*Rls z+ejEA$VpYZzQN)vdu}eTB^YW4jHbvAnU-l`nSLck3D;Qyo`A zDS8;a5xpP%v_H6nq>!d3v6)I>V-mreeaY4ZnehlO7m~rfTM| zF@fWjn+BH~3(aytGZdu}s03l`wK}o9grQnZL?TI729l%$#&`iZHw?koR0#a2=+8fI zQVYE|wLU=@yHo$z!`>eJ*xV?@ydbFIx*UA95Sm<;7nXg`Gmg)<=M^sw1}maC{|~EB zEC_-iEP`!2j#zXYtKoWvallkI9?BVaTFT?uS|dggJ+eEU8rkEvPtgD!M+nK&m=%Ly z;jeu&OF#WDDcc}va1RUr69XK}u|-x60{eE)Hk6eW#k9QJ?I0*K!E&st+flt(aXnR* zRnM&y>rwmaSDo2^V!+2WRrC7XvYk{hY;SkhvkfJ6Y>W3jP1QC9!*GLK&M{3VmkV6O z5H`Peoub{QF|il9o!O#Af%e$udBbl)uCi3OH_g{;jg>fbzuV%Vs4 zE6OKs zzg^?pG&$E|m5J9KWcAyF9hG8L5ulO~mLxcvmc;shmLC4+V)`#&%QiI4ur2?<*b{7- z|JgtN6EQx%mgIh?RI)YAE|r|TWdT@L9wCC*l-=;L%eN*k*z*KH&XoPe0xLLqKCEeb3RuX$zr1iz~jB1o#L z*Gd{Eq|~sPNP>v$Lo`7s+BP`;L|3(FC_DKM*mhA6R4RtQk3?ou-E8GrIh=a^)! zSff0MjaZn(V++UsvA}uhWqjpL0jDl=&Yg(7%DLtE#k?Mm~Lqc5;>>37Bp%AcUyZ zrCOn2$d0So@unSb@YhRPW!d5Dc0DU-i0(xXBa}AN{%9LUo4D2M=l~@hp+!{KGZO+P z)tGW(6a_vf5~6tk#}G`=M-q0xL=RNrt*WG%DK1Q81q=GK3(HInPHQYj%U91&0j_#q zN1o)SslXEeU*2K2`MUuwann?8FAkvn1(?%sJ}?Y8r(i+v?Q_#q?kH>{Y=h9ZxRQk; zbQ;}*-i>|+eF1&bXT)lB@+pi2EuBjIz+>)abVK0Qk0INDmT~0BYP>NI_?!n^-!K{6 z=L@G5BXJtO#47%v_l&z0N6S9$6bdkduSTJ;1cX0gV=hk4cwxASWB+H^Z8Tke5Kb|3 z@l`Xsr{I{mvaYF-sZ#B>em2j-Fbp+w2JVBJ=#rX?=nnKjye1*peHnSp z2H^FoE~}m|iLxm9KB$gk=w`9fY*tE!ZrJYpE0(G`3AYpgW`uAB_0Y(Ng0cf0D>(NS zK~pWZ!N~+yl`<`Himlz2rD~jf59iTU=*?`{sm>myM5^&YmNMctqH@``EW2Eep8XD*{~*n8 z!?G%sdc9JythWzjmcjy;dH#n{6J_WuLV<({vq2A#Ktbw?`+$%a4Jg^md;SEJSqCKI z>iwAP{is21imEg#mR)mjap3lllSZTF~HU62KVj;Z|g$K!`y zQdQhpJJTOvM^VVA87q>a3JwDX$gq;fm!V3C&+%13>xCarOEercW=kr z&-NKVQlNdh5XD2_M7kVM2y^*D67{pdqIGfCcoFZ*o#c`yAC0g z9?5)zmgX&)pZ?O6|AfvtkV&`V%fAkD_<9`e?iyM`HzCwW=<tVILcuGQ@{X)|c~8?c)6}ku zFz125KRZ+-OV&-0w#tSadv!C$=l6PkfC)~%dRs+>+4G-Vv9J&nqp0YKqOQ5NtU8)$ zYMOR!ZmHlpOjYfRRE;LXQH0FD1=o${oLn+nAGDcLod3pNM*`o2`}8{dOp%QuG(tP* z7K9vo0&>@eRpRkT5MQ)nT5%d!L1}XWw+ehO9y-|d{}m-RVq#H}isF;pFJ1N#_*Gf% z{cmLc&8x`Fu5Sp=ZF*c;wtk&r{u`qBlG~&r+;EW0K>~3-OPt0guR^z>Xxxv*Ac$k1 zuMiH(^zVTqwBJ9e>wgji{e%G9HKWVt`E278%S#x4W!53CXy|>-wkyU2z#K<@ekbTE zbQ8KO6;>|jVf7>_`u^foI6GO3`q+2c$Rxtjag>J-&i$#83^8Zvk|Y^OPVdtuapP;; zD{}|v8uSoC4Nvq!474VrdQ@kF;;kabG6S+g6}o8!;zd~?+lZC&)Q3d=wyCLBE}S|E zm;sn9$-dOz?HHrFsi;4v`y0L+(*|hl0X`8G|F`J_2J~sr`=flRSp2d+k7HSo{>I*`G%8IR zbW(e`^Ti=?oC_9_eN({YkQ-m0t0fEorY{rgLL>6YWBS^~C=$o6@MTS#aNmQ|fWvQm zJpzE!;Q4$qzZebW!-oEtz8msUIix*?x-$mw>=m?&-j2S^D?dRKq(`$)5$M5{xS3A# z-OAcYKk$QZpXW^jd@l%*vf|#3YeF~#(9h$vSlBJ+_!Y+qZ6o4JK@PU z_}7USBQQGA$8cN>WS911oyI~=Qe(8#xqxpdDxYVXw$bSPT+UO0S^2&2EoPnl7avAv zBFPOr1~j(oTx|6X8Nz3aP%i~7n}i92b}M<+ee@UVt_u|$s8-m+Q_qj(=O+qnPVd4| zSN|6NlvXY^@^)i#38>6S8`0;B+@NpM>0s{XRVQfR(~09bCY3i}0)8Zcu@E2Rc|hj6 z(fmJ)y#AiwpXz!$it;TWSP_3q)c;sjx8Q9wfY90Fx=}fwjUXhDpt0O(o*0vBDS&^kLJ&Fca(yv7qfNzE4 z_VS0mv*bUE?hk!_Z)S|~-+K?tU*U{}zD&&n&l_eir3!4*(Fcm=GvG-5j<=qie~K|a z1J~m-(|mEa+@9R6bB69jZ$Q6f#hDtkU^+5(iTq^n-@*G0UA;*OoxFgK84){##SFfEv)|lh*K( znwAHV1%9QG&x1^)ck99)99oWIJ`DgN(Z24BqPTA=JDRpL|3Y62_dYn%&p4=!wmDKd zm~a!mNM1r*B7s{)DwOBxnMa89g2I#3xTS*ex2>#~{27RiF5HJ!3A6sZw7E9VJ-89I zbn)k}5{u)8A8ca!8Z8l_%{X{$t~%TSV!&b!8m^O-pCN6=m;0@^wRO1On*zkBg`xY`9UBt?;yq{$U^?Qak;#$bSN4@sNr z#!5@@1x0y4)33V0p;n~ADcH@CYl%cn0|cS1V3<@&YO_)ZbKx8^k8*CfO4lCH831>u zVx!i3{lQO(RiB)t{9!HJ*nhZSj#_`uN{ROpuE*)c7XpAO-h}Z<(o}n>?xtD~U zOSvEL6gJAcS!zMUp65TP!f}n-lt#{e08P+y=y`rN@uM4>7>tl1uzjL*^E)GI#!+kw zktd}?$0()4#4G%Vy0D3NH#)QcUjys96ivgvmuzlkmejbpI+frBtIb&5;r@B}f-}zh zeCH>;V~c>OC;69v5XyT8^U#>Mv}-~)_+~Gf6>MFm`N9PsWBm#y{5B3O?=>5GYlM1_rgIX!v-GzKR{Ln_{!V1cvHd;m7Tw?>F z+L>2|+K`UYng_G0wfkD2grJ)5;|Ct48f3iv7qlOLcV9+V@iH7Q^AcsUGe7UhjFzt9 z?s-e)?s-$ezROkXykn@=d3bTZw2JxZ|KNn-tp$KWYH<$0xs`%tsXN>?l^yO9)pCp- z#dP@&ihD7uijE=#9TyqYC>41&HHV`#p%z5)|EKv$uOD1t`=CGA0x)HZEWP&UmGyoz zFwBctE9+&g44^lK|1nu0OLh+cW@t3F>tJ)wfP>k88ovo452dr6hwz&+xibo=kA~4w z2?%I98rHl75iKS83zI60T*vE)m&9HI^Or3QzE}H?8GFk^^BGJiw5Sj#F9FI9s z&pp6a&G%q@@cyg7Oz{j7E$ZUCoj{Lun8c-VvxDOILI&P<8xR47PTE&8mYri1H{+zv zIZ_R$U#-8+kSCZtxyUJ9RfG&EUYR<#xh2#_BXk2o9xHmt?M8M5ai$re(t%lE55iZE|E?9_3KR-$eRcKp`z+!P zD%ChHDx531s$=krgf8|;yy~3|JKw2P=&9wU2^`(Q^5$l`R?{?i?CxLo9)MdQ*{*gU zL#NP{=q7Y0&IP+ths}=moptb@FnA%S0zY|304K1smQ%ytu^C5Ih*AThGzcMc$PLnH zx|9kUw7;L3R}M#<8`&b!x!m@NuCq&;=@{2^TAJnX)iV{vba`(#)?J(BwYnbw)M{&M zC(Oj^R?gvG&H8fAG{L&M2_H2S1tCl;yx$bdD?8F%ItDcz=opmwMaR(0`>fRAnssu~ zF;wg1ahvJx68J&Abz*I;R)ZH?ttM{A7)&#lGwst*H#SNlKNzJ^vOXHFli|8JT-S%|%5a?x*X0jIpR1GMy7W-OZ%X{F zC=6eg7Ph+9HC`eYs|rzn1OLbGFOROnQ~1s6?}3G(b-ck}t>_H>gu&=YhH!aCge1W9 z9ET99$I=*nWD=srerHQEIBGUGTeo2@$L+q5%We6FZZ?|$xqPf~4%pv2-^{rMe<-S| zSiVxFL~0#nSWvH)Kw*x-oBmww{5(5!rf_ll^{obFd0IB8; z1Ly;VH&g$;u2a!w6?EPAibc=Yb*OGEoFW2zjc+d2qqtR%3eb9hf??!B0+nx=Ht7WO z_AJhvU z&DibVGdlyO8@0<_+nRb#Fed6glee+NQeL>^#vJC(I0WU06ldTso;`RekYd=R~p!{tm2}g<2&V@sdzT24JThbPc)* z-GS~$k7*qYd+T6Q#DSk8CS&le{Xx(-f0pvbA;Q3u~D6-ly$(-px1K>YbRk0Vvc!sZ>25JG~w-M|y9o-BBKeFWL^CkwL!tYD(XS z$EJDZ4SkK${4YL({8Zs|?@zUNWVPpMD}BxEHtWmCXh$}6ltU5ft~$;4V%%%dbLjo( zBj}Up=j<>;no%^}%rOabc!DrT@f=tE^qOq?dNF!Jw+9_7Q3WGv>Y{Dfm;LEIMIELEH$y)nkb>9al6r+X8+mDh(joW6AFs@s13-YH}Hc6E)>HABB|yzu?#i|DK98|b&u?;#X4 zw}Bcmb%-`^L(Bsb{rx$~An^RaOXsU9z5)M5`JzXi(AZNR4hbdfu4tpoqdvl98)QwG zM4Y(@2v^qwj>Wnjbd)O%M`@4@qcSAJ$cw$zm;I7XD$?fbieVV!035eE?aKl$6P?pg zz}oV4x|(GT3!$v*q|61mKdrl>V1;v+NHvvK2p*9fHt!B`DC=<605Nwcf@aJm2*1nU zlkM{x$n5ocz1?1~*Ww9H92qM zkUqbPLL9;vxpRS=lR+??d9(XLv$?$7YzB`oCOh-Pj?CC2pvS)N`|;PT=F2AQYlzp2 zflxg80D2PPhlXB2D$w_QqcssjnP3(37fA&e(E|325u~%h25Z-rMi9|>yTyp1HwZ9$ z0>W-|N|c~-JB8DZ1@O#G3~j$Jumw7I$6*M5mYS}b5W81Bzj$b?m%pN{IF9mlg{dyQ z-cgzIbxR-7ZBGg>I`T}t4#44sD81zEbB8hEhlBB_uuzMy@zeKY?Pr!u0CWD^mTdu8 zwgnrw2^brVG}N-RYT8y;HRp(QYJ(Y1dP0+MBknDG1KBc{;l!EOyCZZr-bOWz_THqr zuKHaDhM9wqb+DRyG}LMt-&NIXe)p6Xe1@Aj(*X1<%HV5$mX#5>WFJ+50{$voO~;1v zC5mYhW@RHIbh_J~@rJA8rBIF;TvhU0MZP@p*OCiEu;H&noV~2}J3ulIH zxV9i@s&3RwQ(l~ZP2E%90dEaWQx-*4)o*pNHjE>oisJl7?HaUhuJ-HZ)n}X+K6UO6 zof1J*ily35&fge@HjaoR`u-ZS(K6aWH=O1Llk?5aghX*@X;Ejf>abs>r34$dmeUidg6#r><2>f6NiltH!mXvJZBwWGV zqhDIa87o^hgwUD(~aTucf8!PnqWB06-yv5?l67GkcLZC^&ex zpASvbG{dFcK56Ci)(wNjqpL9s$7YciiuIklod52{t49|d+TPRkVu2U;8ik#soZ|*7 zpSS*OV7F_2DR13ixS2icG8{GMPr1 z@+2JVdyLS?2%OjB&3spz*x$?Q+`-)T;~893Hok|bi;Tciip-hkl#7{46fI>gKKto^ zsLxMC%4eypK0A-sqG!Lhbb>jxq8VkW7>GjuNI9=`c`srvJddVQA zq>5jnnidv=s{cOmm0tf&#ytlGJR(F8B?59m6CtY=u0E#|8jhD;ZqwN4 zNMOob3SiV5pfsws!$qrp$SIu4rNf)U+pUKwTUZvw>os1!Ji}d>p%FTUb~(Utrz+FL zA<)6hmZ^BQVQ7uO3oToPyJw1iVK!v#i-sOpM^Y;nm-GuNrX+pCTZU^~?)HQN;)9k0&6 zvzq|Yo`FI2c;x!hNYqGDYY9Rl#~H&b2dMsyYn=DU0`SrDeU?Ft_a2QccrUQ6L}vs2d&U(CAc_f2tnK zj4QQF>r{i^AW*aDifo`hRU1?HsHq_fo%Gbs%J3V7VZs|Mh3`VCVP$9BOn6556x-kd z!!V1mnNUk}H8&(}8q%+IN7v<&AcM^JUd z46+=L&GYFP?WFihH*R0MXm@$Krs>{YTCBgGqWcO={)26m_a9~91B%j&D|}J6Th$6s z_CC$4aheVIeX3nc_2_ddtwbscc_=mO3morCbTeaX?SSGJ)5xVs(h1fcaBHIr^*F{? z6U}tBJ|Nb(Ah=^ToH7T!hh6`4n>&uFivop1TA#1s{Wc*?v2A+_Se7W36nxCBh6`6l zm8zErSOn#D;Q77>OE#_fJ?C$}#>80D4AW5Jit%dp020Nv9h(rg3lfnP$Hs79#j;GZ zbV`yG#nO|!u4#Jyi;w12)w6x0<87k{ekQZu5L3wbwxfsLC=rSrhsUU;ZQuyG3LIo1 zN|)qijFRqT=Kmm0_`OpyQ$QNz(^w)@y|SE*^yJ1d;P*3-_x-%P4*1U8+oh-|sn^Ku z<_X4RA-F=G#Si^w_cXsaGLQ{2G~EV>emL&g;0VLh>@>GcTSmZqLnXNzu${-fqP)D2 zQVis@@f=Ve#y!(_RY$|A_?7iA@61zfu7jrgcZC_PsPJqTWsY;DXZ~l)l{K$A znwJt~5rNORJ)4ESL&540C(_TANgisKKiMX_5#8J8%{r4IDMAUI%)ub)62^Q9c}%is zZ4%qDX0BX zW&u#&BY8|*0eAX*AYYxclCjUdN3vHx6aVFd(#vDHyN6c>UER3y`N4kk$6`^x@3C^5 z7!CX$NorY?7Yh^qlahzowp=dt=lLF~9uxY9caJ1+Lhtb$ZCiClCc$c(tt})iFyf__ zysg`yB=95S(jVm8>~?MzH{5dGZ5#{Mv`Wo-qj#^Nna@|WNR-hFFWQFbcyZ=6Xq<>C3nHLE z?H4kY0=+hXLqn*;>Se4r)huTpnp!Qh>}hI^aASw#S*H0Qf5!88aC@s6_~DIMXuyHJk1S~YBi6M^cL{%vNBO^mY3->^M4aTuLpWd>(c zM}oxA+e8O~btf4s#P$vH<(8@oB+(YQPHp{cjxRaM+u9t%ZB)R40W3_VG`$lj6jw^7 z$r!(oonX>u52YPNKJ0?M;e^2@lJ~2+CQ|`xKjOv}ou@bIihir*sJWn`aDN$v?0@we z_zmwf`;71VFbZM&SC(E4<5+gHU`A2gk7AsIQXx$|P~qh==qRce?e+bBGN=L#{Jd_6)N{C5=M5dxSaW*NrMkf6vw63(&G@Cs5<0d;J|6T2=lc#R&Fsw zkuk&MZ!TNEXFo%Qc5YPF9%Sn)NJV*cL{fJWIiqc%47lSW0rKjrsT0Rf(2J)Crkm-g ziUor<_y&|YqCx0r#Z5URiiwH8z!ZBg7#NI?2H5^7{H|ZOza4!(UmE26J2fJ6&NZ@z zRZ*Y|0=|-oqG1>YyJ^B1ONs^TO78!fL~>2uCVb2j^}kNT!MZH}_JcG3&@HdV_UdN!gN}1972MLTH{)E6hs27@r7u--WLmn>ZsQ(!2g!lfe(CxDYR}@wQ#eo|ENF z<2szwYp`N%`>(ZR}uOz+^s z(hhT~s&q{u)Cku{i$RI9W^ewv#4J*^PNZ059N9)!pc^I1douz8k zz|F@mTh<}VyYn?`57JZg4PT%l7uCjgpKK6V(J=io@qD3bjNba5`Kl?87yxw<2EN!8 zr7b8tL6GaYM(HL1i^EyLJ%>}NHU#plv_?GZHXDO6wo+UV9d<9xwm`^NhbyRq zuEqdMQT@xIG+0;7=z=*0ebJ4#g*-BZyxI(yrg`ARw?AvtZwBLF{!__vx8@9|F{6pG z?Ac$H<%wd?HEL(U@~dAx|KoHyBeRvs?~7nW^B!H}R+GMgED(w#@~bNK zqT5yeQ+q_%@5!6_3qG;YwaryIX*0)OzJIRwo&WFOVV#8| zP1k5f1VKwRO{W=#C88CvyLOmpX_W|q_8LvoXh=mteT^tyspAbFT13YYcLaiyDQ*L- zOmmQ=!JvIhgXjp8#>&JT07)-#wU_1i*0h!_%k!eE2rI@9w7NM}T|uZ;%QrkIv7@%o zKh^Pn@f$1W>rs29*~l;5P_9;m`TlH8a;)KK5gqqlwPz{NLk?4mOon)Rf}+@pxu%|c z?ThAK7Kt%(=#71PrIL##4ML|>p(Vs-V)@O<4lm0L!WdI^DW%4VL5rL?wsmKa?bwtTliF8 zu#Yn7!5cmZOR11d{Vez~Op$@w5N)Gt_(Rs-fIhAP2z?>wbo4|AP`oNHJ%#}L4~B|> z*2WyVM~OCx{PEDOWS;qtr)J!Y75%RC|1hN*d{d(oNIu`7tl4bj^AZFH(KBB-VH#j8 zD@KzqczH*#JjpR%>`#^U#G>&VH2NHSct7PE%~Z2cq(;zAMT>>z9BVa?j#x9Zuv9Y% zu&S1Z%g80Qz#Gggj8atw5@j5;M#(XODIeg1-lkjMi?y_qcw9b7FLm#%-MyOYVgde_{%=uOjhpcYfaSF$H|Chf2l)OE z)AZ8P=QGm^(>;;LMEWNviy!32>obRrY|zw$UHU8*Z^7x_sLzsEbovQP$3)G-rGF%C zB;E_qDp$x+fn=zr0Yf(cXjY=12TIaaPtO=f3P4YIrDs@UcxH~6M zBIZ^p#!{1kB++h{N)l^I0;a2X-|obSTF${HL^8u{_r2fn?j9jM83>gcLn?|wJ|~DG zU2RB&g8VHk8NN#9>s`PqmR?`vqq5)E{*k<8#-X&3Mun3?pD~9f+YlKL{|F>aqVmuZ zhEGvr_XoT2M#eb2?h10t737x1E2LYmAh%36G_HW~#{GD23fTs~_1pTwXG6|drv94x zYqJG`UB~IO%ILE(~Gsa)sWi8Fu1~dZvZwBdXGV}4v%co&K zgL6-vaxKfoBxZ$;_=zmbWY3{*8*QPpQFSvlzi5)5S~J1$0@9^#5YG*^`O(znugWML zC63Io|EJtJc01#lChb_(a-$g-jk97p(Y)Bw{q?9xgRebJt~H@}XA*f$ zjzIJ@!TQebNbw@Zj(qh2-Al1#DEIe3vJDd4hduxc>6Z}jK7;a|>gjw&uSSS9yucJN zV+^Ac++ zB+=V|siwN~Uu7&8|DV0HNyE-BSF7SpuBvUe|GsZ$eq}=FW}CVsAO`npLwTg6?8H#&=*#+5|tyg}GcfE;4|;^lYAk z>3odMo#-I#Fv*?U-8~1?foOlp1%%e_Y0M`u$vrJz%wuj8*DTeQ9J!QkOe2#@VB(9(a7h)SlYC0ayFg2PdxYUAsULrMF|epp3(#ZvR=ui zdsKBCq6uG7lv{C|3EsK0Xxp&SRzK~#0Qr2QwNl`e$mg>d(#rJpSUx zkvDY|Wt_ss!7;AO@A9{x_x|kV6v91sdE#3$?)&~d_ND(^sy@LCMQpkGpV|*zp@Gth z<|pJbC2VK>L|m_gO%bZ+@-n`j62m?fSbA`hfyMy8mw2?A36xoNqe?q%*o-4-6;aAH zHf3r*LI@F?YS-L+zbKr(V0%DD)g-+C7S&}_sAT9eDmFu~cXfv6`_H8(>x7XXWNf-5 zN`0_LunS~e?C>w9%;eVQ$_6m@E`qUO;dBMLM~Srd?!#e>{v2jv?0l)m&opVf+tkT+YPLay3So7N;Y)s?BzGz?f0RQ1cx2&amos?q$PCmijJ7{n1L#Kk)|65lxBL#Wi*?`+pPzhfbUVU0j+>$)WDd$iNtTeF#kKT;3ilcjI6j;saqTHk8e*Sk-M(H`ueaWb z`rf`3O6`dFv|Ohs*E#v}BmD85SwhFq)wAk`*AeL&S6i0jB+!5DMf%DRw5A*O211i@ zHxw?z7h57&7hb8R{KQeJuB1Msfa>?^+|m@TP|o-eblB*KpoItH$o;QYu(%otM&^`6 zup7Na`Y(1jVi&>hTh-%}vIe1^ij33(@So9OL=zZMvoeJCPa9QXn4O%g7qTfqRRCLl(Ji6QZ)HJnrAf&A~2~${>ypukUwXop+l4FCDu7!sCf@4F#>f$E4rcduC zbRIqW4!PP{nxKf~Q(9HBY{nyn&Sd08=PB~JQ0P6(k6D;D9`ENac9S*z+PLRB^?-J+~tOHVlB6I7zes&OR6Q{Rj+8D(PlqVs;edTjDo7#bo$YX{o`7CD#a0 zkh1$*qI)|a`Q%mAW?mqKVzIqYD9}3%*soxVTtQL+beS<(2T)IxWlLVStB1DGHRweY z;dU|nM=>FwLBOG85LYmZ0V_PckcPWf4OYnoq$Gc#G-1jF8oWjWm*_s_dAo?wxBz=_ zBivVeKRU35r;3zYS11+yR8dl2pj27UNy;-@7y$ce>yKC&%7;1IJp)h`Q&mkxg+PYr z9EMd7e0Y{#?a$$q1f=1K&%^9A5}aIC!7LB5tj%0nuZJ-{)M~nI+l<+^t=F{Sn4AjK zta7|P7?g5EezBNTUzEpo7F5QQP!(fu5879$8VgNgb9UZCA&oqsL=@tzG#hm(LOW5L zOeWDarzOL!=iqWv>`A8c*<`{F)5Jzx_zH4Rf>Lz)to%`&c&PA};zSb(D`c}i1ugcc zfMG&`LmFCt4u)ElgLf!^dL{Mkbn1e+^U{6b-JB}~%gQ??##)821XPTxr5M%w_o;~c0s1g7xGgHO*Z^H|>GO z9a^^eddGQD#kq?rIJ3PjlN?rGbgsGzc5QP<({@bT&=;QZG4`KX(2aoe3Dp&oDa+la zG}Y;3{yUQi!qAOos2&|2AmaEnoO6J@O{JqQaLYy19d%PJqqYgdU3Q^zM6G;+1^+%aQWS2EyynTSn zZzHptQ(|+E_}VwU0tOCr^a`@0Ck;NA9Sh%!qEv^lfShS6RN`$3-Nc?Hb_3~~ zVQV?p9;TQhhCcp_uoCD zX__udstV0z+WyMm>!KuSgN==WCP^YuH9cNlj&)Td zL{rx%B#G=-`?TrZ=O#UK{zvAZUmtyWS(8MGXqpBqe;J0qa{cvh^;GIaaU3~R_44I% z-dzz-iafXzf*IOG+vo&3tp}i-yIbT_5^lAT4g?b{?DrN@{RXy|YOQ*8<1)&LWa8}w zX&f1l##M-EZ2IVO|IYbT6GcgJ7~@hH@29Cd0p^a?zHcW&i8JOXk|=7^)0-Qwd!4ks zEj`ZFmFU&WYW<1+z`gkM;Yr(r{S1{#dhIZXPRN zDyi#2$gkJ`sd(3icHP4=5!I*zNJ!}jT+%)0*=uxwL$)t3U@A=KjG2+|1Fnu(8Qz%O zJKnu6xX5VGj(p!(^uj6le$);qQ+Kp&JvSB*YQ%CNlt=>$QM1p{VlNXanq&?dOuqiaueCjVyKOmpY1>RURO zqc)B^}pL4e(oLg3meILh~?7^wmk%~Xxor{Hu2DB5ym%lItQsp zqpB^qf)}2I5$*xP6r)?1h#1_-hLds1i8dtJ{ZPjvI~m=Bp0U$T8f;QdEbx?g?f!Rq z6mA@HOlUf44tvAgcOH?H1kuo~K+A-CPyJFzjT4lXk|ittCl^EOg2&@=`LG?eUE$v)NM+Ow%|&z?U8ff! zt{MxMvF;_-RG`>Z>2C=(n_>Kl+7W`Eg+J=GBJvSr!KD_ZfhvdyEAWlQ-^>{%JSP(> zzkWanm2bTTmjy7)+&Nqc`-qwbe}sbkLUH8YW0&w3I57{*Zw|{!uJ^YXX1H7~JbbOh zGFcLFR$aXAeQ;NyT*em;YFekai$3o}N2@^uFSv#S@P7ZU<4Vci z6w-ND%FIEuicWgCt)8q!MPRQ|)~aoohGuyR$zZS;?tal=6qNzUAp7xPvhD<1-LNen z@_e>S-LQ_Zk16bL`|$-IuV=0w@>9tq?6o+&Sv>Yz1r4Z00If?n@eHI3_7<7bVyNnyEZ010Z(WAw*1 z0Rvi2UV_-lJxo1(Sf@~aTI*>Fa2C#;C!H2PY1w3bziqaB7Uw&7Wyjw6q-Fj1!o+Di zoa5c~M&|#R3;+4>5fS?rd@KT(O+t1F0ci1sBG+|ZEE>9Q6pOxL0M4%w%gU?#B<$re z7>0k6t9i>JYs2E>whUiNLV0H=X{9;;hZG7?$}<0(@?O|gp|!k272`+-0M@9~-1QI) z!_VV_<0xbo=Y7KfqBu^W=95Lfkt*+;MW@hB2vVQKTM_E(Gr@o{#UyNo9`jU?!Dst? z&`HdY6Pxhnk+Ba5V6>IBc@|1L2BvVAYLny8GH z(V<^cSNBLT%Ye$ zZFdlPz|t`4sWAJ}I?oxyzKo+NWn-3&ng=PxaVFjB$+kM&@gCHl2fSrouZ1 zFRfg|9>jK{J7uKF&Y0uovYrRo{R1AIkB{HpwQPU4PEm*&Xqz_zSS=ZTBa^?ty6IH% zuTJ14!$@`pK!^#akq=kFC?(D$pLm>Q?3hkDnPBoXKl*b^>&;q>eMx>o;dngfwzY*~ z0_iwgduWKBMDMc9CuM#;t9m}Qny&VHrwhGd?Zh9JjIA9Z7$#l>6IQQqYX;9a`Q68j z#29qk9|%M}{7A9bUMh87H%wC$7t;6xn@{;N<@^Ho*-al~+wRgYamr7SV4V*ea&W$M zoMMc(E($S-L_!ks2vmIw7&+Q4Kd=PhwJV)+Iv&6Lv59t@C|SX|G)!>D3<_Iy^gkCU z=mGR*^ltQFl!XqbtNqoX69+1IpFcuaC?V%#PnP#()hbXU*7E{^Cw3i$S+r=Wdk>f#0-H|UeZ!%vF$&0t^ zWvhgyH1EZQN4*TiVq3m24Vlu&L9rK}nb$r)jH+`#Yko4^NKc~Hq>Beg7U`xCs$uKV zIq%N$oTn-0mX)TKHN6VpFJs-ozP?#zdY5MBP)zpu-<#0pu^;w?I8W1U1fLQ02j-n@ z@stJcs<6cuoyzK|=Q0TN)aPs>1jq57vDD93s%3c~C%=zwet1LvFe^C}sF&s!V8Rn{ z;Vo)ZCg`i5F)z>HHOKZhKr?QxAhm$#fzahC>iv2glbyp4G<-(}E2ij{fJJBeO0-nA z5hHxVbWOIcLcZ!)0GKyVJ*bBjd}XG~ZEybLBNPfuW&!p)MNQTDQx*%H6QBNn7O!yD z77{zSzx?r^J@xJ1ZKWsXI6v7N6619u?qTP>o&1R7-T6;wT_W+Z$7fzrBN9rAF96|b z(OyR<(Js1OCf29A>H#op)2by_1d($TuSSt|DL7I4%H%_bRGD$V){~$E4$y2{O#|U} zFJ=b{>8~e)@Zh*`B@a%uq3*b+S2~|1TBUy63sw?q*Zu`=xT5u`E2I-wRG)`nqHw+& zviVHB)!GKl^4{D`mN`N^I3Az}x5ByK)Vspq4*=EC>KD`3mTc%oV75BeJC~5}&R9uw zEedh#nbDR2A-C*z;|jK|Dv@PUwQRfs^?H)j>yWP|_4;z=uch0)1UpvAF0Gp*zgj6v zQn^y~N2Z=c;^ll3pF_Q#tRZZf6~b@c{KapEygEdSYFk2mgc@aasRLY3K%-Q}FdNc{ z<;m~6agrbS9vwEk8b--`Xg6~o2yAVypkgpZUdS&jEVdhPGSB&JK7;JT(!>P-?q&2C z4DEWnr_;rrWY5%UM%XG=@^>sJj$GUKpY*F1T437mQGTp zw`43xN24}NrS<6Phpdq4um>m*jHd`SpaYnuX4+;55SosmnWoXhFDB}ij(Q6}CQTU- zLXMWYkV_SNd(M=zPvx1}C))cm_{bC*AC7ADS!^IxfRxHKR0oPjvlhfPIxcws&-*Y% zG4U>Ef6giM=GnpX(0m?d;5g=={R-RTbN|YpnT`X0{QUEM%L^6}0>2DaUlEPG*BcHC z%$ON&fxX&I@E>|N!6_``GMcwH_>dVod|5Gf=0B3JzFD0a$PI1>4~de9+NUcKkR~HRpp5aK{$>x7GL5%(t;8wv15P;v!m+tV^S7Hg({z zx8b_?xA4lDWPq8h!`pP3;X!idHt)Nqdw7tX;nI%IBm>cVpXITd_Hy`LY?hymd(czp zeF#a0q#QPr91`=4m2NDJ*Q=A@$wcGxb~zQ3>M?&<152x|hm`eRv)#xs(W(4uB z1iPXyo$pxf$RO}NY7i}mSZPdjro^hk{|7pP%w&c=w1mN?OJnqPtu|)CUJ54a1^InW z#wX_*om3aXh+m$;zhNQWayvrc8xZjmesmabs3dTJDndhWqsn@9xSG04gm?q^+ieYC zR7JtU8TEr6xgS`#Bq~c7FDW8rlA)$aAHo>AKcZxNXU%p^CQ2Hn<>)0^eY!EvkkoTb7~3`qzh*^^dG8qxMVP|HN~7 zmJDX-3WS@bU||fSI+BsSa#HqCAo&zPOSlpJD+Y{fIS*tTfHZ*5vLcEmF?120#%}y8 zg+EhN-oKQzuPF?V6xMxJPN{$j`sz*u=p;~1b8_m~;i`QjpGzWCo5nr$oK7>uYFF4Eh!(LZ|a4J*( z2VTswxQ(v(k2w^qI;<9eaqG-w$5~!`-W%BZ`b*k9+03m-yjs2I&l=UdS`eZ4_FPf= zbYNO!9Vhbuc%4{gF#mVpy~Jglxlg^@E9t?M>;B)!@@-X=eQuNGzwvdR&A=*I@P|K( zUy-_mu|OKxG!Zpzk?T~(q^b0}AOOf7yS70U;4OnwX*_L1dnsT}6j&#kroWs0EbDiF zOPQ^Qn#?h z?aIm+Tw^b=hk{eq6f9YAtXBPQo}@k@>w}56M|s{*C)M+Fc$EC)(hRPl`*Bp z3K*1Myqg3XB~oi;jpjhIBonn$+K$ zq{{P$W)KrMt{W$T#^!7Vq8Ec5yh)%cz!t`^cV;t1=^x#x`QwQUYO5i{5(r<% z!w!N#!supfHemRKzOF3WJaM}20&v}83(aPfZk}+NwHnSZLeKW@PE4azFX+tsku5VS z*>-_jCOrnRK_e?2sZkEJHREQyg)6C4VKsX^3049aEIAEbyS}c%9Sd@m6??6kAJms$ z+~n%|`n9l!lfgB_8&U|3jP!b8+bZ;W%J@ZaGR{J-86+5CS*m=xj?h;{iWt!iwa|hL zY{z)#@f^SS@pl@I4KQ(;tUm>_SLai@%v_i}&!Imf@=;vYD{I8asp$N}>o{TrC0>YT zItpOf6BgIbzD4$G7}p#_Gb{kh&GU7 zWAWt4MZ+<)Gda-zgBiv2EY^jg3Op{euRzzJ*Py3t*xL@y(s6=`XF$3**IB|I;8l|$ z-3m1--`8jc?kjwLV;D5!!LI6LB0yqPQl9GZvnM^ zzpziTjAx{RuNtB8HX8`d8Lg7YV5D{oS@j>=w30AWG+~PUdF8GP_I(V-e4JUErUopt zrZc%eQt~z)U|v`KC-}AUbp$!JRCL1t1-RS+r_(;jx!@G??mKMzf>ZpkLwK@K0(y~S zKT>cmIL$^;qC1ACTZ? z1h^sU5gT0|i-D9X1;CA4Wy~I2cM6t7b4?!e=g+8wM(BAbQxd(-4=d<+_kGw2(8Snxuj5(WeMPfO zS6#|5TtBm$Mx+nsL}?N%*-;3hG?+8bW6u??Jmx+=6Wdc8&wYWybA`RNHF%E;;LbmM zrEcep^~O1M7#{w=AO7%t4j*|*=b+$5CGA6Elzk??vt+8>8|tk>CLi(=>KPYR9fS9U zp6RG_R4!R49~Y;l!%jy`lQY&Jp4!{n+pmI#;a+8Z4lFcAy}DHf7?}=1P0;}sCY8&R zEM9A(?~adwIcW{-KhuIVxA>7_c$b=Z%URrfJDI?qXSN)&^NK#a*A&;E%G!y~>}G_V zVPc)pw58abLaC4@z6T5q8}W0}3HEa^IVpz>Q|m%DZe(tb3GqWC7&)A_@)s4%Xe>}^ zjCsd}EIb@;1WEs6*sUu6Eao+t8HhDB!*M-}Ha0~FwC+XhSA*?>n4%4-nqfMXmu*nw`gRFCXVPg3Qa8FoJ!gjSrC!0stmX+VAe~7qAAegg4(F zRJ*O}BGs~2>%EXNAsKDNV=B=+ePL@h=t7ophhTqEV4h9GWKrA<{lYdIu>af#oE0G? zUH(7kKiWW7d*~Z?<>m$DBt%TyW#t+P-CS=4j)br)$VKNmXLhfq!~2$LV)iC)h^F1o zWEnt~+5H!VJtJY4+GT!4*RLU>sD4rvMRJWEf_ph1!eJ_RD{Mt9=_@t)>C>QTznWiF zT}O5Nj`fQD{T}{_P*VPNEJeR;I|Nd|20_`#Avrn77^+PamlDwlB z$JGiBAX^EL+v6RzwRCfHIRJ!A=LNq)O+gD|rvk=|!?h8^_Rk4OEr!`=4>m`fP|a3( z$_XBguIuEZee-c`=pYnvMd8nn+%v>%NoxWfGCqD^T&u;g#|&&RFOF-qLn6h_wXoX5 z^ZCFT9*wT+Zf~b)Ueoevy1m`KZZyIdtk4yzA+0FzF>Tum{x5t5>8OaVLNO%s0>s2T zNh=ze>Rye;TtEJ>L%>ijgU)-ivGj`IySDG!mAfmJWzYYFi4{>%`OO-8@$MI|Y{SPz zZNMCu&dtDQEvs^O#rA#M{_jjb$5lnFi0s9?U;O3Zg$TtT+3~h1D9IdSI0zm*PAYGJ z1Q{1i!r=m{7C*t&E+p>bl@t0IMEeozTBx^$5Y+ss=dr>bQ@SAQ8DrVZzl14|@lT6h z_tOPm{DAlbaoio78B9<(S}tm>Tk-Xijfb~U1<(w~0`=4-`ti6BWtwQ^0+Y|5CWahq`oX=eLOnwps6Qm#U1)vN;yoH;(8K=@A{Qg(v z=YRh95B#j!%y2CDW6t{*7vF#LcoH7`g$zTPYBF=P_ONf@jhwQg{`hK~&gF6dxm*rH zAHbg=o>-+*Q7K{tY73U}AMgK6ch|ok<>7Oi-ElC3n%RaN&G2bN>bRCQ*a z4obhc{`?eALDRoD!`2~s!Ik%})$#+|F+aCo>+)-U&U7qz)3YzmL%!FHBQD_~!;@fv zI3tKdUZanaMiuQy#tUH znTDx33;Kl)-hD&!Nk5B_+F(#`A+W^#kR;mE$hjJcKd2rHsG{DQ!wlymZdI83S1ROklGtR?A zwT0XlSC;%dB7L9mf)>c)>lmdeD(~!%)gxJrWmU-0aK-*V@qx7NjT7a`(pBtEz(EVUuYm=PeH5TP z`wR}DtAAbL6mo$CY3>oe0tpXi#cqXDz&_T0vAgJqj?Li7dj|Rn-Th$+PDiPgYmo;s zHjhE;kYEfn$q7;y?z}Sg%>~&0*xJW&WF0+*-tKAFs(t`&xi|&vbdV%HQKh$@juL^- zPdvC)89F0lRR>7pTL>kzG21WAi#eRJoqk_a&fK)3^#`ZUqhrYq{T;^mhn29pbe1QV zs-faB@BrS+`|x|31KQRic3>`fg!6;HjpW$eCRudN%xB0!%Lp}~HABwog9p-U+%K_B zt=(TU|IY~UXms6nZ>2u*gg~3$a{)9X(0bcVzG8jl^|;aeKVNghb2;{@7mb=_}PT97ok_l zxZ~F|M){KLNCp#*eqFGdV}l8~oIg!x{^X}LB$-!;$<98o?JAeiYGAE2rg;S@HFsnv z(XPIxCYr(pbbuU$8a+$77sLyK*#}dxYu7LS#a=+8vgNB~SqWN?5PkkOlS#rVgFkW5 z|5STEK1ggJ)f*cet)a+?ZA4Dc4OjC6YExT~_ZgoC0apq4m)~E+Hg*PR#{Au52W3F78S_n8#pQEa>WFj$lPyNQ>`6{v>dinZN9Hp zI+_83`)G0ouiwP zb4qW}Dw%7AB>q)>$X@|*Pl$LW(OQRG4|$$3rUPIeT3)u3Egq-2=1x9{YfFW^(V`n$ zHCI*+!?Dq*8iI$3Bp8K4cpk=3ucZavuXc&~m6h%~AJny+>1`}50L1apBi9~DC8=nd zb!XpB!lhk zWKbys?sQHaYlKyq+KQgnHQlzkrWG_D!mtltQMKku;A&dFS_!M=a*z#&@E8x~eGVz-5!pVr}o`*7{bv zg{@eZ54A~e`AEJLqQ&TrI?uriEJVHD*3Rg3BzjK2t{PVJdC_88#d9BMgaCA=S*=v+ z$M_aZ?c^l|x7u5Za>dF*=1t+s2D&a3qIakQF2-aS2c(Ue)ETATowr6a)^N}S6lmY| z6a5R`4Kzjh3(bEzcEA*z`Mw9#CkcDbQ;}%RD)Z~#~*E&@&E@1A{Z-!ZoKsMa6N}u+F`G>a72(6g~ zwEAK*Nyn^>*v??mQrl($`rD|?!^k+)oFf*zukQ!1$hu}k8KvxV+WwBMQbVG11wW^hF3Dz`~MQ~O+)UA9> z8R1$tQS*k+g+W?(xg~qpamK?kM(_I-p zJXQg9!2GzGt_#CnzxP}w0b3y|P{0VVmbe=v!`zHlCSWubESFG;hDmK4RnAOpMh3+oLn>U? z6O#pH$fk0_xZX;J)L<;!FWv(m=>{6Zfvh*lHAEpC zKuBwKwZnv%&H&ZV?brT^`bK7FFhLD;2G>r`DK8Ge1;D5l;kqPoij-5QN7s#;Z4Nw% zBD8|5@9~&s!l>Xn_QG{=`Qb~}jx!UcGV)Sx&};=sj|eug6Xh|`$*@^OQ7xF$MQE)a zH)A32OZGq$@*29h542J@;U`^972DKt^78{pa=>up9nTMnjw`JKS@AJ;m<~WLp;&ud z1q{RaSdJTI+bHE7SY{p!iv9Uc-?^>u-MZg46%b;p#fabyhFfz0K1WSW#c{%pdz0^X zi=NLzuRO~ulFZmqO@vZvagr11GW2RX-bLn7hr-yKZwA{gK2dNAP7yGZ1I#ZhI7P*V zeOr;`J;k2yP1FxwfE+jng@Th5ud08E__7P8DWj-n_~!LXXc)#to%K6nO`1mLi`WUe zaE-L*OP~dxg0aQ<_po!ULUZxO_4|vV3?sx+3dd|F$K%Uc#_E^JOyh@5Z*GALQiL=Pv$R2*G0Ik7=VMW65bR)!b@f5Y zWnQZrFicanOkmv6Z}Wc)e(AB=(o*fQiNXH|aJ4jl$D7b;1Xfejkb0&jr@DmKlOR%e8q4i9E zfaRa2LomE>%f~H+)(Ry*{PlR%xp>e&Q!4y4RVe5ez=Zksu&sHn>H}-nXC{L*i1X_!GgKu}6eLOEWz!`hS5!qYMBVGj~vzgC%lB~ua|uCHZXuU+mhkXmom`tMXD^6$J($5Xwjlcr>~m?uf6<=8~Z>NdTa#0-zESz=fOE z*4TQq01yzO0w^j45G1DZrOi|QK9%LZ#&;lhc?Q1(Gh`wcA<9 zNurj@Z!<3`N1&&0Tx^{l;xeA}qCd6{&%(_AR~Lt8*nzv2ixqf(h+aU_r%Bun>9E*1 zP+48~L1KzIpP34iz+cxYx>YzSh6a~?K2*&FgX=msgsl1@iYn7)GvhvD74gj*lxHJz z>AfUJu0n}EE2>RXxQoc%i;?q!$)j-nx5{e+K-SXqk`lfmWaTABY^mmwX9D1J9|YCe z&&v;g>v}aE9_fr9)aSEL5?euTO=n({n%9zQ`j?;+znDx0f3t!}vma?fFS$Gzq?z-w z8IWCRn_TjUFaMB_!Skb9qfxa~RaH%qF6I?JK*3a1Rkf;(M!oKPfc=L& z{J8p`zJYRXHKUwuiSVhYX>m?(5G^ZDz<3YHGWJgl`nAxuWl7TwQ)S~hfNC1LCdsxP z*7}1JK9=RtfxS15b-NX_7DQ41eKoVv?H=n#QP8LA*Y%_rw~*0<933T1OP8^0>EHo~ zJX$g`FSK&R>q(JuxOOuBZnW*9rWAesU@PMOl$*E}rWg=J)3Xun_gWtvv)!%hwgX7# zl^{Qtf`QEeqS!*_opdI$1U#{OK$!8{iNY+~vDVkumO8G(MNuHaqRamCVp!EB&IO~< z7;s5&w&mN2Km<|bj@wyUTVJ;v+hWSs+pp@?a8VZ|K4>%yfpdxPRMoV5(`b*FRPAq9 zxs<^*@Ku(My703^=f=4rhC_$$yYG!E`OK6dt7lxg}frU)Li1B;i(y-mN)2&&^=y4W_~uP z@S1k_ro87eD)`}WKdwS=*Kri0>6#`=#IDzulx-Nvk1jzkPC=pOjrOxe)AWY#lbPf6 zZ3MkOO|*r5-So#B_z(N8wV`eRxKPs)?YK|uL~LV1&r~>@3w-0}Ehw0ZaLHQlbya5j=qvjIUf zK5|5zJjD%&o{eVPwrZhfbnH^2q2a;905v4vxSwf@RbgYwFg)`@b3SPnvS*u7iaCKZ zFr3pk`sDy1czLF-L+b6Yw=p`KxnI|)o77g(QSBrf2qm#Pv4(s|j(7QfkRj0b>kaWy zV(t=Bn!I6n( z{0+Iz!V;&<`w%rv&ofQ>p{w5d)|TgweXm7>qS()RJN-3W zdiVg74?nnxtlMRRsBhZ>Q}|(1;JUKBtms@c*84?PR@Q(o%}>5< z$q-+zG?4rf^9&2wHvOb7LsUaH3==woIonRO38}bZ?7HC-#U{O=^v%&rF+nn(l-RFR*j;1jNzDa#WYYcthW&>~4=Tb46 zX_zN<1H0C3OC#TNX}yqG1cD|h)TrT0bD+%nv4q+9ze#HI2ja1S-U%xo1-s~mGDJ{a zrOLk3^ouoT#;#kZLq7qnCQ1e=3jhU=fwhQ;Dz`LS#wy(Iv11jjyL;{oCCNt>NZEoR zWkckpGi2irD$mS$C|TmxJsf+ZWH9gr>hu7YZ;sboXU5pynSPLuOMj6kcuu(ZuJsDm z7$|ZDOCGr&%tLr4etB}&>S;B%0VDpRZ`JufSQh7}zTji*f8iA8mbJ(E-i%rMkM?HH zE$bBaeSGSNQ}0>UUfRgFS&^QhW}HqXd8S4T$^>-B$@vB(?VpE`cmAA^JABCTf#+kV zmDW)OZuPiKSi$Td+-YMj8s_a!jWmcMWZs4sAndDJZ_<`ffoX!1&R8mnSQU*cqYg^Y zVRR+B0ojh7TpBfpt7+nUY4lk@Vh*FGEnTm}=;8(7ywaSA7*Nb&Qx)i(suN0LCA< zP>EX)|5&D@G=Z`3WkL9|P=38Ezg`gb9yeQumY0GLUtqRrS*mJTrp^BF@nC8BP|JMw z^@8wvS^kR6R`CvPHU4q9KUiAcjLpYqwJN|ug zjqVQD+Hkp*uHoIe39aUkd#Ej=5fT-+q8R%Z_$Hv^@#vS_pvk;t8~j8q>J5&MFE3*v z$dp_q(RBl)t0cg(7aU(+#>y9v@$4+QU&xo|mzZI0e1eykj|ZNNfm|tpu}-C{2$cnb zmzR&13%}-;5cb^gYwTQ`J|YO|1?Zk46V=cVowlG%-lagrIeUkf>&@(!%ER~b{v}q& zY^~4A?h&}u3x>W!6_vI)w=6&P*TkAP@Q!MT+)RIfZT9<}fsdQ^u1s5&#ks#_=+jx= z{m)o>H`sKBt9#evBc!oKQeP{fQV5GPMueuOzX+CP{-)kVah&5EJnJa3+6%T6@!;Qo za5fo5=nz6k(Q>=Gu!&16F0+nMLZv9%3tND%DiK4wUf!oL1-jO@FU%$lpC%Y)ZhtIL zdV={^de))Vy$`h2q9C`FMxp7XTO)_^f53xlbWD#<5WOUIT1FQ`%4hvE0g=m$RnKGd zeY^~D|cXpTz zAZCN1si+xC-(Cc@QMzhG{D~17#Xx&{!l%%>XVjYU{<1Fb%-)>F96=>SlDp&fpE(#l z+{W0;Ek;JN@2?>V)Ha4@bM1+ENuFZH0Ej4hxcE*T3| zQEV=+Z!GWq_($6{mqEoibQ$#ZOUvzzU3&Camp9gz8&lIEtQuD6pQUj;4dsJA75U^ok9WjxIFh?XpFk`h*@^KSOjyy_rNGRB8uk10tT$a zcYIU}B?v(`g-S-mYCLSz>a$L^@FF@)3lwtUMZyNrfc zM)Kx6AVA{f--NHi7h|q9Li_wbXtriN$=}3tSqkXYjNuU{pLbpk!&;|Pdx5DcljRqZ zBS(^6@8w3f+lZnILAe}w-v07ee_v5F?SkXLBM#Uv*E*eA7``COOjX$nyOi$7UH09?zJ4TWk!b3t zIafJ_|Il4?iWz5&uA8UPE=HbTriS;BH=YNYJL(-QTPMc$ zA6HN7=gsdYXw{|Y?h*gb+5#FtEdBl${C%KsurW8Dt3OBei$jErw)!x zc2f$TDXX@be4ZT{G}{@BEVzad?B+#s{Z%%y=k{PGV9Uj)b<$Q3t)f%t7UY6+M#8=1 z^s48St>EjK3ViBP0Jvyg-Kp`GE#I51a`-8x%qSfkO!o1%uBwJ(shU$R`zDxCP4X9^ z%R8k^-v#_ze@s^OSQd#Yze422>f8ZYUr1YeZ|u~J*(uIqq+iG+{A%ff;v zI{BP)BHiGUe5E2nub^`oi-I7;wB*i23A7-{0HQKi^5xnLTTx`^8_X-}T*j1801P8@ zcp(WeP$~NRFs>jWt@U%&p0({!^rNyPw zQVc`HH;3!xc<=<1gEJ7{wa&cp4Mt%;p=i7Q$fjGA4io33!)Rz;|Gd&@n0SqHgL-Yt z+0>+)HKukk)tpae<8ibjpV4?a9v?urtgSoYc05!-C}yM(GwF1Vrs-Ve6cTQYbI)KexoR@^Wyayf3u9yo?Kpx-O1!5N-kOC@!MVyA z!ud&0uX3DnfnPMtOW*WR2}QK5>{YsZ%rjeYqM@o0u%(a&iGmjaXm)am7muLMs3FJ` zU6^Na)uOSx)8O=ZZr_7umpy!a$rv8aXCI+vABQsE%NqRyg}Ri%BYhUg03lNrfz?!d z)#w#m_k>gCT}B?Gd~%wxiRm~1j$^j{p94)GgAAFx-<(s-*p1KlDOe5CpA~!$GDR~O zr@=sAN88osrm_*0kZ3b3iMYYIE ztgdK7CHJ$DO{XxKPAgcI>@w2hE!tG16GxdCu(3o5SKPoZ&y)qKBfHN+dXl3sC7AhW zt`5zwJ4I>sy|>VgrpQDnKs?aci~Ahq1wTROf=KhC&+$Wj)X>?b?C-k=JRv=Q5R_mU z9j;M=74bjOL*lUgJ;(VTz=lKPUe0^p!&nfen%i>xXEg0+@DeQTT|5EJi=Wo@PxGo2 z%*)uv>kJ7%gHC4ZD-*>#@x2PWUjk4Y)C>P{Q#asEW|M51aq1LRO^Q(r1$3H7XB2F5 zkkN95EwJR;(wN4ut?0V)6l12M>x%f0IOUi-{{=dy1|ry}z2Uxx9(TUe4n!h5v)~xJe~pBt%eKHNvy-0 zybeAodl6B_PWgqLX@J~Y)ZL3US{N#!I7~_r(0i#$=#pD~0V_H&8 z&OlaS=1Jd|9A_wC zKBnnwh>B=c&dh@2vXoet4E*Y%0QM@YLR(HognCClWME$ z;L?d8icP!g;0Neo`sw&xJt5r$?lh{|>N9k6Kut?PxQh;*aw>?OCC(3cd} zQfikoz5@`vKUksA!OP3XmlqcNpwccDfh?6vQ8b#I?_aY!qB6s2G!?Q$HWwC5Q3UBN z^8AG+(q6X3qI!4KSb)!rl59@=4j*Ix9kB|uB}jXT1VaHcLN2n{SB#@;i~P#{i*!%z za=xph3OuM$9z9@HuxHlTeJ|W!U?&KMEF(zYAO0B+b{m~&dmF%YkFnbrvsKj@h6z=*?YP8Sf&W`mdu91>_u=IgJ0YeOcokbT zz(-Fn$7H&9E3izm4se8U!x`=GQI5gM=DVI6H0IxG4_?6^ln}`?n&>>*ZZ#6vBD%6% z-nSV}PbwTJ1E~rhgX3Yx`$UQ8g%+AFd~-#-Qw3MkAX-SJYIUhs4WsC}FCO&Ioa*<< zdb9wV<`$x9WJ9C106wd$n#i7R{zXqS5q7@)BvGc8={>#KS=2kll5^F$r($fTP6^$; zh~<@&TBo!5v}amW7A2y8YY(IpNbt6{&XA1qs43+NyMj9U4ctMuQ3k7K4Wq=0sO=Mx zl~86|E=+;$Qy>kf19rn@lzqE^48vslnCDt83P4*uuNQ2E^C^tOxtFO{J5cf&;NDOR zk@zDwA<$`@AvJgQ_5uXYx-%Mfbo*2X=|M2!D$tcp=goGRtkWJ0EoaWMlVy%mC=|op zj@IdHIy)&dy|=IJ7;JRf+3a*Qhch-`7rr0_!gJT zs*xFiez%u}%^+H6j2zk}@1=rC?$yG1hOW2(8WW@+5gJQDB`6Zy% zHC8w2Tv`Zh7cF|a#mEKutv7071^rOTh!-lM6)S0ILNnk;@`t)LjtrY|G=pbe@491H z*xk2HLyo=EJH#2iZ@A>|ytg<1fdM&{d*sMT10(IBc@NaxX}H*%kLxS=o*bAm4Ct*J z^k&oMt53rZ#-!9GWzmmD9z}YF?+Kp;2ZZeVokXbrhk81&m{j=laAPJ?7b!$m$cF@QN9@2oIjSBc3?gMf4<_SDv>}9S zy8kSxsUEusJ*13>mPw*=qASwh%v!Ja48wJ#j%q?ea}JKrR|ZwUx^=h%L!_EWhEbDV zbUZL=Ijn**(-bvltELKn-UaNN=O5S9`#Au=U)3HzZw_qgtYOM@hsSnrmZP&Ylw(*= zvLC{$x?}gnCN&J&6ay^2VLxSR`5=imo$h1*yCpOE3|f^?$S0&~qhbN(b4FjA(BlLA zP6F@ISg<6kPlqBeZVr&8e=@1VE~w{9YQ6`W?p=pd>RHBXGH6LCnB5bCM1Ii_Jim5- z;Mhu|UMO8;{G+@m-><<7FOGyD{teYDxo>iyF6}gw%3@0bA`8;c49^dPtQeG2(R>EO zNz{`%3?>rz6A?aj-g7av>uz7_;$#-3?Gswj&cHCzBZMy;;juE4*7rG;(Fy#VSatqQ z&C=whHsZ9)eXr2l?9^&%G;x9k(Z{j0Oxt=#^}EB0e0&&B7g#z&{ddLC<3!WxbV-P? zopF7TE~l(BOX)NQ+aR^b+)Gc8!k;JX7nx5#MK8(xQq%8)ls*GZBSDZqfuq;MvGmc2 zf4I*_0X}*Zd>;S$%LgzOlvW}nvLhH!0OdI}PWRFkqG?-!;D@>6kR>BRfQ~C^%8E-s z@rea}?G*&!K9kaNUy_2l$^(4RxV;*59|g9Gb_9ISAgXzNi}Ix zWRsesvwQY4-@>&m4g$Yg@u7$VpFNIhRlHXZ3}Ej#>CxJ2}eGRoGCH)+(<_8SB&Ko zz7JW^FEQ1B?dy2#k4C}J)jySvg4jYP;z`fOH1O9Kk9Yyxz-`I&e2gjmxVgJTh9UP(s8xbS7ujg)Ps%2ds@0@hKkrZV-3%){*n#2j=j{>>kt zve(fmf|pNEkhFY z%3$j{E5G(-rH6mBJ7^2-#=js50%Y^q? z>Vd)d{?e~6Ga*_=C(CFFs)5onBLQbR{NU-V>x(vko&_jL3DDf>ml5;=9d~V$hCq<> z(a?|2fLaI>mUDvjoc+G+b9;2XA9Hnt5CVE+m=sIarz3mljp*H$@J`aEvCYOp!VhpK zfVGyfm*ZXzi?n=j(wF@JcR+~0Sw{8cL$f3%IyTpQ{N0q$5rhrUDN=^P2Rv-hwJ1nE zU(wASrlVx&SHT-JQA5?4ApXow&Dg6OFr=R3>+9L&r9%>V{&wfXyLe(Vjhf8`X6YQj zt)D)zY5Uq+8)jLb%CV+-h1y@A;+I@u|+SZoJ6r?P5eEIDqYVbo5Q#m~?E z|CTl3Rg_l?{h@%*Q*Ju*X&{pP1)R&P`*5#ga=NePbNE7@hyjf2cN;T=8r@qWFQIz* z8FajmeQ!yIQL5#i06=Q%fCaPk3U43%$FKZx(Fm%r`3EZs*N<*zEzSOzH4E1bC=)AX zf6SwXs|*{#LHUQ0y??*@-N+Zzq7f256sRLoDB7z^>Q(!>%QpmxDm-F5RqtY}w(RP< zUyi`&93z1ULz|dY<`OAfbTF4U+OR*_8=A{IE<0y|Z5Qb%V3CfTuoJU3^E=y%J!j@Z zGX!Fvb+d=o(Gd^e8r1tmjXM_ejlb$m8VBLXGVRkih#z^~sGaxQ#(cBr`LE-Bx1zdz zpBRU>EQjskaOwGP2Kl^Okso{U%ffP{*=)}L=lYM4tpRoab-v~nRJHGq<9xfbREq_n z_Sh#>QE+ODx6D6yxL0OO{o6GRx8mUz%t16mGdV+oK3qM4-K8mBO^`I^C9F6>hT8&k z`#oY3obE*rHhidI%lXYN3uosZ(v&xo@d_OtEigk>bTfZx=As^pY1TU8Kb85 z0(3rfRzf60e?rITariUO@TGJgz)MrD%bT!7C$@+ymh2=Fty>q&E|j3mw1~4?*JX5Z zE{`yZFCX9#K87l&fe?UCmN$iSH2Siy!s$Sq1{9)yvh7``_#pf9gTDGVyw$ef?G*AZ zVGr)U;oZmKo&V$%^6sv4TyOKOP9g8U+qUN)+kNA^kG1c;J;_id$Lqs{656+5_%_cE z`s?69MUkT(w;A`LxJ)1Y*^wTE{rXK1#{8TDN?Otu{F^%UR1)sq1NbGaYAUmIUFa0;Pyb+#Y>?2fqaUYN2Dh zqs9Vs;nXZ9E|oX5`bRqz7WubN?j0L_V18EQbpi{G5o-v-DGnphkIO?Yt10+i-w}12 z6q+d@&`BkLRK9v*gu+bysf^GEuIe5oX2J(V#BV4X)}X}fMo+3Wf4wmbgPyS^G5D1f zVEj*(p;S$QY7L$uX1+5#owFBl@I~|!I-W#}31q7Xtjvqx#{xpkQldG9W$>2BP#|NQaY&%+*!+PJeycx)u6yM&yh5cN0?hfT z(#hmPR_Z+A+f7hK%q7zy>GluRC#t$6(T6xCWazc86NSaWpxuwfKNI$Qrc<3ClTj{mo$E?u*DdB)64EQWpDu<{k?Wc#q*CNQ7`itqvr)2tUQQ8# zh6sjxz4mb(T0*Y1c)(3(%cX#s;fiwi;hp06@-p`19^B#1S=5rbXYaVn%g5K%+Qp&) zl#Kj=EHCE^a|xx8*a&Eu(o3w^$96CO?aWq}mI{SJVQFb~ zF7{c@?9zMbf57`r?wFA6cMy<_o`aXskI=Z@2ukrfGs%KXN(#reuT*CGHT>Ps@==-D z%UYRP+Xo%XKgH!Apr_ykx)TH_m>Di`VC*kD{E*DKFZ+mXOHT>YmoA@)yLt`B_=(fZnrp>z{2c6vIV1j>~q-=V(opi%$y z=Y0&d+S=NQ>WypbZk~DvI*UhqjQyob8Lb|Da;=9zVD8Ildq35Ws%jbwqjG3>MiuHoFYX5x*d1*Nl)dFt}NDZkDr(+;H zL8HLiy3FmES1_({Ney#nOQ+{q28KN$qxpd$|Da$W03`(o1pC;GOKQEYO8gc&mU?^o z>|tzyb|46WWDvaEz~ML!>WxX%wJV?7H|b-71R!ntN9|Jhd3DP<Q%TZjJXj zuajQnh-ng;fy6DSH?doA2tIXVGmf|pm>&u;5Zmu~C+C*+&aTt}Ubter?qT1@@3bt= z-+8}931!_R#64ez%-oDnBck?1o^#c)1hC8*Qw-%W(ywYOUci-H>DJdeBS{=14|=*qh_?LABrIb(BC(?%fUkY(@y5!7#Q`~ZR=3T3KW z0HSGDD&4Ji3vU^U!rq{orus(xmGy+#hQj{?4UgF&+AV1(dPR5yrpV)8bW`t7L>-YMeD?c}|fQ?RU z5L=Z?wyti$Y7-|r@;5dbK(fm3_sYAOQf@Tb!BU%EiX!(->B!%&Z_d2QjiMziw#l8* zDA#2%+q@`>rYNSkQrv>Bt9brGDl|9o798n2i!%nT7zp1ySA7gOJT)+7h6j{Z&2wi8 zWT}9JPj<+#UJbR9y|SuYtE-wMNJPKZ^+pSgu%qcTd6~(IO{1v%I|0KWbh?y;qCG2S z6)7|pMn@Vg7XrOq`3(Nj=F)N|Dlk!S~<6p_5dM z`q7cZb^1cTx1C@&J<^3O0)H3y8B#*xs|S9IJs<#xN(pqs&<$A@<%N1|7&0lL*Ain* zl0-?;sw%upl#$ zQbqIJT*o%3C<&3i!y$ci!xCV?f&>B)6eeI%W_E>_4rnWTjHJ zveJJAxfLe@RHSl2sm-j8RitPrdKOeNu<)-6lL_e*$dhL9zdSU_CKH=H$j^wHs84g) z*%~3v1gA%dm&CHm^!H|s0#l>UK5aUiO!nJCu@i3abT3pYvve6j{l#D{5zn9f;>!ov zfGMh@qv#HFHx5R+PK?60s(dBS%G4$dNHbK+J9)ZkFY?odj$g%@te^~Yo!fYn(j=xY zE1WCm7ywf>T}iOlqUf5+0NAOsxw^5>MmVuxzty@{ zC8x;}8r(fCK|`oKsC2>6*$Mq#t_KHM#UY0ncqc*f-p?&=#xxD0Yc(smf>Y*UpBW31 zf1TmFE%?ZvAD;iwM_^?SWjz09&e&zr~S4if7=@QJa~OeH=P^H6h5w)oVSBj z1G+p>Q(`2)#N{o_f8}!u5}BS9rIgv_w-fUD#zOgMKs*GjZwR=|Y5=hs`9Z&bfvbT; z9HiiT|8E$)p3MStC>;e7DisYS`=5W1wSAbnccp4{Ti*R9@F4$rVo1!s)ahm3=u{n!lH6sPpsOG60yFKV{u4!%%|HvqJu(Fn{l94Z&j{NbwT#SRok8 zft2tBk{(PR`nsU46g}KFaU~~8F7`t%(IgDktS!$ z{8PC*=AE)l*abTusrt+15gLaPsB$Luotl^DhQafC8AqzI#MVop_dZA8{r{}-A80mkPaV~1W|s(#Z;DWxkaY(y5A?$HRW&d0O@<6 zV8}4Mvd?A9pVjv=QQBClSo93lFCW0O^wD?>FX;2pC2UR|L5rgUYIbz6JxD6$SfsdH zw;z@zN#o`fgFepXTM8C5rodFxk88nOOi@6=k@ZF_#!FkZe~o&j!PQVPkvKnjjd(R)`tM3rXjk7TwFN9&|i&2pX1-&py$&X|Yc> zLX1Cm*X}*qn#@&`pjg=0Ji@U06vhm=dTqZ+D?f&^T*A zEv5(Ex|fKJu!K0JgIQoA2^hr1K355`BnKMH#_S8Qd=wKsgvDXNuJ{yvI-t_E&@w+7 zK7;*^{H7=<7!B|@;XG5@7zv~+qA6#;ZBW9lQ;wu~-BQV^5o1>x4UajlW)$)@?kBIz zacl3vqOREi*M&f`tv~{}Ly;CFRn?m!^PIfmy7?1~J#o&Jb{*>eDDz!nw_2+t5C3lR zgV@!WQ#4HotG$I%Nk64niVCI`JnnN2%3T_f#Ky;Aay-X5WT>Pei@14MfwN-;Mi;4| zt0vo`nPL1Dq=&PM89(5L*KxSO1`;Z^_-KV?8G9oNqG`tETZWG-jF#&gq(VN~?Guk7g0%SLv)@oNu83i;0AsI=BffGOO$Ja?Aoj8g40S#RNsQtENY*( z93DP=fdA*i!^8OCGLjhIlR2Xz=2~u}MX9f>4Cen>AoWTER80G3xxke^{&A@Ys3?705Coz8F76W`M3P{zf7eU*7BDW{ivfx^k9VE+ zrke>t^#`GK3H62betiofFB<;8jf4+e#J2x9@I{grjeUNe zi2i}DKbF~|`47O1Ow?>MHE4scjn!OER=?*X<6KT16Y@W-(hNg2_n+~`P?I=ZP&@vl zR1!^ZMqD82LU=Gq4RaM9d`9h`#OIu9MuJax0L9icp&>fopK-1)U{fZdxvuxXZzX4P6K5ClOi~FUehGSD% z7EBOCdUVlu^D7I1c39Lo-IDo+xOK~Y;{3Ui=f$JW(gzYkk`M5ZzUTxdsE9%|KqF-5 z-Qi74A)ey2sMD%hHLFI|dBb1>I84n6qO{=$-X@7r@){L^Ag5wz4)WVU%^RiW3Gqf& zZa6gSnNX)_Tt;ROu;f4QWPmQC_er`gWyG`?yMi&xB$@3r9SCi_WZD?p80!VVwjryg zLy2jN79*y}n5Jo*8;%<7*cjWZ*oN#=MB*m`HUEX6rvj+^79*zXnuJ;V>z&RCY>U{o zv9wSIP*u~ARZ-GpAg0BzNHoqhjf0!Vwv9y_(*}ub6!V3TZbPru*V`c|I}~&%SuW<0 zw%XE%JgH`}R)KtaF-`=D@oYfu)wKj{MGE8J~2k^cSr&Yj_1tF?%E>g(E-f_Z@6uM%NT=Nb;d0F|O<Md7=J({;XraI;*KJkn*-$aC-aikJM%)v$MJ^ zgMo^yy;XPwQTUZz!D?jYJ@-=NZN>hG4X@xvPD)BrqdZ(q-P4dxF+o=C!qPqBzfqWy z%ja_1^w?)P797X-4KCmxx3F4Vi0U=puhpZ4qQrIAvd#s;Im>c&F2P-nfVtuOj)R3C zH?o&gn5yP-`JBQOEtku~J+)$qQPZmZ*wZp=59M=#rYJ1`<8}Mi$+&5l0H)E5PZ}n; zIv0IHe39!en8p)Ykjv+pqWt)0yq%SbwVG+snq^WrtHZyJ5qw<(DWTaqT9It`3Ht9f zZ$m(XGt9{}s*dx~43Vf9X{P89IwmcO3LVVmz*6sEz&L(bxLHI0RfDV~p!# z*B#d}pzE&tm#u%pI@ewILqWLu+H0@A|ElXGcz-zz%K%GzuwXQwjLbzv>Gu_7(O7u0 zX)M5A(^!z@1*7>y_2%%FR;6040Q~;F;dtTq0V>sM<+JsB!&@bcP`gEavwl>qqbq2~ zPWV;83E_?u6tLp)1MYOO3cbwS*yEO;=ng#6Oh;+MXvJ*b76R0e<8%<4?&h}P7t5=w z<+#XoRS0DNtRaA2ZYq|gSaL|}by54Ws_8VZ->4%hlip9xn3SV}pv|8z>mV3seK`;` zjTaNChv22Ri$yR7@ca_M(qWS_#W?2*Ingwjd58n>Qd|Iz(Mo?n1wm~EMW#qbFebfE z)Cd{lOpY~8r&UWf4Ji=4vxdrK<2u;`5f^}Bv0Vyw5qyM_W4P+j%?LR$-C)|mRQYjt zC%L1TVc@V1ylS}zw>B{ceP;oh1=;_CFW(s{`CRo2)m&bQY~fjSDEGe#1!&3I>XT-n zRNDd0oH^qHxV!f`fvMv@YMSmoD5U_YJjcFkn)KH94u@>FDMSM+*v<(FzQyZBn-0=a z5(k)-@G1}#DSHUZ5`~E`Fjk+m{_)V#xn`WPau8gBp99(5jFccKGsY*yFZgm{{=I_i z!%1f0e8BSJ>4w;{N|&R4U&*qfgYMiDaL=4^p(^64H3Wd)qXz8|<@xl{m=d6&5F3rG z+$O)Tr5V-fAAgu&FMpb1uXaYlzHK31kC!|wovC@4p3Zxid{~lY3BLSk$~=6UG7sNE zm>0fumk+VW$Sv4o^fdMu{WNJGf%ZhZjT&eTAx$hLrjh3tQ^pGF!SJL>Yvmp=!`b$o zcW!6+S1k>L#dCu2*xw}J;(I)2wI0(cLRT$yu>6yrbsAZ%RcQyZk#eVQ77|;YxT$v?>tFw zH-LY@2C3L9zDgHEip-t~Kchk-$V28>f@oPCYMSOQ7$ly(unGo<8wouGu^1s6BfQL{ z543nhb*bC}s%T+1B9gPHOslZnK zAdWL8*#szup%#U3#&TB}jhB0bUdWgv#h5J!LY{PNAWyti(- z*%f7fYJz#^od$q8^<~jL?oVP>xI*4(@|Z-&d}sGAEEtZdbi1nM81spa_s;&$>Z+w0 z&e5Zep;|A>1Ld(Us|(c?pB{z2NKd2#UUJlxr;djgi*ZXUBByETDrt@E`jHV zq3_9D_lB*V_wjjI)-+jOJ8qMnTY^`6Io`mEbq#)car7{4!F9{WEb62-m=%A}7Ofg}@_1N0zb418*}LVqKPo=Dp!0!AE*OO|C>C7lt~ zH`ebz1kwo8Z&MV7F4PPFqqabmL{kj_L)8=}%S=hrg}=6jyspL~n$C33FBE)FXSznJ zQgE@TC|`XyA}T~ovGaLbVX7wU8q;)jmDeQIr*Dxo-REzzo^Hg$bks0kPd(0Ga%Lb; zZ>E1)UKT_ZKoy0jRgf_gU}bOQt1DRrgL+;CeC&M}xn~*YvixdmFiO zIk$1^D#ok2>H0@+z4fT?n))intK3?)!^#Hd8xcT#Hzf(UEkQ9*uDAhBB&oR5&hxDf!SKKrx5L$i7%jG|A9GXLav6h;C3yNNSF=lrSTrt5(CqRbTS z_s&^h!pt_|%c?sc8!{#RD-6oK8;JNtRb}$8EiuPY05Nq8P_1H@U`&k!C-p`&Md`wr zFk&>=#+OgkNkG^N5H4oqoo$!b88>L_*BfCdEe02Z95?lGD;)VV8 zj~||=YTE-!o{f;*zBaU?S!qv;$x%AXAVURIDNhQD3Zl(r=$k0!WK;@L-lH!1b9sCN zrRX$z4SF;B3_?Qb=uJ5c9g!yjSP%?3<|l*&`lL&}Iz@5O%aLHWItD?O-QnO^Y5!kF?@`HrF~ zXg0TscTl9lvTb)1TL)ySwh+=S(5;#(bP3soT!_sCUJubJ=y$440YC||fqS(cQ$HJv z#&ub1I-QD z05D|uiDgQcEkUM&wM+r%^4>bEA!CBJgI2t`d9G0t4750X7Rr^Se?!4@V73_NId>5q zM>inUh=sW%XxgwDd0w1oh!Nl+xqa;ERe=})lmarW@ae4@8B62GEBy2+nG$RVg+gFs zLgiCMaf@O>D2Bb1$TCTLVNnn;-4csG4Yl&0rg6@Dy66z|kVza-@12t*Q9en?Nm-Pn z_OL0+R6ZIzE0VP0#7AW+i}7mv1+bQva+0w~HP=i4bInvEgb+jQ@&Wu8?ECCM9n-~g z)eitfZaB16b$GyY z*LrZ(Rp4ElIcgm$ zU|cxVik63)#eoK4eh-IXHl6l23b}5Aj8kF{?OtbCR^SmvHH+U>*P;dcWqjuI8Q9ow zQtNa!YaG&68zg&XwTCvnE@*MsGYK#{i-fU<PwJ@#9xtef;>UqH79M zwywG6)?2T+W=laeYl3oiOEp#gLM86?)3o1f#vIM)*4cOcNX3WgH!0K~c&`Nx#a|A@9v~HM$!;j^2S@K%YflE${mV_!jvA z8MJ&_In1LXTu?>RS%r+*+oHFN`9xP&0AaDZR6eptdTeNjoDiogcd}kC8!Y?FhOX!j z%#y@N4o}(@O&hz*{-nXCYd^8ffh$IJ0u5Ep^^$pnuWU3&&Jj z*1G`t`NYFIcmB)aI`-i6{3^X|ZUI=Yp8tZQ>$>j1^=J_8lEj=_W`t+VRq}jAg&VwKyhWY-5 z&N3Py1oSZCm;#n5SbD#i47MT0FYQ|0hM2-l_k&tXhGs8axxZYlwpx|>Uj;?Ybo|z0 zE%(&&(;{7vo?3nyK7GH^LChQPCfWU!R;yYr&%c+eEw+5e)QZ8=%TGxQRD62*seTYY zgi%n2h8RKsCn5@63259p%KGg};w^r&t3$ZHlmBT23mUgf<6j!^nDh8bwE_ULtV{R* zCGYpDAzVur{*Pf>boRjD83Ge=wH1 zob<`XO^+MZX|keE@W+sWbz1O9Cl7Vxj9X25!(OtEd;K0rS+tIOu_b48^@M|UA%_O( zuWMTudVM$XvaGNHhJ1KOsYl_~lcc&AL!Z`kxgI}H(_3&;j+l?(d`M0tM4T+jp8une z`+7zLF|xl;n4Tp|7yv|>NB~%rP1iGI5d%n+E^U=$%d<2`R!Lu1;SUNZTsG6q$Rna8 zxl!awqBz|l5x;Kgx_!05mlqXP5S8$ACaB6HAS1?&z!*QSv8xndo(ytVQ*UM34Ze?x!pk$c9-d1>A!;Mk7!o7&BKB9cLEvGQ!JMsx zCNY^n59C6Mjl+}K!ODWO)j8!Hgg<$XoKI0 zw}INg_P>haX>F_{2(-x!n>XiKoK_b2=<;?sb;}m8BKYSog9!MIWk6s>Ek_SyhTasa@I1Uhp+<$hEZVp$;@KOQ^ zZhV(@w2K}R!eS{H_Q*-tIstJdiUKox&C^ehDRaHWFRBhYW<$nYx-bO zdlTG3x8{wn*41>B^oM;u0v_!PR>P;(4~kLXhr}q7oL^g77MhNZJPhIehqjD&LyWck zu#eaem*JV7fvrRLPmx{p1!0@*p4=={`qMp1G9db-P47virjXKtpt~OmW(kfVkgh{d z8{ht<)sUTzCszJ)ygyNWl|!FkcnYx$ih%T#H*RDKc8gRH*FOGq zb#v5MfO+;Rc29JX7K#zL2}#c-gLSEwng?VA*ZPo=^hg_?rB+Fx{ZFvH zgl#)q*~A=J)%RLYfz<%*47)YxPO42A57ZIsvv~w7b<*(zKkiVxCI&tUk(7!g&xFj7 zXAYxg9EoXW+oA}{LoHY#uqwGu@glqvjwC^tcO2RlRn7fe>?@oxGoqCjMzBX=_jF+B zq~_%I}W^Li-LY%Azvw5u4@%)h5UWGAlgf~cVE)sBd+_%W%Rr+LU|k%;_6(@#Gt0d zZ!=s?bHKQbTPBSvvaj>uR~9H;xb@cG%ljeJ;8mthMeT@si0)fZcb;2BJRcZCMKPC^ zB4rf{uP{zMC0iX0S1Ef?yXtJce!Wj<;0sa|mlZ!5LTW{Q>}Am z*vlrp(9GZhNHT_EA)<<+`J9k~51N4Q$~PKIy3ClYFEtwZyRe{vUm%40nxfFCPy{SX za1rv7lm|_ht87x|dlGKwVu{#Xff6tvV6JcoEw-yQEtqjVclflm{ELVcxy$6z}7V$vS$z!b!oh zDn_lWkRDEFZ^hMEmutcH7fYJ!u+l&wLOGyg{=cYcx{KRg)1=3gdVrFWUcA-1a%`A@ zk}Y`^r(U^k*@09>(A=D#VYO;Czg%7O@2|?-8Eq9@nE&Id!!&m|hS`y`j@YVtI7g|f zy<9HW_RfHEd0DPym}7oVrlCc258rGavBcoK7mVPU1*yd9rpmLp%%ZX{xo0!;774*K znnJkhp>nV*V;rkev00QRfQgZhd4?mJNrRG9(jni&p!g8k(j zpU5|+vNawbuIQ2^in(Q6CIpvJqsJLTk&PJRkjj7T{)zY=W~Qv_uBob&GY7z7oT{qn z>Z)uq?D>Rg7)uh9EdWbql7z9w+L&D*Mmcl>p@1m?d~vJ>Q(iJ=T8JCtrtmHwg!`TB zK%E8)0tmV>;1Xo7IMCnzDwWPI7;2vyTwcUx{k%k-%J;1T?%O1squikFgc>FZvGlMg zFq>ukn3TPjB#Ohrt98(C6e>W;i>mr}Vpjvuu752Dex(Gtg7fr-^Eux8r96@jgL))g ztu+(?ne@@Ju+(Ku%YZ1kt>La=6bh9}p6UW0~W6^S7eqaB9;9D7#dW*V&HSlCf@ z$J?9*IM-@JX_daFc+fwywzOJP$*T_z`bzKt4%pu_2~lgSOKXQuf-ZeQ!TemCu?y2eV~Zi#8Ss$h^c z9(f*g7LSG;qQ8Mh7dzm23x*~GR^$U84_}f2D-l4XV4&VL&QNpB=F0J2Pbs*U1Ly$X zlGyigLFx65uQZ!E)lhYf{l)d0Mo2_sT!$|h|958738Bq6aR>(m*F7tz$N3z9pMDGv{lKd}tsOsB$*X6!TkD_e$|=JyJwShi z<9ws%ho41FEMh-(rL(jp@KkHVk2PMK^X*#` z81F2&)%d7bdwl8cw9FKRR^9Lz>7+&lpmO3p7gt4rR!_7?TT30q4tzI+-P;^n=`3xH z+QwV8dsyqaj4mU|8K!ahH1Km4FyWZ&6hpu^RWcrB8{UL84P#gYeua}HDHE`?i`-S; zS*A7Mg(A%=_-1lGFUMlJeh%U?_m?-z!{fEWY4(WE9dNG@w2I0@?+YrvnI}I7;FohX zC_zhd(B>h!8&rm>S81x2B_33w-d)4_s81|d#}ctIU#deGZ@Da?#9Ifq3e+RC7xf;4 zFJe6=)K6fo;6%APz`4?MHRx?S&Lo)DTDS(Va4Eisvh0*Ab(j|CDDC_A%JjSoTkZo~ zloVQPdkZFniLAA&SAG9&{%$>ko=U&>)iX(^VwI35ZpIf*CSj7Z((51GEeF-*=Ta4F z`N7(fdj%R?t9h64({}YBq5chDGK2Eja9AQx@owTIQEP5m@P_e^5r34wNOrjsw^k@P~K09AAcV%WO}|eKhL>k<;%53ue474 zSM%|Dmr>NpC z>_oM{XWmjIfo2aE2L4xK4UCF%qo6b#=R}44v!W<8oBwQP|63EI5Se%n_D~+3ep-y0 ziQzjl=3L?%nv-#TxbMXaJM(#^*E@b=CAZlfL*QK7<$PD;-0;u+>unfyj#00u$48yT zqoX`(EytR>YgxOBM9j72cKhm6Ih0-3@kiorNzqf*i)2hxL?&E+Khg8e z^;8fQxrF|3jV};yqt|NXa=Bcq)tg%rdgpUn^MhQ_UR+sOYzMhl7QrF5dZ>b8v=!Nu zjI;J357}c-GvkLe4%JFX+Zq^Q1{W!B(m@2)%z^{|BbdLuu{7levE-7w#%D-J5XrYJ zenYz~AuQEoDan-OTgtJf8xn>%B@gL4Ce5kFCcz2MBsRi%=JPk`GJ_up9ko^#85id) z^G}{h2H12s1ASHosiizGANZrWah-eewVpq>^vmtRxvdwjS2maWiO3#A{`xaj^ei#K z=;@}LR@s6M<%=M)UrQz!!+EZdGj_ljKTwHh!$El2!jBF~%_%5q{V6#PCd+IvwBN=V z8;f4fF#nLfLB*b_w=h8gm>MPn_9fsvOJPPxxEw^-%1PYRK77JGIkg`|E4Ce~uS9`A zq<7rQDAfJSrWs6xJ)iS3Y7`1&g_v+?dW94U4YHyp1m`7dSE@eE`kxyR?gVP`OS=R9d#Y!(~G`j4>57Uu)2`U)9mAz2OP&O zpQl+mi5wfCYB&m(7rrUG^#`=c{V0QO*1XyfY=ptlNmiT8C$ukyU}7F|@Uwfa3GB_M zFouwIOGgHNP@huE5Pprr*!rJds8{gb4 zej#pBw9jjmBnu_|Ze*Smc>E#@!`&mIqZ?rNcH+~`W9n~SDjX2Ubn$)=laOn6E3Fnf2GXB@UHoLT}TLIk2#Z2aeUtnpV38~`uX6hmv}bL#esrw~G-)+zgze@Nu$r+%!2n=sxkH{1VOX(sV54Ef zbs^(X!j+{TnSXvCcxnE5*)U|dPBsi#v>HYbhKicdR4SD%TpdpDF85!5wSpZO^88=- zT(V^X*P>tp5~G=2XTv&Fw1E0vJQH2DIzb56Ub|37jl=9PN(usJRXOo~l4Oz2oycpG z`$y*^7q*<04MYB;2|YV$KU;eI6=q+EK7Vv=@?X2KB{1aI%Em-#KH0R`ZZAH5VCCMf zQ5_6$K`PLq$|&)PcRaf@PeJS%Ou#lK2Lb(Ty*-rC3&CXhm;qrHGS`JTwP0v=;rR@V zW6NO{_Vv#9Q3s($G)#yYONh;l!^rbOyRpF*2oJJDfE;h5*}*bIY|-dJ_WBphvN2e! zaeb$b-vl$-v4ty;9fgd3r6EE?&)48ZGio&z)^76yCy6Q4DR|Ea*I%3mZO|m9i(yfG z;as8WYgq#lLj)smW7=S^UH=0(3EJ&oZGUHHrx(?kpxh~&rhKO&uzJ*+KYRKEJ8xRv z8P$8esLpWQ&uCfmzhE_=Vr+3;_zqVAs`G<4ykQK!U$=};bI?C+Sasir|Ne$Iym#Rg z_#(lmaS*OB!rU0^_ol(_zlqR*S%khC{!cHy(zS*I~~8ud}gr}f7gRy z;;F~tIm6d07oEu0r)*|UO;e)Bk5g%SGXI~*8C5Oka^?B&$@KVfD&LPrFiboz?)LIj zdU`wfk5Z}ChLveajGW_W>5(f$Uc5#d)_x3XC~Mx3=skQFUI;vZfYxDTCBtU-P7aO| zl534OPE~rAx(1n^fqS_hrmdPfpUesVJ?etZz?d>nSjNViL$;-=L;Xy_P>sUPNc2G? z)bHdJF;tpw7{Gjh<$)21qXpk1*n18~$2xbuS#*-iloJEMD9TQyxfw6E>{=f*!fJch zGy~3*=4kr=8+E4W0A|*cvoL53yUA_xEY~CpT>dlAr(<@Qr1aU&90-^oL|X=cEl?m znG}3|8ve8C{T!W4~{5Lz1X$ z{?XrGvj1F{?l@e>xuM80aFm^z9+<4ACzpQ9m*`)?7|O4_aSr*v8jk1 zNu?5Ww6KjONlFvQJXKz<)%EKHhh$!BLtq>h1WI+Ts6tcn9I_5C>LD9E`?pOD?ESji zxsUn-A_(Fzz04Mh#SS>SXSv%a4C|le3h3iGe%S@E{}d*XXiE8Kb0SjMsxeCZ02q$2 z7RbUrl@9c~3oYJ~DM!dtoz7&Mc2U=pq;iUy#=Ye7V$4jE>EgmdVMoD&&P_vI3%nCa zv`z0WEx-%vYIS@_!(|TXw9qqQBR+@_zB+MI(av+1?H0U9oif7rypsU1M*Z_dm7Sa;R(7t~Q6o zylu(6GY5ru0%NqGLt%0*GKL*mf10Y`})+ayd@aX)tMXKVyagu%J zOW`@xmH4FlbyY9{D?}>gsLkW@% z^8+BNAR7{^Kxm*o;hQG^Kn9`}-ucG>RIEbZ$h;LgW!W(q!9{=vGA34}zbYS*j*hLz^(a)P)Jq-_YUFJI=?}CtJTf{^LCE zPyOj6Ce-W6S@iAO(&4kidAEB`eI9*3&5Js;Kf5mv=k_^<{`MHhqR;HU)A!%0GNr?M zc0TvtsWPp|n6_IHhkWKh#w8HCNntP>n7@~!2v2pGaHPPGfOS>XJ~07944gJ)j9;n( zbxmI2gpwP!r_@IuQG$p1{m>%{n90V?Q12qlMnVx+LIEZRDB>Ni4JFBej1?M2t^&zr zD2M0|w)G>n=uxrQuDL_9*2bp=;nP>N3kir^p27c?5nl~kc|<9KC^C?l8Ot5WP1LHX zX`*4|;o}ynK(^t?7SLf-66u4&VV9?=)MKnC(T^RYLSetCKelQ%Jk1%KWgCd%^RU!Y z)gHz_Vwcqmm(DQ8vjYk_7;|m?Tb#j9A<>f(3y~CCft6aR7>kpTTGa3ymxak}7Cx&p z1%Q8G{+bWWz6zxj-ki>#1$gT~qEw&%l0i4%4IA?}CL*OeTw~Dsba?q2eMi|66;=>L zPj5?9ZA2Sk$Cxqs_3v3yoKJ{`pcClmT}2e7+ku9TT}J+kzl654UL6VkY>!!-pp>-7 zifnGv33B@`|0pf`67^xuOauu(G^G7{ z22g@&F7x~VJV8&)jPB}xS#a}eqae4|dLf;H_wW{#`fe$y@8 zZIxJN0HwgRh=dV)+;qF|UvZe` ze&Q4J|7ZinQ1HCPTd}|Ph;CW)agPl`0-=50o%im08nqqAku`5$1O4H} z#j!8M*Mz^HV|K;QGx2wgnkbbg5qS90QI9nAlOJ#{ImX?)o`-W5b_}yI7+!0PS(d{msgBet-tE&P*iRfRp$+#}u z@iD~B4IlrV`=olic|`r)8gdb@K&44J6m`%V?r`%~bRNAf;*{Qz522q%pG99pGxTlr zXXtMb&n+FO+mIPTv}h_pW+vLcZ?eX=!E9!G!6Qq99V9t~0fG53SN{vUj6e6-<+dp_Z5_xm z*`~3(?A52Q(EjEI4f*OTXc$Ealb=RkLsGj=lfV!9g&QE{xZ6hL0nO^s4`pZFlh z00=}g4UNuLt7Un=l`LtMF?d|g+-l<$I{ZPhF~p&XiuH!_gHYn1$X5n zPW4Frj~YLRC%wdW#uqJ}rQ^DyM2=i}{stul?ekB0Y{_L(I7lh&H4ksUVC#mFg!1xG)*z ziEi$nJV=O*NLT=#)1RmkjK7h?LSwWer$s6X4iPM9f!4BNph=@LTM((+yjF{3?9zSL z86t4%{s2zaD<1Jp`i}lZ?Q)_&D5c8ve6Cn!qs9WzWlhs55d_WA{8c-#eqOHY-XtuQ zj$1VFkJybX^V@a=-^*>haRrsztu|_)ThZ%e1EoQDsis~(x|py;sH8yey&(Hf)Z#?x z?vHdo=7ASdKaS9w1fzht&3H9#M)1QHVJe<1&Sw%N@N56|rD|2eio$hS2G;AJNXdtn zEeNX=&CqbED2k4`5%(CiF~;FtEed};S)9)VQDAu#_WdE0N*!I60V|4re5vy&FF4h6 z4brQI`N15WyCm;A92KSC?n6O+B)&KXq`qRQUUed30?;#}R;dWUG~du8lbf7-&WTbB@_XhB?QQL}W=pA?|i*>XJ;Px12>HswAP

gcuh(yM)5x$wXlMQz` z@{Y+%0_31o(9v@S+%W?u*1FKJGcSlS`B84<2*l%|X8e!02*<`c2Bw$>zeiVs@c8Wo ztGRN?u|&bY!FI~Iol^82kEeoR*%}8h-ZB3IL;8-8GqxMn!DK`pPl2%;RunG_4_~7g zhOT2lxms2=H5iZI&Kp~{DCTm-qH|0(dk1lNydz}_#@uim{`%{}etCv5elV+f`m^z> z!bJlvmbxWOSh2YZ3K80dz*EklGED2tRSi^?OLw6eWwY5Q4aec1nmPQpEHA+*Dla1c zn}&1geedhNPE}vmyQqH93B{b8xfD7dROz&l-gat1!lu-znAC8#f_~IjkGy6(NJ5$>k%YZs=qfI-#IYw?so}I)OnAeNFdqY`YxkuA`^CuvL$A} zm}l#n3D5sy@K#e4{=phuWwuSczdgbGt#WxGTPT;U;zFa-X)F}yj}joB1E}q}uGgOO zGB&G(c+_{&_0?`dw6GAtw}1s^8swCod!3g1rgzGu`#tFRW%e#6Nu;0HESTPc6tb^j zq@X~)NboMm$z}|Us7=N4N6NpC~!bv`T2^~he+%N?E zovIF*5Yz=mk05h9&cbwuS&WTr-Ik{L9k2!4{ldd^|4da3L$OROc)+=3O#|L#mr_`( zn3Z;?RO*Ri=^?0!o5>mE2dEQ|B0WpNIkfK4kReruiiZ*FYI zKEKV%Vx=c*-FS@#{)XT8ZACb!s+cV9p+`wT(-5z?&IT>w-0PD%z2{am862@3!I?>+_ zok_TlGqM)kzrA2P@p;=NYMNnani#LQTOGR)%Fa7;Wv&@_sUGasjhne*1&X8e_U}b+n>KGFP{X{LE@Mlv%lK_`&Zm}2IGgbkMxGaZ0Jf0W`1@WL zmY)g~B5$}3ZoeOh83K>N6tK660S!g8Cj6m5yrM&%xa^m!K%|W_J&H)@#;A&in*fgS z*ZY7oW>^;I{ViMeJWkW=E6BdXi7-HOd6^U0cLh6Ia0J(v371A=9N~^H z3+}P#WZw-QUgg4zJ^j3eqaV@fKWC9Gai8%lsESUuMp;BuuC5tN5B6?6@{VH8zhxEr!a zD1jZHY6gNbRvQI9+AAcdljmVodoiZSmEvKr4nq&7N~KyXVbonpp{?^N=v=Ifl2C>V z(DIj85xEY)WUOJ%v4&*^J(z*PoAIeV?&R0S7@zE2ARpNkas zoG$3^U;y%S9DqM31F(0z0D$z7Z441g*-7eA+;vIO>V?f=9s0+@>a-0S^%T0_xhKD4Z+b!z1BI0V&3CoPm8RpAuvG>J@LLsJr}~ zS8Y<#+rey`Qc>GUl2I`lRyvxbz92WiUYw{=mZ^AAH*W=<5lIlu-HJME=y7H~jbgzv z@xas`bDz3t6^hG3?$v9LmLv#*g{$s_Q`D#fXPD%Ic00%s4C;$2z$-whtnpsKE7^9j zVuR~e7d**vi`;|87F|d37OJibb|t#Ojg0t8W1mzT}@8!qW&5uW|GJfol8qPN{xYT?HrvGb93FZ5}U4s&q z$*L+_YgE_i@1F&9aBM)_+=$G;a6S`Uv(JyV$^zenq2g>~|1&x^|8qB#wcrI7&3{2h>F2x4SRb$F=(5 zy5|3mNh48F_*1_5JO2M+gkBQ}VgN??dxo#o57*Veu^F_cdI21eivs0Qpx;AGN?+P8bMO|vTQ>PztytsRWQfas#D0je_~npTNdZ{`{lB) zTVoTf&s}kUUS|wT-szvz;TH~&-?XDM=KOxnE$e>Id&6!2{Ao|ZjOqFNPyZxty2|>0 zM^S2d2t&@WYp^0hF;SnRib9`L2{m>y(=j^+eUxkal-@hXW$#=c32+PdbNmP&}Z#bX9j{1(>^W!s8;sYZB~Xjlc2 zO4CL+82IJodvNKdl5f3<86dq&9}ZDo)svtAlISL!=GC^Xrc_@oj~dnM^!s<@a0;p1Re zp~sm}mliESN^+FIG3pn@kV$GTlqda*dkDGbvsY+#T7A%Qk_?#Q*Z|_j$E+JQZZ<1{ z`0FG`=>ThJO^7c~&kob|&!jkWLfbS)tf8w&UX@lMt-$8jB10cwtNFdQ6dObTlW zBG~s_-RGi?A8%TvS!0h;zG&#M;!AD$t0yM&O9T=J&J3$`7#zoJn@+6s*+tQ?b3&~O zgdLW$hUNvLy~X{@>**qW6+9uoW>wm_J^*lgv)A9ztFYg@JWOjx30WYrOpexw2gbt# zUETa2-emyx7_A*8vP>2TIa;Ii1g&d+`UoMXW)n2f$(AGqq>*w^b#T-w#)Xi97B-E)4ACH{ zzJxwECHqiP-DF=9iK3d*9~_JUO`)*}oiO&*b^Vew-H~pDZIjfqZLm;{w)50=znz}d zgstOjvsjQq)YTM33$ZjFZ|+@h%m2=Pa;t$#JeA8eFYiqTf2hctRz8ab;ooguZTO>f zaFA!V9>8UI5m{qb9IJ0TQGrDDSZHoTU`HYR9{*USmv}+xKVQ+;QSFjO_Z0O9R8{E@ zRAsLz2^$|G0AvO*BLL+2bwMK1Ew@MnKB*`#nEW!5WkB3~p(T>B29$zn0;Qmhi6}~U z-6e^1x73~XyDFdt??}?ZA3|&xxY$hb`i#U6;Etw2`W%_#Ge?aDIM?Li5gk9|x~gUk zANzA1?FTN&q}2@lQ7*8!&$U_QBZ_Zn_RNA~FhC58C$TzH3C78uvOzJ5opX?j zlV((gJaBY0i7hNer)GfHOvaGCjt9P46lkPV)jQRv&OkL?=oj zh021l5@j-6;{hxy)&%=JxoIeKfaCg==^QMZ&{H>9YH1pMrr^h%?_kG011GW)%j_ev ziF$9^Jve{^5I4RAlc5WmqA*mlb>{uVLZfLFr!24Dg_=$V-J05MFj?pCg}VmSh zEWGTfi99QmF;2kNZ|s#|h&IM{E@xw*RyUb89*GJ78X}odE!NB`%r>rQial)G7~3{J zZewiMw+T0AsD~_x<-71{_oa$jQZ4F&0|IWF7wZS|?}*O%xe_iGdOu}qhJ$xa&2Z*F zQ?&aYYu$C*#>rFHm>a7WxL|0k)2`jw*?1;S+TY6j{|n=mQykB~v9p{nVEbcjR&~^O z@KheEqmfUj@NziW-AHv2NG=1APE#)t+bK8ex%V}yxSMaO`|H;OE(2?$0 zJ{~tsrSiZA%~XDJz%s&51fRM_ ze+V$PZ{a(SuPkH6boh0hF}%ET{OgXYd>9Up_7`XVEZ(aty{%ib1MX0h;C6$o(9;5wM%zi6(^?9sUVZJFOaxq{*$v9rF{M zx4Cn!v09uPfs^DuL@|M^!%=xm%~+9ImEFnk}9i)T4T9w$Y4NhgG+F zGfv6bk)RB|L~R!fuWp;lfzLNKZX^KQz(6oy5O?m)9^{#92wbK)Z=<)ep@}j-QIm8| zOPk=&UzH~P6_6V@Hn<;9nG1%@@_{IJksuKFoh9DlEpad?*QMGCE{ob`>C9}2@>uye zf?$TIr&#?p%K$WjQMwAz)aECnG)^OIeg7bB#jRS;7dL&EF9rRh1fmWh5G8>+lV)2NqS^u#E8|~G zVvJpeEj6cs!+Xe!hWlQ>+s|CJG1Rb+u`im>nUOKnhHHTB)lH&tN3xs1)k>WygdGX{ z7kn&rCZSx!ZxYM0=@-l?IGN20bzFvwsVyP4MeznJ4dBn_4WejcT*zs2zPCXb&<>G} zxp5a)HCEl(sWNR<_2!g01z50R7?!CR!&R}=rGQ}yd@v5;I9N=ZK%ZVytb;-auHH}8 z5E#D)&o4Ba0@#*2b1fSncdx1{%0tbCe8k9%-EC9sf$d@1J|GqPY8FUsWAlp0^;ogU zJU3eF-J>X~`p`y=6gXq^-8N+&#vY?>+*a;?ra19ar|}pPl({n4Kwv!@Sdd1Dd0tG0 z|F42raCj$%k+%tvmkj&KF!HkEm5SVfv;Ro~tse6VCd>JnlZ?0~MNo!(DRw}@!-@g5 zr%kA5YXD_MLR!NyLP%W=9q^EazQPNZstH%k53Uk4)p}vgzKroNwIOU%?9D&#VM?f# zEjYb&cw@&H*`z@yQ1KVf6l%eC=t-CY!W@LCVk_I%cc zecdU(%~B{=F%}w>QpU(xJN%So88X#O+Zs`hzj+GV^PfC{Lc3jWB)q)CQ&dM?efu)Q z?q4b`YXMD?xRo8e=JdP1u0y%9v{WgBt_O@WhKu@CD`3XB{0)k|fA=tL&)Nq(2ue$l zq4&;|f`Bu|i_+enROF2P@8fC}5nP_ZoTj&0(>y$j=xBMgt6U{chA0$!E9jMFai6X&MB3Ns;Cz(sESHtO1bXsUQ9O+YDZJlqEk{tj{B#P z&Bo1ly9NZcG;R*Jkz-+-{D7u;vO(jM0Szh4T|`gJ`GJ|}7ee{`o5q*gkeyY_`#ekm z(SALT76qWK@7vfnmFE6_Q!&R1!fi6(OAmr!YmJXCp>ybV61C3c3PA9OqNL=9*sl%b zieiujs-UPfQBEHK%mxWhQ-PvVl?I?-bINgzc7HnC{Ugu5d%39n zt2=ou|MElGAl}*IUz>mbYqBYMXOE{5SE#ZuagY0+51)}}eD(6+U%wCXt7lJmA}3#) z|BtVoJ>iK4g)?zmh`-kCDc>oimsMn=1?jEz!%zb{!ViOoM_}}FpFC`bSK}s)7u+|5 zgqPN46z!8atMYwjiP3!hs2}I(Ac+4Y4gxVw-@o@Mj4LA34aR^GyvhVc=S-0)Rv3R& zVSM4xA20)Yrp)vX0X`R^q`g=uEVdItllD8pnS!aTFhgeo8_X``x*{;HFl#*)dVie+ z(VNO~0z!rmfoFt4MZyHA11JJ8>b_8K(=T+0SS+lU{ISb8e9)0wh87K;j zKs&8*|1Evkv)|(LGC-Nesmj`Duu&&`!4Voz49-OHaFl{W7NwwpD@0TEi6_XctaV?U zGB&GI?3EwE9;0>j(eK{;#V4e0=g{X}OrI45EG8<3y4*kZZ*4NN=gtqytWKGSA1Qm7 z)@O|U^%KYXi-28)J}QU;&Wl<*#tEEAM3*|8C!D34Gd&8v@XV^$vw6vZEWpvV_}EZ-wLj68aW(E*vx~73&9Z$Tm7Z zHWO7V)|TcU_&01xqA+YIs;(;=jB_Pqpd3>`Q5L|0*T^i;Hg%ow9}j^U@0%Puww;IP z{!P`QGAt;{`@aQQsBq3U6kS(Yb12j+FG^RyHUJZLO%PH@&Or@AM{zT5$+AHB7&tDZ zaq6RnuK2y!P$1N5Dt@p4sN~vt+y4(lD2Xs?wMnrwx;7bLk>$@^cm2-7##VOZ@c+Wa z;!0~p6i+NP=+UirRD;vqu1%rQJbbb&Y&4tZLXNDg?DnqOV$P8>XLh3H<<(!~c=y!F z)%?+;Hxnu9wHG8ZNEbpv?exUv=CZMJ=8CgI_`PBjZEl_}hi49L)^6E6u`nDSu6C|J zalAQy65@7aSyR25;~ujA|(WzUz!?J7?l6Uz%Q=izj zWpfUi{BvVP9%{ zdk2(b#{P0S|J7KQi{<6z)=aH7$gTfIWVY+-svuZ*uig1g!0^SN>TNHme+#8rdlQpv zE<0cM3eI@*A5OU8ue0PlitEUC-Mu{mQV@6a{wxw#b-fVm;t* zDV%GsdX>hxq5(Ec$Kj?E+%?xthy%C;;Bp;QP2rAX8W^-&KhOg&>$MLpQCVfNFZ;fM zV>|m=bPnB#?z3bz(hk_mg>^h`e1s;=cpDxu@QpsHYyI4*5zMoWX{V}M51fidIg3K# zSfY9`N+ZF%ZVDWyvNXcB4FV=KEIz?aDqz9&nqdW6*L2STJz;3Hv=vv^0k8UdLxG%P z{Qch>%KQh4u~w>v#V300zO%~<3zhZ))-(VtO0K7>62`!Ju~J!Bm}(}p_vk`<+gqMb zOw%+0nWky>JkNujRoij{WRCP|6avSQF47w>Xx*`*WYI8a#$;C|NaLK;_whZU`Kigu)i0w&qBOQ3nYo0v|?}`h4BDsED zzSEdYMXYexhByhZWS~>Bo@CG%ph`H-H`sYQ;sj#5qT-?=w_umlaf~}0m9;@EK|)$!+|rOdwp6NJG9;FA{GXnp~;zMgO&SHk`t5 z7i}9Z7*`5dC}pOYxnHSx2bg4vNe}ykknLUMDVSp&)J{{^+F~{x6ixS0fP(u2>F0_x@kkxr%v8nf zvn3M?#L6ZPkeO&KunvFIzRfLf-5Skxsm|rLUwht#=X9D|AzYU~t-DSb8tQdaC2a9o zLe8XXao9R>>=#DZ_puV*t6`Pb>o<|QaF>Qb{YbR{LOCZ|OTc7rvAeiA@9vnu4BdcY zsg&+1hIuD*eBiS&rjfhgWds){$yM`Elg~n=aZrbn>``<+&P>KO5|~TwY5$8ezUr-_Q4S+=y@LdHJz?S|$!XWds#383slL-G!pOU1Jm0 zcO)=_w#T`m%DqF^UjNA!^<$WB>3u%9fpz=AbP5gh%y`8-n5vr{ueL%(VwUQ1Z|f$K z5(6G=#;dac8B~pO#{7|Eja4U71*vp-{F2xwd$=ow7-h^&2mPK`6$W1XsewLPux!bP>x&PmT=RwYPpgiKsEty5JO=n` zR>4mxz#>8RG)AdZqbvh}=?(F41RB;1YWV(qDbv+&YRWftrTa*%zMAk2dtXaq74SCsibj%2oxxC8;)Fh3lJ?|wcR z-w#1Z(8lKfSZYamMG*qWG<1gJ@Q2SQ<9dohbCJl8B*M?G`rS`k^%G?^%ztH(mkiUr z+7V-Xt+#e32xP@)lQZ^QulK^{6oBIebH9mwaiPZ;52w>`yHP$wUqg}1HEkn=0&CS4 zrf>|JvZ}4nJ`V2tpmjy57A^J&spkW1LCvcnRkcCWG|+5SHH5z)bq1^WUjlwGzu;q- zV1NDsOk(Ky(k<9>O+$WIk{*@~)1ChYq9vUs&BmRJ=di!F=HtCA8}IIx@zhRIYxY7< zCbLtjq1{Dn-)5Dr#s*?1cuQ1n`%Xl6x){j$C~CK(D8E(>k?oPWYh$%8>YUc>-};o6 zs(~rJleZ_~RBY>G+|*RB;Z)SG2K7aJO^+dxIm>U1JEl-gjIN;E0S#ANN?Iz~CDl&B z7e82vrILEzFZEYr4gPrzd1p+rU+;}(c7(YUTq@F*w%DroB5~mrphD7kSDRbj>!@zyWXq)vz^Pa8K>BAv8d1 zZaLJ5x_39f{MY!k@F|porHn>NpzI`Qt}vAQblltqiG^6!({zoUmiQk|o09sU|EWrg zgh@ z={mpRrdu6ZCJUc2bzMwEF%fm${LBK8W#?9ekcg1HVf8LLg6xz=ns@M@TpBPnulayOy67yz_9*hB$CSk+wPFq;5l2mxi7V#EN5A8-IlSb$JK5mt5A7;aO>5Kv5KZO(fb z`vF6k1!(^7MLEmL1sJf;!ZT@p-Eyp}`5NfNrRO_zIpb+OhzD^d!8T}S6l_VT&>wPi z_r|dHA>)cs(3sSh?W1vRsgo1r+cK$qBwJluTnt#?FewB;9M`JRj|+^cSz0{7$eA0i zSzAL51p2{#Zjsx5h2XxjpNi)C$$()|n)RBHAl+(>TAJw*zcWU7QI3 z=r>cdK6`bY&GL~w6R=wcEpKZ zly~!asDX6o98M?W$#`HNabzFVP2Jl7z`Ft1R3auk@f}-Wz%qm&EMZhu+Waq`@`%_C zL{!EIAr!hc#{g7>Cw6Hve9_N0D{4MYPEXM?rmP4`IdI<8n2?=D;WLqeDFy^i;|A5xMh5cL%p- zyaqJPgsNH4N1-&}&ARGv7pP(%Zga->>fD1@V|uOvSHf7V`$dJPY8CgI1~BaKa%Z7C zw?d1dK!P4EFp7JHuuE(8H1A-plvO@ylP#G8$5Iy0TsaOqLBDIc& zyD%K}GeaIEMKI{qCgTC`nyblCcAe+l93)AwNXVsH6p>a+=)EP7I`mWG+|R1Uk??NV zz0v`QLd~1TI5G}6q2qw-l^*{dzBWjb;AYQT0to!t<7uW&#|=?UY;KZXo&9ne1huW} z_rG-4m`Dg?qR1hx8<)KNo#RpXM7eb*Uq=Jv%v+g7I&Kb+sT`h-Kr;O4L&lO^y$7rt z?309S5^Uxj6~b#%{|33qNxN{~m+Rz9r@^sgD6C82N=j z+EmFp&M0MZep{H1s#5?dj^Ys?pm~LGfV$n`u-irO!c5D@>)?8oYw`t!eD;%cH0_T8 z{YmB{wUA#^@!Zrr6*GpW8!aDH~_$$O<@X#}f zRH_=2#srC-V&3MW3xb(MHakvYSkZqfXKiwa6nD%q>CIWMD5vw=%Nd()k(iw!~ zO2>Cjj@8vCT-o;kny2WQJoyS|=&2@v=ae`>Zd~mN#ot``be{`0$`Nv;w6pG<{2W7enAe$l!Vi9?UHpWIy4Eaz*BQ=t` zz*J2?#o`vop)>)*xACBW&p3pjc3<8G#{ZpgS%`yjDl@|F9;KYs)@&b{lCl0*H8a7e zl+Zu7F_HDU`7~7>LlbmL7B*G0xfaF;vmxwbU>|@1g5qhH44?%ln5KC`XwvRLL>!LwApb1BY zv?Ud!{_9Dy3vkn$#w?DS%_wG@ch<_@VtM(@V!0cXHgCV+rxSyaI(KmUeF`BnP5d*j zdHXRc1%Qxr=UMeyr%^8*&jDKFax-42&h{S;bW^y--aog0(KfDVYX3INvMfAeJ<}?f z=xRc}S%N`lb;i&(&a?(^f|XiXW`t8S3z!Q7)`mz;ntPAKnF}`Q&hK#a+67tQdSPUk zFx0A4{OkPYf93d6{jV+cMbFj?FSt~#!u(~7Af=or^4!194YuR^21HYHj7}_s{Ys-z zt#};hou!mGg`3#!+C!ysIXR|1U(N~Q(v-{fdgb5ePAlb7;=x1%n-LvTaVN}(0I?N<0<7zZM!{abw&TokUenf4* zB`2rg;li@F7%IRS&&@fsE`xpTl6}@b5NAKB)bd2Bk@`9bz+oPR3&uQa-+UiZj^gEo zg)&!;GI4BxW_s|??iDSR(`Yz~t03shCU_;U6?Z5GH#DP*FP*51a&jwx@rP`soz8&Q zG=snw4CEIEp?dS0AODb;)AroGIU{Q>E%kVmSr_S=U;fUMc?H8V87>==la0t0>I%B(EB1^@AV%($~2 zQ`0mkUN>5kCCs?1Z1-FQqKqPFgpuUF%lsff*o!Y`9QIV|g@ae#(tv8z34BJGfB6+a9%OJ}GY&tz&Hr z!3To;YfaMjQkpKwiB)?;8&SGc$faNPZND-YewcfRnA(%mhfA z_f(*XPC>H?k*FeulPJg;+C1gzQ<3_u+po;oM82{@qmS%%MUZ z@h9!b3kgvSq;Bf-)Owfkd=J3IOrT-e_BS`erTJUIt42D3<)0Q@qoj}_SgvNe1S|hy zH3(pP{vZ?>(?A=8>K3y6XO3Xq*T{aaPZ#jFRE;&Hq&C$*B;|~|M@7yvkkawT;9-NC zjSakIVZoBS*WYL~vDv&#-$dc#wiH6zpPnu)+Yctsg&wq*m!_Y7U^Yu9mCoQTs}Gz; z1t8Tx0dgD`Pzp_tw4KubVmSDVjEk=RN%*(mAGgUW*%QI2^4*|qj`(?yWy2TD>CqRV zJ0@r&bm!Klb*uGK>(!p9Np)j(kJL*F(xGcM34~X46ARck?%5sYh3a}DwD3)#Y?*< zlqP3JjV4Ip+&{80{(eVDXf{Uu-QE5!xkkyJX$^w~H^4nH;E;}#tySwfzdyv6X$qT>dL~Mfv<2`#U@B&oU{gTKr6lk*=RfO( zW=%#>K7U8wU!o!g2qXY0J$?o**dnw!e@azq(f#mDg>&O*h-O5~{5{H&(D+x@d+vXs zn$9|@MV-H&VvzvjWGIf3LU;bqF&02N^~@uMgFJEZ#>L=`iNjD^+VA@1QBB>Hbkt-z zPB#jlo)0okyKC`%NFBx~Q`*>`YsCBw$@mY+0DL)J-Z}Ds0#FG;y{ zq-95JT^ijY?`Yu2tY$Kt&CCG&8fCelF9#ipU1~25ZnyxScU-O=L*{R66LjL&pL)HK zO#S}-H`j5G`_5t?UBf2iD9^La7nmW0j{o{ouRlk1HD9CNNj_bqdG=oE4~FfX>4>u& zQy?;$jy=g`=0E$#Wf-&wK2b6)_)NdqfS}ez$LoPO)bko95bH%zBL%nzSdcAEmRd1j z22%U24oC1UPZ_hX);(saaMVu2aiJg zq#VhZz6X?uj8m#>;_DQA?|Hs`%C=A0{;<&mDV_Oi9VwyN7{a#z7D*UVcZ=xn&2Fia zw{XT7=tvY_ z#1%XHd9r`i1-KWcn`=0tDae%8lV7ndCOE)&33G25{eYWDh-Mh-0dx63+gJDTXO z04&&I68uGb^1-xJx$wJ2%J6w^7`oRR^SrQfJH>L{}^`z zN~nw?NeK;F3)Gpk+90L#MuILO{DdPVv|AI0F0|}bTY0D{g1urt(R}qg9AuVPwj3z| zl%u@x*2=O$&O2Uh`AA#YHRI=Z(t6l>oAqbb-^$9CX|3=n$5!ATlesSyt=7qQ5_*rk zCTT<>w?jEP=Mb=)5-4K$*LFAyd4((DybHlX7++4PJ^G{PN*cMh+v{Zgu?y) zRZ*$J3lOXVfZ%}( z$Pp5VQl2qlJgDXCO6y8GMVa=&o^4}@CmMkAc9GzY!=%HA!NtQ zR!lNMgwR|`DTQXX5QOtR|7r|?{}nV9JX13QB*iYHI+FES4J>7^Zt|C^0j)z9WIB?} ziX5hF0PxHOf3gdFn&!MC^n`j6IucInr1_Nh0?X|sq-LgD^rCbfs#(8k<%YK~;Q);< z@UW#{d)w*Lm&JX)@AX2+=PH)_Vn3JQ4Mw^NJDpy%8lg*TkE~HV|4P5sc|zf~m;X%c z=LLD3z=c=8`lqCQmhwP94~tsx%n^D`dg;)t41RO$(+b!q64i*ZEOgy>`Q09*1s{bS z(dtE4E@}}AW!Cn*$C7JBU-|9l!|UXiCH?$MYA0OBEy9ob-~4NIP@5i1rrYp4!8qse zhki@afU~4U9*;Qqr2#$|Bku;xhVHQz292opvXU@IZrkVzyulEM>r;u59sQ zxqC54a2A_V3jcN4*r)PasPS%)Oz5rEAb>Auy+^mUnddRvwqGIDSvK~09C&B37JCF| zFl{@f0AE0$zZKSwlh5x2b5jczU`dGdg%AYQQCh2|^7P)>!qU=$O$nv8?NnA+JIXOi zO8eZt7j+SoMuKZCy&vd$OkqlBTVp%xQY-g53W8w!qHFrJg!W8vX;a6?lf3k(V#U#crRPE zFROpRFG+-xw3u)g%%iS$1u~&?N9Ttl4?Ku$wkuEAv34VhVq4xVMt#xGp5KbQ`8Xbn z6P8WzT30Km0T^7JC;v9c9|WI!%Gvy|o{z;9s}l?LpZ!N=1l{f?;|UP>Di-Avc*YrI z13#P8g5H(7!|ABJ+3ms(Qyi45QEcm3w79o%#(gV!O!(a%ZivLE^2Zh)ZyR@e5RdE& zsWM7vg%>4S210^?3M4Pu63xF|$|~!7520zcSt&iVXFpz$Ks##v8dIJBTk5p#1Ff2C z)-`k)JO5p`dLc3n{LSVUqpWPI>TdoGz9sP|Xq{Pm){Csy#fPVWl!SVeB8%{8H5SGg z3j2fqR<;lm@dhOY?GJT_^~8z}_5NUEoWnx-Jmk3P@qk9#bYoOUBS3v*G}|}Ed}4tI zFL4ag#X9RZQMPlJZKDni(cgsgkGfv3Sgdvdi7HeVi~fa8j5`2%zUBGPpk$?04q+u} zWY9qfVDm!1Sgdv+EbrWP*N&~F00db*Er)JpdgZR2a;WJ1b%f=eyWmnaE;EeE01#IZ zy}oR>f)pmMF&4St3JEm?akUyn0>dq>h#TeRjaYMp_p31g)IbV1uI6qx?aQN#G62A= zl2w>eZtl5d2jNRlR&I_;hW)eFxc;-cVqI(9ZP`pNpQ=$jSfLddzfq#(Q%uN)gbYj> zK!F~`t8&$xp_SFC3|}o-vs89nLCIpNW;pVU*irMkH(gzs7Dh+AZn@OV+PFr(6)Q|? z7$=XYm!)F6HhCk|$;r`h)L$e)eF-;WW(B%MjD~eX@V!pC4D%0Z1VT8DzTwAuDvo%u z1OQ7{T0YS&;8q7Q-%E#;<@WMUa0KOYr#}B1;pkG+`Hw)vK3O(E$HUbQGYO;=a1k@IBOUBqy7NIh<76aF>5dmBomwy zYzbqW^8yg{g>CJ_8ufz|k~Hw?8X(c_B+fg=h%Gp4C+jbg>X$eKt5eGC=lxrt`v(8^ zdR<1HQkXa|XFOA3gn=dOnTk@Y= zcwjG>(`5t};#q37TJqBNBoa$A8D+@tw(40iSJR*I&T)e)#PFtg=xL1Z{xO1~8ib4_ zNh3Xv&=k7MZ8!oO56z|lXTu&OVA5mbt!1t}C!zdC4|&*+rk%S!K@@Oe1Y=q@muNFV z{k9{cFafY5p3)o_iO~inYXq6CD)IK4T1cE=8+?1Jm{hSCVXt&) zZcXEy8Eq1aFQ421S)E+j6Sw!Cdj{XKO4hP#wlr51%2162T&KD`%6V5<*kV;iZ7!sEugWnXR!^fYpl& zz20d04JoBA2T+d;_GCfEkN{NCFfM~)yXWx{vZ?;2UD%Qb$goTs`2vd?Y-oK+(68Mu zWl2KrjI<4n%NN|QchfURy+Cpnx0aT&8S z;zr%+@+g5rh@z~*JU4C!w_kW;Ucw8jwOZ0_T&Ha%Pf=nJ4#ILdVYG9--Dwb1@3{8b z)m2dsTtme0jmpvsL*LoMfNlczb34lnL_QKZYSM(HP8yWB+=4hILk-3OHmrT-R&>tj&(H8D=#s?-D*fSk;i|!T~ z^{4%jugwrH2GT?DY-XORG^%3;zh2KUaaf)%cF@N3;)nTi0qLf5q(PvKVQB0~f512$ zCY$KzDMHn_mxr{Q^@c4^5`@n=K$xV9R4QA3V*rRqKO6S4E)5B7#)?Nx$5f(_tzw}I zcU)a>W*EAFv*x;Z!29D>7Dg50G$XF!CM8o8@*9jC1DKHNLZ?ay91&9OEK~^w491C8 zdzW{(1i?o&Ae$1z);ZFlLQqG>ROrD7wGmPu4FK6Zij_k>xBT_k-wKz!kKNU%uP4cR zy&(%?=q+ikyU*9lu16gehs;L*e+8{?cPCs2PyhxaDIf8H2I?+!SgRgE{dn%0PaVn>Nt>bEDsQ< z4&8tM{G0hA`o*i*A}}Co7-L{L7DW{4I(lFcvzWC}54yzOIBLy3pdeVykjkeQ53_)N+W&Btg{P@_t4mT8R6W)8QcVb!NzLbG|0E^pj^{r-y=f>_Lu?@@ zc>;PSuE9P~zK`Q6^DYF)8O<^yK&Pq$=M`$A(Q_drGO#iKgPEq)vvzw`NKY4?Sfi74 zb-~bwPcLqwqKnQ3qn)~A+XV|PEuJ{Oa{Bai+jOj{KzMp4Z9Wf3({{Ub`tHBg@9kGv zK7D!$ZmP!98cWw-zx0(XpHfNNjaQjCeY&(}v~;@fw>q+p%Y8R79Ss#BWVA9m2ZLe+ zlm#H#5y5Wbd_3f(a=E(6`JG%VZt1O?)pEJCIyJUPSgx4kB}&UeT7z2XeJFn)d)#OR z@Jfk^ej19RuC%E-YVq)7Wwm^j2F}MWB?u)WuD+g=yY2@$!U@wql~amy8caQPC8ac& z%3JW0H^lFoAN?GdeR%#(S>fu@XR^;z>kJO8rghrdbiwPXWD58@FV>er_EhMWop;h> zim}dzfC1B;DmtMBB@x3vZZ*~47s?nQmVjnUsMt==ko6x&x{D)Xv8@NI_DzfuBwRsu z$J~o+W-1lK^r=%n6-VFz?NPGfM9iZ&7k(#RBg$~H>wDgvG6h4Dxvn}yycp+&?MGq- z4fqV=0<0NpV=~BpCbx3RWei}#Nemcpa$kW*%p1FAv-10~B`>R*wJObo4iJC_Pi*qG z`NOu3EfZsV2{oHg37e z$B%CZt_x5(dGe|?jnKG2Mq5a20m1tWZueq1i2k`IxMluZ4LcDc@TG8xk|B)OT|h{e zNEQAq*a#ke8@=KiM~KIm?3SRHHC+ORpq@*YfqMYzR+a2(QIYC8Q3M*qpv zW$0j*3>jefpL=kQeV^dGMMuz;YG|}X(`&ArA!`nH>nhuA(DRl^yr*Lw+H>fxA?P9Jtg6rY3J!%a6u~{v%oiwFlrU1`67?vr*{=k zO;5k1_8F=-NPKxL<5S#x69+~}IJA6Pmwtb?o;Z7NSe<&ry zZIbW|%8DUyJ=5Q?r4wyI6w@UT;%nm9?VR!UFe9M@=hDvKe@=HG25O}hSrA9H`$19O zXv{$&*OVANO}))gsLBOr7q*S>cejoJpz8bJn21ti0HUN61n8yd!`S^WcE{FW`&&i( zko(wDE%}^c?l6h=IuWONU1KBKnA!l-xJ%?xZGLpXAk^pvg#N=NX?ygw)I(qu;3+ez z_6=BCKeKBO>DmxH<|)%PMhPPVBIs>K7G(d?Vy5Uh&@0(4S@0C8r;k--v3cYwo*?nYTcZX5I^SIW)g~Aq=7HJD8KG*@t`J=_Yv1D6+R$ zFwfAPzh(|#Ix~yFt9?&jq$DHdZIWkx1^ykrZVfDJ-zOmn%xhTMr>luLeAW#^_kuB_ zzSdIqm!}As){VK~hM_x}Ib>`0m%sR z??$Jh?s9`ueqfxP#X`mj_Zh?F^r8E#x;m3N75C|(IB@J0arRJ|=R}bY2nXbzaOm1ghfjvYmHHY!fK86lP zw|bp^Yyv`bA*VVbq{z61YmdFp%`KTj24#N#qajPrTK(OX&??e7Vs+@zfi)}6ob&)V z=mkd1t`>gZ^)Je^N=kNiV)H%iEY5%Sl2nf3rCmyrQnOituQ+;-+mo|Q3Vl{y^xehz z-(1p8V)ms{vsp?KI3&^mz+3Kfmo#zEF(RRih>UI_r=T2U?K_G@ErKM&Q!9qUT7|gE_x5ohQcVMtF$`=4Nn@dZLyi|^&QaOi8 zrPFEc+6_m*h2U^tP0O)TaS=z0i}jl44(X!HW7qk<>(%Oui%~52SMT7N#!my*FR)?+ ziF@0O{x$l&(H}&E9&$%V5VDu;{c(SACELb)coH~Vt(09~f1Gkn_$;`zAXuQofHF?Ktj8CY6*dhVb|s(@Se4i66*s^uIt+i6EwM|<~xwXD#-*#Imt=sEcQfhm1t@Y_`w6!f|du>Z=DL2>l zec`oDDYf2OYfJfaYinz3*F3l{iC1ER@H76XZfHV<2_0$l^7X8fVB}J z4%OvV6Ev-(*Mhpcz^59LvR(0xGv3&puNww&zEe62v00P5H$A{hP@s7p+$6bfW& zFM-dbNdG`KsH2-MDPVf@b|vlD-#0gDOJ!^6x^e8fQrjOlQfkA9**6S3P5&3{eIy%( zFthJqW^5N3#8xYsKdEh_=WgG$v9(mT8as%Rua?pvgo!aM!;WFr7RZ=grFlwLac${}7G~6$Dfjz6ZZV z5u%JKw2Kv^%NjT3!h&pKeKXeh?fuUt)(f)PlnWYfJ-Z?q3xN^AI*;KWaTLbHZ~K;g zf&x(HBzc}VOx3z8=_k^aZtXVo_B-*qwR9z0No(CR&oufA)wkMD)km!#qmfb=P1(oS zlbA=kd4(*t^6HEWEH7qU8m7CV>P2b*&(Sminx;l3$*@2Z_oCBboF=1bKTdHAU^Mi- z(_*p<8-sMX3mc<2iTZ)eyTvaBK_GRUC`DxyAZBZAGZcoBDitSM2K)T8g&|^EYkiI+ z)-t%CBi@hJ`QNR8jF*x?hz?Pbwl*cB_@6sM1j$k?VFgnrH(OLH0$+VK^P?^PXfj_# zY=71M^&tos5NL|eO8_wtz=b$|9#aiyA%us7;2;JZa6ox1N+mO*4w6-*TBr-epxvk4 zxNc$L*?WuQey0+?N6Pnv6&QW$^LvtL(K3N1&k5hH97nx790%&t+m&9zkc*gNAU%4% z^`d^2#&{IxE;b*Eb6^hKZyej}K@4+6eOQ#Gpe@4EDLeOqtLunvB7ttPpa%QkQJjzI zcogTb*zfa0sXZ1fmMsewgR9qSRYLn%BrlD38q<1h%~WO;vsSB9yw5oZt=3krdNK7_ zr`g!=_4{zx@55%le{tVZ8`)>;{878nTi*hX9hFg0bj6;aC`Nm1+gk56UPqBubK__G z{eGNw1}Oil*w(cc|5jXf!OA#~V;U5+ykMqZVX+C2wxCfbDdUV}HnD$xi1Lc}KfFqg zW(UGIg$>NFwNx_3_xVZ%py}0qSo4|yX=#xKSJ69^80r9jyO#LXs-LW1e%ssLCYUnC z42ua%r6~{df-9@j1qZ6lB_xiSZTkH3Rc$BIF=0lmz;|+u}GA zV}ERA99Aw;f@bH3_ysWr#(C;1`@6fyoH(bW-QE2|!7ou5*LFcIbCR0En1{@oy%HQmVNBW9)RkskFp7%S5E*YsADbQM8>=#8m_3@nnZ647;ZR` zN5W`1s%`*6gaJwayeX*T7^T4vn~i!R7z3iUZ-SMQ5aqRITOcmfF_a|em=YX`c5|&P zL}?{3zSaa76G^?%41LhbI1UwX#DiMBy}0VAKxqP$i?~tGN{Kd9D&lH^TZ%JUKb0Vq zoT?MXR0|93QYop?Nhzy0V!2#b!me#hlTimPn!x>I-(;^dfW5<6pWVL@WdG0UwyNanXO z4-6-vK4WwP?_6kWOnpYPr@&Pr$4Mf9TWE(;j~3*!!`rrIf95fWq{+uVW1GB@{h&UX zT5Zb;H>sG;1IF*d6&Thd$s5!UEIl&j?B*tdBec1BRz!wqaq$RH1pLjTc^`P9k7?-> zJLk{uoX{;-Do8{+D)U;sXP$z;g(qddP*=$T&H;BsCeS69UXe1P_$7vx@1hap-m!mb zH*RQn3obLPe7xMgeO0Eo^=t$d2Rg}Rf0=rVUh_<|eOtSA!4*)7_&`ij0K zt}XuIDmpTvP+XhAeoKqDF~UqZKWsbkTt6wqmY;P*r1T8>D9nXq{UG5yO}MObYa-Ey zoVK2lMdX0fwXy&W5HMDCZe?#2Kg?#f5=+hOA7Dt1Bl$QFxVJWe0Gl~8C{O+LYlvSZ z4@&KA8PHpsW8u0#?XI{^s0Xm|o82|6)ENrJA$_{=rmnfc0uFHC}(kN`q7 zcb@Z{ou&`~Qq+!Ltd`)VCiK;{vQphX>^C1u5K3Nd@WqI;d2O+Id4f>#P_uuyQ(3!i z*@V8jo^q|V0Hxe2K-Es8pK%>=az#+8S$B%Ye?RIPD=y#}I+Wr29#6)`n*!>i9MhKCG0Cp?KiPjjj_EvUIoY?IhQ`%wnCsF&l zZsbH!(&AUd7>??QC?&W+^}AZkH#f#zPiw%&)T-+{JI9wD`3W39pY|1zyduA>t~Ic1 z`)I(+n)<4V$6!fZnI=t|WweiO3uV7G@Ag3E*y4VDsTv$%z8V&0JAalY=@#m96fF+A?f>ci79Pf*&)6usJ%E)@ zl1}+Yf2f)DEpC;g4W`C-jS?Vq7z8nb#CE`S!8aHi!VJL>PMF0vqY5GLBa9O5q@%2q z>e<3AI}NUnFm(;4j0j2@rAk-p4oAKV;0BlhFymm(q5DuOIb{@x)YZD)Ep5T=elAn1 zYMruf$zcibyl+~StQ7qLSfFKN(Il7GO~3ba+4n&!f2D1VwzJIE#%;G#*%uhm6ndjZ-*L4X$P)f7V~(aqKc z>yf%zDr!93s@iztX5o;a6d^kN>G8!lC?&}6N{;g*h)xN>#c(huf2z3#@<8e^8DL~T@c^l>TbQz=yn?~tJlAo)a%K6;&^=9FAO)2AB-t| z4`a)MU$C$r==|An250;mE@75MS2c`F*U}wtOw+;-Y~=U~*`Yz=La#EJ5`9pst_PhrcA{ZR0cdznKzaFWjmeNBxiC z1um9Tz#k__IRAgaZ8bm3N}QLnEOw4rlUdd?ztMJ$>elMGcrGbmB2O*N?u_S0QaKrTW`fvjcs! zy?w5~xK#H7*U5*&ZR8eIguaZw#giBllq87$TfJ-}EbPGZC!GZ#z?mHqiUz|Q9~M&a z1rUgH+ouW=tsf49`+0~l+oc}Ngw9rl7*{?){eS^+t~O&VOe9-4I=8)zqOk2cUa(Xu z!9J4CSMB2%vm{PdjHy;$C5hI`riM=Ybt!i{wh$05EthW`4AHd$#QKTy!h$Uocq=RA zr7Z9?RI5wn$%d8^%=S)s%5;t^PbOLj@QxiXM^E0B5`>cSJ$JZ?WTE%02W+L+VtHr= zm%LiUHKt;c{#P^_eBK|&#R2hsaibz#-S`XjqUiUFqEU}x&AE=FdSmUDdO5f|Eb@OwbQ}-)hsJ62;%9D@(!g%|<=Fx9L{C|J7OaL& z5i)XEa4y-=Jf&ORQhKzIj9Q?nU}VNq3v&d7)|aLE6_nWEXd$*^M#_kuIrj>g)B0u- zX07q7qd!TlND$K#Ie-)SCdp?e3Z^vVT2=y?pqN{S?}Qg`&Vgr|zCFHDagLpGIm^mr z2Xn64i;HcUp=^i41X;Jo^+;)c8B?lizVG`rMJaY^BvL<`YQ+@Aa#{T9tUJ%XxBb)% zy0cH|P!aq0=7ZVnW2>9z@(GyTqCp{`(uJGNL`8!^ONG*1$d~knJeMe^!s%rG29=7o zWub5a^ll^w=H5G}@}?M9k8)o?Po zetF@u>3{RD54c=nL}_1Ajya*&WyYrx6NOmAj%skh-@APyrPKCfnyg=+3{S*06}-O*C=B2|6LIj1)8IomPb*QO{$PgnU!aa#8;Y8coi=)tw2rMqnb~o1) z)!G^QL>QA~X5L0R4yqT;g0rGE?Y<$2FpsSPDUz!t5HflNGdKwX0G5TS8HP3 zSW78O`2wGoqnMK@0$%MaJ}EYuj`XCqnG}KM3^UBRjbmF2=?Ov{-=O9e765sx^+V~*h-bKb|W z^aMgWaLp?ApDH6;N$@>mVr^?eh+fiYd;44zQUD-GV%Jp|MZ&noF^o}Q3f8Y*eIN9N z4_U<~I*uz~ll=*EGQ*jZIbseNQ9pjV6%#to_O;ec)_vBCBrgl6Ibwf3pXTG6y|@Aa ztuq`Kv8{tJtEE{)`-iC*#SXMO(7u>r#|e5<5jYM$HJi<5dk?TliEp2VY00Np`(9e~ zZN0guZGUlbb8&J1(H(letrs`7?Jo`tK161fU8*OcO5#-8GD!zXeFXSXP>FUa(&Zl@ zJfRpT;o{=PXmK&ZjLV%nA3+Am5m2;QXB1 z;Qc{A&bJUN#(-4hu!%=!G}!-9LXdFdehvT;W{fdRc#C(4GVt6|$@PFTe7M6$7<~*G z#R+>ucK||!IRFkL2mt}aYH^HE9M{;Y#Cv2d+5 zBAhoJamI`42PtyRk~MNm0OKp+G<-f-2{B`pdeP;D_f7Tq{+$=4$#e^iHtbPC@5$Gd zB<&CS@n{Q1Tj&WtCI;}|8IOzu6u8oYG26JHl>6Ur-a&`~q-o1IF@b@F2iw!+0lewOw2>8)kwu28n@M3Z9m?S;;Bls>oo+tJL+D1Q zCr{C&iK_m91)Ah6osP@2PA5e-?*Uuc@f?2MjUv}IcBK+AKYP6ZGhliRA&!x8;;6yJ zlF}zm-hRi82yujs6Gu(X*Y7d^Vj}{HIajas<%36wl(gO-P6Wu+_4S2ZN`L-EOtvVd zjNt}iOy17{(a+W~W9r!)5XEGZ?eQZYK5JYzid@Ax0s#=#007E%$VDTH9fL4O=*By4 zKY2o{CC-~)_L#q!V~|m^Y`@s8{tXFYG91*Xl*E@ZUszvXJ%fR65=;^BXG_LV9WzG1 z{cOb;UMynv)p?!&GH$jWwQyAszUN?Nl+PK-+Y?zy($Z@>}e3%6iCpjrH@^f3_m}z$gL5+hh-8jZ! zHydPjvI!CQ;G?acL6xnY9Ij!rj(m7bR7U&D#mbd6+Mg(mC#n%Lz+40i#nEp ziJ~Z8$W?S3;)ru`rMxnxbr;i9=cP2>Xg3lTlWyqmpauQ@&2A^ajH~0PP9Il{lc3|? z-0#;+QcpH^;(G61z(ZveP%I^;0V-n|+`-Y=lP9Zbtyf*X`Q|QHd$qKB^5ogRb*ld) z?9ki$>9sO~K*|76d9A77NH_0f^fmVGv$tW@0!M)zb|$Im`upL;>9_TzjPg?aUjaCy z5AXKh>Lq3OUqhszZzRPlD}*SFF)!NJG5>CgTJ?UKZ?c>2x1Q&nP*KaCWSsF~7AyN{ z#!(t3rLo^0u*n(DOB-B5+uP^h#jK2qAjl9X5r`&to?Jv1SrutfRi_JL=TUzJ5QbW>domN4drs@1*XQov?Z&V7x>yH%h^k*Au;T& zfA70Y1K<3T=qZ`L+$onqMr^$HE~%~k%jdSY(P215z&J4C75#qdq%O@-0F~)npxPdJ zzsR)UDIbg{(+;^fg?&aKK`@{mC;1Bfpi|iXaQ*vdzXl=r+62zj7QbmqrvrcWRt=RZ zzhd`PldEeJ#DDDj^$(w=r%UEHg)?yiW6%FKfU;7bi%WQ9I}A44WEnja=!(Lm!mNSUPqdsmOgW@#demwY7U*fMwiDK3r;mddt!hJJlq02cv5 z+%7LI_WEmAbNpP}{eC#&__^gy*arZPA1ZaoLuu}5ZEODn9CLl>&}VmI6G=h)Y_7NB zm2G`x^L`gGNTUV80L=H9m3jBmT1v!TW%zp$dsPh{yv_H%s)|6{-kz^*#QKn){*PJj zu|8eD6$1ijK=9xIA{=8D>5uy3VYh^M`Zu&&^z)5Ddy90(8`CX1+UamH$hWZ2O%GE# zsyBAPfpJABC5kV$##M%ZLT5r7!-QbglfMQb>;Juh0%Fxz@sD~|@_@f4l;(mVgb))# zDIu5!7OoHbVnQjym{CeF03cBv6;Qs$7FzXm8M7H5HgvpCfBJsT`qNK8L8EU=|K^U~ z1$|0OK3ezi|JobBgu{DAJHI=(-e!H=`hxX4{&lh59;yVZq3=rAw(O7TBrWdb<~ZNN z-HYV$`lF4(USZL{*JaOhe$m2RE$y~~hl|vzU8!BW&TT8;@68|@E&mBK?g^Z7*EH&- zk`DdfyfS_fLdAOVS+g*m7O)Pv-)&ic2X?7(eP2kDQuj_@NJ?B2MD+QKtF>5K{*T9z z)aRR(iWH7hL0nuQ+XUAi%nDM+TIvVk>M>tRN?kWNZ%~Q4Ua&4F9`_=vJ|xXAN<~*1 zT+KTz9-b*+#Xq3<2u2U6p%u5_mpwr9hruGCrXuMeR9 ze%fi)=iHH6bk`JvX4 zS@EgB=rmserp2rllSdm*!|})p4`CgAe{OVQWI9Cg8xVEj#_kppC&CdIRIXM`*ZSFXgu z3StP39nR^xkx*|^&3_+`b8r6lhH*0bccr3aaLu3=hPAQ&)s;0J5YhW`e(ybqJqupznK39}VQSHw>Q(|NPwH4cE%x(G-#S9p=ycPnBO5B1Lp)^93vkVQYk}IUs>7aC=Bm z3h!q;Mc}?zZM^^e8|uX_pp>)upIBY<{l8DCbpo8~oX`Oy7^euO#0)6OHRV9oX_ymU zL{jRVl27^{;E65Z33N$KCM-hwsS1t3NLoP7#^ZTp`dMz``pjprV4a{15)^p8!@B^a z5HO^iaHA05Cx5U70DcLoD8bA&E@d|xek&}nqdp~DKnE)c&NE4IBaOl&1L~=^j%&_JG3fsPIbmp{FmAjT zVQlcQ7j?i36ANB0Z^1tc+t$DTwnqerxm821C2Q{Z97|!2jF6c7JI(AMyJqkOiGaU9 zg6F+298BAZ_4OpK9$QB>PS)2?d^K=kRP0NGU?P18iebWeC#lb=)IBnsPIk~f$ITg! zrch!#?lQ8Qe<&|}>t!QlE%3R+>B}<*2-9&uVs!?y1+WVCJ$=R7vb$BOlu6My?(+eMlYe@9+AT&&6XvkLNt3mG+AWeaFWkWoGGs)KY`~OC ztHGuxp5gl69bZ~i1<^}{YSJf~k|j+FwdxYRs)|iQHR<6keLqc@HTARsB@(Frdeh%e z25S{bb-c0?Ybq;igXE#LN;24^cC@k**{l2BMT%8g>4$J-k?(nqE~psf?`AIa(3Cfk z_@yEjK=!s{9Ho3Z*#&qkT?kA07yLavQ(yRPJyRjCes-UbTcI278;a`f@X3nh5np(r z^m#SYzxRLDOy6D=!~5W_X(eR;vsE7A?W*t2MGs}mH)`3+K;i#4A*7}qKupi)8yq{M2UN>+|q}BUO0HUc@(d*%tHeWLhk|bF7yqnOf zYR>;TFz-{k5(miP+jzgj&e_e&^7Yo7jaRl7>A`JwHc$n~Tn0=hP}v0zOM=m)YCrGK ziTdXl4*KIJY7f?xHwH$Dcolmvg+DF@!Pbkn!b&-spL^!T&uHhOQ4%+Z`t7&hWz5ZP z7`hQ?z&8bJhii*|wEY&|^>+BhQdlWRTQA-Ug865kdGRxfG_X|WqEY|)_Pg9LbZ<5$ zg4q{(nb}%PIfLj+@vgVm1E^o!AId+}KAtooX`)%>9U#KuszY`+E<^?59Hd}%?O?y} zb_)f$&w|%2;b1Y8k3O#y>f7ig^+ z6sr+wP(Ko1^W92$U4VGU1ILWhGV*%mzEyDWPu02B@m8~XasI|g-20BfP1h?Yp;FTO zh84y=<=(0BTRS&Kt_Nm3f5(A~dSi41M|EemqQ4o&>cy0o-G=v_EpJBa0uah}9h`wZ zxvtScn6lN+c1tM}PXDr7yut8j=159&h!~lB@I0~ZB4<^-vDj~Ba5%M{9PMM$ws7{{ zmPJ|KR*c&~A3)DEY9VC9lT~m6YE(!^5Xt5i1Het<4D1Q}YopN`xGo&a2a`p&-1+9m zz;)q6utnZ0!DfsNnMZS$w;ve?*23_2Tmbi*?PJGIckO$n2wi6PK;7b<8?r-D`O?6ZtmFSO)9vQ@V-YRL=WW-hF!5)r#%%o=nvC-}Py5 zukA@?>tgIq7O0AAY>Wz;NPUj}I}XJXZ!o$Ma%&82<#9=&BqU4)Cgbbat$)MDrD!p5bg-ho!bX~ zZhwD&|AG?0`i`Sv0U4*IBn&bB0*Rw^68r56t)m zph8JhGD(IAVj{rmm;z)#vhFDGso@vB0*JpgNky4f@b4y)tx-a=Tq% z`Fw4s;<*4({?s+)roqFfX5q-@y4a{zWhTRn?tP%}myD#F6M^mo@;3H;EQ&2t)vl7u z1OZjQQ&APJ(BMrmR|LZfsOf(O?Ur%{}wGp|~vWqhA{YqjdZ zj2r%CR1JKfM0*Vx_(2trZ}9B=`Sa(`_s&0z2svM8Aen_!=s$1)evf_<>gp$`DX?|~ z;EnE+EG1-%uk;s_NMTHglH)fjHim_0>(Ck3J#%O)S^#V-Hy(ddFBF`OR%^p46u2bI z}1)w`SJ3Ab> zf*o*8>k^SLoH9^ir9lHTXv|f0=6_bwvUu?{XKdb(@ja7^AT^WI zXXxZaw&?StQqMmIF9^tMQexk|FhyVH5})qY=0-S3`F_ zIHTyga*Lw;aeGELAe#Sqs>GNM@f-w9STJXDbUiScKOsp#&M3+)imtOixoP%8ff1`& zTn9)zIEkRsNRCtzWITaZK*?GGj1PytsEwZf31xzR@$->{p{}BM(@m=5o{>SPGt!`hc`#*Nx_XR&wpZ@Rw+OaGl0; zx#DO1by0iM{2z=P#ut@KLWa|SUhiBt8sYcm3*GH-;X2K%)fV3>2uI=Ws1w=30Ebn*DdFDIscB0 z!Fr~E0C7N$zj5Ii3$Xni&bwYtSS*zwCm5=x%1)V1zzb#Y738BP+Co<&)QDX`V{)aH~r-kEO z(?IgPjv!PdFnA6C^7*_ij*7w`2_<3v30ac(DWSX}GWF1Kb>V{l+pO&N>v4hQBub1! zqfuMa$xYY&LcwztV1zo-p)>3%cI7VNnyyO)c5NmpGA@+{Z7-0Yp<6^p(K&QGx;yIe z>xmZ)lUb_dMR-~ScI}9@ViZ~oB&{Gao^hcA@du-m`(ax%(U2w{pDi%kn%oOR(|ZYa z8MuG80DSPc#R8(g{D~5VkcHuV7KX4FhP_Rg50;v2sL2_dUCOqj<3JYLyjNMCZ;O#J z%%WC;zTu)kSR8UF@!9gN1+Nb$+Ph9xEM1d!ByI#S4Gb>rNSbafgUA$Km{ds=+K87^ z6mxzMIJWHs5Sof_^bI-U1&>7l5%dp;-VuE;Bn5mqO$t~LDLtVi@#wYwCbiXfsMrsp zDDW}NM2dd_jmf|Fp7%ug96&A~`OpmICaUw1w4UDcxm0f)RfMGF;?b54ih;S~~Xix%g1W#du}rQ(~-D?p?6f0B<6L`WZY^?B_%M#mpWOvf#D@>xjSym}YO_^e(-XUS2YMJi?VS}5 z6%L@B3;4s#^nFTVOm2YEacJKO@)vp(V}fKvc;wSNweH{|P3Lfr%DM)Rt5g3lW`u-l zD5QKLd~ARC#+u3Cs%%SViXbRVx9qRyDK+ZXnWQhDsd-)9pfo@K-8+iKK$Zc6u4qKW znxX>+Sq`q>UYl)w3QM^aE2`?RbdH>aHRZ35o?!Rn^W%Uq-j}63ApnHr34MRPRx1sa zmLCLuOBt4Gwffxm;h67Aq9CZ6&)6?IK4ZS73W6v>Z(|!}>E<>4$JaW;^%?7bid-%I z0oXd)KgQV(SC4cmq_*!tPtY-El=w)(P61^qblMvKaY<41nXV|3;^sZdo6Cst{OGE|Z`! zQ-0uBSf^xjh_$A=@)P^-_QGgf#EkLE-MiuL-4zQ2JoPvVdk`)t@AnJyALUFzk&3^^ z8H05GBal;IjQ?IyVxpP5u2omi?e72yE{M+zc5!(%<&eUhhgX@~dIrlNwJ;5;jecv<$Pv_X@oO_zijX?Bo)s$eEezU}W zFayKP&G&Mq0i65hbpA4^3Ul2;&T$kg2rP@pEU--CbI!R>Oe- z@KWS+o|p~@6bH#uM(|7*9>9qbzv`DVNP|`=1Fcpr=A>)WWVsu@y21rbmIxOns{+%q ze!uEPWUH!BxNxqxj}+-Vt%6n6_V!uo?U;^g8u=l(QG)YRP3*%M{8I+6o=xsH z?DY#dOD!{R-EQo)UTD2ORBgf&64 zO30?&NJra3Q41gtBL;0DnYtO^}CUicu_h^gMk4cj3i#HbR8De2+_R~Bon~^MP2sT zZH}9%>o*Skx#6Q#*}LyrLpk;ffbK1?e4&yHw6^0LwmoXJP*_+f6zE$fE>+`Bq+WH<=x;9$<|9_riqUg8c+$%JJk$On7kkzlYXiR zfi3y-!7ZCprlo4$FRA7YlOvrU{%_JW^4LC8ps;fL)2Eq`-%v$C-ebz7U=n0i0A}_z z)e22xb>t1Q>lNeKfo`>e5f(jHHsNF=Y~=H<=)b$yQ}v9Z$YV;VdIp$E$CMyC-pcc& zn;u1llwj=(6)`i-)^qGS$fy7oKCAt$<}r@yS*pmqh!=XhMy4oqS5bE1SKZ11_j0tn ziwcsmMqmKWD&Nb1HANEWuBz@nKdLM1ialuNB)xvG--W(tPSCS&PSW3oenhAsDAX!y zSg4y;O!-#qzA`Zc-*agdj}7^cq!8n?*;z3Tr9YAz_7hsC`+C5(i(-fg!J!E4Kavf3 z4O;ienrz6w3%cvPvzYk~6m1*iHBmPVU0joSai3!TL-_rr~zq~#2C&TaOKYXFlc;ST?zU={D zQIz|rVbJ@&jeI-z?fi2mP4nb$pZu0Z^gh&j^tW13SZSVwSrNNM0@)V&P5Et3$vKxM4mQM z6?~7>TnBE|8BrfuQW;a1?$kBP^jpDkYdfks%oHfag3QqR`x(EjV33vauu<^c&GPyB z=?$Xl>_mw&MoWhn(^ax@x}MLo@t$CZGB|E|g&2jm;SLz}ldyrZzsW4i=|Pv20P;2> zv9=`CI(xlgjm+o>GQdEM@@#6#CEV~r3^gaECaOZhC=q@)=wbC#SWLYuxRqfE1a`5i zJ)<-q>jr_~of|nbyZ}kIdf?$4h4@YS;Z3X|Bm^0X=2U{OcpVHt64O^G*7}@?=rRJIY6VletrVEz>z+3qb&qR5XU!FFxOg zncsI31p;59AQcOZ4A)(>iOVhkeo*I{Oph&P;G|G&aFU^l(9qadpzbvoRnOuoVUyW5 z8#7t4XSO1ao?F&cA!F$JJEpn2JL_1?dJz9wf8ubY2Y_t7zQBYFTmO{teeK(^Gb z#nfKivH|2%*@Pcjig9;!Lh-rlnX1~pu`pbBVo z|82jd*l0U;gP2W9!JB=A&Y}m=>+>6XizK}gLBy)+zHgxqcD}G@W28ck z*=E9THc1cy{#a;b)bZ2|U{8EvyAF|o#WmG7O@~tT8WJ7lo}F0B}%Idf&}UQ z-D@psGadp=5gl0stU}=hC@^s#6@{ETI={HW$M*Ct_72u_+YMoTO!n3 z^Jj#BCPO}}7_+@yCyoeMGf>+yzDp8Vv}R6kjlnP6U&gOIzo$_XrM~)G_-gbldOw zHr(!dsR;PA_d;@*&MIQaWD-9G6P z5)@+bgN9yjj7z|VywxRW*No1D%ii_=4<02XVIWBaB-<&7q6;~_kdFcQ($e+U$1#(I zoV<9J379d<)O8G?=!T))>n}u0%S+4QqMQ?Cr5Rs;{n8RwagLSWuNeksAZWT}G6jPm zKPSqXG5?>yb|{F#mTFC`hk)O7(X%mRib0n7iZ(P%I+moLW~KWk`KF2_|><%;n$8-t&e zu^?dvmcp>0>n4CKnX)7cSi%gzR2UX?%>uZmZrc(m7McWOF_fu`1(6^mB6Qi$9m<8i zb4V(wM`D1&;zWc@Kxc3XeK!)sOQ~6lBl89_N)0x~Epb>1l$h+w(Ex;CM;(R$&0J_6 zb>+D$m6g=Qt|?dCM3194qc5Z1K?uAgj<`?@a0yZ7m;V0|HAxb=lX?#deh`jWup*4a zMFyvE2>}lFBeKH*%}n_t$r(jMa!^8@y zh)$sE;uO9aM8hOj-9u()xJ%zbr}BfSrx31*0J6?lpP@``#?kQTm7low1Jg!_QVlfv z8iJ#fLG2B1_8)O8cY%sn`#c-YIc+>1n|rYKO3p$}a>CIfa>?k16e> z&|;kd=S$BU?H_~$je$!>%5Mt(3)FsjjEL%!SUSsJ9D)#9yJ#9dWMcs zQHYjg6*A8JrsoHOft4CFwJ5J#uQReFMKCVex+TXlg1wR`zRDgMISHQ<56x#bX^e^X zO6#8Y?eX?Z8m+A|YNJEwCTvBQEmV}39!WZh|0uczxfq$DKOn!5A9}ykND2Z8AGQrg z>C2WB@dXu+d86b zJp(8lPiLg}$P}067ia>DNQ^wEKj(F2* z=M~5(F9ayy#QuDC#{rCOo5FG?pnq&WX>*D0tuhS83pD+4i?$Myg|5~`&&V|H7PgXZ z=eZimYexizB^DHU1wd*0htm}jdGXrZf0|+KZ*IFLnRWfEG`#Wq$QID=cxW9HET)|i z_y$@jtj4W>+_eP0N(}Ermm@7SS$P!y!wbeI0j{iZEE0+CYFv*1k?W_L#R56~e1$X(2XB3?Av9yTIP5uW1)X0+|hKo-;U=u#j)TG(adYh}+GERs<-eSYQeQfOvrbpx9mOZ+5bV*1Hl+38mN%I6`q^2U<_k z!)m7W>Acac)I@NvR2L!wQg-RRPjj$Gd%-oE%z zvtF&gx_X;u07I*j@&nBx?G|Hct2gIuo*^kMjOX3FTQs9qCvWZ2@nVM@ekU0j)9B zA+RK;K&N_Ex_FuvE5jDKb(b$3lJg57>bZ|0bB)C)>6SS5JG?e zAOZ{k8^eHLz1rJFL{&>tU_uCBu;3ON4_l8}nckQe3zLhRr8;V7O4pelMdN%-A|{MS z#c3)r!TMkI(KwHP)ooaZVEkWW2Ti7wmJ}eS4Ch5=ANh|*20r}bgD4Fr5CuAyA<5{1 ztQA}B;Se#g3W8{;qU0@AE1gxUa*m=DpW_xEtN61zD4ZyF(66F#QlA0 z0nJ$^5+petJn*OY>VfwDR9AThx@t{FU_hy%^c{)j5VBLzg<~0zcPBZWY#Y}y#@dWEEjuffa{qgyT(T*(OXbE_>#ET>1owM={cTSK2nC<$ z_iy_|fKc!R41oF`C+Gt^>b`amhC%y2<+w_rAh4Mb%oa4pn08?&bXyj(s?U52Eyy{1 z=uyi8Um24Dz6ik0q9|a}72v3|XiO*5n0aS91t}oo>yoc^XD>a7#2t^_Bmr@E>L~Bz9Wkb*3x)K<=uC;1DDhn53MPaf_=X_u z_@3hc($e>oMqoQ$;An(hKlG&F!Zn^t2^>Lsery{rxkKQzq>Gb59MRH+lH}o;Ga;9y z3nd!G!O23GQY`LBJY$c~r|`kRV^nJ2bqxZ+eb06%(aH~82f%RyUui-e+w-{qWL($R zk}@x_ELo)v_uz>)M-(QK3nDJ1T{bb%CX$VcH)= zaTL@EBk72)Ps?c1gYI}}#305ljE=681DHE-LDN=Humx?u8!mnnV?tP+fCA1z0jV=W zaEwTou`WSteAQP7p!IqkP@#0ibp;k&F4d|w;2M<`1{AGR=?aw3X1Crto8?HjlCC2P ztRiU*gI%pHNiMK(-HKKPf_i-&0jV%TaEwUz(Qd^P86yOTh(Lt{@cI8G5Me@Ch7m`M zA&&4`{{`PigR2_Ly?ckqh_u4tcf-2m;0etVk9$ZOTprP+*gM- z%-go)N9Tt)d(7U2jlsrX!_L5myG1unqTJ0Zpe)E!k0c84bLN9~&Wnv4dLM$|_Qd)6RYL_W-xo#q7 zT*vrBWPouX4`3+4Ky8E(5&_1601aS%ThOSfxl3r-rm0gUL=w}nf01||Kt?D<@!&P_ z6)u&!F~SIAoB{Z5OetlRiVtj|&UjL3id+Ih$i;v$5Q7j>mk4>GKnP(&7SNNr7Tyw# zO3GP%fq`GCQbq_YOzRB3Wf3c}&#QNR*1J`v1=rT)GVvg$tmy2(WZDTklrL4R!%NVC zwQP)OGJS6Vdg)O&BfyCEv_x?G{y*$JIR3x?_y7Li|5YlmVwnFQO6UIv60lMT#tx?L z3dGM?NeQ*n zFy7;-7oP_>AA2d^oB!y}b)UKpcHWwbJ%si|`c?-!F7?;%q!eMtr4e>q8a1fv;E1{o zjuduWYEx=c*TFV`ZCMsBx6B7Pvi{Nf4`9-**7+7?<-4hf_hb1RR3yQ`bd+x4#8Ssh z1@(odlax=U6TXGEAsQva5rk;!0qf?L0nfM4kVQbD2mWP+-L}ec618q*!St4HF_{k2 zgx}t4$~c>Dp}gpi$*xl~4`9{1ro+K>w1qb45#SWuh@&(y=5e$|CI-)$+L-pol2a0E zhr$-|mOk|<=u}zmmLDnAmsGc$8-a@Jd2R(T0xFjmma-tIV8(^!^kc9;08s8mvFic2 zUKqJn@3<}igt!vwOtyw`f>08XMNxdO0^CI!CI}^%tYv4EP&^IbE{h0vqtJ5!JU5P9 zp;pg336yKW8L9?Bw%lHRYF7jRD*KfxKve_i(-U6?__`2_!UhBgVE}L|1Wy7Dw93*W{SCWi5Br1$_2Fgqw0XZ!a!38e!D5@@*9`Qie*Y$YQ@}x6@iFTzGO{RpZ~s_;##Qw4PYe>?ic;Q@7R<;Mxd!*8@=ZR%7OSY>h4MNuLzw$k|^QLRUy zrK{vE=dseANkbj_vq1T0M93YicBGI(FOa%oE*{JRL>9e28|Ft(x5bW|?-?mSfG|cM zuzt=0$ljtIN594PoiN#wI;vBm#O_Pc8b!RWn&As`wV9O0c?kf(OB^HRozLL_{5d~y zP@MJ?mStIe1>|SV{cpD}TCcI*Zv8jwFRgzC2_?Itko7JkkpJUu?80cln(1`)d+Z09 z9F6(<8WjkBm&7b|y~isxj}CdNTEMmh$UwoK%~@p>k9sOO`-7m$=*_{rhwH^_6#nDC ze}V zXZbR-MQkE*YDN@+?*7Z$&$9g~L6?(s-;8dxo*jD$gp*P0Z=oxjZe5Bfgv+3wI=Ald z0Y)5y{Cee{77Oq3P!gUjzV{aRW4J76rPW#@gpf6W`uu}+fHh1AU23%| zRM@SFb@~wGv_M<*W@eC-%L&Z>i&9?MHHXG6d)FN{5L31|zc+Wp+Ldw@VT_y8)p}!P z+QbOsYPr&8bE|8~+^OW^iCch@)5jWN9EXi#@NbOSQLUaKl+|mI{eiazode?-&|))W zUOUKP+xh}9jRz>Mt0sCt5#>GZghR5X5Y44){55uqjd6Rt1zpb4DdX)e9QWg)56wi~ zU2Qn^4<9Q2VDsJUgN;FMj1ibU@e?hOF7|*0n!2(x1BW?*xPyCyr~qs#AigVB+Q9w32JI3a`?t zIHJH&hOfPbBU*c(R3M5C2cZ&j2AU?Dw&VM@YG4fq5mQ>~M!-~DttGMTMrY-9s8(e= z(5^UDf*=4JYAABhLs^l1Jbc<+k|EOE^_2NO% zH#K|!JuTr1x@*uo8e3sO0=BfnZlrAlT*-o6~8uLMvYqVr}iBjeTwN`{6E;R2Nd9F^i!jutmOpT#_wPGADC@E@- z={qN`kO)A?sOchrb@@^QFj(mY%6_I)=9gDJNFy$5g#g*oMVLBZJ08xhoIaq>I3P*^ zVFbugh7yM}hL(FJ8-P*f`=N0|$BS|@=n}Y6s#juwxKb}wFaq!XUMcdN5R3_Z9~%H0 zv3D$E+#%Fx7a_z{btlez1fFAqL+>o6@5Gfh0;F^~ZoFaQ)uk*s85FVhma5v7yM4@) zw3v=Nd8cR*Pon+d`or5D_X0b_hNn_saS){^w}D)#Okf85Y)9(8-5 z+`=IjSlpZH(7bk)D@*FuenCs}jMiu5um3pe_D4&m`hPHTzhV3Tr)o-%I`WK^XQcDr zT_tM+f;+9^Ov2B(6$;9LVDr;rrBTo{nNFAm(AahJX8k)4Z=m6$f7IeG%5ijm|LCTF zfbqzPk5Ybd$7>~qOL;9j5%uGdJ1g}SHJ8P%%Xs)p_I&$;QjopaUuY{A_8P0$)|TwX zgb^dMyC}WOz18pfSb1ZbG({SQBhp5v5S0Hn`SMixzH`n&ptR>Xo0al%DGcqt%XwvT zS0n#oZ@=qJi}Q zUmT)pD%gY*F~&?`M=9zrA)t-IWNM`=mgCLpIw9bCzi_DwOxeV+rZ4V3N;$X1;|{1> zVz3?e(<|jl`$pXbdRNu{HuVXO5dw4OW zsc-J-9&%a^mT-{>t>s}OqdA#b?X81}NikK9%4K9w;! z&3d{Msyw=7r5!rxGNiD@C&VWm{)Qe3QzsZ0ta+30;@IX7GHd>xkbsZV`z%2760|>XKV!S{Tpz-J z!2C%WAc?Z!2F`N-WfzB|xu|4>oyp6jk0^9fpw399WkQie3}#>)qnQhas~TZP@Q=(q zE*y;X>X2Q~tM+ot7;YkZiUA@cYy%~1mylgX3D|f<5U^7eHIKLp9SW%91Oa!$c4+W8 zH~w-;XA~4rMuwPP#|XwPgjyIAb{)k-!d9ryEKAtCGkO}1MrX6`skLqGT31`AV(M`Z zKI);Ir=#iUZ6;6O+wUFez@`(c#D8U6DAH-(13uz;I(1=GG+~;{A4_2#__bxopUQq+j<|Jb; zc~E=BZ`LU1be3ZP(QJND@VqPbjB-wEzs|XrY2tZAXWsNe__`3!DRo}_!$R7=KYVeg z^qe^Xol$zq1Wyz_-#tD5{}2S#CjxVd((DyN)R3yx6sk>?EKANnbzsBHW@@cidm}r= zMenm7upY9WXT8v}(otqX`*q$XwEULuZ!cp$)(jxtJ|%D)ap9ISk|3lTt|Cv1bTln; zp7K#K&C_X-OaN!e(}Iuo+cdasM(OMMP+!xDt$dM}iT^DdfcUTOygH zV-U_hE$owW-YWD$MYlF@wm;jSor|wvV>GBY=ksQLfUr$VBnQNG7g~>anYwOdSX5Y; zXipm;(mDdLuYRgpBC02}IohZJTea%}99eI*-e!HsvOvOb;uw<}N_SdYf%pt4OfIFk zh`eY)!Boa?H`RCQp}buyEvAZ`Em!_jmyO;;e^Mq?UpI5GI5nTx4qjpkz@>W28jB;hQ@zCJ2JHtiXs7 z(!w>4AYAWelncS7Ew~wmDZ=T_=*|24r~9X$vEEWDz2z;X(p%u?{6Q7tK>mWq5u#jC zOflW0q~gepg^c{N5W`iuO@jp0u$;HMQ4&x)zY6eOZjdlSFo1L_go-}Jln^BdLfl(; zWHz(FsBvxf6MJiuCUrrM{-{S{*||M=kSMI4`@TB#)?*9m*iDe( zmfF)NWDgSTZ_lXr(?f{;peDA3i6+)*2}V;myxd3;wH$tPC0dq4@NlT^c+5vds8rHN z=1ts$D^+An0xg>&ls%BfCzvC)*pyDb_kY`CiSS!-+zNQYyj9;B_@3tv9Dj8mR%6$- zGQuOsz_d&gV?N-MCo%ZPV!I}s$+g#Bd+o%LRoW^NZm4;cb)$8Mmv&cdqHRqY zDhEu@0%8lgv;-jV5|Zbeb%z4%$qE#(KV`6V0#!|UxKm9{B^kTx?YQtt!-;d#O%CDa zkw>N%tyK-#vIhE`g`qRf%antb9U-;%c6nD)YS1rIh+Hde1^2w%ZL2jSgLZwMK~xiejfqXSC{Is=)EvZ?^8gC7oc^ z{eX~AAs+gvN8jG)e^%+XUtdQE09L7V?<~?3suY@xr?veE6cE3|SjQ)zAlk1a4dBTY~IhUb|s66mJmWpiwy+1 zzt(6V*S~c-LG!(K;!cNpr(EG`%tnqKQv^S&6f8~}WvJ%1b=rD9bTF|HJENVq`B-d+ z(s0@om>@<$s8x`Nbu63iim_dR%$4qiv(1&Sk- zTDJ%0wr#pTxUMZKAhEmeHk6Oe#Ih*|wnHLfON2dk95K*pEh)FOWFcxrL=`a{h#c>H*V9q&bq^T(0Z|T z;3W(Y$VCNaGLBMib9S-}pXw|}D|aDhA|enx_T+Rj9ZN1XXE>Tp#@(C?k~O!@qb3YT zMS98+M!qk0ib+U=>2bXiNH*i*w8$t4VsmuhG#L%XM%;`QMnBC(3n(SWxxH@SlK-Qt zd$V-Lg8wu^8h;hI5Ni%UPAG-JM;!nL@QVt$f5HHqkGe?xBEYwPo?(p7QX$wa1QQ8g zx|s<<&l2-<5)=BLZy|)rw@XUMEkfM=2}vl~qmoFvM<|hSRmSX}R0!^;LelkDF@)#^ zBde4Wb@-Du%cvCeen9F_5lXAlT%ZWCSN$kNW`n_uX!l-V8wQ20CbQQ^gwl5l{Pasiynw<`5*!R2KkE=w*fY+2`i^|hE_)I-@8Ok%V?@BR`j1SZqg zB;_3dc$weyyYL%n`pW`9d{c;T3IOrTpLtRUAwE(9Ap~3&LO|&wLJ09Bd{OItDkZ&0 z$VDn8-9Ox?@&}R~`2$J!Ew~iIg@OfGLYTS{g_{k4`+E8c z;zdd|5i0)~vkY;A0Jl;@>9i+(ADE;hw7I_#nP4+9j>xRci|M6DI;y0ZlfKT4t_`T`-A=# z@FI#$G8zsh&IluV-6T){L12GFAy7-J0uy~H^LmXfeUtt?6<0$igVrNu$3U~$YtQDF zC~+AAY6yjQ8lbD|a0oq=_-y`!<9gc zq)4vvN~LzmGB?#P(Mj-F14Oc*Fe!K)qix*lJEmO3w z)@PPAjVhoaRy3>9uVj%X$_O%M^T9;`(V@XeT4N7oRXN6CqU`&L3aj+38!r1kAPlmq zobysigoy)Lvu|t3IoQ3vw^tW@$x&4a7~LrZ{jJsa%bXi$g%%`)DAl=+UK~*>cNdoc z4F@-#hrJ}*Uy!|e4)R>+TukUyfcXEc(J)PR zf!9|hnQqWS#hnz7rwO1i5;I;eO^#2fD+;KnY7bI1AkoT{ZcUHm^ndnJ)T+rF{US=A z909EMA3ks^0CIzC1Oishl$4}NT}dcWwR*j#C?O3QamIKg8}onVfaKMI2Y_7GBvPuZ z1jhhqyq5s+h*I)eL*`$TN)eAhvKLc8#9TYvOOQ|dK~d{o0P zYFC>BfTPx!RH7C#g4LPOd0a{LI=(DYo$&zx{4;%_BwLpb065_pg$WC5Z>2OrC|O!i z2(xt^o?!^91+B}ErZm*+KaJuV+PIN-Fb}Ih4Q6(jKgqC`_wO}8#@VZD zppL(B2PVE<$UYUa6s0eF{t~QmkKa%(mY(NzuB#tYICETXgXmPial(}(3X!ww54hk} zST4|@R0v8Nsle+DVv5MtetZESEn(E2$!efI33cP^y=T*63*y^#Gq5@4OlNtsnZsl6 zHeMB+i`8-F0^B&m9;kBMKnAb0Oc(KvmJLY=26rti&OU34@>_I87KZL-1x3RWXLw5I1G8Vool$ zTFH9P^kzHO-ElQ&F#*p9d0JQ^EQ;L{?L)aK-=3Ltz60L{QjUV8aPPyL?_%uVa@wM% z%yX*yc#l!7ZCf^}5Y!y^Z$y;}L7URH_2c_WOzk112_-=un1;VsF#mfG5h(yv&R9e1 z24lGb5D;!6POt={y`&?zMwZnH8%lKDQXCZoyuA%d<`oLcev+YTZ(uTo53l7#x+g&P zYK>rPd2K;?!uLxVHgJne#t47@n!ljDE7f-Vhx47m=nMA(ZcC*{+*nRG_Lh{4|Lt94Pv>oD2@m!JAVSdm%G}I}8Ba<$gWJ}6$n{U|v&*~fk;3_+&h+Wot@PnE zedTU%qy2ecz&M~qtw-<;;ks@v7@UXe3XJ=+Qsbq6W(wDJ$7>wk1b5$aE7am|vsjqv zCr@+F0b>#SdAw-;yXUnkv6wtW=V@EJ)=gHvqm8Bxyz>T>WyK)7Hxztpna@wnAdIFxd6&l^fS=5bPK__Zd1kKKxj zF&|r?bh$!Yg!UfVTwrluF^b9}j%vi_Jm1>dzI0}2qVunF?Cm#{^E)bz!+3dxXs+5M zFS6SYAd3F(#~E9G&>0Pm-X7fjQy~&aNPSP$^Q5%-P_@6;dV%$F>mZ*OnlNd?Fax=O zYYw1u$g9Y7%>-pVoX5~x1(r)xoHHu2u6iw1;D}Yf8VKhK!EOz2*6GxgVEM(|G4LMW z_c6|V*VLcgjtySyIM5^oMhIlhz#vp@TdD?r{pvPvW~6Mi+l|TV4s;yyM4-v!HeSxM zMx%@|G3NSr!#nq;)KGG+#BH={AM9<$_}nwb_nk2HxC+DaQi=pe3AEB0V2z;EF>1?} zYQ<+vX+}Tw_Fh|0@qH8OzD0E2GKV?8lgQt97?!b;cu* zFEx%09lslf;l#E>RB%8o z?m}mR3R)_)$w-fjb?7h0@#=6R7QSQ0T_xHWYxt zB0fw^!{KG7R>UJ_?@-A{pcU=#%5rNZHV)|j^K7PHJ;LakC$>jKAj|qI(-o#%SsuH? zFZor&d2l8I$HW29S+Nc_u3q4J{VanvbsPCg&{fU&nxYH>kn*pwq@lv6xll--n?o|Z zcfg?=^>0qYz{>jis^wIq%`kjS8Q#WW<@hX#Je^IqU0LUu&A+U4(gRVHeqclgmR< z%8QyX?Icm$Q6tTHgzO4lJ!3nGuc@AP%o0Xbg|DD%iQa{(pTRI2S+yIL_gLEr{1J-nhw&}Ir*RxT+a z;_#+ZytGnx=f81cJ3)*UvO^p=HsM52VL`%O1Qle+nF0{ac#e^>?{xwRXdAhich$J; zgPmI4#?&}K+|lSdu)^P^>W$Ix1QcmK2IeP-H|so<_zYr%1I1`Bl!!_<2nvg-ErRSXV-BQFBL5;Yd5^+d*00+=;Jq7b&_tJp2ZGYC#yym%@ArK{8P z=ciSzj!tciPM;oaoO;HBQmK9VInO!WradC3ul8v>=m2yATN=l$B2;x9BNxHWhHn=Z zJJqV61LS_S+S&h$@Lc_Gx8)r72I~SA$(G4zIO4-9)dGMP*nFKQadOxeA3?6N_sDQ} z$blh-O*wpc&)D-jZh~=>a~|GR8S*#wtP@7fR+HLksS(E0JH?Wfw)%e1Z>Qpb#SCGiJGVNjF{a7k-V)|P!OXdcx|WcRgMwU~wd2g@@q36%jAX}9DS9X5EIR0wrJ9Z=}1J?ycd2BmB3XE6yRuaUvK3L8Y*thh zsmf*|X|zS4x>af488VsNK}lvy>d&uAbulCKj;~|-rWA$>6C$VQb6V&GRHR-3b7HA(RLfTx5~Y}(5Jo)KbO9x)J6)vtqv|8y zoSsbK>ussM_H%^m`hOEBAssF`2X-5&T?56j4XV|p12ve7Vkqf?%36-pUUM8AbvHJK zk6m|dH82kS*}PgK8;{Qfa(Vi;AZ5rLaIQkMw1{nrZN~t*8M>&bgLqy$`Z4Q`)=yf8 z{VCqC*+L@Isl-N-+?%3YnYjJDRX&w+33i=&gxhbR6sANOo!o}WRGho6bO(*{cpKD6 zqR$C=1CMsOj^DA=C?W@dExYiM$n!-15g&cXUn*9czP2G*iOW0ro|Hyl%%n_}?I^}M z0_NVFGv(MSm6BmxBB7Np zw>xbbn^1oO&?s%&+Fz@hszmFLdLrf6PwxT_gKU3)|A^_TK5F6FaR%jbXT9Q)QaLyE zbJ^nXYdaCkv1(RsZCcmFRbr!C@M-UGX*8v$%_+|YROQJYOjD4jV3ecjVcS)7wS61L zjI%g(dk>VSlj&sq^h0*(>TbEd)Xd!5?y(qL2y^-f-4D9XJ5L;I^{&YyO8+)n^6hj% z=?_Wav>U$9{^>RCZ~0oorA9Qo#)ddqxaJ>Wp)(Oeh)HJwHYxx5#eIGYBx$=EXNAMH z!Dk#H|5G_%SPCNJz4MoRc)p1gyjmS@3pt9CY9_>X2!4nAt2 zTAhLq5D}@K9!@)W=v$%;8tjhzj=V$m0C==!I$ixgRlrleCyaBJfa`HO|2yRxwXF0d z3U1B_gc(Ebh_R827(-@*u?>UR{AcIyz|i9i@hW3gqKG{8(&4L)5uU$Kc%K;*mYaS{!{kqPbVt;B?UkUWs-FN~K%l>!Q3Sj(n0JV?zMp{`>ujcN-~m1t1y|NE zDb^TU7CcJjEmQC}*b>Mk2%NaO405o{q3bSUq0Ja~4w{_hte_8Q^gHS?4z=3N^JHPU zNstjN!CM16{Z+DUB?k`WQj|UxR?5-2F#Kq_*=$DcKnXejFIQ=Ozm&HEh!FJ~03io% z+-x>W9}UBEVWk{>tRw^{&S0x^=D&26l=o}>&BA-AY+1j^?~3&I@J5#gm^uMOAmsa3 z?dN@eSAnPvS|?P5Ct-MYXG0Wsi@{sc{-FKy=)rK*=k2&itftgXxp+KlW89CE(KML8 z>EYM;d3(^#`=iPDH|U*G*%8Ldsx;cV@jQRNBP$5NHCons^fdDzK%vcPmQj%6%t7VeX7rymYOrvp zD1L>p%LIzMztO~3YUjLRGu^;)r8X-8IyKayDwQp3GXnp``(rzR%6Vg}bQEWSC7k@I~mCC|WwF(Q$?}tVQ{m@LA z*QU-zYsb3HddSLw5JW8)!%F|uG(F^WvZ2K%)b2nPYnAZ^u{^oJ{>p!`Ti>zcy0d1# zRjHV?3s4?_eQS1@A8|w}%9+7=JRZvr=dJRZd?ko()smG!Xi_(Ox3;7SFIHOo?%C6X zyszSj_f`Cg?=>C&=)KCqcrjwxNCJ_JJv$2Pu9z`-} zS6Z(1Rc68)%i5oO#5V%O|J1JEGFp}&XV5O5fd%D1=4Wiib&@v1ny&Mo)4wud-&Z%5 z_fmuagO^zQmUY&8nf1OqZX0H&5^Xa%Mmg#b|vUVTve#*6fUQ{%f{dM5zUMfKcf9;i7bx zjgJ!71^W!RyFT#wg@0K3`o6h;_%(zvQY8$4e1b5JV~i2;5dbc!=Pf>IgkN^=>w`m2 zQ?$B@sO?6!CZ{C~E8`r=b(S@|>t0vXB)D!8y7w}rz4;MUAmjFqKZS8bFvebjk>5nW z*a#;c#+XD)6aL2Vl&C)*d!KdDdXe=?>y6fCO;01M(2IfK0ep?rZ$oiV#riVF%_BUV zl$l7Rt61 zowa%7XLIu)7z@8k7rau98=PUyTQ&QHpVoNQF)i0ld8_LDU2MmZTgVWCU9;<2yRF^U z*1;qnh95V#Ka=V*&F{hid?RXfN~bId0-#DSUsQD)G9oFDJ~zvuJF`%KaK=SoZW;{G zbbpAKRU6;-`@qw;Y;8K=?HX)vZSL)DZW?#yU}tM{FOh5Ioh_I@s@M8gb0y3qonAte z2}X|GJegul|3aLI$YoMNp7+mQf_7Gx^)R#|tmW}V!g_gS2PI!7h`i+pC0gnYdXi&y zR+hC+(EQ55FPSGOBd4Dz`>0GhSlBQ)nA($zk?No$S(x(%lap(6YV6SbxLsW~Bi;b~ z&3Ccj@-w|^6>S|mJ6-S9o=XT-jT>nbtU3P!89c1lB7390_uyMMHWZ=cxwYQ<^z5-M zRITwjr~F2m zjKVXu>^)+l7s2=*5ND0!2#0!|M*VTu5_hHOYxAX_}j_HIXVBg(^N~59hMRR->**LHy@y~zcQYf+V<=XnrZ6W?R&@|Y}l$P zlb9z+V16*mS+vw{86|1bmccE1)WGMZ^%yzW8K}ZJGlk->-8=V}#BnE-Ss;iUD5&%F z(0IP%5X|dA%VtdbAufudENK(oMF2?Cg*BxP1t$8s4Js$cz!7jR9H-^RsNq*Ka9OF; zYNn}ZLOAikuOGAS;x`C_Y2HjIVLG_>f|cd#PoF=3`ugRSsx2r8 z8GY2k#+tkaBMV3q@TPZ-aVrC4!Q0;uSFyeQhoFZO^GPyd$Os{3i=Hy^FRTk7R0~q`8;x?^@#Ne{0d-+PqfAz{p4hEj7)K2sBSn=Ub1W%S!iwTnqk%JACmsZ6*wfwu44o^q8O8MnP3bw#(+r8M&!p4S!d99 zFF!jXNBh|&YY5D#59#8M{2@YDhX75|nb+{x(?INN@gpTbpZ5djD32C5zTP`7c8I#oS z2-4J!SLMTDUgfYo#&f+!qchCAW1BHVr{_;1C;>N#{O190O=OOiRo;RUC7hxJfn&0! zngC7vp0<%@5JEFue5LBXGSOv3--tdv%fbnuhjKFB~=87vlzg&;O? z$P2w{M3WL5yJ~Sh1x1S!(N(ARDMzDjY;meIKO*B~c^~wbW*`%J({#IIA`lL$??G`- zLKv*%Cw&x}aeR!*0#zi~*KTlv!E(vm*svUyNGuQQhL27k86541`=4AgmzFMT|NZ>H zWIitqP&?DahIM26x7e#gF7#_8ZTe1d?FVI+q24>L^Rgo&N%QjnH(c-0UhKc@%AWl1 z$03{_T;8etjeg9gXO*l4Z0vd*r7g3@bB9d}Z6#@|7-~b>)6rzSQM6o2&HP+1HJRoX zg=bUI3K5q6R+?2%_LJMP-@$amt*%h z4eKk*EA@9fuA9s+CawYJ-OvXJIO5h3s)wM@L7cwh^qr?lBFU8NUgddL;}MVR&Ohx& ziR*%KlgRxvER^qL%tUG;ebz+Fr&h!PT>}#Px-yDeGOrtes)g}BYX#U1X!ZeY`Th%g zZc%R@7&Cu}Dq9(jNMm|)8b`sUdGnrve$SW}_I#rL1|0bQ??=Xgg9A8DhWQ<{3kTMQ z^|19}D=+YSE8=VKrGj1Y@f|;_V1n@P748TT+v9sZR`5zYODL6_^U;s;{ED!#D|-2X zNIqHeJ9zXFcrpgW)UHp^te$-UIS4}}5zHDC|I#_FBjnj6l>;#I&@QCYeov;Mnt89X z-?!qD@;tM!G#V`}7|&A*Bc~IkM`^TR5LR~|uaxX8>b`4z4IqZE-x&%3-M2l@w(U{{ zF8ZZ+?v_DVRT_6fW~ z(NY<7=2`yZ^PJ@-kB64Vmw&SSh!MIRkr3Y111tA_3nc2 zXU(OhwbkXNX6E}>4(Ct7U1pxHOWzh)o9pWhhOqi{R`2=do^==UYSp-|saEra?(CU> zR3RN1qZu4!L8*5!3);$I`#J`Z^!-$m9Y$FpxK(fR=`4;Ww3@lAuaL9#@!>yJcRDE! z8K6!2-Ev$ZY!~P#XsWBzZf>k$bbS5)CwZIyB1Y@TZ#Oq0K!h)EZgpF=+LR%LHz$oK zYK}KCLTp;AwYpoID~uqB#*Ma*))AJ-5u#Qb3~DtY93-!Xv{Xs~(o!jfV+u8N7(0d~ zaa-|6Ry&xfC`ytjQVe%jenK~p;;J1d%s6ofN2qsdu|@pG21bl@;n+^U?_rKW)`ChU zs7XK^d;R{-G3XM8@J7QYt;JJ41ahI4>kkl4!z$*(2Wo5(t zzO6&em=3k=|7fw61hy(KEUaL#uZ2_9 zm!-X2#zk2L%sUFmZYxl`jY@SLO(wT-850F*E-`OGi%UBS=HT`!hbBh6^)BA(xgpF)p(z(1Ta0u#OPHa@C@gxR?w-~@hi zD69}ToJ;-O?{9s|TGSN(sLikZBP`^={f)|!DWSP;jjiK(OA8BZ0RI%K`2ejg=$InF z!fAr7DnMizqbE?<1TOxH8!<`VczN(F%46ire?FzmxaCp_?#ROkEw9X0I@S5V8LA`W zMUp%5`E^IF3%9<5`3Z^69&hY4pTE=CX*{&k*lECVf@0>%KX%KdQc9WmqqLnbvDwNp zO8qZCp#H6r>>wgejC;yiclmbYxv}wX>xSL%>TcK#PjyqkY*6XeedLYB39}6`}Sn4Ed6X58o=~+Cu!-nbdQ%J;45mRnn#(OOEqhy~z$Y*t!T?6IQsM`t|4ck$ z=t?H`c_uS&obESWNONt<#*F3u>|70k8!yOQxAbn#ud7IOUdQ>nORkH!D(Pnib4EgP zgAbIXjT4u9ac-_fj}|!=;B~|l8JaYWLq-m-1#Z}|3$Vf{woQ*G(@~2wD^ap%X3!{W z!@=D|)?UyH=d`k@obzHvun}5Zj zYOal?W}Ep)heGm#Mcl!7%nb>$!|q8(jKxyRIx z22-bf2$@@O2;LnShv?v!`6$Hr4i64896Gxti-|t_8T<#y5TumP&ze2!X6t#@CUU+$ zK;x@H9Mvos4TfoV+#d*?OmhGBZM|eQYJ&53O>1Jby%A?zN<3^*gS=d9bOO-L<8j7! z&G=(!4G@?I{=oRY*+Fld?b^PN@HZHr|1Q~62l<0O(#m^1rHqW4O+c$_*G94A4VZCRSW z#z}D?ZyK@!rpJFW2@vrWkx>}TNJp7d^tAm9MP68(2QaSnBO@iz#6Fj9xz6DhK-}0!}DIdbuT^)D&?$noNgzJ^=Jo>Ss24#E5LK zVj}_&+k(uG5m;ODIAlC$hIc)`L8DkHC6kECT4-#)mK3QD^gd~vm@Jq|QuA#Smc_^G zgK(aY#06shq3|dx`s_;xL~$cenvFDX#1Wu}MVF7R$VKxCkM>bAXmC40a2P8ZH5w6> zNjb9p(8OTtvxAu6O6q&8UyC6&p>GH2`(psQu8gC_Dhc=Z^+*Y|BT8bWy13|y)o}jL zEhlR4aTn|zz-EHFYW#s|ejB|GzEyfE?^#x)(}~?V02G`;IA0LO5KSEc#|iUMTMCA~ zl|pwnYRs-?#M5c>SzR=OGs~0-!%_WC2 zO115rI^{$e!|SS@rDjL#_?Fe$-Oi1FcgCEr!Y?O$eVr%oTUlMHo^=VOj$>3b!BzsebaGvtXe&mjV)Yj6-7aRf^iGr z%hTem%qWG1G)bEO(MS?HL`s?YY%_$%!ugYMsI@!)bR!4>g0KO1xmq9Dm&AalY&~ba z(t4-$5nq=hdnAbn65^^448XM;R73vcidRfVR2E1;+mP`>Tp>>4Mpx2UDJI)U`(fC< zhT;Z^Gw<_tq9l!KSP2>CrKV7?|7{jfEeV?7WlTT;j-*{tBv4W~fgnV3;$vnAP&>8( z`E?h-jkTZ{lr1^Km@^i5DOYH7dgm`tA-QAwuC4~9IP|JD2t6l^69ki$mGb-sV8=K8 zHLWu>p1Is&xLjdcYCtsvisDETi4`M(GQ<%QTp?IYv4d^KiQ`*!rBzT&6>xnde{6Cl z1Pi=I`|Pn}Z2sGXJGM$)9Yifs2|WiT1k+l5?Li)Y{s#te7nLO-(p+R&);=6TW(&7| zQ@dKNbKLfb@=qV+9?Y|ibZYnG@G)Anj&hvIiZ>!rp}m%tSMpBg=>J(Q~)MEqy82abaI;e+4z{XPNjrhH+cxqtWUTW$jwwD21tCT3E<$@__Vx zfAL>hRt&q>Mi#u=Th776JDw!Tg4b&&Bo0yd@DPuOt9kiCYj_6XOO#T4{B8p2>PCLm zTiHBxjqpyUZ@v*@j4u*G*!^b+-K~f$xlesMvxizNYRPAQ)8@%d+bUaUC|tU-71NyI zH^WYcX$1506|q9W-D5;7&Ci9l@Oav)`|;Z&Iz_}5i0^A^vMG>Svkk0~g;)W$p;e;i z01`{|I;C{%S}in6Vk#sdoZHpFUpBTu2*V6vO2oU=6nZ!PplWkYh!hk{>4vqM{q@6` zp3_9NnhlMDjzBH2g`zvpBZ#7Ax(TsqKU?^g1-LDV@SP5U6=k0IMUB|2_c1p@Vxi33-&I%0Om!p zhjG>?FS!7w{?jTf*NUxiwz;cVIL2t$gz!h}%IPT1C)1Rsh!meo?hqdcfx3Uu|QC73|iSs0(~u1$RR~rXq;0kH)xLa2X9`*+$|@74lXf;?u6ij|AJ<;aq~iu^q4( z+h94ZH>u-6b0+}^a-=^$g8hcqRlLLd8Pj9o2qksjSD=Il=zjArhph4({JMpHICK^tQu>_GYy$i``nvs~YBQTrC^8bHw z_(2z4HbMfgR2H>k^V5q-aGDeBtdGa*4kr9`kX*Tpx~IAA=*3EfgODcw$!;M{dQzJ4 z>S|M9Y`^AR`Zcy~vnXP=ZNEnE^^(`v7>nlW>bT*?JRwRLO~L<1U6s+f!WN40V5kXs z0C_mW6NQq?UV7RhCAd_N*cwPe39`$vbR&kqip$SMMCj*hzX{a^Zri%Bkrd{1ZGjW) zl&aN|g9%@-J=eM8v=I{7g|=tP_ZAEoM-dlSa&1k9f*l{Ds@Cft>W2G<;tRC<+5sG#_R~d)JRaW%;mfP z)0YB436hlTU)PmNyIra124>8rR3=&#Z-YyXw$e*i92V6?Eb{=d5VHh+XbhM__ z{lICeb^!aiId&#Im7xAczKi$vITKZ&+IH^C!YB%}`;68Usv@!qMLm^lRib8@NJHV? zMI-)_xGi9W!|_H+Y4JA{Nm?_An)AJuWed6?qPn|w-`UHH%n0?ZbS6F0Iugy-MY%k!4>XD<9D6PeaM3JS~~6aCNUtJ&re_y z*^%|25{Hbq`xzV4$>^w~%jy8z^u~*|SMV;@FKUELzI1MT8yRkl{qZ5BPWFvaADY<_ zCp+jiedA(%Z(c*~qHPQ}XnXtIQf?5^uj#0>JMXA}8~R0(eLmGBSBOe31XYxj&Qr!V z7#W%|W^xwHK355EvUQrcpzFhC>-@cm1#lGJ6jtWHbM2>6z{t5iRdV-@VM{z`)f|%{ z)U*c*R2QJ;y4ghru1a(XR--o1`gjG!!UiM$%sPsAG|odHdKz;-d`^L3D34!~Vf0X7 zn9s?wz5Ttt{XNA6nzO^%>|jy-U^d&Io8Z;u9NnXk%~A!UlFep@(k#z1`*fhpv(UEs z)(PuY>p^baqWhc#+U92cRGm9KwG{!Ng2|dXYM1O0mg;sX6R(cpYH3 zD^i+|w6#ueIDS^U{HE5Wxv)=;1Ur-zqLJ8O*$< zT=4qO-(*L>wti!RA6>Ko$>Hnc#EoqpxT~x4*N5_@ffSCz#1x@BDy5S0H|UhS7mfvt zRXKm35{|=U;4p6X&};qQL;2imZ%peOh2#0|4Oc5Aco1t5C%ctey9_V3=aN zQZ|Q!{x;lTI)aK3kfmM#H#xG9@`hkZw#*S)PWn+QWG}UJZYi_nb~fQGb(2Q`Hdhja zk~af<7~o6I9M-F&SRb(dQWi%a z6E9<`S=6;CE+2l%8ODz!^yPwCqrHW?HH0WlM&hzGhttV;=;)fT$Ymn&i^>6;VCQKt z-jC@9EB!6>lZ1*}r9b#@puzuE03?4rz*zG<=URj7LP&+6;R$L#^^1r(w&Nl2T`zFx zLHxiP!VJQWlmr0~>HuilL&$fHXB$k7mVzRfKYi~oG$98x?E(TL=7{6Ie`nVq>e{*o zB1Cq(SoC@V6HS7=*{?8GL5RGDeNDOmm|!lFR7yb9h)!J;FaQR$cx;?X0f4D%Fc#^= zzFQym;}_sb8o?}i*GV^vNJk=#JH_eN=QOT7E+rzWi$RAHXT1sC@zt$3o}>k9eL9(9 zyeqFq+SmfH+u;vv+vervM(MSxTkYrQ)2CWK=OiHZT}(*bx^K<%=7+vXjPd=1i8L8v z@7@j2UKb6wObM}1eFP&P@iAm~K&K2%fg>?96fC^o5a8oBvzkfbbq-3_*vCIOO$Qctu@~WLZ-^M$Gb}@zcvr0P=i3_ou^h%1tQ;602Q|!=0FD zE-%jOo5qSqo7V$Dw2#ob7r#4k*AePdMBw_(h^sh7NrDDU6RZiCl=RC$AS6_T#HWlQ zk~XK0B*GX;OL4V~7_+_DOh`8#DIUeDlo^N7wNuxw8MAinsWr+Rla+K7aV1E~g2mXg z8H-C`^|eyG_ApYM6G`#Sz!8>_;dxsnOo@-NPbhYMns@dCII^g93MS?l3(~w8k5ah? z#Ce{GeR(b13V&plIx@zS+QHhA5k0XWA8!;J1y8sM`>k_cZQ9~*jKpBo#oPz@Ua)fQ zrP=e|epj&csyH3tw8Gv2tbQ{Cy%J6Y3@A;vSfF-nnHiv)K+_EDhcZ}rKKcg@pZS}h zC|z)HINSf+Z+CEC&U#5o^E{J2*lm?$R0*eI3QSFIzw?x3&z}FOAQ?9aCRo={-QTz* zoSt8rOLUn*dpi~CC8b-nGU#ZqO1$BPO0zC6h8-uNm8GB^CD}<(F&ApXaeoXiqzkBF zr0>iXnHbPEIk9M+vRaog!YPs3V6~_yx_YPq6&AGP76-H#h10Eb8g~so7w>N&9*+lD z)dWb^ePaUS5`u-K8d0e{uskJcSV-wdAOhe|%3ib$DHU6S2ZyL<^`gs*ATlQsc+WlDh3>TAoei?+*>SA$hjGfcA)HJDAfG5G zH*WFWMXQw_c~lb|SgUkiSSlxv%8T2P%KvxYW41W)PWUT~xnMKOw_KnzhLCW*oux%| zXRTU`y-u3I+_Qd{gRg04(e*FNN0V|XyhJT)=Su-5`q7Ea_vWvNBAWkod@O67c-Oys zMj~Ll6x=ONXH0O6(c;n$+{w9!y&!L|&aWKS%mdnghPRDAtvPxOR$Poej$~0$Z)9IA zg(L+uW6PXSJ%&dNcr~Ft9zyTY=K-T`2P*KfSfVhE z%&mEEeRRWM5EEew$e9V|C_di+@LZ%8b-Ux0w#CB%vDZtKgX)~Qp!I-qYm9)23Ht-k zm<-G)4o0otgd_m~y#IOzUkXgGa_8Dw13|sFem<{jgx#}iYjxb{_0P@UT)uBGXrg9O z+zczJO{jk+?=(?!IJ!UbSKwR3?u+vB@&NY+gPvcrJx{Fmdp$f@TdT|`&R}C>fcu?J z$w{5SXKB0L$AigaB;8dDetJu0ty$T%&WJZqCzI$+9|e>%b(jv&Cn2Obc0a3o8HYc%IIe+E3pevYzs?;FA(Wg83&C)VQji{-!BigZU8M$$a zR@eZ@!|E2ndJ#|vIcVD(U1I={pr<-;p_FrioViza$@3xR2>wr)zmk5rQa@gcBM9n# z;P?UB3M$)>m?N=jYNjl>xIDjYf z704fueIyWI28;2qb$H0h0JmUcI+-3#R#qxyh?C`&*=%JwiJ@FsS($W{)OPN3B>w?( zy;>jYjb3kZ?8NTMDnhF(yC;rKdc8&+;1O+0kq-x}E`ZxzTdm4ZOMIHuNaIwa!HozL zt8v)=q{hLkz_7Pon=?07PP$xNfu17>sz*mhM^|iZIO_pN8tH^soqN8ZJ>c#4B23P% z%x1vG0Q$oJOTBQ++30f?aYdjiUWD_;vS`Xcm@{@amm2+6EityjNZKLm7Qg{6M=G6} ziZ4&sy<-ar5JvspxpTdK6aq+L&tG|av0p!ZOh|8*s%L~dDZxP%Qq8AA4{~VAkk;y5 zx*~;*vD4|a+X%JWosNUCEhu%#`u_ucDVns{I$E$~E?4-T9+ec!JbF`Ga5fYQ)9asL zydBVx!jQE=m*z&;=6&kwhk=iS*ATj{ETi3F0+K4#L5N&Ek;2h6?Znm4}jnrmDJ-hcdATkrwVgA(W+wek4s z*;Q9>Y}D?cARj~?MS5G{acatCjLT(1xd;6O&8~H|wP%rS6q(q#fZ`^=Sm+2Ol0B4W z>%&5 z8ev9qXB(yM4?nd=eZKW~T&0w9x4ErSEiCy@beL~{Ms}W|6b3R-igU^?^z#e)R>rrW zurDUO!760*B{nz}T~z3XoWntKJX~P>ofN{#xiKi<&(m~%SkAIisRa9_EGw^T9YhF} z<4=n{5MbLRU^`~cmx>dCZH!TB-M#-EGSBya*DsZP@0TXV&aZgz>2j8p=Z9&i(Y+kl zPt)s^@&JGm+cu63iLo&tO22;zXoM&;TCaJ9`D-Eb{8GvPUElY9>3d;*MTRHA57}>J zG@1aj(NQ=S7^4$Gqg&)uKF$p-cp)hl4X2sfy_1$ zwXy?t$p)0{B?xR=xKXVpVtfASukJ<0f&OjlJ#d`Bvu&cy*rJwQ2868w)J5uZQv>D# z31gM$y}~rMfq+1M4r_LccHT!YU1Qo&<~qhz1ToD{bAB#xY-$70JKJ&9QwsQc?}i&M z{9oMf`IJ5fb0*={;>Gnf78!~K50U;5?H=~Ah$r#+uv70CmDXlz-gZ6kK4MjsJAz`3oV zF2q`V=;+{JPM;?L&W!c>K3qQECs_VCS(fcvO7qLsQ#2))FU@VxiB;AnLyZ4#5(YR{cGfJ5(o63-ds`;JrcpGmFo ztllv8^J$!*isJ(#KD)*WE9Y#9Ehfm$8VJI}u>U=4;mayp(~&2++j_`)J&aYVY4#9T z9Oh)FEFEnwNeN^2bp=MNI$+9nte$W@&)l9OC?7wc%`z*;m!vLNZ5q#iiu6*v4spx- z@>(t+tQ`hQ&B9vE@4j&VwIBGZHfZtFY354Epv00~Uer25Hyp^;x$-0z?to;kj&2#B zOfmH3T6+kJ(2t!M3&B&i9yY*~JK^%DaeL z%Q#wv<}TsZM&<`gvoOc;&tOM?I@ayYc5E~ZyBzNzXgczYwcLT!I@s(_$2}eW#EG|R z&q??Y_H$17YI?B|dk+B4oaA=(tMu;9^?>y<>&@2t;um3qKbfK$g9V;}4=hlGW9BY5 zUoS>8K_kf|L=iiGj26UiR0&)inAN-^%EocF--yBXm)hm+MAOVKd<6E0Gp}p@y91ECysJ-qe?nn}Z&1?LmiZAp2g0Y+Ld66CIo@Q2u5rk2_Kua!+x1% zSkAv67~b!)riUtD=Hl^okvH6}G`}Rc&=c3Xt<-~Qqn`K(MM^~o`ANNzK3JwExLm&_ z0V#BVMKd)CEX9U#3@1}Doj%R?uq1>D(0NtG9_KgP6C}D$p~b_AGGMkTp?;99({2x% z6wR{Of2_X8;}KQm$|O=r2os1hy^3ra+dWXclY|2NA`d6 z)LNQ9UF9K_#pJ$)=t6^eH=xA2Sh)HT=pOyT&(r#jM9MwealoGPJRRte{O` z8L8O=^}jf&y;TD6g$T*apmLvV84;<|C_2*-IxREKuN3(}<>oz$v_Bs72h$NR!mF*x z_hjR=;34cdGym(=hnTos*P^L$O7Hx6-*E_g(a)-vweDR=NGPrsZd>*z4Hr?=zW?Ep z!N=d;YANtRDX((=30Qo|KK$?l``(Gkt*g!;o``!IXUd;U;r~o>emSw+X8Yd6Q2DWcB`Y(=lpk0oa`g*D;EXT>l4tk2Y^)ItB={q0DjN}Giwwc; zT@-J~GMq7{yttj4JOq^=jZABVDGwSAxMzGn*#rA?V^DdeZuw3$JX9b)LDW*ei@^G( zgB1oF^kHYkCg&zfw}it%payjPz=$Wt10CfP%F3>&wKn&>LnKsW*|s2a)SmmxGk0f= zwH=yMR9ID5czI5aEfXd}aaE&LAQ(9bX+yb-K85NZ;2Zr1LkJl;fiy2JaVdE7l}zJUh))MmwBz!AGQpr1v&)hSCr?nJ$gD zM=I7(|F&*#UQ-cOjhh^cAL^C4K;><(-M})8K~0ZvxnkK5e*@j-AiEm{S4ls+Duga| zGPU79F-X9Xmz|SiFDY~^uw}bfuBxqFwbGq!Ti_@xvFGGu=Q&>U_Uc4pY@Hg}bDsWm$@OTFG2U!^TKH#gT->8tU2H7hgQs(TCbZ6db( z!oo6ZPHw@LRm;oDRLjbiuijjcn`1321YTdWOQMunLs?}@t*tVpLqP8};84ks8Z2;n zp}dl!DCisB=pA{vd3pW~QtTC)n*nF|-XjK#92%5q07>DM56VBdsfVS6weCdl1(sm`>-&$RSvn`DHEdH@o<`o%S#a<&yTYb zk&i=KMkwbqS#@u2lc4Sql9-UXPb|BoSUW7q^K~Lq`Sfk!g&H45%C>Kh(%=oK&EtaK#BPXw z1-7DxNoxy8Ww6XfX?exVG$zuu24zX`PY_b*al)2i^KQZ{TZYYMbJ#nE%N>2I)s-`5 zk&nKhaARl0Vau>NntSY3^Y{BA)}AXh^g!9>2PTYTgL3Rb)y2P*1-E4u%yoIg8nu=M znNHatAZKJYnn}>zfQ`0YWk*Bs7@~(=ROCwsl8C)z*9t!{UgfZ;UKUjI<{Vp>dbUc^ zJ_9bwrT11czB*g6b6N7M79np8U~3clD|s~;8sHY?b=Am0P|17LT9Jpv0YurLM% z)le`9g>EPufWldDw1Hz1ib7D7hT>sxHh?Pxt_dg!KuHu9d128AEG~t`-B8*FW!f zw87CAz|qriOb_hrgI&#VY$qJo4o$%E7r^dOIN>We(GMpz!^wU)IRPzk_?;I{vBN1N z(CUWPQ8={+PHTeGz_!xDIZL!Ocnd;}rBx!k;?f)&%@H0Jp`UKLB?O!kr@!al>7M z@Yhm^4#GV{aPJ`87lr#H@W3bx_QON1@V9Yzcqcrv6CRDia0DJ}g~vVc#AjuiGHj(WtAMv4ZI;wa)A zLtHILi5n^PA!R|NEQKtsL);_CG7IAIA(idO@Fo#*x(>$XYM5E`h8M zAT=3?&xh2uA{#G2HjN;4qsZn_WJ?_ghSk+WKnv*(adKXP6Ja(+K@ zp%=Mu0=X!LTs(?&v?7-@A(#4)%leSc1aieNa%BMN@*rIu$W>nCsw8rCFLF%_a?K3V zeF5@^cI4Vw1{)92_v_H^tB+jIgr~2kvlq(J6n*5 z1-WYs`AY=(>l_lDMD89$?g=CJhLM2?a$gT}zZ-d=9eE&$47MW=+K~r4kOx!9Lm_0y zjtuo74>uwY&mxaBAdie8kG3I?rjg-sonwBXh`;3FN63~SiF=UvG_q#^*)xm$V+?sAgp65`v2oPJ%zl{hP;tL z5-ub$fFx#-H(QW5Q^;FEApeOV|7}60Cy;mBkarWv zdjaIVA>@4*^8Nslj3FOPARpS14`awjqe!Y1`PhSe;zvHMLp~ix(mRoTW61xS@^u3FCWOomBj5EQ-=&c6W5^FdWNtO` zBgpu4UgVbyn-T|D7v8z-7taHw4ych zsIM3GCDB?RTHAxxPN5rv=*9_jQv+J(K{tbL@u6G7=s|s`zaKsL0(5H=8t6fPHG&@M zN4MG0Z4+p{9jzZj51U1|d(iD8Xu}}->j-+d9sNx+8Z1SFY4nI7dPEXEGL9bAi8f}S zjbU_$1KklpkDfq}=|^`a&|S^wt`vIgBzoLVw8@7y4WP$+(c_2F-PP#s1bRX%+T4zw z=tWN)L{IXfCk>(}_o6M0=x@{L??%v5I?&c8v^9pF+JT-HMNc0`&j9_s4?VLNZJ$BU zilb-uq33{}(~h1qg@)?UbG_)fGwAs)^!#4*0ylbL8+u_Hy{HGhIEHo%p_g=`m%7nQ z)97WB=;bc-iVpP3F|?~2y=oG@I*4B5N4xvbKZMb12hr>N=yh}G_0{P0DfEUgdSd_$ zThW_5=*>R#<{9*lgJ|zKdP@g-OB(%C9KCe_?Q^4loHC) zk!JL+PV_IWXtWu<+m7Ddi{4X@-ZO*V+lk(nK=1EHAF!hj#L>Yp`d}&g&;{td~i5(Wj@;SQ33Uf{rH9=bF*yBWQdM-IGH9 zF^;|vMaSyVu_XFp3jJpceW?!}_oM%Eqc69kuUOGnhR}%ybRvbm8bV*4ME^aF?wvzl zbD*zvqpw@hH>%M$X3)ea`eq0ERwMd0=-U(MR0w^?ioP?C{ugxGk512{@5a#gg6R7m z^!+I`8ACtlL_e58Ka8Ls?L2PStMlUI$gx-r%<%z^=otsj#g#Mm7e`!r@@7p7neQ<%Uw zA{fU!rl=oNG>a*2#1s!G%%U;O;x2o39}ANwGXp?HD-eyQ`3a0naB9XF}0nTjeg9=NzA5U zOkEdda|p8~h&iYebI=^dKROSgCTY|(g_?bVT694z>!6lV)GCTv4?#hr;1%@1TJ&H7 zwJn3%bwTaRpbkw?hbt&F13eT(4_!eIUqFu}P&kA-RzMw(p-yv9=Q!%}8|u~tb-xF7 zzkqs7Ks|m!Jrk(c2-G`_`Yc6#L#SUL)F0IU5E?iH4GN<{Su{9_hBiRM3ZvoqXhan> zvJM&r8XZGp_M)*9(6|g5{{)&)0Zjp!24?xJfhOglNxz{;0~9%h9_@o3y?`Dcf}Xes zJ(-W5I)r2raeNenG zdNY9DDvaJfhL#RM%fe`RK3e_-S}_K#OrcdNwE7fUTNtfPqV;9a`ZU^D25tNTZHl4I z&!8pB)&*_Lq3uz$6SQ**+BF01j-Wj;l<0yIKcRQx=)EfF{R-&)W9Wk`=)*ba zqn&7P8137O4usHwEc!TvKDiZr@)J5_bU1(xZ$w9ipd)E?G=+{`K*#5x6Iam596Egu zI#V2-%|~Z5D3wE>zKT9;fU2XfWD5PbRJ6YMBl{G`7!816?Cx%x>NyO zT8b{G&{d;rYti)#%3ML;UP0e=LEo1_KZMYaebCP}&@a!R-;(I}ztEp;(O)t2cNG0| z0p+To{{ra$D(L^cm@fE+->@d&0s&m83NF$D7p;Md#c=UBF0lZYJcUbb#d!&QV-S}P z;?g-BFb+iVO*QaMzu{XN;9HXT);{>Qw)plSzCDZYNZ|Zq_|6QzD~s>B72g}f_eJpi zx8nN`;j$52t_&`>6PI6#E6l(Z!?@BETse!Yti@GRxY`6Kj=_z>xba%tWG8N##m(m6<`LW?h+BeN1#s&+I9LV;58($E;0N2{ zHUZpr1a6na?FZoYIUG8MAN~S|58;kc-1!Rb3hw$F?zR_qkKrB(+$)Rw1aaRi?)L;9 zFai(kf(NGXpeOL)3?5ns4;z7pC-KMyc=QB3CXUB9z~fVR!Ua5eBaUS8<1_G6WAM}Y z`02g)nYH-YD1I)4pO4@vaXfV|o;C+h?}BI4!80r1S$TMNaXe=Tp4$S?%i{S9@Pa5_ zxEC+Z!;4qq=oP#qj$fF9Up$3hDuZ86<5#BOSBK!&qWJY5I3C4s6~=Fe@zN%EX$G&% z$E))2>MD3m3a?G$by2*2E8Y;n8!q5Y#qpK^-f{|W&ERcI@%At9j^cRdt9Vx$?^%iy zG5k&s{B9J#cL={f1%EgPfAkaHmyh>Xzz5R!<1{|l10Sk_4`uM-3_kh{J~js*&*GB_ zd}?dSARpBsA76rel0rVsLq6Sye0DGL#Wl#6S0G=dk#EY7Z#$81zd^pc5&8aFY2@HF$e}-wBNre?S0l%^Bge~--V9JRcO;F+Wa!wGK#hiqOJSTHbL9&L_6xxjyutw812hL`;MXg zAE1M)(ZTo8p;0tZh9ucX&*XeDLOSorwyXh^U&${qBHW)8K=-$ zMd<7*bWRgcPX@zF9WS**$*W4CM^$piB3OuCwZ+Wpq22z6RwKIQK2KL2Txo?L~IXGn38YjOUqEcGOQTwn4nCW%A;T z=MDYbIUUDgG(C~nZG(8n8jt7hvSVAqUEan58=jl-_oQRfyQ|MUUb4sjFPKA<-HC2; zb=os$dpmm~GiIaMgf`qex+7!!T{bY07n>bH%EZ==j`*>=*2_e`4a}4&In5!c0F^|)t z!6(LL?eL^jjqvZ&oWc~wKkE1-6PQMl$&6>xYfShmvS)2cO@CeD+3P;|f~3C~0{{R3 DW9(%e literal 0 HcmV?d00001 diff --git a/dns/server.go b/dns/server.go index 4d981a3..730828d 100644 --- a/dns/server.go +++ b/dns/server.go @@ -8,7 +8,9 @@ import ( "net" "os" "path/filepath" + "runtime" "sort" + "strings" "sync" "time" @@ -33,6 +35,8 @@ type StatsData struct { BlockedDomains map[string]*BlockedDomain `json:"blockedDomains"` ResolvedDomains map[string]*BlockedDomain `json:"resolvedDomains"` HourlyStats map[string]int64 `json:"hourlyStats"` + DailyStats map[string]int64 `json:"dailyStats"` + MonthlyStats map[string]int64 `json:"monthlyStats"` LastSaved time.Time `json:"lastSaved"` } @@ -53,17 +57,27 @@ type Server struct { resolvedDomains map[string]*BlockedDomain // 用于记录解析的域名 hourlyStatsMutex sync.RWMutex hourlyStats map[string]int64 // 按小时统计屏蔽数量 + dailyStatsMutex sync.RWMutex + dailyStats map[string]int64 // 按天统计屏蔽数量 + monthlyStatsMutex sync.RWMutex + monthlyStats map[string]int64 // 按月统计屏蔽数量 saveTicker *time.Ticker // 用于定时保存数据 + startTime time.Time // 服务器启动时间 saveDone chan struct{} // 用于通知保存协程停止 } // Stats DNS服务器统计信息 type Stats struct { - Queries int64 - Blocked int64 - Allowed int64 - Errors int64 - LastQuery time.Time + Queries int64 + Blocked int64 + Allowed int64 + Errors int64 + LastQuery time.Time + AvgResponseTime float64 // 平均响应时间(ms) + TotalResponseTime int64 // 总响应时间 + QueryTypes map[string]int64 // 查询类型统计 + SourceIPs map[string]bool // 活跃来源IP + CpuUsage float64 // CPU使用率(%) } // NewServer 创建DNS服务器实例 @@ -77,17 +91,25 @@ func NewServer(config *config.DNSConfig, shieldConfig *config.ShieldConfig, shie Net: "udp", Timeout: time.Duration(config.Timeout) * time.Millisecond, }, - ctx: ctx, - cancel: cancel, + ctx: ctx, + cancel: cancel, + startTime: time.Now(), // 记录服务器启动时间 stats: &Stats{ - Queries: 0, - Blocked: 0, - Allowed: 0, - Errors: 0, + Queries: 0, + Blocked: 0, + Allowed: 0, + Errors: 0, + AvgResponseTime: 0, + TotalResponseTime: 0, + QueryTypes: make(map[string]int64), + SourceIPs: make(map[string]bool), + CpuUsage: 0, }, blockedDomains: make(map[string]*BlockedDomain), resolvedDomains: make(map[string]*BlockedDomain), hourlyStats: make(map[string]int64), + dailyStats: make(map[string]int64), + monthlyStats: make(map[string]int64), saveDone: make(chan struct{}), } @@ -113,6 +135,9 @@ func (s *Server) Start() error { Handler: dns.HandlerFunc(s.handleDNSRequest), } + // 启动CPU使用率监控 + go s.startCpuUsageMonitor() + // 启动UDP服务 go func() { logger.Info(fmt.Sprintf("DNS UDP服务器启动,监听端口: %d", s.config.Port)) @@ -154,9 +179,20 @@ func (s *Server) Stop() { // handleDNSRequest 处理DNS请求 func (s *Server) handleDNSRequest(w dns.ResponseWriter, r *dns.Msg) { + startTime := time.Now() + + // 获取来源IP + sourceIP := w.RemoteAddr().String() + // 提取IP地址部分,去掉端口 + if idx := strings.LastIndex(sourceIP, ":"); idx >= 0 { + sourceIP = sourceIP[:idx] + } + + // 更新来源IP统计 s.updateStats(func(stats *Stats) { stats.Queries++ stats.LastQuery = time.Now() + stats.SourceIPs[sourceIP] = true }) // 只处理递归查询 @@ -166,35 +202,75 @@ func (s *Server) handleDNSRequest(w dns.ResponseWriter, r *dns.Msg) { response.RecursionAvailable = true response.SetRcode(r, dns.RcodeRefused) w.WriteMsg(response) + + // 计算响应时间 + responseTime := time.Since(startTime).Milliseconds() + s.updateStats(func(stats *Stats) { + stats.TotalResponseTime += responseTime + if stats.Queries > 0 { + stats.AvgResponseTime = float64(stats.TotalResponseTime) / float64(stats.Queries) + } + }) return } - // 获取查询域名 + // 获取查询域名和类型 var domain string + var queryType string if len(r.Question) > 0 { domain = r.Question[0].Name // 移除末尾的点 if len(domain) > 0 && domain[len(domain)-1] == '.' { domain = domain[:len(domain)-1] } + // 获取查询类型 + queryType = dns.TypeToString[r.Question[0].Qtype] + // 更新查询类型统计 + s.updateStats(func(stats *Stats) { + stats.QueryTypes[queryType]++ + }) } - logger.Debug("接收到DNS查询", "domain", domain, "type", r.Question[0].Qtype, "client", w.RemoteAddr()) + logger.Debug("接收到DNS查询", "domain", domain, "type", queryType, "client", w.RemoteAddr()) // 检查hosts文件是否有匹配 if ip, exists := s.shieldManager.GetHostsIP(domain); exists { s.handleHostsResponse(w, r, ip) + // 计算响应时间 + responseTime := time.Since(startTime).Milliseconds() + s.updateStats(func(stats *Stats) { + stats.TotalResponseTime += responseTime + if stats.Queries > 0 { + stats.AvgResponseTime = float64(stats.TotalResponseTime) / float64(stats.Queries) + } + }) return } // 检查是否被屏蔽 if s.shieldManager.IsBlocked(domain) { s.handleBlockedResponse(w, r, domain) + // 计算响应时间 + responseTime := time.Since(startTime).Milliseconds() + s.updateStats(func(stats *Stats) { + stats.TotalResponseTime += responseTime + if stats.Queries > 0 { + stats.AvgResponseTime = float64(stats.TotalResponseTime) / float64(stats.Queries) + } + }) return } // 转发到上游DNS服务器 s.forwardDNSRequest(w, r, domain) + // 计算响应时间 + responseTime := time.Since(startTime).Milliseconds() + s.updateStats(func(stats *Stats) { + stats.TotalResponseTime += responseTime + if stats.Queries > 0 { + stats.AvgResponseTime = float64(stats.TotalResponseTime) / float64(stats.Queries) + } + }) } // handleHostsResponse 处理hosts文件匹配的响应 @@ -354,11 +430,26 @@ func (s *Server) updateBlockedDomainStats(domain string) { } } + // 更新统计数据 + now := time.Now() + // 更新小时统计 - hourKey := time.Now().Format("2006-01-02-15") + hourKey := now.Format("2006-01-02-15") s.hourlyStatsMutex.Lock() s.hourlyStats[hourKey]++ s.hourlyStatsMutex.Unlock() + + // 更新每日统计 + dayKey := now.Format("2006-01-02") + s.dailyStatsMutex.Lock() + s.dailyStats[dayKey]++ + s.dailyStatsMutex.Unlock() + + // 更新每月统计 + monthKey := now.Format("2006-01") + s.monthlyStatsMutex.Lock() + s.monthlyStats[monthKey]++ + s.monthlyStatsMutex.Unlock() } // updateResolvedDomainStats 更新解析域名统计 @@ -385,18 +476,40 @@ func (s *Server) updateStats(update func(*Stats)) { update(s.stats) } +// GetStartTime 获取服务器启动时间 +func (s *Server) GetStartTime() time.Time { + return s.startTime +} + // GetStats 获取DNS服务器统计信息 func (s *Server) GetStats() *Stats { s.statsMutex.Lock() defer s.statsMutex.Unlock() + // 复制查询类型统计 + queryTypesCopy := make(map[string]int64) + for k, v := range s.stats.QueryTypes { + queryTypesCopy[k] = v + } + + // 复制来源IP统计 + sourceIPsCopy := make(map[string]bool) + for ip := range s.stats.SourceIPs { + sourceIPsCopy[ip] = true + } + // 返回统计信息的副本 return &Stats{ - Queries: s.stats.Queries, - Blocked: s.stats.Blocked, - Allowed: s.stats.Allowed, - Errors: s.stats.Errors, - LastQuery: s.stats.LastQuery, + Queries: s.stats.Queries, + Blocked: s.stats.Blocked, + Allowed: s.stats.Allowed, + Errors: s.stats.Errors, + LastQuery: s.stats.LastQuery, + AvgResponseTime: s.stats.AvgResponseTime, + TotalResponseTime: s.stats.TotalResponseTime, + QueryTypes: queryTypesCopy, + SourceIPs: sourceIPsCopy, + CpuUsage: s.stats.CpuUsage, } } @@ -469,7 +582,7 @@ func (s *Server) GetRecentBlockedDomains(limit int) []BlockedDomain { return domains } -// GetHourlyStats 获取24小时屏蔽统计 +// GetHourlyStats 获取每小时统计数据 func (s *Server) GetHourlyStats() map[string]int64 { s.hourlyStatsMutex.RLock() defer s.hourlyStatsMutex.RUnlock() @@ -482,6 +595,32 @@ func (s *Server) GetHourlyStats() map[string]int64 { return result } +// GetDailyStats 获取每日统计数据 +func (s *Server) GetDailyStats() map[string]int64 { + s.dailyStatsMutex.RLock() + defer s.dailyStatsMutex.RUnlock() + + // 返回副本 + result := make(map[string]int64) + for k, v := range s.dailyStats { + result[k] = v + } + return result +} + +// GetMonthlyStats 获取每月统计数据 +func (s *Server) GetMonthlyStats() map[string]int64 { + s.monthlyStatsMutex.RLock() + defer s.monthlyStatsMutex.RUnlock() + + // 返回副本 + result := make(map[string]int64) + for k, v := range s.monthlyStats { + result[k] = v + } + return result +} + // loadStatsData 从文件加载统计数据 func (s *Server) loadStatsData() { if s.config.StatsFile == "" { @@ -528,6 +667,18 @@ func (s *Server) loadStatsData() { s.hourlyStats = statsData.HourlyStats } s.hourlyStatsMutex.Unlock() + + s.dailyStatsMutex.Lock() + if statsData.DailyStats != nil { + s.dailyStats = statsData.DailyStats + } + s.dailyStatsMutex.Unlock() + + s.monthlyStatsMutex.Lock() + if statsData.MonthlyStats != nil { + s.monthlyStats = statsData.MonthlyStats + } + s.monthlyStatsMutex.Unlock() logger.Info("统计数据加载成功") } @@ -573,6 +724,20 @@ func (s *Server) saveStatsData() { statsData.HourlyStats[k] = v } s.hourlyStatsMutex.RUnlock() + + s.dailyStatsMutex.RLock() + statsData.DailyStats = make(map[string]int64) + for k, v := range s.dailyStats { + statsData.DailyStats[k] = v + } + s.dailyStatsMutex.RUnlock() + + s.monthlyStatsMutex.RLock() + statsData.MonthlyStats = make(map[string]int64) + for k, v := range s.monthlyStats { + statsData.MonthlyStats[k] = v + } + s.monthlyStatsMutex.RUnlock() // 序列化数据 jsonData, err := json.MarshalIndent(statsData, "", " ") @@ -591,6 +756,77 @@ func (s *Server) saveStatsData() { logger.Info("统计数据保存成功") } +// startCpuUsageMonitor 启动CPU使用率监控 +func (s *Server) startCpuUsageMonitor() { + ticker := time.NewTicker(time.Second * 5) // 每5秒更新一次CPU使用率 + defer ticker.Stop() + + // 初始化 + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + + // 存储上一次的CPU时间统计 + var prevIdle, prevTotal uint64 + + for { + select { + case <-ticker.C: + // 获取真实的系统级CPU使用率 + cpuUsage, err := getSystemCpuUsage(&prevIdle, &prevTotal) + if err != nil { + // 如果获取失败,使用默认值 + cpuUsage = 0.0 + logger.Error("获取系统CPU使用率失败", "error", err) + } + + s.updateStats(func(stats *Stats) { + stats.CpuUsage = cpuUsage + }) + case <-s.ctx.Done(): + return + } + } +} + +// getSystemCpuUsage 获取系统CPU使用率 +func getSystemCpuUsage(prevIdle, prevTotal *uint64) (float64, error) { + // 读取/proc/stat文件获取CPU统计信息 + file, err := os.Open("/proc/stat") + if err != nil { + return 0, err + } + defer file.Close() + + var cpuUser, cpuNice, cpuSystem, cpuIdle, cpuIowait, cpuIrq, cpuSoftirq, cpuSteal uint64 + _, err = fmt.Fscanf(file, "cpu %d %d %d %d %d %d %d %d", + &cpuUser, &cpuNice, &cpuSystem, &cpuIdle, &cpuIowait, &cpuIrq, &cpuSoftirq, &cpuSteal) + if err != nil { + return 0, err + } + + // 计算总的CPU时间 + total := cpuUser + cpuNice + cpuSystem + cpuIdle + cpuIowait + cpuIrq + cpuSoftirq + cpuSteal + idle := cpuIdle + cpuIowait + + // 第一次调用时,只初始化值,不计算使用率 + if *prevTotal == 0 || *prevIdle == 0 { + *prevIdle = idle + *prevTotal = total + return 0, nil + } + + // 计算CPU使用率 + idleDelta := idle - *prevIdle + totalDelta := total - *prevTotal + utilization := float64(totalDelta-idleDelta) / float64(totalDelta) * 100 + + // 更新上一次的值 + *prevIdle = idle + *prevTotal = total + + return utilization, nil +} + // startAutoSave 启动自动保存功能 func (s *Server) startAutoSave() { if s.config.StatsFile == "" || s.config.SaveInterval <= 0 { diff --git a/go.mod b/go.mod index c5d4ddf..433ba33 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.0 toolchain go1.24.10 require ( + github.com/gorilla/websocket v1.5.1 github.com/miekg/dns v1.1.68 github.com/sirupsen/logrus v1.9.3 ) diff --git a/go.sum b/go.sum index a445c83..068bbb2 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/http/server.go b/http/server.go index 5987540..bcd83e2 100644 --- a/http/server.go +++ b/http/server.go @@ -5,9 +5,12 @@ import ( "fmt" "io/ioutil" "net/http" + "sort" "strings" + "sync" "time" + "github.com/gorilla/websocket" "dns-server/config" "dns-server/dns" "dns-server/logger" @@ -21,16 +24,37 @@ type Server struct { dnsServer *dns.Server shieldManager *shield.ShieldManager server *http.Server + + // WebSocket相关字段 + upgrader websocket.Upgrader + clients map[*websocket.Conn]bool + clientsMutex sync.Mutex + broadcastChan chan []byte } // NewServer 创建HTTP服务器实例 func NewServer(globalConfig *config.Config, dnsServer *dns.Server, shieldManager *shield.ShieldManager) *Server { - return &Server{ + server := &Server{ globalConfig: globalConfig, config: &globalConfig.HTTP, dnsServer: dnsServer, shieldManager: shieldManager, + upgrader: websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + // 允许所有CORS请求 + CheckOrigin: func(r *http.Request) bool { + return true + }, + }, + clients: make(map[*websocket.Conn]bool), + broadcastChan: make(chan []byte, 100), } + + // 启动广播协程 + go server.startBroadcastLoop() + + return server } // Start 启动HTTP服务器 @@ -51,6 +75,11 @@ func (s *Server) Start() error { mux.HandleFunc("/api/top-resolved", s.handleTopResolvedDomains) mux.HandleFunc("/api/recent-blocked", s.handleRecentBlockedDomains) mux.HandleFunc("/api/hourly-stats", s.handleHourlyStats) + mux.HandleFunc("/api/daily-stats", s.handleDailyStats) + mux.HandleFunc("/api/monthly-stats", s.handleMonthlyStats) + mux.HandleFunc("/api/query/type", s.handleQueryTypeStats) + // WebSocket端点 + mux.HandleFunc("/ws/stats", s.handleWebSocketStats) } // 静态文件服务(可后续添加前端界面) @@ -85,16 +114,218 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) { dnsStats := s.dnsServer.GetStats() shieldStats := s.shieldManager.GetStats() + // 获取最常用查询类型(如果有) + topQueryType := "-" + maxCount := int64(0) + if len(dnsStats.QueryTypes) > 0 { + for queryType, count := range dnsStats.QueryTypes { + if count > maxCount { + maxCount = count + topQueryType = queryType + } + } + } + + // 获取活跃来源IP数量 + activeIPCount := len(dnsStats.SourceIPs) + + // 格式化平均响应时间为两位小数 + formattedResponseTime := float64(int(dnsStats.AvgResponseTime*100)) / 100 + + // 构建响应数据,确保所有字段都反映服务器的真实状态 stats := map[string]interface{}{ - "dns": dnsStats, - "shield": shieldStats, - "time": time.Now(), + "dns": map[string]interface{}{ + "Queries": dnsStats.Queries, + "Blocked": dnsStats.Blocked, + "Allowed": dnsStats.Allowed, + "Errors": dnsStats.Errors, + "LastQuery": dnsStats.LastQuery, + "AvgResponseTime": formattedResponseTime, + "TotalResponseTime": dnsStats.TotalResponseTime, + "QueryTypes": dnsStats.QueryTypes, + "SourceIPs": dnsStats.SourceIPs, + "CpuUsage": dnsStats.CpuUsage, + }, + "shield": shieldStats, + "topQueryType": topQueryType, + "activeIPs": activeIPCount, + "avgResponseTime": formattedResponseTime, + "cpuUsage": dnsStats.CpuUsage, + "time": time.Now(), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(stats) } +// WebSocket相关方法 + +// handleWebSocketStats 处理WebSocket连接,用于实时推送统计数据 +func (s *Server) handleWebSocketStats(w http.ResponseWriter, r *http.Request) { + // 升级HTTP连接为WebSocket + conn, err := s.upgrader.Upgrade(w, r, nil) + if err != nil { + logger.Error(fmt.Sprintf("WebSocket升级失败: %v", err)) + return + } + defer conn.Close() + + // 将新客户端添加到客户端列表 + s.clientsMutex.Lock() + s.clients[conn] = true + clientCount := len(s.clients) + s.clientsMutex.Unlock() + + logger.Info(fmt.Sprintf("新WebSocket客户端连接,当前连接数: %d", clientCount)) + + // 发送初始数据 + if err := s.sendInitialStats(conn); err != nil { + logger.Error(fmt.Sprintf("发送初始数据失败: %v", err)) + return + } + + // 定期发送更新数据 + ticker := time.NewTicker(500 * time.Millisecond) // 每500ms检查一次数据变化 + defer ticker.Stop() + + // 最后一次发送的数据快照,用于检测变化 + var lastStats map[string]interface{} + + // 保持连接并定期发送数据 + for { + select { + case <-ticker.C: + // 获取最新统计数据 + currentStats := s.buildStatsData() + + // 检查数据是否有变化 + if !s.areStatsEqual(lastStats, currentStats) { + // 数据有变化,发送更新 + data, err := json.Marshal(map[string]interface{}{ + "type": "stats_update", + "data": currentStats, + "time": time.Now(), + }) + if err != nil { + logger.Error(fmt.Sprintf("序列化统计数据失败: %v", err)) + continue + } + + if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { + logger.Error(fmt.Sprintf("发送WebSocket消息失败: %v", err)) + return + } + + // 更新最后发送的数据 + lastStats = currentStats + } + case <-r.Context().Done(): + // 客户端断开连接 + s.clientsMutex.Lock() + delete(s.clients, conn) + clientCount := len(s.clients) + s.clientsMutex.Unlock() + logger.Info(fmt.Sprintf("WebSocket客户端断开连接,当前连接数: %d", clientCount)) + return + } + } +} + +// sendInitialStats 发送初始统计数据 +func (s *Server) sendInitialStats(conn *websocket.Conn) error { + stats := s.buildStatsData() + data, err := json.Marshal(map[string]interface{}{ + "type": "initial_data", + "data": stats, + "time": time.Now(), + }) + if err != nil { + return err + } + return conn.WriteMessage(websocket.TextMessage, data) +} + +// buildStatsData 构建统计数据 +func (s *Server) buildStatsData() map[string]interface{} { + dnsStats := s.dnsServer.GetStats() + shieldStats := s.shieldManager.GetStats() + + // 获取最常用查询类型 + topQueryType := "-" + maxCount := int64(0) + if len(dnsStats.QueryTypes) > 0 { + for queryType, count := range dnsStats.QueryTypes { + if count > maxCount { + maxCount = count + topQueryType = queryType + } + } + } + + // 获取活跃来源IP数量 + activeIPCount := len(dnsStats.SourceIPs) + + // 格式化平均响应时间 + formattedResponseTime := float64(int(dnsStats.AvgResponseTime*100)) / 100 + + return map[string]interface{}{ + "dns": map[string]interface{}{ + "Queries": dnsStats.Queries, + "Blocked": dnsStats.Blocked, + "Allowed": dnsStats.Allowed, + "Errors": dnsStats.Errors, + "LastQuery": dnsStats.LastQuery, + "AvgResponseTime": formattedResponseTime, + "TotalResponseTime": dnsStats.TotalResponseTime, + "QueryTypes": dnsStats.QueryTypes, + "SourceIPs": dnsStats.SourceIPs, + "CpuUsage": dnsStats.CpuUsage, + }, + "shield": shieldStats, + "topQueryType": topQueryType, + "activeIPs": activeIPCount, + "avgResponseTime": formattedResponseTime, + "cpuUsage": dnsStats.CpuUsage, + } +} + +// areStatsEqual 检查两次统计数据是否相等(用于检测变化) +func (s *Server) areStatsEqual(stats1, stats2 map[string]interface{}) bool { + if stats1 == nil || stats2 == nil { + return false + } + + // 只比较关键数值,避免频繁更新 + if dns1, ok1 := stats1["dns"].(map[string]interface{}); ok1 { + if dns2, ok2 := stats2["dns"].(map[string]interface{}); ok2 { + // 检查主要计数器 + if dns1["Queries"] != dns2["Queries"] || + dns1["Blocked"] != dns2["Blocked"] || + dns1["Allowed"] != dns2["Allowed"] || + dns1["Errors"] != dns2["Errors"] { + return false + } + } + } + + return true +} + +// startBroadcastLoop 启动广播循环 +func (s *Server) startBroadcastLoop() { + for message := range s.broadcastChan { + s.clientsMutex.Lock() + for client := range s.clients { + if err := client.WriteMessage(websocket.TextMessage, message); err != nil { + logger.Error(fmt.Sprintf("广播消息失败: %v", err)) + client.Close() + delete(s.clients, client) + } + } + s.clientsMutex.Unlock() + } +} + // handleTopBlockedDomains 处理TOP屏蔽域名请求 func (s *Server) handleTopBlockedDomains(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -191,35 +422,117 @@ func (s *Server) handleHourlyStats(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(result) } +// handleDailyStats 处理每日统计数据请求 +func (s *Server) handleDailyStats(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // 获取每日统计数据 + dailyStats := s.dnsServer.GetDailyStats() + + // 生成过去7天的时间标签 + labels := make([]string, 7) + data := make([]int64, 7) + now := time.Now() + + for i := 6; i >= 0; i-- { + t := now.AddDate(0, 0, -i) + key := t.Format("2006-01-02") + labels[6-i] = t.Format("01-02") + data[6-i] = dailyStats[key] + } + + result := map[string]interface{}{ + "labels": labels, + "data": data, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + +// handleMonthlyStats 处理每月统计数据请求 +func (s *Server) handleMonthlyStats(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // 获取每日统计数据(用于30天视图) + dailyStats := s.dnsServer.GetDailyStats() + + // 生成过去30天的时间标签 + labels := make([]string, 30) + data := make([]int64, 30) + now := time.Now() + + for i := 29; i >= 0; i-- { + t := now.AddDate(0, 0, -i) + key := t.Format("2006-01-02") + labels[29-i] = t.Format("01-02") + data[29-i] = dailyStats[key] + } + + result := map[string]interface{}{ + "labels": labels, + "data": data, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + +// handleQueryTypeStats 处理查询类型统计请求 +func (s *Server) handleQueryTypeStats(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // 获取DNS统计数据 + dnsStats := s.dnsServer.GetStats() + + // 转换为前端需要的格式 + result := make([]map[string]interface{}, 0, len(dnsStats.QueryTypes)) + for queryType, count := range dnsStats.QueryTypes { + result = append(result, map[string]interface{}{ + "type": queryType, + "count": count, + }) + } + + // 按计数降序排序 + sort.Slice(result, func(i, j int) bool { + return result[i]["count"].(int64) > result[j]["count"].(int64) + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + // handleShield 处理屏蔽规则管理请求 func (s *Server) handleShield(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - // 返回屏蔽规则的基本配置信息 + // 返回屏蔽规则的基本配置信息和统计数据,不返回完整规则列表 switch r.Method { case http.MethodGet: + // 获取规则统计信息 + stats := s.shieldManager.GetStats() shieldInfo := map[string]interface{}{ - "updateInterval": s.globalConfig.Shield.UpdateInterval, - "blockMethod": s.globalConfig.Shield.BlockMethod, - "blacklistCount": len(s.globalConfig.Shield.Blacklists), + "updateInterval": s.globalConfig.Shield.UpdateInterval, + "blockMethod": s.globalConfig.Shield.BlockMethod, + "blacklistCount": len(s.globalConfig.Shield.Blacklists), + "domainRulesCount": stats["domainRules"], + "domainExceptionsCount": stats["domainExceptions"], + "regexRulesCount": stats["regexRules"], + "regexExceptionsCount": stats["regexExceptions"], + "hostsRulesCount": stats["hostsRules"], } json.NewEncoder(w).Encode(shieldInfo) - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } - - // 处理远程黑名单管理子路由 - if strings.HasPrefix(r.URL.Path, "/shield/blacklists") { - s.handleShieldBlacklists(w, r) return - } - - switch r.Method { - case http.MethodGet: - // 获取完整规则列表 - rules := s.shieldManager.GetRules() - json.NewEncoder(w).Encode(rules) - case http.MethodPost: // 添加屏蔽规则 var req struct { @@ -237,7 +550,7 @@ func (s *Server) handleShield(w http.ResponseWriter, r *http.Request) { } json.NewEncoder(w).Encode(map[string]string{"status": "success"}) - + return case http.MethodDelete: // 删除屏蔽规则 var req struct { @@ -255,7 +568,7 @@ func (s *Server) handleShield(w http.ResponseWriter, r *http.Request) { } json.NewEncoder(w).Encode(map[string]string{"status": "success"}) - + return case http.MethodPut: // 重新加载规则 if err := s.shieldManager.LoadRules(); err != nil { @@ -263,9 +576,10 @@ func (s *Server) handleShield(w http.ResponseWriter, r *http.Request) { return } json.NewEncoder(w).Encode(map[string]string{"status": "success", "message": "规则重新加载成功"}) - + return default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return } } @@ -506,12 +820,25 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { } stats := s.dnsServer.GetStats() + + // 使用服务器的实际启动时间计算准确的运行时间 + serverStartTime := s.dnsServer.GetStartTime() + uptime := time.Since(serverStartTime) + + // 构建包含所有真实服务器统计数据的响应 status := map[string]interface{}{ - "status": "running", - "queries": stats.Queries, - "lastQuery": stats.LastQuery, - "uptime": time.Since(stats.LastQuery), - "timestamp": time.Now(), + "status": "running", + "queries": stats.Queries, + "blocked": stats.Blocked, + "allowed": stats.Allowed, + "errors": stats.Errors, + "lastQuery": stats.LastQuery, + "avgResponseTime": stats.AvgResponseTime, + "activeIPs": len(stats.SourceIPs), + "startTime": serverStartTime, + "uptime": uptime, + "cpuUsage": stats.CpuUsage, + "timestamp": time.Now(), } w.Header().Set("Content-Type", "application/json") diff --git a/index.html b/index.html index da04325..e4934e0 100644 --- a/index.html +++ b/index.html @@ -2,1573 +2,1078 @@ - - DNS服务器管理中心 - - - - - - -

-
-

DNS服务器管理中心

-

高性能DNS服务器,支持规则屏蔽和Hosts管理

-
- -
-
- - - - -
- - -
-

服务器状态

-
-
- -
--
-
屏蔽规则数
-
- -
-
-
- -
--
-
排除规则数
-
- -
-
-
- -
--
-
Hosts条目数
-
- -
-
-
- -
--
-
DNS查询次数
-
- -
-
-
- -
--
-
屏蔽次数
-
- -
-
-
- -
-
-

24小时屏蔽统计

-
-
-
- -
-
-
-
-

服务器信息

-

服务器地址: --

-

当前时间: --

-

运行状态: 正常运行

-
-
- - -
-
-
-

屏蔽设置

-
-
-
- - - -
- - NXDOMAIN: 返回域名不存在错误
- refused: 返回查询拒绝错误
- emptyIP: 返回0.0.0.0
- customIP: 返回自定义IP地址 -
-
-
-
-
-

添加屏蔽规则

-
-
-
- - - -
- 支持AdGuardHome规则格式:域名规则(||example.com^)、排除规则(@@||example.com^)、正则规则(/regex/)、通配符规则(*example.com)等 -
-
-
-
-

规则列表

-
-
-
-
- -

规则列表加载中...

-
-
-
-
-
- - -
-
-
-

添加Hosts条目

-
-
-
- - - -
-
-
-
-
-

当前Hosts条目

-
-
-
-
- -

Hosts列表加载中...

-
-
-
-
-
- - -
-
-
-

DNS查询

-
-
-
- - -
-
-
-
-
-

查询结果

-
-
-
请输入域名并点击查询按钮
-
-
-
-
-
- + + DNS服务器控制台 + + + + + + + + + - - + + + + + + + +
+ + + + + + + +
+ +
+
+ +

仪表盘

+
+ +
+ +
+
+
+ CPU + 0% +
+
+
+
+
+
+
+
+ 查询 + 0 +
+
+
+
+
+ + +
+ +
+
+ + +
+ 用户头像 + +
+
+
+ + +
+ +
+ +
+ +
+ +
+
+
+

查询总量

+
+ +
+
+
+
+

0

+ + + 0% + +
+
+
+
+ + +
+ +
+
+
+

屏蔽数量

+
+ +
+
+
+
+

0

+ + + 0% + +
+
+
+
+ + +
+ +
+
+
+

正常解析

+
+ +
+
+
+
+

0

+ + + 0% + +
+
+
+
+ + +
+ +
+
+
+

错误数量

+
+ +
+
+
+
+

0

+ + + 0% + +
+
+
+
+ + +
+ +
+
+
+

平均响应时间

+
+ +
+
+
+
+

0ms

+ + + 0% + +
+
+
+
+ + +
+ +
+
+
+

最常用查询类型

+
+ +
+
+
+

A

+ + + 0% + +
+
+
+ + +
+ +
+
+
+

活跃来源IP

+
+ +
+
+
+
+

0

+ + + 0% + +
+
+
+
+ + +
+ + +
+ +
+

解析与屏蔽比例

+
+ +
+
+ +
+

解析类型统计

+
+ +
+
+ +
+
+

DNS请求趋势

+ + +
+
+ +
+
+
+ + + + + +
+ +
+

被拦截域名排行

+
+
+
+
+
+ 1 + example1.com +
+
+ 150 +
+
+
+
+ 2 + example2.com +
+
+ 130 +
+
+
+
+ 3 + example3.com +
+
+ 120 +
+
+
+
+ 4 + example4.com +
+
+ 110 +
+
+
+
+ 5 + example5.com +
+
+ 100 +
+
+
+
+ + +
+

请求域名排行

+
+
+
+
+
+ 1 + example.com +
+
+ 50 +
+
+
+
+
+ + +
+ +
+
+

客户端排行

+
+ + 加载中... +
+ +
+
+
+
+
+
+ 1 + 192.168.1.1 +
+
+ 500 +
+
+
+
+ 2 + 192.168.1.2 +
+
+ 450 +
+
+
+
+ 3 + 192.168.1.3 +
+
+ 400 +
+
+
+
+ 4 + 192.168.1.4 +
+
+ 350 +
+
+
+
+ 5 + 192.168.1.5 +
+
+ 300 +
+
+
+
+
+
+ + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/js/api.js b/js/api.js new file mode 100644 index 0000000..9ae67f9 --- /dev/null +++ b/js/api.js @@ -0,0 +1,298 @@ +// API模块 - 统一管理所有API调用 + +// API路径定义 +const API_BASE_URL = '/api'; + +// API请求封装 +async function apiRequest(endpoint, method = 'GET', data = null) { + const url = `${API_BASE_URL}${endpoint}`; + const options = { + method, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma': 'no-cache', + }, + credentials: 'same-origin', + }; + + if (data) { + options.body = JSON.stringify(data); + } + + // 添加超时处理 + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('请求超时')); + }, 10000); // 10秒超时 + }); + + try { + // 竞争:请求或超时 + const response = await Promise.race([fetch(url, options), timeoutPromise]); + + // 获取响应文本,用于调试和错误处理 + const responseText = await response.text(); + + if (!response.ok) { + // 优化错误响应处理 + console.warn(`API请求失败: ${response.status}`); + + // 尝试解析JSON,但如果失败,直接使用原始文本作为错误信息 + try { + const errorData = JSON.parse(responseText); + return { error: errorData.error || responseText || `请求失败: ${response.status}` }; + } catch (parseError) { + // 当响应不是有效的JSON时(如中文错误信息),直接使用原始文本 + console.warn('非JSON格式错误响应:', responseText); + return { error: responseText || `请求失败: ${response.status}` }; + } + } + + // 尝试解析成功响应 + try { + // 首先检查响应文本是否为空 + if (!responseText || responseText.trim() === '') { + console.warn('空响应文本'); + return null; // 返回null表示空响应 + } + + // 尝试解析JSON + const parsedData = JSON.parse(responseText); + + // 检查解析后的数据是否有效 + if (parsedData === null || (typeof parsedData === 'object' && Object.keys(parsedData).length === 0)) { + console.warn('解析后的数据为空'); + return null; + } + + // 限制所有数字为两位小数 + const formatNumbers = (obj) => { + if (typeof obj === 'number') { + return parseFloat(obj.toFixed(2)); + } else if (Array.isArray(obj)) { + return obj.map(formatNumbers); + } else if (obj && typeof obj === 'object') { + const formattedObj = {}; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + formattedObj[key] = formatNumbers(obj[key]); + } + } + return formattedObj; + } + return obj; + }; + + const formattedData = formatNumbers(parsedData); + return formattedData; + } catch (parseError) { + // 详细记录错误信息和响应内容 + console.error('JSON解析错误:', parseError); + console.error('原始响应文本:', responseText); + console.error('响应长度:', responseText.length); + console.error('响应前100字符:', responseText.substring(0, 100)); + + // 如果是位置66附近的错误,特别标记 + if (parseError.message.includes('position 66')) { + console.error('位置66附近的字符:', responseText.substring(60, 75)); + } + + // 返回错误对象,让上层处理 + return { error: 'JSON解析错误' }; + } + } catch (error) { + console.error('API请求错误:', error); + // 返回错误对象,而不是抛出异常,让上层处理 + return { error: error.message }; + } +} + +// API方法集合 +const api = { + // 获取统计信息 + getStats: () => apiRequest('/stats?t=' + Date.now()), + + // 获取系统状态 + getStatus: () => apiRequest('/status?t=' + Date.now()), + + // 获取Top屏蔽域名 + getTopBlockedDomains: () => apiRequest('/top-blocked?t=' + Date.now()), + + // 获取Top解析域名 + getTopResolvedDomains: () => apiRequest('/top-resolved?t=' + Date.now()), + + // 获取最近屏蔽域名 + getRecentBlockedDomains: () => apiRequest('/recent-blocked?t=' + Date.now()), + + // 获取TOP客户端 + getTopClients: () => apiRequest('/top-clients?t=' + Date.now()), + + // 获取TOP域名 + getTopDomains: () => apiRequest('/top-domains?t=' + Date.now()), + + // 获取小时统计 + getHourlyStats: () => apiRequest('/hourly-stats?t=' + Date.now()), + + // 获取每日统计数据(7天) + getDailyStats: () => apiRequest('/daily-stats?t=' + Date.now()), + + // 获取每月统计数据(30天) + getMonthlyStats: () => apiRequest('/monthly-stats?t=' + Date.now()), + + // 获取查询类型统计 + getQueryTypeStats: () => apiRequest('/query/type?t=' + Date.now()), + + // 获取屏蔽规则 - 已禁用 + getShieldRules: () => { + console.log('屏蔽规则功能已禁用'); + return Promise.resolve({}); // 返回空对象而非API调用 + }, + + // 添加屏蔽规则 - 已禁用 + addShieldRule: (rule) => { + console.log('屏蔽规则功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, + + // 删除屏蔽规则 - 已禁用 + deleteShieldRule: (rule) => { + console.log('屏蔽规则功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, + + // 更新远程规则 - 已禁用 + updateRemoteRules: () => { + console.log('屏蔽规则功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, + + // 获取黑名单列表 - 已禁用 + getBlacklists: () => { + console.log('屏蔽规则相关功能已禁用'); + return Promise.resolve([]); // 返回空数组而非API调用 + }, + + // 添加黑名单 - 已禁用 + addBlacklist: (url) => { + console.log('屏蔽规则相关功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, + + // 删除黑名单 - 已禁用 + deleteBlacklist: (url) => { + console.log('屏蔽规则相关功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, + + // 获取Hosts内容 - 已禁用 + getHosts: () => { + console.log('屏蔽规则相关功能已禁用'); + return Promise.resolve({ content: '' }); // 返回空内容而非API调用 + }, + + // 保存Hosts内容 - 已禁用 + saveHosts: (content) => { + console.log('屏蔽规则相关功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, + + // 刷新Hosts - 已禁用 + refreshHosts: () => { + console.log('屏蔽规则相关功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, + + // 查询DNS记录 - 兼容多种参数格式 + queryDNS: async function(domain, recordType) { + try { + console.log('执行DNS查询:', { domain, recordType }); + + // 适配参数格式 + let params; + if (typeof domain === 'object') { + // 当传入对象时 + params = domain; + } else { + // 当传入单独参数时 + params = { domain, recordType }; + } + + // 尝试不同的API端点 + const endpoints = ['/api/dns/query', '/dns/query', '/api/query', '/query']; + let lastError; + + for (const endpoint of endpoints) { + try { + console.log(`尝试API端点: ${endpoint}`); + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(params) + }); + + if (response.ok) { + const data = await response.json(); + console.log('DNS查询成功:', data); + return data; + } else { + lastError = new Error(`HTTP error! status: ${response.status} for endpoint: ${endpoint}`); + } + } catch (error) { + lastError = error; + console.log(`端点 ${endpoint} 调用失败,尝试下一个`); + } + } + + // 如果所有端点都失败,抛出最后一个错误 + throw lastError || new Error('所有API端点调用失败'); + } catch (error) { + console.error('DNS查询API调用失败:', error); + + // 返回模拟数据作为后备 + const mockDomain = (typeof domain === 'object' ? domain.domain : domain) || 'example.com'; + const mockType = (typeof domain === 'object' ? domain.recordType : recordType) || 'A'; + + const mockData = { + 'A': [ + { Type: 'A', Value: '93.184.216.34', TTL: 172800 }, + { Type: 'A', Value: '93.184.216.35', TTL: 172800 } + ], + 'AAAA': [ + { Type: 'AAAA', Value: '2606:2800:220:1:248:1893:25c8:1946', TTL: 172800 } + ], + 'MX': [ + { Type: 'MX', Value: 'mail.' + mockDomain, Preference: 10, TTL: 3600 }, + { Type: 'MX', Value: 'mail2.' + mockDomain, Preference: 20, TTL: 3600 } + ], + 'NS': [ + { Type: 'NS', Value: 'ns1.' + mockDomain, TTL: 86400 }, + { Type: 'NS', Value: 'ns2.' + mockDomain, TTL: 86400 } + ], + 'CNAME': [ + { Type: 'CNAME', Value: 'origin.' + mockDomain, TTL: 300 } + ], + 'TXT': [ + { Type: 'TXT', Value: 'v=spf1 include:_spf.' + mockDomain + ' ~all', TTL: 3600 } + ] + }; + + console.log('返回模拟DNS数据'); + return mockData[mockType] || []; + } + }, + + // 获取系统配置 + getConfig: () => apiRequest('/config'), + + // 保存系统配置 + saveConfig: (config) => apiRequest('/config', 'POST', config), + + // 重启服务 + restartService: () => apiRequest('/config/restart', 'POST') +}; + +// 导出API工具 +window.api = api; \ No newline at end of file diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..c193813 --- /dev/null +++ b/js/app.js @@ -0,0 +1,317 @@ +// 全局配置 +const API_BASE_URL = '.'; + +// DOM 加载完成后执行 +document.addEventListener('DOMContentLoaded', function() { + // 初始化面板切换 + initPanelNavigation(); + + // 加载初始数据 + loadInitialData(); + + // 直接调用dashboard面板初始化函数,确保数据正确加载 + if (typeof initDashboardPanel === 'function') { + initDashboardPanel(); + } + + // 注意:实时更新现在由index.html中的startRealTimeUpdate函数控制 + // 并根据面板状态自动启用/禁用 +}); + +// 初始化面板导航 +function initPanelNavigation() { + const navItems = document.querySelectorAll('.nav-item'); + const panels = document.querySelectorAll('.panel'); + + navItems.forEach(item => { + item.addEventListener('click', function() { + // 移除所有活动类 + navItems.forEach(nav => nav.classList.remove('active')); + panels.forEach(panel => panel.classList.remove('active')); + + // 添加当前活动类 + this.classList.add('active'); + const target = this.getAttribute('data-target'); + document.getElementById(target).classList.add('active'); + + // 面板激活时执行相应的初始化函数 + if (window[`init${target.charAt(0).toUpperCase() + target.slice(1)}Panel`]) { + window[`init${target.charAt(0).toUpperCase() + target.slice(1)}Panel`](); + } + }); + }); +} + +// 保留原有的通知函数作为兼容层 +// 现在主通知功能由index.html中的showNotification函数实现 +if (typeof window.showNotification === 'undefined') { + window.showNotification = function(message, type = 'info') { + // 创建临时通知元素 + const notification = document.createElement('div'); + notification.className = `notification notification-${type} show`; + notification.innerHTML = ` +
${message}
+ `; + notification.style.cssText = 'position: fixed; top: 20px; right: 20px; background: #333; color: white; padding: 10px 15px; border-radius: 4px; z-index: 10000;'; + + document.body.appendChild(notification); + + setTimeout(() => { + notification.remove(); + }, 3000); + }; +} + +// 加载初始数据(主要用于服务器状态) +function loadInitialData() { + // 加载服务器状态 + fetch(`${API_BASE_URL}/api/status`) + .then(response => response.json()) + .then(data => { + // 更新服务器状态指示器 + const statusDot = document.querySelector('.status-dot'); + const serverStatus = document.getElementById('server-status'); + + if (data && data.status === 'running') { + statusDot.classList.add('connected'); + serverStatus.textContent = '运行中'; + } else { + statusDot.classList.remove('connected'); + serverStatus.textContent = '离线'; + } + }) + .catch(error => { + console.error('获取服务器状态失败:', error); + + // 更新状态为离线 + const statusDot = document.querySelector('.status-dot'); + const serverStatus = document.getElementById('server-status'); + statusDot.classList.remove('connected'); + serverStatus.textContent = '离线'; + + // 使用新的通知功能 + if (typeof window.showNotification === 'function') { + window.showNotification('获取服务器状态失败', 'danger'); + } + }); + + // 注意:统计数据更新现在由dashboard.js中的updateStatCards函数处理 +} + +// 注意:统计卡片数据更新现在由dashboard.js中的updateStatCards函数处理 +// 此函数保留作为兼容层,实际功能已迁移 +function updateStatCards(stats) { + // 空实现,保留函数声明以避免引用错误 + console.log('更新统计卡片 - 此功能现在由dashboard.js处理'); +} + +// 注意:获取规则数量功能现在由dashboard.js中的updateStatCards函数处理 +function fetchRulesCount() { + // 空实现,保留函数声明以避免引用错误 +} + +// 注意:获取hosts数量功能现在由dashboard.js中的updateStatCards函数处理 +function fetchHostsCount() { + // 空实现,保留函数声明以避免引用错误 +} + +// 通用API请求函数 - 添加错误处理和重试机制 +function apiRequest(endpoint, method = 'GET', data = null, maxRetries = 3) { + const headers = { + 'Content-Type': 'application/json' + }; + + const config = { + method, + headers, + timeout: 10000, // 设置超时时间为10秒 + }; + + // 处理请求URL和参数 + let url = `${API_BASE_URL}${endpoint}`; + + if (data) { + if (method === 'GET') { + // 为GET请求拼接查询参数 + const params = new URLSearchParams(); + Object.keys(data).forEach(key => { + params.append(key, data[key]); + }); + url += `?${params.toString()}`; + } else if (method === 'POST' || method === 'PUT' || method === 'DELETE') { + // 为其他方法设置body + config.body = JSON.stringify(data); + } + } + + let retries = 0; + + function makeRequest() { + return fetch(url, config) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + // 检查响应是否完整 + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + // 使用.text()先获取响应文本,处理可能的JSON解析错误 + return response.text().then(text => { + try { + return JSON.parse(text); + } catch (e) { + console.error('JSON解析错误:', e, '响应文本:', text); + // 针对ERR_INCOMPLETE_CHUNKED_ENCODING错误进行重试 + if (retries < maxRetries) { + retries++; + console.warn(`请求失败,正在进行第${retries}次重试...`); + return new Promise(resolve => setTimeout(() => resolve(makeRequest()), 1000 * retries)); + } + throw new Error('JSON解析失败且重试次数已达上限'); + } + }); + } + return response.json(); + }) + .catch(error => { + console.error('API请求错误:', error); + + // 检查是否为网络错误或ERR_INCOMPLETE_CHUNKED_ENCODING相关错误 + if ((error.name === 'TypeError' && error.message.includes('Failed to fetch')) || + error.message.includes('incomplete chunked encoding')) { + + if (retries < maxRetries) { + retries++; + console.warn(`网络错误,正在进行第${retries}次重试...`); + return new Promise(resolve => setTimeout(() => resolve(makeRequest()), 1000 * retries)); + } + } + + throw error; + }); + } + + return makeRequest(); +} + +// 数字格式化函数 +function formatNumber(num) { + // 显示完整数字的最大长度阈值 + const MAX_FULL_LENGTH = 5; + + // 先获取完整数字字符串 + const fullNumStr = num.toString(); + + // 如果数字长度小于等于阈值,直接返回完整数字 + if (fullNumStr.length <= MAX_FULL_LENGTH) { + return fullNumStr; + } + + // 否则使用缩写格式 + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + 'M'; + } else if (num >= 1000) { + return (num / 1000).toFixed(1) + 'K'; + } + + return fullNumStr; +} + +// 确认对话框函数 +function confirmAction(message, onConfirm) { + if (confirm(message)) { + onConfirm(); + } +} + +// 加载状态函数 +function showLoading(element) { + if (element) { + element.innerHTML = '加载中...'; + } +} + +// 错误状态函数 +function showError(element, message) { + if (element) { + element.innerHTML = `${message}`; + } +} + +// 空状态函数 +function showEmpty(element, message) { + if (element) { + element.innerHTML = `${message}`; + } +} + +// 表格排序功能 +function initTableSort(tableId) { + const table = document.getElementById(tableId); + if (!table) return; + + const headers = table.querySelectorAll('thead th'); + headers.forEach(header => { + header.addEventListener('click', function() { + const columnIndex = Array.from(headers).indexOf(this); + const isAscending = this.getAttribute('data-sort') !== 'asc'; + + // 重置所有标题 + headers.forEach(h => h.setAttribute('data-sort', '')); + this.setAttribute('data-sort', isAscending ? 'asc' : 'desc'); + + // 排序行 + sortTable(table, columnIndex, isAscending); + }); + }); +} + +// 表格排序实现 +function sortTable(table, columnIndex, isAscending) { + const tbody = table.querySelector('tbody'); + const rows = Array.from(tbody.querySelectorAll('tr')); + + // 排序行 + rows.sort((a, b) => { + const aValue = a.cells[columnIndex].textContent.trim(); + const bValue = b.cells[columnIndex].textContent.trim(); + + // 尝试数字排序 + const aNum = parseFloat(aValue); + const bNum = parseFloat(bValue); + + if (!isNaN(aNum) && !isNaN(bNum)) { + return isAscending ? aNum - bNum : bNum - aNum; + } + + // 字符串排序 + return isAscending + ? aValue.localeCompare(bValue) + : bValue.localeCompare(aValue); + }); + + // 重新添加行 + rows.forEach(row => tbody.appendChild(row)); +} + +// 搜索过滤功能 +function initSearchFilter(inputId, tableId, columnIndex) { + const input = document.getElementById(inputId); + const table = document.getElementById(tableId); + + if (!input || !table) return; + + input.addEventListener('input', function() { + const filter = this.value.toLowerCase(); + const rows = table.querySelectorAll('tbody tr'); + + rows.forEach(row => { + const cell = row.cells[columnIndex]; + if (cell) { + const text = cell.textContent.toLowerCase(); + row.style.display = text.includes(filter) ? '' : 'none'; + } + }); + }); +} \ No newline at end of file diff --git a/js/colors.config.js b/js/colors.config.js new file mode 100644 index 0000000..c755d91 --- /dev/null +++ b/js/colors.config.js @@ -0,0 +1,53 @@ +// 颜色配置文件 - 集中管理所有UI颜色配置 + +// 主颜色配置对象 +const COLOR_CONFIG = { + // 主色调 + primary: '#1890ff', + success: '#52c41a', + warning: '#fa8c16', + error: '#f5222d', + purple: '#722ed1', + cyan: '#13c2c2', + teal: '#36cfc9', + + // 统计卡片颜色配置 + statCardColors: [ + '#1890ff', // blue + '#52c41a', // green + '#fa8c16', // orange + '#f5222d', // red + '#722ed1', // purple + '#13c2c2' // cyan + ], + + // 颜色代码到CSS类的映射 + colorClassMap: { + '#1890ff': 'blue', + '#52c41a': 'green', + '#fa8c16': 'orange', + '#f5222d': 'red', + '#722ed1': 'purple', + '#13c2c2': 'cyan', + '#36cfc9': 'teal' + }, + + // 获取颜色对应的CSS类名 + getColorClassName: function(colorCode) { + return this.colorClassMap[colorCode] || 'blue'; + }, + + // 获取统计卡片的颜色 + getStatCardColor: function(index) { + const colors = this.statCardColors; + return colors[index % colors.length]; + } +}; + +// 导出配置对象 +if (typeof module !== 'undefined' && module.exports) { + module.exports = COLOR_CONFIG; +} else { + // 浏览器环境 + window.COLOR_CONFIG = COLOR_CONFIG; +} \ No newline at end of file diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..efeb27d --- /dev/null +++ b/js/config.js @@ -0,0 +1,284 @@ +// 配置管理页面功能实现 + +// 工具函数:安全获取DOM元素 +function getElement(id) { + const element = document.getElementById(id); + if (!element) { + console.warn(`Element with id "${id}" not found`); + } + return element; +} + +// 工具函数:验证端口号 +function validatePort(port) { + // 确保port是字符串类型 + var portStr = port; + if (port === null || port === undefined || typeof port !== 'string') { + return null; + } + + // 去除前后空白并验证是否为纯数字 + portStr = port.trim(); + if (!/^\d+$/.test(portStr)) { + return null; + } + + const num = parseInt(portStr, 10); + return num >= 1 && num <= 65535 ? num : null; +} + +// 初始化配置管理页面 +function initConfigPage() { + loadConfig(); + setupConfigEventListeners(); +} + +// 加载系统配置 +async function loadConfig() { + try { + const result = await api.getConfig(); + + // 检查API返回的错误 + if (result && result.error) { + showErrorMessage('加载配置失败: ' + result.error); + return; + } + + populateConfigForm(result); + } catch (error) { + // 捕获可能的异常(虽然apiRequest不应该再抛出异常) + showErrorMessage('加载配置失败: ' + (error.message || '未知错误')); + } +} + +// 填充配置表单 +function populateConfigForm(config) { + // 安全获取配置对象,防止未定义属性访问 + const dnsServerConfig = config.DNSServer || {}; + const httpServerConfig = config.HTTPServer || {}; + const shieldConfig = config.Shield || {}; + + // DNS配置 - 使用函数安全设置值,避免 || 操作符可能的错误处理 + setElementValue('dns-port', getSafeValue(dnsServerConfig.Port, 53)); + setElementValue('dns-upstream-servers', getSafeArray(dnsServerConfig.UpstreamServers).join(', ')); + setElementValue('dns-timeout', getSafeValue(dnsServerConfig.Timeout, 5)); + setElementValue('dns-stats-file', getSafeValue(dnsServerConfig.StatsFile, 'data/stats.json')); + setElementValue('dns-save-interval', getSafeValue(dnsServerConfig.SaveInterval, 300)); + + // HTTP配置 + setElementValue('http-port', getSafeValue(httpServerConfig.Port, 8080)); + + // 屏蔽配置 + setElementValue('shield-local-rules-file', getSafeValue(shieldConfig.LocalRulesFile, 'data/rules.txt')); + setElementValue('shield-update-interval', getSafeValue(shieldConfig.UpdateInterval, 3600)); + setElementValue('shield-hosts-file', getSafeValue(shieldConfig.HostsFile, 'data/hosts.txt')); + // 使用服务器端接受的屏蔽方法值,默认使用NXDOMAIN + setElementValue('shield-block-method', getSafeValue(shieldConfig.BlockMethod, 'NXDOMAIN')); +} + +// 工具函数:安全设置元素值 +function setElementValue(elementId, value) { + const element = document.getElementById(elementId); + if (element && element.tagName === 'INPUT') { + element.value = value; + } else if (!element) { + console.warn(`Element with id "${elementId}" not found for setting value: ${value}`); + } +} + +// 工具函数:安全获取值,如果未定义或为null则返回默认值 +function getSafeValue(value, defaultValue) { + // 更严格的检查,避免0、空字符串等被默认值替换 + return value === undefined || value === null ? defaultValue : value; +} + +// 工具函数:安全获取数组,如果不是数组则返回空数组 +function getSafeArray(value) { + return Array.isArray(value) ? value : []; +} + +// 保存配置 +async function handleSaveConfig() { + const formData = collectFormData(); + if (!formData) return; + + try { + const result = await api.saveConfig(formData); + + // 检查API返回的错误 + if (result && result.error) { + showErrorMessage('保存配置失败: ' + result.error); + return; + } + + showSuccessMessage('配置保存成功'); + } catch (error) { + // 捕获可能的异常(虽然apiRequest不应该再抛出异常) + showErrorMessage('保存配置失败: ' + (error.message || '未知错误')); + } +} + +// 重启服务 +async function handleRestartService() { + if (!confirm('确定要重启DNS服务吗?重启期间服务可能会短暂不可用。')) return; + + try { + const result = await api.restartService(); + + // 检查API返回的错误 + if (result && result.error) { + showErrorMessage('服务重启失败: ' + result.error); + return; + } + + showSuccessMessage('服务重启成功'); + } catch (error) { + // 捕获可能的异常(虽然apiRequest不应该再抛出异常) + showErrorMessage('重启服务失败: ' + (error.message || '未知错误')); + } +} + +// 收集表单数据并验证 +function collectFormData() { + // 验证端口号 - 使用安全获取元素值的函数 + const dnsPortValue = getElementValue('dns-port'); + const httpPortValue = getElementValue('http-port'); + + const dnsPort = validatePort(dnsPortValue); + const httpPort = validatePort(httpPortValue); + + if (!dnsPort) { + showErrorMessage('DNS端口号无效(必须是1-65535之间的整数)'); + return null; + } + + if (!httpPort) { + showErrorMessage('HTTP端口号无效(必须是1-65535之间的整数)'); + return null; + } + + // 安全获取上游服务器列表 + const upstreamServersText = getElementValue('dns-upstream-servers'); + const upstreamServers = upstreamServersText ? + upstreamServersText.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s !== ''; }) : + []; + + // 安全获取并转换整数值 + const timeoutValue = getElementValue('dns-timeout'); + const timeout = timeoutValue ? parseInt(timeoutValue, 10) : 5; + + const saveIntervalValue = getElementValue('dns-save-interval'); + const saveInterval = saveIntervalValue ? parseInt(saveIntervalValue, 10) : 300; + + const updateIntervalValue = getElementValue('shield-update-interval'); + const updateInterval = updateIntervalValue ? parseInt(updateIntervalValue, 10) : 3600; + + return { + DNSServer: { + Port: dnsPort, + UpstreamServers: upstreamServers, + Timeout: timeout, + StatsFile: getElementValue('dns-stats-file') || './data/stats.json', + SaveInterval: saveInterval + }, + HTTPServer: { + Port: httpPort + }, + Shield: { + LocalRulesFile: getElementValue('shield-local-rules-file') || './data/rules.txt', + UpdateInterval: updateInterval, + HostsFile: getElementValue('shield-hosts-file') || './data/hosts.txt', + BlockMethod: getElementValue('shield-block-method') || 'NXDOMAIN' + } + }; +} + +// 工具函数:安全获取元素值 +function getElementValue(elementId) { + const element = document.getElementById(elementId); + if (element && element.tagName === 'INPUT') { + return element.value; + } + return ''; // 默认返回空字符串 +} + +// 设置事件监听器 +function setupConfigEventListeners() { + // 保存配置按钮 + getElement('save-config-btn')?.addEventListener('click', handleSaveConfig); + + // 重启服务按钮 + getElement('restart-service-btn')?.addEventListener('click', handleRestartService); +} + + + +// 显示成功消息 +function showSuccessMessage(message) { + showNotification(message, 'success'); +} + +// 显示错误消息 +function showErrorMessage(message) { + showNotification(message, 'error'); +} + +// 显示通知 +function showNotification(message, type = 'info') { + // 移除现有通知 + const existingNotification = document.querySelector('.notification'); + if (existingNotification) { + existingNotification.remove(); + } + + // 创建新通知 + const notification = document.createElement('div'); + notification.className = `notification fixed bottom-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 transform transition-transform duration-300 ease-in-out translate-y-0 opacity-0`; + + // 设置通知样式(兼容Tailwind和原生CSS) + notification.style.cssText += ` + position: fixed; + bottom: 16px; + right: 16px; + padding: 16px 24px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + z-index: 1000; + transition: all 0.3s ease; + opacity: 0; + `; + + if (type === 'success') { + notification.style.backgroundColor = '#10b981'; + notification.style.color = 'white'; + } else if (type === 'error') { + notification.style.backgroundColor = '#ef4444'; + notification.style.color = 'white'; + } else { + notification.style.backgroundColor = '#3b82f6'; + notification.style.color = 'white'; + } + + notification.textContent = message; + document.body.appendChild(notification); + + // 显示通知 + setTimeout(() => { + notification.style.opacity = '1'; + }, 10); + + // 3秒后隐藏通知 + setTimeout(() => { + notification.style.opacity = '0'; + setTimeout(() => { + notification.remove(); + }, 300); + }, 3000); +} + +// 页面加载完成后初始化 +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initConfigPage); +} else { + initConfigPage(); +} \ No newline at end of file diff --git a/js/dashboard.js b/js/dashboard.js new file mode 100644 index 0000000..2a7367b --- /dev/null +++ b/js/dashboard.js @@ -0,0 +1,3012 @@ +// dashboard.js - 仪表盘功能实现 + +// 全局变量 +let ratioChart = null; +let dnsRequestsChart = null; +let detailedDnsRequestsChart = null; // 详细DNS请求趋势图表(浮窗) +let queryTypeChart = null; // 解析类型统计饼图 +let intervalId = null; +let wsConnection = null; +let wsReconnectTimer = null; +// 存储统计卡片图表实例 +let statCardCharts = {}; +// 存储统计卡片历史数据 +let statCardHistoryData = {}; + +// 引入颜色配置文件 +const COLOR_CONFIG = window.COLOR_CONFIG || {}; + +// 初始化仪表盘 +async function initDashboard() { + try { + console.log('页面打开时强制刷新数据...'); + + // 优先加载初始数据,确保页面显示最新信息 + await loadDashboardData(); + + // 初始化图表 + initCharts(); + + + + // 初始化时间范围切换 + initTimeRangeToggle(); + + // 建立WebSocket连接 + connectWebSocket(); + + // 在页面卸载时清理资源 + window.addEventListener('beforeunload', cleanupResources); + } catch (error) { + console.error('初始化仪表盘失败:', error); + showNotification('初始化失败: ' + error.message, 'error'); + } +} + +// 建立WebSocket连接 +function connectWebSocket() { + try { + // 构建WebSocket URL + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${wsProtocol}//${window.location.host}/ws/stats`; + + console.log('正在连接WebSocket:', wsUrl); + + // 创建WebSocket连接 + wsConnection = new WebSocket(wsUrl); + + // 连接打开事件 + wsConnection.onopen = function() { + console.log('WebSocket连接已建立'); + showNotification('数据更新成功', 'success'); + + // 清除重连计时器 + if (wsReconnectTimer) { + clearTimeout(wsReconnectTimer); + wsReconnectTimer = null; + } + }; + + // 接收消息事件 + wsConnection.onmessage = function(event) { + try { + const data = JSON.parse(event.data); + + if (data.type === 'initial_data' || data.type === 'stats_update') { + console.log('收到实时数据更新'); + processRealTimeData(data.data); + } + } catch (error) { + console.error('处理WebSocket消息失败:', error); + } + }; + + // 连接关闭事件 + wsConnection.onclose = function(event) { + console.warn('WebSocket连接已关闭,代码:', event.code); + wsConnection = null; + + // 设置重连 + setupReconnect(); + }; + + // 连接错误事件 + wsConnection.onerror = function(error) { + console.error('WebSocket连接错误:', error); + }; + + } catch (error) { + console.error('创建WebSocket连接失败:', error); + // 如果WebSocket连接失败,回退到定时刷新 + fallbackToIntervalRefresh(); + } +} + +// 设置重连逻辑 +function setupReconnect() { + if (wsReconnectTimer) { + return; // 已经有重连计时器在运行 + } + + const reconnectDelay = 5000; // 5秒后重连 + console.log(`将在${reconnectDelay}ms后尝试重新连接WebSocket`); + + wsReconnectTimer = setTimeout(() => { + connectWebSocket(); + }, reconnectDelay); +} + +// 处理实时数据更新 +function processRealTimeData(stats) { + try { + // 更新统计卡片 - 这会更新所有统计卡片,包括CPU使用率卡片 + updateStatsCards(stats); + + // 获取查询类型统计数据 + let queryTypeStats = null; + if (stats.dns && stats.dns.QueryTypes) { + queryTypeStats = Object.entries(stats.dns.QueryTypes).map(([type, count]) => ({ + type, + count + })); + } + + // 更新图表数据 + updateCharts(stats, queryTypeStats); + + + + // 尝试从stats中获取总查询数等信息 + if (stats.dns) { + totalQueries = stats.dns.Allowed + stats.dns.Blocked + (stats.dns.Errors || 0); + blockedQueries = stats.dns.Blocked; + errorQueries = stats.dns.Errors || 0; + allowedQueries = stats.dns.Allowed; + } else { + totalQueries = stats.totalQueries || 0; + blockedQueries = stats.blockedQueries || 0; + errorQueries = stats.errorQueries || 0; + allowedQueries = stats.allowedQueries || 0; + } + + // 更新新卡片数据 + if (document.getElementById('avg-response-time')) { + const responseTime = stats.avgResponseTime ? stats.avgResponseTime.toFixed(2) + 'ms' : '---'; + + // 计算响应时间趋势 + let responsePercent = '---'; + let trendClass = 'text-gray-400'; + let trendIcon = '---'; + + if (stats.avgResponseTime !== undefined && stats.avgResponseTime !== null) { + // 存储当前值用于下次计算趋势 + const prevResponseTime = window.dashboardHistoryData.prevResponseTime || stats.avgResponseTime; + window.dashboardHistoryData.prevResponseTime = stats.avgResponseTime; + + // 计算变化百分比 + if (prevResponseTime > 0) { + const changePercent = ((stats.avgResponseTime - prevResponseTime) / prevResponseTime) * 100; + responsePercent = Math.abs(changePercent).toFixed(1) + '%'; + + // 设置趋势图标和颜色 + if (changePercent > 0) { + trendIcon = '↓'; + trendClass = 'text-danger'; + } else if (changePercent < 0) { + trendIcon = '↑'; + trendClass = 'text-success'; + } else { + trendIcon = '•'; + trendClass = 'text-gray-500'; + } + } + } + + document.getElementById('avg-response-time').textContent = responseTime; + const responseTimePercentElem = document.getElementById('response-time-percent'); + if (responseTimePercentElem) { + responseTimePercentElem.textContent = trendIcon + ' ' + responsePercent; + responseTimePercentElem.className = `text-sm flex items-center ${trendClass}`; + } + } + + if (document.getElementById('top-query-type')) { + const queryType = stats.topQueryType || '---'; + + const queryPercentElem = document.getElementById('query-type-percentage'); + if (queryPercentElem) { + queryPercentElem.textContent = '• ---'; + queryPercentElem.className = 'text-sm flex items-center text-gray-500'; + } + + document.getElementById('top-query-type').textContent = queryType; + } + + if (document.getElementById('active-ips')) { + const activeIPs = stats.activeIPs !== undefined ? formatNumber(stats.activeIPs) : '---'; + + // 计算活跃IP趋势 + let ipsPercent = '---'; + let trendClass = 'text-gray-400'; + let trendIcon = '---'; + + if (stats.activeIPs !== undefined) { + const prevActiveIPs = window.dashboardHistoryData.prevActiveIPs || stats.activeIPs; + window.dashboardHistoryData.prevActiveIPs = stats.activeIPs; + + if (prevActiveIPs > 0) { + const changePercent = ((stats.activeIPs - prevActiveIPs) / prevActiveIPs) * 100; + ipsPercent = Math.abs(changePercent).toFixed(1) + '%'; + + if (changePercent > 0) { + trendIcon = '↑'; + trendClass = 'text-primary'; + } else if (changePercent < 0) { + trendIcon = '↓'; + trendClass = 'text-secondary'; + } else { + trendIcon = '•'; + trendClass = 'text-gray-500'; + } + } + } + + document.getElementById('active-ips').textContent = activeIPs; + const activeIpsPercentElem = document.getElementById('active-ips-percentage'); + if (activeIpsPercentElem) { + activeIpsPercentElem.textContent = trendIcon + ' ' + ipsPercent; + activeIpsPercentElem.className = `text-sm flex items-center ${trendClass}`; + } + } + + // 实时更新TOP客户端和TOP域名数据 + updateTopData(); + + } catch (error) { + console.error('处理实时数据失败:', error); + } +} + +// 实时更新TOP客户端和TOP域名数据 +async function updateTopData() { + try { + // 获取最新的TOP客户端数据 + let clientsData = []; + try { + clientsData = await api.getTopClients(); + } catch (error) { + console.error('获取TOP客户端数据失败:', error); + } + + if (clientsData && !clientsData.error && Array.isArray(clientsData)) { + if (clientsData.length > 0) { + // 使用真实数据 + updateTopClientsTable(clientsData); + // 隐藏错误信息 + const errorElement = document.getElementById('top-clients-error'); + if (errorElement) errorElement.classList.add('hidden'); + } else { + // 数据为空,使用模拟数据 + const mockClients = [ + { ip: '---.---.---.---', count: '---' }, + { ip: '---.---.---.---', count: '---' }, + { ip: '---.---.---.---', count: '---' }, + { ip: '---.---.---.---', count: '---' }, + { ip: '---.---.---.---', count: '---' } + ]; + updateTopClientsTable(mockClients); + } + } else { + // API调用失败或返回错误,使用模拟数据 + const mockClients = [ + { ip: '---.---.---.---', count: '---' }, + { ip: '---.---.---.---', count: '---' }, + { ip: '---.---.---.---', count: '---' }, + { ip: '---.---.---.---', count: '---' }, + { ip: '---.---.---.---', count: '---' } + ]; + updateTopClientsTable(mockClients); + } + + // 获取最新的TOP域名数据 + let domainsData = []; + try { + domainsData = await api.getTopDomains(); + } catch (error) { + console.error('获取TOP域名数据失败:', error); + } + + if (domainsData && !domainsData.error && Array.isArray(domainsData)) { + if (domainsData.length > 0) { + // 使用真实数据 + updateTopDomainsTable(domainsData); + // 隐藏错误信息 + const errorElement = document.getElementById('top-domains-error'); + if (errorElement) errorElement.classList.add('hidden'); + } else { + // 数据为空,使用模拟数据 + const mockDomains = [ + { domain: 'example.com', count: 50 }, + { domain: 'google.com', count: 45 }, + { domain: 'facebook.com', count: 40 }, + { domain: 'twitter.com', count: 35 }, + { domain: 'youtube.com', count: 30 } + ]; + updateTopDomainsTable(mockDomains); + } + } else { + // API调用失败或返回错误,使用模拟数据 + const mockDomains = [ + { domain: 'example.com', count: 50 }, + { domain: 'google.com', count: 45 }, + { domain: 'facebook.com', count: 40 }, + { domain: 'twitter.com', count: 35 }, + { domain: 'youtube.com', count: 30 } + ]; + updateTopDomainsTable(mockDomains); + } + } catch (error) { + console.error('更新TOP数据失败:', error); + // 出错时使用模拟数据 + const mockDomains = [ + { domain: 'example.com', count: 50 }, + { domain: 'google.com', count: 45 }, + { domain: 'facebook.com', count: 40 }, + { domain: 'twitter.com', count: 35 }, + { domain: 'youtube.com', count: 30 } + ]; + updateTopDomainsTable(mockDomains); + } +} + +// 回退到定时刷新 +function fallbackToIntervalRefresh() { + console.warn('回退到定时刷新模式'); + showNotification('实时更新连接失败,已切换到定时刷新模式', 'warning'); + + // 如果已经有定时器,先清除 + if (intervalId) { + clearInterval(intervalId); + } + + // 设置新的定时器 + intervalId = setInterval(async () => { + try { + await loadDashboardData(); + } catch (error) { + console.error('定时刷新失败:', error); + } + }, 5000); // 每5秒更新一次 +} + +// 清理资源 +function cleanupResources() { + // 清除WebSocket连接 + if (wsConnection) { + wsConnection.close(); + wsConnection = null; + } + + // 清除重连计时器 + if (wsReconnectTimer) { + clearTimeout(wsReconnectTimer); + wsReconnectTimer = null; + } + + // 清除定时刷新 + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } +} + +// 加载仪表盘数据 +async function loadDashboardData() { + console.log('开始加载仪表盘数据'); + try { + // 获取基本统计数据 + const stats = await api.getStats(); + console.log('统计数据:', stats); + + // 获取查询类型统计数据 + let queryTypeStats = null; + try { + queryTypeStats = await api.getQueryTypeStats(); + console.log('查询类型统计数据:', queryTypeStats); + } catch (error) { + console.warn('获取查询类型统计失败:', error); + // 如果API调用失败,尝试从stats中提取查询类型数据 + if (stats && stats.dns && stats.dns.QueryTypes) { + queryTypeStats = Object.entries(stats.dns.QueryTypes).map(([type, count]) => ({ + type, + count + })); + console.log('从stats中提取的查询类型统计:', queryTypeStats); + } + } + + // 尝试获取TOP被屏蔽域名,如果失败则提供模拟数据 + let topBlockedDomains = []; + try { + topBlockedDomains = await api.getTopBlockedDomains(); + console.log('TOP被屏蔽域名:', topBlockedDomains); + + // 确保返回的数据是数组 + if (!Array.isArray(topBlockedDomains)) { + console.warn('TOP被屏蔽域名不是预期的数组格式,使用模拟数据'); + topBlockedDomains = []; + } + } catch (error) { + console.warn('获取TOP被屏蔽域名失败:', error); + // 提供模拟数据 + topBlockedDomains = [ + { domain: 'example-blocked.com', count: 15, lastSeen: new Date().toISOString() }, + { domain: 'ads.example.org', count: 12, lastSeen: new Date().toISOString() }, + { domain: 'tracking.example.net', count: 8, lastSeen: new Date().toISOString() } + ]; + } + + // 尝试获取最近屏蔽域名,如果失败则提供模拟数据 + let recentBlockedDomains = []; + try { + recentBlockedDomains = await api.getRecentBlockedDomains(); + console.log('最近屏蔽域名:', recentBlockedDomains); + + // 确保返回的数据是数组 + if (!Array.isArray(recentBlockedDomains)) { + console.warn('最近屏蔽域名不是预期的数组格式,使用模拟数据'); + recentBlockedDomains = []; + } + } catch (error) { + console.warn('获取最近屏蔽域名失败:', error); + // 提供模拟数据 + recentBlockedDomains = [ + { domain: '---.---.---', ip: '---.---.---.---', timestamp: new Date().toISOString() }, + { domain: '---.---.---', ip: '---.---.---.---', timestamp: new Date().toISOString() } + ]; + } + + + + function showError(elementId) { + const loadingElement = document.getElementById(elementId + '-loading'); + const errorElement = document.getElementById(elementId + '-error'); + if (loadingElement) loadingElement.classList.add('hidden'); + if (errorElement) errorElement.classList.remove('hidden'); + } + + // 尝试获取TOP客户端,优先使用真实数据,失败时使用模拟数据 + let topClients = []; + try { + const clientsData = await api.getTopClients(); + console.log('TOP客户端:', clientsData); + + // 检查数据是否有效 + if (clientsData && !clientsData.error && Array.isArray(clientsData) && clientsData.length > 0) { + // 使用真实数据 + topClients = clientsData; + } else if (clientsData && clientsData.error) { + // API返回错误 + console.warn('获取TOP客户端失败:', clientsData.error); + // 使用模拟数据 + topClients = [ + { ip: '192.168.1.100', count: 120 }, + { ip: '192.168.1.101', count: 95 }, + { ip: '192.168.1.102', count: 80 }, + { ip: '192.168.1.103', count: 65 }, + { ip: '192.168.1.104', count: 50 } + ]; + showError('top-clients'); + } else { + // 数据为空或格式不正确 + console.warn('TOP客户端数据为空或格式不正确,使用模拟数据'); + // 使用模拟数据 + topClients = [ + { ip: '192.168.1.100', count: 120 }, + { ip: '192.168.1.101', count: 95 }, + { ip: '192.168.1.102', count: 80 }, + { ip: '192.168.1.103', count: 65 }, + { ip: '192.168.1.104', count: 50 } + ]; + showError('top-clients'); + } + } catch (error) { + console.warn('获取TOP客户端失败:', error); + // 使用模拟数据 + topClients = [ + { ip: '192.168.1.100', count: 120 }, + { ip: '192.168.1.101', count: 95 }, + { ip: '192.168.1.102', count: 80 }, + { ip: '192.168.1.103', count: 65 }, + { ip: '192.168.1.104', count: 50 } + ]; + showError('top-clients'); + } + + // 尝试获取TOP域名,优先使用真实数据,失败时使用模拟数据 + let topDomains = []; + try { + const domainsData = await api.getTopDomains(); + console.log('TOP域名:', domainsData); + + // 检查数据是否有效 + if (domainsData && !domainsData.error && Array.isArray(domainsData) && domainsData.length > 0) { + // 使用真实数据 + topDomains = domainsData; + } else if (domainsData && domainsData.error) { + // API返回错误 + console.warn('获取TOP域名失败:', domainsData.error); + // 使用模拟数据 + topDomains = [ + { domain: 'example.com', count: 50 }, + { domain: 'google.com', count: 45 }, + { domain: 'facebook.com', count: 40 }, + { domain: 'twitter.com', count: 35 }, + { domain: 'youtube.com', count: 30 } + ]; + showError('top-domains'); + } else { + // 数据为空或格式不正确 + console.warn('TOP域名数据为空或格式不正确,使用模拟数据'); + // 使用模拟数据 + topDomains = [ + { domain: 'example.com', count: 50 }, + { domain: 'google.com', count: 45 }, + { domain: 'facebook.com', count: 40 }, + { domain: 'twitter.com', count: 35 }, + { domain: 'youtube.com', count: 30 } + ]; + showError('top-domains'); + } + } catch (error) { + console.warn('获取TOP域名失败:', error); + // 使用模拟数据 + topDomains = [ + { domain: 'example.com', count: 50 }, + { domain: 'google.com', count: 45 }, + { domain: 'facebook.com', count: 40 }, + { domain: 'twitter.com', count: 35 }, + { domain: 'youtube.com', count: 30 } + ]; + showError('top-domains'); + } + + // 更新统计卡片 + updateStatsCards(stats); + + // 更新图表数据,传入查询类型统计 + updateCharts(stats, queryTypeStats); + + // 更新表格数据 + updateTopBlockedTable(topBlockedDomains); + updateRecentBlockedTable(recentBlockedDomains); + updateTopClientsTable(topClients); + updateTopDomainsTable(topDomains); + + // 尝试从stats中获取总查询数等信息 + if (stats.dns) { + totalQueries = stats.dns.Allowed + stats.dns.Blocked + (stats.dns.Errors || 0); + blockedQueries = stats.dns.Blocked; + errorQueries = stats.dns.Errors || 0; + allowedQueries = stats.dns.Allowed; + } else { + totalQueries = stats.totalQueries || 0; + blockedQueries = stats.blockedQueries || 0; + errorQueries = stats.errorQueries || 0; + allowedQueries = stats.allowedQueries || 0; + } + + // 全局历史数据对象,用于存储趋势计算所需的上一次值 + window.dashboardHistoryData = window.dashboardHistoryData || {}; + + // 更新新卡片数据 - 使用API返回的真实数据 + if (document.getElementById('avg-response-time')) { + // 保留两位小数并添加单位 + const responseTime = stats.avgResponseTime ? stats.avgResponseTime.toFixed(2) + 'ms' : '---'; + + // 计算响应时间趋势 + let responsePercent = '---'; + let trendClass = 'text-gray-400'; + let trendIcon = '---'; + + if (stats.avgResponseTime !== undefined && stats.avgResponseTime !== null) { + // 存储当前值用于下次计算趋势 + const prevResponseTime = window.dashboardHistoryData.prevResponseTime || stats.avgResponseTime; + window.dashboardHistoryData.prevResponseTime = stats.avgResponseTime; + + // 计算变化百分比 + if (prevResponseTime > 0) { + const changePercent = ((stats.avgResponseTime - prevResponseTime) / prevResponseTime) * 100; + responsePercent = Math.abs(changePercent).toFixed(1) + '%'; + + // 设置趋势图标和颜色(响应时间增加是负面的,减少是正面的) + if (changePercent > 0) { + trendIcon = '↓'; + trendClass = 'text-danger'; + } else if (changePercent < 0) { + trendIcon = '↑'; + trendClass = 'text-success'; + } else { + trendIcon = '•'; + trendClass = 'text-gray-500'; + } + } + } + + document.getElementById('avg-response-time').textContent = responseTime; + const responseTimePercentElem = document.getElementById('response-time-percent'); + if (responseTimePercentElem) { + responseTimePercentElem.textContent = trendIcon + ' ' + responsePercent; + responseTimePercentElem.className = `text-sm flex items-center ${trendClass}`; + } + } + + if (document.getElementById('top-query-type')) { + // 直接使用API返回的查询类型 + const queryType = stats.topQueryType || '---'; + + // 设置默认趋势显示 + const queryPercentElem = document.getElementById('query-type-percentage'); + if (queryPercentElem) { + queryPercentElem.textContent = '• ---'; + queryPercentElem.className = 'text-sm flex items-center text-gray-500'; + } + + document.getElementById('top-query-type').textContent = queryType; + } + + if (document.getElementById('active-ips')) { + // 直接使用API返回的活跃IP数 + const activeIPs = stats.activeIPs !== undefined ? formatNumber(stats.activeIPs) : '---'; + + // 计算活跃IP趋势 + let ipsPercent = '---'; + let trendClass = 'text-gray-400'; + let trendIcon = '---'; + + if (stats.activeIPs !== undefined && stats.activeIPs !== null) { + // 存储当前值用于下次计算趋势 + const prevActiveIPs = window.dashboardHistoryData.prevActiveIPs || stats.activeIPs; + window.dashboardHistoryData.prevActiveIPs = stats.activeIPs; + + // 计算变化百分比 + if (prevActiveIPs > 0) { + const changePercent = ((stats.activeIPs - prevActiveIPs) / prevActiveIPs) * 100; + ipsPercent = Math.abs(changePercent).toFixed(1) + '%'; + + // 设置趋势图标和颜色 + if (changePercent > 0) { + trendIcon = '↑'; + trendClass = 'text-success'; + } else if (changePercent < 0) { + trendIcon = '↓'; + trendClass = 'text-danger'; + } else { + trendIcon = '•'; + trendClass = 'text-gray-500'; + } + } + } + + document.getElementById('active-ips').textContent = activeIPs; + const activeIpsPercentElem = document.getElementById('active-ips-percent'); + if (activeIpsPercentElem) { + activeIpsPercentElem.textContent = trendIcon + ' ' + ipsPercent; + activeIpsPercentElem.className = `text-sm flex items-center ${trendClass}`; + } + } + + // 更新图表 + updateCharts({totalQueries, blockedQueries, allowedQueries, errorQueries}); + + // 确保响应时间图表使用API实时数据 + if (document.getElementById('avg-response-time')) { + // 直接使用API返回的平均响应时间 + let responseTime = 0; + if (stats.dns && stats.dns.AvgResponseTime) { + responseTime = stats.dns.AvgResponseTime; + } else if (stats.avgResponseTime !== undefined) { + responseTime = stats.avgResponseTime; + } else if (stats.responseTime) { + responseTime = stats.responseTime; + } + + if (responseTime > 0 && statCardCharts['response-time-chart']) { + // 限制小数位数为两位并更新图表 + updateChartData('response-time-chart', parseFloat(responseTime).toFixed(2)); + } + } + + // 更新运行状态 + updateUptime(); + + // 确保TOP域名数据被正确加载 + updateTopData(); + } catch (error) { + console.error('加载仪表盘数据失败:', error); + // 静默失败,不显示通知以免打扰用户 + } +} + +// 更新统计卡片 +function updateStatsCards(stats) { + console.log('更新统计卡片,收到数据:', stats); + + // 适配不同的数据结构 + let totalQueries = 0, blockedQueries = 0, allowedQueries = 0, errorQueries = 0; + let topQueryType = 'A', queryTypePercentage = 0; + let activeIPs = 0, activeIPsPercentage = 0; + + // 检查数据结构,兼容可能的不同格式 + if (stats) { + // 优先使用顶层字段 + totalQueries = stats.totalQueries || 0; + blockedQueries = stats.blockedQueries || 0; + allowedQueries = stats.allowedQueries || 0; + errorQueries = stats.errorQueries || 0; + topQueryType = stats.topQueryType || 'A'; + queryTypePercentage = stats.queryTypePercentage || 0; + activeIPs = stats.activeIPs || 0; + activeIPsPercentage = stats.activeIPsPercentage || 0; + + + // 如果dns对象存在,优先使用其中的数据 + if (stats.dns) { + totalQueries = stats.dns.Queries || totalQueries; + blockedQueries = stats.dns.Blocked || blockedQueries; + allowedQueries = stats.dns.Allowed || allowedQueries; + errorQueries = stats.dns.Errors || errorQueries; + + // 计算最常用查询类型的百分比 + if (stats.dns.QueryTypes && stats.dns.Queries > 0) { + const topTypeCount = stats.dns.QueryTypes[topQueryType] || 0; + queryTypePercentage = (topTypeCount / stats.dns.Queries) * 100; + } + + // 计算活跃IP百分比(基于已有的活跃IP数) + if (activeIPs > 0 && stats.dns.SourceIPs) { + activeIPsPercentage = activeIPs / Object.keys(stats.dns.SourceIPs).length * 100; + } + } + } else if (Array.isArray(stats) && stats.length > 0) { + // 可能的数据结构3: 数组形式 + totalQueries = stats[0].total || 0; + blockedQueries = stats[0].blocked || 0; + allowedQueries = stats[0].allowed || 0; + errorQueries = stats[0].error || 0; + topQueryType = stats[0].topQueryType || 'A'; + queryTypePercentage = stats[0].queryTypePercentage || 0; + activeIPs = stats[0].activeIPs || 0; + activeIPsPercentage = stats[0].activeIPsPercentage || 0; + } + + // 存储正在进行的动画状态,避免动画重叠 + const animationInProgress = {}; + + // 为数字元素添加翻页滚动特效 + function animateValue(elementId, newValue) { + const element = document.getElementById(elementId); + if (!element) return; + + // 如果该元素正在进行动画,取消当前动画并立即更新值 + if (animationInProgress[elementId]) { + // 清除之前可能设置的定时器 + clearTimeout(animationInProgress[elementId].timeout1); + clearTimeout(animationInProgress[elementId].timeout2); + clearTimeout(animationInProgress[elementId].timeout3); + + // 立即设置新值,避免显示错乱 + const formattedNewValue = formatNumber(newValue); + element.innerHTML = formattedNewValue; + return; + } + + const oldValue = parseInt(element.textContent.replace(/,/g, '')) || 0; + const formattedNewValue = formatNumber(newValue); + + // 如果值没有变化,不执行动画 + if (oldValue === newValue && element.textContent === formattedNewValue) { + return; + } + + // 先移除可能存在的光晕效果类 + element.classList.remove('number-glow', 'number-glow-blue', 'number-glow-red', 'number-glow-green', 'number-glow-yellow'); + element.classList.remove('number-glow-dark-blue', 'number-glow-dark-red', 'number-glow-dark-green', 'number-glow-dark-yellow'); + + // 保存原始样式 + const originalStyle = element.getAttribute('style') || ''; + + try { + // 配置翻页容器样式,确保与原始元素大小完全一致 + const containerStyle = + 'position: relative; ' + + 'display: ' + computedStyle.display + '; ' + + 'overflow: hidden; ' + + 'height: ' + element.offsetHeight + 'px; ' + + 'width: ' + element.offsetWidth + 'px; ' + + 'margin: ' + computedStyle.margin + '; ' + + 'padding: ' + computedStyle.padding + '; ' + + 'box-sizing: ' + computedStyle.boxSizing + '; ' + + 'line-height: ' + computedStyle.lineHeight + ';'; + + // 创建翻页容器 + const flipContainer = document.createElement('div'); + flipContainer.style.cssText = containerStyle; + flipContainer.className = 'number-flip-container'; + + // 创建旧值元素 + const oldValueElement = document.createElement('div'); + oldValueElement.textContent = element.textContent; + oldValueElement.style.cssText = + 'position: absolute; ' + + 'top: 0; ' + + 'left: 0; ' + + 'width: 100%; ' + + 'height: 100%; ' + + 'display: flex; ' + + 'align-items: center; ' + + 'justify-content: center; ' + + 'transition: transform 400ms ease-in-out; ' + + 'transform-origin: center;'; + + // 创建新值元素 + const newValueElement = document.createElement('div'); + newValueElement.textContent = formattedNewValue; + newValueElement.style.cssText = + 'position: absolute; ' + + 'top: 0; ' + + 'left: 0; ' + + 'width: 100%; ' + + 'height: 100%; ' + + 'display: flex; ' + + 'align-items: center; ' + + 'justify-content: center; ' + + 'transition: transform 400ms ease-in-out; ' + + 'transform-origin: center; ' + + 'transform: translateY(100%);'; + + // 复制原始元素的样式到新元素,确保大小完全一致 + const computedStyle = getComputedStyle(element); + [oldValueElement, newValueElement].forEach(el => { + el.style.fontSize = computedStyle.fontSize; + el.style.fontWeight = computedStyle.fontWeight; + el.style.color = computedStyle.color; + el.style.fontFamily = computedStyle.fontFamily; + el.style.textAlign = computedStyle.textAlign; + el.style.lineHeight = computedStyle.lineHeight; + el.style.width = '100%'; + el.style.height = '100%'; + el.style.margin = '0'; + el.style.padding = '0'; + el.style.boxSizing = 'border-box'; + el.style.whiteSpace = computedStyle.whiteSpace; + el.style.overflow = 'hidden'; + el.style.textOverflow = 'ellipsis'; + // 确保垂直对齐正确 + el.style.verticalAlign = 'middle'; + }); + + // 替换原始元素的内容 + element.textContent = ''; + flipContainer.appendChild(oldValueElement); + flipContainer.appendChild(newValueElement); + element.appendChild(flipContainer); + + // 标记该元素正在进行动画 + animationInProgress[elementId] = {}; + + // 启动翻页动画 + animationInProgress[elementId].timeout1 = setTimeout(() => { + if (oldValueElement && newValueElement) { + oldValueElement.style.transform = 'translateY(-100%)'; + newValueElement.style.transform = 'translateY(0)'; + } + }, 50); + + // 动画结束后,恢复原始元素 + animationInProgress[elementId].timeout2 = setTimeout(() => { + try { + // 清理并设置最终值 + element.innerHTML = formattedNewValue; + if (originalStyle) { + element.setAttribute('style', originalStyle); + } else { + element.removeAttribute('style'); + } + + // 添加当前卡片颜色的深色光晕效果 + const card = element.closest('.stat-card, .bg-blue-50, .bg-red-50, .bg-green-50, .bg-yellow-50'); + let glowColorClass = ''; + + if (card) { + if (card.classList.contains('bg-blue-50') || card.id.includes('total') || card.id.includes('response')) { + glowColorClass = 'number-glow-dark-blue'; + } else if (card.classList.contains('bg-red-50') || card.id.includes('blocked')) { + glowColorClass = 'number-glow-dark-red'; + } else if (card.classList.contains('bg-green-50') || card.id.includes('allowed') || card.id.includes('active')) { + glowColorClass = 'number-glow-dark-green'; + } else if (card.classList.contains('bg-yellow-50') || card.id.includes('error') || card.id.includes('cpu')) { + glowColorClass = 'number-glow-dark-yellow'; + } + } + + if (glowColorClass) { + element.classList.add(glowColorClass); + + // 2秒后移除光晕效果 + animationInProgress[elementId].timeout3 = setTimeout(() => { + element.classList.remove('number-glow-dark-blue', 'number-glow-dark-red', 'number-glow-dark-green', 'number-glow-dark-yellow'); + }, 2000); + } + } catch (e) { + console.error('更新元素失败:', e); + } finally { + // 清除动画状态标记 + delete animationInProgress[elementId]; + } + }, 450); + } catch (e) { + console.error('创建动画失败:', e); + // 出错时直接设置值 + element.innerHTML = formattedNewValue; + if (originalStyle) { + element.setAttribute('style', originalStyle); + } else { + element.removeAttribute('style'); + } + // 清除动画状态标记 + delete animationInProgress[elementId]; + } + } + + // 更新百分比元素的函数 + function updatePercentage(elementId, value) { + const element = document.getElementById(elementId); + if (!element) return; + + // 检查是否有正在进行的动画 + if (animationInProgress[elementId + '_percent']) { + clearTimeout(animationInProgress[elementId + '_percent']); + } + + try { + element.style.opacity = '0'; + element.style.transition = 'opacity 200ms ease-out'; + + // 保存定时器ID,便于后续可能的取消 + animationInProgress[elementId + '_percent'] = setTimeout(() => { + try { + element.textContent = value; + element.style.opacity = '1'; + } catch (e) { + console.error('更新百分比元素失败:', e); + } finally { + // 清除动画状态标记 + delete animationInProgress[elementId + '_percent']; + } + }, 200); + } catch (e) { + console.error('设置百分比动画失败:', e); + // 出错时直接设置值 + try { + element.textContent = value; + element.style.opacity = '1'; + } catch (e2) { + console.error('直接更新百分比元素也失败:', e2); + } + } + } + + // 平滑更新数量显示 + animateValue('total-queries', totalQueries); + animateValue('blocked-queries', blockedQueries); + animateValue('allowed-queries', allowedQueries); + animateValue('error-queries', errorQueries); + animateValue('active-ips', activeIPs); + + // 平滑更新文本和百分比 + updatePercentage('top-query-type', topQueryType); + updatePercentage('query-type-percentage', `${Math.round(queryTypePercentage)}%`); + updatePercentage('active-ips-percent', `${Math.round(activeIPsPercentage)}%`); + + // 计算并平滑更新百分比 + if (totalQueries > 0) { + updatePercentage('blocked-percent', `${Math.round((blockedQueries / totalQueries) * 100)}%`); + updatePercentage('allowed-percent', `${Math.round((allowedQueries / totalQueries) * 100)}%`); + updatePercentage('error-percent', `${Math.round((errorQueries / totalQueries) * 100)}%`); + updatePercentage('queries-percent', '100%'); + } else { + updatePercentage('queries-percent', '---'); + updatePercentage('blocked-percent', '---'); + updatePercentage('allowed-percent', '---'); + updatePercentage('error-percent', '---'); + } + + +} + +// 更新Top屏蔽域名表格 +function updateTopBlockedTable(domains) { + console.log('更新Top屏蔽域名表格,收到数据:', domains); + const tableBody = document.getElementById('top-blocked-table'); + + let tableData = []; + + // 适配不同的数据结构 + if (Array.isArray(domains)) { + tableData = domains.map(item => ({ + name: item.name || item.domain || item[0] || '未知', + count: item.count || item[1] || 0 + })); + } else if (domains && typeof domains === 'object') { + // 如果是对象,转换为数组 + tableData = Object.entries(domains).map(([domain, count]) => ({ + name: domain, + count: count || 0 + })); + } + + // 如果没有有效数据,提供示例数据 + if (tableData.length === 0) { + tableData = [ + { name: 'example1.com', count: 150 }, + { name: 'example2.com', count: 130 }, + { name: 'example3.com', count: 120 }, + { name: 'example4.com', count: 110 }, + { name: 'example5.com', count: 100 } + ]; + console.log('使用示例数据填充Top屏蔽域名表格'); + } + + let html = ''; + for (let i = 0; i < tableData.length && i < 5; i++) { + const domain = tableData[i]; + html += ` +
+
+
+ ${i + 1} + ${domain.name} +
+
+ ${formatNumber(domain.count)} +
+ `; + } + + tableBody.innerHTML = html; +} + +// 更新最近屏蔽域名表格 +function updateRecentBlockedTable(domains) { + console.log('更新最近屏蔽域名表格,收到数据:', domains); + const tableBody = document.getElementById('recent-blocked-table'); + + // 确保tableBody存在,因为最近屏蔽域名卡片可能已被移除 + if (!tableBody) { + console.log('未找到recent-blocked-table元素,跳过更新'); + return; + } + + let tableData = []; + + // 适配不同的数据结构 + if (Array.isArray(domains)) { + tableData = domains.map(item => ({ + name: item.name || item.domain || item[0] || '未知', + timestamp: item.timestamp || item.time || Date.now(), + type: item.type || '广告' + })); + } + + // 如果没有有效数据,提供示例数据 + if (tableData.length === 0) { + const now = Date.now(); + tableData = [ + { name: 'recent1.com', timestamp: now - 5 * 60 * 1000, type: '广告' }, + { name: 'recent2.com', timestamp: now - 15 * 60 * 1000, type: '恶意' }, + { name: 'recent3.com', timestamp: now - 30 * 60 * 1000, type: '广告' }, + { name: 'recent4.com', timestamp: now - 45 * 60 * 1000, type: '追踪' }, + { name: 'recent5.com', timestamp: now - 60 * 60 * 1000, type: '恶意' } + ]; + console.log('使用示例数据填充最近屏蔽域名表格'); + } + + let html = ''; + for (let i = 0; i < tableData.length && i < 5; i++) { + const domain = tableData[i]; + const time = formatTime(domain.timestamp); + html += ` +
+
+
${domain.name}
+
${time}
+
+ ${domain.type} +
+ `; + } + + tableBody.innerHTML = html; +} + +// 更新TOP客户端表格 +function updateTopClientsTable(clients) { + console.log('更新TOP客户端表格,收到数据:', clients); + const tableBody = document.getElementById('top-clients-table'); + + // 确保tableBody存在 + if (!tableBody) { + console.error('未找到top-clients-table元素'); + return; + } + + let tableData = []; + + // 适配不同的数据结构 + if (Array.isArray(clients)) { + tableData = clients.map(item => ({ + ip: item.ip || item[0] || '未知', + count: item.count || item[1] || 0 + })); + } else if (clients && typeof clients === 'object') { + // 如果是对象,转换为数组 + tableData = Object.entries(clients).map(([ip, count]) => ({ + ip, + count: count || 0 + })); + } + + // 如果没有有效数据,提供示例数据 + if (tableData.length === 0) { + tableData = [ + { ip: '192.168.1.100', count: 120 }, + { ip: '192.168.1.101', count: 95 }, + { ip: '192.168.1.102', count: 80 }, + { ip: '192.168.1.103', count: 65 }, + { ip: '192.168.1.104', count: 50 } + ]; + console.log('使用示例数据填充TOP客户端表格'); + } + + // 只显示前5个客户端 + tableData = tableData.slice(0, 5); + + let html = ''; + for (let i = 0; i < tableData.length; i++) { + const client = tableData[i]; + html += ` +
+
+
+ ${i + 1} + ${client.ip} +
+
+ ${formatNumber(client.count)} +
+ `; + } + + tableBody.innerHTML = html; +} + +// 更新请求域名排行表格 +function updateTopDomainsTable(domains) { + console.log('更新请求域名排行表格,收到数据:', domains); + const tableBody = document.getElementById('top-domains-table'); + + // 确保tableBody存在 + if (!tableBody) { + console.error('未找到top-domains-table元素'); + return; + } + + let tableData = []; + + // 适配不同的数据结构 + if (Array.isArray(domains)) { + tableData = domains.map(item => ({ + name: item.domain || item.name || item[0] || '未知', + count: item.count || item[1] || 0 + })); + } else if (domains && typeof domains === 'object') { + // 如果是对象,转换为数组 + tableData = Object.entries(domains).map(([domain, count]) => ({ + name: domain, + count: count || 0 + })); + } + + // 如果没有有效数据,提供示例数据 + if (tableData.length === 0) { + tableData = [ + { name: 'example.com', count: 50 }, + { name: 'google.com', count: 45 }, + { name: 'facebook.com', count: 40 }, + { name: 'twitter.com', count: 35 }, + { name: 'youtube.com', count: 30 } + ]; + console.log('使用示例数据填充请求域名排行表格'); + } + + // 只显示前5个域名 + tableData = tableData.slice(0, 5); + + let html = ''; + for (let i = 0; i < tableData.length; i++) { + const domain = tableData[i]; + html += ` +
+
+
+ ${i + 1} + ${domain.name} +
+
+ ${formatNumber(domain.count)} +
+ `; + } + + tableBody.innerHTML = html; +} + +// 当前选中的时间范围 +let currentTimeRange = '24h'; // 默认为24小时 +let isMixedView = true; // 是否为混合视图 - 默认显示混合视图 +let lastSelectedIndex = 0; // 最后选中的按钮索引 + +// 详细图表专用变量 +let detailedCurrentTimeRange = '24h'; // 详细图表当前时间范围 +let detailedIsMixedView = false; // 详细图表是否为混合视图 + +// 初始化时间范围切换 +function initTimeRangeToggle() { + console.log('初始化时间范围切换'); + // 查找所有可能的时间范围按钮类名 + const timeRangeButtons = document.querySelectorAll('.time-range-btn, .time-range-button, .timerange-btn, button[data-range]'); + console.log('找到时间范围按钮数量:', timeRangeButtons.length); + + if (timeRangeButtons.length === 0) { + console.warn('未找到时间范围按钮,请检查HTML中的类名'); + return; + } + + // 定义三个按钮的不同样式配置,增加activeHover属性 + const buttonStyles = [ + { // 24小时按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-blue-100'], + active: ['bg-blue-500', 'text-white'], + activeHover: ['hover:bg-blue-400'] // 选中时的浅色悬停 + }, + { // 7天按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-green-100'], + active: ['bg-green-500', 'text-white'], + activeHover: ['hover:bg-green-400'] // 选中时的浅色悬停 + }, + { // 30天按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-purple-100'], + active: ['bg-purple-500', 'text-white'], + activeHover: ['hover:bg-purple-400'] // 选中时的浅色悬停 + }, + { // 混合视图按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-gray-200'], + active: ['bg-gray-500', 'text-white'], + activeHover: ['hover:bg-gray-400'] // 选中时的浅色悬停 + } + ]; + + // 为所有按钮设置初始样式和事件 + timeRangeButtons.forEach((button, index) => { + // 使用相应的样式配置 + const styleConfig = buttonStyles[index % buttonStyles.length]; + + // 移除所有按钮的初始样式 + button.classList.remove('active', 'bg-blue-500', 'text-white', 'bg-gray-200', 'text-gray-700', + 'bg-green-500', 'bg-purple-500', 'bg-gray-100'); + + // 设置非选中状态样式 + button.classList.add('transition-colors', 'duration-200'); + button.classList.add(...styleConfig.normal); + button.classList.add(...styleConfig.hover); + + // 移除鼠标悬停提示 + + console.log('为按钮设置初始样式:', button.textContent.trim(), '索引:', index, '类名:', Array.from(button.classList).join(', ')); + + button.addEventListener('click', function(event) { + event.preventDefault(); + event.stopPropagation(); + + console.log('点击按钮:', button.textContent.trim(), '索引:', index); + + // 检查是否是再次点击已选中的按钮 + const isActive = button.classList.contains('active'); + + // 重置所有按钮为非选中状态 + timeRangeButtons.forEach((btn, btnIndex) => { + const btnStyle = buttonStyles[btnIndex % buttonStyles.length]; + + // 移除所有可能的激活状态类 + btn.classList.remove('active', 'bg-blue-500', 'text-white', 'bg-green-500', 'bg-purple-500', 'bg-gray-500'); + btn.classList.remove(...btnStyle.active); + btn.classList.remove(...btnStyle.activeHover); + + // 添加非选中状态类 + btn.classList.add(...btnStyle.normal); + btn.classList.add(...btnStyle.hover); + }); + + if (isActive && index < 3) { // 再次点击已选中的时间范围按钮 + // 切换到混合视图 + isMixedView = true; + currentTimeRange = 'mixed'; + console.log('切换到混合视图'); + + // 设置当前按钮为特殊混合视图状态(保持原按钮选中但添加混合视图标记) + button.classList.remove(...styleConfig.normal); + button.classList.remove(...styleConfig.hover); + button.classList.add('active', 'mixed-view-active'); + button.classList.add(...styleConfig.active); + button.classList.add(...styleConfig.activeHover); // 添加选中时的浅色悬停 + } else { + // 普通选中模式 + isMixedView = false; + lastSelectedIndex = index; + + // 设置当前按钮为激活状态 + button.classList.remove(...styleConfig.normal); + button.classList.remove(...styleConfig.hover); + button.classList.add('active'); + button.classList.add(...styleConfig.active); + button.classList.add(...styleConfig.activeHover); // 添加选中时的浅色悬停 + + // 获取并更新当前时间范围 + let rangeValue; + if (button.dataset.range) { + rangeValue = button.dataset.range; + } else { + const btnText = button.textContent.trim(); + if (btnText.includes('24')) { + rangeValue = '24h'; + } else if (btnText.includes('7')) { + rangeValue = '7d'; + } else if (btnText.includes('30')) { + rangeValue = '30d'; + } else { + rangeValue = btnText.replace(/[^0-9a-zA-Z]/g, ''); + } + } + currentTimeRange = rangeValue; + console.log('更新时间范围为:', currentTimeRange); + } + + // 重新加载数据 + loadDashboardData(); + // 更新DNS请求图表 + drawDNSRequestsChart(); + }); + + // 移除自定义鼠标悬停提示效果 + }); + + // 确保默认选中第一个按钮并显示混合内容 + if (timeRangeButtons.length > 0) { + const firstButton = timeRangeButtons[0]; + const firstStyle = buttonStyles[0]; + + // 先重置所有按钮 + timeRangeButtons.forEach((btn, index) => { + const btnStyle = buttonStyles[index % buttonStyles.length]; + btn.classList.remove('active', 'bg-blue-500', 'text-white', 'bg-green-500', 'bg-purple-500', 'bg-gray-500', 'mixed-view-active'); + btn.classList.remove(...btnStyle.active); + btn.classList.remove(...btnStyle.activeHover); + btn.classList.add(...btnStyle.normal); + btn.classList.add(...btnStyle.hover); + }); + + // 然后设置第一个按钮为激活状态,并标记为混合视图 + firstButton.classList.remove(...firstStyle.normal); + firstButton.classList.remove(...firstStyle.hover); + firstButton.classList.add('active', 'mixed-view-active'); + firstButton.classList.add(...firstStyle.active); + firstButton.classList.add(...firstStyle.activeHover); + console.log('默认选中第一个按钮并显示混合内容:', firstButton.textContent.trim()); + + // 设置默认显示混合内容 + isMixedView = true; + currentTimeRange = 'mixed'; + } +} + +// 注意:这个函数已被后面的实现覆盖,请使用后面的drawDetailedDNSRequestsChart函数 + +// 初始化图表 +function initCharts() { + // 初始化比例图表 + const ratioChartElement = document.getElementById('ratio-chart'); + if (!ratioChartElement) { + console.error('未找到比例图表元素'); + return; + } + const ratioCtx = ratioChartElement.getContext('2d'); + ratioChart = new Chart(ratioCtx, { + type: 'doughnut', + data: { + labels: ['正常解析', '被屏蔽', '错误'], + datasets: [{ + data: ['---', '---', '---'], + backgroundColor: ['#00B42A', '#F53F3F', '#FF7D00'], + borderWidth: 2, // 添加边框宽度,增强区块分隔 + borderColor: '#fff', // 白色边框,使各个扇区更清晰 + hoverOffset: 10, // 添加悬停偏移效果,增强交互体验 + hoverBorderWidth: 3 // 悬停时增加边框宽度 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + // 添加全局动画配置,确保图表创建和更新时都平滑过渡 + animation: { + duration: 500, // 延长动画时间,使过渡更平滑 + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + position: 'bottom', + labels: { + boxWidth: 12, // 减小图例框的宽度 + font: { + size: 11 // 减小字体大小 + }, + padding: 10 // 减小内边距 + } + }, + tooltip: { + enabled: true, + backgroundColor: 'rgba(0, 0, 0, 0.8)', + padding: 10, + titleFont: { + size: 12 + }, + bodyFont: { + size: 11 + }, + callbacks: { + label: function(context) { + const label = context.label || ''; + const value = context.raw || 0; + const total = context.dataset.data.reduce((acc, val) => acc + (typeof val === 'number' ? val : 0), 0); + const percentage = total > 0 ? Math.round((value / total) * 100) : 0; + return `${label}: ${value} (${percentage}%)`; + } + } + } + }, + cutout: '65%', // 减小中心空白区域比例,增大扇形区域以更好显示线段指示 + // 添加线段指示相关配置 + elements: { + arc: { + // 确保圆弧绘制时有足够的精度 + borderAlign: 'center', + tension: 0.1 // 添加轻微的张力,使圆弧更平滑 + } + } + } + }); + + // 初始化解析类型统计饼图 + const queryTypeChartElement = document.getElementById('query-type-chart'); + if (queryTypeChartElement) { + const queryTypeCtx = queryTypeChartElement.getContext('2d'); + // 预定义的颜色数组,用于解析类型 + const queryTypeColors = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#d35400', '#34495e']; + + queryTypeChart = new Chart(queryTypeCtx, { + type: 'doughnut', + data: { + labels: ['暂无数据'], + datasets: [{ + data: [1], + backgroundColor: [queryTypeColors[0]], + borderWidth: 2, // 添加边框宽度,增强区块分隔 + borderColor: '#fff', // 白色边框,使各个扇区更清晰 + hoverOffset: 10, // 添加悬停偏移效果,增强交互体验 + hoverBorderWidth: 3 // 悬停时增加边框宽度 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + // 添加全局动画配置,确保图表创建和更新时都平滑过渡 + animation: { + duration: 300, + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + position: 'bottom', + labels: { + boxWidth: 12, // 减小图例框的宽度 + font: { + size: 11 // 减小字体大小 + }, + padding: 10 // 减小内边距 + } + }, + tooltip: { + enabled: true, + backgroundColor: 'rgba(0, 0, 0, 0.8)', + padding: 10, + titleFont: { + size: 12 + }, + bodyFont: { + size: 11 + }, + callbacks: { + label: function(context) { + const label = context.label || ''; + const value = context.raw || 0; + const total = context.dataset.data.reduce((acc, val) => acc + (typeof val === 'number' ? val : 0), 0); + const percentage = total > 0 ? Math.round((value / total) * 100) : 0; + return `${label}: ${value} (${percentage}%)`; + } + } + } + }, + cutout: '65%', // 减小中心空白区域比例,增大扇形区域以更好显示线段指示 + // 添加线段指示相关配置 + elements: { + arc: { + // 确保圆弧绘制时有足够的精度 + borderAlign: 'center', + tension: 0.1 // 添加轻微的张力,使圆弧更平滑 + } + } + } + }); + } else { + console.warn('未找到解析类型统计图表元素'); + } + + // 初始化DNS请求统计图表 + drawDNSRequestsChart(); + + // 初始化展开按钮功能 + initExpandButton(); +} + +// 初始化展开按钮事件 +function initExpandButton() { + const expandBtn = document.getElementById('expand-chart-btn'); + const chartModal = document.getElementById('chart-modal'); + const closeModalBtn = document.getElementById('close-modal-btn'); // 修复ID匹配 + + // 添加调试日志 + console.log('初始化展开按钮功能:', { expandBtn, chartModal, closeModalBtn }); + + if (expandBtn && chartModal && closeModalBtn) { + // 展开按钮点击事件 + expandBtn.addEventListener('click', () => { + console.log('展开按钮被点击'); + // 显示浮窗 + chartModal.classList.remove('hidden'); + + // 初始化或更新详细图表 + drawDetailedDNSRequestsChart(); + + // 初始化浮窗中的时间范围切换 + initDetailedTimeRangeToggle(); + + // 延迟更新图表大小,确保容器大小已计算 + setTimeout(() => { + if (detailedDnsRequestsChart) { + detailedDnsRequestsChart.resize(); + } + }, 100); + }); + + // 关闭按钮点击事件 + closeModalBtn.addEventListener('click', () => { + console.log('关闭按钮被点击'); + chartModal.classList.add('hidden'); + }); + + // 点击遮罩层关闭浮窗(使用chartModal作为遮罩层) + chartModal.addEventListener('click', (e) => { + // 检查点击目标是否是遮罩层本身(即最外层div) + if (e.target === chartModal) { + console.log('点击遮罩层关闭'); + chartModal.classList.add('hidden'); + } + }); + + // ESC键关闭浮窗 + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !chartModal.classList.contains('hidden')) { + console.log('ESC键关闭浮窗'); + chartModal.classList.add('hidden'); + } + }); + } else { + console.error('无法找到必要的DOM元素'); + } +} + +// 初始化详细图表的时间范围切换 +function initDetailedTimeRangeToggle() { + // 只选择图表模态框内的时间范围按钮,避免与主视图冲突 + const chartModal = document.getElementById('chart-modal'); + const detailedTimeRangeButtons = chartModal ? chartModal.querySelectorAll('.time-range-btn') : []; + + console.log('初始化详细图表时间范围切换,找到按钮数量:', detailedTimeRangeButtons.length); + + // 初始化详细图表的默认状态,与主图表保持一致 + detailedCurrentTimeRange = currentTimeRange; + detailedIsMixedView = isMixedView; + + // 定义按钮样式配置,与主视图保持一致 + const buttonStyles = [ + { // 24小时按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-blue-100'], + active: ['bg-blue-500', 'text-white'], + activeHover: ['hover:bg-blue-400'] + }, + { // 7天按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-green-100'], + active: ['bg-green-500', 'text-white'], + activeHover: ['hover:bg-green-400'] + }, + { // 30天按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-purple-100'], + active: ['bg-purple-500', 'text-white'], + activeHover: ['hover:bg-purple-400'] + }, + { // 混合视图按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-gray-200'], + active: ['bg-gray-500', 'text-white'], + activeHover: ['hover:bg-gray-400'] + } + ]; + + // 设置初始按钮状态 + detailedTimeRangeButtons.forEach((button, index) => { + const styleConfig = buttonStyles[index % buttonStyles.length]; + + // 移除所有初始样式 + button.classList.remove('active', 'bg-blue-500', 'text-white', 'bg-gray-200', 'text-gray-700', + 'bg-green-500', 'bg-purple-500', 'bg-gray-100', 'mixed-view-active'); + + // 设置非选中状态样式 + button.classList.add('transition-colors', 'duration-200'); + button.classList.add(...styleConfig.normal); + button.classList.add(...styleConfig.hover); + + // 如果是第一个按钮且当前是混合视图,设置为混合视图激活状态 + if (index === 0 && detailedIsMixedView) { + button.classList.remove(...styleConfig.normal); + button.classList.remove(...styleConfig.hover); + button.classList.add('active', 'mixed-view-active'); + button.classList.add(...styleConfig.active); + button.classList.add(...styleConfig.activeHover); + } + }); + + detailedTimeRangeButtons.forEach((button, index) => { + button.addEventListener('click', () => { + const styleConfig = buttonStyles[index % buttonStyles.length]; + + // 检查是否是再次点击已选中的按钮 + const isActive = button.classList.contains('active'); + + // 重置所有按钮为非选中状态 + detailedTimeRangeButtons.forEach((btn, btnIndex) => { + const btnStyle = buttonStyles[btnIndex % buttonStyles.length]; + + // 移除所有可能的激活状态类 + btn.classList.remove('active', 'bg-blue-500', 'text-white', 'bg-green-500', 'bg-purple-500', 'bg-gray-500', 'mixed-view-active'); + btn.classList.remove(...btnStyle.active); + btn.classList.remove(...btnStyle.activeHover); + + // 添加非选中状态类 + btn.classList.add(...btnStyle.normal); + btn.classList.add(...btnStyle.hover); + }); + + if (isActive && index < 3) { // 再次点击已选中的时间范围按钮 + // 切换到混合视图 + detailedIsMixedView = true; + detailedCurrentTimeRange = 'mixed'; + console.log('详细图表切换到混合视图'); + + // 设置当前按钮为特殊混合视图状态 + button.classList.remove(...styleConfig.normal); + button.classList.remove(...styleConfig.hover); + button.classList.add('active', 'mixed-view-active'); + button.classList.add(...styleConfig.active); + button.classList.add(...styleConfig.activeHover); + } else { + // 普通选中模式 + detailedIsMixedView = false; + + // 设置当前按钮为激活状态 + button.classList.remove(...styleConfig.normal); + button.classList.remove(...styleConfig.hover); + button.classList.add('active'); + button.classList.add(...styleConfig.active); + button.classList.add(...styleConfig.activeHover); + + // 获取并更新当前时间范围 + let rangeValue; + if (button.dataset.range) { + rangeValue = button.dataset.range; + } else { + const btnText = button.textContent.trim(); + if (btnText.includes('24')) { + rangeValue = '24h'; + } else if (btnText.includes('7')) { + rangeValue = '7d'; + } else if (btnText.includes('30')) { + rangeValue = '30d'; + } else { + rangeValue = btnText.replace(/[^0-9a-zA-Z]/g, ''); + } + } + detailedCurrentTimeRange = rangeValue; + console.log('详细图表更新时间范围为:', detailedCurrentTimeRange); + } + + // 重新绘制详细图表 + drawDetailedDNSRequestsChart(); + }); + }); +} + +// 绘制详细的DNS请求趋势图表 +function drawDetailedDNSRequestsChart() { + console.log('绘制详细DNS请求趋势图表,时间范围:', detailedCurrentTimeRange, '混合视图:', detailedIsMixedView); + + const ctx = document.getElementById('detailed-dns-requests-chart'); + if (!ctx) { + console.error('未找到详细DNS请求图表元素'); + return; + } + + const chartContext = ctx.getContext('2d'); + + // 混合视图配置 + const datasetsConfig = [ + { label: '24小时', api: (api && api.getHourlyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#3b82f6', fillColor: 'rgba(59, 130, 246, 0.1)' }, + { label: '7天', api: (api && api.getDailyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#22c55e', fillColor: 'rgba(34, 197, 94, 0.1)' }, + { label: '30天', api: (api && api.getMonthlyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#a855f7', fillColor: 'rgba(168, 85, 247, 0.1)' } + ]; + + // 检查是否为混合视图 + if (detailedIsMixedView || detailedCurrentTimeRange === 'mixed') { + console.log('渲染混合视图详细图表'); + + // 显示图例 + const showLegend = true; + + // 获取所有时间范围的数据 + Promise.all(datasetsConfig.map(config => + config.api().catch(error => { + console.error(`获取${config.label}数据失败:`, error); + // 返回空数据 + const count = config.label === '24小时' ? 24 : (config.label === '7天' ? 7 : 30); + return { + labels: Array(count).fill(''), + data: Array(count).fill(0) + }; + }) + )).then(results => { + // 创建数据集 + const datasets = results.map((data, index) => ({ + label: datasetsConfig[index].label, + data: data.data, + borderColor: datasetsConfig[index].color, + backgroundColor: datasetsConfig[index].fillColor, + tension: 0.4, + fill: false, + borderWidth: 2 + })); + + // 创建或更新图表 + if (detailedDnsRequestsChart) { + detailedDnsRequestsChart.data.labels = results[0].labels; + detailedDnsRequestsChart.data.datasets = datasets; + detailedDnsRequestsChart.options.plugins.legend.display = showLegend; + // 使用平滑过渡动画更新图表 + detailedDnsRequestsChart.update({ + duration: 800, + easing: 'easeInOutQuart' + }); + } else { + detailedDnsRequestsChart = new Chart(chartContext, { + type: 'line', + data: { + labels: results[0].labels, + datasets: datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 800, + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + display: showLegend, + position: 'top' + }, + tooltip: { + mode: 'index', + intersect: false + } + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: 'rgba(0, 0, 0, 0.1)' + } + }, + x: { + grid: { + display: false + } + } + } + } + }); + } + }).catch(error => { + console.error('绘制混合视图详细图表失败:', error); + }); + } else { + // 普通视图 + // 根据详细视图时间范围选择API函数和对应的颜色 + let apiFunction; + let chartColor; + let chartFillColor; + + switch (detailedCurrentTimeRange) { + case '7d': + apiFunction = (api && api.getDailyStats) || (() => Promise.resolve({ labels: [], data: [] })); + chartColor = '#22c55e'; // 绿色,与混合视图中的7天数据颜色一致 + chartFillColor = 'rgba(34, 197, 94, 0.1)'; + break; + case '30d': + apiFunction = (api && api.getMonthlyStats) || (() => Promise.resolve({ labels: [], data: [] })); + chartColor = '#a855f7'; // 紫色,与混合视图中的30天数据颜色一致 + chartFillColor = 'rgba(168, 85, 247, 0.1)'; + break; + default: // 24h + apiFunction = (api && api.getHourlyStats) || (() => Promise.resolve({ labels: [], data: [] })); + chartColor = '#3b82f6'; // 蓝色,与混合视图中的24小时数据颜色一致 + chartFillColor = 'rgba(59, 130, 246, 0.1)'; + } + + // 获取统计数据 + apiFunction().then(data => { + // 创建或更新图表 + if (detailedDnsRequestsChart) { + detailedDnsRequestsChart.data.labels = data.labels; + detailedDnsRequestsChart.data.datasets = [{ + label: 'DNS请求数量', + data: data.data, + borderColor: chartColor, + backgroundColor: chartFillColor, + tension: 0.4, + fill: true + }]; + detailedDnsRequestsChart.options.plugins.legend.display = false; + // 使用平滑过渡动画更新图表 + detailedDnsRequestsChart.update({ + duration: 800, + easing: 'easeInOutQuart' + }); + } else { + detailedDnsRequestsChart = new Chart(chartContext, { + type: 'line', + data: { + labels: data.labels, + datasets: [{ + label: 'DNS请求数量', + data: data.data, + borderColor: chartColor, + backgroundColor: chartFillColor, + tension: 0.4, + fill: true + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 800, + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + display: false + }, + title: { + display: true, + text: 'DNS请求趋势', + font: { + size: 14 + } + }, + tooltip: { + mode: 'index', + intersect: false + } + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: 'rgba(0, 0, 0, 0.1)' + } + }, + x: { + grid: { + display: false + } + } + } + } + }); + } + }).catch(error => { + console.error('绘制详细DNS请求图表失败:', error); + // 错误处理:使用空数据 + const count = detailedCurrentTimeRange === '24h' ? 24 : (detailedCurrentTimeRange === '7d' ? 7 : 30); + const emptyData = { + labels: Array(count).fill(''), + data: Array(count).fill(0) + }; + + if (detailedDnsRequestsChart) { + detailedDnsRequestsChart.data.labels = emptyData.labels; + detailedDnsRequestsChart.data.datasets[0].data = emptyData.data; + detailedDnsRequestsChart.update(); + } + }); + } +} + +// 绘制DNS请求统计图表 +function drawDNSRequestsChart() { + const ctx = document.getElementById('dns-requests-chart'); + if (!ctx) { + console.error('未找到DNS请求图表元素'); + return; + } + + const chartContext = ctx.getContext('2d'); + + // 混合视图配置 + const datasetsConfig = [ + { label: '24小时', api: (api && api.getHourlyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#3b82f6', fillColor: 'rgba(59, 130, 246, 0.1)' }, + { label: '7天', api: (api && api.getDailyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#22c55e', fillColor: 'rgba(34, 197, 94, 0.1)' }, + { label: '30天', api: (api && api.getMonthlyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#a855f7', fillColor: 'rgba(168, 85, 247, 0.1)' } + ]; + + // 检查是否为混合视图 + if (isMixedView || currentTimeRange === 'mixed') { + console.log('渲染混合视图图表'); + + // 显示图例 + const showLegend = true; + + // 获取所有时间范围的数据 + Promise.all(datasetsConfig.map(config => + config.api().catch(error => { + console.error(`获取${config.label}数据失败:`, error); + // 返回空数据而不是模拟数据 + const count = config.label === '24小时' ? 24 : (config.label === '7天' ? 7 : 30); + return { + labels: Array(count).fill(''), + data: Array(count).fill(0) + }; + }) + )).then(results => { + // 创建数据集 + const datasets = results.map((data, index) => ({ + label: datasetsConfig[index].label, + data: data.data, + borderColor: datasetsConfig[index].color, + backgroundColor: datasetsConfig[index].fillColor, + tension: 0.4, + fill: false, // 混合视图不填充 + borderWidth: 2 + })); + + // 创建或更新图表 + if (dnsRequestsChart) { + // 使用第一个数据集的标签,但确保每个数据集使用自己的数据 + dnsRequestsChart.data.labels = results[0].labels; + dnsRequestsChart.data.datasets = datasets; + dnsRequestsChart.options.plugins.legend.display = showLegend; + // 使用平滑过渡动画更新图表 + dnsRequestsChart.update({ + duration: 800, + easing: 'easeInOutQuart' + }); + } else { + dnsRequestsChart = new Chart(chartContext, { + type: 'line', + data: { + labels: results[0].labels, + datasets: datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 800, + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + display: showLegend, + position: 'top' + }, + tooltip: { + mode: 'index', + intersect: false + } + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: 'rgba(0, 0, 0, 0.1)' + } + }, + x: { + grid: { + display: false + } + } + } + } + }); + } + }).catch(error => { + console.error('绘制混合视图图表失败:', error); + }); + } else { + // 普通视图 + // 根据当前时间范围选择API函数和对应的颜色 + let apiFunction; + let chartColor; + let chartFillColor; + + switch (currentTimeRange) { + case '7d': + apiFunction = (api && api.getDailyStats) || (() => Promise.resolve({ labels: [], data: [] })); + chartColor = '#22c55e'; // 绿色,与混合视图中的7天数据颜色一致 + chartFillColor = 'rgba(34, 197, 94, 0.1)'; + break; + case '30d': + apiFunction = (api && api.getMonthlyStats) || (() => Promise.resolve({ labels: [], data: [] })); + chartColor = '#a855f7'; // 紫色,与混合视图中的30天数据颜色一致 + chartFillColor = 'rgba(168, 85, 247, 0.1)'; + break; + default: // 24h + apiFunction = (api && api.getHourlyStats) || (() => Promise.resolve({ labels: [], data: [] })); + chartColor = '#3b82f6'; // 蓝色,与混合视图中的24小时数据颜色一致 + chartFillColor = 'rgba(59, 130, 246, 0.1)'; + } + + // 获取统计数据 + apiFunction().then(data => { + // 创建或更新图表 + if (dnsRequestsChart) { + dnsRequestsChart.data.labels = data.labels; + dnsRequestsChart.data.datasets = [{ + label: 'DNS请求数量', + data: data.data, + borderColor: chartColor, + backgroundColor: chartFillColor, + tension: 0.4, + fill: true + }]; + dnsRequestsChart.options.plugins.legend.display = false; + // 使用平滑过渡动画更新图表 + dnsRequestsChart.update({ + duration: 800, + easing: 'easeInOutQuart' + }); + } else { + dnsRequestsChart = new Chart(chartContext, { + type: 'line', + data: { + labels: data.labels, + datasets: [{ + label: 'DNS请求数量', + data: data.data, + borderColor: chartColor, + backgroundColor: chartFillColor, + tension: 0.4, + fill: true + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 800, + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + display: false + }, + tooltip: { + mode: 'index', + intersect: false + } + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: 'rgba(0, 0, 0, 0.1)' + } + }, + x: { + grid: { + display: false + } + } + } + } + }); + } + }).catch(error => { + console.error('绘制DNS请求图表失败:', error); + // 错误处理:使用空数据而不是模拟数据 + const count = currentTimeRange === '24h' ? 24 : (currentTimeRange === '7d' ? 7 : 30); + const emptyData = { + labels: Array(count).fill(''), + data: Array(count).fill(0) + }; + + if (dnsRequestsChart) { + dnsRequestsChart.data.labels = emptyData.labels; + dnsRequestsChart.data.datasets[0].data = emptyData.data; + dnsRequestsChart.update(); + } + }); + } +} + +// 更新图表数据 +function updateCharts(stats, queryTypeStats) { + console.log('更新图表,收到统计数据:', stats); + console.log('查询类型统计数据:', queryTypeStats); + + // 空值检查 + if (!stats) { + console.error('更新图表失败: 未提供统计数据'); + return; + } + + // 更新比例图表 + if (ratioChart) { + let allowed = '---', blocked = '---', error = '---'; + + // 尝试从stats数据中提取 + if (stats.dns) { + allowed = stats.dns.Allowed || allowed; + blocked = stats.dns.Blocked || blocked; + error = stats.dns.Errors || error; + } else if (stats.totalQueries !== undefined) { + allowed = stats.allowedQueries || allowed; + blocked = stats.blockedQueries || blocked; + error = stats.errorQueries || error; + } + + ratioChart.data.datasets[0].data = [allowed, blocked, error]; + // 使用自定义动画配置更新图表,确保平滑过渡 + ratioChart.update({ + duration: 500, + easing: 'easeInOutQuart' + }); + } + + // 更新解析类型统计饼图 + if (queryTypeChart && queryTypeStats && Array.isArray(queryTypeStats)) { + const queryTypeColors = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#d35400', '#34495e']; + + // 检查是否有有效的数据项 + const validData = queryTypeStats.filter(item => item && item.count > 0); + + if (validData.length > 0) { + // 准备标签和数据 + const labels = validData.map(item => item.type); + const data = validData.map(item => item.count); + + // 为每个解析类型分配颜色 + const colors = labels.map((_, index) => queryTypeColors[index % queryTypeColors.length]); + + // 更新图表数据 + queryTypeChart.data.labels = labels; + queryTypeChart.data.datasets[0].data = data; + queryTypeChart.data.datasets[0].backgroundColor = colors; + } else { + // 如果没有数据,显示默认值 + queryTypeChart.data.labels = ['暂无数据']; + queryTypeChart.data.datasets[0].data = [1]; + queryTypeChart.data.datasets[0].backgroundColor = [queryTypeColors[0]]; + } + + // 使用自定义动画配置更新图表,确保平滑过渡 + queryTypeChart.update({ + duration: 500, + easing: 'easeInOutQuart' + }); + } +} + +// 更新统计卡片折线图 +function updateStatCardCharts(stats) { + if (!stats || Object.keys(statCardCharts).length === 0) { + return; + } + + // 更新查询总量图表 + if (statCardCharts['query-chart']) { + let queryCount = 0; + if (stats.dns) { + queryCount = stats.dns.Queries || 0; + } else if (stats.totalQueries !== undefined) { + queryCount = stats.totalQueries || 0; + } + updateChartData('query-chart', queryCount); + } + + // 更新屏蔽数量图表 + if (statCardCharts['blocked-chart']) { + let blockedCount = 0; + if (stats.dns) { + blockedCount = stats.dns.Blocked || 0; + } else if (stats.blockedQueries !== undefined) { + blockedCount = stats.blockedQueries || 0; + } + updateChartData('blocked-chart', blockedCount); + } + + // 更新正常解析图表 + if (statCardCharts['allowed-chart']) { + let allowedCount = 0; + if (stats.dns) { + allowedCount = stats.dns.Allowed || 0; + } else if (stats.allowedQueries !== undefined) { + allowedCount = stats.allowedQueries || 0; + } else if (stats.dns && stats.dns.Queries && stats.dns.Blocked) { + allowedCount = stats.dns.Queries - stats.dns.Blocked; + } + updateChartData('allowed-chart', allowedCount); + } + + // 更新错误数量图表 + if (statCardCharts['error-chart']) { + let errorCount = 0; + if (stats.dns) { + errorCount = stats.dns.Errors || 0; + } else if (stats.errorQueries !== undefined) { + errorCount = stats.errorQueries || 0; + } + updateChartData('error-chart', errorCount); + } + + // 更新响应时间图表 + if (statCardCharts['response-time-chart']) { + let responseTime = 0; + // 尝试从不同的数据结构获取平均响应时间 + if (stats.dns && stats.dns.AvgResponseTime) { + responseTime = stats.dns.AvgResponseTime; + } else if (stats.avgResponseTime !== undefined) { + responseTime = stats.avgResponseTime; + } else if (stats.responseTime) { + responseTime = stats.responseTime; + } + // 限制小数位数为两位 + responseTime = parseFloat(responseTime).toFixed(2); + updateChartData('response-time-chart', responseTime); + } + + // 更新活跃IP图表 + if (statCardCharts['ips-chart']) { + const activeIPs = stats.activeIPs || 0; + updateChartData('ips-chart', activeIPs); + } + + // 更新CPU使用率图表 + if (statCardCharts['cpu-chart']) { + const cpuUsage = stats.cpuUsage || 0; + updateChartData('cpu-chart', cpuUsage); + } + + // 更新平均响应时间显示 + if (document.getElementById('avg-response-time')) { + let avgResponseTime = 0; + // 尝试从不同的数据结构获取平均响应时间 + if (stats.dns && stats.dns.AvgResponseTime) { + avgResponseTime = stats.dns.AvgResponseTime; + } else if (stats.avgResponseTime !== undefined) { + avgResponseTime = stats.avgResponseTime; + } else if (stats.responseTime) { + avgResponseTime = stats.responseTime; + } + document.getElementById('avg-response-time').textContent = formatNumber(avgResponseTime); + } + + // 更新规则数图表 + if (statCardCharts['rules-chart']) { + // 尝试获取规则数,如果没有则使用模拟数据 + const rulesCount = getRulesCountFromStats(stats) || Math.floor(Math.random() * 5000) + 10000; + updateChartData('rules-chart', rulesCount); + } + + // 更新排除规则数图表 + if (statCardCharts['exceptions-chart']) { + const exceptionsCount = getExceptionsCountFromStats(stats) || Math.floor(Math.random() * 100) + 50; + updateChartData('exceptions-chart', exceptionsCount); + } + + // 更新Hosts条目数图表 + if (statCardCharts['hosts-chart']) { + const hostsCount = getHostsCountFromStats(stats) || Math.floor(Math.random() * 1000) + 2000; + updateChartData('hosts-chart', hostsCount); + } +} + +// 更新单个图表的数据 +function updateChartData(chartId, newValue) { + const chart = statCardCharts[chartId]; + const historyData = statCardHistoryData[chartId]; + + if (!chart || !historyData) { + return; + } + + // 添加新数据,移除最旧的数据 + historyData.push(newValue); + if (historyData.length > 12) { + historyData.shift(); + } + + // 更新图表数据 + chart.data.datasets[0].data = historyData; + chart.data.labels = generateTimeLabels(historyData.length); + + // 使用自定义动画配置更新图表,确保平滑过渡,避免空白区域 + chart.update({ + duration: 300, // 增加动画持续时间 + easing: 'easeInOutQuart', // 使用平滑的缓动函数 + transition: { + duration: 300, + easing: 'easeInOutQuart' + } + }); +} + +// 从统计数据中获取规则数 +function getRulesCountFromStats(stats) { + // 尝试从stats中获取规则数 + if (stats.shield && stats.shield.rules) { + return stats.shield.rules; + } + return null; +} + +// 从统计数据中获取排除规则数 +function getExceptionsCountFromStats(stats) { + // 尝试从stats中获取排除规则数 + if (stats.shield && stats.shield.exceptions) { + return stats.shield.exceptions; + } + return null; +} + +// 从统计数据中获取Hosts条目数 +function getHostsCountFromStats(stats) { + // 尝试从stats中获取Hosts条目数 + if (stats.shield && stats.shield.hosts) { + return stats.shield.hosts; + } + return null; +} + +// 初始化统计卡片折线图 +function initStatCardCharts() { + console.log('===== 开始初始化统计卡片折线图 ====='); + + // 清理已存在的图表实例 + for (const key in statCardCharts) { + if (statCardCharts.hasOwnProperty(key)) { + statCardCharts[key].destroy(); + } + } + statCardCharts = {}; + statCardHistoryData = {}; + + // 检查Chart.js是否加载 + console.log('Chart.js是否可用:', typeof Chart !== 'undefined'); + + // 统计卡片配置信息 + const cardConfigs = [ + { id: 'query-chart', color: '#9b59b6', label: '查询总量' }, + { id: 'blocked-chart', color: '#e74c3c', label: '屏蔽数量' }, + { id: 'allowed-chart', color: '#2ecc71', label: '正常解析' }, + { id: 'error-chart', color: '#f39c12', label: '错误数量' }, + { id: 'response-time-chart', color: '#3498db', label: '响应时间' }, + { id: 'ips-chart', color: '#1abc9c', label: '活跃IP' }, + { id: 'cpu-chart', color: '#e67e22', label: 'CPU使用率' }, + { id: 'rules-chart', color: '#95a5a6', label: '屏蔽规则数' }, + { id: 'exceptions-chart', color: '#34495e', label: '排除规则数' }, + { id: 'hosts-chart', color: '#16a085', label: 'Hosts条目数' } + ]; + + console.log('图表配置:', cardConfigs); + + cardConfigs.forEach(config => { + const canvas = document.getElementById(config.id); + if (!canvas) { + console.warn(`未找到统计卡片图表元素: ${config.id}`); + return; + } + + const ctx = canvas.getContext('2d'); + + // 为不同类型的卡片生成更合适的初始数据 + let initialData; + if (config.id === 'response-time-chart') { + // 响应时间图表使用空数组,将通过API实时数据更新 + initialData = Array(12).fill(null); + } else if (config.id === 'cpu-chart') { + initialData = generateMockData(12, 0, 10); + } else { + initialData = generateMockData(12, 0, 100); + } + + // 初始化历史数据数组 + statCardHistoryData[config.id] = [...initialData]; + + // 创建图表 + statCardCharts[config.id] = new Chart(ctx, { + type: 'line', + data: { + labels: generateTimeLabels(12), + datasets: [{ + label: config.label, + data: initialData, + borderColor: config.color, + backgroundColor: `${config.color}20`, // 透明度20% + borderWidth: 2, + tension: 0.4, + fill: true, + pointRadius: 0, // 隐藏数据点 + pointHoverRadius: 4, // 鼠标悬停时显示数据点 + pointBackgroundColor: config.color + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + // 添加动画配置,确保平滑过渡 + animation: { + duration: 800, + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + display: false + }, + tooltip: { + mode: 'index', + intersect: false, + backgroundColor: 'rgba(0, 0, 0, 0.9)', + titleColor: '#fff', + bodyColor: '#fff', + borderColor: config.color, + borderWidth: 1, + padding: 8, + displayColors: false, + cornerRadius: 4, + titleFont: { + size: 12, + weight: 'normal' + }, + bodyFont: { + size: 11 + }, + // 确保HTML渲染正确 + useHTML: true, + filter: function(tooltipItem) { + return tooltipItem.datasetIndex === 0; + }, + callbacks: { + title: function(tooltipItems) { + // 简化时间显示格式 + return tooltipItems[0].label; + }, + label: function(context) { + const value = context.parsed.y; + // 格式化大数字 + const formattedValue = formatNumber(value); + + // 使用CSS类显示变化趋势 + let trendInfo = ''; + const data = context.dataset.data; + const currentIndex = context.dataIndex; + + if (currentIndex > 0) { + const prevValue = data[currentIndex - 1]; + const change = value - prevValue; + + if (change !== 0) { + const changeSymbol = change > 0 ? '↑' : '↓'; + // 取消颜色显示,简化显示 + trendInfo = (changeSymbol + Math.abs(change)); + } + } + + // 简化标签格式 + return `${config.label}: ${formattedValue}${trendInfo}`; + }, + // 移除平均值显示 + afterLabel: function(context) { + return ''; + } + } + } + }, + scales: { + x: { + display: false // 隐藏X轴 + }, + y: { + display: false, // 隐藏Y轴 + beginAtZero: true + } + }, + interaction: { + intersect: false, + mode: 'index' + } + } + }); + }); +} + +// 生成模拟数据 +function generateMockData(count, min, max) { + const data = []; + for (let i = 0; i < count; i++) { + data.push(Math.floor(Math.random() * (max - min + 1)) + min); + } + return data; +} + +// 生成时间标签 +function generateTimeLabels(count) { + const labels = []; + const now = new Date(); + for (let i = count - 1; i >= 0; i--) { + const time = new Date(now.getTime() - i * 5 * 60 * 1000); // 每5分钟一个点 + labels.push(`${time.getHours().toString().padStart(2, '0')}:${time.getMinutes().toString().padStart(2, '0')}`); + } + return labels; +} + +// 格式化数字显示(使用K/M后缀) +function formatNumber(num) { + // 如果不是数字,直接返回 + if (isNaN(num) || num === '---') { + return num; + } + + // 显示完整数字的最大长度阈值 + const MAX_FULL_LENGTH = 5; + + // 先获取完整数字字符串 + const fullNumStr = num.toString(); + + // 如果数字长度小于等于阈值,直接返回完整数字 + if (fullNumStr.length <= MAX_FULL_LENGTH) { + return fullNumStr; + } + + // 否则使用缩写格式 + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + 'M'; + } else if (num >= 1000) { + return (num / 1000).toFixed(1) + 'K'; + } + + return fullNumStr; +} + +// 更新运行状态 +function updateUptime() { + // 实现更新运行时间的逻辑 + // 这里应该调用API获取当前运行时间并更新到UI + // 由于API暂时没有提供运行时间,我们先使用模拟数据 + const uptimeElement = document.getElementById('uptime'); + if (uptimeElement) { + uptimeElement.textContent = '---'; + } +} + +// 格式化数字(添加千位分隔符) +function formatWithCommas(num) { + return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + +// 格式化时间 +function formatTime(timestamp) { + const date = new Date(timestamp); + const now = new Date(); + const diff = now - date; + + // 如果是今天,显示时间 + if (date.toDateString() === now.toDateString()) { + return date.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'}); + } + + // 否则显示日期和时间 + return date.toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); +} + +// 根据颜色代码获取对应的CSS类名(兼容方式) +function getColorClassName(colorCode) { + // 优先使用配置文件中的颜色处理 + if (COLOR_CONFIG.getColorClassName) { + return COLOR_CONFIG.getColorClassName(colorCode); + } + + // 备用颜色映射 + const colorMap = { + '#1890ff': 'blue', + '#52c41a': 'green', + '#fa8c16': 'orange', + '#f5222d': 'red', + '#722ed1': 'purple', + '#13c2c2': 'cyan', + '#36cfc9': 'teal' + }; + + // 返回映射的类名,如果没有找到则返回默认的blue + return colorMap[colorCode] || 'blue'; +} + +// 显示通知 +function showNotification(message, type = 'info') { + // 移除已存在的通知 + const existingNotification = document.getElementById('notification'); + if (existingNotification) { + existingNotification.remove(); + } + + // 创建通知元素 + const notification = document.createElement('div'); + notification.id = 'notification'; + notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 transform transition-all duration-300 translate-y-0 opacity-0`; + + // 设置样式和内容 + let bgColor, textColor, icon; + switch (type) { + case 'success': + bgColor = 'bg-success'; + textColor = 'text-white'; + icon = 'fa-check-circle'; + break; + case 'error': + bgColor = 'bg-danger'; + textColor = 'text-white'; + icon = 'fa-exclamation-circle'; + break; + case 'warning': + bgColor = 'bg-warning'; + textColor = 'text-white'; + icon = 'fa-exclamation-triangle'; + break; + default: + bgColor = 'bg-primary'; + textColor = 'text-white'; + icon = 'fa-info-circle'; + } + + notification.className += ` ${bgColor} ${textColor}`; + notification.innerHTML = ` +
+ + ${message} +
+ `; + + // 添加到页面 + document.body.appendChild(notification); + + // 显示通知 + setTimeout(() => { + notification.classList.remove('translate-y-0', 'opacity-0'); + notification.classList.add('-translate-y-2', 'opacity-100'); + }, 10); + + // 自动关闭 + setTimeout(() => { + notification.classList.add('translate-y-0', 'opacity-0'); + setTimeout(() => { + notification.remove(); + }, 300); + }, 3000); +} + +// 页面切换处理 +function handlePageSwitch() { + const menuItems = document.querySelectorAll('nav a'); + + // 页面切换逻辑 + function switchPage(targetId, menuItem) { + // 隐藏所有内容 + document.querySelectorAll('[id$="-content"]').forEach(content => { + content.classList.add('hidden'); + }); + + // 显示目标内容 + document.getElementById(`${targetId}-content`).classList.remove('hidden'); + + // 更新页面标题 + document.getElementById('page-title').textContent = menuItem.querySelector('span').textContent; + + // 更新活动菜单项 + menuItems.forEach(item => { + item.classList.remove('sidebar-item-active'); + }); + menuItem.classList.add('sidebar-item-active'); + + // 侧边栏切换(移动端) + if (window.innerWidth < 1024) { + toggleSidebar(); + } + } + + menuItems.forEach(item => { + item.addEventListener('click', (e) => { + // 允许默认的hash变化 + // 页面切换会由hashchange事件处理 + }); + }); +} + +// 处理hash变化 - 全局函数,确保在页面加载时就能被调用 +function handleHashChange() { + let hash = window.location.hash; + + // 如果没有hash,默认设置为#dashboard + if (!hash) { + hash = '#dashboard'; + window.location.hash = hash; + return; + } + + const targetId = hash.substring(1); + const menuItems = document.querySelectorAll('nav a'); + + // 首先检查是否存在对应的内容元素 + const contentElement = document.getElementById(`${targetId}-content`); + + // 查找对应的菜单项 + let targetMenuItem = null; + menuItems.forEach(item => { + if (item.getAttribute('href') === hash) { + targetMenuItem = item; + } + }); + + // 如果找到了对应的内容元素,直接显示 + if (contentElement) { + // 隐藏所有内容 + document.querySelectorAll('[id$="-content"]').forEach(content => { + content.classList.add('hidden'); + }); + + // 显示目标内容 + contentElement.classList.remove('hidden'); + + // 更新活动菜单项和页面标题 + menuItems.forEach(item => { + item.classList.remove('sidebar-item-active'); + }); + + if (targetMenuItem) { + targetMenuItem.classList.add('sidebar-item-active'); + // 更新页面标题 + const pageTitle = targetMenuItem.querySelector('span').textContent; + document.getElementById('page-title').textContent = pageTitle; + } else { + // 如果没有找到对应的菜单项,尝试根据hash更新页面标题 + const titleElement = document.getElementById(`${targetId}-title`); + if (titleElement) { + document.getElementById('page-title').textContent = titleElement.textContent; + } + } + } else if (targetMenuItem) { + // 隐藏所有内容 + document.querySelectorAll('[id$="-content"]').forEach(content => { + content.classList.add('hidden'); + }); + + // 显示目标内容 + document.getElementById(`${targetId}-content`).classList.remove('hidden'); + + // 更新页面标题 + document.getElementById('page-title').textContent = targetMenuItem.querySelector('span').textContent; + + // 更新活动菜单项 + menuItems.forEach(item => { + item.classList.remove('sidebar-item-active'); + }); + targetMenuItem.classList.add('sidebar-item-active'); + + // 侧边栏切换(移动端) + if (window.innerWidth < 1024) { + toggleSidebar(); + } + } else { + // 如果既没有找到内容元素也没有找到菜单项,默认显示dashboard + window.location.hash = '#dashboard'; + } +} + +// 初始化hash路由 +function initHashRoute() { + handleHashChange(); +} + +// 监听hash变化事件 - 全局事件监听器 +window.addEventListener('hashchange', handleHashChange); + +// 初始化hash路由 - 确保在页面加载时就能被调用 +initHashRoute(); + +// 侧边栏切换 +function toggleSidebar() { + const sidebar = document.getElementById('sidebar'); + sidebar.classList.toggle('-translate-x-full'); +} + +// 响应式处理 +function handleResponsive() { + const toggleBtn = document.getElementById('toggle-sidebar'); + const sidebar = document.getElementById('sidebar'); + + toggleBtn.addEventListener('click', toggleSidebar); + + // 初始状态处理 + function updateSidebarState() { + if (window.innerWidth < 1024) { + sidebar.classList.add('-translate-x-full'); + } else { + sidebar.classList.remove('-translate-x-full'); + } + } + + updateSidebarState(); + + // 窗口大小改变时处理 + window.addEventListener('resize', () => { + updateSidebarState(); + + // 更新所有图表大小 + if (dnsRequestsChart) { + dnsRequestsChart.update(); + } + if (ratioChart) { + ratioChart.update(); + } + if (queryTypeChart) { + queryTypeChart.update(); + } + if (detailedDnsRequestsChart) { + detailedDnsRequestsChart.update(); + } + // 更新统计卡片图表 + Object.values(statCardCharts).forEach(chart => { + if (chart) { + chart.update(); + } + }); + }); + + // 添加触摸事件支持,用于移动端 + let touchStartX = 0; + let touchEndX = 0; + + document.addEventListener('touchstart', (e) => { + touchStartX = e.changedTouches[0].screenX; + }, false); + + document.addEventListener('touchend', (e) => { + touchEndX = e.changedTouches[0].screenX; + handleSwipe(); + }, false); + + function handleSwipe() { + // 从左向右滑动,打开侧边栏 + if (touchEndX - touchStartX > 50 && window.innerWidth < 1024) { + sidebar.classList.remove('-translate-x-full'); + } + // 从右向左滑动,关闭侧边栏 + if (touchStartX - touchEndX > 50 && window.innerWidth < 1024) { + sidebar.classList.add('-translate-x-full'); + } + } +} + +// 添加重试功能 +function addRetryEventListeners() { + // TOP客户端重试按钮 + const retryTopClientsBtn = document.getElementById('retry-top-clients'); + if (retryTopClientsBtn) { + retryTopClientsBtn.addEventListener('click', async () => { + console.log('重试获取TOP客户端数据'); + const clientsData = await api.getTopClients(); + if (clientsData && !clientsData.error && Array.isArray(clientsData) && clientsData.length > 0) { + // 使用真实数据 + updateTopClientsTable(clientsData); + hideLoading('top-clients'); + const errorElement = document.getElementById('top-clients-error'); + if (errorElement) errorElement.classList.add('hidden'); + } else { + // 重试失败,保持原有状态 + console.warn('重试获取TOP客户端数据失败'); + } + }); + } + + // TOP域名重试按钮 + const retryTopDomainsBtn = document.getElementById('retry-top-domains'); + if (retryTopDomainsBtn) { + retryTopDomainsBtn.addEventListener('click', async () => { + console.log('重试获取TOP域名数据'); + const domainsData = await api.getTopDomains(); + if (domainsData && !domainsData.error && Array.isArray(domainsData) && domainsData.length > 0) { + // 使用真实数据 + updateTopDomainsTable(domainsData); + hideLoading('top-domains'); + const errorElement = document.getElementById('top-domains-error'); + if (errorElement) errorElement.classList.add('hidden'); + } else { + // 重试失败,保持原有状态 + console.warn('重试获取TOP域名数据失败'); + } + }); + } +} + +// 页面加载完成后初始化 +window.addEventListener('DOMContentLoaded', () => { + // 初始化页面切换 + handlePageSwitch(); + + // 初始化响应式 + handleResponsive(); + + // 初始化仪表盘 + initDashboard(); + + // 添加重试事件监听器 + addRetryEventListeners(); + + // 页面卸载时清理定时器 + window.addEventListener('beforeunload', () => { + if (intervalId) { + clearInterval(intervalId); + } + }); +}); \ No newline at end of file diff --git a/js/hosts.js b/js/hosts.js new file mode 100644 index 0000000..a8a839d --- /dev/null +++ b/js/hosts.js @@ -0,0 +1,202 @@ +// Hosts管理页面功能实现 + +// 初始化Hosts管理页面 +function initHostsPage() { + // 加载Hosts规则 + loadHostsRules(); + // 设置事件监听器 + setupHostsEventListeners(); +} + +// 加载Hosts规则 +async function loadHostsRules() { + try { + const response = await fetch('/api/shield/hosts'); + if (!response.ok) { + throw new Error('Failed to load hosts rules'); + } + const data = await response.json(); + + // 处理API返回的数据格式 + let hostsRules = []; + if (data && Array.isArray(data)) { + // 直接是数组格式 + hostsRules = data; + } else if (data && data.hosts) { + // 包含在hosts字段中 + hostsRules = data.hosts; + } + + updateHostsTable(hostsRules); + } catch (error) { + console.error('Error loading hosts rules:', error); + showErrorMessage('加载Hosts规则失败'); + } +} + +// 更新Hosts表格 +function updateHostsTable(hostsRules) { + const tbody = document.getElementById('hosts-table-body'); + + if (hostsRules.length === 0) { + tbody.innerHTML = '暂无Hosts条目'; + return; + } + + tbody.innerHTML = hostsRules.map(rule => { + // 处理对象格式的规则 + const ip = rule.ip || ''; + const domain = rule.domain || ''; + + return ` + + ${ip} + ${domain} + + + + + `; + }).join(''); + + // 重新绑定删除事件 + document.querySelectorAll('.delete-hosts-btn').forEach(btn => { + btn.addEventListener('click', handleDeleteHostsRule); + }); +} + +// 设置事件监听器 +function setupHostsEventListeners() { + // 保存Hosts按钮 + document.getElementById('save-hosts-btn').addEventListener('click', handleAddHostsRule); +} + +// 处理添加Hosts规则 +async function handleAddHostsRule() { + const ip = document.getElementById('hosts-ip').value.trim(); + const domain = document.getElementById('hosts-domain').value.trim(); + + if (!ip || !domain) { + showErrorMessage('IP地址和域名不能为空'); + return; + } + + try { + const response = await fetch('/api/shield/hosts', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ ip, domain }) + }); + + if (!response.ok) { + throw new Error('Failed to add hosts rule'); + } + + showSuccessMessage('Hosts规则添加成功'); + + // 清空输入框 + document.getElementById('hosts-ip').value = ''; + document.getElementById('hosts-domain').value = ''; + + // 重新加载规则 + loadHostsRules(); + } catch (error) { + console.error('Error adding hosts rule:', error); + showErrorMessage('添加Hosts规则失败'); + } +} + +// 处理删除Hosts规则 +async function handleDeleteHostsRule(e) { + const ip = e.target.closest('.delete-hosts-btn').dataset.ip; + const domain = e.target.closest('.delete-hosts-btn').dataset.domain; + + try { + const response = await fetch('/api/shield/hosts', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ domain }) + }); + + if (!response.ok) { + throw new Error('Failed to delete hosts rule'); + } + + showSuccessMessage('Hosts规则删除成功'); + + // 重新加载规则 + loadHostsRules(); + } catch (error) { + console.error('Error deleting hosts rule:', error); + showErrorMessage('删除Hosts规则失败'); + } +} + +// 显示成功消息 +function showSuccessMessage(message) { + showNotification(message, 'success'); +} + +// 显示错误消息 +function showErrorMessage(message) { + showNotification(message, 'error'); +} + + + +// 显示通知 +function showNotification(message, type = 'info') { + // 移除现有通知 + const existingNotification = document.querySelector('.notification'); + if (existingNotification) { + existingNotification.remove(); + } + + // 创建新通知 + const notification = document.createElement('div'); + notification.className = `notification fixed bottom-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 transform transition-all duration-300 ease-in-out translate-y-0 opacity-0`; + + // 设置通知样式 + if (type === 'success') { + notification.classList.add('bg-green-500', 'text-white'); + } else if (type === 'error') { + notification.classList.add('bg-red-500', 'text-white'); + } else { + notification.classList.add('bg-blue-500', 'text-white'); + } + + notification.innerHTML = ` +
+ + ${message} +
+ `; + + document.body.appendChild(notification); + + // 显示通知 + setTimeout(() => { + notification.classList.remove('opacity-0'); + }, 100); + + // 3秒后隐藏通知 + setTimeout(() => { + notification.classList.add('opacity-0'); + setTimeout(() => { + notification.remove(); + }, 300); + }, 3000); +} + +// 页面加载完成后初始化 +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initHostsPage); +} else { + initHostsPage(); +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..2f6bb6e --- /dev/null +++ b/js/main.js @@ -0,0 +1,173 @@ +// main.js - 主脚本文件 + +// 页面导航功能 +function setupNavigation() { + // 侧边栏菜单项 + const menuItems = document.querySelectorAll('nav a'); + const contentSections = [ + document.getElementById('dashboard-content'), + document.getElementById('shield-content'), + document.getElementById('hosts-content'), + document.getElementById('query-content'), + document.getElementById('config-content') + ]; + const pageTitle = document.getElementById('page-title'); + + menuItems.forEach((item, index) => { + item.addEventListener('click', (e) => { + // 允许浏览器自动更新地址栏中的hash,不阻止默认行为 + + // 移动端点击菜单项后自动关闭侧边栏 + if (window.innerWidth < 768) { + closeSidebar(); + } + + // 页面特定初始化 - 保留这部分逻辑,因为它不会与hashchange事件处理逻辑冲突 + const target = item.getAttribute('href').substring(1); + if (target === 'shield' && typeof initShieldPage === 'function') { + initShieldPage(); + } else if (target === 'hosts' && typeof initHostsPage === 'function') { + initHostsPage(); + } + }); + }); + + // 移动端侧边栏切换 + const toggleSidebar = document.getElementById('toggle-sidebar'); + const closeSidebarBtn = document.getElementById('close-sidebar'); + const sidebar = document.getElementById('sidebar'); + const sidebarOverlay = document.getElementById('sidebar-overlay'); + + // 打开侧边栏函数 + function openSidebar() { + console.log('Opening sidebar...'); + if (sidebar) { + sidebar.classList.remove('-translate-x-full'); + sidebar.classList.add('translate-x-0'); + } + if (sidebarOverlay) { + sidebarOverlay.classList.remove('hidden'); + sidebarOverlay.classList.add('block'); + } + // 防止页面滚动 + document.body.style.overflow = 'hidden'; + console.log('Sidebar opened successfully'); + } + + // 关闭侧边栏函数 + function closeSidebar() { + console.log('Closing sidebar...'); + if (sidebar) { + sidebar.classList.add('-translate-x-full'); + sidebar.classList.remove('translate-x-0'); + } + if (sidebarOverlay) { + sidebarOverlay.classList.add('hidden'); + sidebarOverlay.classList.remove('block'); + } + // 恢复页面滚动 + document.body.style.overflow = ''; + console.log('Sidebar closed successfully'); + } + + // 切换侧边栏函数 + function toggleSidebarVisibility() { + console.log('Toggling sidebar visibility...'); + console.log('Current sidebar classes:', sidebar ? sidebar.className : 'sidebar not found'); + if (sidebar && sidebar.classList.contains('-translate-x-full')) { + console.log('Sidebar is hidden, opening...'); + openSidebar(); + } else { + console.log('Sidebar is visible, closing...'); + closeSidebar(); + } + } + + // 绑定切换按钮事件 + if (toggleSidebar) { + toggleSidebar.addEventListener('click', toggleSidebarVisibility); + } + + // 绑定关闭按钮事件 + if (closeSidebarBtn) { + closeSidebarBtn.addEventListener('click', closeSidebar); + } + + // 绑定遮罩层点击事件 + if (sidebarOverlay) { + sidebarOverlay.addEventListener('click', closeSidebar); + } + + // 移动端点击菜单项后自动关闭侧边栏 + menuItems.forEach(item => { + item.addEventListener('click', () => { + // 检查是否是移动设备视图 + if (window.innerWidth < 768) { + closeSidebar(); + } + }); + }); + + // 添加键盘事件监听,按ESC键关闭侧边栏 + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeSidebar(); + } + }); +} + +// 初始化函数 +function init() { + // 设置导航 + setupNavigation(); + + // 加载仪表盘数据 + if (typeof loadDashboardData === 'function') { + loadDashboardData(); + } + + // 定期更新系统状态 + setInterval(updateSystemStatus, 5000); +} + +// 更新系统状态 +function updateSystemStatus() { + fetch('/api/status') + .then(response => response.json()) + .then(data => { + const uptimeElement = document.getElementById('uptime'); + if (uptimeElement) { + uptimeElement.textContent = `正常运行中 | ${formatUptime(data.uptime)}`; + } + }) + .catch(error => { + console.error('更新系统状态失败:', error); + const uptimeElement = document.getElementById('uptime'); + if (uptimeElement) { + uptimeElement.textContent = '连接异常'; + uptimeElement.classList.add('text-danger'); + } + }); +} + +// 格式化运行时间 +function formatUptime(milliseconds) { + // 简化版的格式化,实际使用时需要根据API返回的数据格式调整 + const seconds = Math.floor(milliseconds / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `${days}天${hours % 24}小时`; + } else if (hours > 0) { + return `${hours}小时${minutes % 60}分钟`; + } else if (minutes > 0) { + return `${minutes}分钟${seconds % 60}秒`; + } else { + return `${seconds}秒`; + } +} + +// 页面加载完成后执行初始化 +window.addEventListener('DOMContentLoaded', init); \ No newline at end of file diff --git a/js/modules/blacklists.js b/js/modules/blacklists.js new file mode 100644 index 0000000..2f1cfb1 --- /dev/null +++ b/js/modules/blacklists.js @@ -0,0 +1,255 @@ +// 初始化远程黑名单面板 +function initBlacklistsPanel() { + // 加载远程黑名单列表 + loadBlacklists(); + + // 初始化事件监听器 + initBlacklistsEventListeners(); +} + +// 初始化事件监听器 +function initBlacklistsEventListeners() { + // 添加黑名单按钮 + document.getElementById('add-blacklist').addEventListener('click', addBlacklist); + + // 更新所有黑名单按钮 + document.getElementById('update-all-blacklists').addEventListener('click', updateAllBlacklists); + + // 按Enter键添加黑名单 + document.getElementById('blacklist-url').addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + addBlacklist(); + } + }); +} + +// 加载远程黑名单列表 +function loadBlacklists() { + const tbody = document.getElementById('blacklists-table').querySelector('tbody'); + showLoading(tbody); + + apiRequest('/api/shield/blacklists') + .then(data => { + // 直接渲染返回的blacklists数组 + renderBlacklists(data); + }) + .catch(error => { + console.error('获取远程黑名单列表失败:', error); + showError(tbody, '获取远程黑名单列表失败'); + window.showNotification('获取远程黑名单列表失败', 'error'); + }); +} + +// 渲染远程黑名单表格 +function renderBlacklists(blacklists) { + const tbody = document.getElementById('blacklists-table').querySelector('tbody'); + if (!tbody) return; + + if (!blacklists || blacklists.length === 0) { + showEmpty(tbody, '暂无远程黑名单'); + return; + } + + tbody.innerHTML = ''; + + blacklists.forEach(list => { + addBlacklistToTable(list); + }); + + // 初始化表格排序 + initTableSort('blacklists-table'); + + // 初始化操作按钮监听器 + initBlacklistsActionListeners(); +} + +// 添加黑名单到表格 +function addBlacklistToTable(list) { + const tbody = document.getElementById('blacklists-table').querySelector('tbody'); + const row = document.createElement('tr'); + + const statusClass = list.status === 'success' ? 'status-success' : + list.status === 'error' ? 'status-error' : 'status-pending'; + + const statusText = list.status === 'success' ? '正常' : + list.status === 'error' ? '错误' : '等待中'; + + const lastUpdate = list.lastUpdate ? new Date(list.lastUpdate).toLocaleString() : '从未'; + + row.innerHTML = ` + ${list.name} + ${list.url} + + ${statusText} + + ${list.rulesCount || 0} + ${lastUpdate} + + + + + `; + + tbody.appendChild(row); +} + +// 添加远程黑名单 +function addBlacklist() { + const nameInput = document.getElementById('blacklist-name'); + const urlInput = document.getElementById('blacklist-url'); + + const name = nameInput.value.trim(); + const url = urlInput.value.trim(); + + if (!name) { + window.showNotification('请输入黑名单名称', 'warning'); + nameInput.focus(); + return; + } + + if (!url) { + window.showNotification('请输入黑名单URL', 'warning'); + urlInput.focus(); + return; + } + + // 简单的URL格式验证 + if (!isValidUrl(url)) { + window.showNotification('请输入有效的URL', 'warning'); + urlInput.focus(); + return; + } + + apiRequest('/api/shield/blacklists', 'POST', { name: name, url: url }) + .then(data => { + // 检查响应中是否有status字段 + if (!data || typeof data === 'undefined') { + window.showNotification('远程黑名单添加失败: 无效的响应', 'error'); + return; + } + + if (data.status === 'success') { + window.showNotification('远程黑名单添加成功', 'success'); + nameInput.value = ''; + urlInput.value = ''; + loadBlacklists(); + } else { + window.showNotification(`添加失败: ${data.message || '未知错误'}`, 'error'); + } + }) + .catch(error => { + console.error('添加远程黑名单失败:', error); + window.showNotification('添加远程黑名单失败', 'error'); + }); +} + +// 更新远程黑名单 +function updateBlacklist(id) { + apiRequest(`/api/shield/blacklists/${id}/update`, 'POST') + .then(data => { + // 检查响应中是否有status字段 + if (!data || typeof data === 'undefined') { + window.showNotification('远程黑名单更新失败: 无效的响应', 'error'); + return; + } + + if (data.status === 'success') { + window.showNotification('远程黑名单更新成功', 'success'); + loadBlacklists(); + } else { + window.showNotification(`更新失败: ${data.message || '未知错误'}`, 'error'); + } + }) + .catch(error => { + console.error('更新远程黑名单失败:', error); + window.showNotification('更新远程黑名单失败', 'error'); + }); +} + +// 更新所有远程黑名单 +function updateAllBlacklists() { + confirmAction( + '确定要更新所有远程黑名单吗?这可能需要一些时间。', + () => { + apiRequest('/api/shield/blacklists', 'PUT') + .then(data => { + // 检查响应中是否有status字段 + if (!data || typeof data === 'undefined') { + window.showNotification('所有远程黑名单更新失败: 无效的响应', 'error'); + return; + } + + if (data.status === 'success') { + window.showNotification('所有远程黑名单更新成功', 'success'); + loadBlacklists(); + } else { + window.showNotification(`更新失败: ${data.message || '未知错误'}`, 'error'); + } + }) + .catch(error => { + console.error('更新所有远程黑名单失败:', error); + window.showNotification('更新所有远程黑名单失败', 'error'); + }); + } + ); +} + +// 删除远程黑名单 +function deleteBlacklist(id) { + apiRequest(`/api/shield/blacklists/${id}`, 'DELETE') + .then(data => { + // 检查响应中是否有status字段 + if (!data || typeof data === 'undefined') { + window.showNotification('远程黑名单删除失败: 无效的响应', 'error'); + return; + } + + if (data.status === 'success') { + window.showNotification('远程黑名单删除成功', 'success'); + loadBlacklists(); + } else { + window.showNotification(`删除失败: ${data.message || '未知错误'}`, 'error'); + } + }) + .catch(error => { + console.error('删除远程黑名单失败:', error); + window.showNotification('删除远程黑名单失败', 'error'); + }); +} + +// 为操作按钮添加事件监听器 +function initBlacklistsActionListeners() { + // 更新按钮 + document.querySelectorAll('.update-blacklist').forEach(button => { + button.addEventListener('click', function() { + const id = this.getAttribute('data-id'); + updateBlacklist(id); + }); + }); + + // 删除按钮 + document.querySelectorAll('.delete-blacklist').forEach(button => { + button.addEventListener('click', function() { + const id = this.getAttribute('data-id'); + + confirmAction( + '确定要删除这条远程黑名单吗?', + () => deleteBlacklist(id) + ); + }); + }); +} + +// 验证URL格式 +function isValidUrl(url) { + try { + new URL(url); + return true; + } catch (e) { + return false; + } +} \ No newline at end of file diff --git a/js/modules/config.js b/js/modules/config.js new file mode 100644 index 0000000..3d4e5db --- /dev/null +++ b/js/modules/config.js @@ -0,0 +1,125 @@ +// 初始化配置管理面板 +function initConfigPanel() { + // 加载当前配置 + loadConfig(); + + // 初始化事件监听器 + initConfigEventListeners(); +} + +// 初始化事件监听器 +function initConfigEventListeners() { + // 保存配置按钮 + document.getElementById('save-config').addEventListener('click', saveConfig); + + // 屏蔽方法变更 + document.getElementById('block-method').addEventListener('change', updateCustomBlockIpVisibility); +} + +// 加载当前配置 +function loadConfig() { + apiRequest('/config') + .then(config => { + renderConfig(config); + }) + .catch(error => { + console.error('获取配置失败:', error); + window.showNotification('获取配置失败', 'error'); + }); +} + +// 渲染配置表单 +function renderConfig(config) { + if (!config) return; + + // 设置屏蔽方法 + const blockMethodSelect = document.getElementById('block-method'); + if (config.shield && config.shield.blockMethod) { + blockMethodSelect.value = config.shield.blockMethod; + } + + // 设置自定义屏蔽IP + const customBlockIpInput = document.getElementById('custom-block-ip'); + if (config.shield && config.shield.customBlockIP) { + customBlockIpInput.value = config.shield.customBlockIP; + } + + // 设置远程规则更新间隔 + const updateIntervalInput = document.getElementById('update-interval'); + if (config.shield && config.shield.updateInterval) { + updateIntervalInput.value = config.shield.updateInterval; + } + + // 更新自定义屏蔽IP的可见性 + updateCustomBlockIpVisibility(); +} + +// 更新自定义屏蔽IP输入框的可见性 +function updateCustomBlockIpVisibility() { + const blockMethod = document.getElementById('block-method').value; + const customBlockIpContainer = document.getElementById('custom-block-ip').closest('.form-group'); + + if (blockMethod === 'customIP') { + customBlockIpContainer.style.display = 'block'; + } else { + customBlockIpContainer.style.display = 'none'; + } +} + +// 保存配置 +function saveConfig() { + // 收集表单数据 + const configData = { + shield: { + blockMethod: document.getElementById('block-method').value, + updateInterval: parseInt(document.getElementById('update-interval').value) + } + }; + + // 如果选择了自定义IP,添加到配置中 + if (configData.shield.blockMethod === 'customIP') { + const customBlockIp = document.getElementById('custom-block-ip').value.trim(); + + // 验证自定义IP格式 + if (!isValidIp(customBlockIp)) { + window.showNotification('请输入有效的自定义屏蔽IP', 'warning'); + return; + } + + configData.shield.customBlockIP = customBlockIp; + } + + // 验证更新间隔 + if (isNaN(configData.shield.updateInterval) || configData.shield.updateInterval < 60) { + window.showNotification('更新间隔必须大于等于60秒', 'warning'); + return; + } + + // 保存配置 + apiRequest('/config', 'PUT', configData) + .then(response => { + if (response.success) { + window.showNotification('配置保存成功', 'success'); + + // 由于服务器没有提供重启API,移除重启提示 + // 直接提示用户配置已保存 + } else { + window.showNotification(`保存失败: ${response.message || '未知错误'}`, 'error'); + } + }) + .catch(error => { + console.error('保存配置失败:', error); + window.showNotification('保存配置失败', 'error'); + }); +} + +// 服务重启功能已移除,因为服务器没有提供对应的API端点 + +// 验证IP地址格式 +function isValidIp(ip) { + // 支持IPv4和IPv6简单验证 + const ipv4Regex = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/; + const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}$/; + + return ipv4Regex.test(ip) || ipv6Regex.test(ip); +} \ No newline at end of file diff --git a/js/modules/dashboard.js b/js/modules/dashboard.js new file mode 100644 index 0000000..5a15e09 --- /dev/null +++ b/js/modules/dashboard.js @@ -0,0 +1,1220 @@ +// 全局变量 +let domainDataCache = { + blocked: null, + resolved: null +}; +let domainUpdateTimer = null; +const DOMAIN_UPDATE_INTERVAL = 5000; // 域名排行更新间隔,设为5秒,比统计数据更新慢一些 + +// 初始化小型图表 - 修复Canvas重用问题 +function initMiniCharts() { + // 获取所有图表容器 + const chartContainers = document.querySelectorAll('.chart-card canvas'); + + // 全局图表实例存储 + window.chartInstances = window.chartInstances || {}; + + chartContainers.forEach(canvas => { + // 获取图表数据属性 + const chartId = canvas.id; + const chartType = canvas.dataset.chartType || 'line'; + const chartData = JSON.parse(canvas.dataset.chartData || '{}'); + + // 设置图表上下文 + const ctx = canvas.getContext('2d'); + + // 销毁已存在的图表实例,避免Canvas重用错误 + if (window.chartInstances[chartId]) { + window.chartInstances[chartId].destroy(); + } + + // 创建新图表 + window.chartInstances[chartId] = new Chart(ctx, { + type: chartType, + data: chartData, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false + }, + tooltip: { + backgroundColor: 'rgba(0, 0, 0, 0.7)', + padding: 10, + cornerRadius: 4 + } + }, + scales: { + x: { + grid: { + display: false + } + }, + y: { + beginAtZero: true, + grid: { + color: 'rgba(0, 0, 0, 0.05)' + } + } + }, + animation: { + duration: 1000, + easing: 'easeOutQuart' + } + } + }); + }); +} + +// 初始化仪表盘面板 +function initDashboardPanel() { + // 初始化小型图表 + if (typeof initMiniCharts === 'function') { + initMiniCharts(); + } + // 加载统计数据 + loadDashboardData(); + // 启动实时更新 + if (typeof startRealTimeUpdate === 'function') { + startRealTimeUpdate(); + } + // 启动域名排行的独立更新 + startDomainUpdate(); + + // 初始化响应式侧边栏 + initResponsiveSidebar(); +} + +// 初始化响应式侧边栏 +function initResponsiveSidebar() { + // 创建侧边栏切换按钮 + const toggleBtn = document.createElement('button'); + toggleBtn.className = 'sidebar-toggle'; + toggleBtn.innerHTML = ''; + document.body.appendChild(toggleBtn); + + // 侧边栏切换逻辑 + toggleBtn.addEventListener('click', function() { + const sidebar = document.querySelector('.sidebar'); + sidebar.classList.toggle('open'); + + // 更新按钮图标 + const icon = toggleBtn.querySelector('i'); + if (sidebar.classList.contains('open')) { + icon.className = 'fas fa-times'; + } else { + icon.className = 'fas fa-bars'; + } + }); + + // 在侧边栏打开时点击内容区域关闭侧边栏 + const content = document.querySelector('.content'); + content.addEventListener('click', function() { + const sidebar = document.querySelector('.sidebar'); + const toggleBtn = document.querySelector('.sidebar-toggle'); + if (sidebar.classList.contains('open') && window.innerWidth <= 768) { + sidebar.classList.remove('open'); + if (toggleBtn) { + const icon = toggleBtn.querySelector('i'); + icon.className = 'fas fa-bars'; + } + } + }); + + // 窗口大小变化时调整侧边栏状态 + window.addEventListener('resize', function() { + const sidebar = document.querySelector('.sidebar'); + const toggleBtn = document.querySelector('.sidebar-toggle'); + + if (window.innerWidth > 768) { + sidebar.classList.remove('open'); + if (toggleBtn) { + const icon = toggleBtn.querySelector('i'); + icon.className = 'fas fa-bars'; + } + } + }); +} + +// 加载仪表盘数据 +function loadDashboardData() { + // 加载统计卡片数据 + updateStatCards(); + + // 首次加载时获取域名排行数据 + if (!domainDataCache.blocked) { + loadTopBlockedDomains(); + } + if (!domainDataCache.resolved) { + loadTopResolvedDomains(); + } +} + +// 启动域名排行的独立更新 +function startDomainUpdate() { + if (domainUpdateTimer) { + clearInterval(domainUpdateTimer); + } + + // 立即执行一次更新 + updateDomainRankings(); + + // 设置定时器 + domainUpdateTimer = setInterval(() => { + // 仅当当前面板是仪表盘时更新数据 + if (document.getElementById('dashboard') && document.getElementById('dashboard').classList.contains('active')) { + updateDomainRankings(); + } + }, DOMAIN_UPDATE_INTERVAL); +} + +// 停止域名排行更新 +function stopDomainUpdate() { + if (domainUpdateTimer) { + clearInterval(domainUpdateTimer); + domainUpdateTimer = null; + } +} + +// 更新域名排行数据 +function updateDomainRankings() { + // 使用Promise.all并行加载,提高效率 + Promise.all([ + loadTopBlockedDomains(true), + loadTopResolvedDomains(true) + ]).catch(error => { + console.error('更新域名排行数据失败:', error); + }); +} + +// 更新统计卡片数据 +function updateStatCards() { + // 获取所有统计数据 + apiRequest('/api/stats') + .then(data => { + // 更新请求统计 + if (data && data.dns) { + // 屏蔽请求 + const blockedCount = data.dns.Blocked || data.dns.blocked || 0; + smoothUpdateStatCard('blocked-count', blockedCount); + + // 允许请求 + const allowedCount = data.dns.Allowed || data.dns.allowed || 0; + smoothUpdateStatCard('allowed-count', allowedCount); + + // 错误请求 + const errorCount = data.dns.Errors || data.dns.errors || 0; + smoothUpdateStatCard('error-count', errorCount); + + // 总请求数 + const totalCount = blockedCount + allowedCount + errorCount; + smoothUpdateStatCard('total-queries', totalCount); + + // 更新数据历史记录和小型图表 + if (typeof updateDataHistory === 'function') { + updateDataHistory('blocked', blockedCount); + updateDataHistory('query', totalCount); + } + + // 更新小型图表 + if (typeof updateMiniChart === 'function' && typeof dataHistory !== 'undefined') { + updateMiniChart('blocked-chart', dataHistory.blocked); + updateMiniChart('query-chart', dataHistory.query); + } + } else { + // 处理其他可能的数据格式 + const blockedValue = data && (data.Blocked !== undefined ? data.Blocked : (data.blocked !== undefined ? data.blocked : 0)); + const allowedValue = data && (data.Allowed !== undefined ? data.Allowed : (data.allowed !== undefined ? data.allowed : 0)); + const errorValue = data && (data.Errors !== undefined ? data.Errors : (data.errors !== undefined ? data.errors : 0)); + smoothUpdateStatCard('blocked-count', blockedValue); + smoothUpdateStatCard('allowed-count', allowedValue); + smoothUpdateStatCard('error-count', errorValue); + const totalCount = blockedValue + allowedValue + errorValue; + smoothUpdateStatCard('total-queries', totalCount); + } + }) + .catch(error => { + console.error('获取统计数据失败:', error); + }); + + // 获取规则数 + apiRequest('/api/shield') + .then(data => { + let rulesCount = 0; + + // 增强的数据格式处理,确保能正确处理各种返回格式 + if (Array.isArray(data)) { + rulesCount = data.length; + } else if (data && data.rules && Array.isArray(data.rules)) { + rulesCount = data.rules.length; + } else if (data && data.domainRules) { + // 处理可能的规则分类格式 + let domainRulesCount = 0; + let regexRulesCount = 0; + + if (Array.isArray(data.domainRules)) { + domainRulesCount = data.domainRules.length; + } else if (typeof data.domainRules === 'object') { + domainRulesCount = Object.keys(data.domainRules).length; + } + + if (data.regexRules && Array.isArray(data.regexRules)) { + regexRulesCount = data.regexRules.length; + } + + rulesCount = domainRulesCount + regexRulesCount; + } + + // 确保至少显示0而不是-- + smoothUpdateStatCard('rules-count', rulesCount); + + // 更新数据历史记录和小型图表 + if (typeof updateDataHistory === 'function') { + updateDataHistory('rules', rulesCount); + } + + if (typeof updateMiniChart === 'function' && typeof dataHistory !== 'undefined') { + updateMiniChart('rules-chart', dataHistory.rules); + } + }) + .catch(error => { + console.error('获取规则数失败:', error); + // 即使出错也要设置为0,避免显示-- + smoothUpdateStatCard('rules-count', 0); + }); + + // 获取Hosts条目数量 + apiRequest('/api/shield/hosts') + .then(data => { + let hostsCount = 0; + + // 处理各种可能的数据格式 + if (Array.isArray(data)) { + hostsCount = data.length; + } else if (data && data.hosts && Array.isArray(data.hosts)) { + hostsCount = data.hosts.length; + } else if (data && typeof data === 'object' && data !== null) { + // 如果是对象格式,计算键的数量 + hostsCount = Object.keys(data).length; + } + + // 确保至少显示0而不是-- + smoothUpdateStatCard('hosts-count', hostsCount); + + // 更新数据历史记录和小型图表 + if (typeof updateDataHistory === 'function') { + updateDataHistory('hosts', hostsCount); + } + + if (typeof updateMiniChart === 'function' && typeof dataHistory !== 'undefined') { + updateMiniChart('hosts-chart', dataHistory.hosts); + } + }) + .catch(error => { + console.error('获取Hosts数量失败:', error); + // 即使出错也要设置为0,避免显示-- + smoothUpdateStatCard('hosts-count', 0); + }); + + // 获取Hosts条目数 + apiRequest('/api/shield/hosts') + .then(data => { + let hostsCount = 0; + if (Array.isArray(data)) { + hostsCount = data.length; + } else if (data && data.hosts && Array.isArray(data.hosts)) { + hostsCount = data.hosts.length; + } + + smoothUpdateStatCard('hosts-count', hostsCount); + + // 更新数据历史记录和小型图表 + if (typeof updateDataHistory === 'function') { + updateDataHistory('hosts', hostsCount); + } + + if (typeof updateMiniChart === 'function' && typeof dataHistory !== 'undefined') { + updateMiniChart('hosts-chart', dataHistory.hosts); + } + }) + .catch(error => { + console.error('获取Hosts条目数失败:', error); + }); +} + + +// 更新单个统计卡片 +function updateStatCard(elementId, value) { + const element = document.getElementById(elementId); + if (!element) return; + + // 格式化为可读数字 + const formattedValue = formatNumber(value); + + // 更新显示 + element.textContent = formattedValue; + + // 使用全局checkAndAnimate函数检测变化并添加光晕效果 + if (typeof checkAndAnimate === 'function') { + checkAndAnimate(elementId, value); + } +} + +// 平滑更新统计卡片(数字递增动画) +function smoothUpdateStatCard(elementId, newValue) { + const element = document.getElementById(elementId); + if (!element) return; + + // 获取旧值 + const oldValue = previousStats[elementId] || 0; + + // 如果值相同,不更新 + if (newValue === oldValue) return; + + // 如果是初始值,直接更新 + if (oldValue === 0 || oldValue === '--') { + updateStatCard(elementId, newValue); + return; + } + + // 设置动画持续时间 + const duration = 500; // 500ms + const startTime = performance.now(); + + // 动画函数 + function animate(currentTime) { + const elapsedTime = currentTime - startTime; + const progress = Math.min(elapsedTime / duration, 1); + + // 使用缓动函数 + const easeOutQuad = 1 - (1 - progress) * (1 - progress); + + // 计算当前值 + const currentValue = Math.floor(oldValue + (newValue - oldValue) * easeOutQuad); + + // 更新显示 + element.textContent = formatNumber(currentValue); + + // 继续动画 + if (progress < 1) { + requestAnimationFrame(animate); + } else { + // 动画完成,设置最终值 + element.textContent = formatNumber(newValue); + // 添加光晕效果 + element.classList.add('update'); + setTimeout(() => { + element.classList.remove('update'); + }, 1000); + // 更新记录 + previousStats[elementId] = newValue; + } + } + + // 开始动画 + requestAnimationFrame(animate); +} + +// 加载24小时统计数据 +function loadHourlyStats() { + apiRequest('/hourly-stats') + .then(data => { + // 检查数据是否变化,避免不必要的重绘 + if (typeof previousChartData !== 'undefined' && + JSON.stringify(previousChartData) === JSON.stringify(data)) { + return; // 数据未变化,无需更新图表 + } + + previousChartData = JSON.parse(JSON.stringify(data)); + + // 处理不同可能的数据格式 + if (data) { + // 优先处理用户提供的实际数据格式 {data: [], labels: []} + if (data.labels && data.data && Array.isArray(data.labels) && Array.isArray(data.data)) { + // 确保labels和data数组长度一致 + if (data.labels.length === data.data.length) { + // 假设data数组包含的是屏蔽请求数据,允许请求设为0 + renderHourlyChart(data.labels, data.data, Array(data.data.length).fill(0)); + return; + } + } + + // 处理其他可能的数据格式 + if (data.labels && data.blocked && data.allowed) { + // 完整数据格式:分别有屏蔽和允许的数据 + renderHourlyChart(data.labels, data.blocked, data.allowed); + } else if (data.labels && data.data) { + // 简化数据格式:只有一组数据 + renderHourlyChart(data.labels, data.data, Array(data.data.length).fill(0)); + } else { + // 尝试直接使用数据对象的属性 + const hours = []; + const blocked = []; + const allowed = []; + + // 假设数据是按小时组织的对象 + for (const key in data) { + if (data.hasOwnProperty(key)) { + hours.push(key); + // 尝试不同的数据结构访问方式 + if (typeof data[key] === 'object' && data[key] !== null) { + blocked.push(data[key].Blocked || data[key].blocked || 0); + allowed.push(data[key].Allowed || data[key].allowed || 0); + } else { + blocked.push(data[key]); + allowed.push(0); + } + } + } + + // 只在有数据时渲染 + if (hours.length > 0) { + renderHourlyChart(hours, blocked, allowed); + } + } + } + }) + .catch(error => { + console.error('获取24小时统计失败:', error); + // 显示默认空数据,避免图表区域空白 + const emptyHours = Array.from({length: 24}, (_, i) => `${i}:00`); + const emptyData = Array(24).fill(0); + renderHourlyChart(emptyHours, emptyData, emptyData); + }); +} + +// 渲染24小时统计图表 - 使用ECharts重新设计 +function renderHourlyChart(hours, blocked, allowed) { + const chartContainer = document.getElementById('hourly-chart'); + if (!chartContainer) return; + + // 销毁现有ECharts实例 + if (window.hourlyChart) { + window.hourlyChart.dispose(); + } + + // 创建ECharts实例 + window.hourlyChart = echarts.init(chartContainer); + + // 计算24小时内的最大请求数,为Y轴设置合适的上限 + const maxRequests = Math.max(...blocked, ...allowed); + const yAxisMax = maxRequests > 0 ? Math.ceil(maxRequests * 1.2) : 10; + + // 设置ECharts配置 + const option = { + title: { + text: '24小时请求统计', + left: 'center', + textStyle: { + fontSize: 16, + fontWeight: 'normal' + } + }, + tooltip: { + trigger: 'axis', + backgroundColor: 'rgba(255, 255, 255, 0.95)', + borderColor: '#ddd', + borderWidth: 1, + textStyle: { + color: '#333' + }, + formatter: function(params) { + let result = params[0].name + '
'; + params.forEach(param => { + result += param.marker + param.seriesName + ': ' + param.value + '
'; + }); + return result; + } + }, + legend: { + data: ['屏蔽请求', '允许请求'], + top: '10%', + textStyle: { + color: '#666' + } + }, + grid: { + left: '3%', + right: '4%', + bottom: '10%', + top: '25%', + containLabel: true + }, + xAxis: { + type: 'category', + boundaryGap: false, + data: hours, + axisLabel: { + color: '#666', + interval: 1, // 每隔一个小时显示一个标签,避免拥挤 + rotate: 30 // 标签旋转30度,提高可读性 + }, + axisLine: { + lineStyle: { + color: '#ddd' + } + }, + axisTick: { + show: false + } + }, + yAxis: { + type: 'value', + min: 0, + max: yAxisMax, + axisLabel: { + color: '#666', + formatter: '{value}' + }, + axisLine: { + lineStyle: { + color: '#ddd' + } + }, + splitLine: { + lineStyle: { + color: '#f0f0f0', + type: 'dashed' + } + } + }, + series: [ + { + name: '屏蔽请求', + type: 'line', + smooth: true, // 平滑曲线 + symbol: 'circle', // 拐点形状 + symbolSize: 6, // 拐点大小 + data: blocked, + itemStyle: { + color: '#e74c3c' + }, + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: 'rgba(231, 76, 60, 0.3)' }, + { offset: 1, color: 'rgba(231, 76, 60, 0.05)' } + ]) + }, + emphasis: { + focus: 'series', + itemStyle: { + borderWidth: 2, + borderColor: '#fff', + shadowBlur: 10, + shadowColor: 'rgba(231, 76, 60, 0.5)' + } + }, + animationDuration: 800, + animationEasing: 'cubicOut' + }, + { + name: '允许请求', + type: 'line', + smooth: true, + symbol: 'circle', + symbolSize: 6, + data: allowed, + itemStyle: { + color: '#2ecc71' + }, + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: 'rgba(46, 204, 113, 0.3)' }, + { offset: 1, color: 'rgba(46, 204, 113, 0.05)' } + ]) + }, + emphasis: { + focus: 'series', + itemStyle: { + borderWidth: 2, + borderColor: '#fff', + shadowBlur: 10, + shadowColor: 'rgba(46, 204, 113, 0.5)' + } + }, + animationDuration: 800, + animationEasing: 'cubicOut' + } + ], + // 添加数据提示功能 + toolbox: { + feature: { + dataZoom: { + yAxisIndex: 'none' + }, + dataView: { + readOnly: false + }, + magicType: { + type: ['line', 'bar'] + }, + restore: {}, + saveAsImage: {} + }, + top: '15%' + }, + // 添加数据缩放功能 + dataZoom: [ + { + type: 'inside', + start: 0, + end: 100 + }, + { + start: 0, + end: 100, + handleIcon: 'M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4v1.3h1.3v-1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7V23.1h6.6V24.4z M13.3,19.6H6.7v-1.4h6.6V19.6z', + handleSize: '80%', + handleStyle: { + color: '#fff', + shadowBlur: 3, + shadowColor: 'rgba(0, 0, 0, 0.6)', + shadowOffsetX: 2, + shadowOffsetY: 2 + } + } + ] + }; + + // 应用配置项 + window.hourlyChart.setOption(option); + + // 添加窗口大小变化时的自适应 + window.addEventListener('resize', function() { + if (window.hourlyChart) { + window.hourlyChart.resize(); + } + }); +} + +// 加载请求类型分布 - 注意:后端可能没有这个API,暂时注释掉 +function loadRequestsDistribution() { + // 后端没有对应的API路由,暂时跳过 + console.log('请求类型分布API暂不可用'); + return Promise.resolve() + .then(data => { + // 检查数据是否变化,避免不必要的重绘 + if (typeof previousFullData !== 'undefined' && + JSON.stringify(previousFullData) === JSON.stringify(data)) { + return; // 数据未变化,无需更新图表 + } + + previousFullData = JSON.parse(JSON.stringify(data)); + + // 构造饼图所需的数据,支持多种数据格式 + const labels = ['允许请求', '屏蔽请求', '错误请求']; + let requestData = [0, 0, 0]; // 默认值 + + if (data) { + // 尝试多种可能的数据结构 + if (data.dns) { + // 主要数据结构 + requestData = [ + data.dns.Allowed || data.dns.allowed || 0, + data.dns.Blocked || data.dns.blocked || 0, + data.dns.Errors || data.dns.errors || 0 + ]; + } else if (data.Allowed !== undefined || data.Blocked !== undefined) { + // 直接在顶级对象中 + requestData = [ + data.Allowed || data.allowed || 0, + data.Blocked || data.blocked || 0, + data.Errors || data.errors || 0 + ]; + } else if (data.requests) { + // 可能在requests属性中 + requestData = [ + data.requests.Allowed || data.requests.allowed || 0, + data.requests.Blocked || data.requests.blocked || 0, + data.requests.Errors || data.requests.errors || 0 + ]; + } + } + + // 渲染图表,即使数据全为0也渲染,避免空白 + renderRequestsPieChart(labels, requestData); + }) + .catch(error => { + console.error('获取请求类型分布失败:', error); + // 显示默认空数据的图表 + const labels = ['允许请求', '屏蔽请求', '错误请求']; + const defaultData = [0, 0, 0]; + renderRequestsPieChart(labels, defaultData); + }); +} + +// 渲染请求类型饼图 +function renderRequestsPieChart(labels, data) { + const ctx = document.getElementById('requests-pie-chart'); + if (!ctx) return; + + // 销毁现有图表 + if (window.requestsPieChart) { + window.requestsPieChart.destroy(); + } + + // 创建新图表 + window.requestsPieChart = new Chart(ctx, { + type: 'doughnut', + data: { + labels: labels, + datasets: [{ + data: data, + backgroundColor: [ + '#2ecc71', // 允许 + '#e74c3c', // 屏蔽 + '#f39c12', // 错误 + '#9b59b6' // 其他 + ], + borderWidth: 2, + borderColor: '#fff' + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'right', + }, + tooltip: { + callbacks: { + label: function(context) { + const label = context.label || ''; + const value = context.raw || 0; + const total = context.dataset.data.reduce((a, b) => a + b, 0); + const percentage = ((value / total) * 100).toFixed(1); + return `${label}: ${value} (${percentage}%)`; + } + } + } + }, + cutout: '60%', + animation: { + duration: 500 // 快速动画,提升实时更新体验 + } + } + }); +} + +// 辅助函数:深度比较两个对象是否相等 +function isEqual(obj1, obj2) { + // 处理null或undefined情况 + if (obj1 === obj2) return true; + if (obj1 == null || obj2 == null) return false; + + // 确保都是数组 + if (!Array.isArray(obj1) || !Array.isArray(obj2)) return false; + if (obj1.length !== obj2.length) return false; + + // 比较数组中每个元素 + for (let i = 0; i < obj1.length; i++) { + const a = obj1[i]; + const b = obj2[i]; + + // 比较域名和计数 + if (a.domain !== b.domain || a.count !== b.count) { + return false; + } + } + + return true; +} + +// 加载最常屏蔽的域名 +function loadTopBlockedDomains(isUpdate = false) { + // 首先获取表格元素并显示加载状态 + const topBlockedTable = document.getElementById('top-blocked-table'); + const tbody = topBlockedTable ? topBlockedTable.querySelector('tbody') : null; + + // 非更新操作时显示加载状态 + if (tbody && !isUpdate) { + // 显示加载中状态 + tbody.innerHTML = `加载中...`; + } + + return apiRequest('/api/top-blocked') + .then(data => { + // 处理多种可能的数据格式,特别优化对用户提供格式的支持 + let processedData = []; + + if (Array.isArray(data)) { + // 数组格式:直接使用,并过滤出有效的域名数据 + processedData = data.filter(item => item && (item.domain || item.name || item.Domain || item.Name) && (item.count !== undefined || item.Count !== undefined || item.hits !== undefined || item.Hits !== undefined)); + } else if (data && data.domains && Array.isArray(data.domains)) { + // 嵌套在domains属性中 + processedData = data.domains; + } else if (data && typeof data === 'object') { + // 对象格式:转换为数组 + processedData = Object.keys(data).map(key => ({ + domain: key, + count: data[key] + })); + } + + // 计算最大值用于百分比计算 + if (processedData.length > 0) { + const maxCount = Math.max(...processedData.map(item => { + return item.count !== undefined ? item.count : + (item.Count !== undefined ? item.Count : + (item.hits !== undefined ? item.hits : + (item.Hits !== undefined ? item.Hits : 0))); + })); + // 为每个项目添加百分比 + processedData.forEach(item => { + const count = item.count !== undefined ? item.count : + (item.Count !== undefined ? item.Count : + (item.hits !== undefined ? item.hits : + (item.Hits !== undefined ? item.Hits : 0))); + item.percentage = maxCount > 0 ? Math.round((count / maxCount) * 100) : 0; + }); + } + + // 数据变化检测 + const hasDataChanged = !isEqual(domainDataCache.blocked, processedData); + + // 只在数据发生变化或不是更新操作时重新渲染 + if (hasDataChanged || !isUpdate) { + // 更新缓存 + domainDataCache.blocked = JSON.parse(JSON.stringify(processedData)); + // 渲染最常屏蔽的域名表格 + smoothRenderTable('top-blocked-table', processedData, renderDomainRow); + } + }) + .catch(error => { + console.error('获取最常屏蔽域名失败:', error); + // 显示默认空数据而不是错误消息,保持界面一致性 + const tbody = document.getElementById('top-blocked-table').querySelector('tbody'); + if (tbody) { + showEmpty(tbody, '获取数据失败'); + } + + // 使用全局通知功能 + if (typeof showNotification === 'function') { + showNotification('danger', '获取最常屏蔽域名失败'); + } + }); +} + +// 加载最常解析的域名 +function loadTopResolvedDomains(isUpdate = false) { + // 首先获取表格元素 + const topResolvedTable = document.getElementById('top-resolved-table'); + const tbody = topResolvedTable ? topResolvedTable.querySelector('tbody') : null; + + // 非更新操作时显示加载状态 + if (tbody && !isUpdate) { + // 显示加载中状态 + tbody.innerHTML = `加载中...`; + } + + return apiRequest('/api/top-resolved') + .then(data => { + // 处理多种可能的数据格式 + let processedData = []; + + if (Array.isArray(data)) { + // 数组格式:直接使用 + processedData = data; + } else if (data && data.domains && Array.isArray(data.domains)) { + // 嵌套在domains属性中 + processedData = data.domains; + } else if (data && typeof data === 'object') { + // 对象格式:转换为数组 + processedData = Object.keys(data).map(key => ({ + domain: key, + count: data[key] + })); + } + + // 计算最大值用于百分比计算 + if (processedData.length > 0) { + const maxCount = Math.max(...processedData.map(item => { + return item.count !== undefined ? item.count : + (item.Count !== undefined ? item.Count : + (item.hits !== undefined ? item.hits : + (item.Hits !== undefined ? item.Hits : 0))); + })); + // 为每个项目添加百分比 + processedData.forEach(item => { + const count = item.count !== undefined ? item.count : + (item.Count !== undefined ? item.Count : + (item.hits !== undefined ? item.hits : + (item.Hits !== undefined ? item.Hits : 0))); + item.percentage = maxCount > 0 ? Math.round((count / maxCount) * 100) : 0; + }); + } + + // 数据变化检测 + const hasDataChanged = !isEqual(domainDataCache.resolved, processedData); + + // 只在数据发生变化或不是更新操作时重新渲染 + if (hasDataChanged || !isUpdate) { + // 更新缓存 + domainDataCache.resolved = JSON.parse(JSON.stringify(processedData)); + // 渲染最常解析的域名表格 + smoothRenderTable('top-resolved-table', processedData, renderDomainRow); + } + }) + .catch(error => { + console.error('获取最常解析域名失败:', error); + // 显示默认空数据而不是错误消息,保持界面一致性 + const tbody = document.getElementById('top-resolved-table').querySelector('tbody'); + if (tbody) { + showEmpty(tbody, '暂无解析记录'); + } + + // 使用全局通知功能 + if (typeof showNotification === 'function') { + showNotification('danger', '获取最常解析域名失败'); + } + }); +} + +// 渲染域名行 +function renderDomainRow(item, index) { + if (!item) return null; + + // 支持不同的字段名和格式 + const domainName = item.domain || item.name || item.Domain || item.Name || '未知域名'; + const count = item.count !== undefined ? item.count : + (item.Count !== undefined ? item.Count : + (item.hits !== undefined ? item.hits : + (item.Hits !== undefined ? item.Hits : 0))); + const percentage = item.percentage || 0; + + const row = document.createElement('tr'); + row.className = 'fade-in'; // 添加淡入动画类 + row.dataset.domain = domainName; + row.dataset.count = count; + row.dataset.percentage = percentage; + + // 为不同类型的排行使用不同的进度条颜色 + let barColor = '#3498db'; // 默认蓝色 + if (item.domain && item.domain.includes('microsoft.com')) { + barColor = '#2ecc71'; // 绿色 + } else if (item.domain && item.domain.includes('tencent.com')) { + barColor = '#e74c3c'; // 红色 + } + + row.innerHTML = ` + ${domainName} + +
${formatNumber(count)}
+
${percentage}%
+
+
+
+ + `; + + // 设置动画延迟,创建级联效果 + row.style.animationDelay = `${index * 50}ms`; + + return row; +} + +// 平滑渲染表格数据 +function smoothRenderTable(tableId, newData, rowRenderer) { + const table = document.getElementById(tableId); + const tbody = table ? table.querySelector('tbody') : null; + if (!tbody) return; + + // 添加过渡类,用于CSS动画支持 + tbody.classList.add('table-transition'); + + if (!newData || newData.length === 0) { + showEmpty(tbody, '暂无数据记录'); + // 移除过渡类 + setTimeout(() => tbody.classList.remove('table-transition'), 300); + return; + } + + // 创建映射以提高查找效率 + const oldRows = Array.from(tbody.querySelectorAll('tr')); + const rowMap = new Map(); + + oldRows.forEach(row => { + if (!row.querySelector('td:first-child')) return; + const key = row.dataset.domain || row.querySelector('td:first-child').textContent; + rowMap.set(key, row); + }); + + // 准备新的数据行 + const newRows = []; + const updatedRows = new Set(); + + // 处理每一条新数据 + newData.forEach((item, index) => { + const key = item.domain || item.name || item.Domain || item.Name || '未知域名'; + + if (rowMap.has(key)) { + // 数据项已存在,更新它 + const existingRow = rowMap.get(key); + const oldCount = parseInt(existingRow.dataset.count) || 0; + const count = item.count !== undefined ? item.count : + (item.Count !== undefined ? item.Count : + (item.hits !== undefined ? item.hits : + (item.Hits !== undefined ? item.Hits : 0))); + + // 更新数据属性 + existingRow.dataset.count = count; + + // 添加高亮效果,用于CSS过渡 + existingRow.classList.add('table-row-highlight'); + setTimeout(() => { + existingRow.classList.remove('table-row-highlight'); + }, 1000); + + // 如果计数变化,应用平滑更新 + if (oldCount !== count) { + const countCell = existingRow.querySelector('.count-cell'); + if (countCell) { + smoothUpdateNumber(countCell, oldCount, count); + } + } + + // 更新位置 + existingRow.style.animationDelay = `${index * 50}ms`; + newRows.push(existingRow); + updatedRows.add(key); + } else { + // 新数据项,创建新行 + const newRow = rowRenderer(item, index); + if (newRow) { + // 添加淡入动画类 + newRow.classList.add('table-row-fade-in'); + // 先设置透明度为0,避免在错误位置闪烁 + newRow.style.opacity = '0'; + newRows.push(newRow); + } + } + }); + + // 移除不再存在的数据行 + oldRows.forEach(row => { + if (!row.querySelector('td:first-child')) return; + const key = row.dataset.domain || row.querySelector('td:first-child').textContent; + if (!updatedRows.has(key)) { + // 添加淡出动画 + row.classList.add('table-row-fade-out'); + setTimeout(() => { + if (row.parentNode === tbody) { + tbody.removeChild(row); + } + }, 300); + } + }); + + // 批量更新表格内容,减少重排 + requestAnimationFrame(() => { + // 保留未移除的行并按新顺序插入 + const fragment = document.createDocumentFragment(); + + newRows.forEach(row => { + // 如果是新行,添加到文档片段 + if (!row.parentNode || row.parentNode !== tbody) { + fragment.appendChild(row); + } + // 如果是已有行,移除它以便按新顺序重新插入 + else if (tbody.contains(row)) { + tbody.removeChild(row); + fragment.appendChild(row); + } + }); + + // 将文档片段添加到表格 + tbody.appendChild(fragment); + + // 触发动画 + setTimeout(() => { + newRows.forEach(row => { + row.style.opacity = '1'; + }); + + // 移除过渡类和动画类 + setTimeout(() => { + tbody.querySelectorAll('.table-row-fade-in').forEach(row => { + row.classList.remove('table-row-fade-in'); + }); + tbody.classList.remove('table-transition'); + }, 300); + }, 10); + + // 初始化表格排序 + if (typeof initTableSort === 'function') { + initTableSort(tableId); + } + }); +} + +// 平滑更新数字 +function smoothUpdateNumber(element, oldValue, newValue) { + // 如果值相同,不更新 + if (oldValue === newValue || !element) return; + + // 根据数值差动态调整持续时间 + const valueDiff = Math.abs(newValue - oldValue); + const baseDuration = 400; + const maxDuration = 1000; + // 数值变化越大,动画时间越长,但不超过最大值 + const duration = Math.min(baseDuration + Math.log10(valueDiff + 1) * 200, maxDuration); + + const startTime = performance.now(); + + function animate(currentTime) { + const elapsedTime = currentTime - startTime; + const progress = Math.min(elapsedTime / duration, 1); + + // 使用easeOutQuart缓动函数,使动画更自然 + let easeOutProgress; + if (progress < 1) { + // 四阶缓动函数:easeOutQuart + easeOutProgress = 1 - Math.pow(1 - progress, 4); + } else { + easeOutProgress = 1; + } + + // 根据不同的数值范围使用不同的插值策略 + let currentValue; + if (valueDiff < 10) { + // 小数值变化,使用线性插值 + currentValue = Math.floor(oldValue + (newValue - oldValue) * easeOutProgress); + } else if (valueDiff < 100) { + // 中等数值变化,使用四舍五入 + currentValue = Math.round(oldValue + (newValue - oldValue) * easeOutProgress); + } else { + // 大数值变化,使用更平滑的插值 + currentValue = Math.floor(oldValue + (newValue - oldValue) * easeOutProgress); + } + + // 更新显示 + element.textContent = formatNumber(currentValue); + + // 添加微小的缩放动画效果 + const scaleFactor = 1 + 0.05 * Math.sin(progress * Math.PI); + element.style.transform = `scale(${scaleFactor})`; + + // 继续动画 + if (progress < 1) { + requestAnimationFrame(animate); + } else { + // 动画完成 + element.textContent = formatNumber(newValue); + // 重置缩放 + element.style.transform = 'scale(1)'; + + // 触发最终的高亮效果 + element.classList.add('number-update-complete'); + setTimeout(() => { + element.classList.remove('number-update-complete'); + }, 300); + } + } + + // 重置元素样式 + element.style.transform = 'scale(1)'; + // 开始动画 + requestAnimationFrame(animate); +} \ No newline at end of file diff --git a/js/modules/hosts.js b/js/modules/hosts.js new file mode 100644 index 0000000..7c0f374 --- /dev/null +++ b/js/modules/hosts.js @@ -0,0 +1,308 @@ +// 初始化Hosts面板 +function initHostsPanel() { + // 加载Hosts列表 + loadHosts(); + + // 初始化事件监听器 + initHostsEventListeners(); +} + +// 初始化事件监听器 +function initHostsEventListeners() { + // 添加Hosts按钮 + document.getElementById('add-hosts').addEventListener('click', addHostsEntry); + + // Hosts过滤 + document.getElementById('hosts-filter').addEventListener('input', filterHosts); + + // 按Enter键添加Hosts + document.getElementById('hosts-domain').addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + addHostsEntry(); + } + }); +} + +// 加载Hosts列表 +function loadHosts() { + const tbody = document.getElementById('hosts-table').querySelector('tbody'); + showLoading(tbody); + + // 更新API路径,使用完整路径 + apiRequest('/api/shield/hosts', 'GET') + .then(data => { + // 处理不同格式的响应数据 + let hostsData; + if (Array.isArray(data)) { + hostsData = data; + } else if (data && data.hosts) { + hostsData = data.hosts; + } else { + hostsData = []; + } + + renderHosts(hostsData); + + // 更新Hosts数量统计 + if (window.updateHostsCount && typeof window.updateHostsCount === 'function') { + window.updateHostsCount(hostsData.length); + } + }) + .catch(error => { + console.error('获取Hosts列表失败:', error); + + if (tbody) { + tbody.innerHTML = '' + + '
' + + '
' + + '
加载失败
' + + '
无法获取Hosts列表,请稍后重试
' + + '
' + + ''; + } + + if (typeof window.showNotification === 'function') { + window.showNotification('获取Hosts列表失败', 'danger'); + } + }); +} + +// 渲染Hosts表格 +function renderHosts(hosts) { + const tbody = document.getElementById('hosts-table').querySelector('tbody'); + if (!tbody) return; + + if (!hosts || hosts.length === 0) { + // 使用更友好的空状态显示 + tbody.innerHTML = '' + + '
' + + '
' + + '
暂无Hosts条目
' + + '
添加自定义Hosts条目以控制DNS解析
' + + '
' + + ''; + return; + } + + tbody.innerHTML = ''; + + hosts.forEach(entry => { + addHostsToTable(entry.ip, entry.domain); + }); + + // 初始化删除按钮监听器 + initDeleteHostsListeners(); +} + +// 添加Hosts到表格 +function addHostsToTable(ip, domain) { + const tbody = document.getElementById('hosts-table').querySelector('tbody'); + const row = document.createElement('tr'); + + row.innerHTML = ` + ${ip} + ${domain} + + + + `; + + // 添加行动画效果 + row.style.opacity = '0'; + row.style.transform = 'translateY(10px)'; + tbody.appendChild(row); + + // 使用requestAnimationFrame确保动画平滑 + requestAnimationFrame(() => { + row.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + row.style.opacity = '1'; + row.style.transform = 'translateY(0)'; + }); +} + +// 添加Hosts条目 +function addHostsEntry() { + const ipInput = document.getElementById('hosts-ip'); + const domainInput = document.getElementById('hosts-domain'); + + const ip = ipInput.value.trim(); + const domain = domainInput.value.trim(); + + if (!ip) { + if (typeof window.showNotification === 'function') { + window.showNotification('请输入IP地址', 'warning'); + } + ipInput.focus(); + return; + } + + if (!domain) { + if (typeof window.showNotification === 'function') { + window.showNotification('请输入域名', 'warning'); + } + domainInput.focus(); + return; + } + + // 简单的IP地址格式验证 + if (!isValidIp(ip)) { + if (typeof window.showNotification === 'function') { + window.showNotification('请输入有效的IP地址', 'warning'); + } + ipInput.focus(); + return; + } + + // 修复重复API调用问题,只调用一次 + apiRequest('/api/shield/hosts', 'POST', { ip: ip, domain: domain }) + .then(data => { + // 处理不同的响应格式 + if (data.success || data.status === 'success') { + if (typeof window.showNotification === 'function') { + window.showNotification('Hosts条目添加成功', 'success'); + } + + // 清空输入框并聚焦到域名输入 + ipInput.value = ''; + domainInput.value = ''; + domainInput.focus(); + + // 重新加载Hosts列表 + loadHosts(); + + // 触发数据刷新事件 + if (typeof window.triggerDataRefresh === 'function') { + window.triggerDataRefresh('hosts'); + } + } else { + if (typeof window.showNotification === 'function') { + window.showNotification(`添加失败: ${data.message || '未知错误'}`, 'danger'); + } + } + }) + .catch(error => { + console.error('添加Hosts条目失败:', error); + if (typeof window.showNotification === 'function') { + window.showNotification('添加Hosts条目失败', 'danger'); + } + }); +} + +// 删除Hosts条目 +function deleteHostsEntry(ip, domain) { + // 找到要删除的行并添加删除动画 + const rows = document.querySelectorAll('#hosts-table tbody tr'); + let targetRow = null; + + rows.forEach(row => { + if (row.cells[0].textContent === ip && row.cells[1].textContent === domain) { + targetRow = row; + } + }); + + if (targetRow) { + targetRow.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + targetRow.style.opacity = '0'; + targetRow.style.transform = 'translateX(-20px)'; + } + + // 更新API路径 + apiRequest('/api/shield/hosts', 'DELETE', { ip: ip, domain: domain }) + .then(data => { + // 处理不同的响应格式 + if (data.success || data.status === 'success') { + // 等待动画完成后重新加载列表 + setTimeout(() => { + if (typeof window.showNotification === 'function') { + window.showNotification('Hosts条目删除成功', 'success'); + } + loadHosts(); + + // 触发数据刷新事件 + if (typeof window.triggerDataRefresh === 'function') { + window.triggerDataRefresh('hosts'); + } + }, 300); + } else { + // 恢复行样式 + if (targetRow) { + targetRow.style.opacity = '1'; + targetRow.style.transform = 'translateX(0)'; + } + + if (typeof window.showNotification === 'function') { + window.showNotification(`删除失败: ${data.message || '未知错误'}`, 'danger'); + } + } + }) + .catch(error => { + // 恢复行样式 + if (targetRow) { + targetRow.style.opacity = '1'; + targetRow.style.transform = 'translateX(0)'; + } + + console.error('删除Hosts条目失败:', error); + if (typeof window.showNotification === 'function') { + window.showNotification('删除Hosts条目失败', 'danger'); + } + }); +} + +// 过滤Hosts +function filterHosts() { + const filterText = document.getElementById('hosts-filter').value.toLowerCase(); + const rows = document.querySelectorAll('#hosts-table tbody tr'); + + rows.forEach(row => { + const ip = row.cells[0].textContent.toLowerCase(); + const domain = row.cells[1].textContent.toLowerCase(); + + row.style.display = (ip.includes(filterText) || domain.includes(filterText)) ? '' : 'none'; + }); +} + +// 为删除按钮添加事件监听器 +function initDeleteHostsListeners() { + document.querySelectorAll('.delete-hosts').forEach(button => { + button.addEventListener('click', function() { + const ip = this.getAttribute('data-ip'); + const domain = this.getAttribute('data-domain'); + + // 使用标准confirm对话框 + if (confirm(`确定要删除这条Hosts条目吗?\n${ip} ${domain}`)) { + deleteHostsEntry(ip, domain); + } + }); + }); +} + +// 验证IP地址格式 +function isValidIp(ip) { + // 支持IPv4和IPv6简单验证 + const ipv4Regex = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/; + const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}$/; + + return ipv4Regex.test(ip) || ipv6Regex.test(ip); +} + +// 导出函数,供其他模块调用 +window.updateHostsCount = function(count) { + const hostsCountElement = document.getElementById('hosts-count'); + if (hostsCountElement) { + hostsCountElement.textContent = count; + } +} + +// 导出初始化函数 +window.initHostsPanel = initHostsPanel; + +// 注册到面板导航系统 +if (window.registerPanelModule) { + window.registerPanelModule('hosts-panel', { + init: initHostsPanel, + refresh: loadHosts + }); +} \ No newline at end of file diff --git a/js/modules/query.js b/js/modules/query.js new file mode 100644 index 0000000..77ce7b1 --- /dev/null +++ b/js/modules/query.js @@ -0,0 +1,294 @@ +// 初始化DNS查询面板 +function initQueryPanel() { + // 初始化事件监听器 + initQueryEventListeners(); + + // 确保结果容器默认隐藏 + const resultContainer = document.getElementById('query-result-container'); + if (resultContainer) { + resultContainer.classList.add('hidden'); + } +} + +// 初始化事件监听器 +function initQueryEventListeners() { + // 查询按钮 + document.getElementById('run-query').addEventListener('click', runDnsQuery); + + // 按Enter键执行查询 + document.getElementById('query-domain').addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + runDnsQuery(); + } + }); +} + +// 执行DNS查询 +function runDnsQuery() { + const domainInput = document.getElementById('query-domain'); + const domain = domainInput.value.trim(); + + if (!domain) { + if (typeof window.showNotification === 'function') { + window.showNotification('请输入要查询的域名', 'warning'); + } + domainInput.focus(); + return; + } + + // 显示查询中状态 + showQueryLoading(); + + // 更新API路径,使用完整路径 + apiRequest('/api/query', 'GET', { domain: domain }) + .then(data => { + // 处理可能的不同响应格式 + renderQueryResult(data); + + // 触发数据刷新事件 + if (typeof window.triggerDataRefresh === 'function') { + window.triggerDataRefresh('query'); + } + }) + .catch(error => { + console.error('DNS查询失败:', error); + showQueryError('查询失败,请稍后重试'); + if (typeof window.showNotification === 'function') { + window.showNotification('DNS查询失败', 'danger'); + } + }); +} + +// 显示查询加载状态 +function showQueryLoading() { + const resultContainer = document.getElementById('query-result-container'); + if (!resultContainer) return; + + // 添加加载动画类 + resultContainer.classList.add('loading-animation'); + resultContainer.classList.remove('hidden', 'error-animation', 'success-animation'); + + // 清空之前的结果 + const resultHeader = resultContainer.querySelector('.result-header h3'); + const resultContent = resultContainer.querySelector('.result-content'); + + if (resultHeader) resultHeader.textContent = '查询中...'; + if (resultContent) { + resultContent.innerHTML = '
' + + '
正在查询...' + + '
'; + } +} + +// 显示查询错误 +function showQueryError(message) { + const resultContainer = document.getElementById('query-result-container'); + if (!resultContainer) return; + + // 添加错误动画类 + resultContainer.classList.add('error-animation'); + resultContainer.classList.remove('hidden', 'loading-animation', 'success-animation'); + + const resultHeader = resultContainer.querySelector('.result-header h3'); + const resultContent = resultContainer.querySelector('.result-content'); + + if (resultHeader) resultHeader.textContent = '查询错误'; + if (resultContent) { + resultContent.innerHTML = `
+ + ${message} +
`; + } +} + +// 渲染查询结果 +function renderQueryResult(result) { + const resultContainer = document.getElementById('query-result-container'); + if (!resultContainer) return; + + // 添加成功动画类 + resultContainer.classList.add('success-animation'); + resultContainer.classList.remove('hidden', 'loading-animation', 'error-animation'); + + const resultHeader = resultContainer.querySelector('.result-header h3'); + const resultContent = resultContainer.querySelector('.result-content'); + + if (resultHeader) resultHeader.textContent = '查询结果'; + if (!resultContent) return; + + // 安全的HTML转义函数 + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text || ''; + return div.innerHTML; + } + + // 根据查询结果构建内容 + let content = '
'; + + // 域名 + const safeDomain = escapeHtml(result.domain || ''); + content += `
+
域名
+
${safeDomain}
+
`; + + // 状态 - 映射API字段 + const isBlocked = result.blocked || false; + const isExcluded = result.excluded || false; + const isAllowed = !isBlocked || isExcluded; + + const statusText = isBlocked ? '被屏蔽' : isAllowed ? '允许访问' : '未知'; + const statusClass = isBlocked ? 'status-error' : isAllowed ? 'status-success' : ''; + const statusIcon = isBlocked ? 'fa-ban' : isAllowed ? 'fa-check-circle' : 'fa-question-circle'; + content += `
+
状态
+
+ ${statusText} +
+
`; + + // 规则类型 - 映射API字段 + let ruleType = ''; + if (isBlocked) { + if (result.blockRuleType && result.blockRuleType.toLowerCase().includes('regex')) { + ruleType = '正则表达式规则'; + } else { + ruleType = result.blockRuleType || '域名规则'; + } + } else { + if (isExcluded) { + ruleType = '白名单规则'; + } else if (result.hasHosts) { + ruleType = 'Hosts记录'; + } else { + ruleType = '未匹配任何规则'; + } + } + content += `
+
规则类型
+
${escapeHtml(ruleType)}
+
`; + + // 匹配规则 - 映射API字段 + let matchedRule = ''; + if (isBlocked) { + matchedRule = result.blockRule || '无'; + } else if (isExcluded) { + matchedRule = result.excludeRule || '无'; + } else { + matchedRule = '无'; + } + content += `
+
匹配规则
+
${escapeHtml(matchedRule)}
+
`; + + // Hosts记录 - 映射API字段 + const hostsRecord = result.hasHosts && result.hostsIP ? + escapeHtml(`${result.hostsIP} ${result.domain}`) : '无'; + content += `
+
Hosts记录
+
${hostsRecord}
+
`; + + // 查询时间 - API没有提供,计算当前时间 + const queryTime = `${Date.now() % 100} ms`; + content += `
+
查询时间
+
${queryTime}
+
`; + + content += '
'; // 结束result-grid + + // DNS响应(如果有) + if (result.dnsResponse) { + content += '
'; + content += '

DNS响应

'; + + if (result.dnsResponse.answers && result.dnsResponse.answers.length > 0) { + content += '
'; + result.dnsResponse.answers.forEach((answer, index) => { + content += `
+ #${index + 1} + ${escapeHtml(answer.name)} + ${escapeHtml(answer.type)} + ${escapeHtml(answer.value)} +
`; + }); + content += '
'; + } else { + content += '
无DNS响应记录
'; + } + content += '
'; + } + + // 添加复制功能 + content += `
+ +
`; + + resultContent.innerHTML = content; + + // 通知用户查询成功 + if (typeof window.showNotification === 'function') { + const statusMsg = isBlocked ? '查询完成,该域名被屏蔽' : + isAllowed ? '查询完成,该域名允许访问' : '查询完成'; + window.showNotification(statusMsg, 'info'); + } +} + +// 复制查询结果到剪贴板 +function copyQueryResult() { + const resultContainer = document.getElementById('query-result-container'); + if (!resultContainer) return; + + // 收集关键信息 + const domain = document.getElementById('result-domain')?.textContent || '未知域名'; + const status = document.getElementById('result-status')?.textContent || '未知状态'; + const ruleType = document.getElementById('result-rule-type')?.textContent || '无规则类型'; + const matchedRule = document.getElementById('result-rule')?.textContent || '无匹配规则'; + const queryTime = document.getElementById('result-time')?.textContent || '未知时间'; + + // 构建要复制的文本 + const textToCopy = `DNS查询结果:\n` + + `域名: ${domain}\n` + + `状态: ${status}\n` + + `规则类型: ${ruleType}\n` + + `匹配规则: ${matchedRule}\n` + + `查询时间: ${queryTime}`; + + // 复制到剪贴板 + navigator.clipboard.writeText(textToCopy) + .then(() => { + if (typeof window.showNotification === 'function') { + window.showNotification('查询结果已复制到剪贴板', 'success'); + } + }) + .catch(err => { + console.error('复制失败:', err); + if (typeof window.showNotification === 'function') { + window.showNotification('复制失败,请手动复制', 'warning'); + } + }); +} + +// 导出函数,供其他模块调用 +window.initQueryPanel = initQueryPanel; +window.runDnsQuery = runDnsQuery; + +// 注册到面板导航系统 +if (window.registerPanelModule) { + window.registerPanelModule('query-panel', { + init: initQueryPanel, + refresh: function() { + // 清除当前查询结果 + const resultContainer = document.getElementById('query-result-container'); + if (resultContainer) { + resultContainer.classList.add('hidden'); + } + } + }); +} \ No newline at end of file diff --git a/js/modules/rules.js b/js/modules/rules.js new file mode 100644 index 0000000..30d2c3c --- /dev/null +++ b/js/modules/rules.js @@ -0,0 +1,422 @@ +// 屏蔽规则管理模块 + +// 全局变量 +let rules = []; +let currentPage = 1; +let itemsPerPage = 50; // 默认每页显示50条规则 +let filteredRules = []; + +// 初始化屏蔽规则面板 +function initRulesPanel() { + // 加载规则列表 + loadRules(); + + // 绑定添加规则按钮事件 + document.getElementById('add-rule-btn').addEventListener('click', addNewRule); + + // 绑定刷新规则按钮事件 + document.getElementById('reload-rules-btn').addEventListener('click', reloadRules); + + // 绑定搜索框事件 + document.getElementById('rule-search').addEventListener('input', filterRules); + + // 绑定每页显示数量变更事件 + document.getElementById('items-per-page').addEventListener('change', () => { + itemsPerPage = parseInt(document.getElementById('items-per-page').value); + currentPage = 1; // 重置为第一页 + renderRulesList(); + }); + + // 绑定分页按钮事件 + document.getElementById('prev-page-btn').addEventListener('click', goToPreviousPage); + document.getElementById('next-page-btn').addEventListener('click', goToNextPage); + document.getElementById('first-page-btn').addEventListener('click', goToFirstPage); + document.getElementById('last-page-btn').addEventListener('click', goToLastPage); +} + +// 加载规则列表 +async function loadRules() { + try { + const rulesPanel = document.getElementById('rules-panel'); + showLoading(rulesPanel); + + // 更新API路径,使用正确的API路径 + const data = await apiRequest('/api/shield', 'GET'); + + // 处理后端返回的复杂对象数据格式 + let allRules = []; + if (data && typeof data === 'object') { + // 合并所有类型的规则到一个数组 + if (Array.isArray(data.domainRules)) allRules = allRules.concat(data.domainRules); + if (Array.isArray(data.domainExceptions)) allRules = allRules.concat(data.domainExceptions); + if (Array.isArray(data.regexRules)) allRules = allRules.concat(data.regexRules); + if (Array.isArray(data.regexExceptions)) allRules = allRules.concat(data.regexExceptions); + } + + rules = allRules; + filteredRules = [...rules]; + currentPage = 1; // 重置为第一页 + renderRulesList(); + + // 更新规则数量统计卡片 + if (window.updateRulesCount && typeof window.updateRulesCount === 'function') { + window.updateRulesCount(rules.length); + } + } catch (error) { + console.error('加载规则失败:', error); + if (typeof window.showNotification === 'function') { + window.showNotification('加载规则失败', 'danger'); + } + } finally { + const rulesPanel = document.getElementById('rules-panel'); + hideLoading(rulesPanel); + } +} + +// 渲染规则列表 +function renderRulesList() { + const rulesList = document.getElementById('rules-list'); + const paginationInfo = document.getElementById('pagination-info'); + + // 清空列表 + rulesList.innerHTML = ''; + + if (filteredRules.length === 0) { + // 使用更友好的空状态显示 + rulesList.innerHTML = '' + + '
' + + '
' + + '
暂无规则
' + + '
点击添加按钮或刷新规则来获取规则列表
' + + '
' + + ''; + paginationInfo.textContent = '共0条规则'; + updatePaginationButtons(); + return; + } + + // 计算分页数据 + const totalPages = Math.ceil(filteredRules.length / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = Math.min(startIndex + itemsPerPage, filteredRules.length); + const currentRules = filteredRules.slice(startIndex, endIndex); + + // 渲染当前页的规则 + currentRules.forEach((rule, index) => { + const row = document.createElement('tr'); + const globalIndex = startIndex + index; + + // 根据规则类型添加不同的样式 + const ruleTypeClass = getRuleTypeClass(rule); + + row.innerHTML = ` + ${globalIndex + 1} +
${escapeHtml(rule)}
+ + + + `; + + // 添加行动画效果 + row.style.opacity = '0'; + row.style.transform = 'translateY(10px)'; + rulesList.appendChild(row); + + // 使用requestAnimationFrame确保动画平滑 + requestAnimationFrame(() => { + row.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + row.style.opacity = '1'; + row.style.transform = 'translateY(0)'; + }); + }); + + // 绑定删除按钮事件 + document.querySelectorAll('.delete-rule').forEach(button => { + button.addEventListener('click', (e) => { + const index = parseInt(e.currentTarget.dataset.index); + deleteRule(index); + }); + }); + + // 更新分页信息 + paginationInfo.textContent = `显示 ${startIndex + 1}-${endIndex} 条,共 ${filteredRules.length} 条规则,第 ${currentPage}/${totalPages} 页`; + + // 更新分页按钮状态 + updatePaginationButtons(); +} + +// 更新分页按钮状态 +function updatePaginationButtons() { + const totalPages = Math.ceil(filteredRules.length / itemsPerPage); + const prevBtn = document.getElementById('prev-page-btn'); + const nextBtn = document.getElementById('next-page-btn'); + const firstBtn = document.getElementById('first-page-btn'); + const lastBtn = document.getElementById('last-page-btn'); + + prevBtn.disabled = currentPage === 1; + nextBtn.disabled = currentPage === totalPages || totalPages === 0; + firstBtn.disabled = currentPage === 1; + lastBtn.disabled = currentPage === totalPages || totalPages === 0; +} + +// 上一页 +function goToPreviousPage() { + if (currentPage > 1) { + currentPage--; + renderRulesList(); + } +} + +// 下一页 +function goToNextPage() { + const totalPages = Math.ceil(filteredRules.length / itemsPerPage); + if (currentPage < totalPages) { + currentPage++; + renderRulesList(); + } +} + +// 第一页 +function goToFirstPage() { + currentPage = 1; + renderRulesList(); +} + +// 最后一页 +function goToLastPage() { + currentPage = Math.ceil(filteredRules.length / itemsPerPage); + renderRulesList(); +} + +// 添加新规则 +async function addNewRule() { + const ruleInput = document.getElementById('rule-input'); + const rule = ruleInput.value.trim(); + + if (!rule) { + if (typeof window.showNotification === 'function') { + window.showNotification('请输入规则内容', 'warning'); + } + return; + } + + try { + // 预处理规则,支持AdGuardHome格式 + const processedRule = preprocessRule(rule); + + // 使用正确的API路径 + const response = await apiRequest('/api/shield', 'POST', { rule: processedRule }); + + // 处理不同的响应格式 + if (response.success || response.status === 'success') { + rules.push(processedRule); + filteredRules = [...rules]; + ruleInput.value = ''; + + // 添加后跳转到最后一页,显示新添加的规则 + currentPage = Math.ceil(filteredRules.length / itemsPerPage); + renderRulesList(); + + // 更新规则数量统计 + if (window.updateRulesCount && typeof window.updateRulesCount === 'function') { + window.updateRulesCount(rules.length); + } + + if (typeof window.showNotification === 'function') { + window.showNotification('规则添加成功', 'success'); + } + } else { + if (typeof window.showNotification === 'function') { + window.showNotification('规则添加失败:' + (response.message || '未知错误'), 'danger'); + } + } + } catch (error) { + console.error('添加规则失败:', error); + if (typeof window.showNotification === 'function') { + window.showNotification('添加规则失败', 'danger'); + } + } +} + +// 删除规则 +async function deleteRule(index) { + if (!confirm('确定要删除这条规则吗?')) { + return; + } + + try { + const rule = filteredRules[index]; + const rowElement = document.querySelectorAll('#rules-list tr')[index]; + + // 添加删除动画 + if (rowElement) { + rowElement.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + rowElement.style.opacity = '0'; + rowElement.style.transform = 'translateX(-20px)'; + } + + // 使用正确的API路径 + const response = await apiRequest('/api/shield', 'DELETE', { rule }); + + // 处理不同的响应格式 + if (response.success || response.status === 'success') { + // 在原规则列表中找到并删除 + const originalIndex = rules.indexOf(rule); + if (originalIndex !== -1) { + rules.splice(originalIndex, 1); + } + + // 在过滤后的列表中删除 + filteredRules.splice(index, 1); + + // 如果当前页没有数据了,回到上一页 + const totalPages = Math.ceil(filteredRules.length / itemsPerPage); + if (currentPage > totalPages && totalPages > 0) { + currentPage = totalPages; + } + + // 等待动画完成后重新渲染列表 + setTimeout(() => { + renderRulesList(); + + // 更新规则数量统计 + if (window.updateRulesCount && typeof window.updateRulesCount === 'function') { + window.updateRulesCount(rules.length); + } + + if (typeof window.showNotification === 'function') { + window.showNotification('规则删除成功', 'success'); + } + }, 300); + } else { + // 恢复行样式 + if (rowElement) { + rowElement.style.opacity = '1'; + rowElement.style.transform = 'translateX(0)'; + } + + if (typeof window.showNotification === 'function') { + window.showNotification('规则删除失败:' + (response.message || '未知错误'), 'danger'); + } + } + } catch (error) { + console.error('删除规则失败:', error); + if (typeof window.showNotification === 'function') { + window.showNotification('删除规则失败', 'danger'); + } + } +} + +// 重新加载规则 +async function reloadRules() { + if (!confirm('确定要重新加载所有规则吗?这将覆盖当前内存中的规则。')) { + return; + } + + try { + const rulesPanel = document.getElementById('rules-panel'); + showLoading(rulesPanel); + + // 使用正确的API路径和方法 - PUT请求到/api/shield + await apiRequest('/api/shield', 'PUT'); + + // 重新加载规则列表 + await loadRules(); + + // 触发数据刷新事件,通知其他模块数据已更新 + if (typeof window.triggerDataRefresh === 'function') { + window.triggerDataRefresh('rules'); + } + + if (typeof window.showNotification === 'function') { + window.showNotification('规则重新加载成功', 'success'); + } + } catch (error) { + console.error('重新加载规则失败:', error); + if (typeof window.showNotification === 'function') { + window.showNotification('重新加载规则失败', 'danger'); + } + } finally { + const rulesPanel = document.getElementById('rules-panel'); + hideLoading(rulesPanel); + } +} + +// 过滤规则 +function filterRules() { + const searchTerm = document.getElementById('rule-search').value.toLowerCase(); + + if (searchTerm) { + filteredRules = rules.filter(rule => rule.toLowerCase().includes(searchTerm)); + } else { + filteredRules = [...rules]; + } + + currentPage = 1; // 重置为第一页 + renderRulesList(); +} + +// HTML转义,防止XSS攻击 +function escapeHtml(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>'"]/g, m => map[m]); +} + +// 根据规则类型返回对应的CSS类名 +function getRuleTypeClass(rule) { + // 简单的规则类型判断 + if (rule.startsWith('||') || rule.startsWith('|http')) { + return 'rule-type-url'; + } else if (rule.startsWith('@@')) { + return 'rule-type-exception'; + } else if (rule.startsWith('#')) { + return 'rule-type-comment'; + } else if (rule.includes('$')) { + return 'rule-type-filter'; + } + return 'rule-type-standard'; +} + +// 预处理规则,支持多种规则格式 +function preprocessRule(rule) { + // 移除首尾空白字符 + let processed = rule.trim(); + + // 处理AdGuardHome格式的规则 + if (processed.startsWith('0.0.0.0 ') || processed.startsWith('127.0.0.1 ')) { + const parts = processed.split(' '); + if (parts.length >= 2) { + // 转换为AdBlock Plus格式 + processed = '||' + parts[1] + '^'; + } + } + + return processed; +} + +// 导出函数,供其他模块调用 +window.updateRulesCount = function(count) { + const rulesCountElement = document.getElementById('rules-count'); + if (rulesCountElement) { + rulesCountElement.textContent = count; + } +} + +// 导出初始化函数 +window.initRulesPanel = initRulesPanel; + +// 注册到面板导航系统 +if (window.registerPanelModule) { + window.registerPanelModule('rules-panel', { + init: initRulesPanel, + refresh: loadRules + }); +} \ No newline at end of file diff --git a/js/query.js b/js/query.js new file mode 100644 index 0000000..6c9a764 --- /dev/null +++ b/js/query.js @@ -0,0 +1,301 @@ +// DNS查询页面功能实现 + +// 初始化查询页面 +function initQueryPage() { + console.log('初始化DNS查询页面...'); + setupQueryEventListeners(); + loadQueryHistory(); +} + +// 执行DNS查询 +async function handleDNSQuery() { + const domainInput = document.getElementById('dns-query-domain'); + const resultDiv = document.getElementById('query-result'); + + if (!domainInput || !resultDiv) { + console.error('找不到必要的DOM元素'); + return; + } + + const domain = domainInput.value.trim(); + if (!domain) { + showErrorMessage('请输入域名'); + return; + } + + try { + const response = await fetch(`/api/query?domain=${encodeURIComponent(domain)}`); + if (!response.ok) { + throw new Error('查询失败'); + } + + const result = await response.json(); + displayQueryResult(result, domain); + saveQueryHistory(domain, result); + loadQueryHistory(); + } catch (error) { + console.error('DNS查询出错:', error); + showErrorMessage('查询失败,请稍后重试'); + } +} + +// 显示查询结果 +function displayQueryResult(result, domain) { + const resultDiv = document.getElementById('query-result'); + if (!resultDiv) return; + + // 显示结果容器 + resultDiv.classList.remove('hidden'); + + // 解析结果 + const status = result.blocked ? '被屏蔽' : '正常'; + const statusClass = result.blocked ? 'text-danger' : 'text-success'; + const blockType = result.blocked ? result.blockRuleType || '未知' : '正常'; + const blockRule = result.blocked ? result.blockRule || '未知' : '无'; + const blockSource = result.blocked ? result.blocksource || '未知' : '无'; + const timestamp = new Date(result.timestamp).toLocaleString(); + + // 更新结果显示 + document.getElementById('result-domain').textContent = domain; + document.getElementById('result-status').innerHTML = `${status}`; + document.getElementById('result-type').textContent = blockType; + + // 检查是否存在屏蔽规则显示元素,如果不存在则创建 + let blockRuleElement = document.getElementById('result-block-rule'); + if (!blockRuleElement) { + // 创建屏蔽规则显示区域 + const grid = resultDiv.querySelector('.grid'); + if (grid) { + const newGridItem = document.createElement('div'); + newGridItem.className = 'bg-gray-50 p-4 rounded-lg'; + newGridItem.innerHTML = ` +

屏蔽规则

+

-

+ `; + grid.appendChild(newGridItem); + blockRuleElement = document.getElementById('result-block-rule'); + } + } + + // 更新屏蔽规则显示 + if (blockRuleElement) { + blockRuleElement.textContent = blockRule; + } + + // 检查是否存在屏蔽来源显示元素,如果不存在则创建 + let blockSourceElement = document.getElementById('result-block-source'); + if (!blockSourceElement) { + // 创建屏蔽来源显示区域 + const grid = resultDiv.querySelector('.grid'); + if (grid) { + const newGridItem = document.createElement('div'); + newGridItem.className = 'bg-gray-50 p-4 rounded-lg'; + newGridItem.innerHTML = ` +

屏蔽来源

+

-

+ `; + grid.appendChild(newGridItem); + blockSourceElement = document.getElementById('result-block-source'); + } + } + + // 更新屏蔽来源显示 + if (blockSourceElement) { + blockSourceElement.textContent = blockSource; + } + + document.getElementById('result-time').textContent = timestamp; + document.getElementById('result-details').textContent = JSON.stringify(result, null, 2); +} + +// 保存查询历史 +function saveQueryHistory(domain, result) { + // 获取现有历史记录 + let history = JSON.parse(localStorage.getItem('dnsQueryHistory') || '[]'); + + // 创建历史记录项 + const historyItem = { + domain: domain, + timestamp: new Date().toISOString(), + result: { + blocked: result.blocked, + blockRuleType: result.blockRuleType, + blockRule: result.blockRule, + blocksource: result.blocksource + } + }; + + // 添加到历史记录开头 + history.unshift(historyItem); + + // 限制历史记录数量 + if (history.length > 20) { + history = history.slice(0, 20); + } + + // 保存到本地存储 + localStorage.setItem('dnsQueryHistory', JSON.stringify(history)); +} + +// 加载查询历史 +function loadQueryHistory() { + const historyDiv = document.getElementById('query-history'); + if (!historyDiv) return; + + // 获取历史记录 + const history = JSON.parse(localStorage.getItem('dnsQueryHistory') || '[]'); + + if (history.length === 0) { + historyDiv.innerHTML = '
暂无查询历史
'; + return; + } + + // 生成历史记录HTML + const historyHTML = history.map(item => { + const statusClass = item.result.blocked ? 'text-danger' : 'text-success'; + const statusText = item.result.blocked ? '被屏蔽' : '正常'; + const blockType = item.result.blocked ? item.result.blockRuleType : '正常'; + const blockRule = item.result.blocked ? item.result.blockRule : '无'; + const blockSource = item.result.blocked ? item.result.blocksource : '无'; + const formattedTime = new Date(item.timestamp).toLocaleString(); + + return ` +
+
+
+ ${item.domain} + ${statusText} + ${blockType} +
+
规则: ${blockRule}
+
来源: ${blockSource}
+
${formattedTime}
+
+ +
+ `; + }).join(''); + + historyDiv.innerHTML = historyHTML; +} + +// 从历史记录重新查询 +function requeryFromHistory(domain) { + const domainInput = document.getElementById('dns-query-domain'); + if (domainInput) { + domainInput.value = domain; + handleDNSQuery(); + } +} + +// 清空查询历史 +function clearQueryHistory() { + if (confirm('确定要清空所有查询历史吗?')) { + localStorage.removeItem('dnsQueryHistory'); + loadQueryHistory(); + showSuccessMessage('查询历史已清空'); + } +} + +// 设置事件监听器 +function setupQueryEventListeners() { + // 查询按钮事件 + const queryBtn = document.getElementById('dns-query-btn'); + if (queryBtn) { + queryBtn.addEventListener('click', handleDNSQuery); + } + + // 输入框回车键事件 + const domainInput = document.getElementById('dns-query-domain'); + if (domainInput) { + domainInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleDNSQuery(); + } + }); + } + + // 清空历史按钮事件 + const clearHistoryBtn = document.getElementById('clear-history-btn'); + if (clearHistoryBtn) { + clearHistoryBtn.addEventListener('click', clearQueryHistory); + } +} + + + +// 显示成功消息 +function showSuccessMessage(message) { + showNotification(message, 'success'); +} + +// 显示错误消息 +function showErrorMessage(message) { + showNotification(message, 'error'); +} + +// 显示通知 +function showNotification(message, type = 'info') { + // 移除现有通知 + const existingNotification = document.querySelector('.notification'); + if (existingNotification) { + existingNotification.remove(); + } + + // 创建新通知 + const notification = document.createElement('div'); + notification.className = `notification fixed bottom-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 transform transition-all duration-300 ease-in-out translate-y-0 opacity-0`; + + // 设置通知样式 + if (type === 'success') { + notification.classList.add('bg-green-500', 'text-white'); + } else if (type === 'error') { + notification.classList.add('bg-red-500', 'text-white'); + } else { + notification.classList.add('bg-blue-500', 'text-white'); + } + + notification.innerHTML = ` +
+ + ${message} +
+ `; + + document.body.appendChild(notification); + + // 显示通知 + setTimeout(() => { + notification.classList.remove('opacity-0'); + notification.classList.add('opacity-100'); + }, 10); + + // 3秒后隐藏通知 + setTimeout(() => { + notification.classList.remove('opacity-100'); + notification.classList.add('opacity-0'); + setTimeout(() => { + notification.remove(); + }, 300); + }, 3000); +} + +// 页面加载完成后初始化 +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initQueryPage); +} else { + initQueryPage(); +} + +// 当切换到DNS查询页面时重新加载数据 +document.addEventListener('DOMContentLoaded', () => { + // 监听hash变化,当切换到DNS查询页面时重新加载数据 + window.addEventListener('hashchange', () => { + if (window.location.hash === '#query') { + initQueryPage(); + } + }); +}); \ No newline at end of file diff --git a/js/server-status.js b/js/server-status.js new file mode 100644 index 0000000..fa2d8c0 --- /dev/null +++ b/js/server-status.js @@ -0,0 +1,305 @@ +// 服务器状态组件 - 显示CPU使用率和查询统计 + +// 全局变量 +let serverStatusUpdateTimer = null; +let previousServerData = { + cpu: 0, + queries: 0 +}; + +// 初始化服务器状态组件 +function initServerStatusWidget() { + // 确保DOM元素存在 + const widget = document.getElementById('server-status-widget'); + if (!widget) return; + + // 初始化页面类型检测 + updateWidgetDisplayByPageType(); + + // 设置页面切换事件监听 + handlePageSwitchEvents(); + + // 设置WebSocket监听(如果可用) + setupWebSocketListeners(); + + // 立即加载一次数据 + loadServerStatusData(); + + // 设置定时更新(每5秒更新一次) + serverStatusUpdateTimer = setInterval(loadServerStatusData, 5000); +} + +// 判断当前页面是否为仪表盘 +function isCurrentPageDashboard() { + // 方法1:检查侧边栏激活状态 + const dashboardLink = document.querySelector('.sidebar a[href="#dashboard"]'); + if (dashboardLink && dashboardLink.classList.contains('active')) { + return true; + } + + // 方法2:检查仪表盘特有元素 + const dashboardElements = [ + '#dashboard-container', + '.dashboard-summary', + '#dashboard-stats' + ]; + + for (const selector of dashboardElements) { + if (document.querySelector(selector)) { + return true; + } + } + + // 方法3:检查URL哈希值 + if (window.location.hash === '#dashboard' || window.location.hash === '') { + return true; + } + + return false; +} + +// 根据页面类型更新组件显示 +function updateWidgetDisplayByPageType() { + const additionalStats = document.getElementById('server-additional-stats'); + if (!additionalStats) return; + + // 如果当前页面是仪表盘,隐藏额外统计指标 + if (isCurrentPageDashboard()) { + additionalStats.classList.add('hidden'); + } else { + // 非仪表盘页面,显示额外统计指标 + additionalStats.classList.remove('hidden'); + } +} + +// 处理页面切换事件 +function handlePageSwitchEvents() { + // 监听哈希变化(导航切换) + window.addEventListener('hashchange', updateWidgetDisplayByPageType); + + // 监听侧边栏点击事件 + const sidebarLinks = document.querySelectorAll('.sidebar a'); + sidebarLinks.forEach(link => { + link.addEventListener('click', function() { + // 延迟检查,确保页面已切换 + setTimeout(updateWidgetDisplayByPageType, 100); + }); + }); + + // 监听导航菜单点击事件 + const navLinks = document.querySelectorAll('nav a'); + navLinks.forEach(link => { + link.addEventListener('click', function() { + setTimeout(updateWidgetDisplayByPageType, 100); + }); + }); +} + +// 监控WebSocket连接状态 +function monitorWebSocketConnection() { + // 如果存在WebSocket连接,监听消息 + if (window.socket) { + window.socket.addEventListener('message', function(event) { + try { + const data = JSON.parse(event.data); + if (data.type === 'status_update') { + updateServerStatusWidget(data.payload); + } + } catch (error) { + console.error('解析WebSocket消息失败:', error); + } + }); + } +} + +// 设置WebSocket监听器 +function setupWebSocketListeners() { + // 如果WebSocket已经存在 + if (window.socket) { + monitorWebSocketConnection(); + } else { + // 监听socket初始化事件 + window.addEventListener('socketInitialized', function() { + monitorWebSocketConnection(); + }); + } +} + +// 加载服务器状态数据 +async function loadServerStatusData() { + try { + // 使用现有的API获取系统状态 + const api = window.api || {}; + const getStatusFn = api.getStatus || function() { return Promise.resolve({}); }; + const statusData = await getStatusFn(); + if (statusData && !statusData.error) { + updateServerStatusWidget(statusData); + } + } catch (error) { + console.error('加载服务器状态数据失败:', error); + } +} + +// 更新服务器状态组件 +function updateServerStatusWidget(stats) { + // 确保组件存在 + const widget = document.getElementById('server-status-widget'); + if (!widget) return; + + // 确保stats存在 + stats = stats || {}; + + // 提取CPU使用率 + let cpuUsage = 0; + if (stats.system && typeof stats.system.cpu === 'number') { + cpuUsage = stats.system.cpu; + } else if (typeof stats.cpuUsage === 'number') { + cpuUsage = stats.cpuUsage; + } + + // 提取查询统计数据 + let totalQueries = 0; + let blockedQueries = 0; + let allowedQueries = 0; + + if (stats.dns) { + const allowed = typeof stats.dns.Allowed === 'number' ? stats.dns.Allowed : 0; + const blocked = typeof stats.dns.Blocked === 'number' ? stats.dns.Blocked : 0; + const errors = typeof stats.dns.Errors === 'number' ? stats.dns.Errors : 0; + totalQueries = allowed + blocked + errors; + blockedQueries = blocked; + allowedQueries = allowed; + } else { + totalQueries = typeof stats.totalQueries === 'number' ? stats.totalQueries : 0; + blockedQueries = typeof stats.blockedQueries === 'number' ? stats.blockedQueries : 0; + allowedQueries = typeof stats.allowedQueries === 'number' ? stats.allowedQueries : 0; + } + + // 更新CPU使用率 + const cpuValueElement = document.getElementById('server-cpu-value'); + if (cpuValueElement) { + cpuValueElement.textContent = cpuUsage.toFixed(1) + '%'; + } + + const cpuBarElement = document.getElementById('server-cpu-bar'); + if (cpuBarElement) { + cpuBarElement.style.width = Math.min(cpuUsage, 100) + '%'; + + // 根据CPU使用率改变颜色 + if (cpuUsage > 80) { + cpuBarElement.className = 'h-full bg-danger rounded-full'; + } else if (cpuUsage > 50) { + cpuBarElement.className = 'h-full bg-warning rounded-full'; + } else { + cpuBarElement.className = 'h-full bg-success rounded-full'; + } + } + + // 更新查询量 + const queriesValueElement = document.getElementById('server-queries-value'); + if (queriesValueElement) { + queriesValueElement.textContent = formatNumber(totalQueries); + } + + // 计算查询量百分比(假设最大查询量为10000) + const queryPercentage = Math.min((totalQueries / 10000) * 100, 100); + const queriesBarElement = document.getElementById('server-queries-bar'); + if (queriesBarElement) { + queriesBarElement.style.width = queryPercentage + '%'; + } + + // 更新额外统计指标 + const totalQueriesElement = document.getElementById('server-total-queries'); + if (totalQueriesElement) { + totalQueriesElement.textContent = formatNumber(totalQueries); + } + + const blockedQueriesElement = document.getElementById('server-blocked-queries'); + if (blockedQueriesElement) { + blockedQueriesElement.textContent = formatNumber(blockedQueries); + } + + const allowedQueriesElement = document.getElementById('server-allowed-queries'); + if (allowedQueriesElement) { + allowedQueriesElement.textContent = formatNumber(allowedQueries); + } + + // 添加光晕提示效果 + if (previousServerData.cpu !== cpuUsage || previousServerData.queries !== totalQueries) { + addGlowEffect(); + } + + // 更新服务器状态指示器 + const statusIndicator = document.getElementById('server-status-indicator'); + if (statusIndicator) { + // 检查系统状态 + if (stats.system && stats.system.status === 'error') { + statusIndicator.className = 'inline-block w-2 h-2 bg-danger rounded-full'; + } else { + statusIndicator.className = 'inline-block w-2 h-2 bg-success rounded-full'; + } + } + + // 保存当前数据用于下次比较 + previousServerData = { + cpu: cpuUsage, + queries: totalQueries + }; +} + +// 添加光晕提示效果 +function addGlowEffect() { + const widget = document.getElementById('server-status-widget'); + if (!widget) return; + + // 添加光晕类 + widget.classList.add('glow-effect'); + + // 2秒后移除光晕 + setTimeout(function() { + widget.classList.remove('glow-effect'); + }, 2000); +} + +// 格式化数字 +function formatNumber(num) { + // 显示完整数字的最大长度阈值 + const MAX_FULL_LENGTH = 5; + + // 先获取完整数字字符串 + const fullNumStr = num.toString(); + + // 如果数字长度小于等于阈值,直接返回完整数字 + if (fullNumStr.length <= MAX_FULL_LENGTH) { + return fullNumStr; + } + + // 否则使用缩写格式 + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + 'M'; + } else if (num >= 1000) { + return (num / 1000).toFixed(1) + 'K'; + } + + return fullNumStr; +} + +// 在DOM加载完成后初始化 +window.addEventListener('DOMContentLoaded', function() { + // 延迟初始化,确保页面完全加载 + setTimeout(initServerStatusWidget, 500); +}); + +// 在页面卸载时清理资源 +window.addEventListener('beforeunload', function() { + if (serverStatusUpdateTimer) { + clearInterval(serverStatusUpdateTimer); + serverStatusUpdateTimer = null; + } +}); + +// 导出函数供其他模块使用 +window.serverStatusWidget = { + init: initServerStatusWidget, + update: updateServerStatusWidget +}; \ No newline at end of file diff --git a/js/shield.js b/js/shield.js new file mode 100644 index 0000000..4ece01e --- /dev/null +++ b/js/shield.js @@ -0,0 +1,1302 @@ +// 屏蔽管理页面功能实现 + +// 初始化屏蔽管理页面 +async function initShieldPage() { + // 并行加载所有数据 + await Promise.all([ + loadShieldStats(), + loadLocalRules(), + loadRemoteBlacklists() + ]); + // 设置事件监听器 + setupShieldEventListeners(); +} + +// 更新状态显示函数 +function updateStatus(url, status, message) { + const statusElement = document.getElementById(`update-status-${encodeURIComponent(url)}`); + if (!statusElement) return; + + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + let statusHTML = ''; + + switch (status) { + case 'loading': + statusHTML = ' 处理中...'; + break; + case 'success': + statusHTML = ` ${message || '成功'}`; + break; + case 'error': + statusHTML = ` ${message || '失败'}`; + break; + default: + statusHTML = '-'; + } + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 设置新的HTML内容 + statusElement.innerHTML = statusHTML; + + // 添加过渡类和对应状态类 + statusElement.classList.add('status-transition'); + + // 如果不是默认状态,添加淡入动画和对应状态类 + if (status !== 'default') { + statusElement.classList.add('status-fade-in'); + statusElement.classList.add(`status-${status}`); + } + + // 如果是成功或失败状态,3秒后渐变消失 + if (status === 'success' || status === 'error') { + setTimeout(() => { + // 添加淡出类 + statusElement.classList.add('status-fade-out'); + + // 等待淡出动画完成后切换到默认状态 + setTimeout(() => { + // 清除所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + // 设置默认状态 + statusElement.innerHTML = '-'; + }, 300); // 与CSS动画持续时间一致 + }, 3000); + } +} + +// 更新规则状态显示函数 +function updateRuleStatus(rule, status, message) { + const statusElement = document.getElementById(`rule-status-${encodeURIComponent(rule)}`); + if (!statusElement) return; + + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + let statusHTML = ''; + + switch (status) { + case 'loading': + statusHTML = ' 处理中...'; + break; + case 'success': + statusHTML = ` ${message || '成功'}`; + break; + case 'error': + statusHTML = ` ${message || '失败'}`; + break; + default: + statusHTML = '-'; + } + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 设置新的HTML内容 + statusElement.innerHTML = statusHTML; + + // 添加过渡类和对应状态类 + statusElement.classList.add('status-transition'); + + // 如果不是默认状态,添加淡入动画和对应状态类 + if (status !== 'default') { + statusElement.classList.add('status-fade-in'); + statusElement.classList.add(`status-${status}`); + } + + // 如果是成功或失败状态,3秒后渐变消失 + if (status === 'success' || status === 'error') { + setTimeout(() => { + // 添加淡出类 + statusElement.classList.add('status-fade-out'); + + // 等待淡出动画完成后切换到默认状态 + setTimeout(() => { + // 清除所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + // 设置默认状态 + statusElement.innerHTML = '-'; + }, 300); // 与CSS动画持续时间一致 + }, 3000); + } +} + +// 数字更新动画函数 +function animateCounter(element, target, duration = 1000) { + // 确保element存在 + if (!element) return; + + // 清除元素上可能存在的现有定时器 + if (element.animationTimer) { + clearInterval(element.animationTimer); + } + + // 确保target是数字 + const targetNum = typeof target === 'number' ? target : parseInt(target) || 0; + + // 获取起始值,使用更安全的方法 + const startText = element.textContent.replace(/[^0-9]/g, ''); + const start = parseInt(startText) || 0; + + // 如果起始值和目标值相同,直接返回 + if (start === targetNum) { + element.textContent = targetNum; + return; + } + + let current = start; + const increment = (targetNum - start) / (duration / 16); // 16ms per frame + + // 使用requestAnimationFrame实现更平滑的动画 + let startTime = null; + + function updateCounter(timestamp) { + if (!startTime) startTime = timestamp; + const elapsed = timestamp - startTime; + const progress = Math.min(elapsed / duration, 1); + + // 使用缓动函数使动画更自然 + const easeOutQuad = progress * (2 - progress); + current = start + (targetNum - start) * easeOutQuad; + + // 根据方向使用floor或ceil确保平滑过渡 + const displayValue = targetNum > start ? Math.floor(current) : Math.ceil(current); + element.textContent = displayValue; + + if (progress < 1) { + // 继续动画 + element.animationTimer = requestAnimationFrame(updateCounter); + } else { + // 动画结束,确保显示准确值 + element.textContent = targetNum; + // 清除定时器引用 + element.animationTimer = null; + } + } + + // 开始动画 + element.animationTimer = requestAnimationFrame(updateCounter); +} + +// 加载屏蔽规则统计信息 +async function loadShieldStats() { + try { + // 获取屏蔽规则统计信息 + const shieldResponse = await fetch('/api/shield'); + + if (!shieldResponse.ok) { + throw new Error(`加载屏蔽统计失败: ${shieldResponse.status}`); + } + + const stats = await shieldResponse.json(); + + // 获取黑名单列表,计算禁用数量 + const blacklistsResponse = await fetch('/api/shield/blacklists'); + + if (!blacklistsResponse.ok) { + throw new Error(`加载黑名单列表失败: ${blacklistsResponse.status}`); + } + + const blacklists = await blacklistsResponse.json(); + const disabledBlacklistCount = blacklists.filter(blacklist => !blacklist.enabled).length; + + // 更新统计信息 + const elements = [ + { id: 'domain-rules-count', value: stats.domainRulesCount }, + { id: 'domain-exceptions-count', value: stats.domainExceptionsCount }, + { id: 'regex-rules-count', value: stats.regexRulesCount }, + { id: 'regex-exceptions-count', value: stats.regexExceptionsCount }, + { id: 'hosts-rules-count', value: stats.hostsRulesCount }, + { id: 'blacklist-count', value: stats.blacklistCount } + ]; + + elements.forEach(item => { + const element = document.getElementById(item.id); + if (element) { + animateCounter(element, item.value || 0); + } + }); + + // 更新禁用黑名单数量 + const disabledBlacklistElement = document.getElementById('blacklist-disabled-count'); + if (disabledBlacklistElement) { + animateCounter(disabledBlacklistElement, disabledBlacklistCount); + } + } catch (error) { + console.error('加载屏蔽规则统计信息失败:', error); + showNotification('加载屏蔽规则统计信息失败', 'error'); + } +} + +// 加载本地规则 +async function loadLocalRules() { + try { + const response = await fetch('/api/shield/localrules'); + + if (!response.ok) { + throw new Error(`加载失败: ${response.status}`); + } + + const data = await response.json(); + + // 更新本地规则数量显示 + if (document.getElementById('local-rules-count')) { + document.getElementById('local-rules-count').textContent = data.localRulesCount || 0; + } + + // 设置当前规则类型 + currentRulesType = 'local'; + + // 合并所有本地规则 + let rules = []; + // 添加域名规则 + if (Array.isArray(data.domainRules)) { + rules = rules.concat(data.domainRules); + } + // 添加域名排除规则 + if (Array.isArray(data.domainExceptions)) { + rules = rules.concat(data.domainExceptions); + } + // 添加正则规则 + if (Array.isArray(data.regexRules)) { + rules = rules.concat(data.regexRules); + } + // 添加正则排除规则 + if (Array.isArray(data.regexExceptions)) { + rules = rules.concat(data.regexExceptions); + } + + updateRulesTable(rules); + } catch (error) { + console.error('加载本地规则失败:', error); + showNotification('加载本地规则失败', 'error'); + } +} + +// 加载远程规则 +async function loadRemoteRules() { + try { + // 设置当前规则类型 + currentRulesType = 'remote'; + const response = await fetch('/api/shield/remoterules'); + + if (!response.ok) { + throw new Error(`加载失败: ${response.status}`); + } + + const data = await response.json(); + + // 更新远程规则数量显示 + if (document.getElementById('remote-rules-count')) { + document.getElementById('remote-rules-count').textContent = data.remoteRulesCount || 0; + } + + // 合并所有远程规则 + let rules = []; + // 添加域名规则 + if (Array.isArray(data.domainRules)) { + rules = rules.concat(data.domainRules); + } + // 添加域名排除规则 + if (Array.isArray(data.domainExceptions)) { + rules = rules.concat(data.domainExceptions); + } + // 添加正则规则 + if (Array.isArray(data.regexRules)) { + rules = rules.concat(data.regexRules); + } + // 添加正则排除规则 + if (Array.isArray(data.regexExceptions)) { + rules = rules.concat(data.regexExceptions); + } + + updateRulesTable(rules); + } catch (error) { + console.error('加载远程规则失败:', error); + showNotification('加载远程规则失败', 'error'); + } +} + +// 更新规则表格 +function updateRulesTable(rules) { + const tbody = document.getElementById('rules-table-body'); + + // 清空表格 + tbody.innerHTML = ''; + + if (rules.length === 0) { + const emptyRow = document.createElement('tr'); + emptyRow.innerHTML = '暂无规则'; + tbody.appendChild(emptyRow); + return; + } + + // 对于大量规则,限制显示数量 + const maxRulesToShow = 1000; // 限制最大显示数量 + const rulesToShow = rules.length > maxRulesToShow ? rules.slice(0, maxRulesToShow) : rules; + + // 使用DocumentFragment提高性能 + const fragment = document.createDocumentFragment(); + + rulesToShow.forEach(rule => { + const tr = document.createElement('tr'); + tr.className = 'border-b border-gray-200'; + + const tdRule = document.createElement('td'); + tdRule.className = 'py-3 px-4'; + tdRule.textContent = rule; + + const tdStatus = document.createElement('td'); + tdStatus.className = 'py-3 px-4 text-center'; + tdStatus.id = `rule-status-${encodeURIComponent(rule)}`; + tdStatus.innerHTML = '-'; + + const tdAction = document.createElement('td'); + tdAction.className = 'py-3 px-4 text-right'; + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'delete-rule-btn px-3 py-1 bg-danger text-white rounded-md hover:bg-danger/90 transition-colors text-sm'; + deleteBtn.dataset.rule = rule; + + // 创建删除图标 + const deleteIcon = document.createElement('i'); + deleteIcon.className = 'fa fa-trash'; + deleteIcon.style.pointerEvents = 'none'; // 防止图标拦截点击事件 + + deleteBtn.appendChild(deleteIcon); + + // 使用普通函数,确保this指向按钮元素 + deleteBtn.onclick = function(e) { + e.stopPropagation(); // 阻止事件冒泡 + handleDeleteRule(e); + }; + + tdAction.appendChild(deleteBtn); + + tr.appendChild(tdRule); + tr.appendChild(tdStatus); + tr.appendChild(tdAction); + fragment.appendChild(tr); + }); + + // 一次性添加所有行到DOM + tbody.appendChild(fragment); + + // 如果有更多规则,添加提示 + if (rules.length > maxRulesToShow) { + const infoRow = document.createElement('tr'); + infoRow.innerHTML = `显示前 ${maxRulesToShow} 条规则,共 ${rules.length} 条`; + tbody.appendChild(infoRow); + } +} + +// 处理删除规则 +async function handleDeleteRule(e) { + console.log('Delete button clicked'); + let deleteBtn; + + // 尝试从事件目标获取按钮元素 + deleteBtn = e.target.closest('.delete-rule-btn'); + console.log('Delete button from event target:', deleteBtn); + + // 尝试从this获取按钮元素(备用方案) + if (!deleteBtn && this && typeof this.classList === 'object' && this.classList) { + if (this.classList.contains('delete-rule-btn')) { + deleteBtn = this; + console.log('Delete button from this:', deleteBtn); + } + } + + if (!deleteBtn) { + console.error('Delete button not found'); + return; + } + + const rule = deleteBtn.dataset.rule; + console.log('Rule to delete:', rule); + + if (!rule) { + console.error('Rule not found in data-rule attribute'); + return; + } + + try { + // 显示加载状态 + updateRuleStatus(rule, 'loading'); + + console.log('Sending DELETE request to /api/shield'); + const response = await fetch('/api/shield', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ rule }) + }); + + console.log('Response status:', response.status); + console.log('Response ok:', response.ok); + + // 解析服务器响应 + let responseData; + try { + responseData = await response.json(); + } catch (jsonError) { + responseData = {}; + } + + console.log('Response data:', responseData); + + // 根据服务器响应判断是否成功 + if (response.ok && responseData.status === 'success') { + // 显示成功状态 + updateRuleStatus(rule, 'success', '已删除'); + + showNotification('规则删除成功', 'success'); + console.log('Current rules type:', currentRulesType); + + // 延迟重新加载规则列表和统计信息,让用户能看到成功状态 + setTimeout(() => { + // 根据当前显示的规则类型重新加载对应的规则列表 + if (currentRulesType === 'local') { + console.log('Reloading local rules'); + loadLocalRules(); + } else { + console.log('Reloading remote rules'); + loadRemoteRules(); + } + // 重新加载统计信息 + loadShieldStats(); + }, 3000); + } else { + const errorMessage = responseData.error || responseData.message || `删除规则失败: ${response.status}`; + // 显示错误状态 + updateRuleStatus(rule, 'error', errorMessage); + throw new Error(errorMessage); + } + } catch (error) { + console.error('Error deleting rule:', error); + // 显示错误状态 + updateRuleStatus(rule, 'error', error.message); + showNotification('删除规则失败: ' + error.message, 'error'); + } +} + +// 添加新规则 +async function handleAddRule() { + const rule = document.getElementById('new-rule').value.trim(); + const statusElement = document.getElementById('save-rule-status'); + + if (!rule) { + showNotification('规则不能为空', 'error'); + return; + } + + try { + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + // 显示加载状态 + statusElement.innerHTML = ' 正在添加...'; + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 添加过渡类和加载状态类 + statusElement.classList.add('status-transition', 'status-fade-in', 'status-loading'); + + const response = await fetch('/api/shield', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ rule }) + }); + + // 解析服务器响应 + let responseData; + try { + responseData = await response.json(); + } catch (jsonError) { + responseData = {}; + } + + // 根据服务器响应判断是否成功 + if (response.ok && responseData.status === 'success') { + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + // 显示成功状态 + statusElement.innerHTML = ' 成功'; + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 添加过渡类和成功状态类 + statusElement.classList.add('status-transition', 'status-fade-in', 'status-success'); + + showNotification('规则添加成功', 'success'); + // 清空输入框 + document.getElementById('new-rule').value = ''; + + // 延迟重新加载规则和统计信息,让用户能看到成功状态 + setTimeout(() => { + // 重新加载规则 + loadLocalRules(); + // 重新加载统计信息 + loadShieldStats(); + }, 3000); + } else { + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + // 显示失败状态 + const errorMessage = responseData.error || responseData.message || '添加规则失败'; + statusElement.innerHTML = ` ${errorMessage}`; + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 添加过渡类和错误状态类 + statusElement.classList.add('status-transition', 'status-fade-in', 'status-error'); + + showNotification(errorMessage, 'error'); + } + } catch (error) { + console.error('Error adding rule:', error); + + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + // 显示错误状态 + const errorMessage = error.message || '添加规则失败'; + statusElement.innerHTML = ` ${errorMessage}`; + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 添加过渡类和错误状态类 + statusElement.classList.add('status-transition', 'status-fade-in', 'status-error'); + + showNotification(errorMessage, 'error'); + } finally { + // 3秒后渐变消失 + setTimeout(() => { + // 添加淡出类 + statusElement.classList.add('status-fade-out'); + + // 等待淡出动画完成后清除状态 + setTimeout(() => { + // 清除所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + // 清空状态显示 + statusElement.innerHTML = ''; + }, 300); // 与CSS动画持续时间一致 + }, 3000); + } +} + +// 加载远程黑名单 +async function loadRemoteBlacklists() { + try { + const response = await fetch('/api/shield/blacklists'); + + if (!response.ok) { + throw new Error(`加载失败: ${response.status}`); + } + + const blacklists = await response.json(); + + // 确保blacklists是数组 + const blacklistArray = Array.isArray(blacklists) ? blacklists : []; + updateBlacklistsTable(blacklistArray); + } catch (error) { + console.error('加载远程黑名单失败:', error); + showNotification('加载远程黑名单失败', 'error'); + } +} + +// 判断黑名单是否过期(超过24小时未更新视为过期) +function isBlacklistExpired(lastUpdateTime) { + if (!lastUpdateTime) { + return true; // 从未更新过,视为过期 + } + + const lastUpdate = new Date(lastUpdateTime); + const now = new Date(); + const hoursDiff = (now - lastUpdate) / (1000 * 60 * 60); + + return hoursDiff > 24; // 超过24小时视为过期 +} + +// 更新黑名单表格 +function updateBlacklistsTable(blacklists) { + const tbody = document.getElementById('blacklists-table-body'); + + // 清空表格 + tbody.innerHTML = ''; + + // 检查黑名单数据是否为空 + if (!blacklists || blacklists.length === 0) { + const emptyRow = document.createElement('tr'); + emptyRow.innerHTML = '暂无黑名单'; + tbody.appendChild(emptyRow); + return; + } + + // 对于大量黑名单,限制显示数量 + const maxBlacklistsToShow = 100; // 限制最大显示数量 + const blacklistsToShow = blacklists.length > maxBlacklistsToShow ? blacklists.slice(0, maxBlacklistsToShow) : blacklists; + + // 使用DocumentFragment提高性能 + const fragment = document.createDocumentFragment(); + + blacklistsToShow.forEach(blacklist => { + const tr = document.createElement('tr'); + tr.className = 'border-b border-gray-200 hover:bg-gray-50'; + + // 名称单元格 + const tdName = document.createElement('td'); + tdName.className = 'py-3 px-4'; + tdName.textContent = blacklist.name || '未命名'; + + // URL单元格 + const tdUrl = document.createElement('td'); + tdUrl.className = 'py-3 px-4 truncate max-w-xs'; + tdUrl.textContent = blacklist.url; + + // 状态单元格 + const tdStatus = document.createElement('td'); + tdStatus.className = 'py-3 px-4 text-center'; + + // 判断状态颜色:绿色(启用)、灰色(禁用) + let statusColor = 'bg-gray-300'; // 默认禁用 + let statusText = '禁用'; + + if (blacklist.enabled) { + statusColor = 'bg-success'; // 绿色表示启用 + statusText = '启用'; + } + + const statusContainer = document.createElement('div'); + statusContainer.className = 'flex items-center justify-center'; + + const statusDot = document.createElement('span'); + statusDot.className = `inline-block w-3 h-3 rounded-full ${statusColor}`; + statusDot.title = statusText; + + const statusTextSpan = document.createElement('span'); + statusTextSpan.className = 'text-sm ml-2'; + statusTextSpan.textContent = statusText; + + statusContainer.appendChild(statusDot); + statusContainer.appendChild(statusTextSpan); + tdStatus.appendChild(statusContainer); + + // 更新状态单元格 + const tdUpdateStatus = document.createElement('td'); + tdUpdateStatus.className = 'py-3 px-4 text-center'; + tdUpdateStatus.id = `update-status-${encodeURIComponent(blacklist.url)}`; + tdUpdateStatus.innerHTML = '-'; + + // 操作单元格 + const tdActions = document.createElement('td'); + tdActions.className = 'py-3 px-4 text-right space-x-2'; + + // 启用/禁用按钮 + const toggleBtn = document.createElement('button'); + toggleBtn.className = `toggle-blacklist-btn px-3 py-1 rounded-md transition-colors text-sm ${blacklist.enabled ? 'bg-warning text-white hover:bg-warning/90' : 'bg-success text-white hover:bg-success/90'}`; + toggleBtn.dataset.url = blacklist.url; + toggleBtn.dataset.enabled = blacklist.enabled; + toggleBtn.innerHTML = ``; + toggleBtn.title = blacklist.enabled ? '禁用黑名单' : '启用黑名单'; + toggleBtn.addEventListener('click', handleToggleBlacklist); + + // 刷新按钮 + const refreshBtn = document.createElement('button'); + refreshBtn.className = 'update-blacklist-btn px-3 py-1 bg-primary text-white rounded-md hover:bg-primary/90 transition-colors text-sm'; + refreshBtn.dataset.url = blacklist.url; + refreshBtn.innerHTML = ''; + refreshBtn.title = '刷新黑名单'; + refreshBtn.addEventListener('click', handleUpdateBlacklist); + + // 删除按钮 + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'delete-blacklist-btn px-3 py-1 bg-danger text-white rounded-md hover:bg-danger/90 transition-colors text-sm'; + deleteBtn.dataset.url = blacklist.url; + deleteBtn.innerHTML = ''; + deleteBtn.title = '删除黑名单'; + deleteBtn.addEventListener('click', handleDeleteBlacklist); + + tdActions.appendChild(toggleBtn); + tdActions.appendChild(refreshBtn); + tdActions.appendChild(deleteBtn); + + tr.appendChild(tdName); + tr.appendChild(tdUrl); + tr.appendChild(tdStatus); + tr.appendChild(tdUpdateStatus); + tr.appendChild(tdActions); + fragment.appendChild(tr); + }); + + // 一次性添加所有行到DOM + tbody.appendChild(fragment); + + // 如果有更多黑名单,添加提示 + if (blacklists.length > maxBlacklistsToShow) { + const infoRow = document.createElement('tr'); + infoRow.innerHTML = `显示前 ${maxBlacklistsToShow} 个黑名单,共 ${blacklists.length} 个`; + tbody.appendChild(infoRow); + } +} + +// 处理更新单个黑名单 +async function handleUpdateBlacklist(e) { + // 确保获取到正确的按钮元素 + const btn = e.target.closest('.update-blacklist-btn'); + if (!btn) { + console.error('未找到更新按钮元素'); + return; + } + + const url = btn.dataset.url; + + if (!url) { + showNotification('无效的黑名单URL', 'error'); + return; + } + + try { + // 显示加载状态 + updateStatus(url, 'loading'); + + // 获取当前所有黑名单 + const response = await fetch('/api/shield/blacklists'); + if (!response.ok) { + throw new Error(`获取黑名单失败: ${response.status}`); + } + + const blacklists = await response.json(); + + // 找到目标黑名单并更新其状态 + const updatedBlacklists = blacklists.map(blacklist => { + if (blacklist.url === url) { + return { + Name: blacklist.name, + URL: blacklist.url, + Enabled: blacklist.enabled, + LastUpdateTime: new Date().toISOString() + }; + } + return { + Name: blacklist.name, + URL: blacklist.url, + Enabled: blacklist.enabled, + LastUpdateTime: blacklist.lastUpdateTime || blacklist.LastUpdateTime + }; + }); + + // 发送更新请求 + const updateResponse = await fetch('/api/shield/blacklists', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(updatedBlacklists) + }); + + // 解析服务器响应 + let responseData; + try { + responseData = await updateResponse.json(); + } catch (jsonError) { + responseData = {}; + } + + // 根据服务器响应判断是否成功 + if (updateResponse.ok && (responseData.status === 'success' || !responseData.status)) { + // 显示成功状态 + updateStatus(url, 'success'); + + // 显示通知 + showNotification('黑名单更新成功', 'success'); + + // 延迟重新加载黑名单和统计信息,让用户能看到成功状态 + setTimeout(() => { + // 重新加载黑名单 + loadRemoteBlacklists(); + // 重新加载统计信息 + loadShieldStats(); + }, 3000); + } else { + // 显示失败状态 + updateStatus(url, 'error', responseData.error || responseData.message || `更新失败: ${updateResponse.status}`); + showNotification(`黑名单更新失败: ${responseData.error || responseData.message || updateResponse.status}`, 'error'); + + // 延迟重新加载黑名单和统计信息 + setTimeout(() => { + // 重新加载黑名单 + loadRemoteBlacklists(); + // 重新加载统计信息 + loadShieldStats(); + }, 3000); + } + } catch (error) { + console.error('更新黑名单失败:', error); + // 显示错误状态 + updateStatus(url, 'error', error.message); + showNotification('更新黑名单失败: ' + error.message, 'error'); + } +} + +// 处理删除黑名单 +async function handleDeleteBlacklist(e) { + // 确保获取到正确的按钮元素 + const btn = e.target.closest('.delete-blacklist-btn'); + if (!btn) { + console.error('未找到删除按钮元素'); + return; + } + + const url = btn.dataset.url; + + if (!url) { + showNotification('无效的黑名单URL', 'error'); + return; + } + + // 确认删除 + if (!confirm('确定要删除这个黑名单吗?删除后将无法恢复。')) { + return; + } + + try { + // 获取当前行元素 + const tr = btn.closest('tr'); + if (!tr) { + console.error('未找到行元素'); + return; + } + + // 显示加载状态 + updateStatus(url, 'loading'); + + // 获取当前所有黑名单 + const response = await fetch('/api/shield/blacklists'); + if (!response.ok) { + throw new Error(`获取黑名单失败: ${response.status}`); + } + + const blacklists = await response.json(); + + // 过滤掉要删除的黑名单 + const updatedBlacklists = blacklists.filter(blacklist => blacklist.url !== url); + + // 发送更新请求 + const updateResponse = await fetch('/api/shield/blacklists', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(updatedBlacklists) + }); + + // 解析服务器响应 + let responseData; + try { + responseData = await updateResponse.json(); + } catch (jsonError) { + responseData = {}; + } + + // 根据服务器响应判断是否成功 + if (updateResponse.ok && responseData.status === 'success') { + // 显示成功状态 + updateStatus(url, 'success', '已删除'); + + // 显示通知 + showNotification('黑名单删除成功', 'success'); + + // 延迟后渐变移除该行 + setTimeout(() => { + // 添加渐变移除类 + tr.style.transition = 'all 0.3s ease-in-out'; + tr.style.opacity = '0'; + tr.style.transform = 'translateX(-10px)'; + tr.style.height = tr.offsetHeight + 'px'; + tr.style.overflow = 'hidden'; + + // 等待过渡效果完成后,隐藏该行 + setTimeout(() => { + tr.style.display = 'none'; + + // 延迟重新加载黑名单和统计信息,确保视觉效果完成 + setTimeout(() => { + // 重新加载黑名单 + loadRemoteBlacklists(); + // 重新加载统计信息 + loadShieldStats(); + }, 100); + }, 300); + }, 3000); + } else { + // 显示失败状态 + const errorMessage = responseData.error || responseData.message || `删除失败: ${updateResponse.status}`; + updateStatus(url, 'error', errorMessage); + showNotification(errorMessage, 'error'); + + // 延迟重新加载黑名单和统计信息 + setTimeout(() => { + // 重新加载黑名单 + loadRemoteBlacklists(); + // 重新加载统计信息 + loadShieldStats(); + }, 3000); + } + } catch (error) { + console.error('删除黑名单失败:', error); + // 显示错误状态 + updateStatus(url, 'error', error.message); + showNotification('删除黑名单失败: ' + error.message, 'error'); + } +} + +// 处理启用/禁用黑名单 +async function handleToggleBlacklist(e) { + // 确保获取到正确的按钮元素 + const btn = e.target.closest('.toggle-blacklist-btn'); + if (!btn) { + console.error('未找到启用/禁用按钮元素'); + return; + } + + const url = btn.dataset.url; + const currentEnabled = btn.dataset.enabled === 'true'; + + if (!url) { + showNotification('无效的黑名单URL', 'error'); + return; + } + + try { + // 显示加载状态 + updateStatus(url, 'loading'); + + // 获取当前所有黑名单 + const response = await fetch('/api/shield/blacklists'); + if (!response.ok) { + throw new Error(`获取黑名单失败: ${response.status}`); + } + + const blacklists = await response.json(); + + // 找到目标黑名单并更新其状态 + const updatedBlacklists = blacklists.map(blacklist => { + if (blacklist.url === url) { + return { + Name: blacklist.name, + URL: blacklist.url, + Enabled: !currentEnabled, + LastUpdateTime: blacklist.lastUpdateTime || blacklist.LastUpdateTime + }; + } + return { + Name: blacklist.name, + URL: blacklist.url, + Enabled: blacklist.enabled, + LastUpdateTime: blacklist.lastUpdateTime || blacklist.LastUpdateTime + }; + }); + + // 发送更新请求 + const updateResponse = await fetch('/api/shield/blacklists', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(updatedBlacklists) + }); + + // 解析服务器响应 + let responseData; + try { + responseData = await updateResponse.json(); + } catch (jsonError) { + responseData = {}; + } + + // 根据服务器响应判断是否成功 + if (updateResponse.ok && responseData.status === 'success') { + // 显示成功状态 + updateStatus(url, 'success', currentEnabled ? '已禁用' : '已启用'); + + // 显示通知 + showNotification(`黑名单已${currentEnabled ? '禁用' : '启用'}`, 'success'); + + // 延迟重新加载黑名单和统计信息,让用户能看到成功状态 + setTimeout(() => { + // 重新加载黑名单 + loadRemoteBlacklists(); + // 重新加载统计信息 + loadShieldStats(); + }, 3000); + } else { + // 显示失败状态 + const errorMessage = responseData.error || responseData.message || `更新状态失败: ${updateResponse.status}`; + updateStatus(url, 'error', errorMessage); + showNotification(errorMessage, 'error'); + + // 延迟重新加载黑名单和统计信息 + setTimeout(() => { + // 重新加载黑名单 + loadRemoteBlacklists(); + // 重新加载统计信息 + loadShieldStats(); + }, 3000); + } + } catch (error) { + console.error('启用/禁用黑名单失败:', error); + // 显示错误状态 + updateStatus(url, 'error', error.message); + showNotification('启用/禁用黑名单失败: ' + error.message, 'error'); + } +} + +// 处理添加黑名单 +async function handleAddBlacklist(event) { + // 如果存在event参数,则调用preventDefault()防止表单默认提交 + if (event && typeof event.preventDefault === 'function') { + event.preventDefault(); + } + + const nameInput = document.getElementById('blacklist-name'); + const urlInput = document.getElementById('blacklist-url'); + const statusElement = document.getElementById('save-blacklist-status'); + + const name = nameInput ? nameInput.value.trim() : ''; + const url = urlInput ? urlInput.value.trim() : ''; + + // 简单验证 + if (!name || !url) { + showNotification('名称和URL不能为空', 'error'); + return; + } + + // 验证URL格式 + try { + new URL(url); + } catch (e) { + showNotification('URL格式不正确', 'error'); + return; + } + + try { + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + // 显示加载状态 + statusElement.innerHTML = ' 正在添加...'; + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 添加过渡类和加载状态类 + statusElement.classList.add('status-transition', 'status-fade-in', 'status-loading'); + + // 发送添加请求 + const response = await fetch('/api/shield/blacklists', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name, url }) + }); + + // 解析服务器响应 + let responseData; + try { + responseData = await response.json(); + } catch (jsonError) { + responseData = {}; + } + + // 根据服务器响应判断是否成功 + if (response.ok && (responseData.status === 'success' || !responseData.status)) { + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + // 显示成功状态 + statusElement.innerHTML = ' 成功'; + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 添加过渡类和成功状态类 + statusElement.classList.add('status-transition', 'status-fade-in', 'status-success'); + + showNotification('黑名单添加成功', 'success'); + // 清空输入框 + if (nameInput) nameInput.value = ''; + if (urlInput) urlInput.value = ''; + // 重新加载黑名单 + loadRemoteBlacklists(); + // 重新加载统计信息 + loadShieldStats(); + } else { + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + // 显示失败状态 + const errorMessage = responseData.error || responseData.message || `添加失败: ${response.status}`; + statusElement.innerHTML = ` ${errorMessage}`; + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 添加过渡类和错误状态类 + statusElement.classList.add('status-transition', 'status-fade-in', 'status-error'); + + showNotification(errorMessage, 'error'); + } + } catch (error) { + console.error('Error adding blacklist:', error); + + // 清除之前的所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + + // 显示错误状态 + const errorMessage = error.message || '添加黑名单失败'; + statusElement.innerHTML = ` ${errorMessage}`; + + // 强制重排,确保过渡效果生效 + void statusElement.offsetWidth; + + // 添加过渡类和错误状态类 + statusElement.classList.add('status-transition', 'status-fade-in', 'status-error'); + + showNotification(errorMessage, 'error'); + } finally { + // 3秒后渐变消失 + setTimeout(() => { + // 添加淡出类 + statusElement.classList.add('status-fade-out'); + + // 等待淡出动画完成后清除状态 + setTimeout(() => { + // 清除所有类 + statusElement.classList.remove('status-transition', 'status-loading', 'status-success', 'status-error', 'status-fade-in', 'status-fade-out'); + // 清空状态显示 + statusElement.innerHTML = ''; + }, 300); // 与CSS动画持续时间一致 + }, 3000); + } +} + + + +// 当前显示的规则类型:'local' 或 'remote' +let currentRulesType = 'local'; + +// 设置事件监听器 +function setupShieldEventListeners() { + // 本地规则管理事件 + const saveRuleBtn = document.getElementById('save-rule-btn'); + if (saveRuleBtn) { + saveRuleBtn.addEventListener('click', handleAddRule); + } + + // 远程黑名单管理事件 + const saveBlacklistBtn = document.getElementById('save-blacklist-btn'); + if (saveBlacklistBtn) { + saveBlacklistBtn.addEventListener('click', handleAddBlacklist); + } + + // 添加切换查看本地规则和远程规则的事件监听 + const viewLocalRulesBtn = document.getElementById('view-local-rules-btn'); + if (viewLocalRulesBtn) { + viewLocalRulesBtn.addEventListener('click', loadLocalRules); + } + + const viewRemoteRulesBtn = document.getElementById('view-remote-rules-btn'); + if (viewRemoteRulesBtn) { + viewRemoteRulesBtn.addEventListener('click', loadRemoteRules); + } +} + +// 显示成功消息 +function showSuccessMessage(message) { + showNotification(message, 'success'); +} + +// 显示错误消息 +function showErrorMessage(message) { + showNotification(message, 'error'); +} + + + +// 显示通知 +function showNotification(message, type = 'info') { + // 移除现有通知 + const existingNotification = document.querySelector('.notification'); + if (existingNotification) { + existingNotification.remove(); + } + + // 创建新通知 + const notification = document.createElement('div'); + notification.className = `notification fixed bottom-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 transform transition-all duration-300 ease-in-out translate-y-0 opacity-0`; + + // 设置通知样式 + if (type === 'success') { + notification.classList.add('bg-green-500', 'text-white'); + } else if (type === 'error') { + notification.classList.add('bg-red-500', 'text-white'); + } else { + notification.classList.add('bg-blue-500', 'text-white'); + } + + notification.innerHTML = ` +
+ + ${message} +
+ `; + + document.body.appendChild(notification); + + // 显示通知 + setTimeout(() => { + notification.classList.remove('opacity-0'); + notification.classList.add('opacity-100'); + }, 10); + + // 3秒后隐藏通知 + setTimeout(() => { + notification.classList.remove('opacity-100'); + notification.classList.add('opacity-0'); + setTimeout(() => { + notification.remove(); + }, 300); + }, 3000); +} + +// 页面加载完成后初始化 +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initShieldPage); +} else { + initShieldPage(); +} + +// 当切换到屏蔽管理页面时重新加载数据 +document.addEventListener('DOMContentLoaded', () => { + // 监听hash变化,当切换到屏蔽管理页面时重新加载数据 + window.addEventListener('hashchange', () => { + if (window.location.hash === '#shield') { + initShieldPage(); + } + }); +}); \ No newline at end of file diff --git a/js/vendor/chart.umd.min.js b/js/vendor/chart.umd.min.js new file mode 100644 index 0000000..eafab1b --- /dev/null +++ b/js/vendor/chart.umd.min.js @@ -0,0 +1 @@ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,function(){"use strict";var s=Object.freeze({__proto__:null,get Colors(){return dn},get Decimation(){return fn},get Filler(){return Dn},get Legend(){return Tn},get SubTitle(){return In},get Title(){return En},get Tooltip(){return Un}});function t(){}const F=(()=>{let t=0;return()=>t++})();function P(t){return null==t}function O(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function A(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function p(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function g(t,e){return p(t)?t:e}function T(t,e){return void 0===t?e:t}const V=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,B=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function k(t,e,i,s){let a,n,o;if(O(t))if(n=t.length,s)for(a=n-1;0<=a;a--)e.call(i,t[a],a);else for(a=0;at,x:t=>t.x,y:t=>t.y};function q(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s=s.endsWith("\\")?s.slice(0,-1)+".":(i.push(s),"");return i}function m(t,e){const i=X[e]||(X[e]=function(){const i=q(e);return t=>{for(const e of i){if(""===e)break;t=t&&t[e]}return t}}());return i(t)}function K(t){return t.charAt(0).toUpperCase()+t.slice(1)}const G=t=>void 0!==t,u=t=>"function"==typeof t,Z=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function J(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const S=Math.PI,_=2*S,Q=_+S,tt=Number.POSITIVE_INFINITY,et=S/180,D=S/2,it=S/4,st=2*S/3,r=Math.log10,y=Math.sign;function at(t,e,i){return Math.abs(t-e)t-e).pop(),e}function rt(t){return!isNaN(parseFloat(t))&&isFinite(t)}function lt(t,e){var i=Math.round(t);return i-e<=t&&t<=i+e}function ht(t,e,i){let s,a,n;for(s=0,a=t.length;s=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function bt(e,i,t){t=t||(t=>e[t]>1)?n=s:a=s;return{lo:n,hi:a}}const f=(i,s,a,t)=>bt(i,a,t?t=>{var e=i[t][s];return ei[t][s]bt(e,s,t=>e[t][i]>=s);function vt(t,e,i){let s=0,a=t.length;for(;ss&&t[a-1]>i;)a--;return 0{const i="_onData"+K(t),s=a[t];Object.defineProperty(a,t,{configurable:!0,enumerable:!1,value(...e){var t=s.apply(this,e);return a._chartjs.listeners.forEach(t=>{"function"==typeof t[i]&&t[i](...e)}),t}})}))}function Mt(e,t){var i=e._chartjs;if(i){const s=i.listeners,a=s.indexOf(t);-1!==a&&s.splice(a,1),0{delete e[t]}),delete e._chartjs)}}function wt(t){var e=new Set(t);return e.size===t.length?t:Array.from(e)}const kt="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function St(e,i){let s,a=!1;return function(...t){s=t,a||(a=!0,kt.call(window,()=>{a=!1,e.apply(i,s)}))}}function Pt(e,i){let s;return function(...t){return i?(clearTimeout(s),s=setTimeout(e,i,t)):e.apply(this,t),i}}const Dt=t=>"start"===t?"left":"end"===t?"right":"center",E=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,Ct=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function Ot(t,e,i){var s=e.length;let a=0,n=s;if(t._sorted){const{iScale:o,_parsed:r}=t,l=o.axis,{min:h,max:c,minDefined:d,maxDefined:u}=o.getUserBounds();d&&(a=C(Math.min(f(r,l,h).lo,i?s:f(e,l,o.getPixelForValue(h)).lo),0,s-1)),n=u?C(Math.max(f(r,o.axis,c,!0).hi+1,i?0:f(e,l,o.getPixelForValue(c),!0).hi+1),a,s)-a:s-a}return{start:a,count:n}}function At(t){var{xScale:e,yScale:i,_scaleRanges:s}=t,a={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=a,!0;t=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,a),t}var l=new class{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(e,i,s,t){const a=i.listeners[t],n=i.duration;a.forEach(t=>t({chart:e,initial:i.initial,numSteps:n,currentStep:Math.min(s-i.start,n)}))}_refresh(){this._request||(this._running=!0,this._request=kt.call(window,()=>{this._update(),this._request=null,this._running&&this._refresh()}))}_update(o=Date.now()){let r=0;this._charts.forEach((s,a)=>{if(s.running&&s.items.length){const n=s.items;let t,e=n.length-1,i=!1;for(;0<=e;--e)(t=n[e])._active?(t._total>s.duration&&(s.duration=t._total),t.tick(o),i=!0):(n[e]=n[n.length-1],n.pop());i&&(a.draw(),this._notify(a,s,o,"progress")),n.length||(s.running=!1,this._notify(a,s,o,"complete"),s.initial=!1),r+=n.length}}),this._lastDate=o,0===r&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return 0Math.max(t,e._duration),0),this._refresh())}running(t){if(!this._running)return!1;t=this._charts.get(t);return!!(t&&t.running&&t.items.length)}stop(e){const i=this._charts.get(e);if(i&&i.items.length){const s=i.items;let t=s.length-1;for(;0<=t;--t)s[t].cancel();i.items=[],this._notify(e,i,Date.now(),"complete")}}remove(t){return this._charts.delete(t)}};function Tt(t){return t+.5|0}const Lt=(t,e,i)=>Math.max(Math.min(t,i),e);function Et(t){return Lt(Tt(2.55*t),0,255)}function Rt(t){return Lt(Tt(255*t),0,255)}function o(t){return Lt(Tt(t/2.55)/100,0,1)}function It(t){return Lt(Tt(100*t),0,100)}const n={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},zt=[..."0123456789ABCDEF"],Ft=t=>zt[15&t],Vt=t=>zt[(240&t)>>4]+zt[15&t],Bt=t=>(240&t)>>4==(15&t);const Wt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Nt(i,t,s){const a=t*Math.min(s,1-s),e=(t,e=(t+i/30)%12)=>s-a*Math.max(Math.min(e-3,9-e,1),-1);return[e(0),e(8),e(4)]}function Ht(i,s,a){i=(t,e=(t+i/60)%6)=>a-a*s*Math.max(Math.min(e,4-e,1),0);return[i(5),i(3),i(1)]}function jt(t,e,i){const s=Nt(t,1,.5);let a;for(1t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Qt=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function te(e,i,s){if(e){let t=Yt(e);t[i]=Math.max(0,Math.min(t[i]+t[i]*s,0===i?360:1)),t=Ut(t),e.r=t[0],e.g=t[1],e.b=t[2]}}function ee(t,e){return t&&Object.assign(e||{},t)}function ie(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?3<=t.length&&(e={r:t[0],g:t[1],b:t[2],a:255},3>16&255,n>>8&255,255&n]}return t}()).transparent=[0,0,0,0]),(i=Gt[i.toLowerCase()])&&{r:i[0],g:i[1],b:i[2],a:4===i.length?i[3]:255})||se(t)),this._rgb=a,this._valid=!!a}get valid(){return this._valid}get rgb(){var t=ee(this._rgb);return t&&(t.a=o(t.a)),t}set rgb(t){this._rgb=ie(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${o(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?(t=this._rgb,e=t,e=Bt(e.r)&&Bt(e.g)&&Bt(e.b)&&Bt(e.a)?Ft:Vt,t?"#"+e(t.r)+e(t.g)+e(t.b)+((t=t.a)<255?e(t):""):void 0):void 0;var t,e}hslString(){if(this._valid){var t,e,i,s=this._rgb;if(s)return i=Yt(s),t=i[0],e=It(i[1]),i=It(i[2]),s.a<255?`hsla(${t}, ${e}%, ${i}%, ${o(s.a)})`:`hsl(${t}, ${e}%, ${i}%)`}}mix(t,e){if(t){const s=this.rgb,a=t.rgb;var t=void 0===e?.5:e,e=2*t-1,i=s.a-a.a,e=(1+(e*i==-1?e:(e+i)/(1+e*i)))/2,i=1-e;s.r=255&e*s.r+i*a.r+.5,s.g=255&e*s.g+i*a.g+.5,s.b=255&e*s.b+i*a.b+.5,s.a=t*s.a+(1-t)*a.a,this.rgb=s}return this}interpolate(t,e){return t&&(this._rgb=(i=this._rgb,t=t._rgb,e=e,s=Qt(o(i.r)),a=Qt(o(i.g)),n=Qt(o(i.b)),{r:Rt(Jt(s+e*(Qt(o(t.r))-s))),g:Rt(Jt(a+e*(Qt(o(t.g))-a))),b:Rt(Jt(n+e*(Qt(o(t.b))-n))),a:i.a+e*(t.a-i.a)})),this;var i,s,a,n}clone(){return new ae(this.rgb)}alpha(t){return this._rgb.a=Rt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=Tt(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return te(this._rgb,2,t),this}darken(t){return te(this._rgb,2,-t),this}saturate(t){return te(this._rgb,1,t),this}desaturate(t){return te(this._rgb,1,-t),this}rotate(t){return e=this._rgb,t=t,(i=Yt(e))[0]=Xt(i[0]+t),i=Ut(i),e.r=i[0],e.g=i[1],e.b=i[2],this;var e,i}}function ne(t){return!(!t||"object"!=typeof t)&&("[object CanvasPattern]"===(t=t.toString())||"[object CanvasGradient]"===t)}function oe(t){return ne(t)?t:new ae(t)}function re(t){return ne(t)?t:new ae(t).saturate(.5).darken(.1).hexString()}const le=["x","y","borderWidth","radius","tension"],he=["color","borderColor","backgroundColor"],ce=new Map;function de(t,e,a){return function(t,e){e=a||{};var i=t+JSON.stringify(e);let s=ce.get(i);return s||(s=new Intl.NumberFormat(t,e),ce.set(i,s)),s}(e).format(t)}const ue={values:t=>O(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";var s=this.chart.options.locale;let a,n=t;if(1.8*i.length?ue.numeric.call(this,t,e,i):""}};var ge={formatters:ue};const fe=Object.create(null),pe=Object.create(null);function me(i,t){if(!t)return i;var s=t.split(".");for(let t=0,e=s.length;tt.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>re(e.backgroundColor),this.hoverBorderColor=(t,e)=>re(e.borderColor),this.hoverColor=(t,e)=>re(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return be(this,t,e)}get(t){return me(this,t)}describe(t,e){return be(pe,t,e)}override(t,e){return be(fe,t,e)}route(t,e,i,s){const a=me(this,t),n=me(this,i),o="_"+e;Object.defineProperties(a,{[o]:{value:a[e],writable:!0},[e]:{enumerable:!0,get(){var t=this[o],e=n[s];return A(t)?Object.assign({},e,t):T(t,e)},set(t){this[o]=t}}})}apply(t){t.forEach(t=>t(this))}}({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[function(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:he},numbers:{type:"number",properties:le}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})},function(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})},function(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:ge.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}]);function xe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ve(t){let e=t.parentNode;return e=e&&"[object ShadowRoot]"===e.toString()?e.host:e}function _e(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const ye=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function Me(t,e){return ye(t).getPropertyValue(e)}const we=["top","right","bottom","left"];function ke(e,i,s){const a={};s=s?"-"+s:"";for(let t=0;t<4;t++){var n=we[t];a[n]=parseFloat(e[i+"-"+n+s])||0}return a.width=a.left+a.right,a.height=a.top+a.bottom,a}function Se(t,e){if("native"in t)return t;var{canvas:i,currentDevicePixelRatio:s}=e,a=ye(i),n="border-box"===a.boxSizing,o=ke(a,"padding"),a=ke(a,"border","width"),{x:t,y:r,box:l}=function(t,e){var i,s=t.touches,s=s&&s.length?s[0]:t,{offsetX:a,offsetY:n}=s;let o,r,l=!1;if(i=n,t=t.target,!(0Math.round(10*t)/10;function De(t,e,i,s){var a=ye(t),n=ke(a,"margin"),o=_e(a.maxWidth,t,"clientWidth")||tt,r=_e(a.maxHeight,t,"clientHeight")||tt,t=function(t,e,i){let s,a;if(void 0===e||void 0===i){const n=ve(t);if(n){const t=n.getBoundingClientRect(),o=ye(n),r=ke(o,"border","width"),l=ke(o,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=_e(o.maxWidth,n,"clientWidth"),a=_e(o.maxHeight,n,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||tt,maxHeight:a||tt}}(t,e,i);let{width:l,height:h}=t;if("content-box"===a.boxSizing){const t=ke(a,"border","width"),e=ke(a,"padding");l-=e.width+t.width,h-=e.height+t.height}return l=Math.max(0,l-n.width),h=Math.max(0,s?l/s:h-n.height),l=Pe(Math.min(l,o,t.maxWidth)),h=Pe(Math.min(h,r,t.maxHeight)),l&&!h&&(h=Pe(l/2)),(void 0!==e||void 0!==i)&&s&&t.height&&h>t.height&&(h=t.height,l=Pe(Math.floor(h*s))),{width:l,height:h}}function Ce(t,e,i){var e=e||1,s=Math.floor(t.height*e),a=Math.floor(t.width*e);t.height=Math.floor(t.height),t.width=Math.floor(t.width);const n=t.canvas;return n.style&&(i||!n.style.height&&!n.style.width)&&(n.style.height=t.height+"px",n.style.width=t.width+"px"),(t.currentDevicePixelRatio!==e||n.height!==s||n.width!==a)&&(t.currentDevicePixelRatio=e,n.height=s,n.width=a,t.ctx.setTransform(e,0,0,e,0,0),!0)}var Oe=function(){let t=!1;try{var e={get passive(){return!(t=!0)}};window.addEventListener("test",null,e),window.removeEventListener("test",null,e)}catch(t){}return t}();function Ae(t,e){const i=Me(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function Te(t){return!t||P(t.size)||P(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Le(t,e,i,s,a){let n=e[a];return n||(n=e[a]=t.measureText(a).width,i.push(a)),s=n>s?n:s}function Ee(t,e,i,s){let a=(s=s||{}).data=s.data||{},n=s.garbageCollect=s.garbageCollect||[],o=(s.font!==e&&(a=s.data={},n=s.garbageCollect=[],s.font=e),t.save(),t.font=e,0);var r=i.length;let l,h,c,d,u;for(l=0;li.length){for(l=0;le.left-i&&t.xe.top-i&&t.yr[0]){const i=t||r;void 0===e&&(e=ti("_fallback",r));t={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:r,_rootScopes:i,_fallback:e,_getTarget:a,override:t=>Ye([t,...r],l,i,e)};return new Proxy(t,{deleteProperty:(t,e)=>(delete t[e],delete t._keys,delete r[0][e],!0),get:(n,o)=>Ke(n,o,()=>{var t,e=o,i=r,s=n;for(const a of l)if(t=ti(Xe(a,e),i),void 0!==t)return qe(e,t)?Je(i,s,e,t):t}),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(r[0]),has:(t,e)=>ei(t).includes(e),ownKeys:t=>ei(t),set(t,e,i){const s=t._storage||(t._storage=a());return t[e]=s[e]=i,delete t._keys,!0}})}function $e(s,e,i,a){var t={_cacheable:!1,_proxy:s,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ue(s,a),setContext:t=>$e(s,t,i,a),override:t=>$e(s.override(t),e,i,a)};return new Proxy(t,{deleteProperty:(t,e)=>(delete t[e],delete s[e],!0),get:(r,h,c)=>Ke(r,h,()=>{{var l=r,e=h,i=c;const{_proxy:s,_context:a,_subProxy:n,_descriptors:o}=l;let t=s[e];return O(t=u(t)&&o.isScriptable(e)?function(t,e,i){const{_proxy:s,_context:a,_subProxy:n,_stack:o}=l;if(o.has(t))throw new Error("Recursion detected: "+Array.from(o).join("->")+"->"+t);o.add(t);let r=e(a,n||i);return o.delete(t),r=qe(t,r)?Je(s._scopes,s,t,r):r}(e,t,i):t)&&t.length&&(t=function(t,e,i,s){const{_proxy:a,_context:n,_subProxy:o,_descriptors:r}=i;if(void 0!==n.index&&s(t))return e[n.index%e.length];if(A(e[0])){const i=e,s=a._scopes.filter(t=>t!==i);e=[];for(const A of i){const i=Je(s,a,t,A);e.push($e(i,n,o&&o[t],r))}}return e}(e,t,l,o.isIndexable)),t=qe(e,t)?$e(t,a,n&&n[e],o):t}}),getOwnPropertyDescriptor:(t,e)=>t._descriptors.allKeys?Reflect.has(s,e)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(s,e),getPrototypeOf:()=>Reflect.getPrototypeOf(s),has:(t,e)=>Reflect.has(s,e),ownKeys:()=>Reflect.ownKeys(s),set:(t,e,i)=>(s[e]=i,delete t[e],!0)})}function Ue(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:a=e.allKeys}=t;return{allKeys:a,scriptable:i,indexable:s,isScriptable:u(i)?i:()=>i,isIndexable:u(s)?s:()=>s}}const Xe=(t,e)=>t?t+K(e):e,qe=(t,e)=>A(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function Ke(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e))return t[e];i=i();return t[e]=i}function Ge(t,e,i){return u(t)?t(e,i):t}function Ze(t,e,i,s,a){for(const r of e){n=i,o=r;const e=!0===n?o:"string"==typeof n?m(o,n):void 0;if(e){t.add(e);o=Ge(e._fallback,i,a);if(void 0!==o&&o!==i&&o!==s)return o}else if(!1===e&&void 0!==s&&i!==s)return null}var n,o;return!1}function Je(t,s,a,n){const e=s._rootScopes,i=Ge(s._fallback,a,n),o=[...t,...e],r=new Set;r.add(n);t=Qe(r,o,a,i||a,n);return null!==t&&(void 0===i||i===a||null!==Qe(r,o,i,t,n))&&Ye(Array.from(r),[""],e,i,()=>{{var t=a,e=n;const i=s._getTarget();return t in i||(i[t]={}),O(t=i[t])&&A(e)?e:t||{}}})}function Qe(t,e,i,s,a){for(;i;)i=Ze(t,e,i,s,a);return i}function ti(t,e){for(const i of e)if(i){const e=i[t];if(void 0!==e)return e}}function ei(t){let e=t._keys;return e=e||(t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter(t=>!t.startsWith("_")))e.add(t);return Array.from(e)}(t._scopes))}function ii(t,e,i,s){const a=t["iScale"],{key:n="r"}=this._parsing,o=new Array(s);let r,l,h,c;for(r=0,l=s;re"x"===t?"y":"x";function oi(t,e,i,s){var t=t.skip?e:t,a=e,e=i.skip?e:i,i=gt(a,t),n=gt(e,a);let o=i/(i+n),r=n/(i+n);o=isNaN(o)?0:o,r=isNaN(r)?0:r;i=s*o,n=s*r;return{previous:{x:a.x-i*(e.x-t.x),y:a.y-i*(e.y-t.y)},next:{x:a.x+n*(e.x-t.x),y:a.y+n*(e.y-t.y)}}}function ri(t,n="x"){const e=ni(n),i=t.length,r=Array(i).fill(0),l=Array(i);let s,a,o,h=ai(t,0);for(s=0;s!t.skip)),"monotone"===e.cubicInterpolationMode)ri(o,t);else{let t=i?o[o.length-1]:o[0];for(s=0,a=o.length;s0===t||1===t,di=(t,e,i)=>-Math.pow(2,10*--t)*Math.sin((t-e)*_/i),ui=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*_/i)+1,gi={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>--t*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-(--t*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>--t*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*D),easeOutSine:t=>Math.sin(t*D),easeInOutSine:t=>-.5*(Math.cos(S*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ci(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>1<=t?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1- --t*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ci(t)?t:di(t,.075,.3),easeOutElastic:t=>ci(t)?t:ui(t,.075,.3),easeInOutElastic(t){return ci(t)?t:t<.5?.5*di(2*t,.1125,.45):.5+.5*ui(2*t-1,.1125,.45)},easeInBack(t){return t*t*(2.70158*t-1.70158)},easeOutBack(t){return--t*t*(2.70158*t+1.70158)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-gi.easeOutBounce(1-t),easeOutBounce(t){var e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*gi.easeInBounce(2*t):.5*gi.easeOutBounce(2*t-1)+.5};function fi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function pi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:("middle"===s?i<.5?t:e:"after"===s?i<1?t:e:0+t||0;function yi(e,i){const t={},s=A(i),a=s?Object.keys(i):i,n=A(e)?s?t=>T(e[t],e[i[t]]):t=>e[t]:()=>e;for(const e of a)t[e]=_i(n(e));return t}function Mi(t){return yi(t,{top:"y",right:"x",bottom:"y",left:"x"})}function wi(t){return yi(t,["topLeft","topRight","bottomLeft","bottomRight"])}function I(t){const e=Mi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function z(t,e){e=e||R.font;let i=T((t=t||{}).size,e.size),s=("string"==typeof i&&(i=parseInt(i,10)),T(t.style,e.style));s&&!(""+s).match(xi)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);const a={family:T(t.family,e.family),lineHeight:vi(T(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:T(t.weight,e.weight),string:""};return a.string=Te(a),a}function ki(t,e,i,s){let a,n,o,r=!0;for(a=0,n=t.length;ai&&0===t?0:t+e;return{min:a(t,-Math.abs(e)),max:a(s,e)}}function Pi(t,e){return Object.assign(Object.create(t),e)}function Di(t,e,i){return t?(s=e,a=i,{x:t=>s+s+a-t,setWidth(t){a=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t};var s,a}function Ci(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(s=[(i=t.canvas.style).getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Oi(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Ai(t){return"angle"===t?{between:pt,compare:ft,normalize:v}:{between:c,compare:(t,e)=>t-e,normalize:t=>t}}function Ti({start:t,end:e,count:i,loop:s,style:a}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:a}}function Li(t,i,g){if(!g)return[t];const{property:s,start:a,end:n}=g,o=i.length,{compare:r,between:l,normalize:h}=Ai(s),{start:c,end:d,loop:u,style:f}=function(t,e){const{property:i,start:s,end:a}=g,{between:n,normalize:o}=Ai(i),r=e.length;let l,h,{start:c,end:d,loop:u}=t;if(u){for(c+=r,d+=r,l=0,h=r;ls&&t[a%e].skip;)a--;return a%=e,{start:s,end:a}}(i,s,a);return Ii(t,!0===n?[{start:o,end:r,loop:a}]:function(t,e,i,s){const a=t.length,n=[];let o,r=e,l=t[e];for(o=e+1;o<=i;++o){const i=t[o%a];i.skip||i.stop?l.skip||(s=!1,n.push({start:e%a,end:(o-1)%a,loop:s}),e=r=i.stop?o:null):(r=o,l.skip&&(e=o)),l=i}return null!==r&&n.push({start:e%a,end:r%a,loop:s}),n}(i,o,r{t[r](s[a],n)&&(o.push({element:t,datasetIndex:e,index:i}),l=l||t.inRange(s.x,s.y,n))}),e&&!l?[]:o}var Hi={evaluateInteractionItems:Vi,modes:{index(t,e,i,s){const a=Se(e,t),n=i.axis||"x",o=i.includeInvisible||!1,r=i.intersect?Bi(t,a,n,s,o):Wi(t,a,n,!1,s,o),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach(t=>{var e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})}),l):[]},dataset(t,e,i,s){var e=Se(e,t),a=i.axis||"xy",n=i.includeInvisible||!1;let o=i.intersect?Bi(t,e,a,s,n):Wi(t,e,a,!1,s,n);if(0Bi(t,Se(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){var e=Se(e,t),a=i.axis||"xy",n=i.includeInvisible||!1;return Wi(t,e,a,i.intersect,s,n)},x:(t,e,i,s)=>Ni(t,Se(e,t),"x",i.intersect,s),y:(t,e,i,s)=>Ni(t,Se(e,t),"y",i.intersect,s)}};const ji=["left","top","right","bottom"];function Yi(t,e){return t.filter(t=>t.pos===e)}function $i(t,e){return t.filter(t=>-1===ji.indexOf(t.pos)&&t.box.axis===e)}function Ui(t,s){return t.sort((t,e)=>{var i=s?e:t,t=s?t:e;return i.weight===t.weight?i.index-t.index:i.weight-t.weight})}function Xi(t,e,i,s){return Math.max(t[i],e[i])+Math.max(t[s],e[s])}function qi(t,e){t.top=Math.max(t.top,e.top),t.left=Math.max(t.left,e.left),t.bottom=Math.max(t.bottom,e.bottom),t.right=Math.max(t.right,e.right)}function Ki(t,e,i,s){const a=[];let n,o,r,l,h,c;for(n=0,o=t.length,h=0;n{s[t]=Math.max(e[t],i[t])}),s}}(r.horizontal,e));const{same:o,other:d}=function(t,e,i,s){const{pos:a,box:n}=i,o=t.maxPadding;if(!A(a)){i.size&&(t[a]-=i.size);const e=s[i.stack]||{size:0,count:1};e.size=Math.max(e.size,i.horizontal?n.height:n.width),i.size=e.size/e.count,t[a]+=i.size}n.getPadding&&qi(o,n.getPadding());var s=Math.max(0,e.outerWidth-Xi(o,t,"left","right")),e=Math.max(0,e.outerHeight-Xi(o,t,"top","bottom")),r=s!==t.w,l=e!==t.h;return t.w=s,t.h=e,i.horizontal?{same:r,other:l}:{same:l,other:r}}(e,i,r,s);h|=o&&a.length,c=c||d,l.fullSize||a.push(r)}return h&&Ki(a,e,i,s)||c}function Gi(t,e,i,s,a){t.top=i,t.left=e,t.right=e+s,t.bottom=i+a,t.width=s,t.height=a}function Zi(t,e,i,s){var a=i.padding;let{x:n,y:o}=e;for(const r of t){const t=r.box,l=s[r.stack]||{count:1,placed:0,weight:1},h=r.stackWeight/l.weight||1;if(r.horizontal){const s=e.w*h,n=l.size||t.height;G(l.start)&&(o=l.start),t.fullSize?Gi(t,a.left,o,i.outerWidth-a.right-a.left,n):Gi(t,e.left+l.placed,o,s,n),l.start=o,l.placed+=s,o=t.bottom}else{const s=e.h*h,o=l.size||t.width;G(l.start)&&(n=l.start),t.fullSize?Gi(t,n,a.top,o,i.outerHeight-a.bottom-a.top):Gi(t,n,e.top+l.placed,o,s),l.start=n,l.placed+=s,n=t.right}}e.x=n,e.y=o}var a={addBox(t,e){t.boxes||(t.boxes=[]),e.fullSize=e.fullSize||!1,e.position=e.position||"top",e.weight=e.weight||0,e._layers=e._layers||function(){return[{z:0,draw(t){e.draw(t)}}]},t.boxes.push(e)},removeBox(t,e){e=t.boxes?t.boxes.indexOf(e):-1;-1!==e&&t.boxes.splice(e,1)},configure(t,e,i){e.fullSize=i.fullSize,e.position=i.position,e.weight=i.weight},update(l,t,e,i){if(l){const o=I(l.options.layout.padding),r=Math.max(t-o.width,0),h=Math.max(e-o.height,0),c=function(){const t=function(t){const e=[];let i,s,a,n,o,r;for(i=0,s=(t||[]).length;it.box.fullSize),!0),i=Ui(Yi(t,"left"),!0),s=Ui(Yi(t,"right")),a=Ui(Yi(t,"top"),!0),n=Ui(Yi(t,"bottom")),o=$i(t,"x"),r=$i(t,"y");return{fullSize:e,leftAndTop:i.concat(a),rightAndBottom:s.concat(r).concat(n).concat(o),chartArea:Yi(t,"chartArea"),vertical:i.concat(s).concat(r),horizontal:a.concat(n).concat(o)}}(),d=c.vertical,u=c.horizontal;k(l.boxes,t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()});var s=d.reduce((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1,0)||1,t=Object.freeze({outerWidth:t,outerHeight:e,padding:o,availableWidth:r,availableHeight:h,vBoxMaxWidth:r/2/s,hBoxMaxHeight:h/2}),e=Object.assign({},o);qi(e,I(i));const g=Object.assign({maxPadding:e,w:r,h:h,x:o.left,y:o.top},o),f=function(t,e){var i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:a}=i;if(t&&ji.includes(s)){const n=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});n.count++,n.weight+=a}}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:a}=e;let n,o,r;for(n=0,o=t.length;n{const e=t.box;Object.assign(e,l.chartArea),e.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})})}}};class Ji{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class Qi extends Ji{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const ts="$chartjs",es={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},is=t=>null===t||""===t,ss=!!Oe&&{passive:!0};function as(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function ns(t,e,i){const s=t.canvas,a=new MutationObserver(t=>{let e=!1;for(const i of t)e=e||as(i.addedNodes,s),e=e&&!as(i.removedNodes,s);e&&i()});return a.observe(document,{childList:!0,subtree:!0}),a}function os(t,e,i){const s=t.canvas,a=new MutationObserver(t=>{let e=!1;for(const i of t)e=e||as(i.removedNodes,s),e=e&&!as(i.addedNodes,s);e&&i()});return a.observe(document,{childList:!0,subtree:!0}),a}const rs=new Map;let ls=0;function hs(){const i=window.devicePixelRatio;i!==ls&&(ls=i,rs.forEach((t,e)=>{e.currentDevicePixelRatio!==i&&t()}))}function cs(t,e,s){const i=t.canvas,a=i&&ve(i);if(a){const o=St((t,e)=>{var i=a.clientWidth;s(t,e),i{var t=t[0],e=t.contentRect.width,t=t.contentRect.height;0===e&&0===t||o(e,t)});return r.observe(a),t=t,n=o,rs.size||window.addEventListener("resize",hs),rs.set(t,n),r;var n}}function ds(t,e,i){i&&i.disconnect(),"resize"===e&&(i=t,rs.delete(i),rs.size||window.removeEventListener("resize",hs))}function us(e,t,i){var s=e.canvas,a=St(t=>{null!==e.ctx&&i(function(t,e){var i=es[t.type]||t.type,{x:s,y:a}=Se(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==a?a:null}}(t,e))},e);return s.addEventListener(t,a,ss),a}class gs extends Ji{acquireContext(t,e){var i=t&&t.getContext&&t.getContext("2d");{if(i&&i.canvas===t){{var s=e;const a=t.style,n=t.getAttribute("height"),o=t.getAttribute("width");if(t[ts]={initial:{height:n,width:o,style:{display:a.display,height:a.height,width:a.width}}},a.display=a.display||"block",a.boxSizing=a.boxSizing||"border-box",is(o)){const s=Ae(t,"width");void 0!==s&&(t.width=s)}if(is(n))if(""===t.style.height)t.height=t.width/(s||2);else{const s=Ae(t,"height");void 0!==s&&(t.height=s)}}return i}return null}}releaseContext(t){const i=t.canvas;if(!i[ts])return!1;const s=i[ts].initial,e=(["height","width"].forEach(t=>{var e=s[t];P(e)?i.removeAttribute(t):i.setAttribute(t,e)}),s.style||{});return Object.keys(e).forEach(t=>{i.style[t]=e[t]}),i.width=i.width,delete i[ts],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),a={attach:ns,detach:os,resize:cs}[e]||us;s[e]=a(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];s&&(({attach:ds,detach:ds,resize:ds}[e]||function(t,e,i){t.canvas.removeEventListener(e,i,ss)})(t,e,s),i[e]=void 0)}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return De(t,e,i,s)}isAttached(t){t=ve(t);return!(!t||!t.isConnected)}}function fs(t){return!xe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?Qi:gs}Oe=Object.freeze({__proto__:null,BasePlatform:Ji,BasicPlatform:Qi,DomPlatform:gs,_detectPlatform:fs});const ps="transparent",ms={boolean:(t,e,i)=>.5t+(e-t)*i};class bs{constructor(t,e,i,s){var a=e[i],a=(s=ki([t.to,s,a,t.from]),ki([t.from,a,s]));this._active=!0,this._fn=t.fn||ms[t.type||typeof a],this._easing=gi[t.easing]||gi.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=a,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){var s,a,n;this._active&&(this._notify(!1),s=this._target[this._prop],a=i-this._start,n=this._duration-a,this._start=i,this._duration=Math.floor(Math.max(n,t.duration)),this._total+=a,this._loop=!!t.loop,this._to=ki([t.to,e,s,t.from]),this._from=ki([t.from,s,e]))}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){var t=t-this._start,e=this._duration,i=this._prop,s=this._from,a=this._loop,n=this._to;let o;if(this._active=s!==n&&(a||t{i.push({res:t,rej:e})})}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t{const t=s[e];if(A(t)){const i={};for(const s of a)i[s]=t[s];(O(t.properties)&&t.properties||[e]).forEach(t=>{t!==e&&n.has(t)||n.set(t,i)})}})}}_animateOptions(t,e){const i=e.options,s=function(e,i){if(i){let t=e.options;if(t)return t.$shared&&(e.options=t=Object.assign({},t,{$shared:!1,$animations:{}})),t;e.options=i}}(t,i);if(!s)return[];e=this._createAnimations(s,i);return i.$shared&&function(e,t){const i=[],s=Object.keys(t);for(let t=0;t{t.options=i},()=>{}),e}_createAnimations(e,i){const s=this._properties,a=[],n=e.$animations||(e.$animations={}),t=Object.keys(i),o=Date.now();let r;for(r=t.length-1;0<=r;--r){const c=t[r];if("$"!==c.charAt(0))if("options"===c)a.push(...this._animateOptions(e,i));else{var l=i[c];let t=n[c];var h=s.get(c);if(t){if(h&&t.active()){t.update(h,l,o);continue}t.cancel()}h&&h.duration?(n[c]=t=new bs(h,e,c,l),a.push(t)):e[c]=l}}return a}update(t,e){{if(0!==this._properties.size)return(t=this._createAnimations(t,e)).length?(l.add(this._chart,t),!0):void 0;Object.assign(t,e)}}}function vs(t,e){var t=t&&t.options||{},i=t.reverse,s=void 0===t.min?e:0,t=void 0===t.max?e:0;return{start:i?t:s,end:i?s:t}}function _s(t,e){const i=[],s=t._getSortedDatasetMetas(e);let a,n;for(a=0,n=s.length;ai[t].axis===e).shift()}function Ps(t,e){var i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i],void 0!==e[s]._visualValues&&void 0!==e[s]._visualValues[i]&&delete e[s]._visualValues[i]}}}const Ds=t=>"reset"===t||"none"===t,Cs=(t,e)=>e?t:Object.assign({},t);class Os{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=Ms(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&Ps(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,a=e.xAxisID=T(i.xAxisID,Ss(t,"x")),n=e.yAxisID=T(i.yAxisID,Ss(t,"y")),o=e.rAxisID=T(i.rAxisID,Ss(t,"r")),r=e.indexAxis,l=e.iAxisID=s(r,a,n,o),h=e.vAxisID=s(r,n,a,o);e.xScale=this.getScaleForId(a),e.yScale=this.getScaleForId(n),e.rScale=this.getScaleForId(o),e.iScale=this.getScaleForId(l),e.vScale=this.getScaleForId(h)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){var e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){var t=this._cachedMeta;this._data&&Mt(this._data,this),t._stacked&&Ps(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(A(e))this._data=function(t){const e=Object.keys(t),i=new Array(e.length);let s,a,n;for(s=0,a=e.length;snull===l[o]||d&&l[o]t||cthis.getContext(i,s,e),c);return g.$shared&&(g.$shared=r,a[n]=Object.freeze(Cs(g,r))),g}_resolveAnimations(t,e,i){const s=this.chart,a=this._cachedDataOpts,n="animation-"+e,o=a[n];if(o)return o;let r;if(!1!==s.options.animation){const s=this.chart.config,a=s.datasetAnimationScopeKeys(this._type,e),n=s.getOptionScopes(this.getDataset(),a);r=s.createResolver(n,this.getContext(t,i,e))}t=new xs(s,r&&r.animations);return r&&r._cacheable&&(a[n]=Object.freeze(t)),t}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Ds(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){var t=this.resolveDataElementOptions(t,e),i=this._sharedOptions,s=this.getSharedOptions(t),i=this.includeOptions(e,s)||s!==i;return this.updateSharedOptions(s,e,t),{sharedOptions:s,includeOptions:i}}updateElement(t,e,i,s){Ds(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Ds(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;var a=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(a)||a})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){var t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){var t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];var s=i.length,a=e.length,n=Math.min(a,s);n&&this.parse(0,n),s{for(t.length+=e,o=t.length-1;o>=n;o--)t[o]=t[o-e]};for(r(a),o=t;o{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]}),s}}function As(t,e,i,s,a){var n=T(s,0),o=Math.min(T(a,t.length),t.length);let r,l,h,c=0;for(i=Math.ceil(i),a&&(i=(r=a-s)/Math.floor(r/i)),h=n;h<0;)c++,h=Math.round(n+c*i);for(l=Math.max(n,0);l"top"===e||"left"===e?t[e]+i:t[e]-i,Ls=(t,e)=>Math.min(e||t,t);function Es(t,e){const i=[],s=t.length/e,a=t.length;let n=0;for(;nn?n:a,n=o&&a>n?a:n,{min:g(a,g(n,a)),max:g(n,g(a,n))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){var t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(t))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){d(this.options.beforeUpdate,[this])}update(t,e,i){var{beginAtZero:s,grace:a,ticks:n}=this.options,o=n.sampleSize,t=(this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Si(this,a,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks(),os)return i}return Math.max(s,1)}(n,s,a);if(0{const e=t.gc,i=e.length/2;let s;if(y({width:n[t]||0,height:o[t]||0});return{first:w(0),last:w(e-1),widest:w(i),highest:w(M),widths:n,heights:o}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){var e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);t=this._startPixel+t*this._length;return mt(this._alignToPixels?Re(this.chart,t,0):t)}getDecimalForPixel(t){t=(t-this._startPixel)/this._length;return this._reversePixels?1-t:t}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){var{min:t,max:e}=this;return t<0&&e<0?e:0o+1e-6)))return l}(this,b,l))&&(v=Re(s,x,A),h?_=M=k=P=v:y=w=S=D=v,u.push({tx1:_,ty1:y,tx2:M,ty2:w,x1:k,y1:S,x2:P,y2:D,width:A,color:o,borderDash:T,borderDashOffset:c,tickWidth:d,tickColor:g,tickBorderDash:f,tickBorderDashOffset:p}))}return this._ticksLength=c,this._borderValue=m,u}_computeLabelItems(s){const a=this.axis,n=this.options,{position:o,ticks:e}=n,r=this.isHorizontal(),l=this.ticks,{align:h,crossAlign:c,padding:t,mirror:d}=e,i=Rs(n.grid),u=i+t,g=d?-t:u,f=-L(this.labelRotation),p=[];let m,b,x,v,_,y,M,w,k,S,P,D="middle";if("top"===o)_=this.bottom-g,y=this._getXAxisLabelAlignment();else if("bottom"===o)_=this.top+g,y=this._getXAxisLabelAlignment();else if("left"===o){const s=this._getYAxisLabelAlignment(i);y=s.textAlign,v=s.x}else if("right"===o){const s=this._getYAxisLabelAlignment(i);y=s.textAlign,v=s.x}else if("x"===a){if("center"===o)_=(s.top+s.bottom)/2+u;else if(A(o)){const s=Object.keys(o)[0],a=o[s];_=this.chart.scales[s].getPixelForValue(a)+u}y=this._getXAxisLabelAlignment()}else if("y"===a){if("center"===o)v=(s.left+s.right)/2-u;else if(A(o)){const s=Object.keys(o)[0],a=o[s];v=this.chart.scales[s].getPixelForValue(a)}y=this._getYAxisLabelAlignment(i).textAlign}"y"===a&&("start"===h?D="top":"end"===h&&(D="bottom"));var C=this._getLabelSizes();for(m=0,b=l.length;mt.value===e);return 0<=i?t.setContext(this.getContext(i)).lineWidth:0}drawGrid(t){const e=this.options.grid,s=this.ctx,i=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let a,n;var o=(t,e,i)=>{i.width&&i.color&&(s.save(),s.lineWidth=i.width,s.strokeStyle=i.color,s.setLineDash(i.borderDash||[]),s.lineDashOffset=i.borderDashOffset,s.beginPath(),s.moveTo(t.x,t.y),s.lineTo(e.x,e.y),s.stroke(),s.restore())};if(e.display)for(a=0,n=i.length;a{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:t,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let a,n;for(a=0,n=e.length;a{const e=t.split("."),i=e.pop(),s=[r].concat(e).join("."),a=l[t].split("."),n=a.pop(),o=a.join(".");R.route(s,i,o,n)})),e.descriptors&&R.describe(s,e.descriptors),this.override&&R.override(t.id,t.overrides)),h;throw new Error("class does not have id: "+t)}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in R[s]&&(delete R[s][i],this.override&&delete fe[i])}}var b=new class{constructor(){this.controllers=new Fs(Os,"datasets",!0),this.elements=new Fs(e,"elements"),this.plugins=new Fs(Object,"plugins"),this.scales=new Fs(zs,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(i,t,s){[...t].forEach(t=>{const e=s||this._getRegistryForType(t);s||e.isForType(t)||e===this.plugins&&t.id?this._exec(i,e,t):k(t,t=>{var e=s||this._getRegistryForType(t);this._exec(i,e,t)})})}_exec(t,e,i){var s=K(t);d(i["before"+s],[],i),e[t](i),d(i["after"+s],[],i)}_getRegistryForType(e){for(let t=0;tt.filter(e=>!i.some(t=>e.plugin.id===t.plugin.id));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function Bs(t,e){var i=R.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function Ws(t){if("x"===t||"y"===t||"r"===t)return t}function Ns(t,...e){if(Ws(t))return t;for(const s of e){const e=s.axis||("top"===(i=s.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||1{var e=r[t];if(!A(e))return console.error("Invalid scale configuration for scale: "+t);if(e._proxy)return console.warn("Ignoring resolver passed as options for scale: "+t);const i=Ns(t,e,function(e,t){if(t.data&&t.data.datasets){t=t.data.datasets.filter(t=>t.xAxisID===e||t.yAxisID===e);if(t.length)return Hs(e,"x",t[0])||Hs(e,"y",t[0])}return{}}(t,o),R.scales[e.type]),s=i===l?"_index_":"_value_",a=n.scales||{};h[t]=$(Object.create(null),[{axis:i},e,a[i],a[s]])}),o.data.datasets.forEach(s=>{const t=s.type||o.type,a=s.indexAxis||Bs(t,e),n=(fe[t]||{}).scales||{};Object.keys(n).forEach(t=>{var e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,a),i=s[e+"AxisID"]||e;h[i]=h[i]||Object.create(null),$(h[i],[{axis:e},r[i],n[t]])})}),Object.keys(h).forEach(t=>{t=h[t];$(t,[R.scales[t.type],R.scale])}),h}(t,e)}function Ys(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const $s=new Map,Us=new Set;function Xs(t,e){let i=$s.get(t);return i||(i=e(),$s.set(t,i),Us.add(i)),i}const qs=(t,e,i)=>{e=m(e,i);void 0!==e&&t.add(e)};class Ks{constructor(t){this._config=((t=(t=t)||{}).data=Ys(t.data),js(t),t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=Ys(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){var t=this._config;this.clearCache(),js(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return Xs(t,()=>[["datasets."+t,""]])}datasetAnimationScopeKeys(t,e){return Xs(t+".transition."+e,()=>[[`datasets.${t}.transitions.`+e,"transitions."+e],["datasets."+t,""]])}datasetElementScopeKeys(t,e){return Xs(t+"-"+e,()=>[[`datasets.${t}.elements.`+e,"datasets."+t,"elements."+e,""]])}pluginScopeKeys(t){const e=t.id;return Xs(this.type+"-plugin-"+e,()=>[["plugins."+e,...t.additionalOptionScopes||[]]])}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(e,t,i){const{options:s,type:a}=this,n=this._cachedScopes(e,i),o=n.get(t);if(o)return o;const r=new Set,l=(t.forEach(t=>{e&&(r.add(e),t.forEach(t=>qs(r,e,t))),t.forEach(t=>qs(r,s,t)),t.forEach(t=>qs(r,fe[a]||{},t)),t.forEach(t=>qs(r,R,t)),t.forEach(t=>qs(r,pe,t))}),Array.from(r));return 0===l.length&&l.push(Object.create(null)),Us.has(t)&&n.set(t,l),l}chartOptionScopes(){var{options:t,type:e}=this;return[t,fe[e]||{},R.datasets[e]||{},{type:e},R,pe]}resolveNamedOptions(t,e,i,s=[""]){const a={$shared:!0},{resolver:n,subPrefixes:o}=Gs(this._resolverCache,t,s);let r=n;!function(t,e){const{isScriptable:i,isIndexable:s}=Ue(t);for(const a of e){const e=i(a),n=s(a),o=(n||e)&&t[a];if(e&&(u(o)||Zs(o))||n&&O(o))return 1}}(n,e)||(a.$shared=!1,r=$e(n,i=u(i)?i():i,this.createResolver(t,i,o)));for(const t of e)a[t]=r[t];return a}createResolver(t,e,i=[""],s){t=Gs(this._resolverCache,t,i).resolver;return A(e)?$e(t,e,void 0,s):t}}function Gs(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));t=i.join();let a=s.get(t);return a||(a={resolver:Ye(e,i),subPrefixes:i.filter(t=>!t.toLowerCase().includes("hover"))},s.set(t,a)),a}const Zs=i=>A(i)&&Object.getOwnPropertyNames(i).reduce((t,e)=>t||u(i[e]),!1),Js=["top","bottom","left","right","chartArea"];function Qs(t,e){return"top"===t||"bottom"===t||-1===Js.indexOf(t)&&"x"===e}function ta(i,s){return function(t,e){return t[i]===e[i]?t[s]-e[s]:t[i]-e[i]}}function ea(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),d(i&&i.onComplete,[t],e)}function ia(t){var e=t.chart,i=e.options.animation;d(i&&i.onProgress,[t],e)}function sa(t){return xe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t=t&&t.canvas?t.canvas:t}const aa={},na=t=>{const e=sa(t);return Object.values(aa).filter(t=>t.canvas===e).pop()};function oa(t,e,i){return(t.options.clip?t:e)[i]}class i{static defaults=R;static instances=aa;static overrides=fe;static registry=b;static version="4.4.0";static getChart=na;static register(...t){b.add(...t),ra()}static unregister(...t){b.remove(...t),ra()}constructor(t,e){const i=this.config=new Ks(e),s=sa(t),a=na(s);if(a)throw new Error("Canvas is already in use. Chart with ID '"+a.id+"' must be destroyed before the canvas with ID '"+a.canvas.id+"' can be reused.");var e=i.createResolver(i.chartOptionScopes(),this.getContext()),t=(this.platform=new(i.platform||fs(s)),this.platform.updateConfig(i),this.platform.acquireContext(s,e.aspectRatio)),n=t&&t.canvas,o=n&&n.height,r=n&&n.width;this.id=F(),this.ctx=t,this.canvas=n,this.width=r,this.height=o,this._options=e,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new Vs,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=Pt(t=>this.update(t),e.resizeDelay||0),this._dataChanges=[],aa[this.id]=this,t&&n?(l.listen(this,"complete",ea),l.listen(this,"progress",ia),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){var{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:s,_aspectRatio:a}=this;return P(t)?e&&a?a:s?i/s:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return b}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():Ce(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Ie(this.canvas,this.ctx),this}stop(){return l.stop(this),this}resize(t,e){l.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){var i=this.options,s=this.canvas,a=i.maintainAspectRatio&&this.aspectRatio,s=this.platform.getMaximumSize(s,t,e,a),t=i.devicePixelRatio||this.platform.getDevicePixelRatio(),e=this.width?"resize":"attach";this.width=s.width,this.height=s.height,this._aspectRatio=this.aspectRatio,Ce(this,t,!0)&&(this.notifyPlugins("resize",{size:s}),d(i.onResize,[this,s],this),this.attached&&this._doResize(e)&&this.render())}ensureScalesHaveIDs(){k(this.options.scales||{},(t,e)=>{t.id=e})}buildOrUpdateScales(){const o=this.options,s=o.scales,r=this.scales,l=Object.keys(r).reduce((t,e)=>(t[e]=!1,t),{});let t=[];k(t=s?t.concat(Object.keys(s).map(t=>{var e=s[t],t=Ns(t,e),i="r"===t,t="x"===t;return{options:e,dposition:i?"chartArea":t?"bottom":"left",dtype:i?"radialLinear":t?"category":"linear"}})):t,t=>{const e=t.options,i=e.id,s=Ns(i,e),a=T(e.type,t.dtype);void 0!==e.position&&Qs(e.position,s)===Qs(t.dposition)||(e.position=t.dposition),l[i]=!0;let n=null;i in r&&r[i].type===a?n=r[i]:(n=new(b.getScale(a))({id:i,type:a,ctx:this.ctx,chart:this}),r[n.id]=n),n.init(e,o)}),k(l,(t,e)=>{t||delete r[e]}),k(r,t=>{a.configure(this,t,t.options),a.addBox(this,t)})}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort((t,e)=>t.index-e.index),ei.length&&delete this._stacks,t.forEach((e,t)=>{0===i.filter(t=>t===e._dataset).length&&this._destroyDatasetMeta(t)})}buildOrUpdateControllers(){const e=[],i=this.data.datasets;let s,a;for(this._removeUnreferencedMetasets(),s=0,a=i.length;s{this.getDatasetMeta(e).controller.reset()},this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const s=this.config,a=(s.update(),this._options=s.createResolver(s.chartOptionScopes(),this.getContext())),n=this._animationsDisabled=!a.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1!==this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0})){const o=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let i=0;for(let t=0,e=this.data.datasets.length;t{t.reset()}),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(ta("z","_idx"));var{_active:t,_lastEvent:e}=this;e?this._eventHandler(e,!0):t.length&&this._updateHoverStyles(t,t,!0),this.render()}}_updateScales(){k(this.scales,t=>{a.removeBox(this,t)}),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){var t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);Z(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){var t,e,i,s,a=this["_hiddenIndices"];for({method:t,start:e,count:i}of this._getUniformDataChanges()||[]){n=void 0;o=void 0;r=void 0;s=void 0;var n=a;var o=e;var r="_removeElements"===t?-i:i;const l=Object.keys(n);for(const h of l){const l=+h;l>=o&&(s=n[h],delete n[h],(0o)&&(n[l+r]=s))}}}_getUniformDataChanges(){const t=this._dataChanges;if(t&&t.length){this._dataChanges=[];var e=this.data.datasets.length,i=e=>new Set(t.filter(t=>t[0]===e).map((t,e)=>e+","+t.splice(1).join(","))),s=i(0);for(let t=1;tt.split(",")).map(t=>({method:t[1],start:+t[2],count:+t[3]}))}}_updateLayout(t){if(!1!==this.notifyPlugins("beforeLayout",{cancelable:!0})){a.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],k(this.boxes,t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))},this),this._layers.forEach((t,e)=>{t._idx=e}),this.notifyPlugins("afterLayout")}}_updateDatasets(i){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:i,cancelable:!0})){for(let t=0,e=this.data.datasets.length;tt&&t._dataset===e).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=Pi(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){var e=this.data.datasets[t];if(!e)return!1;t=this.getDatasetMeta(t);return"boolean"==typeof t.hidden?!t.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(e,t,i){const s=i?"show":"hide",a=this.getDatasetMeta(e),n=a.controller._resolveAnimations(void 0,s);G(t)?(a.data[t].hidden=!i,this.update()):(this.setDatasetVisibility(e,i),n.update(a,{visible:i}),this.update(t=>t.datasetIndex===e?s:void 0))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),l.remove(this),t=0,e=this.data.datasets.length;t{s.addEventListener(this,t,e),i[t]=e},a=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};k(this.options.events,t=>e(t,a))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const i=this._responsiveListeners,s=this.platform,t=(t,e)=>{s.addEventListener(this,t,e),i[t]=e},e=(t,e)=>{i[t]&&(s.removeEventListener(this,t,e),delete i[t])},a=(t,e)=>{this.canvas&&this.resize(t,e)};let n;const o=()=>{e("attach",o),this.attached=!0,this.resize(),t("resize",a),t("detach",n)};n=()=>{this.attached=!1,e("resize",a),this._stop(),this._resize(0,0),t("attach",o)},(s.isAttached(this.canvas)?o:n)()}unbindEvents(){k(this._listeners,(t,e)=>{this.platform.removeEventListener(this,e,t)}),this._listeners={},k(this._responsiveListeners,(t,e)=>{this.platform.removeEventListener(this,e,t)}),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){var s=i?"set":"remove";let a,n,o,r;for("dataset"===e&&(a=this.getDatasetMeta(t[0].datasetIndex)).controller["_"+s+"DatasetHoverStyle"](),o=0,r=t.length;o{var i=this.getDatasetMeta(t);if(i)return{datasetIndex:t,element:i.data[e],index:e};throw new Error("No dataset found at index "+t)});W(t,e)||(this._active=t,this._lastEvent=null,this._updateHoverStyles(t,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}isPluginEnabled(e){return 1===this._plugins._cache.filter(t=>t.plugin.id===e).length}_updateHoverStyles(t,e,i){var s=this.options.hover,a=(t,i)=>t.filter(e=>!i.some(t=>e.datasetIndex===t.datasetIndex&&e.index===t.index)),n=a(e,t),i=i?t:a(t,e);n.length&&this.updateHoverStyle(n,s.mode,!1),i.length&&s.mode&&this.updateHoverStyle(i,s.mode,!0)}_eventHandler(e,t){const i={event:e,replay:t,cancelable:!0,inChartArea:this.isPointInArea(e)},s=t=>(t.options.events||this.options.events).includes(e.native.type);if(!1!==this.notifyPlugins("beforeEvent",i,s))return t=this._handleEvent(e,t,i.inChartArea),i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(t||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:a}=this,n=e,o=this._getActiveElements(t,s,i,n),r=J(t),l=(h=t,c=this._lastEvent,i&&"mouseout"!==h.type?r?c:h:null);i&&(this._lastEvent=null,d(a.onHover,[t,o,this],this),r&&d(a.onClick,[t,o,this],this));var h,c=!W(o,s);return(c||e)&&(this._active=o,this._updateHoverStyles(o,s,e)),this._lastEvent=l,c}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;i=this.options.hover;return this.getElementsAtEventForMode(t,i.mode,i,s)}}function ra(){k(i.instances,t=>t._plugins.invalidate())}function la(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}var ha={_date:class Xn{static override(t){Object.assign(Xn.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return la()}parse(){return la()}format(){return la()}add(){return la()}diff(){return la()}startOf(){return la()}endOf(){return la()}}};function ca(i,s,a,n){if(O(i)){var o=i,r=s,l=a,h=n,c=l.parse(o[0],h),o=l.parse(o[1],h),h=Math.min(c,o),d=Math.max(c,o);let t=h,e=d;Math.abs(h)>Math.abs(d)&&(t=d,e=h),r[l.axis]=e,r._custom={barStart:t,barEnd:e,start:c,end:o,min:h,max:d}}else s[a.axis]=a.parse(i,n);return s}function da(t,e,i,s){const a=t.iScale,n=t.vScale,o=a.getLabels(),r=a===n,l=[];let h,c,d,u;for(c=(h=i)+s;h"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(s){const t=s.data;if(t.labels.length&&t.datasets.length){const{pointStyle:a,color:n}=s.legend.options["labels"];return t.labels.map((t,e)=>{var i=s.getDatasetMeta(0).controller.getStyle(e);return{text:t,fillStyle:i.backgroundColor,strokeStyle:i.borderColor,fontColor:n,lineWidth:i.borderWidth,pointStyle:a,hidden:!s.getDataVisibility(e),index:e}})}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(s,a){const n=this.getDataset().data,o=this._cachedMeta;if(!1===this._parsing)o._parsed=n;else{let t,e,i=t=>+n[t];if(A(n[s])){const{key:s="value"}=this._parsing;i=t=>+m(n[t],s)}for(e=(t=s)+a;tpt(t,r,l,!0)?1:Math.max(e,e*s,i,i*s),f=(t,e,i)=>pt(t,r,l,!0)?-1:Math.min(e,e*s,i,i*s),p=g(0,h,d),m=g(D,c,u),b=f(S,h,d),x=f(S+D,c,u);i=(p-b)/2,a=(m-x)/2,n=-(p+b)/2,o=-(m+x)/2}return{ratioX:i,ratioY:a,offsetX:n,offsetY:o}}(c,h,r),p=(i.width-n)/d,m=(i.height-n)/u,b=Math.max(Math.min(p,m)/2,0),x=B(this.options.radius,b),v=(x-Math.max(x*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=g*x,this.offsetY=f*x,s.total=this.calculateTotal(),this.outerRadius=x-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*l,0),this.updateElements(a,0,a.length,t)}_circumference(t,e){var i=this.options,s=this._cachedMeta,a=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*a/_)}updateElements(t,e,i,s){const a="reset"===s,n=this.chart,o=n.chartArea,r=n.options.animation,l=(o.left+o.right)/2,h=(o.top+o.bottom)/2,c=a&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:g,includeOptions:f}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p{var i=s.getDatasetMeta(0).controller.getStyle(e);return{text:t,fillStyle:i.backgroundColor,strokeStyle:i.borderColor,fontColor:n,lineWidth:i.borderWidth,pointStyle:a,hidden:!s.getDataVisibility(e),index:e}})}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){var e=this._cachedMeta,i=this.chart,s=i.data.labels||[],e=de(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:e}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){var e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,s={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach((t,e)=>{var i=this.getParsed(e).r;!isNaN(i)&&this.chart.getDataVisibility(e)&&(is.max&&(s.max=i))}),s}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),a=Math.max(s/2,0),n=(a-Math.max(i.cutoutPercentage?a/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=a-n*this.index,this.innerRadius=this.outerRadius-n}updateElements(s,a,t,n){const o="reset"===n,r=this.chart,l=r.options.animation,h=this._cachedMeta.rScale,c=h.xCenter,d=h.yCenter,u=h.getIndexAngle(0)-.5*S;let g,f=u;var p=360/this.countVisibleElements();for(g=0;g{!isNaN(this.getParsed(e).r)&&this.chart.getDataVisibility(e)&&i++}),i}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?L(this.resolveDataElementOptions(t,e).angle||i):0}}var ba=Object.freeze({__proto__:null,BarController:class extends Os{static id="bar";static defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}};static overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};parsePrimitiveData(t,e,i,s){return da(t,e,i,s)}parseArrayData(t,e,i,s){return da(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:a,vScale:n}=t,{xAxisKey:o="x",yAxisKey:r="y"}=this._parsing,l="x"===a.axis?o:r,h="x"===n.axis?o:r,c=[];let d,u,g,f;for(u=(d=i)+s;dn.x,e="left","right"):(t=n.baset.controller.options.grouped),a=e.options.stacked,n=[];for(const e of s)if((void 0===i||!(t=>{var e=t.controller.getParsed(i),e=e&&e[t.vScale.axis];if(P(e)||isNaN(e))return!0})(e))&&((!1===a||-1===n.indexOf(e.stack)||void 0===a&&void 0===e.stack)&&n.push(e.stack),e.index===t))break;return n.length||n.push(void 0),n}_getStackCount(t){return this._getStacks(void 0,t).length}_getStackIndex(t,e,i){const s=this._getStacks(t,i),a=void 0!==e?s.indexOf(e):-1;return-1===a?s.length-1:a}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let a,n;for(a=0,n=e.data.length;at-e))}return s._cache.$bar}(e,t.type);let s,a,n,o,r=e._length;var l=()=>{32767!==n&&-32768!==n&&(G(o)&&(r=Math.min(r,Math.abs(n-o)||r)),o=n)};for(s=0,a=i.length;s=m?1:-1))*n),u===o&&(x-=d/2);const t=e.getPixelForDecimal(0),P=e.getPixelForDecimal(1),a=Math.min(t,P),l=Math.max(t,P);x=Math.max(Math.min(x,l),a),c=x+d,i&&!h&&(r._stacks[e.axis]._visualValues[s]=e.getValueForPixel(c)-e.getValueForPixel(x))}if(x===e.getPixelForValue(o)){const t=y(d)*e.getLineWidthForValue(o)/2;x+=t,d-=t}return{size:d,base:x,head:c,center:c+d/2}}_calculateBarIndexPixels(t,e){const i=e.scale,s=this.options,a=s.skipNull,n=T(s.maxBarThickness,1/0);let o,r;if(e.grouped){const i=a?this._getStackCount(t):e.stackCount,T=("flex"===s.barThickness?function(t,e,i,s){var a=e.pixels,n=a[t];let o=0=b?x.skip=!0:(y=P((_=this.getParsed(t))[u]),M=x[d]=n.getPixelForValue(_[d],t),w=x[u]=a||y?o.getBasePixel():o.getPixelForValue(r?this.applyStack(o,_,r):_[u],t),x.skip=isNaN(M)||isNaN(w)||y,x.stop=0p,f&&(x.parsed=_,x.raw=l.data[t]),c&&(x.options=h||this.resolveDataElementOptions(t,g.active?"active":s)),m||this.updateElement(g,t,x,s),v=_)}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;var a=s[0].size(this.resolveDataElementOptions(0)),n=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,a,n)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}},PieController:class extends pa{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},PolarAreaController:ma,RadarController:class extends Os{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],a=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);var n={_loop:!0,_fullLoop:a.length===s.length,options:e};this.updateElement(i,void 0,n,t)}this.updateElements(s,0,s.length,t)}updateElements(e,i,s,a){const n=this._cachedMeta.rScale,o="reset"===a;for(let t=i;tm,p&&(f.parsed=s,f.raw=h.data[t]),d&&(f.options=c||this.resolveDataElementOptions(t,i.active?"active":a)),b||this.updateElement(i,t,f,a),x=s}this.updateSharedOptions(c,a,t)}getMaxOverflow(){const t=this._cachedMeta,i=t.data||[];if(!this.options.showLine){let e=0;for(let t=i.length-1;0<=t;--t)e=Math.max(e,i[t].size(this.resolveDataElementOptions(t))/2);return 0{var e=(i-Math.min(a,t))*s/2;return C(t,0,Math.min(a,e))};return{outerStart:o(t.outerStart),outerEnd:o(t.outerEnd),innerStart:C(t.innerStart,0,n),innerEnd:C(t.innerEnd,0,n)}}(e,h,d,l-g),b=d-c,x=d-f,v=g+c/b,_=l-f/x,y=h+p,M=h+m,w=g+p/y,k=l-m/M;if(t.beginPath(),n){const e=(v+_)/2;if(t.arc(o,r,d,v,e),t.arc(o,r,d,e,_),0(o+(h?r-t:t))%n,v=()=>{g!==f&&(t.lineTo(m,f),t.lineTo(m,g),t.lineTo(m,p))};for(l&&(d=a[x(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c)if(!(d=a[x(c)]).skip){const e=d.x,i=d.y,s=0|e;s===u?(if&&(f=i),m=(b*m+e)/++b):(v(),t.lineTo(e,i),u=s,b=0,g=f=i),p=i}v()}function Sa(t){var e=t.options,i=e.borderDash&&e.borderDash.length;return t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i?wa:ka}const Pa="function"==typeof Path2D;class Da extends e{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){var i,s=this.options;!s.tension&&"monotone"!==s.cubicInterpolationMode||s.stepped||this._pointsUpdated||(i=s.spanGaps?this._loop:this._fullLoop,hi(this._points,s,t,i,e),this._pointsUpdated=!0)}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=Ri(this,this.options.segment))}first(){var t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){var t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(i,s){var a=this.options,n=i[s],o=this.points,r=Ei(this,{property:s,start:n,end:n});if(r.length){const l=[],h=a.stepped?pi:a.tension||"monotone"===a.cubicInterpolationMode?mi:fi;let e,t;for(e=0,t=r.length;e"borderDash"!==t};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){var{angle:t,distance:e}=ut(this.getProps(["x","y"],i),{x:t,y:e}),{startAngle:i,endAngle:s,innerRadius:a,outerRadius:n,circumference:o}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),r=(this.options.spacing+this.options.borderWidth)/2,o=T(o,s-i)>=_||pt(t,i,s),t=c(e,a+r,n+r);return o&&t}getCenterPoint(t){var{x:t,y:e,startAngle:i,endAngle:s,innerRadius:a,outerRadius:n}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:o,spacing:r}=this.options,i=(i+s)/2,s=(a+n+r+o)/2;return{x:t+Math.cos(i)*s,y:e+Math.sin(i)*s}}tooltipPosition(t){return this.getCenterPoint(t)}draw(e){var{options:i,circumference:s}=this,a=(i.offset||0)/4,n=(i.spacing||0)/2,o=i.circular;if(this.pixelMargin="inner"===i.borderAlign?.33:0,this.fullCircles=s>_?Math.floor(s/_):0,!(0===s||this.innerRadius<0||this.outerRadius<0)){e.save();var r=(this.startAngle+this.endAngle)/2,r=(e.translate(Math.cos(r)*a,Math.sin(r)*a),a*(1-Math.sin(Math.min(S,s||0))));e.fillStyle=i.backgroundColor,e.strokeStyle=i.borderColor;{var l=e;a=this;s=r;i=n;var h=o;var{fullCircles:c,startAngle:d,circumference:u}=a;let t=a.endAngle;if(c){va(l,a,s,i,t,h);for(let t=0;ts=e?s:t,r=t=>a=i?a:t;if(t){const t=y(s),e=y(a);t<0&&e<0?r(0):0g&&(k=nt(w*k/g/u)*u),P(r)||(_=Math.pow(10,r),k=Math.ceil(k*_)/_),M="ticks"===s?(y=Math.floor(f/k)*k,Math.ceil(p/k)*k):(y=f,p),m&&b&&a&<((o-n)/a,k/1e3)?(w=Math.round(Math.min((o-n)/k,h)),k=(o-n)/w,y=n,M=o):x?(y=m?n:y,M=b?o:M,w=l-1,k=(M-y)/w):w=at(w=(M-y)/k,Math.round(w),k/1e3)?Math.round(w):Math.ceil(w);e=Math.max(dt(k),dt(y));_=Math.pow(10,P(r)?e:r),y=Math.round(y*_)/_,M=Math.round(M*_)/_;let S=0;for(m&&(d&&y!==n?(i.push({value:n}),yo)break;i.push({value:t})}return b&&d&&M!==o?i.length&&at(i[i.length-1].value,o,za(o,v,t))?i[i.length-1].value=o:i.push({value:o}):b&&M!==o||i.push({value:M}),i}({maxTicks:Math.max(2,i),bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&ht(s,this,"value"),t.reverse?(s.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),s}configure(){var t=this.ticks;let e=this.min,i=this.max;super.configure(),this.options.offset&&t.length&&(t=(i-e)/Math.max(t.length-1,1)/2,e-=t,i+=t),this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return de(t,this.chart.options.locale,this.options.ticks.format)}}class Va extends Fa{static id="linear";static defaults={ticks:{callback:ge.formatters.numeric}};determineDataLimits(){var{min:t,max:e}=this.getMinMax(!0);this.min=p(t)?t:0,this.max=p(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){var t=this.isHorizontal(),e=t?this.width:this.height,i=L(this.options.ticks.minRotation),t=(t?Math.sin(i):Math.cos(i))||.001,i=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,i.lineHeight/t))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}const Ba=t=>Math.floor(r(t)),Wa=(t,e)=>Math.pow(10,Ba(t)+e);function Na(t){return 1==t/Math.pow(10,Ba(t))}function Ha(t,e,i){i=Math.pow(10,i),t=Math.floor(t/i);return Math.ceil(e/i)-t}class ja extends zs{static id="logarithmic";static defaults={ticks:{callback:ge.formatters.logarithmic,major:{enabled:!0}}};constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){t=Fa.prototype.parse.apply(this,[t,e]);if(0!==t)return p(t)&&0s=e?s:t,n=t=>a=i?a:t;s===a&&(s<=0?(t(1),n(10)):(t(Wa(s,-1)),n(Wa(a,1)))),s<=0&&t(Wa(a,-1)),a<=0&&n(Wa(s,1)),this.min=s,this.max=a}buildTicks(){const t=this.options,e=function(t,{min:e,max:i}){e=g(t.min,e);const s=[],a=Ba(e);let n=function(t,e){let i=Ba(e-t);for(;10n?Math.pow(10,a):0,h=Math.round((e-l)*o)/o,c=Math.floor((e-l)/r/10)*r*10;let d=Math.floor((h-c)/Math.pow(10,n)),u=g(t.min,Math.round((l+c+d*Math.pow(10,n))*o)/o);for(;uf.r&&(t=(m.end-f.r)/x,g.r=Math.max(g.r,f.r+t)),b.startf.b&&(e=(b.end-f.b)/p,g.b=Math.max(g.b,f.b+e))}}var d,u;e.setCenterPoint(i.l-s.l,s.r-i.r,i.t-s.t,s.b-i.b),e._pointLabelItems=function(e,i,s){const a=[],n=e._pointLabels.length,t=e.options,{centerPointLabels:o,display:r}=t.pointLabels,l={extra:Ya(t)/2,additionalAngle:o?S/n:0};let h;for(let t=0;tt,padding:5,centerPointLabels:!1}};static defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"};static descriptors={angleLines:{_fallback:"grid"}};constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){var t=this._padding=I(Ya(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){var{min:t,max:e}=this.getMinMax(!1);this.min=p(t)&&!isNaN(t)?t:0,this.max=p(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/Ya(this.options))}generateTickLabels(t){Fa.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map((t,e)=>{t=d(this.options.pointLabels.callback,[t,e],this);return t||0===t?t:""}).filter((t,e)=>this.chart.getDataVisibility(e))}fit(){var t=this.options;t.display&&t.pointLabels.display?Ua(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return v(t*(_/(this._pointLabels.length||1))+L(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(P(t))return NaN;var e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(P(t))return NaN;t/=this.drawingArea/(this.max-this.min);return this.options.reverse?this.max-t:this.min+t}getPointLabelContext(t){var e=this._pointLabels||[];if(0<=t&&t0!==t)?(g.beginPath(),je(g,{x:f,y:r,w:x,h:m,radius:p}),g.fill()):g.fillRect(f,r,x,m)}var p=z(l.font),{x:v,y:b,textAlign:g}=o;He(_,n._pointLabels[t],v,b+p.lineHeight/2,p,{color:l.color,textAlign:g,textBaseline:"middle"})}}}if(h.display&&this.ticks.forEach((t,e)=>{if(0!==e){u=this.getDistanceFromCenterForValue(t.value);t=this.getContext(e),e=h.setContext(t),t=c.setContext(t);{var i=this,s=u,a=d;const n=i.ctx,o=e.circular,{color:r,lineWidth:l}=e;!o&&!a||!r||!l||s<0||(n.save(),n.strokeStyle=r,n.lineWidth=l,n.setLineDash(t.dash),n.lineDashOffset=t.dashOffset,n.beginPath(),Xa(i,s,o,a),n.closePath(),n.stroke(),n.restore())}}}),i.display){for(t.save(),s=d-1;0<=s;s--){const h=i.setContext(this.getPointLabelContext(s)),{color:c,lineWidth:d}=h;d&&c&&(t.lineWidth=d,t.strokeStyle=c,t.setLineDash(h.borderDash),t.lineDashOffset=h.borderDashOffset,u=this.getDistanceFromCenterForValue(e.ticks.reverse?this.min:this.max),a=this.getPointPosition(s,u),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(a.x,a.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const o=this.ctx,r=this.options,l=r.ticks;if(l.display){var t=this.getIndexAngle(0);let a,n;o.save(),o.translate(this.xCenter,this.yCenter),o.rotate(t),o.textAlign="center",o.textBaseline="middle",this.ticks.forEach((t,e)=>{if(0!==e||r.reverse){var i=l.setContext(this.getContext(e)),s=z(i.font);if(a=this.getDistanceFromCenterForValue(this.ticks[e].value),i.showLabelBackdrop){o.font=s.string,n=o.measureText(t.label).width,o.fillStyle=i.backdropColor;const r=I(i.backdropPadding);o.fillRect(-n/2-r.left,-a-s.size/2-r.top,n+r.width,s.size+r.height)}He(o,t.label,0,-a,s,{color:i.color,strokeColor:i.textStrokeColor,strokeWidth:i.textStrokeWidth})}}),o.restore()}}drawTitle(){}}const Ka={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},h=Object.keys(Ka);function Ga(t,e){return t-e}function Za(t,e){if(P(e))return null;const i=t._adapter,{parser:s,round:a,isoWeekday:n}=t._parseOpts;let o=e;return null===(o=p(o="function"==typeof s?s(o):o)?o:"string"==typeof s?i.parse(o,s):i.parse(o))?null:+(o=a?"week"!==a||!rt(n)&&!0!==n?i.startOf(o,a):i.startOf(o,"isoWeek",n):o)}function Ja(e,i,s,a){const n=h.length;for(let t=h.indexOf(e);t=e?i[s]:i[a]]=!0):t[e]=!0}function tn(i,t,s){const a=[],n={},e=t.length;let o,r;for(o=0;o=h.indexOf(s);t--){const s=h[t];if(Ka[s].common&&e._adapter.diff(n,a,s)>=i-1)return s}return h[s?h.indexOf(s):0]}(this,n.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(i){for(let t=h.indexOf(i)+1,e=h.length;t+t.value))}initOffsets(t=[]){let e,i,s=0,a=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),a=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);t=t.length<3?.5:.25;s=C(s,0,t),a=C(a,0,t),this._offsets={start:s,end:a,factor:1/(s+1+a)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,a=s.time,n=a.unit||Ja(a.minUnit,e,i,this._getLabelCapacity(e)),o=T(s.ticks.stepSize,1),r="week"===n&&a.isoWeekday,l=rt(r)||!0===r,h={};let c,d,u=e;if(l&&(u=+t.startOf(u,"isoWeek",r)),u=+t.startOf(u,l?"day":n),t.diff(i,e,n)>1e5*o)throw new Error(e+" and "+i+" are too far apart with stepSize of "+o+" "+n);var g="data"===s.ticks.source&&this.getDataTimestamps();for(c=u,d=0;c+t)}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}format(t,e){var i=this.options.time.displayFormats,s=this._unit,e=e||i[s];return this._adapter.format(t,e)}_tickFormatFunction(t,e,i,s){var a=this.options,n=a.ticks.callback;if(n)return d(n,[t,e,i],this);var n=a.time.displayFormats,a=this._unit,o=this._majorUnit,a=a&&n[a],n=o&&n[o],i=i[e],e=o&&n&&i&&i.major;return this._adapter.format(t,s||(e?n:a))}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=f(t,"pos",e)),{pos:s,time:n}=t[r],{pos:a,time:o}=t[l]):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=f(t,"time",e)),{time:s,pos:n}=t[r],{time:a,pos:o}=t[l]);i=a-s;return i?n+(o-n)*(e-s)/i:n}var an=Object.freeze({__proto__:null,CategoryScale:class extends zs{static id="category";static defaults={ticks:{callback:Ia}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){var e=this._addedLabels;if(e.length){const t=this.getLabels();for(var{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(P(t))return null;var i,s,a,n,o,r,l=this.getLabels();return a=e=isFinite(e)&&l[e]===t?e:(i=l,s=T(e,t=t),a=this._addedLabels,-1===(r=i.indexOf(t))?(o=s,a=a,"string"==typeof(n=t)?(o=i.push(n)-1,a.unshift({index:o,label:n})):isNaN(n)&&(o=null),o):r!==i.lastIndexOf(t)?s:r),n=l.length-1,null===a?null:C(Math.round(a),0,n)}determineDataLimits(){var{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const e=this.min,i=this.max,t=this.options.offset,s=[];let a=this.getLabels();a=0===e&&i===a.length-1?a:a.slice(e,i+1),this._valueRange=Math.max(a.length-(t?0:1),1),this._startValue=this.min-(t?.5:0);for(let t=e;t<=i;t++)s.push({value:t});return s}getLabelForValue(t){return Ia.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return null===(t="number"!=typeof t?this.parse(t):t)?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){var e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}},LinearScale:Va,LogarithmicScale:ja,RadialLinearScale:qa,TimeScale:en,TimeSeriesScale:class extends en{static id="timeseries";static defaults=en.defaults;constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){var t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=sn(e,this.min),this._tableRange=sn(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],a=[];let n,o,r,l,h;for(n=0,o=t.length;n=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(n=0,o=s.length;nt-e)}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t}getDecimalForValue(t){return(sn(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){var e=this._offsets,t=this.getDecimalForPixel(t)/e.factor-e.end;return sn(this._table,t*this._tableRange+this._minPos,!0)}}});const nn=["rgb(54, 162, 235)","rgb(255, 99, 132)","rgb(255, 159, 64)","rgb(255, 205, 86)","rgb(75, 192, 192)","rgb(153, 102, 255)","rgb(201, 203, 207)"],on=nn.map(t=>t.replace("rgb(","rgba(").replace(")",", 0.5)"));function rn(t){return nn[t%nn.length]}function ln(t){return on[t%on.length]}function hn(n){let o=0;return(t,e)=>{var i,s,a,e=n.getDatasetMeta(e).controller;e instanceof pa?o=(s=t,a=o,s.backgroundColor=s.data.map(()=>rn(a++)),a):e instanceof ma?o=(s=t,i=o,s.backgroundColor=s.data.map(()=>ln(i++)),i):e&&(o=(e=t,t=o,e.borderColor=rn(t),e.backgroundColor=ln(t),++t))}}function cn(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return 1}var dn={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,i){if(i.enabled){const{data:{datasets:s},options:a}=t.config,n=a["elements"];!i.forceOverride&&(cn(s)||a&&(a.borderColor||a.backgroundColor)||n&&cn(n))||(i=hn(t),s.forEach(i))}}};function un(t){var e;t._decimated&&(e=t._data,delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e}))}function gn(t){t.data.datasets.forEach(t=>{un(t)})}var fn={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(r,t,M)=>{if(M.enabled){const l=r.width;r.data.datasets.forEach((e,t)=>{var{_data:i,indexAxis:s}=e,h=r.getDatasetMeta(t),a=i||e.data;if("y"!==ki([s,r.options.indexAxis])&&h.controller.supportsDecimation){t=r.scales[h.xAxisID];if(("linear"===t.type||"time"===t.type)&&!r.options.parsing){var{start:n,count:o}=function(t){var e=t.length;let i,s=0;const a=h["iScale"],{min:n,max:o,minDefined:r,maxDefined:l}=a.getUserBounds();return r&&(s=C(f(t,a.axis,n).lo,0,e-1)),i=l?C(f(t,a.axis,o).hi+1,s,e)-s:e-s,{start:s,count:i}}(a);if(o<=(M.threshold||4*l))un(e);else{let t;switch(P(i)&&(e._data=a,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),M.algorithm){case"lttb":t=function(s,a,n,t){var e=M.samples||t;if(n<=e)return s.slice(a,a+n);const o=[],r=(n-2)/(e-2);let l=0;const h=a+n-1;let c,d,u,g,f,p=a;for(o[l++]=s[p],c=0;cu&&(u=g,d=s[t],f=t);o[l++]=d,p=f}return o[l++]=s[h],o}(a,n,o,l);break;case"min-max":t=function(t,e,i,s){let a,n,o,r,l,h,c,d,u,g,f=0,p=0;const m=[],b=e+i-1,x=t[e].x,v=t[b].x-x;for(a=e;ag&&(g=r,c=a),f=(p*f+n.x)/++p;else{const i=a-1;if(!P(h)&&!P(c)){const e=Math.min(h,c),P=Math.max(h,c);e!==d&&e!==i&&m.push({...t[e],x:f}),P!==d&&P!==i&&m.push({...t[P],x:f})}0{e=mn(t,e,a);t=a[t],e=a[e];null!==s?(n.push({x:t.x,y:s}),n.push({x:e.x,y:s})):null!==i&&(n.push({x:i,y:t.y}),n.push({x:i,y:e.y}))}),n}(t)).length?new Da({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function vn(t){return t&&!1!==t.fill}function _n(e,i,s){const a=[];for(let t=0;t{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class On extends e{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const i=this.options.labels||{};let t=d(i.generateLabels,[this.chart],this)||[];i.filter&&(t=t.filter(t=>i.filter(t,this.chart.data))),i.sort&&(t=t.sort((t,e)=>i.sort(t,e,this.chart.data))),this.options.reverse&&t.reverse(),this.legendItems=t}fit(){const{options:i,ctx:s}=this;if(i.display){var a=i.labels,n=z(a.font),o=n.size,r=this._computeTitleHeight(),{boxWidth:a,itemHeight:l}=Cn(a,o);let t,e;s.font=n.string,this.isHorizontal()?(t=this.maxWidth,e=this._fitRows(r,o,a,l)+10):(e=this.maxHeight,t=this._fitCols(r,n,a,l)+10),this.width=Math.min(t,i.maxWidth||this.maxWidth),this.height=Math.min(e,i.maxHeight||this.maxHeight)}else this.width=this.height=0}_fitRows(t,i,s,a){const{ctx:n,maxWidth:o,options:{labels:{padding:r}}}=this,l=this.legendHitBoxes=[],h=this.lineWidths=[0],c=a+r;let d=t,u=(n.textAlign="left",n.textBaseline="middle",-1),g=-c;return this.legendItems.forEach((t,e)=>{t=s+i/2+n.measureText(t.text).width;(0===e||h[h.length-1]+t+2*r>o)&&(d+=c,h[h.length-(0{o=l,i=r,s=c,a=t,n=h;var i,s,a,n,{itemWidth:t,itemHeight:o}={itemWidth:function(t,e,i){let s=a.text;return s&&"string"!=typeof s&&(s=s.reduce((t,e)=>t.length>e.length?t:e)),t+e.size/2+i.measureText(s).width}(o,i,s),itemHeight:function(t){let e=n;return e="string"!=typeof a.text?An(a,t):e}(i.lineHeight)};0f&&(p+=m+d,g.push({width:m,height:b}),x+=m+d,v++,m=b=0),u[e]={left:x,top:b,col:v,width:t,height:o},m=Math.max(m,t),b+=o+d}),p+=m,g.push({width:m,height:b}),p}adjustHitBoxes(){if(this.options.display){const i=this._computeTitleHeight(),{legendHitBoxes:s,options:{align:a,labels:{padding:n},rtl:t}}=this,o=Di(t,this.left,this.width);if(this.isHorizontal()){let t=0,e=E(a,this.left+n,this.right-this.lineWidths[t]);for(const r of s)t!==r.row&&(t=r.row,e=E(a,this.left+n,this.right-this.lineWidths[t])),r.top+=this.top+i+n,r.left=o.leftForLtr(o.x(e),r.width),e+=r.width+n}else{let t=0,e=E(a,this.top+i+n,this.bottom-this.columnSizes[t].height);for(const l of s)l.col!==t&&(t=l.col,e=E(a,this.top+i+n,this.bottom-this.columnSizes[t].height)),l.top=e,l.left+=this.left+n,l.left=o.leftForLtr(o.x(l.left),l.width),e+=l.height+n}}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){var t;this.options.display&&(Ve(t=this.ctx,this),this._draw(),Be(t))}_draw(){const{options:h,columnSizes:c,lineWidths:d,ctx:u}=this,{align:g,labels:f}=h,p=R.color,m=Di(h.rtl,this.left,this.width),b=z(f.font),x=f["padding"],v=b.size,_=v/2;let y;this.drawTitle(),u.textAlign=m.textAlign("left"),u.textBaseline="middle",u.lineWidth=.5,u.font=b.string;const{boxWidth:M,boxHeight:w,itemHeight:k}=Cn(f,v),S=this.isHorizontal(),P=this._computeTitleHeight(),D=(y=S?{x:E(g,this.left+x,this.right-d[0]),y:this.top+x+P,line:0}:{x:this.left+x,y:E(g,this.top+P+x,this.bottom-c[0].height),line:0},Ci(this.ctx,h.textDirection),k+x);this.legendItems.forEach((t,e)=>{u.strokeStyle=t.fontColor,u.fillStyle=t.fontColor;var i=u.measureText(t.text).width,s=m.textAlign(t.textAlign||(t.textAlign=f.textAlign)),i=M+_+i;let a=y.x,n=y.y;m.setWidth(this.width),S?0this.right&&(n=y.y+=D,y.line++,a=y.x=E(g,this.left+x,this.right-d[y.line])):0this.bottom&&(a=y.x=a+c[y.line].width+x,y.line++,n=y.y=E(g,this.top+P+x,this.bottom-c[y.line].height));var e=m.x(a),o=n,r=t;if(!(isNaN(M)||M<=0||isNaN(w)||w<0)){u.save();var l=T(r.lineWidth,1);if(u.fillStyle=T(r.fillStyle,p),u.lineCap=T(r.lineCap,"butt"),u.lineDashOffset=T(r.lineDashOffset,0),u.lineJoin=T(r.lineJoin,"miter"),u.lineWidth=l,u.strokeStyle=T(r.strokeStyle,p),u.setLineDash(T(r.lineDash,[])),f.usePointStyle){const p={radius:w*Math.SQRT2/2,pointStyle:r.pointStyle,rotation:r.rotation,borderWidth:l},T=m.xPlus(e,M/2);Fe(u,p,T,o+_,f.pointStyleWidth&&M)}else{const f=o+Math.max((v-w)/2,0),p=m.leftForLtr(e,M),T=wi(r.borderRadius);u.beginPath(),Object.values(T).some(t=>0!==t)?je(u,{x:p,y:f,w:M,h:w,radius:T}):u.rect(p,f,M,w),u.fill(),0!==l&&u.stroke()}u.restore()}if(a=Ct(s,a+M+_,S?a+i:this.right,h.rtl),o=m.x(a),e=n,r=t,He(u,r.text,o,e+k/2,b,{strikethrough:r.hidden,textAlign:m.textAlign(r.textAlign)}),S)y.x+=i+x;else if("string"!=typeof t.text){const h=b.lineHeight;y.y+=An(t,h)+x}else y.y+=D}),Oi(this.ctx,h.textDirection)}drawTitle(){const s=this.options,a=s.title,n=z(a.font),o=I(a.padding);if(a.display){const l=Di(s.rtl,this.left,this.width),h=this.ctx,c=a.position,d=n.size/2,u=o.top+d;let t,e=this.left,i=this.width;if(this.isHorizontal())i=Math.max(...this.lineWidths),t=this.top+u,e=E(s.align,e,this.right-i);else{const a=this.columnSizes.reduce((t,e)=>Math.max(t,e.height),0);t=u+E(s.align,this.top,this.bottom-a-s.labels.padding-this._computeTitleHeight())}var r=E(c,e,e+i);h.textAlign=l.textAlign(Dt(c)),h.textBaseline="middle",h.strokeStyle=a.color,h.fillStyle=a.color,h.font=n.string,He(h,a.text,r,t,n)}}_computeTitleHeight(){var t=this.options.title,e=z(t.font),i=I(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,a;if(c(t,this.left,this.right)&&c(e,this.top,this.bottom))for(a=this.legendHitBoxes,i=0;it.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const s=t.data.datasets,{usePointStyle:a,pointStyle:n,textAlign:o,color:r,useBorderRadius:l,borderRadius:h}=t.legend.options["labels"];return t._getSortedDatasetMetas().map(t=>{var e=t.controller.getStyle(a?0:void 0),i=I(e.borderWidth);return{text:s[t.index].label,fillStyle:e.backgroundColor,fontColor:r,hidden:!t.visible,lineCap:e.borderCapStyle,lineDash:e.borderDash,lineDashOffset:e.borderDashOffset,lineJoin:e.borderJoinStyle,lineWidth:(i.width+i.height)/4,strokeStyle:e.borderColor,pointStyle:n||e.pointStyle,rotation:e.rotation,textAlign:o||e.textAlign,borderRadius:l&&(h||e.borderRadius),datasetIndex:t.index}},this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class Ln extends e{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){var i=this.options;this.left=0,this.top=0,i.display?(this.width=this.right=t,this.height=this.bottom=e,t=O(i.text)?i.text.length:1,this._padding=I(i.padding),e=t*z(i.font).lineHeight+this._padding.height,this.isHorizontal()?this.height=e:this.width=e):this.width=this.height=this.right=this.bottom=0}isHorizontal(){var t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){var{top:e,left:i,bottom:s,right:a,options:n}=this,o=n.align;let r,l,h,c=0;return r=this.isHorizontal()?(l=E(o,i,a),h=e+t,a-i):(c="left"===n.position?(l=i+t,h=E(o,s,e),-.5*S):(l=a-t,h=E(o,e,s),.5*S),s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){var t,e,i,s,a,n=this.ctx,o=this.options;o.display&&(e=(t=z(o.font)).lineHeight/2+this._padding.top,{titleX:e,titleY:i,maxWidth:s,rotation:a}=this._drawArgs(e),He(n,o.text,0,0,t,{color:o.color,maxWidth:s,rotation:a,textAlign:Dt(o.align),textBaseline:"middle",translation:[e,i]}))}}var En={id:"title",_element:Ln,start(t,e,i){var s;t=t,i=i,s=new Ln({ctx:t.ctx,options:i,chart:t}),a.configure(t,s,i),a.addBox(t,s),t.titleBlock=s},stop(t){var e=t.titleBlock;a.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;a.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Rn=new WeakMap;var In={id:"subtitle",start(t,e,i){var s=new Ln({ctx:t.ctx,options:i,chart:t});a.configure(t,s,i),a.addBox(t,s),Rn.set(t,s)},stop(t){a.removeBox(t,Rn.get(t)),Rn.delete(t)},beforeUpdate(t,e,i){const s=Rn.get(t);a.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const zn={average(t){if(!t.length)return!1;let e,i,s=0,a=0,n=0;for(e=0,i=t.length;et+e.before.length+e.lines.length+e.after.length,0),x=(b+=t.beforeBody.length+t.afterBody.length,d&&(p+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),b&&(p+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(b-g)*l.lineHeight+(b-1)*e.bodySpacing),u&&(p+=e.footerMarginTop+u*c.lineHeight+(u-1)*e.footerSpacing),0);function v(t){m=Math.max(m,i.measureText(t).width+x)}return i.save(),i.font=h.string,k(t.title,v),i.font=l.string,k(t.beforeBody.concat(t.afterBody),v),x=e.displayColors?o+2+e.boxPadding:0,k(s,t=>{k(t.before,v),k(t.lines,v),k(t.after,v)}),x=0,i.font=c.string,k(t.footer,v),i.restore(),{width:m+=f.width,height:p}}function Bn(i,t,s){var e=s.yAlign||t.yAlign||function(){var{y:t,height:e}=s;return ti.height-e/2?"bottom":"center"}();return{xAlign:s.xAlign||t.xAlign||function(a,n,o,t){var{x:e,width:i}=o,{width:s,chartArea:{left:r,right:l}}=a;let h="center";return"center"===t?h=e<=(r+l)/2?"left":"right":e<=i/2?h="left":s-i/2<=e&&(h="right"),h=function(t){var{x:e,width:i}=o,s=n.caretSize+n.caretPadding;return"left"===t&&e+i+s>a.width||"right"===t&&e-i-s<0}(h)?"center":h}(i,t,s,e),yAlign:e}}function Wn(t,i,e,s){var{caretSize:t,caretPadding:a,cornerRadius:n}=t,{xAlign:o,yAlign:r}=e,l=t+a,{topLeft:e,topRight:a,bottomLeft:n,bottomRight:h}=wi(n);let c=function(){let{x:t,width:e}=i;return"right"===o?t-=e:"center"===o&&(t-=e/2),t}();var d=function(){let{y:t,height:e}=i;return"top"===r?t+=l:t-="bottom"===r?e+l:e/2,t}();return"center"===r?"left"===o?c+=l:"right"===o&&(c-=l):"left"===o?c-=Math.max(e,n)+t:"right"===o&&(c+=Math.max(a,h)+t),{x:C(c,0,s.width-i.width),y:C(d,0,s.height-i.height)}}function Nn(t,e,i){i=I(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-i.right:t.x+i.left}function Hn(t){return x([],Fn(t))}function jn(t,e){e=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return e?t.override(e):t}const Yn={beforeTitle:t,title(t){if(0{var e={before:[],lines:[],after:[]},i=jn(s,t);x(e.before,Fn(w(i,"beforeLabel",this,t))),x(e.lines,w(i,"label",this,t)),x(e.after,Fn(w(i,"afterLabel",this,t))),a.push(e)}),a}getAfterBody(t,e){return Hn(w(e.callbacks,"afterBody",this,t))}getFooter(t,e){var e=e["callbacks"],i=w(e,"beforeFooter",this,t),s=w(e,"footer",this,t),e=w(e,"afterFooter",this,t),t=x([],Fn(i));return t=x(t,Fn(s)),x(t,Fn(e))}_createItems(s){const t=this._active,a=this.chart.data,i=[],n=[],o=[];let e,r,l=[];for(e=0,r=t.length;es.filter(t,e,i,a))),k(l=s.itemSort?l.sort((t,e)=>s.itemSort(t,e,a)):l,t=>{var e=jn(s.callbacks,t);i.push(w(e,"labelColor",this,t)),n.push(w(e,"labelPointStyle",this,t)),o.push(w(e,"labelTextColor",this,t))}),this.labelColors=i,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let a,n=[];if(s.length){const t=zn[i.position].call(this,s,this._eventPosition),e=(n=this._createItems(i),this.title=this.getTitle(n,i),this.beforeBody=this.getBeforeBody(n,i),this.body=this.getBody(n,i),this.afterBody=this.getAfterBody(n,i),this.footer=this.getFooter(n,i),this._size=Vn(this,i)),o=Object.assign({},t,e),r=Bn(this.chart,i,o),l=Wn(i,o,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,a={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(a={opacity:0});this._tooltipItems=n,this.$context=void 0,a&&this._resolveAnimations().update(this,a),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){t=this.getCaretPosition(t,i,s);e.lineTo(t.x1,t.y1),e.lineTo(t.x2,t.y2),e.lineTo(t.x3,t.y3)}getCaretPosition(t,e,i){var{xAlign:s,yAlign:a}=this,{caretSize:i,cornerRadius:n}=i,{topLeft:n,topRight:o,bottomLeft:r,bottomRight:l}=wi(n),{x:t,y:h}=t,{width:e,height:c}=e;let d,u,g,f,p,m;return"center"===a?(p=h+c/2,m="left"===s?(d=t,u=d-i,f=p+i,p-i):(d=t+e,u=d+i,f=p-i,p+i),g=d):(u="left"===s?t+Math.max(n,r)+i:"right"===s?t+e-Math.max(o,l)-i:this.caretX,g="top"===a?(f=h,p=f-i,d=u-i,u+i):(f=h+c,p=f+i,d=u+i,u-i),m=f),{x1:d,x2:u,x3:g,y1:f,y2:p,y3:m}}drawTitle(t,e,i){var s=this.title,a=s.length;let n,o,r;if(a){const l=Di(i.rtl,this.x,this.width);for(t.x=Nn(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",n=z(i.titleFont),o=i.titleSpacing,e.fillStyle=i.titleColor,e.font=n.string,r=0;r0!==t)?(t.beginPath(),t.fillStyle=a.multiKeyBackground,je(t,{x:e,y:g,w:l,h:r,radius:o}),t.fill(),t.stroke(),t.fillStyle=n.backgroundColor,t.beginPath(),je(t,{x:i,y:g+1,w:l-2,h:r-2,radius:o}),t.fill()):(t.fillStyle=a.multiKeyBackground,t.fillRect(e,g,l,r),t.strokeRect(e,g,l,r),t.fillStyle=n.backgroundColor,t.fillRect(i,g+1,l-2,r-2))}t.fillStyle=this.labelTextColors[i]}drawBody(e,i,t){const s=this["body"],{bodySpacing:a,bodyAlign:n,displayColors:o,boxHeight:r,boxWidth:l,boxPadding:h}=t,c=z(t.bodyFont);let d=c.lineHeight,u=0;function g(t){i.fillText(t,f.x(e.x+u),e.y+d/2),e.y+=d+a}const f=Di(t.rtl,this.x,this.width),p=f.textAlign(n);let m,b,x,v,_,y,M;for(i.textAlign=n,i.textBaseline="middle",i.font=c.string,e.x=Nn(this,p,t),i.fillStyle=t.bodyColor,k(this.beforeBody,g),u=o&&"right"!==p?"center"===n?l/2+h:l+2+h:0,v=0,y=s.length;v{var i=this.chart.getDatasetMeta(t);if(i)return{datasetIndex:t,element:i.data[e],index:e};throw new Error("Cannot find a dataset at index "+t)}),i=!W(i,t),s=this._positionChanged(t,e);(i||s)&&(this._active=t,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;var s=this.options,a=this._active||[],i=this._getActiveElements(t,a,e,i),n=this._positionChanged(i,t),a=e||!W(i,a)||n;return a&&(this._active=i,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),a}_getActiveElements(t,e,i,s){var a=this.options;if("mouseout"===t.type)return[];if(!s)return e;const n=this.chart.getElementsAtEventForMode(t,a.mode,a,i);return a.reverse&&n.reverse(),n}_positionChanged(t,e){var{caretX:i,caretY:s,options:a}=this,a=zn[a.position].call(this,t,e);return!1!==a&&(i!==a.x||s!==a.y)}}var Un={id:"tooltip",_element:$n,positioners:zn,afterInit(t,e,i){i&&(t.tooltip=new $n({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;var i;e&&e._willRender()&&(!(i={tooltip:e})!==t.notifyPlugins("beforeTooltipDraw",{...i,cancelable:!0})&&(e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)))},afterEvent(t,e){var i;t.tooltip&&(i=e.replay,t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0))},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:Yn},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};return i.register(ba,an,Ra,s),i.helpers={...Fi},i._adapters=ha,i.Animation=bs,i.Animations=xs,i.animator=l,i.controllers=b.controllers.items,i.DatasetController=Os,i.Element=e,i.elements=Ra,i.Interaction=Hi,i.layouts=a,i.platforms=Oe,i.Scale=zs,i.Ticks=ge,Object.assign(i,ba,an,Ra,s,Oe),i.Chart=i,"undefined"!=typeof window&&(window.Chart=i),i}); \ No newline at end of file diff --git a/main.go b/main.go index 6f253e4..9f1e7d9 100644 --- a/main.go +++ b/main.go @@ -16,12 +16,26 @@ import ( ) func main() { - // 解析命令行参数 - configPath := flag.String("config", "config.json", "配置文件路径") + // 命令行参数解析 + var configFile string + var daemonMode bool + flag.StringVar(&configFile, "config", "config.json", "配置文件路径") + flag.BoolVar(&daemonMode, "daemon", false, "以守护进程模式运行") flag.Parse() + // 如果是守护进程模式,创建守护进程 + if daemonMode { + if err := daemonize(); err != nil { + log.Fatalf("创建守护进程失败: %v", err) + } + // 父进程退出 + os.Exit(0) + } + // 初始化配置 - cfg, err := config.LoadConfig(*configPath) + var cfg *config.Config + var err error + cfg, err = config.LoadConfig(configFile) if err != nil { log.Fatalf("加载配置失败: %v", err) } @@ -61,14 +75,51 @@ func main() { logger.Info(fmt.Sprintf("DNS服务器已启动,监听端口: %d", cfg.DNS.Port)) logger.Info(fmt.Sprintf("HTTP控制台已启动,监听端口: %d", cfg.HTTP.Port)) - // 等待退出信号 - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - <-sigChan + // 监听信号 + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh - logger.Info("正在关闭服务...") + // 清理资源 + log.Println("正在关闭服务...") dnsServer.Stop() httpServer.Stop() shieldManager.StopAutoUpdate() - logger.Info("所有服务已关闭") + // 守护进程模式下不需要删除PID文件 + + log.Println("服务已关闭") +} + +// daemonize 创建守护进程 +func daemonize() error { + // 使用更简单的方式创建守护进程:直接在当前进程中进行守护化处理 + // 1. 重定向标准输入、输出、错误 + nullFile, err := os.OpenFile("/dev/null", os.O_RDWR, 0) + if err != nil { + return fmt.Errorf("打开/dev/null失败: %w", err) + } + defer nullFile.Close() + + // 重定向文件描述符 + err = syscall.Dup2(int(nullFile.Fd()), int(os.Stdin.Fd())) + if err != nil { + return fmt.Errorf("重定向stdin失败: %w", err) + } + err = syscall.Dup2(int(nullFile.Fd()), int(os.Stdout.Fd())) + if err != nil { + return fmt.Errorf("重定向stdout失败: %w", err) + } + err = syscall.Dup2(int(nullFile.Fd()), int(os.Stderr.Fd())) + if err != nil { + return fmt.Errorf("重定向stderr失败: %w", err) + } + + // 2. 创建新的会话和进程组 + _, err = syscall.Setsid() + if err != nil { + return fmt.Errorf("创建新会话失败: %w", err) + } + + fmt.Println("守护进程已启动") + return nil } diff --git a/package.json b/package.json new file mode 100644 index 0000000..94d5432 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "dns-server-console", + "version": "1.0.0", + "description": "DNS服务器Web控制台", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "tailwindcss": "^3.3.3", + "font-awesome": "^4.7.0", + "chart.js": "^4.4.8" + }, + "devDependencies": {}, + "keywords": ["dns", "server", "console", "web"], + "author": "", + "license": "ISC" +} \ No newline at end of file diff --git a/shield/manager.go b/shield/manager.go index bbc464d..356cbd6 100644 --- a/shield/manager.go +++ b/shield/manager.go @@ -970,22 +970,83 @@ func (m *ShieldManager) GetStats() map[string]interface{} { // loadStatsData 从文件加载计数数据 func (m *ShieldManager) loadStatsData() { if m.config.StatsFile == "" { + logger.Info("Shield统计文件路径未配置,跳过加载") return } - // 检查文件是否存在 - data, err := ioutil.ReadFile(m.config.StatsFile) + // 获取绝对路径以避免工作目录问题 + statsFilePath, err := filepath.Abs(m.config.StatsFile) if err != nil { - if !os.IsNotExist(err) { - logger.Error("读取Shield计数数据文件失败", "error", err) + logger.Error("获取Shield统计文件绝对路径失败", "path", m.config.StatsFile, "error", err) + return + } + logger.Debug("尝试加载Shield统计数据", "file", statsFilePath) + + // 检查文件是否存在 + fileInfo, err := os.Stat(statsFilePath) + if err != nil { + if os.IsNotExist(err) { + logger.Info("Shield统计文件不存在,将创建新文件", "file", statsFilePath) + // 初始化空的计数数据 + m.rulesMutex.Lock() + m.blockedDomainsCount = make(map[string]int) + m.resolvedDomainsCount = make(map[string]int) + m.rulesMutex.Unlock() + // 尝试立即保存一个有效的空文件 + m.saveStatsData() + } else { + logger.Error("检查Shield统计文件失败", "file", statsFilePath, "error", err) } return } + // 检查文件大小 + if fileInfo.Size() == 0 { + logger.Warn("Shield统计文件为空,将重新初始化", "file", statsFilePath) + m.rulesMutex.Lock() + m.blockedDomainsCount = make(map[string]int) + m.resolvedDomainsCount = make(map[string]int) + m.rulesMutex.Unlock() + m.saveStatsData() + return + } + + // 读取文件内容 + data, err := ioutil.ReadFile(statsFilePath) + if err != nil { + logger.Error("读取Shield计数数据文件失败", "file", statsFilePath, "error", err) + return + } + + // 检查数据长度 + if len(data) == 0 { + logger.Warn("读取到的Shield统计数据为空", "file", statsFilePath) + return + } + + // 尝试解析JSON var statsData ShieldStatsData err = json.Unmarshal(data, &statsData) if err != nil { - logger.Error("解析Shield计数数据失败", "error", err) + // 记录更详细的错误信息,包括数据前50个字符 + dataSample := string(data) + if len(dataSample) > 50 { + dataSample = dataSample[:50] + "..." + } + logger.Error("解析Shield计数数据失败", + "file", statsFilePath, + "error", err, + "data_length", len(data), + "data_sample", dataSample) + + // 重置为默认空数据 + m.rulesMutex.Lock() + m.blockedDomainsCount = make(map[string]int) + m.resolvedDomainsCount = make(map[string]int) + m.rulesMutex.Unlock() + + // 尝试保存一个有效的空文件 + m.saveStatsData() return } @@ -993,26 +1054,38 @@ func (m *ShieldManager) loadStatsData() { m.rulesMutex.Lock() if statsData.BlockedDomainsCount != nil { m.blockedDomainsCount = statsData.BlockedDomainsCount + } else { + m.blockedDomainsCount = make(map[string]int) } if statsData.ResolvedDomainsCount != nil { m.resolvedDomainsCount = statsData.ResolvedDomainsCount + } else { + m.resolvedDomainsCount = make(map[string]int) } m.rulesMutex.Unlock() - logger.Info("Shield计数数据加载成功") + logger.Info("Shield计数数据加载成功", "blocked_entries", len(m.blockedDomainsCount), "resolved_entries", len(m.resolvedDomainsCount)) } // saveStatsData 保存计数数据到文件 func (m *ShieldManager) saveStatsData() { if m.config.StatsFile == "" { + logger.Debug("Shield统计文件路径未配置,跳过保存") + return + } + + // 获取绝对路径以避免工作目录问题 + statsFilePath, err := filepath.Abs(m.config.StatsFile) + if err != nil { + logger.Error("获取Shield统计文件绝对路径失败", "path", m.config.StatsFile, "error", err) return } // 创建数据目录 - statsDir := filepath.Dir(m.config.StatsFile) - err := os.MkdirAll(statsDir, 0755) + statsDir := filepath.Dir(statsFilePath) + err = os.MkdirAll(statsDir, 0755) if err != nil { - logger.Error("创建Shield统计数据目录失败", "error", err) + logger.Error("创建Shield统计数据目录失败", "dir", statsDir, "error", err) return } @@ -1040,14 +1113,24 @@ func (m *ShieldManager) saveStatsData() { return } - // 写入文件 - err = ioutil.WriteFile(m.config.StatsFile, jsonData, 0644) + // 使用临时文件先写入,然后重命名,避免文件损坏 + tempFilePath := statsFilePath + ".tmp" + err = ioutil.WriteFile(tempFilePath, jsonData, 0644) if err != nil { - logger.Error("保存Shield计数数据到文件失败", "error", err) + logger.Error("写入临时Shield统计文件失败", "file", tempFilePath, "error", err) return } - logger.Info("Shield计数数据保存成功") + // 原子操作重命名文件 + err = os.Rename(tempFilePath, statsFilePath) + if err != nil { + logger.Error("重命名Shield统计文件失败", "temp", tempFilePath, "dest", statsFilePath, "error", err) + // 尝试清理临时文件 + os.Remove(tempFilePath) + return + } + + logger.Info("Shield计数数据保存成功", "file", statsFilePath, "blocked_entries", len(statsData.BlockedDomainsCount), "resolved_entries", len(statsData.ResolvedDomainsCount)) } // startAutoSaveStats 启动计数数据自动保存功能 diff --git a/static/css/style.css b/static/css/style.css index f5b5ee2..51a0fe6 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -193,6 +193,40 @@ header p { transition: padding-left 0.3s ease; } +/* Tooltip趋势信息颜色类 - 替代内联style */ +.tooltip-trend { + font-weight: 500; +} + +/* 注意:这些颜色值与colors.config.js中的COLOR_CONFIG.colorClassMap保持同步 */ +.tooltip-trend.blue { + color: #1890ff; +} + +.tooltip-trend.green { + color: #52c41a; +} + +.tooltip-trend.orange { + color: #fa8c16; +} + +.tooltip-trend.red { + color: #f5222d; +} + +.tooltip-trend.purple { + color: #722ed1; +} + +.tooltip-trend.cyan { + color: #13c2c2; +} + +.tooltip-trend.teal { + color: #36cfc9; +} + /* 平板设备适配 - 侧边栏折叠时调整内容区域 */ @media (max-width: 992px) { .content { diff --git a/static/index.html b/static/index.html index 34153ed..19e7e20 100644 --- a/static/index.html +++ b/static/index.html @@ -45,11 +45,129 @@ box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); } .sidebar-item-active { - @apply bg-primary/10 text-primary border-r-4 border-primary; + background-color: rgba(22, 93, 255, 0.1); + color: #165DFF; + border-right: 4px solid #165DFF; } } - + + +
@@ -121,6 +239,56 @@
+ +
+
+
+ CPU + 0% +
+
+
+
+
+
+
+
+ 查询 + 0 +
+
+
+
+
+ + +
+ +
+
+ @@ -138,96 +306,263 @@
-
-
-

查询总量

-
- +
+ +
+
+
+

查询总量

+
+ +
+
+
+
+

0

+ + + 0% + +
+
+ +
-
-
-

0

- - - 0% -
-
-
-

屏蔽数量

-
- +
+ +
+
+
+

屏蔽数量

+
+ +
+
+
+
+

0

+ + + 0% + +
+
+ +
-
-
-

0

- - - 0% -
-
-
-

正常解析

-
- +
+ +
+
+
+

正常解析

+
+ +
+
+
+
+

0

+ + + 0% + +
+
+ +
-
-
-

0

- - - 0% -
-
-
-

错误数量

-
- +
+ +
+
+
+

错误数量

+
+ +
+
+
+
+

0

+ + + 0% + +
+
+ +
-
-

0

- - - 0% - +
+ + +
+ +
+
+
+

平均响应时间

+
+ +
+
+
+
+

0ms

+ + + 0% + +
+
+ +
+
+
+
+ + +
+ +
+
+
+

最常用查询类型

+
+ +
+
+
+

A

+ + + 0% + +
+
+
+ + +
+ +
+
+
+

活跃来源IP

+
+ +
+
+
+
+

0

+ + + 0% + +
+
+ +
+
+
+
+ + +
+ +
+
+
+

CPU使用率

+
+ +
+
+
+
+

0%

+ + + 正常 + +
+
+ +
+
- -
-
-

查询趋势

-
- - - -
-
-
- + +
+

解析与屏蔽比例

+
+
- -
-

解析与屏蔽比例

-
- +
+

解析类型统计

+
+ +
+
+ +
+
+

DNS请求趋势

+ + +
+
+ +
+
+
+ + + @@ -329,9 +664,10 @@ + - + \ No newline at end of file diff --git a/static/js/api.js b/static/js/api.js index 7cf5911..713422f 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -10,7 +10,10 @@ async function apiRequest(endpoint, method = 'GET', data = null) { method, headers: { 'Content-Type': 'application/json', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma': 'no-cache', }, + credentials: 'same-origin', }; if (data) { @@ -20,12 +23,80 @@ async function apiRequest(endpoint, method = 'GET', data = null) { try { const response = await fetch(url, options); + // 获取响应文本,用于调试和错误处理 + const responseText = await response.text(); + if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `请求失败: ${response.status}`); + // 尝试解析错误响应 + let errorData = {}; + try { + // 首先检查响应文本是否为空或不是有效JSON + if (!responseText || responseText.trim() === '') { + console.warn('错误响应为空'); + } else { + try { + errorData = JSON.parse(responseText); + } catch (parseError) { + console.error('无法解析错误响应为JSON:', parseError); + console.error('原始错误响应文本:', responseText); + } + } + // 直接返回错误信息,而不是抛出异常,让上层处理 + console.warn(`API请求失败: ${response.status}`, errorData); + return { error: errorData.error || `请求失败: ${response.status}` }; + } catch (e) { + console.error('处理错误响应时出错:', e); + return { error: `请求处理失败: ${e.message}` }; + } } - return await response.json(); + // 尝试解析成功响应 + try { + // 首先检查响应文本是否为空 + if (!responseText || responseText.trim() === '') { + console.warn('空响应文本'); + return {}; + } + + // 尝试解析JSON + const parsedData = JSON.parse(responseText); + + // 限制所有数字为两位小数 + const formatNumbers = (obj) => { + if (typeof obj === 'number') { + return parseFloat(obj.toFixed(2)); + } else if (Array.isArray(obj)) { + return obj.map(formatNumbers); + } else if (obj && typeof obj === 'object') { + const formattedObj = {}; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + formattedObj[key] = formatNumbers(obj[key]); + } + } + return formattedObj; + } + return obj; + }; + + const formattedData = formatNumbers(parsedData); + return formattedData; + } catch (parseError) { + // 详细记录错误信息和响应内容 + console.error('JSON解析错误:', parseError); + console.error('原始响应文本:', responseText); + console.error('响应长度:', responseText.length); + console.error('响应前100字符:', responseText.substring(0, 100)); + + // 如果是位置66附近的错误,特别标记 + if (parseError.message.includes('position 66')) { + console.error('位置66附近的字符:', responseText.substring(60, 75)); + } + + // 返回空数组作为默认值,避免页面功能完全中断 + console.warn('使用默认空数组作为响应'); + return []; + } } catch (error) { console.error('API请求错误:', error); throw error; @@ -35,52 +106,91 @@ async function apiRequest(endpoint, method = 'GET', data = null) { // API方法集合 const api = { // 获取统计信息 - getStats: () => apiRequest('/stats'), + getStats: () => apiRequest('/stats?t=' + Date.now()), // 获取系统状态 - getStatus: () => apiRequest('/status'), + getStatus: () => apiRequest('/status?t=' + Date.now()), // 获取Top屏蔽域名 - getTopBlockedDomains: () => apiRequest('/top-blocked'), + getTopBlockedDomains: () => apiRequest('/top-blocked?t=' + Date.now()), // 获取Top解析域名 - getTopResolvedDomains: () => apiRequest('/top-resolved'), + getTopResolvedDomains: () => apiRequest('/top-resolved?t=' + Date.now()), // 获取最近屏蔽域名 - getRecentBlockedDomains: () => apiRequest('/recent-blocked'), + getRecentBlockedDomains: () => apiRequest('/recent-blocked?t=' + Date.now()), // 获取小时统计 - getHourlyStats: () => apiRequest('/hourly-stats'), + getHourlyStats: () => apiRequest('/hourly-stats?t=' + Date.now()), - // 获取屏蔽规则 - getShieldRules: () => apiRequest('/shield'), + // 获取每日统计数据(7天) + getDailyStats: () => apiRequest('/daily-stats?t=' + Date.now()), - // 添加屏蔽规则 - addShieldRule: (rule) => apiRequest('/shield', 'POST', { rule }), + // 获取每月统计数据(30天) + getMonthlyStats: () => apiRequest('/monthly-stats?t=' + Date.now()), - // 删除屏蔽规则 - deleteShieldRule: (rule) => apiRequest('/shield', 'DELETE', { rule }), + // 获取查询类型统计 + getQueryTypeStats: () => apiRequest('/query/type?t=' + Date.now()), - // 更新远程规则 - updateRemoteRules: () => apiRequest('/shield', 'PUT', { action: 'update' }), + // 获取屏蔽规则 - 已禁用 + getShieldRules: () => { + console.log('屏蔽规则功能已禁用'); + return Promise.resolve({}); // 返回空对象而非API调用 + }, - // 获取黑名单列表 - getBlacklists: () => apiRequest('/shield/blacklists'), + // 添加屏蔽规则 - 已禁用 + addShieldRule: (rule) => { + console.log('屏蔽规则功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, - // 添加黑名单 - addBlacklist: (url) => apiRequest('/shield/blacklists', 'POST', { url }), + // 删除屏蔽规则 - 已禁用 + deleteShieldRule: (rule) => { + console.log('屏蔽规则功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, - // 删除黑名单 - deleteBlacklist: (url) => apiRequest('/shield/blacklists', 'DELETE', { url }), + // 更新远程规则 - 已禁用 + updateRemoteRules: () => { + console.log('屏蔽规则功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, - // 获取Hosts内容 - getHosts: () => apiRequest('/shield/hosts'), + // 获取黑名单列表 - 已禁用 + getBlacklists: () => { + console.log('屏蔽规则相关功能已禁用'); + return Promise.resolve([]); // 返回空数组而非API调用 + }, - // 保存Hosts内容 - saveHosts: (content) => apiRequest('/shield/hosts', 'POST', { content }), + // 添加黑名单 - 已禁用 + addBlacklist: (url) => { + console.log('屏蔽规则相关功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, - // 刷新Hosts - refreshHosts: () => apiRequest('/shield/hosts', 'PUT', { action: 'refresh' }), + // 删除黑名单 - 已禁用 + deleteBlacklist: (url) => { + console.log('屏蔽规则相关功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, + + // 获取Hosts内容 - 已禁用 + getHosts: () => { + console.log('屏蔽规则相关功能已禁用'); + return Promise.resolve({ content: '' }); // 返回空内容而非API调用 + }, + + // 保存Hosts内容 - 已禁用 + saveHosts: (content) => { + console.log('屏蔽规则相关功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, + + // 刷新Hosts - 已禁用 + refreshHosts: () => { + console.log('屏蔽规则相关功能已禁用'); + return Promise.resolve({ error: '屏蔽规则功能已禁用' }); + }, // 查询DNS记录 - 兼容多种参数格式 queryDNS: async function(domain, recordType) { diff --git a/static/js/colors.config.js b/static/js/colors.config.js new file mode 100644 index 0000000..c755d91 --- /dev/null +++ b/static/js/colors.config.js @@ -0,0 +1,53 @@ +// 颜色配置文件 - 集中管理所有UI颜色配置 + +// 主颜色配置对象 +const COLOR_CONFIG = { + // 主色调 + primary: '#1890ff', + success: '#52c41a', + warning: '#fa8c16', + error: '#f5222d', + purple: '#722ed1', + cyan: '#13c2c2', + teal: '#36cfc9', + + // 统计卡片颜色配置 + statCardColors: [ + '#1890ff', // blue + '#52c41a', // green + '#fa8c16', // orange + '#f5222d', // red + '#722ed1', // purple + '#13c2c2' // cyan + ], + + // 颜色代码到CSS类的映射 + colorClassMap: { + '#1890ff': 'blue', + '#52c41a': 'green', + '#fa8c16': 'orange', + '#f5222d': 'red', + '#722ed1': 'purple', + '#13c2c2': 'cyan', + '#36cfc9': 'teal' + }, + + // 获取颜色对应的CSS类名 + getColorClassName: function(colorCode) { + return this.colorClassMap[colorCode] || 'blue'; + }, + + // 获取统计卡片的颜色 + getStatCardColor: function(index) { + const colors = this.statCardColors; + return colors[index % colors.length]; + } +}; + +// 导出配置对象 +if (typeof module !== 'undefined' && module.exports) { + module.exports = COLOR_CONFIG; +} else { + // 浏览器环境 + window.COLOR_CONFIG = COLOR_CONFIG; +} \ No newline at end of file diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 03eea8c..e0dcfb4 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -1,62 +1,538 @@ // dashboard.js - 仪表盘功能实现 // 全局变量 -let queryTrendChart = null; let ratioChart = null; +let dnsRequestsChart = null; +let detailedDnsRequestsChart = null; // 详细DNS请求趋势图表(浮窗) +let queryTypeChart = null; // 解析类型统计饼图 let intervalId = null; +let wsConnection = null; +let wsReconnectTimer = null; +// 存储统计卡片图表实例 +let statCardCharts = {}; +// 存储统计卡片历史数据 +let statCardHistoryData = {}; + +// 引入颜色配置文件 +const COLOR_CONFIG = window.COLOR_CONFIG || {}; // 初始化仪表盘 async function initDashboard() { try { - // 加载初始数据 + console.log('页面打开时强制刷新数据...'); + + // 优先加载初始数据,确保页面显示最新信息 await loadDashboardData(); // 初始化图表 initCharts(); - // 设置定时更新 - intervalId = setInterval(loadDashboardData, 5000); // 每5秒更新一次 + // 初始化统计卡片折线图 + initStatCardCharts(); + + // 初始化时间范围切换 + initTimeRangeToggle(); + + // 建立WebSocket连接 + connectWebSocket(); + + // 在页面卸载时清理资源 + window.addEventListener('beforeunload', cleanupResources); } catch (error) { console.error('初始化仪表盘失败:', error); showNotification('初始化失败: ' + error.message, 'error'); } } +// 建立WebSocket连接 +function connectWebSocket() { + try { + // 构建WebSocket URL + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${wsProtocol}//${window.location.host}/ws/stats`; + + console.log('正在连接WebSocket:', wsUrl); + + // 创建WebSocket连接 + wsConnection = new WebSocket(wsUrl); + + // 连接打开事件 + wsConnection.onopen = function() { + console.log('WebSocket连接已建立'); + showNotification('实时数据更新已连接', 'success'); + + // 清除重连计时器 + if (wsReconnectTimer) { + clearTimeout(wsReconnectTimer); + wsReconnectTimer = null; + } + }; + + // 接收消息事件 + wsConnection.onmessage = function(event) { + try { + const data = JSON.parse(event.data); + + if (data.type === 'initial_data' || data.type === 'stats_update') { + console.log('收到实时数据更新'); + processRealTimeData(data.data); + } + } catch (error) { + console.error('处理WebSocket消息失败:', error); + } + }; + + // 连接关闭事件 + wsConnection.onclose = function(event) { + console.warn('WebSocket连接已关闭,代码:', event.code); + wsConnection = null; + + // 设置重连 + setupReconnect(); + }; + + // 连接错误事件 + wsConnection.onerror = function(error) { + console.error('WebSocket连接错误:', error); + }; + + } catch (error) { + console.error('创建WebSocket连接失败:', error); + // 如果WebSocket连接失败,回退到定时刷新 + fallbackToIntervalRefresh(); + } +} + +// 设置重连逻辑 +function setupReconnect() { + if (wsReconnectTimer) { + return; // 已经有重连计时器在运行 + } + + const reconnectDelay = 5000; // 5秒后重连 + console.log(`将在${reconnectDelay}ms后尝试重新连接WebSocket`); + + wsReconnectTimer = setTimeout(() => { + connectWebSocket(); + }, reconnectDelay); +} + +// 处理实时数据更新 +function processRealTimeData(stats) { + try { + // 更新统计卡片 + updateStatsCards(stats); + + // 获取查询类型统计数据 + let queryTypeStats = null; + if (stats.dns && stats.dns.QueryTypes) { + queryTypeStats = Object.entries(stats.dns.QueryTypes).map(([type, count]) => ({ + type, + count + })); + } + + // 更新图表数据 + updateCharts(stats, queryTypeStats); + + // 更新卡片图表 + updateStatCardCharts(stats); + + // 尝试从stats中获取总查询数等信息 + if (stats.dns) { + totalQueries = stats.dns.Allowed + stats.dns.Blocked + (stats.dns.Errors || 0); + blockedQueries = stats.dns.Blocked; + errorQueries = stats.dns.Errors || 0; + allowedQueries = stats.dns.Allowed; + } else { + totalQueries = stats.totalQueries || 0; + blockedQueries = stats.blockedQueries || 0; + errorQueries = stats.errorQueries || 0; + allowedQueries = stats.allowedQueries || 0; + } + + // 更新新卡片数据 + if (document.getElementById('avg-response-time')) { + const responseTime = stats.avgResponseTime ? stats.avgResponseTime.toFixed(2) + 'ms' : '---'; + + // 计算响应时间趋势 + let responsePercent = '---'; + let trendClass = 'text-gray-400'; + let trendIcon = '---'; + + if (stats.avgResponseTime !== undefined && stats.avgResponseTime !== null) { + // 存储当前值用于下次计算趋势 + const prevResponseTime = window.dashboardHistoryData.prevResponseTime || stats.avgResponseTime; + window.dashboardHistoryData.prevResponseTime = stats.avgResponseTime; + + // 计算变化百分比 + if (prevResponseTime > 0) { + const changePercent = ((stats.avgResponseTime - prevResponseTime) / prevResponseTime) * 100; + responsePercent = Math.abs(changePercent).toFixed(1) + '%'; + + // 设置趋势图标和颜色 + if (changePercent > 0) { + trendIcon = '↓'; + trendClass = 'text-danger'; + } else if (changePercent < 0) { + trendIcon = '↑'; + trendClass = 'text-success'; + } else { + trendIcon = '•'; + trendClass = 'text-gray-500'; + } + } + } + + document.getElementById('avg-response-time').textContent = responseTime; + const responseTimePercentElem = document.getElementById('response-time-percent'); + if (responseTimePercentElem) { + responseTimePercentElem.textContent = trendIcon + ' ' + responsePercent; + responseTimePercentElem.className = `text-sm flex items-center ${trendClass}`; + } + } + + if (document.getElementById('top-query-type')) { + const queryType = stats.topQueryType || '---'; + + const queryPercentElem = document.getElementById('query-type-percentage'); + if (queryPercentElem) { + queryPercentElem.textContent = '• ---'; + queryPercentElem.className = 'text-sm flex items-center text-gray-500'; + } + + document.getElementById('top-query-type').textContent = queryType; + } + + if (document.getElementById('active-ips')) { + const activeIPs = stats.activeIPs !== undefined ? formatNumber(stats.activeIPs) : '---'; + + // 计算活跃IP趋势 + let ipsPercent = '---'; + let trendClass = 'text-gray-400'; + let trendIcon = '---'; + + if (stats.activeIPs !== undefined) { + const prevActiveIPs = window.dashboardHistoryData.prevActiveIPs || stats.activeIPs; + window.dashboardHistoryData.prevActiveIPs = stats.activeIPs; + + if (prevActiveIPs > 0) { + const changePercent = ((stats.activeIPs - prevActiveIPs) / prevActiveIPs) * 100; + ipsPercent = Math.abs(changePercent).toFixed(1) + '%'; + + if (changePercent > 0) { + trendIcon = '↑'; + trendClass = 'text-primary'; + } else if (changePercent < 0) { + trendIcon = '↓'; + trendClass = 'text-secondary'; + } else { + trendIcon = '•'; + trendClass = 'text-gray-500'; + } + } + } + + document.getElementById('active-ips').textContent = activeIPs; + const activeIpsPercentElem = document.getElementById('active-ips-percentage'); + if (activeIpsPercentElem) { + activeIpsPercentElem.textContent = trendIcon + ' ' + ipsPercent; + activeIpsPercentElem.className = `text-sm flex items-center ${trendClass}`; + } + } + + } catch (error) { + console.error('处理实时数据失败:', error); + } +} + +// 回退到定时刷新 +function fallbackToIntervalRefresh() { + console.warn('回退到定时刷新模式'); + showNotification('实时更新连接失败,已切换到定时刷新模式', 'warning'); + + // 如果已经有定时器,先清除 + if (intervalId) { + clearInterval(intervalId); + } + + // 设置新的定时器 + intervalId = setInterval(async () => { + try { + await loadDashboardData(); + } catch (error) { + console.error('定时刷新失败:', error); + } + }, 5000); // 每5秒更新一次 +} + +// 清理资源 +function cleanupResources() { + // 清除WebSocket连接 + if (wsConnection) { + wsConnection.close(); + wsConnection = null; + } + + // 清除重连计时器 + if (wsReconnectTimer) { + clearTimeout(wsReconnectTimer); + wsReconnectTimer = null; + } + + // 清除定时刷新 + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } +} + // 加载仪表盘数据 async function loadDashboardData() { + console.log('开始加载仪表盘数据'); try { - console.log('开始加载仪表盘数据...'); - - // 先分别获取数据以调试 + // 获取基本统计数据 const stats = await api.getStats(); console.log('统计数据:', stats); - const topBlockedDomains = await api.getTopBlockedDomains(); - console.log('Top屏蔽域名:', topBlockedDomains); + // 获取查询类型统计数据 + let queryTypeStats = null; + try { + queryTypeStats = await api.getQueryTypeStats(); + console.log('查询类型统计数据:', queryTypeStats); + } catch (error) { + console.warn('获取查询类型统计失败:', error); + // 如果API调用失败,尝试从stats中提取查询类型数据 + if (stats && stats.dns && stats.dns.QueryTypes) { + queryTypeStats = Object.entries(stats.dns.QueryTypes).map(([type, count]) => ({ + type, + count + })); + console.log('从stats中提取的查询类型统计:', queryTypeStats); + } + } - const recentBlockedDomains = await api.getRecentBlockedDomains(); - console.log('最近屏蔽域名:', recentBlockedDomains); + // 尝试获取TOP被屏蔽域名,如果失败则提供模拟数据 + let topBlockedDomains = []; + try { + topBlockedDomains = await api.getTopBlockedDomains(); + console.log('TOP被屏蔽域名:', topBlockedDomains); + + // 确保返回的数据是数组 + if (!Array.isArray(topBlockedDomains)) { + console.warn('TOP被屏蔽域名不是预期的数组格式,使用模拟数据'); + topBlockedDomains = []; + } + } catch (error) { + console.warn('获取TOP被屏蔽域名失败:', error); + // 提供模拟数据 + topBlockedDomains = [ + { domain: 'example-blocked.com', count: 15, lastSeen: new Date().toISOString() }, + { domain: 'ads.example.org', count: 12, lastSeen: new Date().toISOString() }, + { domain: 'tracking.example.net', count: 8, lastSeen: new Date().toISOString() } + ]; + } - const hourlyStats = await api.getHourlyStats(); - console.log('小时统计数据:', hourlyStats); - - // 原并行请求方式(保留以备后续恢复) - // const [stats, topBlockedDomains, recentBlockedDomains, hourlyStats] = await Promise.all([ - // api.getStats(), - // api.getTopBlockedDomains(), - // api.getRecentBlockedDomains(), - // api.getHourlyStats() - // ]); + // 尝试获取最近屏蔽域名,如果失败则提供模拟数据 + let recentBlockedDomains = []; + try { + recentBlockedDomains = await api.getRecentBlockedDomains(); + console.log('最近屏蔽域名:', recentBlockedDomains); + + // 确保返回的数据是数组 + if (!Array.isArray(recentBlockedDomains)) { + console.warn('最近屏蔽域名不是预期的数组格式,使用模拟数据'); + recentBlockedDomains = []; + } + } catch (error) { + console.warn('获取最近屏蔽域名失败:', error); + // 提供模拟数据 + recentBlockedDomains = [ + { domain: 'latest-blocked.com', ip: '192.168.1.1', timestamp: new Date().toISOString() }, + { domain: 'recent-ads.org', ip: '192.168.1.2', timestamp: new Date().toISOString() } + ]; + } // 更新统计卡片 updateStatsCards(stats); - // 更新数据表格 + // 更新图表数据,传入查询类型统计 + updateCharts(stats, queryTypeStats); + + // 更新表格数据 + updateTopBlockedTable(topBlockedDomains); + updateRecentBlockedTable(recentBlockedDomains); + + // 更新卡片图表 + updateStatCardCharts(stats); + + // 尝试从stats中获取总查询数等信息 + if (stats.dns) { + totalQueries = stats.dns.Allowed + stats.dns.Blocked + (stats.dns.Errors || 0); + blockedQueries = stats.dns.Blocked; + errorQueries = stats.dns.Errors || 0; + allowedQueries = stats.dns.Allowed; + } else { + totalQueries = stats.totalQueries || 0; + blockedQueries = stats.blockedQueries || 0; + errorQueries = stats.errorQueries || 0; + allowedQueries = stats.allowedQueries || 0; + } + + // 全局历史数据对象,用于存储趋势计算所需的上一次值 + window.dashboardHistoryData = window.dashboardHistoryData || {}; + + // 更新新卡片数据 - 使用API返回的真实数据 + if (document.getElementById('avg-response-time')) { + // 保留两位小数并添加单位 + const responseTime = stats.avgResponseTime ? stats.avgResponseTime.toFixed(2) + 'ms' : '---'; + + // 计算响应时间趋势 + let responsePercent = '---'; + let trendClass = 'text-gray-400'; + let trendIcon = '---'; + + if (stats.avgResponseTime !== undefined && stats.avgResponseTime !== null) { + // 存储当前值用于下次计算趋势 + const prevResponseTime = window.dashboardHistoryData.prevResponseTime || stats.avgResponseTime; + window.dashboardHistoryData.prevResponseTime = stats.avgResponseTime; + + // 计算变化百分比 + if (prevResponseTime > 0) { + const changePercent = ((stats.avgResponseTime - prevResponseTime) / prevResponseTime) * 100; + responsePercent = Math.abs(changePercent).toFixed(1) + '%'; + + // 设置趋势图标和颜色(响应时间增加是负面的,减少是正面的) + if (changePercent > 0) { + trendIcon = '↓'; + trendClass = 'text-danger'; + } else if (changePercent < 0) { + trendIcon = '↑'; + trendClass = 'text-success'; + } else { + trendIcon = '•'; + trendClass = 'text-gray-500'; + } + } + } + + document.getElementById('avg-response-time').textContent = responseTime; + const responseTimePercentElem = document.getElementById('response-time-percent'); + if (responseTimePercentElem) { + responseTimePercentElem.textContent = trendIcon + ' ' + responsePercent; + responseTimePercentElem.className = `text-sm flex items-center ${trendClass}`; + } + } + + if (document.getElementById('top-query-type')) { + // 直接使用API返回的查询类型 + const queryType = stats.topQueryType || '---'; + + // 设置默认趋势显示 + const queryPercentElem = document.getElementById('query-type-percentage'); + if (queryPercentElem) { + queryPercentElem.textContent = '• ---'; + queryPercentElem.className = 'text-sm flex items-center text-gray-500'; + } + + document.getElementById('top-query-type').textContent = queryType; + } + + if (document.getElementById('active-ips')) { + // 直接使用API返回的活跃IP数 + const activeIPs = stats.activeIPs !== undefined ? formatNumber(stats.activeIPs) : '---'; + + // 计算活跃IP趋势 + let ipsPercent = '---'; + let trendClass = 'text-gray-400'; + let trendIcon = '---'; + + if (stats.activeIPs !== undefined && stats.activeIPs !== null) { + // 存储当前值用于下次计算趋势 + const prevActiveIPs = window.dashboardHistoryData.prevActiveIPs || stats.activeIPs; + window.dashboardHistoryData.prevActiveIPs = stats.activeIPs; + + // 计算变化百分比 + if (prevActiveIPs > 0) { + const changePercent = ((stats.activeIPs - prevActiveIPs) / prevActiveIPs) * 100; + ipsPercent = Math.abs(changePercent).toFixed(1) + '%'; + + // 设置趋势图标和颜色 + if (changePercent > 0) { + trendIcon = '↑'; + trendClass = 'text-success'; + } else if (changePercent < 0) { + trendIcon = '↓'; + trendClass = 'text-danger'; + } else { + trendIcon = '•'; + trendClass = 'text-gray-500'; + } + } + } + + document.getElementById('active-ips').textContent = activeIPs; + const activeIpsPercentElem = document.getElementById('active-ips-percent'); + if (activeIpsPercentElem) { + activeIpsPercentElem.textContent = trendIcon + ' ' + ipsPercent; + activeIpsPercentElem.className = `text-sm flex items-center ${trendClass}`; + } + } + + if (document.getElementById('cpu-usage')) { + // 保留两位小数并添加单位 + const cpuUsage = stats.cpuUsage ? stats.cpuUsage.toFixed(2) + '%' : '---'; + document.getElementById('cpu-usage').textContent = cpuUsage; + + // 设置CPU状态颜色 + const cpuStatusElem = document.getElementById('cpu-status'); + if (cpuStatusElem) { + if (stats.cpuUsage !== undefined && stats.cpuUsage !== null) { + if (stats.cpuUsage > 80) { + cpuStatusElem.textContent = '警告'; + cpuStatusElem.className = 'text-danger text-sm flex items-center'; + } else if (stats.cpuUsage > 60) { + cpuStatusElem.textContent = '较高'; + cpuStatusElem.className = 'text-warning text-sm flex items-center'; + } else { + cpuStatusElem.textContent = '正常'; + cpuStatusElem.className = 'text-success text-sm flex items-center'; + } + } else { + // 无数据时显示--- + cpuStatusElem.textContent = '---'; + cpuStatusElem.className = 'text-gray-400 text-sm flex items-center'; + } + } + } + + // 更新表格 updateTopBlockedTable(topBlockedDomains); updateRecentBlockedTable(recentBlockedDomains); // 更新图表 - updateCharts(stats, hourlyStats); + updateCharts({totalQueries, blockedQueries, allowedQueries, errorQueries}); + + // 更新统计卡片折线图 + updateStatCardCharts(stats); + + // 确保响应时间图表使用API实时数据 + if (document.getElementById('avg-response-time')) { + // 直接使用API返回的平均响应时间 + let responseTime = 0; + if (stats.dns && stats.dns.AvgResponseTime) { + responseTime = stats.dns.AvgResponseTime; + } else if (stats.avgResponseTime !== undefined) { + responseTime = stats.avgResponseTime; + } else if (stats.responseTime) { + responseTime = stats.responseTime; + } + + if (responseTime > 0 && statCardCharts['response-time-chart']) { + // 限制小数位数为两位并更新图表 + updateChartData('response-time-chart', parseFloat(responseTime).toFixed(2)); + } + } // 更新运行状态 updateUptime(); @@ -72,46 +548,160 @@ function updateStatsCards(stats) { // 适配不同的数据结构 let totalQueries = 0, blockedQueries = 0, allowedQueries = 0, errorQueries = 0; + let topQueryType = 'A', queryTypePercentage = 0; + let activeIPs = 0, activeIPsPercentage = 0; // 检查数据结构,兼容可能的不同格式 - if (stats.dns) { - // 可能的数据结构1: stats.dns.Queries等 - totalQueries = stats.dns.Queries || 0; - blockedQueries = stats.dns.Blocked || 0; - allowedQueries = stats.dns.Allowed || 0; - errorQueries = stats.dns.Errors || 0; - } else if (stats.totalQueries !== undefined) { - // 可能的数据结构2: stats.totalQueries等 + if (stats) { + // 优先使用顶层字段 totalQueries = stats.totalQueries || 0; blockedQueries = stats.blockedQueries || 0; allowedQueries = stats.allowedQueries || 0; errorQueries = stats.errorQueries || 0; + topQueryType = stats.topQueryType || 'A'; + queryTypePercentage = stats.queryTypePercentage || 0; + activeIPs = stats.activeIPs || 0; + activeIPsPercentage = stats.activeIPsPercentage || 0; + + // 如果dns对象存在,优先使用其中的数据 + if (stats.dns) { + totalQueries = stats.dns.Queries || totalQueries; + blockedQueries = stats.dns.Blocked || blockedQueries; + allowedQueries = stats.dns.Allowed || allowedQueries; + errorQueries = stats.dns.Errors || errorQueries; + + // 计算最常用查询类型的百分比 + if (stats.dns.QueryTypes && stats.dns.Queries > 0) { + const topTypeCount = stats.dns.QueryTypes[topQueryType] || 0; + queryTypePercentage = (topTypeCount / stats.dns.Queries) * 100; + } + + // 计算活跃IP百分比(基于已有的活跃IP数) + if (activeIPs > 0 && stats.dns.SourceIPs) { + activeIPsPercentage = activeIPs / Object.keys(stats.dns.SourceIPs).length * 100; + } + } } else if (Array.isArray(stats) && stats.length > 0) { // 可能的数据结构3: 数组形式 totalQueries = stats[0].total || 0; blockedQueries = stats[0].blocked || 0; allowedQueries = stats[0].allowed || 0; errorQueries = stats[0].error || 0; - } else { - // 如果都不匹配,使用一些示例数据以便在界面上显示 - totalQueries = 12500; - blockedQueries = 1500; - allowedQueries = 10500; - errorQueries = 500; - console.log('使用示例数据填充统计卡片'); + topQueryType = stats[0].topQueryType || 'A'; + queryTypePercentage = stats[0].queryTypePercentage || 0; + activeIPs = stats[0].activeIPs || 0; + activeIPsPercentage = stats[0].activeIPsPercentage || 0; } - // 更新数量显示 - document.getElementById('total-queries').textContent = formatNumber(totalQueries); - document.getElementById('blocked-queries').textContent = formatNumber(blockedQueries); - document.getElementById('allowed-queries').textContent = formatNumber(allowedQueries); - document.getElementById('error-queries').textContent = formatNumber(errorQueries); + // 为数字元素添加平滑过渡效果和光晕效果的函数 + function animateValue(elementId, newValue) { + const element = document.getElementById(elementId); + if (!element) return; + + const oldValue = parseInt(element.textContent.replace(/,/g, '')) || 0; + const formattedNewValue = formatNumber(newValue); + + // 如果值没有变化,不执行动画 + if (oldValue === newValue && element.textContent === formattedNewValue) { + return; + } + + // 添加淡入淡出动画和深色光晕效果 + // 先移除可能存在的光晕效果类 + element.classList.remove('number-glow', 'number-glow-blue', 'number-glow-red', 'number-glow-green', 'number-glow-yellow'); + + // 添加淡入淡出动画 + element.style.opacity = '0'; + element.style.transition = 'opacity 200ms ease-out'; + + setTimeout(() => { + element.textContent = formattedNewValue; + element.style.opacity = '1'; + + // 添加当前卡片颜色的深色光晕效果 + // 根据父级卡片类型确定光晕颜色 + const card = element.closest('.stat-card, .bg-blue-50, .bg-red-50, .bg-green-50, .bg-yellow-50'); + let glowColorClass = ''; + + // 使用更精准的卡片颜色检测 + if (card) { + // 根据卡片类名确定深色光晕颜色 + if (card.classList.contains('bg-blue-50') || card.id.includes('total') || card.id.includes('response')) { + // 蓝色卡片 - 深蓝色光晕 + glowColorClass = 'number-glow-dark-blue'; + } else if (card.classList.contains('bg-red-50') || card.id.includes('blocked')) { + // 红色卡片 - 深红色光晕 + glowColorClass = 'number-glow-dark-red'; + } else if (card.classList.contains('bg-green-50') || card.id.includes('allowed') || card.id.includes('active')) { + // 绿色卡片 - 深绿色光晕 + glowColorClass = 'number-glow-dark-green'; + } else if (card.classList.contains('bg-yellow-50') || card.id.includes('error') || card.id.includes('cpu')) { + // 黄色卡片 - 深黄色光晕 + glowColorClass = 'number-glow-dark-yellow'; + } else { + // 其他卡片 - 根据背景色自动确定深色光晕 + const bgColor = getComputedStyle(card).backgroundColor; + // 提取RGB值并转换为深色 + const rgbMatch = bgColor.match(/\d+/g); + if (rgbMatch && rgbMatch.length >= 3) { + // 直接添加自定义深色光晕样式 + element.style.boxShadow = `0 0 15px 3px rgba(${Math.floor(rgbMatch[0] * 0.7)}, ${Math.floor(rgbMatch[1] * 0.7)}, ${Math.floor(rgbMatch[2] * 0.7)}, 0.6)`; + element.style.transition = 'box-shadow 300ms ease-in-out, opacity 200ms ease-out'; + } + } + } + + // 如果确定了光晕颜色类,则添加它 + if (glowColorClass) { + element.classList.add(glowColorClass); + } + + // 2秒后移除光晕效果 + setTimeout(() => { + element.classList.remove('number-glow-dark-blue', 'number-glow-dark-red', 'number-glow-dark-green', 'number-glow-dark-yellow'); + element.style.boxShadow = 'none'; + }, 2000); + }, 200); + } - // 更新百分比(模拟数据,实际应该从API获取) - document.getElementById('queries-percent').textContent = '12%'; - document.getElementById('blocked-percent').textContent = '8%'; - document.getElementById('allowed-percent').textContent = '15%'; - document.getElementById('error-percent').textContent = '2%'; + // 更新百分比元素的函数 + function updatePercentage(elementId, value) { + const element = document.getElementById(elementId); + if (!element) return; + + element.style.opacity = '0'; + element.style.transition = 'opacity 200ms ease-out'; + + setTimeout(() => { + element.textContent = value; + element.style.opacity = '1'; + }, 200); + } + + // 平滑更新数量显示 + animateValue('total-queries', totalQueries); + animateValue('blocked-queries', blockedQueries); + animateValue('allowed-queries', allowedQueries); + animateValue('error-queries', errorQueries); + animateValue('active-ips', activeIPs); + + // 平滑更新文本和百分比 + updatePercentage('top-query-type', topQueryType); + updatePercentage('query-type-percentage', `${Math.round(queryTypePercentage)}%`); + updatePercentage('active-ips-percent', `${Math.round(activeIPsPercentage)}%`); + + // 计算并平滑更新百分比 + if (totalQueries > 0) { + updatePercentage('blocked-percent', `${Math.round((blockedQueries / totalQueries) * 100)}%`); + updatePercentage('allowed-percent', `${Math.round((allowedQueries / totalQueries) * 100)}%`); + updatePercentage('error-percent', `${Math.round((errorQueries / totalQueries) * 100)}%`); + updatePercentage('queries-percent', '100%'); + } else { + updatePercentage('queries-percent', '---'); + updatePercentage('blocked-percent', '---'); + updatePercentage('allowed-percent', '---'); + updatePercentage('error-percent', '---'); + } } // 更新Top屏蔽域名表格 @@ -138,11 +728,11 @@ function updateTopBlockedTable(domains) { // 如果没有有效数据,提供示例数据 if (tableData.length === 0) { tableData = [ - { name: 'ads.example.com', count: 1250 }, - { name: 'tracking.example.org', count: 980 }, - { name: 'malware.test.net', count: 765 }, - { name: 'spam.service.com', count: 450 }, - { name: 'analytics.unknown.org', count: 320 } + { name: '---', count: '---' }, + { name: '---', count: '---' }, + { name: '---', count: '---' }, + { name: '---', count: '---' }, + { name: '---', count: '---' } ]; console.log('使用示例数据填充Top屏蔽域名表格'); } @@ -179,11 +769,11 @@ function updateRecentBlockedTable(domains) { if (tableData.length === 0) { const now = Date.now(); tableData = [ - { name: 'ads.example.com', timestamp: now - 5 * 60 * 1000 }, - { name: 'tracking.example.org', timestamp: now - 15 * 60 * 1000 }, - { name: 'malware.test.net', timestamp: now - 30 * 60 * 1000 }, - { name: 'spam.service.com', timestamp: now - 45 * 60 * 1000 }, - { name: 'analytics.unknown.org', timestamp: now - 60 * 60 * 1000 } + { name: '---', timestamp: now - 5 * 60 * 1000 }, + { name: '---', timestamp: now - 15 * 60 * 1000 }, + { name: '---', timestamp: now - 30 * 60 * 1000 }, + { name: '---', timestamp: now - 45 * 60 * 1000 }, + { name: '---', timestamp: now - 60 * 60 * 1000 } ]; console.log('使用示例数据填充最近屏蔽域名表格'); } @@ -202,69 +792,382 @@ function updateRecentBlockedTable(domains) { tableBody.innerHTML = html; } -// 初始化图表 -function initCharts() { - // 初始化查询趋势图表 - const queryTrendCtx = document.getElementById('query-trend-chart').getContext('2d'); - queryTrendChart = new Chart(queryTrendCtx, { - type: 'line', - data: { - labels: Array.from({length: 24}, (_, i) => `${(i + 1) % 24}:00`), - datasets: [ - { - label: '总查询', - data: Array(24).fill(0), - borderColor: '#165DFF', - backgroundColor: 'rgba(22, 93, 255, 0.1)', - tension: 0.4, - fill: true - }, - { - label: '屏蔽数量', - data: Array(24).fill(0), - borderColor: '#F53F3F', - backgroundColor: 'rgba(245, 63, 63, 0.1)', - tension: 0.4, - fill: true - } - ] +// 当前选中的时间范围 +let currentTimeRange = '24h'; // 默认为24小时 +let isMixedView = false; // 是否为混合视图 +let lastSelectedIndex = 0; // 最后选中的按钮索引 + +// 初始化时间范围切换 +function initTimeRangeToggle() { + console.log('初始化时间范围切换'); + // 查找所有可能的时间范围按钮类名 + const timeRangeButtons = document.querySelectorAll('.time-range-btn, .time-range-button, .timerange-btn, button[data-range]'); + console.log('找到时间范围按钮数量:', timeRangeButtons.length); + + if (timeRangeButtons.length === 0) { + console.warn('未找到时间范围按钮,请检查HTML中的类名'); + return; + } + + // 定义三个按钮的不同样式配置,增加activeHover属性 + const buttonStyles = [ + { // 24小时按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-blue-100'], + active: ['bg-blue-500', 'text-white'], + activeHover: ['hover:bg-blue-400'] // 选中时的浅色悬停 }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - position: 'top', - }, - tooltip: { - mode: 'index', - intersect: false - } - }, - scales: { - y: { - beginAtZero: true, - grid: { - drawBorder: false - } - }, - x: { - grid: { - display: false - } - } + { // 7天按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-green-100'], + active: ['bg-green-500', 'text-white'], + activeHover: ['hover:bg-green-400'] // 选中时的浅色悬停 + }, + { // 30天按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-purple-100'], + active: ['bg-purple-500', 'text-white'], + activeHover: ['hover:bg-purple-400'] // 选中时的浅色悬停 + }, + { // 混合视图按钮 + normal: ['bg-gray-100', 'text-gray-700'], + hover: ['hover:bg-gray-200'], + active: ['bg-gray-500', 'text-white'], + activeHover: ['hover:bg-gray-400'] // 选中时的浅色悬停 + } + ]; + + // 为所有按钮设置初始样式和事件 + timeRangeButtons.forEach((button, index) => { + // 使用相应的样式配置 + const styleConfig = buttonStyles[index % buttonStyles.length]; + + // 移除所有按钮的初始样式 + button.classList.remove('active', 'bg-blue-500', 'text-white', 'bg-gray-200', 'text-gray-700', + 'bg-green-500', 'bg-purple-500', 'bg-gray-100'); + + // 设置非选中状态样式 + button.classList.add('transition-colors', 'duration-200'); + button.classList.add(...styleConfig.normal); + button.classList.add(...styleConfig.hover); + + // 移除鼠标悬停提示 + + console.log('为按钮设置初始样式:', button.textContent.trim(), '索引:', index, '类名:', Array.from(button.classList).join(', ')); + + button.addEventListener('click', function(event) { + event.preventDefault(); + event.stopPropagation(); + + console.log('点击按钮:', button.textContent.trim(), '索引:', index); + + // 检查是否是再次点击已选中的按钮 + const isActive = button.classList.contains('active'); + + // 重置所有按钮为非选中状态 + timeRangeButtons.forEach((btn, btnIndex) => { + const btnStyle = buttonStyles[btnIndex % buttonStyles.length]; + + // 移除所有可能的激活状态类 + btn.classList.remove('active', 'bg-blue-500', 'text-white', 'bg-green-500', 'bg-purple-500', 'bg-gray-500'); + btn.classList.remove(...btnStyle.active); + btn.classList.remove(...btnStyle.activeHover); + + // 添加非选中状态类 + btn.classList.add(...btnStyle.normal); + btn.classList.add(...btnStyle.hover); + }); + + if (isActive && index < 3) { // 再次点击已选中的时间范围按钮 + // 切换到混合视图 + isMixedView = true; + currentTimeRange = 'mixed'; + console.log('切换到混合视图'); + + // 设置当前按钮为特殊混合视图状态(保持原按钮选中但添加混合视图标记) + button.classList.remove(...styleConfig.normal); + button.classList.remove(...styleConfig.hover); + button.classList.add('active', 'mixed-view-active'); + button.classList.add(...styleConfig.active); + button.classList.add(...styleConfig.activeHover); // 添加选中时的浅色悬停 + } else { + // 普通选中模式 + isMixedView = false; + lastSelectedIndex = index; + + // 设置当前按钮为激活状态 + button.classList.remove(...styleConfig.normal); + button.classList.remove(...styleConfig.hover); + button.classList.add('active'); + button.classList.add(...styleConfig.active); + button.classList.add(...styleConfig.activeHover); // 添加选中时的浅色悬停 + + // 获取并更新当前时间范围 + const rangeValue = button.dataset.range || button.textContent.trim().replace(/[^0-9a-zA-Z]/g, ''); + currentTimeRange = rangeValue; + console.log('更新时间范围为:', currentTimeRange); } + + // 重新加载数据 + loadDashboardData(); + // 更新DNS请求图表 + drawDNSRequestsChart(); + }); + + // 移除自定义鼠标悬停提示效果 + }); + + // 确保默认选中第一个按钮 + if (timeRangeButtons.length > 0) { + const firstButton = timeRangeButtons[0]; + const firstStyle = buttonStyles[0]; + + // 先重置所有按钮 + timeRangeButtons.forEach((btn, index) => { + const btnStyle = buttonStyles[index % buttonStyles.length]; + btn.classList.remove('active', 'bg-blue-500', 'text-white', 'bg-green-500', 'bg-purple-500'); + btn.classList.remove(...btnStyle.active); + btn.classList.add(...btnStyle.normal); + btn.classList.add(...btnStyle.hover); + }); + + // 然后设置第一个按钮为激活状态 + firstButton.classList.remove(...firstStyle.normal); + firstButton.classList.remove(...firstStyle.hover); + firstButton.classList.add('active'); + firstButton.classList.add(...firstStyle.active); + console.log('默认选中第一个按钮:', firstButton.textContent.trim()); + } +} + +// 初始化展开按钮功能 +function initExpandButton() { + const expandBtn = document.getElementById('expand-chart-btn'); + const chartModal = document.getElementById('chart-modal'); + const closeBtn = document.getElementById('close-modal-btn'); + + if (!expandBtn || !chartModal || !closeBtn) { + console.error('未找到展开按钮或浮窗元素'); + return; + } + + // 展开按钮点击事件 + expandBtn.addEventListener('click', () => { + console.log('展开按钮被点击'); + chartModal.classList.remove('hidden'); + // 初始化详细图表 + drawDetailedDNSRequestsChart(); + }); + + // 关闭按钮点击事件 + closeBtn.addEventListener('click', () => { + console.log('关闭浮窗'); + chartModal.classList.add('hidden'); + }); + + // 点击浮窗外部关闭 + chartModal.addEventListener('click', (event) => { + if (event.target === chartModal) { + chartModal.classList.add('hidden'); } }); + // 初始化详细图表的时间范围切换 + initDetailedTimeRangeToggle(); +} + +// 初始化详细图表的时间范围切换 +function initDetailedTimeRangeToggle() { + const timeRangeBtns = document.querySelectorAll('.time-range-btn[data-range]'); + + if (!timeRangeBtns.length) { + console.warn('未找到详细图表的时间范围按钮'); + return; + } + + // 详细图表的当前时间范围 + let detailedCurrentTimeRange = '24h'; + let detailedIsMixedView = false; + + // 为所有时间范围按钮添加点击事件 + timeRangeBtns.forEach(btn => { + btn.addEventListener('click', () => { + const range = btn.getAttribute('data-range'); + + if (range === 'mixed') { + detailedIsMixedView = !detailedIsMixedView; + } else { + detailedCurrentTimeRange = range; + detailedIsMixedView = false; + } + + // 更新按钮样式 + updateTimeRangeButtonStyles(timeRangeBtns, range, detailedIsMixedView); + + // 重新绘制详细图表 + drawDetailedDNSRequestsChart(detailedCurrentTimeRange, detailedIsMixedView); + }); + }); +} + +// 更新时间范围按钮样式 +function updateTimeRangeButtonStyles(buttons, activeRange, isMixedView) { + buttons.forEach(btn => { + const btnRange = btn.getAttribute('data-range'); + + if (btnRange === activeRange && (btnRange !== 'mixed' || isMixedView)) { + btn.classList.add('bg-primary', 'text-white'); + btn.classList.remove('bg-gray-200', 'text-gray-700'); + } else { + btn.classList.remove('bg-primary', 'text-white'); + btn.classList.add('bg-gray-200', 'text-gray-700'); + } + }); +} + +// 绘制详细DNS请求图表 +function drawDetailedDNSRequestsChart(timeRange = '24h', isMixedView = false) { + const chartElement = document.getElementById('detailed-dns-requests-chart'); + if (!chartElement) { + console.error('未找到详细DNS请求图表元素'); + return; + } + + const chartContext = chartElement.getContext('2d'); + + // 这里可以复用或修改现有的drawDNSRequestsChart函数的逻辑 + // 为详细图表使用与原始图表相同的数据获取逻辑 + let apiFunction; + let count; + + if (isMixedView) { + // 混合视图 - 不同时间范围的数据 + apiFunction = () => Promise.resolve({ + labels: ['0h', '6h', '12h', '18h', '24h', '48h', '72h', '96h', '120h', '144h', '168h'], + data: generateMockData(11, 1000, 5000) + }); + } else { + // 普通视图 - 基于时间范围 + if (timeRange === '24h') { + count = 24; + apiFunction = () => Promise.resolve({ + labels: generateTimeLabels(count), + data: generateMockData(count, 100, 500) + }); + } else if (timeRange === '7d') { + count = 7; + apiFunction = () => Promise.resolve({ + labels: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'], + data: generateMockData(count, 1000, 5000) + }); + } else if (timeRange === '30d') { + count = 30; + apiFunction = () => Promise.resolve({ + labels: Array(count).fill(''), + data: generateMockData(count, 10000, 50000) + }); + } + } + + // 获取数据并绘制图表 + if (apiFunction) { + apiFunction().then(data => { + // 创建或更新图表 + if (detailedDnsRequestsChart) { + detailedDnsRequestsChart.data.labels = data.labels; + detailedDnsRequestsChart.data.datasets = [{ + label: 'DNS请求数量', + data: data.data, + borderColor: '#3b82f6', + backgroundColor: 'rgba(59, 130, 246, 0.1)', + tension: 0.4, + fill: true + }]; + detailedDnsRequestsChart.options.plugins.legend.display = false; + // 使用平滑过渡动画更新图表 + detailedDnsRequestsChart.update({ + duration: 800, + easing: 'easeInOutQuart' + }); + } else { + detailedDnsRequestsChart = new Chart(chartContext, { + type: 'line', + data: { + labels: data.labels, + datasets: [{ + label: 'DNS请求数量', + data: data.data, + borderColor: '#3b82f6', + backgroundColor: 'rgba(59, 130, 246, 0.1)', + tension: 0.4, + fill: true + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 800, + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + display: false + }, + tooltip: { + mode: 'index', + intersect: false + } + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: 'rgba(0, 0, 0, 0.1)' + } + }, + x: { + grid: { + display: false + } + } + } + } + }); + } + }).catch(error => { + console.error('绘制详细DNS请求图表失败:', error); + // 错误处理:使用空数据 + const count = timeRange === '24h' ? 24 : (timeRange === '7d' ? 7 : 30); + const emptyData = { + labels: Array(count).fill(''), + data: Array(count).fill(0) + }; + + if (detailedDnsRequestsChart) { + detailedDnsRequestsChart.data.labels = emptyData.labels; + detailedDnsRequestsChart.data.datasets[0].data = emptyData.data; + detailedDnsRequestsChart.update(); + } + }); + } +} + +// 初始化图表 +function initCharts() { // 初始化比例图表 - const ratioCtx = document.getElementById('ratio-chart').getContext('2d'); + const ratioChartElement = document.getElementById('ratio-chart'); + if (!ratioChartElement) { + console.error('未找到比例图表元素'); + return; + } + const ratioCtx = ratioChartElement.getContext('2d'); ratioChart = new Chart(ratioCtx, { type: 'doughnut', data: { labels: ['正常解析', '被屏蔽', '错误'], datasets: [{ - data: [70, 25, 5], + data: ['---', '---', '---'], backgroundColor: ['#00B42A', '#F53F3F', '#FF7D00'], borderWidth: 0 }] @@ -272,23 +1175,602 @@ function initCharts() { options: { responsive: true, maintainAspectRatio: false, + // 添加全局动画配置,确保图表创建和更新时都平滑过渡 + animation: { + duration: 500, // 延长动画时间,使过渡更平滑 + easing: 'easeInOutQuart' + }, plugins: { legend: { position: 'bottom', + labels: { + boxWidth: 12, // 减小图例框的宽度 + font: { + size: 11 // 减小字体大小 + }, + padding: 10 // 减小内边距 + } + }, + tooltip: { + callbacks: { + label: function(context) { + const label = context.label || ''; + const value = context.raw || 0; + const total = context.dataset.data.reduce((acc, val) => acc + (typeof val === 'number' ? val : 0), 0); + const percentage = total > 0 ? Math.round((value / total) * 100) : 0; + return `${label}: ${value} (${percentage}%)`; + } + } } }, - cutout: '70%' + cutout: '75%' // 增加中心空白区域比例,使环形更适合小容器 } }); + + // 初始化解析类型统计饼图 + const queryTypeChartElement = document.getElementById('query-type-chart'); + if (queryTypeChartElement) { + const queryTypeCtx = queryTypeChartElement.getContext('2d'); + // 预定义的颜色数组,用于解析类型 + const queryTypeColors = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#d35400', '#34495e']; + + queryTypeChart = new Chart(queryTypeCtx, { + type: 'doughnut', + data: { + labels: ['暂无数据'], + datasets: [{ + data: [1], + backgroundColor: [queryTypeColors[0]], + borderWidth: 0 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + // 添加全局动画配置,确保图表创建和更新时都平滑过渡 + animation: { + duration: 300, + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + position: 'bottom', + labels: { + boxWidth: 12, // 减小图例框的宽度 + font: { + size: 11 // 减小字体大小 + }, + padding: 10 // 减小内边距 + } + }, + tooltip: { + callbacks: { + label: function(context) { + const label = context.label || ''; + const value = context.raw || 0; + const total = context.dataset.data.reduce((acc, val) => acc + (typeof val === 'number' ? val : 0), 0); + const percentage = total > 0 ? Math.round((value / total) * 100) : 0; + return `${label}: ${value} (${percentage}%)`; + } + } + } + }, + cutout: '75%' // 增加中心空白区域比例,使环形更适合小容器 + } + }); + } else { + console.warn('未找到解析类型统计图表元素'); + } + + // 初始化DNS请求统计图表 + drawDNSRequestsChart(); + + // 初始化展开按钮功能 + initExpandButton(); +} + +// 初始化展开按钮事件 +function initExpandButton() { + const expandBtn = document.getElementById('expand-chart-btn'); + const chartModal = document.getElementById('chart-modal'); + const closeModalBtn = document.getElementById('close-modal-btn'); // 修复ID匹配 + + // 添加调试日志 + console.log('初始化展开按钮功能:', { expandBtn, chartModal, closeModalBtn }); + + if (expandBtn && chartModal && closeModalBtn) { + // 展开按钮点击事件 + expandBtn.addEventListener('click', () => { + console.log('展开按钮被点击'); + // 显示浮窗 + chartModal.classList.remove('hidden'); + + // 初始化或更新详细图表 + drawDetailedDNSRequestsChart(); + + // 初始化浮窗中的时间范围切换 + initDetailedTimeRangeToggle(); + }); + + // 关闭按钮点击事件 + closeModalBtn.addEventListener('click', () => { + console.log('关闭按钮被点击'); + chartModal.classList.add('hidden'); + }); + + // 点击遮罩层关闭浮窗(使用chartModal作为遮罩层) + chartModal.addEventListener('click', (e) => { + // 检查点击目标是否是遮罩层本身(即最外层div) + if (e.target === chartModal) { + console.log('点击遮罩层关闭'); + chartModal.classList.add('hidden'); + } + }); + + // ESC键关闭浮窗 + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !chartModal.classList.contains('hidden')) { + console.log('ESC键关闭浮窗'); + chartModal.classList.add('hidden'); + } + }); + } else { + console.error('无法找到必要的DOM元素'); + } +} + +// 初始化详细图表的时间范围切换 +function initDetailedTimeRangeToggle() { + const detailedTimeRangeButtons = document.querySelectorAll('.time-range-btn'); + + console.log('初始化详细图表时间范围切换,找到按钮数量:', detailedTimeRangeButtons.length); + + detailedTimeRangeButtons.forEach((button) => { + button.addEventListener('click', () => { + // 设置当前时间范围 + const timeRange = button.dataset.range; + const isMixedMode = timeRange === 'mixed'; + + console.log('时间范围按钮被点击:', { timeRange, isMixedMode }); + + // 更新按钮状态 + detailedTimeRangeButtons.forEach((btn) => { + if (btn === button) { + btn.classList.add('bg-blue-500', 'text-white'); + btn.classList.remove('bg-gray-200', 'text-gray-700'); + } else { + btn.classList.remove('bg-blue-500', 'text-white'); + btn.classList.add('bg-gray-200', 'text-gray-700'); + } + }); + + // 更新详细图表 + currentTimeRange = timeRange; + isMixedView = isMixedMode; + drawDetailedDNSRequestsChart(); + }); + }); +} + +// 绘制详细的DNS请求趋势图表 +function drawDetailedDNSRequestsChart() { + console.log('绘制详细DNS请求趋势图表,时间范围:', currentTimeRange, '混合视图:', isMixedView); + + const ctx = document.getElementById('detailed-dns-requests-chart'); + if (!ctx) { + console.error('未找到详细DNS请求图表元素'); + return; + } + + const chartContext = ctx.getContext('2d'); + + // 混合视图配置 + const datasetsConfig = [ + { label: '24小时', api: (api && api.getHourlyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#3b82f6', fillColor: 'rgba(59, 130, 246, 0.1)' }, + { label: '7天', api: (api && api.getDailyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#22c55e', fillColor: 'rgba(34, 197, 94, 0.1)' }, + { label: '30天', api: (api && api.getMonthlyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#a855f7', fillColor: 'rgba(168, 85, 247, 0.1)' } + ]; + + // 检查是否为混合视图 + if (isMixedView || currentTimeRange === 'mixed') { + console.log('渲染混合视图详细图表'); + + // 显示图例 + const showLegend = true; + + // 获取所有时间范围的数据 + Promise.all(datasetsConfig.map(config => + config.api().catch(error => { + console.error(`获取${config.label}数据失败:`, error); + // 返回空数据 + const count = config.label === '24小时' ? 24 : (config.label === '7天' ? 7 : 30); + return { + labels: Array(count).fill(''), + data: Array(count).fill(0) + }; + }) + )).then(results => { + // 创建数据集 + const datasets = results.map((data, index) => ({ + label: datasetsConfig[index].label, + data: data.data, + borderColor: datasetsConfig[index].color, + backgroundColor: datasetsConfig[index].fillColor, + tension: 0.4, + fill: false, + borderWidth: 2 + })); + + // 创建或更新图表 + if (detailedDnsRequestsChart) { + detailedDnsRequestsChart.data.labels = results[0].labels; + detailedDnsRequestsChart.data.datasets = datasets; + detailedDnsRequestsChart.options.plugins.legend.display = showLegend; + // 使用平滑过渡动画更新图表 + detailedDnsRequestsChart.update({ + duration: 800, + easing: 'easeInOutQuart' + }); + } else { + detailedDnsRequestsChart = new Chart(chartContext, { + type: 'line', + data: { + labels: results[0].labels, + datasets: datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 800, + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + display: showLegend, + position: 'top' + }, + tooltip: { + mode: 'index', + intersect: false + } + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: 'rgba(0, 0, 0, 0.1)' + } + }, + x: { + grid: { + display: false + } + } + } + } + }); + } + }).catch(error => { + console.error('绘制混合视图详细图表失败:', error); + }); + } else { + // 普通视图 + // 根据当前时间范围选择API函数 + let apiFunction; + switch (currentTimeRange) { + case '7d': + apiFunction = (api && api.getDailyStats) || (() => Promise.resolve({ labels: [], data: [] })); + break; + case '30d': + apiFunction = (api && api.getMonthlyStats) || (() => Promise.resolve({ labels: [], data: [] })); + break; + default: // 24h + apiFunction = (api && api.getHourlyStats) || (() => Promise.resolve({ labels: [], data: [] })); + } + + // 获取统计数据 + apiFunction().then(data => { + // 创建或更新图表 + if (detailedDnsRequestsChart) { + detailedDnsRequestsChart.data.labels = data.labels; + detailedDnsRequestsChart.data.datasets = [{ + label: 'DNS请求数量', + data: data.data, + borderColor: '#3b82f6', + backgroundColor: 'rgba(59, 130, 246, 0.1)', + tension: 0.4, + fill: true + }]; + detailedDnsRequestsChart.options.plugins.legend.display = false; + // 使用平滑过渡动画更新图表 + detailedDnsRequestsChart.update({ + duration: 800, + easing: 'easeInOutQuart' + }); + } else { + detailedDnsRequestsChart = new Chart(chartContext, { + type: 'line', + data: { + labels: data.labels, + datasets: [{ + label: 'DNS请求数量', + data: data.data, + borderColor: '#3b82f6', + backgroundColor: 'rgba(59, 130, 246, 0.1)', + tension: 0.4, + fill: true + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 800, + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + display: false + }, + title: { + display: true, + text: 'DNS请求趋势', + font: { + size: 14 + } + }, + tooltip: { + mode: 'index', + intersect: false + } + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: 'rgba(0, 0, 0, 0.1)' + } + }, + x: { + grid: { + display: false + } + } + } + } + }); + } + }).catch(error => { + console.error('绘制详细DNS请求图表失败:', error); + // 错误处理:使用空数据 + const count = currentTimeRange === '24h' ? 24 : (currentTimeRange === '7d' ? 7 : 30); + const emptyData = { + labels: Array(count).fill(''), + data: Array(count).fill(0) + }; + + if (detailedDnsRequestsChart) { + detailedDnsRequestsChart.data.labels = emptyData.labels; + detailedDnsRequestsChart.data.datasets[0].data = emptyData.data; + detailedDnsRequestsChart.update(); + } + }); + } +} + +// 绘制DNS请求统计图表 +function drawDNSRequestsChart() { + const ctx = document.getElementById('dns-requests-chart'); + if (!ctx) { + console.error('未找到DNS请求图表元素'); + return; + } + + const chartContext = ctx.getContext('2d'); + + // 混合视图配置 + const datasetsConfig = [ + { label: '24小时', api: (api && api.getHourlyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#3b82f6', fillColor: 'rgba(59, 130, 246, 0.1)' }, + { label: '7天', api: (api && api.getDailyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#22c55e', fillColor: 'rgba(34, 197, 94, 0.1)' }, + { label: '30天', api: (api && api.getMonthlyStats) || (() => Promise.resolve({ labels: [], data: [] })), color: '#a855f7', fillColor: 'rgba(168, 85, 247, 0.1)' } + ]; + + // 检查是否为混合视图 + if (isMixedView || currentTimeRange === 'mixed') { + console.log('渲染混合视图图表'); + + // 显示图例 + const showLegend = true; + + // 获取所有时间范围的数据 + Promise.all(datasetsConfig.map(config => + config.api().catch(error => { + console.error(`获取${config.label}数据失败:`, error); + // 返回空数据而不是模拟数据 + const count = config.label === '24小时' ? 24 : (config.label === '7天' ? 7 : 30); + return { + labels: Array(count).fill(''), + data: Array(count).fill(0) + }; + }) + )).then(results => { + // 创建数据集 + const datasets = results.map((data, index) => ({ + label: datasetsConfig[index].label, + data: data.data, + borderColor: datasetsConfig[index].color, + backgroundColor: datasetsConfig[index].fillColor, + tension: 0.4, + fill: false, // 混合视图不填充 + borderWidth: 2 + })); + + // 创建或更新图表 + if (dnsRequestsChart) { + dnsRequestsChart.data.labels = results[0].labels; + dnsRequestsChart.data.datasets = datasets; + dnsRequestsChart.options.plugins.legend.display = showLegend; + // 使用平滑过渡动画更新图表 + dnsRequestsChart.update({ + duration: 800, + easing: 'easeInOutQuart' + }); + } else { + dnsRequestsChart = new Chart(chartContext, { + type: 'line', + data: { + labels: results[0].labels, + datasets: datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 800, + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + display: showLegend, + position: 'top' + }, + tooltip: { + mode: 'index', + intersect: false + } + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: 'rgba(0, 0, 0, 0.1)' + } + }, + x: { + grid: { + display: false + } + } + } + } + }); + } + }).catch(error => { + console.error('绘制混合视图图表失败:', error); + }); + } else { + // 普通视图 + // 根据当前时间范围选择API函数 + let apiFunction; + switch (currentTimeRange) { + case '7d': + apiFunction = (api && api.getDailyStats) || (() => Promise.resolve({ labels: [], data: [] })); + break; + case '30d': + apiFunction = (api && api.getMonthlyStats) || (() => Promise.resolve({ labels: [], data: [] })); + break; + default: // 24h + apiFunction = (api && api.getHourlyStats) || (() => Promise.resolve({ labels: [], data: [] })); + } + + // 获取统计数据 + apiFunction().then(data => { + // 创建或更新图表 + if (dnsRequestsChart) { + dnsRequestsChart.data.labels = data.labels; + dnsRequestsChart.data.datasets = [{ + label: 'DNS请求数量', + data: data.data, + borderColor: '#3b82f6', + backgroundColor: 'rgba(59, 130, 246, 0.1)', + tension: 0.4, + fill: true + }]; + dnsRequestsChart.options.plugins.legend.display = false; + // 使用平滑过渡动画更新图表 + dnsRequestsChart.update({ + duration: 800, + easing: 'easeInOutQuart' + }); + } else { + dnsRequestsChart = new Chart(chartContext, { + type: 'line', + data: { + labels: data.labels, + datasets: [{ + label: 'DNS请求数量', + data: data.data, + borderColor: '#3b82f6', + backgroundColor: 'rgba(59, 130, 246, 0.1)', + tension: 0.4, + fill: true + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 800, + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + display: false + }, + tooltip: { + mode: 'index', + intersect: false + } + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: 'rgba(0, 0, 0, 0.1)' + } + }, + x: { + grid: { + display: false + } + } + } + } + }); + } + }).catch(error => { + console.error('绘制DNS请求图表失败:', error); + // 错误处理:使用空数据而不是模拟数据 + const count = currentTimeRange === '24h' ? 24 : (currentTimeRange === '7d' ? 7 : 30); + const emptyData = { + labels: Array(count).fill(''), + data: Array(count).fill(0) + }; + + if (dnsRequestsChart) { + dnsRequestsChart.data.labels = emptyData.labels; + dnsRequestsChart.data.datasets[0].data = emptyData.data; + dnsRequestsChart.update(); + } + }); + } } // 更新图表数据 -function updateCharts(stats, hourlyStats) { - console.log('更新图表,收到统计数据:', stats, '小时统计:', hourlyStats); +function updateCharts(stats, queryTypeStats) { + console.log('更新图表,收到统计数据:', stats); + console.log('查询类型统计数据:', queryTypeStats); + + // 空值检查 + if (!stats) { + console.error('更新图表失败: 未提供统计数据'); + return; + } // 更新比例图表 if (ratioChart) { - let allowed = 70, blocked = 25, error = 5; + let allowed = '---', blocked = '---', error = '---'; // 尝试从stats数据中提取 if (stats.dns) { @@ -302,52 +1784,423 @@ function updateCharts(stats, hourlyStats) { } ratioChart.data.datasets[0].data = [allowed, blocked, error]; - ratioChart.update(); + // 使用自定义动画配置更新图表,确保平滑过渡 + ratioChart.update({ + duration: 500, + easing: 'easeInOutQuart' + }); } - // 更新趋势图表 - if (queryTrendChart) { - let labels = Array.from({length: 24}, (_, i) => `${(i + 1) % 24}:00`); - let totalData = [], blockedData = []; + // 更新解析类型统计饼图 + if (queryTypeChart && queryTypeStats && Array.isArray(queryTypeStats)) { + const queryTypeColors = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#d35400', '#34495e']; - // 尝试从hourlyStats中提取数据 - if (Array.isArray(hourlyStats) && hourlyStats.length > 0) { - labels = hourlyStats.map(h => `${h.hour || h.time || h[0]}:00`); - totalData = hourlyStats.map(h => h.total || h.queries || h[1] || 0); - blockedData = hourlyStats.map(h => h.blocked || h[2] || 0); + // 检查是否有有效的数据项 + const validData = queryTypeStats.filter(item => item && item.count > 0); + + if (validData.length > 0) { + // 准备标签和数据 + const labels = validData.map(item => item.type); + const data = validData.map(item => item.count); + + // 为每个解析类型分配颜色 + const colors = labels.map((_, index) => queryTypeColors[index % queryTypeColors.length]); + + // 更新图表数据 + queryTypeChart.data.labels = labels; + queryTypeChart.data.datasets[0].data = data; + queryTypeChart.data.datasets[0].backgroundColor = colors; } else { - // 如果没有小时统计数据,生成示例数据 - for (let i = 0; i < 24; i++) { - // 生成模拟的查询数据,形成一个正常的流量曲线 - const baseValue = 500; - const timeFactor = Math.sin((i - 8) * Math.PI / 12); // 早上8点开始上升,晚上8点开始下降 - const randomFactor = 0.8 + Math.random() * 0.4; // 添加一些随机性 - const hourlyTotal = Math.round(baseValue * (0.5 + timeFactor * 0.5) * randomFactor); - const hourlyBlocked = Math.round(hourlyTotal * (0.1 + Math.random() * 0.2)); // 10-30%被屏蔽 - - totalData.push(hourlyTotal); - blockedData.push(hourlyBlocked); - } - console.log('使用示例数据填充趋势图表'); + // 如果没有数据,显示默认值 + queryTypeChart.data.labels = ['暂无数据']; + queryTypeChart.data.datasets[0].data = [1]; + queryTypeChart.data.datasets[0].backgroundColor = [queryTypeColors[0]]; } - queryTrendChart.data.labels = labels; - queryTrendChart.data.datasets[0].data = totalData; - queryTrendChart.data.datasets[1].data = blockedData; - queryTrendChart.update(); + // 使用自定义动画配置更新图表,确保平滑过渡 + queryTypeChart.update({ + duration: 500, + easing: 'easeInOutQuart' + }); } } +// 更新统计卡片折线图 +function updateStatCardCharts(stats) { + if (!stats || Object.keys(statCardCharts).length === 0) { + return; + } + + // 更新查询总量图表 + if (statCardCharts['query-chart']) { + let queryCount = 0; + if (stats.dns) { + queryCount = stats.dns.Queries || 0; + } else if (stats.totalQueries !== undefined) { + queryCount = stats.totalQueries || 0; + } + updateChartData('query-chart', queryCount); + } + + // 更新屏蔽数量图表 + if (statCardCharts['blocked-chart']) { + let blockedCount = 0; + if (stats.dns) { + blockedCount = stats.dns.Blocked || 0; + } else if (stats.blockedQueries !== undefined) { + blockedCount = stats.blockedQueries || 0; + } + updateChartData('blocked-chart', blockedCount); + } + + // 更新正常解析图表 + if (statCardCharts['allowed-chart']) { + let allowedCount = 0; + if (stats.dns) { + allowedCount = stats.dns.Allowed || 0; + } else if (stats.allowedQueries !== undefined) { + allowedCount = stats.allowedQueries || 0; + } else if (stats.dns && stats.dns.Queries && stats.dns.Blocked) { + allowedCount = stats.dns.Queries - stats.dns.Blocked; + } + updateChartData('allowed-chart', allowedCount); + } + + // 更新错误数量图表 + if (statCardCharts['error-chart']) { + let errorCount = 0; + if (stats.dns) { + errorCount = stats.dns.Errors || 0; + } else if (stats.errorQueries !== undefined) { + errorCount = stats.errorQueries || 0; + } + updateChartData('error-chart', errorCount); + } + + // 更新响应时间图表 + if (statCardCharts['response-time-chart']) { + let responseTime = 0; + // 尝试从不同的数据结构获取平均响应时间 + if (stats.dns && stats.dns.AvgResponseTime) { + responseTime = stats.dns.AvgResponseTime; + } else if (stats.avgResponseTime !== undefined) { + responseTime = stats.avgResponseTime; + } else if (stats.responseTime) { + responseTime = stats.responseTime; + } + // 限制小数位数为两位 + responseTime = parseFloat(responseTime).toFixed(2); + updateChartData('response-time-chart', responseTime); + } + + // 更新活跃IP图表 + if (statCardCharts['ips-chart']) { + const activeIPs = stats.activeIPs || 0; + updateChartData('ips-chart', activeIPs); + } + + // 更新CPU使用率图表 + if (statCardCharts['cpu-chart']) { + const cpuUsage = stats.cpuUsage || 0; + updateChartData('cpu-chart', cpuUsage); + } + + // 更新平均响应时间显示 + if (document.getElementById('avg-response-time')) { + let avgResponseTime = 0; + // 尝试从不同的数据结构获取平均响应时间 + if (stats.dns && stats.dns.AvgResponseTime) { + avgResponseTime = stats.dns.AvgResponseTime; + } else if (stats.avgResponseTime !== undefined) { + avgResponseTime = stats.avgResponseTime; + } else if (stats.responseTime) { + avgResponseTime = stats.responseTime; + } + document.getElementById('avg-response-time').textContent = formatNumber(avgResponseTime); + } + + // 更新规则数图表 + if (statCardCharts['rules-chart']) { + // 尝试获取规则数,如果没有则使用模拟数据 + const rulesCount = getRulesCountFromStats(stats) || Math.floor(Math.random() * 5000) + 10000; + updateChartData('rules-chart', rulesCount); + } + + // 更新排除规则数图表 + if (statCardCharts['exceptions-chart']) { + const exceptionsCount = getExceptionsCountFromStats(stats) || Math.floor(Math.random() * 100) + 50; + updateChartData('exceptions-chart', exceptionsCount); + } + + // 更新Hosts条目数图表 + if (statCardCharts['hosts-chart']) { + const hostsCount = getHostsCountFromStats(stats) || Math.floor(Math.random() * 1000) + 2000; + updateChartData('hosts-chart', hostsCount); + } +} + +// 更新单个图表的数据 +function updateChartData(chartId, newValue) { + const chart = statCardCharts[chartId]; + const historyData = statCardHistoryData[chartId]; + + if (!chart || !historyData) { + return; + } + + // 添加新数据,移除最旧的数据 + historyData.push(newValue); + if (historyData.length > 12) { + historyData.shift(); + } + + // 更新图表数据 + chart.data.datasets[0].data = historyData; + chart.data.labels = generateTimeLabels(historyData.length); + + // 使用自定义动画配置更新图表,确保平滑过渡,避免空白区域 + chart.update({ + duration: 300, // 增加动画持续时间 + easing: 'easeInOutQuart', // 使用平滑的缓动函数 + transition: { + duration: 300, + easing: 'easeInOutQuart' + } + }); +} + +// 从统计数据中获取规则数 +function getRulesCountFromStats(stats) { + // 尝试从stats中获取规则数 + if (stats.shield && stats.shield.rules) { + return stats.shield.rules; + } + return null; +} + +// 从统计数据中获取排除规则数 +function getExceptionsCountFromStats(stats) { + // 尝试从stats中获取排除规则数 + if (stats.shield && stats.shield.exceptions) { + return stats.shield.exceptions; + } + return null; +} + +// 从统计数据中获取Hosts条目数 +function getHostsCountFromStats(stats) { + // 尝试从stats中获取Hosts条目数 + if (stats.shield && stats.shield.hosts) { + return stats.shield.hosts; + } + return null; +} + +// 初始化统计卡片折线图 +function initStatCardCharts() { + console.log('===== 开始初始化统计卡片折线图 ====='); + + // 清理已存在的图表实例 + for (const key in statCardCharts) { + if (statCardCharts.hasOwnProperty(key)) { + statCardCharts[key].destroy(); + } + } + statCardCharts = {}; + statCardHistoryData = {}; + + // 检查Chart.js是否加载 + console.log('Chart.js是否可用:', typeof Chart !== 'undefined'); + + // 统计卡片配置信息 + const cardConfigs = [ + { id: 'query-chart', color: '#9b59b6', label: '查询总量' }, + { id: 'blocked-chart', color: '#e74c3c', label: '屏蔽数量' }, + { id: 'allowed-chart', color: '#2ecc71', label: '正常解析' }, + { id: 'error-chart', color: '#f39c12', label: '错误数量' }, + { id: 'response-time-chart', color: '#3498db', label: '响应时间' }, + { id: 'ips-chart', color: '#1abc9c', label: '活跃IP' }, + { id: 'cpu-chart', color: '#e67e22', label: 'CPU使用率' }, + { id: 'rules-chart', color: '#95a5a6', label: '屏蔽规则数' }, + { id: 'exceptions-chart', color: '#34495e', label: '排除规则数' }, + { id: 'hosts-chart', color: '#16a085', label: 'Hosts条目数' } + ]; + + console.log('图表配置:', cardConfigs); + + cardConfigs.forEach(config => { + const canvas = document.getElementById(config.id); + if (!canvas) { + console.warn(`未找到统计卡片图表元素: ${config.id}`); + return; + } + + const ctx = canvas.getContext('2d'); + + // 为不同类型的卡片生成更合适的初始数据 + let initialData; + if (config.id === 'response-time-chart') { + // 响应时间图表使用空数组,将通过API实时数据更新 + initialData = Array(12).fill(null); + } else if (config.id === 'cpu-chart') { + initialData = generateMockData(12, 0, 10); + } else { + initialData = generateMockData(12, 0, 100); + } + + // 初始化历史数据数组 + statCardHistoryData[config.id] = [...initialData]; + + // 创建图表 + statCardCharts[config.id] = new Chart(ctx, { + type: 'line', + data: { + labels: generateTimeLabels(12), + datasets: [{ + label: config.label, + data: initialData, + borderColor: config.color, + backgroundColor: `${config.color}20`, // 透明度20% + borderWidth: 2, + tension: 0.4, + fill: true, + pointRadius: 0, // 隐藏数据点 + pointHoverRadius: 4, // 鼠标悬停时显示数据点 + pointBackgroundColor: config.color + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + // 添加动画配置,确保平滑过渡 + animation: { + duration: 800, + easing: 'easeInOutQuart' + }, + plugins: { + legend: { + display: false + }, + tooltip: { + mode: 'index', + intersect: false, + backgroundColor: 'rgba(0, 0, 0, 0.9)', + titleColor: '#fff', + bodyColor: '#fff', + borderColor: config.color, + borderWidth: 1, + padding: 8, + displayColors: false, + cornerRadius: 4, + titleFont: { + size: 12, + weight: 'normal' + }, + bodyFont: { + size: 11 + }, + // 确保HTML渲染正确 + useHTML: true, + filter: function(tooltipItem) { + return tooltipItem.datasetIndex === 0; + }, + callbacks: { + title: function(tooltipItems) { + // 简化时间显示格式 + return tooltipItems[0].label; + }, + label: function(context) { + const value = context.parsed.y; + // 格式化大数字 + const formattedValue = formatNumber(value); + + // 使用CSS类显示变化趋势 + let trendInfo = ''; + const data = context.dataset.data; + const currentIndex = context.dataIndex; + + if (currentIndex > 0) { + const prevValue = data[currentIndex - 1]; + const change = value - prevValue; + + if (change !== 0) { + const changeSymbol = change > 0 ? '↑' : '↓'; + // 取消颜色显示,简化显示 + trendInfo = (changeSymbol + Math.abs(change)); + } + } + + // 简化标签格式 + return `${config.label}: ${formattedValue}${trendInfo}`; + }, + // 移除平均值显示 + afterLabel: function(context) { + return ''; + } + } + } + }, + scales: { + x: { + display: false // 隐藏X轴 + }, + y: { + display: false, // 隐藏Y轴 + beginAtZero: true + } + }, + interaction: { + intersect: false, + mode: 'index' + } + } + }); + }); +} + +// 生成模拟数据 +function generateMockData(count, min, max) { + const data = []; + for (let i = 0; i < count; i++) { + data.push(Math.floor(Math.random() * (max - min + 1)) + min); + } + return data; +} + +// 生成时间标签 +function generateTimeLabels(count) { + const labels = []; + const now = new Date(); + for (let i = count - 1; i >= 0; i--) { + const time = new Date(now.getTime() - i * 5 * 60 * 1000); // 每5分钟一个点 + labels.push(`${time.getHours().toString().padStart(2, '0')}:${time.getMinutes().toString().padStart(2, '0')}`); + } + return labels; +} + +// 格式化数字显示(使用K/M后缀) +function formatNumber(num) { + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + 'M'; + } else if (num >= 1000) { + return (num / 1000).toFixed(1) + 'K'; + } + return num.toString(); +} + // 更新运行状态 function updateUptime() { - // 这里应该从API获取真实的运行时间 + // 实现更新运行时间的逻辑 + // 这里应该调用API获取当前运行时间并更新到UI + // 由于API暂时没有提供运行时间,我们先使用模拟数据 const uptimeElement = document.getElementById('uptime'); - uptimeElement.textContent = '正常运行中'; - uptimeElement.className = 'mt-1 text-success'; + if (uptimeElement) { + uptimeElement.textContent = '---'; + } } // 格式化数字(添加千位分隔符) -function formatNumber(num) { +function formatWithCommas(num) { return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); } @@ -371,6 +2224,28 @@ function formatTime(timestamp) { }); } +// 根据颜色代码获取对应的CSS类名(兼容方式) +function getColorClassName(colorCode) { + // 优先使用配置文件中的颜色处理 + if (COLOR_CONFIG.getColorClassName) { + return COLOR_CONFIG.getColorClassName(colorCode); + } + + // 备用颜色映射 + const colorMap = { + '#1890ff': 'blue', + '#52c41a': 'green', + '#fa8c16': 'orange', + '#f5222d': 'red', + '#722ed1': 'purple', + '#13c2c2': 'cyan', + '#36cfc9': 'teal' + }; + + // 返回映射的类名,如果没有找到则返回默认的blue + return colorMap[colorCode] || 'blue'; +} + // 显示通知 function showNotification(message, type = 'info') { // 移除已存在的通知 diff --git a/static/js/server-status.js b/static/js/server-status.js new file mode 100644 index 0000000..b7c6f31 --- /dev/null +++ b/static/js/server-status.js @@ -0,0 +1,292 @@ +// 服务器状态组件 - 显示CPU使用率和查询统计 + +// 全局变量 +let serverStatusUpdateTimer = null; +let previousServerData = { + cpu: 0, + queries: 0 +}; + +// 初始化服务器状态组件 +function initServerStatusWidget() { + // 确保DOM元素存在 + const widget = document.getElementById('server-status-widget'); + if (!widget) return; + + // 初始化页面类型检测 + updateWidgetDisplayByPageType(); + + // 设置页面切换事件监听 + handlePageSwitchEvents(); + + // 设置WebSocket监听(如果可用) + setupWebSocketListeners(); + + // 立即加载一次数据 + loadServerStatusData(); + + // 设置定时更新(每5秒更新一次) + serverStatusUpdateTimer = setInterval(loadServerStatusData, 5000); +} + +// 判断当前页面是否为仪表盘 +function isCurrentPageDashboard() { + // 方法1:检查侧边栏激活状态 + const dashboardLink = document.querySelector('.sidebar a[href="#dashboard"]'); + if (dashboardLink && dashboardLink.classList.contains('active')) { + return true; + } + + // 方法2:检查仪表盘特有元素 + const dashboardElements = [ + '#dashboard-container', + '.dashboard-summary', + '#dashboard-stats' + ]; + + for (const selector of dashboardElements) { + if (document.querySelector(selector)) { + return true; + } + } + + // 方法3:检查URL哈希值 + if (window.location.hash === '#dashboard' || window.location.hash === '') { + return true; + } + + return false; +} + +// 根据页面类型更新组件显示 +function updateWidgetDisplayByPageType() { + const additionalStats = document.getElementById('server-additional-stats'); + if (!additionalStats) return; + + // 如果当前页面是仪表盘,隐藏额外统计指标 + if (isCurrentPageDashboard()) { + additionalStats.classList.add('hidden'); + } else { + // 非仪表盘页面,显示额外统计指标 + additionalStats.classList.remove('hidden'); + } +} + +// 处理页面切换事件 +function handlePageSwitchEvents() { + // 监听哈希变化(导航切换) + window.addEventListener('hashchange', updateWidgetDisplayByPageType); + + // 监听侧边栏点击事件 + const sidebarLinks = document.querySelectorAll('.sidebar a'); + sidebarLinks.forEach(link => { + link.addEventListener('click', function() { + // 延迟检查,确保页面已切换 + setTimeout(updateWidgetDisplayByPageType, 100); + }); + }); + + // 监听导航菜单点击事件 + const navLinks = document.querySelectorAll('nav a'); + navLinks.forEach(link => { + link.addEventListener('click', function() { + setTimeout(updateWidgetDisplayByPageType, 100); + }); + }); +} + +// 监控WebSocket连接状态 +function monitorWebSocketConnection() { + // 如果存在WebSocket连接,监听消息 + if (window.socket) { + window.socket.addEventListener('message', function(event) { + try { + const data = JSON.parse(event.data); + if (data.type === 'status_update') { + updateServerStatusWidget(data.payload); + } + } catch (error) { + console.error('解析WebSocket消息失败:', error); + } + }); + } +} + +// 设置WebSocket监听器 +function setupWebSocketListeners() { + // 如果WebSocket已经存在 + if (window.socket) { + monitorWebSocketConnection(); + } else { + // 监听socket初始化事件 + window.addEventListener('socketInitialized', function() { + monitorWebSocketConnection(); + }); + } +} + +// 加载服务器状态数据 +async function loadServerStatusData() { + try { + // 使用现有的API获取系统状态 + const api = window.api || {}; + const getStatusFn = api.getStatus || function() { return Promise.resolve({}); }; + const statusData = await getStatusFn(); + if (statusData && !statusData.error) { + updateServerStatusWidget(statusData); + } + } catch (error) { + console.error('加载服务器状态数据失败:', error); + } +} + +// 更新服务器状态组件 +function updateServerStatusWidget(stats) { + // 确保组件存在 + const widget = document.getElementById('server-status-widget'); + if (!widget) return; + + // 确保stats存在 + stats = stats || {}; + + // 提取CPU使用率 + let cpuUsage = 0; + if (stats.system && typeof stats.system.cpu === 'number') { + cpuUsage = stats.system.cpu; + } else if (typeof stats.cpuUsage === 'number') { + cpuUsage = stats.cpuUsage; + } + + // 提取查询统计数据 + let totalQueries = 0; + let blockedQueries = 0; + let allowedQueries = 0; + + if (stats.dns) { + const allowed = typeof stats.dns.Allowed === 'number' ? stats.dns.Allowed : 0; + const blocked = typeof stats.dns.Blocked === 'number' ? stats.dns.Blocked : 0; + const errors = typeof stats.dns.Errors === 'number' ? stats.dns.Errors : 0; + totalQueries = allowed + blocked + errors; + blockedQueries = blocked; + allowedQueries = allowed; + } else { + totalQueries = typeof stats.totalQueries === 'number' ? stats.totalQueries : 0; + blockedQueries = typeof stats.blockedQueries === 'number' ? stats.blockedQueries : 0; + allowedQueries = typeof stats.allowedQueries === 'number' ? stats.allowedQueries : 0; + } + + // 更新CPU使用率 + const cpuValueElement = document.getElementById('server-cpu-value'); + if (cpuValueElement) { + cpuValueElement.textContent = cpuUsage.toFixed(1) + '%'; + } + + const cpuBarElement = document.getElementById('server-cpu-bar'); + if (cpuBarElement) { + cpuBarElement.style.width = Math.min(cpuUsage, 100) + '%'; + + // 根据CPU使用率改变颜色 + if (cpuUsage > 80) { + cpuBarElement.className = 'h-full bg-danger rounded-full'; + } else if (cpuUsage > 50) { + cpuBarElement.className = 'h-full bg-warning rounded-full'; + } else { + cpuBarElement.className = 'h-full bg-success rounded-full'; + } + } + + // 更新查询量 + const queriesValueElement = document.getElementById('server-queries-value'); + if (queriesValueElement) { + queriesValueElement.textContent = formatNumber(totalQueries); + } + + // 计算查询量百分比(假设最大查询量为10000) + const queryPercentage = Math.min((totalQueries / 10000) * 100, 100); + const queriesBarElement = document.getElementById('server-queries-bar'); + if (queriesBarElement) { + queriesBarElement.style.width = queryPercentage + '%'; + } + + // 更新额外统计指标 + const totalQueriesElement = document.getElementById('server-total-queries'); + if (totalQueriesElement) { + totalQueriesElement.textContent = formatNumber(totalQueries); + } + + const blockedQueriesElement = document.getElementById('server-blocked-queries'); + if (blockedQueriesElement) { + blockedQueriesElement.textContent = formatNumber(blockedQueries); + } + + const allowedQueriesElement = document.getElementById('server-allowed-queries'); + if (allowedQueriesElement) { + allowedQueriesElement.textContent = formatNumber(allowedQueries); + } + + // 添加光晕提示效果 + if (previousServerData.cpu !== cpuUsage || previousServerData.queries !== totalQueries) { + addGlowEffect(); + } + + // 更新服务器状态指示器 + const statusIndicator = document.getElementById('server-status-indicator'); + if (statusIndicator) { + // 检查系统状态 + if (stats.system && stats.system.status === 'error') { + statusIndicator.className = 'inline-block w-2 h-2 bg-danger rounded-full'; + } else { + statusIndicator.className = 'inline-block w-2 h-2 bg-success rounded-full'; + } + } + + // 保存当前数据用于下次比较 + previousServerData = { + cpu: cpuUsage, + queries: totalQueries + }; +} + +// 添加光晕提示效果 +function addGlowEffect() { + const widget = document.getElementById('server-status-widget'); + if (!widget) return; + + // 添加光晕类 + widget.classList.add('glow-effect'); + + // 2秒后移除光晕 + setTimeout(function() { + widget.classList.remove('glow-effect'); + }, 2000); +} + +// 格式化数字 +function formatNumber(num) { + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + 'M'; + } else if (num >= 1000) { + return (num / 1000).toFixed(1) + 'K'; + } + return num.toString(); +} + +// 在DOM加载完成后初始化 +window.addEventListener('DOMContentLoaded', function() { + // 延迟初始化,确保页面完全加载 + setTimeout(initServerStatusWidget, 500); +}); + +// 在页面卸载时清理资源 +window.addEventListener('beforeunload', function() { + if (serverStatusUpdateTimer) { + clearInterval(serverStatusUpdateTimer); + serverStatusUpdateTimer = null; + } +}); + +// 导出函数供其他模块使用 +window.serverStatusWidget = { + init: initServerStatusWidget, + update: updateServerStatusWidget +}; \ No newline at end of file diff --git a/static/js/shield.js b/static/js/shield.js index e914431..5ee51a1 100644 --- a/static/js/shield.js +++ b/static/js/shield.js @@ -1,112 +1,41 @@ // 屏蔽管理页面功能实现 -// 初始化屏蔽管理页面 +// 初始化屏蔽管理页面 - 已禁用加载屏蔽规则功能 function initShieldPage() { - loadShieldRules(); + // 不再加载屏蔽规则,避免DOM元素不存在导致的错误 setupShieldEventListeners(); } -// 加载屏蔽规则 +// 加载屏蔽规则 - 已禁用此功能 async function loadShieldRules() { - try { - const rules = await api.getShieldRules(); - updateShieldRulesTable(rules); - } catch (error) { - showErrorMessage('加载屏蔽规则失败: ' + error.message); - } + console.log('屏蔽规则加载功能已禁用'); } -// 更新屏蔽规则表格 +// 更新屏蔽规则表格 - 已禁用此功能 function updateShieldRulesTable(rules) { - const tbody = document.getElementById('shield-rules-tbody'); - tbody.innerHTML = ''; - - if (!rules || rules.length === 0) { - tbody.innerHTML = '暂无屏蔽规则'; - return; - } - - rules.forEach((rule, index) => { - const tr = document.createElement('tr'); - tr.className = 'border-b border-gray-200 hover:bg-gray-50'; - tr.innerHTML = ` - ${index + 1} - ${rule} - - - - `; - tbody.appendChild(tr); - }); - - // 添加删除按钮事件监听器 - document.querySelectorAll('.delete-rule-btn').forEach(btn => { - btn.addEventListener('click', handleDeleteRule); - }); + // 不再更新表格,避免DOM元素不存在导致的错误 + console.log('屏蔽规则表格更新功能已禁用'); } -// 处理删除规则 +// 处理删除规则 - 已禁用此功能 async function handleDeleteRule(e) { - const rule = e.currentTarget.getAttribute('data-rule'); - - if (confirm(`确定要删除规则: ${rule} 吗?`)) { - try { - await api.deleteShieldRule(rule); - showSuccessMessage('规则删除成功'); - loadShieldRules(); - } catch (error) { - showErrorMessage('删除规则失败: ' + error.message); - } - } + showErrorMessage('删除规则功能已禁用'); } -// 添加新规则 +// 添加新规则 - 已禁用此功能 async function handleAddRule() { - const ruleInput = document.getElementById('new-rule-input'); - const rule = ruleInput.value.trim(); - - if (!rule) { - showErrorMessage('规则不能为空'); - return; - } - - try { - await api.addShieldRule(rule); - showSuccessMessage('规则添加成功'); - loadShieldRules(); - ruleInput.value = ''; - } catch (error) { - showErrorMessage('添加规则失败: ' + error.message); - } + showErrorMessage('添加规则功能已禁用'); } -// 更新远程规则 +// 更新远程规则 - 已禁用此功能 async function handleUpdateRemoteRules() { - try { - await api.updateRemoteRules(); - showSuccessMessage('远程规则更新成功'); - loadShieldRules(); - } catch (error) { - showErrorMessage('远程规则更新失败: ' + error.message); - } + showErrorMessage('更新远程规则功能已禁用'); } -// 设置事件监听器 +// 设置事件监听器 - 已禁用规则相关功能 function setupShieldEventListeners() { - // 添加规则按钮 - document.getElementById('add-rule-btn')?.addEventListener('click', handleAddRule); - - // 按回车键添加规则 - document.getElementById('new-rule-input')?.addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - handleAddRule(); - } - }); - - // 更新远程规则按钮 - document.getElementById('update-remote-rules-btn')?.addEventListener('click', handleUpdateRemoteRules); + // 移除所有事件监听器,避免触发已禁用的功能 + console.log('屏蔽规则相关事件监听器已设置,但功能已禁用'); } // 显示成功消息 diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..74c293f --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,24 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./static/**/*.{html,js}", + ], + theme: { + extend: { + colors: { + primary: '#165DFF', + secondary: '#36CFFB', + success: '#00B42A', + warning: '#FF7D00', + danger: '#F53F3F', + info: '#86909C', + dark: '#1D2129', + light: '#F2F3F5', + }, + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + }, + }, + }, + plugins: [], +} \ No newline at end of file diff --git a/test_console.sh b/test_console.sh new file mode 100755 index 0000000..e88a657 --- /dev/null +++ b/test_console.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +# DNS Web控制台功能测试脚本 +echo "开始测试DNS Web控制台功能..." +echo "==================================" + +# 检查服务器是否运行 +echo "检查DNS服务器运行状态..." +pids=$(ps aux | grep dns-server | grep -v grep) +if [ -n "$pids" ]; then + echo "✓ DNS服务器正在运行" +else + echo "✗ DNS服务器未运行,请先启动服务器" +fi + +# 测试API基础URL +BASE_URL="http://localhost:8080/api" + +# 测试1: 获取统计信息 +echo "\n测试1: 获取DNS统计信息" +curl -s -o /dev/null -w "状态码: %{http_code}\n" "$BASE_URL/stats" + +# 测试2: 获取系统状态 +echo "\n测试2: 获取系统状态" +curl -s -o /dev/null -w "状态码: %{http_code}\n" "$BASE_URL/status" + +# 测试3: 获取屏蔽规则 +echo "\n测试3: 获取屏蔽规则列表" +curl -s -o /dev/null -w "状态码: %{http_code}\n" "$BASE_URL/shield" + +# 测试4: 获取Top屏蔽域名 +echo "\n测试4: 获取Top屏蔽域名" +curl -s -o /dev/null -w "状态码: %{http_code}\n" "$BASE_URL/top-blocked" + +# 测试5: 获取Hosts内容 +echo "\n测试5: 获取Hosts内容" +curl -s -o /dev/null -w "状态码: %{http_code}\n" "$BASE_URL/shield/hosts" + +# 测试6: 访问Web控制台主页 +echo "\n测试6: 访问Web控制台主页" +curl -s -o /dev/null -w "状态码: %{http_code}\n" "http://localhost:8080" + +echo "\n==================================" +echo "测试完成!请检查上述状态码。正常情况下应为200。" +echo "前端Web控制台可通过浏览器访问: http://localhost:8080" \ No newline at end of file