From 644981eef4bf009ff8e98acef4349694f7d63093 Mon Sep 17 00:00:00 2001 From: Logan Date: Mon, 6 Jul 2026 23:01:04 +0800 Subject: [PATCH] feat: improve datasource query audit workflows --- OPTIMIZATION_COMPARISON_REPORT.md | 198 -- OPTIMIZATION_PLAN.md | 179 -- OPTIMIZATION_REVIEW.md | 149 -- PROJECT_EVALUATION.md | 126 -- README.md | 13 +- ROADMAP.md | 64 - TDD_PLAN.md | 247 --- backend/Dockerfile | 4 +- backend/internal/handler/connection_tester.go | 16 +- backend/internal/handler/instance.go | 32 +- backend/internal/handler/instance_detail.go | 102 +- backend/internal/handler/redis.go | 194 +- backend/internal/service/auth_service.go | 18 +- backend/internal/service/ticket_service.go | 10 +- docker-compose.yml | 2 - frontend/index.html | 2 +- frontend/package-lock.json | 1681 +---------------- frontend/package.json | 5 +- frontend/src/App.tsx | 106 +- .../src/components/EditInstanceDialog.tsx | 87 +- frontend/src/components/Layout.tsx | 3 +- frontend/src/components/SchemaSidebar.tsx | 18 + frontend/src/components/ui/card.tsx | 2 +- frontend/src/index.css | 13 +- frontend/src/lib/queryTemplates.ts | 2 +- frontend/src/pages/AuditRulesPage.tsx | 26 + frontend/src/pages/Dashboard.tsx | 42 +- frontend/src/pages/InstancePage.tsx | 205 +- frontend/src/pages/PlatformConfigPage.tsx | 21 +- frontend/src/pages/QueriesPage.tsx | 24 +- frontend/vite.config.ts | 12 + merge-analysis-report.md | 238 --- 32 files changed, 780 insertions(+), 3061 deletions(-) delete mode 100644 OPTIMIZATION_COMPARISON_REPORT.md delete mode 100644 OPTIMIZATION_PLAN.md delete mode 100644 OPTIMIZATION_REVIEW.md delete mode 100644 PROJECT_EVALUATION.md delete mode 100644 ROADMAP.md delete mode 100644 TDD_PLAN.md delete mode 100644 merge-analysis-report.md diff --git a/OPTIMIZATION_COMPARISON_REPORT.md b/OPTIMIZATION_COMPARISON_REPORT.md deleted file mode 100644 index 77eb3ab..0000000 --- a/OPTIMIZATION_COMPARISON_REPORT.md +++ /dev/null @@ -1,198 +0,0 @@ -# SQL平台代码v1 优化包对比报告 - -> 分析日期: 2026-05-23 -> 分析对象: ~/Downloads/SQL平台代码v1/ (optimized + final) -> 当前项目: ~/devops/sql-platform/ - ---- - -## 一、优化包概览 - -优化包包含两个版本: -- **sql-platform-optimized**: 中间优化版本(前 3 个 Stage 的产出) -- **sql-platform-final**: 最终版本(在 optimized 基础上新增数据库审核规则拆分) - -两者基于 4 阶段优化计划(plan.md): -1. **Stage 1** — 后端关键缺陷修复 -2. **Stage 2** — 前端界面全方位优化(4 组并行) -3. **Stage 3** — 权限逻辑 & 安全增强 -4. **Stage 4** — 构建验证 & 最终集成 - ---- - -## 二、项目评估摘要(来自 PROJECT_EVALUATION.md) - -| 维度 | 评分 | 说明 | -|------|------|------| -| 功能完成度 | 8.5/10 | 核心功能全覆盖:工单全生命周期、SQL审核引擎、规则CRUD、审计日志 | -| 代码质量 | 8.5/10 | TDD驱动、128测试、模块化清晰 | -| 安全性 | 9.0/10 | AES加密、bcrypt、JWT、AST防注入、权限矩阵 | -| 测试覆盖 | 8.5/10 | 128 tests 覆盖11模块 | -| 生产就绪度 | 8.0/10 | Docker部署、Alembic迁移、GitLab CI/CD | -| 用户体验 | 7.5/10 | 缺国际化、深色模式 | -| **综合评分** | **8.1/10** | 目标 9.0+ | - ---- - -## 三、路线图优化项(来自 ROADMAP.md) - -### 已完成的优化 - -| 分类 | 优化项 | 规模 | -|------|--------|------| -| **P0 数据库全景支持** | 统一查询服务 (MySQL/PG/Redis/MongoDB) | query_service.py 完整4种Handler | -| | Redis 只读命令白名单 | 40+ 只读命令 + 子命令验证 | -| | MongoDB 只读操作白名单 | find/findOne/countDocuments/distinct/aggregate | -| | 工单执行多数据库分发 | ticket_executor.py 多DB执行 | -| | MongoDB 连接器 + 前端管理页面 | mongo_connector.py 780行 | -| **P1 审计闭环** | 查询历史服务 | query_history_service.py | -| | SQL 助手 | sql_assistant.py (格式化/风险高亮/EXPLAIN) | -| | 审计规则种子 | MySQL 18规则 + PG 12规则 + Redis 8规则 | -| | 影响分析 | impact_analyzer.py | -| | 回滚生成 | rollback_generator.py | -| | Schema 漂移检测 | schema_drift_detector.py | -| **P2 高级功能** | 数据脱敏 | data_masker.py + masking_rule CRUD | -| | 通知服务 | 钉钉/飞书/邮件 SMTP | -| | 备份管理 | backup_executor.py | -| | 工作流引擎 | workflow_engine.py + workflow_seeds.py | -| | 慢日志收集 | slow_log_collector.py | -| | 连接池 | connection_pool.py | - -### 待完成项 - -- [ ] 执行前后数据快照对比可视化(前端) -- [ ] 快捷查询模板(10个预置 + 自定义共享) -- [ ] SQL 格式化前端集成 -- [ ] 查询结果格式化展示优化 -- [ ] SQL 语法高亮编辑器 -- [ ] Slow query 前端集成 -- [ ] Dashboard 数据源健康概览 - ---- - -## 四、版本差异分析 - -### 4.1 optimized vs final 差异 - -**final 在 optimized 基础上新增了 4 个数据库专用审核规则文件:** - -| 新增文件 | 行数 | 规则数量 | 覆盖范围 | -|----------|------|----------|----------| -| `mysql_audit_rules.py` | 2,101 行 | 63条 | DDL 36条 + DML 9条 + 命名 5条 + 性能 5条 + 安全 5条 | -| `redis_audit_rules.py` | 1,863 行 | 43条 | KEY管理10条 + 命令安全12条 + 数据结构8条 + 性能8条 + 安全5条 | -| `mongodb_audit_rules.py` | 1,638 行 | 42条 | 集合管理 + 查询安全 + 聚合管道 + 写入安全 + 性能 | -| `postgres_audit_rules.py` | 1,289 行 | 45条 | DDL 18条 + DML 8条 + DQL 6条 + 安全6条 + 性能7条 | - -**final 还修改了 5 个关联文件:** -- `audit_engine_v2.py` — 新增 4 个 DB 规则模块的 import,规则数量从 100+ 升级到 200+ -- `audit_rule_seeds.py` — 新增 MongoDB 种子规则 + 别名映射 -- `audit_rule.py` (endpoint) — 新增数据库类型别名支持(postgresql→postgres, mongo→mongodb) -- `AuditRulesPage.tsx` — 前端新增 MongoDB/Redis 相关关键词识别 - -### 4.2 optimized vs 当前项目差异 - -**文件结构:完全一致**(无新增/缺失文件,仅 `__pycache__` 差异) - -**内容差异:共 25 个文件有改动** - -#### 后端 (9 个文件) - -| 文件 | 改动行数 | 改动性质 | -|------|----------|----------| -| `services/workflow_engine.py` | 1,284 | 工作流引擎大幅扩展 | -| `services/query_service.py` | 720 | 查询服务优化/增强 | -| `services/audit_engine.py` | 858 | 审核引擎 V1 优化 | -| `services/data_masker.py` | 668 | 数据脱敏模块增强 | -| `services/ticket_executor.py` | 530 | 工单执行器改进 | -| `models/workflow.py` | 331 | 工作流模型扩展 | -| `models/user.py` | 278 | 用户模型扩展 | -| `endpoints/ticket.py` | 540 | 工单 API 改进 | -| `endpoints/query.py` | 256 | 查询 API 改进 | - -#### 前端 (16 个文件) - -| 文件 | 改动行数 | 改动性质 | -|------|----------|----------| -| `pages/InstancePage.tsx` | 1,372 | 实例管理页大幅优化 | -| `pages/WorkflowPage.tsx` | 971 | 工作流页面优化 | -| `pages/TicketsPage.tsx` | 960 | 工单列表页优化 | -| `pages/TicketCreatePage.tsx` | 868 | 工单创建页优化 | -| `pages/UsersPage.tsx` | 804 | 用户管理页优化 | -| `pages/SlowQueryPage.tsx` | 703 | 慢查询页优化 | -| `pages/TicketDetailPage.tsx` | 719 | 工单详情页优化 | -| `pages/DataDictionaryPage.tsx` | 650 | 数据字典页优化 | -| `pages/Dashboard.tsx` | 636 | 仪表盘优化 | -| `pages/InstanceGroupPage.tsx` | 631 | 实例组页优化 | -| `pages/AuditLogsPage.tsx` | 600 | 审计日志页优化 | -| `pages/QueriesPage.tsx` | 601 | 查询页优化 | -| `pages/AuditRulesPage.tsx` | 584 | 审核规则页优化 | -| `pages/RolesPage.tsx` | 519 | 角色管理页优化 | -| `hooks/useAuth.tsx` | 391 | 认证 hook 优化 | -| `components/SqlEditor.tsx` | 3 | SQL编辑器微调 | - ---- - -## 五、对我们当前项目有价值的改动 - -### 🔴 高价值(建议立即采纳) - -1. **数据库专用审核规则(final 独有)** — 193条数据库专用规则 - - `mysql_audit_rules.py` (63条) — 生产级 MySQL 审核覆盖 - - `redis_audit_rules.py` (43条) — Redis 命令安全检测 - - `mongodb_audit_rules.py` (42条) — MongoDB 操作审核 - - `postgres_audit_rules.py` (45条) — PostgreSQL 特有语法检查 - - 关联修改:`audit_engine_v2.py`, `audit_rule_seeds.py`, `audit_rule.py` - -2. **前端页面全面优化** — 16 个页面的 UI/UX 改进 - - 所有核心页面均有数百行改动,估计是界面交互、组件化、错误处理方面的增强 - -3. **后端核心服务优化** — 9 个关键文件 - - `workflow_engine.py` (1,284 行改动) — 工作流引擎重大升级 - - `query_service.py` (720 行改动) — 查询服务改进 - - `ticket_executor.py` (530 行改动) — 工单执行器改进 - -### 🟡 中等价值 - -4. **数据脱敏增强** — `data_masker.py` 668 行改动 -5. **用户/工作流模型扩展** — `user.py` + `workflow.py` 模型字段扩展 -6. **认证 hook 优化** — `useAuth.tsx` 391 行改动 - -### 🟢 待评估 - -7. **TDD 测试计划** — 当前 128 tests,Phase 4 安全测试未完成 -8. **路线图待完成项** — 快照对比、查询模板、前端集成等 - ---- - -## 六、建议操作 - -### 立即可做 -```bash -# 将 final 版本的4个新审核规则文件复制到我们的项目 -cp ~/Downloads/SQL平台代码v1/sql-platform-final/backend/app/services/mysql_audit_rules.py ~/devops/sql-platform/backend/app/services/ -cp ~/Downloads/SQL平台代码v1/sql-platform-final/backend/app/services/redis_audit_rules.py ~/devops/sql-platform/backend/app/services/ -cp ~/Downloads/SQL平台代码v1/sql-platform-final/backend/app/services/mongodb_audit_rules.py ~/devops/sql-platform/backend/app/services/ -cp ~/Downloads/SQL平台代码v1/sql-platform-final/backend/app/services/postgres_audit_rules.py ~/devops/sql-platform/backend/app/services/ -``` - -### 需要逐步验证 -- 逐个对比 25 个差异文件的改动内容,评估每项改动的兼容性 -- 运行现有测试确保不引入回归 -- 前端改动需逐一构建验证 - -### 建议策略 -1. 先合并 `final` 版本新增的 4 个审核规则文件 + 关联修改(低风险,纯新增) -2. 逐页面合并前端优化(每合并一个页面后构建验证) -3. 最后处理后端核心服务改动(需配合测试) - ---- - -## 七、总结 - -| 项目 | 数据 | -|------|------| -| 优化包总改动文件数 (vs 我们) | **29 个** (25差异 + 4新增) | -| 优化包总新增代码量 | ~6,900 行 (4个新规则文件) + 大量行级改动 | -| 文件结构差异 | **无** (完全一致的目录结构) | -| 最有价值的改动 | **193条数据库专用审核规则** (final 独有) | -| 风险评估 | 中等 — 文件结构一致但内容改动量大 | diff --git a/OPTIMIZATION_PLAN.md b/OPTIMIZATION_PLAN.md deleted file mode 100644 index 28d37d6..0000000 --- a/OPTIMIZATION_PLAN.md +++ /dev/null @@ -1,179 +0,0 @@ -# SQL-Platform-Pro 优化清单 - -> 基于当前实现状态(commit 39ffc14)的差距分析,按优先级 P0/P1/P2 分级。 -> 状态标记:🟢已有基础 🟡部分实现 🔴未实现 - ---- - -## 一、假阳性/假阴性治理 - -### 现状 -- MySQL 117 条审核规则(DDL-036 + DML 等),已持久化到 DB + 前端可调 severity/enabled -- 已做一轮降级(9 条 ERROR→WARNING),但缺少「为什么误报」的反馈闭环 -- PostgreSQL / Redis / MongoDB 审核规则薄弱(PG 少量、Redis 仅 FLUSHDB 拦截、MongoDB 无专用规则) - -### 优化项 - -| # | 优先级 | 内容 | 现状 | 工作量 | -|---|--------|------|------|--------| -| 1.1 | **P0** | **审核结果反馈机制** — 审批人在审批时可标记某条 finding 为「误报」,记录到 `audit_feedback` 表(rule_code, ticket_id, feedback_type, reason),月度统计误报率 TOP10 规则 | 🔴 | 2d | -| 1.2 | **P0** | **上下文感知规则** — `ALTER TABLE ADD INDEX` 在小表(<1万行)应 WARNING 而非 ERROR;连接目标库查 `table_rows` 动态调整 severity | 🔴 | 3d | -| 1.3 | **P1** | **规则分组白名单** — 支持实例级别禁用/调整规则(如测试环境放宽所有 DDL 规则),`instance_rule_override` 表 | 🔴 | 2d | -| 1.4 | **P1** | **PG 审核规则补全** — 参照 MySQL 规则体系,补齐 PG 特有规则(SERIAL 滥用、JSONB 无索引、VACUUM 建议、大对象等) | 🟡 | 3d | -| 1.5 | **P2** | **MongoDB 审核规则** — Pipeline 复杂度限制、`$where`/`eval` 禁止、`dropDatabase` 拦截、索引建议 | 🔴 | 3d | -| 1.6 | **P2** | **规则测试基准** — 每条规则至少 3 个 case(正常通过/应拦截/边界),CI 自动验证规则回归 | 🔴 | 3d | - ---- - -## 二、大 SQL / 复杂语句处理 - -### 现状 -- `_split_statements()` 用 sqlparse 切割多条 SQL -- 无 SQL 长度/复杂度上限(有 `max_query_length` 但仅查长度) -- MongoDB Pipeline 无复杂度检查 - -### 优化项 - -| # | 优先级 | 内容 | 现状 | 工作量 | -|---|--------|------|------|--------| -| 2.1 | **P1** | **SQL 复杂度评分** — 解析 JOIN 数量、子查询嵌套层数、UNION 数量,超过阈值(可配置)提升 risk_level | 🔴 | 2d | -| 2.2 | **P1** | **审核超时保护** — 单条 SQL 审核超过 5s 自动跳过(标记 timeout),避免正则/解析卡死 | 🔴 | 1d | -| 2.3 | **P1** | **审核缓存** — 相同 SQL 文本(去空格后 hash)短期不重复审核,LRU 缓存 1000 条 | 🔴 | 1d | -| 2.4 | **P2** | **MongoDB Pipeline 复杂度** — 限制 stage 数量(默认 20)、`$lookup` 嵌套层数、`$group` 内存风险 | 🔴 | 2d | -| 2.5 | **P2** | **SQL 格式化展示** — 审核详情中高亮问题位置(行号+列号),用 sqlparse 格式化 | 🔴 | 1d | - ---- - -## 三、事务一致性(多条 SQL 原子性) - -### 现状 -- MySQL 执行有 `conn.rollback()` 失败回滚 -- PG 执行有 `transaction()` 包裹 -- 但 **跨库没有事务**,同库多条 SQL 部分成功部分失败时缺少明确的补偿机制 -- Redis/MongoDB 多命令无事务概念(Redis 有 MULTI/EXEC 但未使用) - -### 优化项 - -| # | 优先级 | 内容 | 现状 | 工作量 | -|---|--------|------|------|--------| -| 3.1 | **P0** | **执行进度透明** — 多条 SQL 逐条执行,每条记录 status( pending/running/success/failed ),前端实时展示 | 🟡 | 3d | -| 3.2 | **P0** | **部分失败处理策略** — 可选:`stop_on_error`(默认,遇错停止)/ `continue_on_error`(跳过失败继续)/ `transaction`(整体回滚) | 🔴 | 2d | -| 3.3 | **P1** | **自动回滚 SQL 生成** — INSERT→DELETE、UPDATE→反向 UPDATE、DDL→DROP/ALTER REVERSE,记录到 `rollback_sql` 字段 | 🔴 | 5d | -| 3.4 | **P1** | **Redis MULTI/EXEC** — 工单中多条 Redis 命令用 pipeline 批量执行,保证原子性 | 🔴 | 1d | -| 3.5 | **P2** | **跨库事务标记** — 跨库工单标注「无原子性保障」警告,审批人二次确认 | 🔴 | 1d | - ---- - -## 四、锁表与业务影响分析 - -### 现状 -- 有工单执行锁(防并发执行同一工单) -- **无 EXPLAIN 预估**、无影响行数预估、无锁表风险提示 -- 无大表 ALTER 的 pt-osc / gh-ost 建议 - -### 优化项 - -| # | 优先级 | 内容 | 现状 | 工作量 | -|---|--------|------|------|--------| -| 4.1 | **P0** | **EXPLAIN 预检** — 审核阶段对 DML 执行 EXPLAIN(MySQL)/ EXPLAIN ANALYZE(PG),提取 `rows` 预估扫描行数,超过阈值(可配置,默认 10 万)标 ERROR | 🔴 | 3d | -| 4.2 | **P0** | **大表 ALTER 检测** — 查 `information_schema.tables` 获取表行数,大表(>10万行)ALTER 时提示「建议使用 pt-online-schema-change / gh-ost」,risk_level=high | 🔴 | 2d | -| 4.3 | **P1** | **影响行数预估** — DML 工单执行前用 `SELECT COUNT(*) FROM ... WHERE ` 预估影响行数,超过阈值(默认 1 万)强制高等级审批 | 🔴 | 2d | -| 4.4 | **P1** | **执行时间预估** — 基于 EXPLAIN 的 rows × 历史执行平均耗时,给出粗略预估(快<1s / 中<30s / 慢>30s / 极慢>5min) | 🔴 | 2d | -| 4.5 | **P2** | **执行超时强杀** — MySQL `max_execution_time` / PG `statement_timeout` 硬超时,超时自动 KILL 并标记 failed | 🟡 | 1d | -| 4.6 | **P2** | **维护窗口控制** — 可配置允许执行 DDL 的时间窗口(如凌晨 2-5 点),窗口外提交自动拒绝 | 🔴 | 1d | - ---- - -## 五、敏感操作审核强化 - -### 现状 -- DROP TABLE → 审核规则 ERROR(DDL-004) -- Redis FLUSHDB/FLUSHALL → 仅限 admin -- **但审批只有一级**(任意 admin/dba 可批),无多级审批 -- 无风险等级区分(所有工单走同一审批流) - -### 优化项 - -| # | 优先级 | 内容 | 现状 | 工作量 | -|---|--------|------|------|--------| -| 5.1 | **P0** | **工单风险等级** — 根据 SQL 类型 + 操作对象自动计算 risk_level(low/medium/high/critical):DROP/TRUNCATE/FLUSHDB=critical,大表 ALTER=high,普通 DML=medium,SELECT=low | 🔴 | 2d | -| 5.2 | **P0** | **多级审批流** — risk_level=high 需要 2 人审批(DBA+管理员),critical 需要 3 人审批(DBA+管理员+CTO 或指定人),利用现有 WorkflowStep 模型 | 🟡 | 3d | -| 5.3 | **P1** | **高危操作二次确认** — critical 级别工单执行前,执行人必须输入工单编号确认(防误操作) | 🔴 | 1d | -| 5.4 | **P1** | **敏感表保护** — 配置「保护表清单」(如 user/password/order/pay),对这些表的任何 DML/DDL 自动提升 risk_level,额外审批 | 🔴 | 2d | -| 5.5 | **P2** | **执行前备份** — critical 工单执行前自动备份受影响表(`CREATE TABLE xxx_backup AS SELECT *`),备份保留可配置天数 | 🔴 | 3d | -| 5.6 | **P2** | **MongoDB 危险操作** — `dropDatabase`、`dropCollection`、`remove({})` 拦截 + 多级审批 | 🔴 | 1d | - ---- - -## 六、数据脱敏与审计 - -### 现状 -- `MaskingRule` 模型已有(full/partial/hash/truncate/regex 5 种类型) -- 查询接口有 `DataMasker` 应用脱敏 -- 审计日志 `AuditLog` 记录操作 -- **但审核详情中 SQL 可能看到生产敏感数据**、通知中可能泄露信息 - -### 优化项 - -| # | 优先级 | 内容 | 现状 | 工作量 | -|---|--------|------|------|--------| -| 6.1 | **P0** | **通知脱敏** — 通知消息中的 SQL 内容截断(>200 字符只显示前 100 + ...),隐藏 WHERE 中的值(如 `phone='138****1234'`) | 🔴 | 1d | -| 6.2 | **P1** | **审核日志不可篡改** — AuditLog 表增加 `checksum` 字段(SHA256 of content),定期校验完整性;或写入只追加日志文件 | 🔴 | 2d | -| 6.3 | **P1** | **操作导出** — 审计日志支持 CSV/Excel 导出(按时间/用户/操作类型过滤),满足合规审计需求 | 🔴 | 2d | -| 6.4 | **P1** | **审核详情脱敏** — 审批人看到的工单 SQL 中,对配置了脱敏的字段值做 mask(如 INSERT INTO users (phone) VALUES ('138****1234')) | 🔴 | 2d | -| 6.5 | **P2** | **会话级脱敏策略** — 不同角色看到不同级别的脱敏(admin 看完整、dba 看部分脱敏、developer 看完全脱敏) | 🔴 | 2d | -| 6.6 | **P2** | **SQL 注入检测** — 审核阶段检测疑似 SQL 注入模式(如连续单引号、UNION SELECT 注入、hex 编码绕过) | 🔴 | 2d | - ---- - -## 优先级排序(建议实施路线) - -### 第一阶段(2 周)— 安全基线 -``` -5.1 工单风险等级 → 后续所有功能的基础 -1.2 上下文感知规则 → 减少误报最有效的手段 -4.1 EXPLAIN 预检 → DML 执行安全核心 -3.1 执行进度透明 → 多条 SQL 可观测性 -6.1 通知脱敏 → 快速见效 -``` - -### 第二阶段(2 周)— 体验优化 -``` -5.2 多级审批流 → 基于风险等级的审批 -3.2 部分失败处理策略 → 多条 SQL 容错 -4.2 大表 ALTER 检测 → DBA 最关心的场景 -4.3 影响行数预估 → 审批人最需要的信息 -1.1 审核结果反馈机制 → 持续优化规则质量 -2.2 审核超时保护 → 稳定性兜底 -``` - -### 第三阶段(2 周)— 高级特性 -``` -3.3 自动回滚 SQL 生成 → 最高价值但最复杂 -4.4 执行时间预估 → 配合维护窗口 -5.3 高危操作二次确认 → 防呆机制 -5.4 敏感表保护 → 细粒度管控 -1.4 PG 审核规则补全 → 多 DB 支持 -6.2 审计日志不可篡改 → 合规需求 -``` - -### 第四阶段(持续)— 生态完善 -``` -1.5 MongoDB 审核规则 -1.6 规则测试基准 -2.1 SQL 复杂度评分 -5.5 执行前备份 -6.3 操作导出 -6.6 SQL 注入检测 -``` - ---- - -## 预估总工作量 - -| 阶段 | 工作量 | 核心目标 | -|------|--------|----------| -| P0 合计 | ~15 人天 | 安全基线 + 防误报 | -| P1 合计 | ~23 人天 | 审批流 + 影响分析 | -| P2 合计 | ~18 人天 | 高级特性 + 合规 | -| **总计** | **~56 人天** | — | diff --git a/OPTIMIZATION_REVIEW.md b/OPTIMIZATION_REVIEW.md deleted file mode 100644 index 86dbe96..0000000 --- a/OPTIMIZATION_REVIEW.md +++ /dev/null @@ -1,149 +0,0 @@ -# 优化清单可行性评估 - -> 对 OPTIMIZATION_PLAN.md 逐项评审,结论:**可行但有水分,需要砍掉/合并部分项** -> -> 标记:✅可直接做 ⚠️需要架构调整 ❌过度设计建议砍掉 - ---- - -## 总体结论 - -| 维度 | 评估 | -|------|------| -| **方向正确性** | ✅ 6 个方向都是真实的运维痛点 | -| **工作量估算** | ⚠️ 偏乐观,56 人天实际可能 80+,因为很多项需要改 audit 方法签名(目前不接受 DB 连接) | -| **架构瓶颈** | ⚠️ 审核引擎是纯静态分析(只看 SQL 文本),要加 EXPLAIN/影响分析需要大改 | -| **过度设计项** | ❌ 约 8 项(见下文),属于「有了更好但不是刚需」 | -| **实际建议** | 砍到 **18 项**,分 3 阶段,约 **35 人天** | - ---- - -## 逐项评审 - -### 一、假阳性/假阴性治理 - -| # | 评估 | 理由 | -|---|------|------| -| 1.1 审核结果反馈机制 | ⚠️ **可做但优先级降低** | 需要新表 + 前端改动,但价值在「长期优化」,前期没有足够数据量来统计误报率。建议后期做 | -| 1.2 上下文感知规则 | ✅ **核心价值** | 同一条 ALTER TABLE,小表 WARNING 大表 ERROR,这才是减少假阳性的关键。但实现难点:**审核引擎目前没有 DB 连接**,需要改 `audit()` 方法签名,加 `instance_id` 参数 → 查表行数 | -| 1.3 规则分组白名单 | ⚠️ **合理但可简化** | 不需要 `instance_rule_override` 表,直接在现有 `audit_rules` 表加 `scope` 字段(instance_id NULL=全局,非 NULL=实例级覆盖) | -| 1.4 PG 审核规则补全 | ⚠️ **可以做** | 但 PG 用户量通常远小于 MySQL,优先级可以降低。先补 10 条核心规则就够 | -| 1.5 MongoDB 审核规则 | ❌ **砍掉或极简** | MongoDB 工单场景极少(大部分是 Redis + MySQL),先只做 `dropDatabase` 拦截一条就够 | -| 1.6 规则测试基准 | ⚠️ **有价值但量大** | 117 条 × 3 case = 351 个测试。建议只给 ERROR 级别规则写测试(约 20 条 × 3 = 60 个) | - -### 二、大 SQL / 复杂语句处理 - -| # | 评估 | 理由 | -|---|------|------| -| 2.1 SQL 复杂度评分 | ⚠️ **有一定价值** | 但 JOIN 数/子查询层数对 DBA 审批帮助有限。简化版:只算 SQL 长度 + 语句条数就够了 | -| 2.2 审核超时保护 | ✅ **必须做** | 正则/解析卡死会拖垮整个审核。`asyncio.wait_for(audit(), timeout=5)` 即可,半天工作量 | -| 2.3 审核缓存 | ❌ **砍掉** | 同一 SQL 重复审核的场景极少(工单每次 SQL 都不同),LRU 缓存是过度优化 | -| 2.4 MongoDB Pipeline 复杂度 | ❌ **砍掉** | 同 1.5,MongoDB 工单场景少 | -| 2.5 SQL 格式化展示 | ⚠️ **可做但不是 P0** | 前端已有 CodeMirror,高亮问题位置需要额外 parser 工作。放 P2 | - -### 三、事务一致性 - -| # | 评估 | 理由 | -|---|------|------| -| 3.1 执行进度透明 | ✅ **核心价值** | 多条 SQL 逐条记录状态,审批人能看清哪条成功哪条失败。现有 `_split_statements` 已有框架,改动不大 | -| 3.2 部分失败处理策略 | ✅ **必须做** | 3 种模式(遇错停止/跳过继续/整体回滚)覆盖所有场景。MySQL 已有 transaction,PG 已有 transaction(),加上策略枚举即可 | -| 3.3 自动回滚 SQL 生成 | ❌ **砍掉** | INSERT→反向 DELETE 看似简单,但 UPDATE 的反向需要 SELECT 旧值,DDL 不可逆。业界 Yearning/Archery 也只做了 INSERT 回滚,做了半吊子反而危险。不如做成「执行前手动填写回滚 SQL」 | -| 3.4 Redis MULTI/EXEC | ⚠️ **可做但简化** | Redis Pipeline(非事务)已经能批量执行,`MULTI/EXEC` 在跨 slot 时会失败。用 pipeline 就够 | -| 3.5 跨库事务标记 | ✅ **简单有效** | 前端/通知加一个警告标签,半天工作量 | - -### 四、锁表与业务影响分析 - -| # | 评估 | 理由 | -|---|------|------| -| 4.1 EXPLAIN 预检 | ⚠️ **核心价值但实现复杂** | 最大难点:**审核引擎是纯静态分析**,`audit(sql, db_type)` 没有 DB 连接。要跑 EXPLAIN 需要:(1) 改 audit 方法签名加 instance_id (2) 审核时建临时连接 (3) 执行 EXPLAIN (4) 解析结果。涉及连接池管理、超时处理、权限控制,实际工作量 5d 起步 | -| 4.2 大表 ALTER 检测 | ⚠️ **同 4.1 的前置问题** | 需要查 `information_schema`,同样需要 DB 连接。但可以做成「创建工单时」而非「审核时」查询,利用 ticket endpoint 已有的 db session | -| 4.3 影响行数预估 | ⚠️ **与 4.1 合并** | `SELECT COUNT(*) WHERE ` 就是 EXPLAIN 的简化版,不需要分开做 | -| 4.4 执行时间预估 | ❌ **砍掉** | EXPLAIN 的 rows × 历史耗时 = 极不准,给用户错误预期反而有害。不如只给「快/慢」二分 | -| 4.5 执行超时强杀 | ✅ **已部分实现** | `asyncio.wait_for(cur.execute(stmt), timeout=xxx)` 已有。补充 MySQL 的 `max_execution_time` hint 和 PG 的 `statement_timeout` SET 即可 | -| 4.6 维护窗口控制 | ⚠️ **简单有效** | 配置项 + 中间件检查,1d 工作量 | - -### 五、敏感操作审核强化 - -| # | 评估 | 理由 | -|---|------|------| -| 5.1 工单风险等级 | ✅ **基础能力,必须先做** | Ticket 模型已有 `priority` 字段,直接复用或加 `risk_level`。根据 SQL 类型 + 操作对象自动计算,是后续多级审批的基础 | -| 5.2 多级审批流 | ⚠️ **架构已有但逻辑未通** | WorkflowStep 已有 `step_type=serial/parallel/any_one/condition`,但 ticket 创建时**没有关联 workflow_id**。需要补上:ticket 绑定 workflow → 按 risk_level 匹配不同审批流 | -| 5.3 高危操作二次确认 | ✅ **简单有效** | 前端弹框要求输入工单编号,半天工作量 | -| 5.4 敏感表保护 | ⚠️ **可以做但配置繁琐** | 需要维护「保护表清单」,DBA 愿意配置才行。先做成全局配置(`protected_tables: ["user","password","order","pay"]`) | -| 5.5 执行前备份 | ❌ **砍掉** | `CREATE TABLE xxx_backup AS SELECT *` 对大表会锁表 + 占空间,与要解决的问题矛盾。改成建议提示「请手动备份」更实际 | -| 5.6 MongoDB 危险操作 | ❌ **合并到 5.1** | dropDatabase 在风险等级计算里处理即可,不需要单独做 | - -### 六、数据脱敏与审计 - -| # | 评估 | 理由 | -|---|------|------| -| 6.1 通知脱敏 | ✅ **简单有效** | 模板里 `{sql_preview}` 已经是截断的(取前 100 字符),但需要确认 `error` 字段有没有泄露数据。1d 工作量 | -| 6.2 审计日志不可篡改 | ❌ **砍掉** | checksum 方案在更新/删除场景下无法自洽,需要区块链/WORM 才能真正防篡改。对内部系统来说,DB 权限控制 + 操作审计就够了 | -| 6.3 操作导出 | ✅ **实用** | AuditLog + QueryHistory CSV 导出,API 加个 endpoint 即可 | -| 6.4 审核详情脱敏 | ⚠️ **理想但实现困难** | 对 SQL 中的字面值做脱敏需要 SQL parser 精确定位值位置,正则方案容易误伤。简化版:对审批人隐藏 `WHERE phone='13812341234'` 中的值 | -| 6.5 会话级脱敏策略 | ❌ **砍掉** | 过度设计,现有 MaskingRule 已够用 | -| 6.6 SQL 注入检测 | ⚠️ **有价值但非 P0** | 与审核引擎结合,检测 `UNION SELECT`、`OR 1=1`、hex 编码等模式。放 P2 | - ---- - -## 修订后实施计划(18 项 / ~35 人天) - -### 第一阶段:安全基线(~10d) - -| # | 项目 | 工作量 | 核心改动 | -|---|------|--------|----------| -| 1 | **5.1 工单风险等级** | 2d | Ticket 加 `risk_level` 字段 + 自动计算逻辑(后端) | -| 2 | **5.2 多级审批流打通** | 3d | Ticket 绑定 workflow_id → 按 risk_level 匹配 → 多步审批 | -| 3 | **2.2 审核超时保护** | 0.5d | `wait_for(audit(), timeout=5)` | -| 4 | **3.1 执行进度透明** | 2d | `ticket_sql_items` 表 + 逐条执行 + 前端实时状态 | -| 5 | **6.1 通知脱敏** | 1d | 模板中截断 SQL + 隐藏 WHERE 值 | -| 6 | **5.3 高危二次确认** | 0.5d | 前端输入工单编号确认框 | -| 7 | **4.6 维护窗口控制** | 1d | system_settings 配置 + 中间件检查 | - -### 第二阶段:影响分析(~12d) - -| # | 项目 | 工作量 | 核心改动 | -|---|------|--------|----------| -| 8 | **4.1+4.3 EXPLAIN + 影响行数** | 5d | 改 audit 签名 + DB 连接 + EXPLAIN 解析 + 前端展示 | -| 9 | **4.2 大表 ALTER 检测** | 2d | 创建工单时查 information_schema + 风险提示 | -| 10 | **3.2 部分失败策略** | 2d | stop_on_error / continue / transaction 三模式 | -| 11 | **1.2 上下文感知规则** | 3d | ALTER 规则查表行数动态调 severity | - -### 第三阶段:体验完善(~13d) - -| # | 项目 | 工作量 | 核心改动 | -|---|------|--------|----------| -| 12 | **1.4 PG 审核规则** | 3d | 补 10 条核心规则 | -| 13 | **1.3 规则白名单(简化版)** | 2d | audit_rules 加 scope 字段 | -| 14 | **4.5 执行超时强杀** | 1d | MySQL SET max_execution_time + PG SET statement_timeout | -| 15 | **6.3 审计日志导出** | 2d | CSV/Excel 导出 API | -| 16 | **3.5 跨库事务标记** | 0.5d | 前端/通知警告标签 | -| 17 | **5.4 敏感表保护** | 2d | 全局 protected_tables 配置 | -| 18 | **2.1 SQL 复杂度(简化版)** | 1d | 只算长度 + 语句条数 | - -### 砍掉的 12 项 - -| # | 砍掉原因 | -|---|----------| -| 1.1 审核结果反馈 | 数据量不够,后期做 | -| 1.5 MongoDB 审核规则 | 场景少,dropDatabase 合并到风险等级 | -| 1.6 规则测试基准 | 工作量大,改为只给 ERROR 规则写测试 | -| 2.3 审核缓存 | 同 SQL 重复审核场景极少 | -| 2.4 MongoDB Pipeline 复杂度 | 场景少 | -| 2.5 SQL 格式化展示 | 前端 CodeMirror 够用 | -| 3.3 自动回滚 SQL 生成 | 做半吊子反而危险 | -| 4.4 执行时间预估 | 不准,给错误预期 | -| 5.5 执行前备份 | 大表备份本身锁表 | -| 5.6 MongoDB 危险操作 | 合并到风险等级 | -| 6.2 审计日志不可篡改 | 内部系统不需要区块链 | -| 6.5 会话级脱敏策略 | 现有 MaskingRule 够用 | - ---- - -## 关键架构决策(实施前需确认) - -1. **审核引擎签名改造** — `audit(sql, db_type)` → `audit(sql, db_type, instance_id=None, db_session=None)`,加了 DB 连接才能跑 EXPLAIN。这是一个破坏性变更,需要评估影响面 - -2. **多级审批路由** — ticket 创建时如何匹配 workflow?方案:(A) 按 instance 关联的 workflow_id (B) 按 risk_level 查默认 workflow。建议用 (A),instance 已有 `workflow_id` 字段 - -3. **执行进度存储** — 是新建 `ticket_sql_items` 表还是复用 `execute_result` JSON?建议新建表,便于查询和展示 diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md deleted file mode 100644 index 2e9984c..0000000 --- a/PROJECT_EVALUATION.md +++ /dev/null @@ -1,126 +0,0 @@ -# SQL-Platform 数据审核平台 - 项目评估 - -> 评估日期: 2025-05-22 -> 评估版本: v0.5.0 (Phase 1-5 全部完成) - ---- - -## 一、项目概况 - -| 项目 | 说明 | -|------|------| -| 项目名 | SQL-Platform 数据审核平台 (sql-platform) | -| 定位 | SQL 审核与工单管理平台(对标 Yearning/ArcSQL) | -| 技术栈 | FastAPI + SQLite + React + TypeScript + Vite | -| 代码仓库 | ~/devops/sql-platform | -| Git 提交 | 8 commits | -| 测试 | 128 tests passing | -| 部署 | Docker (docker-compose),SQLite 轻量存储 | - ---- - -## 二、功能完成度 - -### 后端 API (FastAPI) - -| 模块 | 端点数 | 状态 | -|------|--------|------| -| 认证 (auth) | 4 | ✅ 登录/登出/获取用户/验证token | -| 用户管理 (user) | 5 | ✅ CRUD + 状态切换 | -| 数据源 (instance) | 6 | ✅ CRUD + 连接测试 + AES加密 | -| 工单 (ticket) | 8 | ✅ 创建/审批/拒绝/执行/统计/分页 | -| 在线查询 (query) | 1 | ✅ AST安全验证 + 自动LIMIT | -| 仪表盘 (dashboard) | 1 | ✅ 统计数据 | -| 审核引擎 (audit) | 3 | ✅ 规则引擎10条 + 自动评分 + 集成 | -| 审核规则 (audit-rule) | 7 | ✅ CRUD + toggle + stats + filter | -| 审计日志 (audit) | 2 | ✅ 列表/过滤/分页 | - -### 前端页面 (React) - -| 页面 | 路由 | 状态 | -|------|------|------| -| 登录 | /login | ✅ JWT认证 | -| 仪表盘 | /dashboard | ✅ 统计图表 | -| 数据源管理 | /mysql, /postgres 等 | ✅ CRUD + 连接测试 | -| 工单管理 | /tickets | ✅ 创建/审批/执行/统计 | -| 在线查询 | /queries | ✅ SQL执行 + 结果表格 | -| 用户管理 | /users | ✅ CRUD + 状态切换 | -| 审核规则 | /audit-rules | ✅ CRUD + toggle + stats | -| 审计日志 | /audit-logs | ✅ 分页 + 过滤 | - ---- - -## 三、安全特性 - -| 特性 | 实现 | -|------|------| -| 密码存储 | ✅ bcrypt hash | -| JWT Token | ✅ 30分钟过期 | -| 数据源密码 | ✅ AES-256-GCM 加密 | -| SQL 防注入 | ✅ sqlparse AST 解析 | -| 操作审计 | ✅ 全操作记录 | -| 权限控制 | ✅ admin/dba/developer/guest | - ---- - -## 四、测试覆盖 - -| 测试文件 | 数量 | 覆盖范围 | -|----------|------|----------| -| test_auth | 8 | 登录/登出/token验证 | -| test_users | 8 | CRUD/权限/自删 | -| test_instances | 8 | CRUD/加密/连接测试 | -| test_tickets | 11 | 全生命周期/统计/分页 | -| test_query | 4 | SELECT/DROP/DELETE/guest | -| test_dashboard | 2 | 统计/未登录 | -| test_crypto | 9 | AES加密/解密/兼容 | -| test_audit_engine | 23 | 10条规则/集成测试 | -| test_audit_logs | 9 | API/CRUD审计/分页 | -| test_sql_analyzer | 34 | 类型检测/风险/安全检查 | -| test_audit_rules | 14 | CRUD/权限/toggle/stats | -| **合计** | **128** | **11 模块** | - ---- - -## 五、DevOps - -| 项目 | 状态 | -|------|------| -| Dockerfile (backend) | ✅ Python 3.11 + auto migrate | -| Dockerfile (frontend) | ✅ Node 20 build + nginx | -| docker-compose | ✅ 双容器 + volume | -| Alembic 迁移 | ✅ 初始 schema | -| requirements.txt | ✅ 12 依赖 | - ---- - -## 六、评分 - -| 维度 | 分数 (1-10) | 说明 | -|------|-------------|------| -| **功能完成度** | **8.5** | 核心功能全覆盖:工单全生命周期、SQL审核引擎、规则CRUD、审计日志 | -| **代码质量** | **8.5** | TDD驱动、128测试、模块化清晰、类型注解完整 | -| **安全性** | **9.0** | AES加密、bcrypt、JWT、AST防注入、权限矩阵、审计日志 | -| **测试覆盖** | **8.5** | 128 tests 覆盖11模块,含单元+集成+安全测试 | -| **生产就绪度** | **8.0** | Docker部署、Alembic迁移、SQLite轻量、真实DB连接、GitLab CI/CD | -| **用户体验** | **7.5** | 前端响应式、过滤分页、toast提示;缺国际化、深色模式 | -| **文档** | **8.0** | ROADMAP + TDD_PLAN + EVALUATION 完整 | -| **综合评分** | **8.1** | | - ---- - -## 七、下一步优化建议 - -### 高优先级 (P0) -1. **慢查询分析** — EXPLAIN 执行计划展示 + 慢查询日志 -2. **邮件/Webhook 通知** — 工单状态变更通知 -3. **数据源探针** — 实例健康检查 + 连接池 - -### 中优先级 (P1) -4. **权限矩阵细化** — 数据源级权限、SQL 类型权限 -5. **SQL 格式化** — sqlparse 格式化展示 -6. **操作回滚** — SQL 执行前自动备份 - -### 低优先级 (P2) -7. **国际化 (i18n)** — 中英文切换 -8. **深色模式** — Tailwind dark mode diff --git a/README.md b/README.md index 0cbd6e0..be609b4 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ OpsxSQL 是一个面向 DBA 和开发人员的数据库管理平台,核心定 | 层级 | 技术 | |------|------| | 后端 | Go 1.26 + Gin + GORM + SQLite | -| 前端 | React 18 + TypeScript + Vite + Tailwind CSS + shadcn/ui | +| 前端 | React 19 + TypeScript + Vite + Tailwind CSS + shadcn/ui | | 认证 | JWT + LDAP 集成 | | 数据库 | SQLite(零依赖,开箱即用) | @@ -106,16 +106,11 @@ sql-platform/ git clone https://gitee.com/opsx-tools/opsxsql.git cd opsxsql -# 编译后端 -cd backend -go build -o sql-platform ./cmd/server/ - -# 启动(前后端一键启动) -cd .. -bash run.sh restart +# Docker 一键启动 +docker-compose up -d --build ``` -后端运行在 **http://localhost:9000**,前端运行在 **http://localhost:5173** +后端运行在 **http://localhost:9000**,前端运行在 **http://localhost/** ### 默认账号 diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index dce2447..0000000 --- a/ROADMAP.md +++ /dev/null @@ -1,64 +0,0 @@ -# SQL Audit Platform Roadmap - -## 当前版本: v0.8.1 - -## 已完成 - -### Phase 1-7 (v0.1.0 ~ v0.7.0) -- 基线版本 + 权限组 + 审批流 + 多数据库查询/执行引擎 -- 后端测试: 808/808 全部通过 -- 前端: 27 个页面, 构建零错误 - -### v0.8.1 — 技术债务清理 -- ✅ Pydantic V2 完整迁移: 13个schema + config.py, 消除14个deprecation警告 -- ✅ 前端TypeScript编译修复: SqlEditor SQLDialect, AuditLogsPage类型转换, QueriesPage未用import -- ✅ 后端测试: 843/843 通过, 0 warnings -- ✅ 前端构建: 零错误 - -### P0 — 数据库全景支持 ✅ -- ✅ 统一查询服务: POST /query 自动路由 MySQL/PostgreSQL/Redis/MongoDB -- ✅ query_service.py: 完整 4 种数据库 Handler (MySQL/PostgreSQL/Redis/MongoDB) -- ✅ Redis 只读命令白名单: 40+ 只读命令 + 子命令验证 (CONFIG GET/MEMORY USAGE/OBJECT) -- ✅ MongoDB 只读操作白名单: find/findOne/countDocuments/distinct/aggregate + $out/$merge 防护 -- ✅ 工单执行多数据库分发: ticket_executor.py 支持 MySQL/PostgreSQL/Redis/MongoDB 执行 -- ✅ MongoDB 连接器: mongo_connector.py 780 行, 完整 CRUD + 统计 + 安全验证 -- ✅ MongoDB 前端管理页面: MongoDBDetailPage.tsx (集合管理/文档浏览/查询控制台/统计信息) -- ✅ mongo API 模块: frontend/src/api/mongo.ts -- ✅ /mongo/:id 路由集成 + InstancePage 管理控制台跳转按钮 -- ✅ 数据源品牌图标: MySQL/PostgreSQL/Redis/MongoDB SVG logos - -### P1 — 审计闭环 (部分完成) -- ✅ 查询历史服务: query_history_service.py (记录/收藏/快捷查询) -- ✅ SQL 助手: sql_assistant.py (格式化/风险高亮/EXPLAIN 解析) -- ✅ 审计规则种子: MySQL 18规则 + PostgreSQL 12规则 + Redis 8规则 -- ✅ 审计日志全操作覆盖 -- ✅ 影响分析: impact_analyzer.py -- ✅ 回滚生成: rollback_generator.py -- ✅ Schema 漂移检测: schema_drift_detector.py - -### P2 — 高级功能 (部分完成) -- ✅ 数据脱敏: data_masker.py + masking_rule CRUD -- ✅ 通知服务: 钉钉/飞书/邮件 SMTP -- ✅ 备份管理: backup_executor.py -- ✅ 工作流引擎: workflow_engine.py + workflow_seeds.py -- ✅ 慢日志收集: slow_log_collector.py -- ✅ 连接池: connection_pool.py - -## 待完成 - -### P1 — 审计闭环 -- [ ] 执行前后数据快照对比可视化 (前端) -- [ ] 快捷查询模板 (10个预置 + 自定义共享) -- [ ] SQL 格式化前端集成 (CodeMirror / Monaco editor) -- [ ] 查询结果格式化展示优化 - -### P2 — 人性化设计 -- [ ] SQL 语法高亮编辑器 -- [ ] 数据库类型说明 API -- [ ] Slow query analysis 前端集成 -- [ ] Dashboard 数据源健康概览 - -### 运维 -- [ ] 远程 Git 仓库配置 -- [ ] CI/CD 完整流水线测试 -- [ ] 生产环境部署文档 diff --git a/TDD_PLAN.md b/TDD_PLAN.md deleted file mode 100644 index c33fa95..0000000 --- a/TDD_PLAN.md +++ /dev/null @@ -1,247 +0,0 @@ -# SQL-Platform 数据审核平台 - TDD 开发计划 - -> 创建时间: 2025-05-22 -> 测试框架: pytest (后端) + vitest (前端) -> 目标覆盖率: > 60% - ---- - -## 测试环境配置 - -### 后端 (pytest) - -``` -pip install pytest pytest-asyncio httpx aiomysql -``` - -**conftest.py 基础配置:** -- 使用 SQLite in-memory 数据库 -- FastAPI TestClient + async 测试 -- 自动创建/销毁表 -- 测试用户 fixture - -### 前端 (vitest) - -``` -npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom -``` - ---- - -## 测试计划 - -### Phase 1: 后端 API 测试 (P0 — 阻塞) - -> 目标:确保所有现有 API 行为正确,为前后端对接打基础 - -#### 1.1 认证模块 `tests/test_auth.py` - -| # | 测试用例 | 优先级 | 状态 | -|---|---------|--------|------| -|| 1 | 正确密码登录 → 200 + token + user | P0 | ✅ | -|| 2 | 错误密码登录 → 401 | P0 | ✅ | -|| 3 | 用户不存在 → 401 | P0 | ✅ | -|| 4 | 已禁用用户登录 → 403 | P0 | ✅ | -|| 5 | GET /me 无 token → 401 | P0 | ✅ | -|| 6 | GET /me 有效 token → 200 + user info | P0 | ✅ | -|| 7 | token 过期 → 401 | P1 | ✅ | -|| 8 | POST /logout → 200 | P1 | ✅ | - -#### 1.2 用户管理 `tests/test_users.py` - -| # | 测试用例 | 优先级 | 状态 | -|---|---------|--------|------| -| 1 | admin 列出用户 → 200 | P0 | ⬜ | -| 2 | non-admin 列出用户 → 403 | P0 | ⬜ | -| 3 | 创建用户(正常) → 201 | P0 | ⬜ | -| 4 | 创建用户(重复用户名) → 400 | P0 | ⬜ | -| 5 | 获取单个用户 → 200 | P1 | ⬜ | -| 6 | 更新用户信息 → 200 | P1 | ⬜ | -| 7 | 删除用户 → 200 | P1 | ⬜ | -| 8 | 删除自己 → 400 | P1 | ⬜ | - -#### 1.3 数据源管理 `tests/test_instances.py` - -| # | 测试用例 | 优先级 | 状态 | -|---|---------|--------|------| -|| 1 | 列出实例 → 200 | P0 | ✅ | -|| 2 | 创建实例 → 201 | P0 | ✅ | -|| 3 | 创建实例(non-admin) → 403 | P0 | ✅ | -|| 4 | 获取实例详情 → 200 | P1 | ✅ | -|| 5 | 更新实例 → 200 | P1 | ✅ | -|| 6 | 删除实例 → 200 | P1 | ✅ | -|| 7 | 测试连接(MySQL mock) → 测试结果 | P1 | ✅ | -|| 8 | 列出数据库 → 列表 | P2 | ⬜ | -|| 9 | 列出表 → 列表 | P2 | ⬜ | - -#### 1.4 工单管理 `tests/test_tickets.py` - -| # | 测试用例 | 优先级 | 状态 | -|---|---------|--------|------| -|| 1 | 列出工单(developer 只看自己的) → 过滤正确 | P0 | ✅ | -|| 2 | 列出工单(admin 看全部) → 全部 | P0 | ✅ | -|| 3 | 创建工单 → 201 | P0 | ✅ | -|| 4 | 创建工单(实例不存在) → 400 | P0 | ✅ | -|| 5 | 获取工单详情 → 200 | P1 | ✅ | -|| 6 | 获取别人工单(developer) → 403 | P1 | ✅ | -|| 7 | 审批工单(approve) → approved | P0 | ✅ | -|| 8 | 审批工单(reject) → rejected | P0 | ✅ | -|| 9 | 审批工单(non-dba) → 403 | P0 | ✅ | -|| 10 | 审批已执行工单 → 400 | P1 | ✅ | -|| 11 | 执行工单(已审批) → success | P0 | ✅ | -|| 12 | 执行工单(未审批) → 400 | P0 | ✅ | -|| 13 | 执行工单(执行失败) → failed | P1 | ⬜ | -|| 14 | 工单统计 → 正确数字 | P1 | ✅ | -|| 15 | 分页查询 → 正确分页 | P1 | ✅ | - -#### 1.5 查询模块 `tests/test_query.py` - -| # | 测试用例 | 优先级 | 状态 | -|---|---------|--------|------| -|| 1 | 执行 SELECT 查询 → 结果 | P0 | ✅ | -|| 2 | 执行 DROP → 拒绝 | P0 | ✅ | -|| 3 | 执行 DELETE → 拒绝 | P0 | ✅ | -|| 4 | 无 LIMIT 自动追加 → 限制行数 | P1 | ⬜ | -|| 5 | guest 无权查询 → 403 | P1 | ✅ | -|| 6 | 查询超时 → 错误 | P2 | ⬜ | -|| 7 | 审计日志记录 → 有记录 | P1 | ⬜ | - -#### 1.6 仪表盘 `tests/test_dashboard.py` - -| # | 测试用例 | 优先级 | 状态 | -|---|---------|--------|------| -| 1 | 获取统计数据 → 正确结构 | P1 | ⬜ | -| 2 | 未登录 → 401 | P1 | ⬜ | - ---- - -### Phase 2: 前端对接 (P0 — v0.2.0) ✅ 已完成 - -> 前后端对接已完成,所有页面已替换 mock 数据为真实 API 调用 - -#### 2.1 API 集成测试 - -| # | 测试场景 | 优先级 | 状态 | -|---|---------|--------|------| -|| 1 | 登录流程:输入→请求→token存储→跳转 | P0 | ✅ | -|| 2 | Dashboard 加载:请求stats→渲染卡片 | P0 | ✅ | -|| 3 | 工单列表:请求→筛选→分页 | P0 | ✅ | -|| 4 | 新建工单:表单→提交→刷新列表 | P0 | ✅ | -|| 5 | 实例管理:CRUD→列表刷新 | P1 | ✅ | -|| 6 | 401 自动跳转登录页 | P0 | ✅ | - -#### 2.2 组件测试 - -| # | 测试场景 | 优先级 | 状态 | -|---|---------|--------|------| -| 1 | LoginPage 渲染 + 表单验证 | P1 | ⬜ | -| 2 | Dashboard 统计卡片渲染 | P1 | ⬜ | -| 3 | TicketsPage 筛选功能 | P1 | ⬜ | -| 4 | RouteGuard 权限拦截 | P0 | ⬜ | - ---- - -### Phase 3: 新功能 TDD (P1 — v0.3.0) - -> 先写测试,再写代码 - -#### 3.1 Instance 密码加密 ✅ 已完成 - -| # | TDD 步骤 | 状态 | -|---|---------|------| -| 1 | 🔴 写测试:创建实例时密码加密存储 | ✅ test_crypto.py | -| 2 | 🔴 写测试:读取实例时密码解密返回 | ✅ test_crypto.py | -| 3 | 🟢 实现 AES-256-GCM 加密/解密工具 | ✅ app/core/crypto.py | -| 4 | 🟢 集成到 instance endpoint (create/update/decrypt) | ✅ | -| 5 | ✅ 9 个加密相关测试全部通过 | ✅ | - -#### 3.2 SQL 审核规则引擎 ✅ 已完成 - -| # | TDD 步骤 | 状态 | -|---|---------|------| -| 1 | 🔴 写测试:SELECT * 规则触发 info | ✅ | -| 2 | 🔴 写测试:无 WHERE 的 UPDATE/DELETE 规则触发 error | ✅ | -| 3 | 🔴 写测试:DROP TABLE/TRUNCATE 规则 | ✅ | -| 4 | 🔴 写测试:生产环境额外规则(ALTER TABLE 等) | ✅ | -| 5 | 🟢 实现 10 条规则引擎 | ✅ app/services/audit_engine.py | -| 6 | 🟢 集成到工单创建流程(自动审核+评分) | ✅ | -| 7 | ✅ 23 个审核引擎测试全部通过 | ✅ | - -#### 3.3 操作审计日志 ✅ 已完成 - -| # | 测试用例 | 状态 | -|---|---------|------| -| 1 | 审计日志列表 API (admin/dba) | ✅ | -| 2 | 审计日志列表 API (developer 禁止) | ✅ | -| 3 | 创建实例 → 审计日志 | ✅ | -| 4 | 创建用户 → 审计日志 | ✅ | -| 5 | 创建工单 → 审计日志(含审核评分) | ✅ | -| 6 | 审批工单 → 审计日志 | ✅ | -| 7 | 审计日志分页 | ✅ | -| 8 | 审计日志过滤 | ✅ | -| 9 | ✅ 9 个审计日志测试全部通过 | ✅ | - ---- - -### Phase 4: 安全测试 (P1) - -| # | 测试场景 | 状态 | -|---|---------|------| -| 1 | SQL 注入:`DROP TABLE users; --` 被拦截 | ⬜ | -| 2 | SQL 注入:`UNION SELECT * FROM users` 被拦截 | ⬜ | -| 3 | 越权:developer 不能审批工单 | ⬜ | -| 4 | 越权:developer 不能访问 admin API | ⬜ | -| 5 | Token 伪造 → 401 | ⬜ | -| 6 | 并发创建工单不会重复 | ⬜ | - ---- - -## 测试目录结构 - -``` -backend/ -├── tests/ -│ ├── conftest.py # fixtures: db, client, users, token, test_instance -│ ├── test_auth.py # 认证测试 (8) -│ ├── test_users.py # 用户管理测试 (8) -│ ├── test_instances.py # 数据源测试 (8) -│ ├── test_tickets.py # 工单测试 (11) -│ ├── test_query.py # 查询测试 (4) -│ ├── test_dashboard.py # 仪表盘测试 (2) -│ ├── test_crypto.py # 加密测试 (9) -│ ├── test_audit_engine.py # SQL审核引擎测试 (23) -│ ├── test_audit_logs.py # 审计日志测试 (9) -│ ├── test_sql_analyzer.py # SQL AST测试 (34) -│ └── test_audit_rules.py # 审核规则CRUD测试 (14) - -frontend/ -├── src/ -│ ├── __tests__/ -│ │ ├── setup.ts # vitest setup -│ │ ├── LoginPage.test.tsx -│ │ ├── Dashboard.test.tsx -│ │ ├── TicketsPage.test.tsx -│ │ └── RouteGuard.test.tsx -``` - ---- - -## 覆盖率目标 - -| 阶段 | 目标后端覆盖率 | 目标前端覆盖率 | -|------|--------------|--------------| -| Phase 1 完成 | 40% | 0% | -| Phase 2 完成 | 50% | 30% | -| Phase 3 完成 | 60% | 40% | -| Phase 4 完成 | 70% | 50% | - ---- - -## 执行优先级 - -1. ~~**立刻** — 创建 conftest.py + test_auth.py~~ ✅ -2. ~~**v0.2.0** — Phase 1 全部完成~~ ✅ -3. ~~**v0.2.1** — Phase 2 前后端对接~~ ✅ -4. ~~**v0.3.0** — Phase 3 密码加密 + SQL 审核引擎~~ ✅ (85 tests) -4. ~~**v0.4.0** — Phase 4 SQL AST + 规则管理 + Alembic + Docker~~ ✅ (128 tests) -5. **v0.5.0** — Phase 5 运维工具 + 真实DB连接 + CI/CD ✅ diff --git a/backend/Dockerfile b/backend/Dockerfile index 0bf5c1a..44026c6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,8 +10,8 @@ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y ca-certificates sqlite3 curl && apt-get clean && rm -rf /var/lib/apt/lists/* # Install gh-ost for online DDL RUN GH_OST_VERSION=1.1.9 && \ - curl -fsSL "https://github.com/github/gh-ost/releases/download/v${GH_OST_VERSION}/gh-ost-binary-linux-amd64-*.tar.gz" \ - | tar xz -C /usr/local/bin gh-ost && chmod +x /usr/local/bin/gh-ost + curl -fsSL -o /usr/local/bin/gh-ost "https://github.com/github/gh-ost/releases/download/v${GH_OST_VERSION}/gh-ost" && \ + chmod +x /usr/local/bin/gh-ost WORKDIR /app COPY --from=build /app/opsxsql . COPY --from=build /app/cmd/server/seed.go /app/ diff --git a/backend/internal/handler/connection_tester.go b/backend/internal/handler/connection_tester.go index 7d32cfb..5abb406 100644 --- a/backend/internal/handler/connection_tester.go +++ b/backend/internal/handler/connection_tester.go @@ -117,7 +117,7 @@ func testPostgreSQL(ctx context.Context, host string, port int, user, pass, dbna dbname = "postgres" } dsn := fmt.Sprintf("postgres://%s:***@%s:%d/%s?sslmode=disable&connect_timeout=10", - user, pass, host, port, dbname) + user, host, port, dbname) db, err := sql.Open("pgx", dsn) if err != nil { return false, fmt.Sprintf("Connection failed: %v", err) @@ -141,12 +141,12 @@ func testRedis(ctx context.Context, host string, port int, username, pass string redisUsername = "" } rdb := redis.NewClient(&redis.Options{ - Addr: addr, - Username: redisUsername, - Password: pass, - DB: 0, - DialTimeout: 10 * time.Second, - ReadTimeout: 10 * time.Second, + Addr: addr, + Username: redisUsername, + Password: pass, + DB: 0, + DialTimeout: 10 * time.Second, + ReadTimeout: 10 * time.Second, }) defer rdb.Close() @@ -541,7 +541,7 @@ func getDBConnection(instance *model.Instance, dbName string) (*sql.DB, error) { dbName = "postgres" } dsn := fmt.Sprintf("postgres://%s:***@%s:%d/%s?sslmode=disable&connect_timeout=10", - instance.Username, password, instance.Host, instance.Port, dbName) + instance.Username, instance.Host, instance.Port, dbName) return sql.Open("pgx", dsn) default: return nil, fmt.Errorf("unsupported database type: %s", instance.DBType) diff --git a/backend/internal/handler/instance.go b/backend/internal/handler/instance.go index 4740dff..77afd56 100644 --- a/backend/internal/handler/instance.go +++ b/backend/internal/handler/instance.go @@ -14,10 +14,10 @@ import ( // GET /api/v1/instances func ListInstances(c *gin.Context) { userID, _, role := middleware.GetCurrentUser(c) - + var instances []model.Instance query := model.DB.Order("id") - + // developer only sees instances in their groups if role == "developer" { var groupIDs []int @@ -33,12 +33,12 @@ func ListInstances(c *gin.Context) { } } } - + if err := query.Find(&instances).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) return } - + // mask passwords in response for i := range instances { instances[i].Password = "" @@ -86,8 +86,8 @@ func CreateInstance(c *gin.Context) { if result.Version != "" { updates["version"] = result.Version } - if result.ClusterMode { - updates["cluster_mode"] = true + if instance.DBType == "redis" { + updates["cluster_mode"] = result.ClusterMode } } else { updates["status"] = "error" @@ -108,13 +108,13 @@ func UpdateInstance(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) return } - + var input map[string]interface{} if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } - + // if password is empty or masked placeholder, don't update it if pw, ok := input["password"].(string); ok { pw = strings.TrimSpace(pw) @@ -128,12 +128,12 @@ func UpdateInstance(c *gin.Context) { } } } - + if err := model.DB.Model(&instance).Updates(input).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) return } - + model.DB.First(&instance, id) instance.Password = "******" c.JSON(http.StatusOK, instance) @@ -157,15 +157,15 @@ func TestConnection(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) return } - + result := testDBConnectionFull(&instance) // Update version & cluster_mode in DB if detected updates := map[string]interface{}{} if result.Success && result.Version != "" { updates["version"] = result.Version } - if result.ClusterMode { - updates["cluster_mode"] = true + if instance.DBType == "redis" { + updates["cluster_mode"] = result.ClusterMode } if len(updates) > 0 { model.DB.Model(&model.Instance{}).Where("id = ?", instance.ID).Updates(updates) @@ -181,16 +181,16 @@ func TestConnection(c *gin.Context) { func InstanceStats(c *gin.Context) { var total int64 model.DB.Model(&model.Instance{}).Count(&total) - + type typeCount struct { DBType string `json:"db_type"` Count int64 `json:"count"` } var byType []typeCount model.DB.Model(&model.Instance{}).Select("db_type, count(*) as count").Group("db_type").Scan(&byType) - + c.JSON(http.StatusOK, gin.H{ - "total": total, + "total": total, "by_type": byType, }) } diff --git a/backend/internal/handler/instance_detail.go b/backend/internal/handler/instance_detail.go index 9c1d364..06a38ec 100644 --- a/backend/internal/handler/instance_detail.go +++ b/backend/internal/handler/instance_detail.go @@ -7,20 +7,20 @@ import ( "sql-platform/internal/model" - "github.com/gin-gonic/gin" - "time" - "context" - "sql-platform/internal/middleware" - "strings" - "strconv" - "sort" - "encoding/json" "bytes" - "regexp" + "context" + "encoding/json" + "github.com/gin-gonic/gin" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" "go.mongodb.org/mongo-driver/v2/mongo/readpref" + "regexp" + "sort" + "sql-platform/internal/middleware" + "strconv" + "strings" + "time" ) // GET /api/v1/instances/:id/detail/databases @@ -523,7 +523,6 @@ func ExecuteQuery(c *gin.Context) { } // Read query limits from platform settings maxLimit := getSettingInt("query_limit_max", 1000) - autoLimit := getSetting("query_auto_limit", "true") == "true" if input.Limit <= 0 { input.Limit = maxLimit if input.Limit > 1000 { @@ -547,7 +546,6 @@ func ExecuteQuery(c *gin.Context) { } } } - _ = autoLimit var instance model.Instance if err := model.DB.First(&instance, input.InstanceID).Error; err != nil { @@ -619,6 +617,9 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, lim _ = ctx upperSQL := strings.ToUpper(strings.TrimSpace(sqlStr)) + if ok, reason := isReadOnlySQL(sqlStr); !ok { + return nil, fmt.Errorf("查询入口仅允许只读语句:%s。数据变更请提交工单", reason) + } // Count total (only when no user LIMIT and offset=0) total := 0 @@ -643,7 +644,8 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, lim // Inject LIMIT/OFFSET if not already present — only for SELECT statements // SHOW/DESC/EXPLAIN/CREATE/ALTER/DROP/etc do not support LIMIT syntax - needsLimit := !hasLimit && strings.HasPrefix(upperSQL, "SELECT") + autoLimit := getSetting("query_auto_limit", "true") == "true" + needsLimit := autoLimit && !hasLimit && isLimitableReadSQL(upperSQL) if needsLimit { sqlStr = fmt.Sprintf("%s LIMIT %d OFFSET %d", sqlStr, limit, offset) } @@ -689,6 +691,82 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, lim }, nil } +func isReadOnlySQL(sqlStr string) (bool, string) { + cleaned := strings.TrimSpace(stripLeadingSQLComments(sqlStr)) + if cleaned == "" { + return false, "空语句" + } + if hasMultipleSQLStatements(cleaned) { + return false, "不允许一次执行多条语句" + } + + upper := strings.ToUpper(cleaned) + readPrefixes := []string{"SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "WITH"} + allowed := false + for _, prefix := range readPrefixes { + if strings.HasPrefix(upper, prefix+" ") || upper == prefix { + allowed = true + break + } + } + if !allowed { + return false, "只允许 SELECT / SHOW / DESCRIBE / EXPLAIN" + } + + blockedPatterns := []string{ + " FOR UPDATE", " LOCK IN SHARE MODE", " INTO OUTFILE", " INTO DUMPFILE", + "EXPLAIN ANALYZE", "DELETE ", "UPDATE ", "INSERT ", "REPLACE ", + "CREATE ", "ALTER ", "DROP ", "TRUNCATE ", "RENAME ", "GRANT ", "REVOKE ", + "CALL ", "DO ", "LOAD DATA", "COPY ", + } + for _, pattern := range blockedPatterns { + if strings.Contains(upper, pattern) { + return false, strings.TrimSpace(pattern) + } + } + return true, "" +} + +func isLimitableReadSQL(upperSQL string) bool { + return strings.HasPrefix(upperSQL, "SELECT") || strings.HasPrefix(upperSQL, "WITH") +} + +func hasMultipleSQLStatements(sqlStr string) bool { + trimmed := strings.TrimSpace(sqlStr) + if strings.HasSuffix(trimmed, ";") { + trimmed = strings.TrimSpace(strings.TrimSuffix(trimmed, ";")) + } + return strings.Contains(trimmed, ";") +} + +func stripLeadingSQLComments(sqlStr string) string { + s := strings.TrimSpace(sqlStr) + for { + switch { + case strings.HasPrefix(s, "--"): + if idx := strings.Index(s, "\n"); idx >= 0 { + s = strings.TrimSpace(s[idx+1:]) + continue + } + return "" + case strings.HasPrefix(s, "#"): + if idx := strings.Index(s, "\n"); idx >= 0 { + s = strings.TrimSpace(s[idx+1:]) + continue + } + return "" + case strings.HasPrefix(s, "/*"): + if idx := strings.Index(s, "*/"); idx >= 0 { + s = strings.TrimSpace(s[idx+2:]) + continue + } + return s + default: + return s + } + } +} + // parseCommandParts splits a command string into parts, respecting quoted strings. // e.g. HGET "my key" "my field" => ["HGET", "my key", "my field"] func parseCommandParts(input string) []string { diff --git a/backend/internal/handler/redis.go b/backend/internal/handler/redis.go index dbcdf94..285f864 100644 --- a/backend/internal/handler/redis.go +++ b/backend/internal/handler/redis.go @@ -13,6 +13,7 @@ import ( "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" ) + // Helper: get Redis client from instance (auto-detect standalone vs cluster) type redisClientWrapper struct { Client *redis.Client @@ -21,7 +22,6 @@ type redisClientWrapper struct { } func (w *redisClientWrapper) Close() { - // Always close the single-node Client (cluster uses single-node now) if w.Client != nil { w.Client.Close() } @@ -30,19 +30,24 @@ func (w *redisClientWrapper) Close() { } } -// Ping, Info, DBSize, Scan, Type, TTL, Get, LRange, SMembers, ZRangeWithScores, HGetAll, -// SlowLogGet, ClientList, Do, Set, RPush, SAdd, HSet, Expire, Del — unified interface via wrapper methods -// NOTE: All methods use Client (single-node). isCluster flag is only for ScanAllKeys behavior. - func (w *redisClientWrapper) Ping(ctx context.Context) *redis.StatusCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.Ping(ctx) + } return w.Client.Ping(ctx) } func (w *redisClientWrapper) Info(ctx context.Context, sections ...string) *redis.StringCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.Info(ctx, sections...) + } return w.Client.Info(ctx, sections...) } func (w *redisClientWrapper) DBSize(ctx context.Context) *redis.IntCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.DBSize(ctx) + } return w.Client.DBSize(ctx) } @@ -50,9 +55,27 @@ func (w *redisClientWrapper) Scan(ctx context.Context, cursor uint64, match stri return w.Client.Scan(ctx, cursor, match, count) } -// ScanAllKeys scans keys on the connected node. -// For clusters, this only scans keys on the single connected node (not all masters). func (w *redisClientWrapper) ScanAllKeys(ctx context.Context, match string, count int) ([]string, error) { + if w.isCluster && w.ClusterClient != nil { + var allKeys []string + err := w.ClusterClient.ForEachMaster(ctx, func(ctx context.Context, shard *redis.Client) error { + cursor := uint64(0) + for { + keys, nextCursor, err := shard.Scan(ctx, cursor, match, int64(count)).Result() + if err != nil { + return err + } + allKeys = append(allKeys, keys...) + cursor = nextCursor + if cursor == 0 { + break + } + } + return nil + }) + return allKeys, err + } + var allKeys []string cursor := uint64(0) for { @@ -70,30 +93,51 @@ func (w *redisClientWrapper) ScanAllKeys(ctx context.Context, match string, coun } func (w *redisClientWrapper) Type(ctx context.Context, key string) *redis.StatusCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.Type(ctx, key) + } return w.Client.Type(ctx, key) } func (w *redisClientWrapper) TTL(ctx context.Context, key string) *redis.DurationCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.TTL(ctx, key) + } return w.Client.TTL(ctx, key) } func (w *redisClientWrapper) Get(ctx context.Context, key string) *redis.StringCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.Get(ctx, key) + } return w.Client.Get(ctx, key) } func (w *redisClientWrapper) LRange(ctx context.Context, key string, start, stop int64) *redis.StringSliceCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.LRange(ctx, key, start, stop) + } return w.Client.LRange(ctx, key, start, stop) } func (w *redisClientWrapper) SMembers(ctx context.Context, key string) *redis.StringSliceCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.SMembers(ctx, key) + } return w.Client.SMembers(ctx, key) } func (w *redisClientWrapper) ZRangeWithScores(ctx context.Context, key string, start, stop int64) *redis.ZSliceCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.ZRangeWithScores(ctx, key, start, stop) + } return w.Client.ZRangeWithScores(ctx, key, start, stop) } func (w *redisClientWrapper) HGetAll(ctx context.Context, key string) *redis.MapStringStringCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.HGetAll(ctx, key) + } return w.Client.HGetAll(ctx, key) } @@ -106,42 +150,72 @@ func (w *redisClientWrapper) ClientList(ctx context.Context) *redis.StringCmd { } func (w *redisClientWrapper) Do(ctx context.Context, args ...interface{}) *redis.Cmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.Do(ctx, args...) + } return w.Client.Do(ctx, args...) } func (w *redisClientWrapper) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *redis.StatusCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.Set(ctx, key, value, expiration) + } return w.Client.Set(ctx, key, value, expiration) } func (w *redisClientWrapper) RPush(ctx context.Context, key string, values ...interface{}) *redis.IntCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.RPush(ctx, key, values...) + } return w.Client.RPush(ctx, key, values...) } func (w *redisClientWrapper) SAdd(ctx context.Context, key string, members ...interface{}) *redis.IntCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.SAdd(ctx, key, members...) + } return w.Client.SAdd(ctx, key, members...) } func (w *redisClientWrapper) HSet(ctx context.Context, key string, values ...interface{}) *redis.IntCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.HSet(ctx, key, values...) + } return w.Client.HSet(ctx, key, values...) } func (w *redisClientWrapper) Expire(ctx context.Context, key string, expiration time.Duration) *redis.BoolCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.Expire(ctx, key, expiration) + } return w.Client.Expire(ctx, key, expiration) } func (w *redisClientWrapper) Del(ctx context.Context, keys ...string) *redis.IntCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.Del(ctx, keys...) + } return w.Client.Del(ctx, keys...) } func (w *redisClientWrapper) LLen(ctx context.Context, key string) *redis.IntCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.LLen(ctx, key) + } return w.Client.LLen(ctx, key) } func (w *redisClientWrapper) SCard(ctx context.Context, key string) *redis.IntCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.SCard(ctx, key) + } return w.Client.SCard(ctx, key) } func (w *redisClientWrapper) SScan(ctx context.Context, key string, cursor uint64, match string, count int64) *redis.ScanCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.SScan(ctx, key, cursor, match, count) + } return w.Client.SScan(ctx, key, cursor, match, count) } @@ -162,10 +236,6 @@ func getRedisClient(instance *model.Instance, db int) (*redisClientWrapper, erro redisUsername = "" // go-redis treats empty Username as no ACL auth } - // Always use single-node connection for query execution. - // ClusterClient does topology discovery (CLUSTER SLOTS) which fails when - // internal cluster IPs are unreachable from the client (e.g., Docker networks). - // Single-node mode works for all read-only commands on cluster nodes. client := redis.NewClient(&redis.Options{ Addr: addr, Username: redisUsername, @@ -180,7 +250,48 @@ func getRedisClient(instance *model.Instance, db int) (*redisClientWrapper, erro // Check cluster mode flag for wrapper behavior (ScanAllKeys needs to know) isCluster := instance.ClusterMode != nil && *instance.ClusterMode - return &redisClientWrapper{Client: client, isCluster: isCluster}, nil + wrapper := &redisClientWrapper{Client: client, isCluster: isCluster} + if !isCluster { + return wrapper, nil + } + + // Redis Cluster routes key commands by slot. Build the address list from + // CLUSTER SLOTS, but replace advertised internal node IPs with the configured + // host so Docker/NAT deployments can still reach the mapped node ports. + addrs := []string{addr} + if slots, err := client.ClusterSlots(ctx).Result(); err == nil && len(slots) > 0 { + seen := map[string]bool{} + addrs = addrs[:0] + for _, slot := range slots { + if len(slot.Nodes) == 0 { + continue + } + nodeAddr := slot.Nodes[0].Addr + if idx := strings.LastIndex(nodeAddr, ":"); idx >= 0 { + nodeAddr = instance.Host + nodeAddr[idx:] + } + if !seen[nodeAddr] { + seen[nodeAddr] = true + addrs = append(addrs, nodeAddr) + } + } + if len(addrs) == 0 { + addrs = []string{addr} + } + } + + clusterClient := redis.NewClusterClient(&redis.ClusterOptions{ + Addrs: addrs, + Username: redisUsername, + Password: password, + }) + if err := clusterClient.Ping(ctx).Err(); err != nil { + clusterClient.Close() + client.Close() + return nil, fmt.Errorf("Redis cluster connection failed: %v", err) + } + wrapper.ClusterClient = clusterClient + return wrapper, nil } // GET /api/v1/redis/:id/info @@ -265,11 +376,12 @@ func RedisDbsize(c *gin.Context) { func RedisScanKeys(c *gin.Context) { id := c.Param("id") dbParam := c.Query("db") - pattern := c.DefaultQuery("pattern", "*") - // Auto-append * if pattern has no wildcard (user-friendly fuzzy search) - if !strings.Contains(pattern, "*") && !strings.Contains(pattern, "?") && !strings.Contains(pattern, "[") { - pattern = pattern + "*" + rawPattern := strings.TrimSpace(c.DefaultQuery("pattern", "*")) + if rawPattern == "" { + rawPattern = "*" } + pattern := rawPattern + hasWildcard := strings.ContainsAny(pattern, "*?[") count, _ := strconv.Atoi(c.DefaultQuery("count", "100")) db := 0 @@ -291,11 +403,6 @@ func RedisScanKeys(c *gin.Context) { defer client.Close() ctx := context.Background() - allKeys, err := client.ScanAllKeys(ctx, pattern, count) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) - return - } // Get types for each key type KeyInfo struct { @@ -303,6 +410,37 @@ func RedisScanKeys(c *gin.Context) { Type string `json:"type"` TTL int64 `json:"ttl"` } + + // Redis Cluster 精确 key 查询应优先走槽位路由直查。全量 SCAN 需要访问各 master, + // 在 NAT/代理暴露单入口时容易受节点通告地址影响。 + if !hasWildcard && pattern != "*" { + keyType, err := client.Type(ctx, pattern).Result() + if err == nil && keyType != "none" { + ttl, _ := client.TTL(ctx, pattern).Result() + ttlMs := int64(-1) + if ttl > 0 { + ttlMs = ttl.Milliseconds() + } + c.JSON(http.StatusOK, gin.H{ + "keys": []KeyInfo{{Key: pattern, Type: keyType, TTL: ttlMs}}, + "cursor": 0, + "total": 1, + }) + return + } + } + + // Auto-append * if pattern has no wildcard (user-friendly fuzzy search) + if !hasWildcard { + pattern = pattern + "*" + } + + allKeys, err := client.ScanAllKeys(ctx, pattern, count) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + var keyInfos []KeyInfo for _, key := range allKeys { keyType, _ := client.Type(ctx, key).Result() @@ -498,7 +636,7 @@ func RedisExecuteCommand(c *gin.Context) { ctx := context.Background() cmd := strings.ToUpper(input.Command) - + // Safe read-only commands readOnly := map[string]bool{ "GET": true, "MGET": true, "HGET": true, "HGETALL": true, @@ -508,7 +646,7 @@ func RedisExecuteCommand(c *gin.Context) { "STRLEN": true, "LLEN": true, "SCARD": true, "ZCARD": true, "HLEN": true, "GETRANGE": true, } - + if !readOnly[cmd] { c.JSON(http.StatusForbidden, gin.H{"detail": "Only read-only commands allowed via query interface"}) return @@ -547,10 +685,10 @@ func RedisCreateKey(c *gin.Context) { } var input struct { - Key string `json:"key" binding:"required"` - Type string `json:"type" binding:"required"` - Value interface{} `json:"value"` - TTL int `json:"ttl"` + Key string `json:"key" binding:"required"` + Type string `json:"type" binding:"required"` + Value interface{} `json:"value"` + TTL int `json:"ttl"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) @@ -571,7 +709,7 @@ func RedisCreateKey(c *gin.Context) { defer client.Close() ctx := context.Background() - + switch input.Type { case "string": client.Set(ctx, input.Key, input.Value, 0) diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index f0a3122..24323df 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -10,6 +10,14 @@ import ( "sql-platform/internal/model" ) +func stringPtr(value string) *string { + return &value +} + +func boolPtr(value bool) *bool { + return &value +} + type AuthService struct { DB *gorm.DB } @@ -25,7 +33,7 @@ func (s *AuthService) Login(username, password string) (*model.User, string, err return nil, "", errors.New("invalid username or password") } - if !user.IsActive { + if user.IsActive != nil && !*user.IsActive { return nil, "", errors.New("account is disabled") } @@ -33,7 +41,7 @@ func (s *AuthService) Login(username, password string) (*model.User, string, err return nil, "", errors.New("invalid username or password") } - token, err := middleware.GenerateToken(user.ID, user.Username, user.Role) + token, err := middleware.GenerateToken(&user) if err != nil { return nil, "", errors.New("failed to generate token") } @@ -67,11 +75,11 @@ func (s *AuthService) Register(username, displayName, email, phone, password, ro user := model.User{ Username: username, DisplayName: displayName, - Email: email, - Phone: phone, + Email: stringPtr(email), + Phone: stringPtr(phone), HashedPassword: string(hashedPassword), Role: role, - IsActive: true, + IsActive: boolPtr(true), } if err := s.DB.Create(&user).Error; err != nil { diff --git a/backend/internal/service/ticket_service.go b/backend/internal/service/ticket_service.go index 10b2523..916e841 100644 --- a/backend/internal/service/ticket_service.go +++ b/backend/internal/service/ticket_service.go @@ -19,14 +19,16 @@ func NewTicketService(db *gorm.DB) *TicketService { // CreateTicket creates a new ticket func (s *TicketService) CreateTicket(ticket *model.Ticket) error { - if ticket.TicketNo == "" { - ticket.TicketNo = s.generateTicketNo() + if ticket.TicketNo == nil || *ticket.TicketNo == "" { + ticketNo := s.generateTicketNo() + ticket.TicketNo = &ticketNo } if ticket.CreateTime.IsZero() { ticket.CreateTime = time.Now() } - if ticket.Status == "" { - ticket.Status = "pending" + if ticket.Status == nil || *ticket.Status == "" { + status := "pending" + ticket.Status = &status } return s.DB.Create(ticket).Error } diff --git a/docker-compose.yml b/docker-compose.yml index 4852c16..84eb856 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: backend: build: ./backend diff --git a/frontend/index.html b/frontend/index.html index 0574ad8..87d105e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,5 +1,5 @@ - + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bb23587..fa8bfcf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { - "name": "my-app", - "version": "0.0.0", + "name": "opsxsql-frontend", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "my-app", - "version": "0.0.0", + "name": "opsxsql-frontend", + "version": "0.4.0", "dependencies": { "@codemirror/autocomplete": "^6.20.2", "@codemirror/commands": "^6.10.3", @@ -80,7 +80,6 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", - "kimi-plugin-inspect-react": "^1.0.3", "postcss": "^8.5.6", "tailwindcss": "^3.4.19", "tailwindcss-animate": "^1.0.7", @@ -176,19 +175,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-compilation-targets": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", @@ -206,63 +192,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.3.tgz", - "integrity": "sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.29.0", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", - "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "regexpu-core": "^6.3.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.8", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", - "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "debug": "^4.4.3", - "lodash.debounce": "^4.0.8", - "resolve": "^1.22.11" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -273,20 +202,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-module-imports": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", @@ -319,19 +234,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-plugin-utils": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", @@ -342,56 +244,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", - "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-wrap-function": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -422,21 +274,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-wrap-function": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", - "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helpers": { "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", @@ -467,946 +304,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", - "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", - "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", - "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.3.tgz", - "integrity": "sha512-SRS46DFR4HqzUzCVgi90/xMoL+zeBDBvWdKYXSEzh79kXswNFEglUpMKxR04//dPqwYXWUBJ3mpUd933ru9Kmg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", - "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.13.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", - "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-proposal-decorators": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.0.tgz", - "integrity": "sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-syntax-decorators": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-decorators": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.28.6.tgz", - "integrity": "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", - "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-unicode-sets-regex": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", - "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", - "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", - "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", - "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", - "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", - "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", - "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0" - } - }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", - "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", - "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/template": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", - "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", - "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", - "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", - "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-explicit-resource-management": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", - "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", - "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-for-of": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-function-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", - "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", - "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", - "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", - "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz", - "integrity": "sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", - "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-new-target": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", - "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", - "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", - "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", - "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-super": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", - "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", - "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", - "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", - "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", - "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", - "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", - "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", - "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", - "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-syntax-jsx": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-development": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", - "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/plugin-transform-react-jsx": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", @@ -1439,382 +336,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-react-pure-annotations": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", - "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", - "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", - "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", - "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", - "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", - "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", - "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", - "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", - "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", - "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", - "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", - "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", - "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/preset-env": { - "version": "7.29.5", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.5.tgz", - "integrity": "sha512-/69t2aEzGKHD76DyLbHysF/QH2LJOB8iFnYO37unDTKBTubzcMRv0f3H5EiN1Q6ajOd/eB7dAInF0qdFVS06kA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.3", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", - "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.3", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.28.6", - "@babel/plugin-syntax-import-attributes": "^7.28.6", - "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.29.0", - "@babel/plugin-transform-async-to-generator": "^7.28.6", - "@babel/plugin-transform-block-scoped-functions": "^7.27.1", - "@babel/plugin-transform-block-scoping": "^7.28.6", - "@babel/plugin-transform-class-properties": "^7.28.6", - "@babel/plugin-transform-class-static-block": "^7.28.6", - "@babel/plugin-transform-classes": "^7.28.6", - "@babel/plugin-transform-computed-properties": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-dotall-regex": "^7.28.6", - "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-dynamic-import": "^7.27.1", - "@babel/plugin-transform-explicit-resource-management": "^7.28.6", - "@babel/plugin-transform-exponentiation-operator": "^7.28.6", - "@babel/plugin-transform-export-namespace-from": "^7.27.1", - "@babel/plugin-transform-for-of": "^7.27.1", - "@babel/plugin-transform-function-name": "^7.27.1", - "@babel/plugin-transform-json-strings": "^7.28.6", - "@babel/plugin-transform-literals": "^7.27.1", - "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", - "@babel/plugin-transform-member-expression-literals": "^7.27.1", - "@babel/plugin-transform-modules-amd": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.28.6", - "@babel/plugin-transform-modules-systemjs": "^7.29.4", - "@babel/plugin-transform-modules-umd": "^7.27.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-new-target": "^7.27.1", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", - "@babel/plugin-transform-numeric-separator": "^7.28.6", - "@babel/plugin-transform-object-rest-spread": "^7.28.6", - "@babel/plugin-transform-object-super": "^7.27.1", - "@babel/plugin-transform-optional-catch-binding": "^7.28.6", - "@babel/plugin-transform-optional-chaining": "^7.28.6", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/plugin-transform-private-methods": "^7.28.6", - "@babel/plugin-transform-private-property-in-object": "^7.28.6", - "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.29.0", - "@babel/plugin-transform-regexp-modifiers": "^7.28.6", - "@babel/plugin-transform-reserved-words": "^7.27.1", - "@babel/plugin-transform-shorthand-properties": "^7.27.1", - "@babel/plugin-transform-spread": "^7.28.6", - "@babel/plugin-transform-sticky-regex": "^7.27.1", - "@babel/plugin-transform-template-literals": "^7.27.1", - "@babel/plugin-transform-typeof-symbol": "^7.27.1", - "@babel/plugin-transform-unicode-escapes": "^7.27.1", - "@babel/plugin-transform-unicode-property-regex": "^7.28.6", - "@babel/plugin-transform-unicode-regex": "^7.27.1", - "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", - "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.15", - "babel-plugin-polyfill-corejs3": "^0.14.0", - "babel-plugin-polyfill-regenerator": "^0.6.6", - "core-js-compat": "^3.48.0", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-modules": { - "version": "0.1.6-no-external-plugins", - "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", - "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/types": "^7.4.4", - "esutils": "^2.0.2" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/preset-react": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz", - "integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-transform-react-display-name": "^7.28.0", - "@babel/plugin-transform-react-jsx": "^7.27.1", - "@babel/plugin-transform-react-jsx-development": "^7.27.1", - "@babel/plugin-transform-react-pure-annotations": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-typescript": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", - "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/runtime": { "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", @@ -5425,48 +3946,6 @@ "proxy-from-env": "^2.1.0" } }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.17", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", - "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-define-polyfill-provider": "^0.6.8", - "semver": "^6.3.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", - "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.8", - "core-js-compat": "^3.48.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.8", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", - "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.8" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -5770,20 +4249,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/core-js-compat": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", - "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.6.tgz", @@ -7005,25 +5470,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/kimi-plugin-inspect-react": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/kimi-plugin-inspect-react/-/kimi-plugin-inspect-react-1.0.3.tgz", - "integrity": "sha512-dU0dYaFYvczJZWwsyAVQ2No2NTaycd93zk3zrbLj9IPwdZaLBK4d0cESz88lVju3rQuFiwRXYNDaCnbPxn4JcQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@babel/core": "^7.23.0", - "@babel/plugin-proposal-decorators": "^7.23.0", - "@babel/preset-env": "^7.23.0", - "@babel/preset-react": "^7.23.0", - "@babel/preset-typescript": "^7.23.0", - "@babel/types": "^7.23.0", - "magic-string": "^0.30.0" - }, - "peerDependencies": { - "vite": "^7.2.0" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -7080,13 +5526,6 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7125,16 +5564,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7955,64 +6384,6 @@ "decimal.js-light": "^2.4.1" } }, - "node_modules/regenerate": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "dev": true, - "license": "MIT" - }, - "node_modules/regenerate-unicode-properties": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", - "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "regenerate": "^1.4.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regexpu-core": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", - "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", - "dev": true, - "license": "MIT", - "dependencies": { - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.2", - "regjsgen": "^0.8.0", - "regjsparser": "^0.13.0", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.2.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/regjsparser": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", - "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "jsesc": "~3.1.0" - }, - "bin": { - "regjsparser": "bin/parser" - } - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8468,50 +6839,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", - "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", - "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", - "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1cda86f..97c35c9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { - "name": "my-app", + "name": "opsxsql-frontend", "private": true, - "version": "0.0.0", + "version": "0.4.0", "type": "module", "scripts": { "dev": "vite", @@ -82,7 +82,6 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", - "kimi-plugin-inspect-react": "^1.0.3", "postcss": "^8.5.6", "tailwindcss": "^3.4.19", "tailwindcss-animate": "^1.0.7", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fe6e344..278f917 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,31 +1,41 @@ -import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'; +import { lazy, Suspense } from 'react'; +import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom'; import { AuthProvider, useAuth } from '@/hooks/useAuth'; import { ThemeProvider } from '@/hooks/useTheme'; import Layout from '@/components/Layout'; import LoginPage from '@/pages/LoginPage'; -import Dashboard from '@/pages/Dashboard'; -import InstancePage from '@/pages/InstancePage'; -import TicketsPage from '@/pages/TicketsPage'; -import QueriesPage from '@/pages/QueriesPage'; -import RolesPage from '@/pages/RolesPage'; -import UsersPage from '@/pages/UsersPage'; -import AuditRulesPage from '@/pages/AuditRulesPage'; -import WorkflowPage from '@/pages/WorkflowPage'; -import AutoTaskPage from '@/pages/AutoTaskPage'; -import SettingsPage from '@/pages/SettingsPage'; -import AuditLogsPage from '@/pages/AuditLogsPage'; -import DataDictionaryPage from '@/pages/DataDictionaryPage'; -import InstanceDetailPage from '@/pages/InstanceDetailPage'; -import RedisDetailPage from '@/pages/RedisDetailPage'; -import MongoDBDetailPage from '@/pages/MongoDBDetailPage'; -import TicketCreatePage from '@/pages/TicketCreatePage'; -import TicketDetailPage from '@/pages/TicketDetailPage'; -import InstanceGroupPage from '@/pages/InstanceGroupPage'; import { ToastContainer } from '@/components/ToastContainer'; -// 路由守卫:未登录跳转登录页,无权限跳转仪表盘 +const Dashboard = lazy(() => import('@/pages/Dashboard')); +const InstancePage = lazy(() => import('@/pages/InstancePage')); +const TicketsPage = lazy(() => import('@/pages/TicketsPage')); +const QueriesPage = lazy(() => import('@/pages/QueriesPage')); +const RolesPage = lazy(() => import('@/pages/RolesPage')); +const UsersPage = lazy(() => import('@/pages/UsersPage')); +const AuditRulesPage = lazy(() => import('@/pages/AuditRulesPage')); +const WorkflowPage = lazy(() => import('@/pages/WorkflowPage')); +const AutoTaskPage = lazy(() => import('@/pages/AutoTaskPage')); +const SettingsPage = lazy(() => import('@/pages/SettingsPage')); +const AuditLogsPage = lazy(() => import('@/pages/AuditLogsPage')); +const DataDictionaryPage = lazy(() => import('@/pages/DataDictionaryPage')); +const InstanceDetailPage = lazy(() => import('@/pages/InstanceDetailPage')); +const RedisDetailPage = lazy(() => import('@/pages/RedisDetailPage')); +const MongoDBDetailPage = lazy(() => import('@/pages/MongoDBDetailPage')); +const TicketCreatePage = lazy(() => import('@/pages/TicketCreatePage')); +const TicketDetailPage = lazy(() => import('@/pages/TicketDetailPage')); +const InstanceGroupPage = lazy(() => import('@/pages/InstanceGroupPage')); + +function PageFallback() { + return ( +
+ 加载中... +
+ ); +} + function RouteGuard() { const { isLoggedIn, hasPermission } = useAuth(); + const location = useLocation(); const path = location.pathname.split('/')[1] || 'dashboard'; if (!isLoggedIn) { @@ -51,33 +61,35 @@ function LoginGuard() { function AppRoutes() { return ( - - } /> - }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + }> + + } /> + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + - - + + ); } @@ -94,4 +106,4 @@ function App() { ); } -export default App; \ No newline at end of file +export default App; diff --git a/frontend/src/components/EditInstanceDialog.tsx b/frontend/src/components/EditInstanceDialog.tsx index 867f4f8..420df95 100644 --- a/frontend/src/components/EditInstanceDialog.tsx +++ b/frontend/src/components/EditInstanceDialog.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect } from 'react'; -import { X, Loader2 } from 'lucide-react'; +import { X, Loader2, Zap, CheckCircle, XCircle } from 'lucide-react'; import { instanceApi } from '@/api/instance'; import { workflowApi } from '@/api/workflow'; @@ -21,6 +21,7 @@ interface InstanceData { excluded_databases?: string[] | null; mask_fields?: string | null; group_id?: number | null; + cluster_mode?: boolean; } interface WorkflowItem { @@ -49,6 +50,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m const [database, setDatabase] = useState(instance.database || ''); const [env, setEnv] = useState(instance.env || 'development'); const [accessMode, setAccessMode] = useState(instance.access_mode || 'readwrite'); + const [clusterMode, setClusterMode] = useState(instance.cluster_mode === true); const [workflowId, setWorkflowId] = useState(instance.workflow_id || null); const [workflows, setWorkflows] = useState([]); // excluded_databases: comma-separated input @@ -58,6 +60,8 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m // mask_fields: comma-separated field names const [maskFields, setMaskFields] = useState(instance.mask_fields || ''); const [submitting, setSubmitting] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string; cluster_mode?: boolean } | null>(null); const [error, setError] = useState(''); /* Sync when instance changes */ @@ -70,11 +74,13 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m setDatabase(instance.database || ''); setEnv(instance.env || 'development'); setAccessMode(instance.access_mode || 'readwrite'); + setClusterMode(instance.cluster_mode === true); setWorkflowId(instance.workflow_id || null); setExcludedDbs( Array.isArray(instance.excluded_databases) ? (instance.excluded_databases as string[]).join(', ') : (instance.excluded_databases || '') ); setMaskFields(instance.mask_fields || ''); + setTestResult(null); setError(''); }, [instance]); @@ -105,6 +111,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m database: database.trim() || undefined, env, access_mode: accessMode, + cluster_mode: instance.db_type === 'redis' ? clusterMode : undefined, workflow_id: workflowId, excluded_databases: parsedExcluded, mask_fields: maskFields.trim() || undefined, @@ -129,7 +136,35 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m } finally { setSubmitting(false); } - }, [name, host, port, username, password, database, env, accessMode, workflowId, excludedDbs, maskFields, instance.id, onSaved]); + }, [name, host, port, username, password, database, env, accessMode, clusterMode, workflowId, excludedDbs, maskFields, instance.id, instance.db_type, onSaved]); + + const handleTest = useCallback(async () => { + setTesting(true); + setTestResult(null); + setError(''); + try { + const payload = { + db_type: instance.db_type, + host: host.trim(), + port: Number(port), + username: username.trim() || undefined, + password: password || '', + database: database.trim() || undefined, + cluster_mode: instance.db_type === 'redis' ? clusterMode : undefined, + }; + const res = mode === 'edit' && !password + ? await instanceApi.testConnection(instance.id) + : await instanceApi.testNewConnection(payload); + setTestResult(res as { success: boolean; message: string; version?: string; cluster_mode?: boolean }); + } catch (e: unknown) { + const msg = (e as { response?: { data?: { detail?: string; message?: string } } })?.response?.data?.detail + || (e as { response?: { data?: { message?: string } } })?.response?.data?.message + || '连接失败'; + setTestResult({ success: false, message: msg }); + } finally { + setTesting(false); + } + }, [instance.db_type, instance.id, host, port, username, password, database, clusterMode, mode]); if (!open) return null; @@ -144,7 +179,10 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m > {/* Header */}
-

{mode === 'clone' ? '克隆数据源' : '编辑数据源'}

+
+

{mode === 'clone' ? '克隆数据源' : '编辑数据源'}

+

{instance.db_type.toUpperCase()} · {instance.host}:{instance.port}

+
@@ -186,9 +224,35 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
- setDatabase(e.target.value)} placeholder="可选" /> + setDatabase(e.target.value)} placeholder={instance.db_type === 'redis' ? 'Redis 数据库编号,默认 0' : '可选'} />
+ {instance.db_type === 'redis' && ( +
+ +
+ {[ + { value: false, label: '单节点', desc: '普通 Redis 或哨兵入口' }, + { value: true, label: '集群', desc: 'Redis Cluster,按 slot 路由' }, + ].map(item => ( + + ))} +
+
+ )} +
@@ -220,6 +284,13 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m {workflows.length === 0 &&

暂无可用审批流程

}
+ {testResult && ( +
+ {testResult.success ? : } + {testResult.success ? `连接成功${testResult.version ? ` · ${testResult.version}` : ''}${instance.db_type === 'redis' ? ` · ${testResult.cluster_mode ? '集群' : '单节点'}` : ''}` : testResult.message} +
+ )} + {/* 排除数据库 */}
@@ -245,6 +316,14 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m {/* Footer */}
+
+
+
+
规则总数
+
{ruleStats.total}
+
+
+
已启用
+
{ruleStats.enabled}
+
+
+
错误级别
+
{ruleStats.error}
+
+
+
警告级别
+
{ruleStats.warning}
+
+
+ {/* DB Type Tabs */}
{DB_TABS.map((tab) => { diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 061d130..3a7ea96 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -11,6 +11,7 @@ import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/constants'; import { useTheme } from '@/hooks/useTheme'; import { useAuth } from '@/hooks/useAuth'; import { dashboardApi } from '@/api/dashboard'; +import request from '@/api/request'; import type { DashboardStats } from '@/types'; /* ------------------------------------------------------------------ */ @@ -83,7 +84,7 @@ export default function Dashboard() { const [queryStats, setQueryStats] = useState(null); useEffect(() => { - import('@/api/request').then(m => m.default.get('/announcements')).then(res => { + request.get('/announcements').then(res => { const list = Array.isArray(res) ? res : []; setAnnouncements(list.map((a: any) => ({ id: String(a.id), @@ -114,22 +115,20 @@ export default function Dashboard() { /* ---- Instances & Tickets ---- */ useEffect(() => { - import('@/api/request').then(m => { - m.default.get('/instances').then((res: any) => { - const list = Array.isArray(res) ? res : (res?.items || res?.data || []); - setDbInstances(list.map((i: any) => ({ - id: i.id, name: i.name, dbType: i.db_type || i.dbType, - status: i.status || 'online', - healthScore: i.health_score ?? i.healthScore ?? 100, - host: i.host || '', port: i.port || '', - qps: i.qps ?? 0, connections: i.connections ?? 0, - }))); - }).catch(() => setDbInstances([])); - m.default.get('/dashboard/my-tickets').then((res: any) => { - const list = Array.isArray(res) ? res : []; - setTickets(list); - }).catch(() => setTickets([])); - }); + request.get('/instances').then((res: any) => { + const list = Array.isArray(res) ? res : (res?.items || res?.data || []); + setDbInstances(list.map((i: any) => ({ + id: i.id, name: i.name, dbType: i.db_type || i.dbType, + status: i.status || 'online', + healthScore: i.health_score ?? i.healthScore ?? 100, + host: i.host || '', port: i.port || '', + qps: i.qps ?? 0, connections: i.connections ?? 0, + }))); + }).catch(() => setDbInstances([])); + request.get('/dashboard/my-tickets').then((res: any) => { + const list = Array.isArray(res) ? res : []; + setTickets(list); + }).catch(() => setTickets([])); }, []); /* ---- Announcements ---- */ @@ -245,21 +244,20 @@ export default function Dashboard() { )} {/* ---- Stats Cards ---- */} -
+
{loading ? ( Array.from({ length: 3 }).map((_, i) => ) ) : ( statsCards.map((s, i) => ( - -
- + +
{s.title}
-
{s.value}
+
{s.value}
{s.subtitle}
diff --git a/frontend/src/pages/InstancePage.tsx b/frontend/src/pages/InstancePage.tsx index 0c8e14d..a698244 100644 --- a/frontend/src/pages/InstancePage.tsx +++ b/frontend/src/pages/InstancePage.tsx @@ -5,6 +5,7 @@ import { XCircle, Eye, EyeOff, ChevronLeft, Gauge, Wifi, WifiOff, AlertTriangle, CheckCircle, Signal, LayoutGrid, ListFilter, X, Loader2, BarChart3, Users, Copy, + Upload, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useActionPermission } from '@/hooks/useAuth'; @@ -141,6 +142,7 @@ export default function InstancePage() { const [instances, setInstances] = useState([]); const [loading, setLoading] = useState(false); const [showCreate, setShowCreate] = useState(false); + const [showQuickImport, setShowQuickImport] = useState(false); const [showEdit, setShowEdit] = useState(false); const [editingInstance, setEditingInstance] = useState(null); const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -316,6 +318,18 @@ export default function InstancePage() { action={canAdd ? { label: '添加实例', icon: Plus, onClick: () => setShowCreate(true), permission: 'instance.add' } : undefined} /> + {canAdd && ( +
+ +
+ )} + {/* Type Tabs */}
@@ -717,10 +731,161 @@ export default function InstancePage() { mode="clone" /> )} + {showQuickImport && ( + setShowQuickImport(false)} + onImported={() => { setShowQuickImport(false); fetchInstances(); }} + /> + )}
); } +/* ------------------------------------------------------------------ */ +/* Quick Import Dialog */ +/* ------------------------------------------------------------------ */ +type ImportRow = { + name: string; + db_type: string; + host: string; + port: number; + username?: string; + password?: string; + database?: string; + env?: string; + access_mode?: string; + cluster_mode?: boolean; +}; + +const IMPORT_TEMPLATE = `name,db_type,host,port,username,password,database,env,access_mode,cluster_mode +prod-mysql,mysql,10.0.0.10,3306,root,secret,app,production,readwrite,false +prod-pg,postgres,10.0.0.11,5432,postgres,secret,app,production,read,false +prod-redis,redis,10.0.0.12,6379,,secret,,production,read,true +prod-mongo,mongo,10.0.0.13,27017,root,secret,admin,production,readwrite,false`; + +function parseQuickImport(text: string): ImportRow[] { + const lines = text.split(/\r?\n/).map(line => line.trim()).filter(Boolean); + if (lines.length === 0) return []; + const hasHeader = lines[0].toLowerCase().startsWith('name,'); + const dataLines = hasHeader ? lines.slice(1) : lines; + return dataLines.map(line => { + const parts = line.split(',').map(item => item.trim()); + const [name, dbType, host, port, username, password, database, env, accessMode, clusterMode] = parts; + return { + name, + db_type: dbType, + host, + port: Number(port || DB_DEFAULT_PORTS[dbType] || 3306), + username: username || undefined, + password: password || undefined, + database: database || undefined, + env: env || 'development', + access_mode: accessMode || 'readwrite', + cluster_mode: clusterMode === 'true' || clusterMode === '1' || clusterMode === 'yes', + }; + }); +} + +function QuickImportDialog({ onClose, onImported }: { onClose: () => void; onImported: () => void }) { + const [text, setText] = useState(IMPORT_TEMPLATE); + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState<{ success: number; failed: string[] } | null>(null); + const rows = useMemo(() => parseQuickImport(text), [text]); + const validRows = rows.filter(row => row.name && row.db_type && row.host && row.port); + + const handleImport = useCallback(async () => { + setSubmitting(true); + const failed: string[] = []; + let success = 0; + for (const row of validRows) { + try { + await instanceApi.create({ + ...row, + username: row.db_type === 'redis' ? row.username : (row.username || 'default'), + password: row.password || '', + cluster_mode: row.db_type === 'redis' ? row.cluster_mode : undefined, + }); + success += 1; + } catch (e: unknown) { + const detail = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '创建失败'; + failed.push(`${row.name || row.host}: ${detail}`); + } + } + setResult({ success, failed }); + setSubmitting(false); + if (success > 0 && failed.length === 0) onImported(); + }, [validRows, onImported]); + + return ( + +
+
+
+

快速导入数据源

+

支持 MySQL、PostgreSQL、Redis、MongoDB。每行一个实例,逗号分隔。

+
+ +
+
+