From 7ac4b99a9edc81132b871488a4070f9d029cebe7 Mon Sep 17 00:00:00 2001 From: bourdon Date: Sun, 12 Oct 2025 13:06:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20WebUI=20=E9=87=8D=E6=9E=84=E4=B8=8E?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- webui/README.md | 346 +++++++++++ webui/static/css/styles.css | 74 ++- webui/static/index.html | 59 +- webui/static/js/common/api.js | 174 ------ webui/static/js/common/shared-styles.js | 89 --- webui/static/js/components/App.js | 148 +++++ webui/static/js/components/ColumnSelector.js | 186 ++++++ webui/static/js/components/CompactionStats.js | 52 ++ webui/static/js/components/DataTable.js | 203 +++++++ webui/static/js/components/FieldList.js | 105 ++++ webui/static/js/components/FileCard.js | 90 +++ webui/static/js/components/LevelCard.js | 139 +++++ webui/static/js/components/ManifestModal.js | 118 ++++ webui/static/js/components/ManifestView.js | 102 ++++ webui/static/js/components/PageJumper.js | 71 +++ webui/static/js/components/Pagination.js | 122 ++++ webui/static/js/components/RowDetailModal.js | 224 +++++++ webui/static/js/components/Sidebar.js | 31 + webui/static/js/components/StatCard.js | 33 ++ webui/static/js/components/TableCell.js | 72 +++ webui/static/js/components/TableItem.js | 130 +++++ webui/static/js/components/TableRow.js | 68 +++ webui/static/js/components/TableView.js | 183 ++++++ webui/static/js/components/app.js | 145 ----- webui/static/js/components/badge.js | 106 ---- webui/static/js/components/data-view.js | 549 ------------------ webui/static/js/components/field-icon.js | 58 -- webui/static/js/components/manifest-view.js | 320 ---------- webui/static/js/components/modal-dialog.js | 179 ------ webui/static/js/components/page-header.js | 267 --------- webui/static/js/components/table-list.js | 283 --------- webui/static/js/components/table-view.js | 376 ------------ webui/static/js/components/theme-toggle.js | 123 ---- webui/static/js/hooks/useCellPopover.js | 155 +++++ webui/static/js/hooks/useTooltip.js | 76 +++ webui/static/js/main.js | 112 +--- webui/webui.go | 85 +-- 37 files changed, 2779 insertions(+), 2874 deletions(-) create mode 100644 webui/README.md delete mode 100644 webui/static/js/common/api.js delete mode 100644 webui/static/js/common/shared-styles.js create mode 100644 webui/static/js/components/App.js create mode 100644 webui/static/js/components/ColumnSelector.js create mode 100644 webui/static/js/components/CompactionStats.js create mode 100644 webui/static/js/components/DataTable.js create mode 100644 webui/static/js/components/FieldList.js create mode 100644 webui/static/js/components/FileCard.js create mode 100644 webui/static/js/components/LevelCard.js create mode 100644 webui/static/js/components/ManifestModal.js create mode 100644 webui/static/js/components/ManifestView.js create mode 100644 webui/static/js/components/PageJumper.js create mode 100644 webui/static/js/components/Pagination.js create mode 100644 webui/static/js/components/RowDetailModal.js create mode 100644 webui/static/js/components/Sidebar.js create mode 100644 webui/static/js/components/StatCard.js create mode 100644 webui/static/js/components/TableCell.js create mode 100644 webui/static/js/components/TableItem.js create mode 100644 webui/static/js/components/TableRow.js create mode 100644 webui/static/js/components/TableView.js delete mode 100644 webui/static/js/components/app.js delete mode 100644 webui/static/js/components/badge.js delete mode 100644 webui/static/js/components/data-view.js delete mode 100644 webui/static/js/components/field-icon.js delete mode 100644 webui/static/js/components/manifest-view.js delete mode 100644 webui/static/js/components/modal-dialog.js delete mode 100644 webui/static/js/components/page-header.js delete mode 100644 webui/static/js/components/table-list.js delete mode 100644 webui/static/js/components/table-view.js delete mode 100644 webui/static/js/components/theme-toggle.js create mode 100644 webui/static/js/hooks/useCellPopover.js create mode 100644 webui/static/js/hooks/useTooltip.js diff --git a/webui/README.md b/webui/README.md new file mode 100644 index 0000000..edecafb --- /dev/null +++ b/webui/README.md @@ -0,0 +1,346 @@ +# SRDB WebUI + +基于 Preact.js 的 SRDB WebUI,使用无构建工具工作流(No Build Workflow)。 + +## 技术栈 + +- **Preact 10.19.3** - 轻量级 React 替代品(3KB) +- **HTM 3.1.1** - JSX 替代方案,直接在浏览器中使用 +- **dom-align** - 智能元素定位(用于 Popover 和 Tooltip) +- **ESM.sh** - ES Modules CDN + +## 特性 + +✅ **零构建工具** - 无需 Webpack、Vite 等构建工具 +✅ **原生 ES Modules** - 直接使用 `import` 语法 +✅ **HTM 语法** - 使用 `html` 标签模板字符串代替 JSX +✅ **主题切换** - 支持深色/浅色主题,持久化保存 +✅ **智能 Popover** - 鼠标悬停单元格显示完整数据 +✅ **字段 Tooltip** - 悬停表头显示字段注释,悬停表名显示表注释 +✅ **响应式设计** - 适配不同屏幕尺寸 +✅ **数据分页** - 支持自定义每页显示行数(20/50/100/200)+ 跳页功能 +✅ **列选择器** - 下拉菜单选择要显示的列,支持持久化保存 +✅ **详情模态框** - 点击眼睛图标查看单条记录的完整数据 +✅ **Manifest 弹窗** - 点击按钮打开弹窗,实时显示 LSM-Tree 结构和 Compaction 统计 +✅ **索引标识** - 有索引的字段显示 🔍 图标 +✅ **悬浮加载提示** - 加载时顶部显示悬浮提示,不影响布局 + +## 目录结构 + +``` +webui/ +├── static/ +│ ├── index.html # 主 HTML 文件 +│ ├── css/ +│ │ └── styles.css # 全局样式(支持主题) +│ │ +│ └── js/ +│ ├── main.js # 应用入口 +│ ├── hooks/ # 自定义 Hooks +│ │ ├── useCellPopover.js # 单元格 Popover Hook +│ │ └── useTooltip.js # Tooltip Hook +│ │ +│ └── components/ # Preact 组件 +│ ├── App.js # 主应用组件 +│ ├── Sidebar.js # 侧边栏(表列表) +│ ├── TableItem.js # 表列表项(可展开字段) +│ ├── FieldList.js # 字段列表 +│ ├── TableView.js # 表视图(主容器) +│ ├── ColumnSelector.js # 列选择器(下拉菜单) +│ ├── DataTable.js # 数据表格 +│ ├── TableRow.js # 表格行 +│ ├── TableCell.js # 表格单元格 +│ ├── Pagination.js # 分页器(sticky 底部) +│ ├── PageJumper.js # 跳页输入框 +│ ├── RowDetailModal.js # 记录详情模态框 +│ ├── ManifestModal.js # Manifest 弹窗 +│ ├── ManifestView.js # Manifest 视图 +│ ├── LevelCard.js # 层级卡片 +│ ├── FileCard.js # 文件卡片 +│ ├── StatCard.js # 统计卡片 +│ └── CompactionStats.js # Compaction 统计 +│ +├── webui.go # Go 后端处理器 +└── README.md # 本文件 +``` + +## 快速开始 + +### 1. 启动后端服务 + +```bash +cd /path/to/srdb/examples/webui +go run main.go serve --db ./data --addr :8080 +``` + +### 2. 访问 WebUI + +浏览器打开:`http://localhost:8080/` + +## 使用技巧 + +1. **查看字段说明**:鼠标悬停在表头字段名上,会显示该字段的 comment +2. **查看表说明**:鼠标悬停在表名上,会显示该表的 comment +3. **选择显示列**:点击右上角的 "Columns" 按钮,勾选要显示的列,选择会自动保存 +4. **查看完整数据**:鼠标悬停在表格单元格上,会显示该单元格的完整数据 +5. **查看记录详情**:鼠标悬停在行上,点击出现的眼睛图标查看完整记录 +6. **跳转到指定页**:在分页器输入页码,点击"跳转"按钮 +7. **查看 LSM-Tree 结构**:点击右上角的 "📊 Manifest" 按钮打开弹窗 +8. **展开表字段**:在侧边栏点击表名前的 ▶ 图标展开查看所有字段 +9. **识别索引字段**:带有 🔍 图标的字段表示该字段已建立索引 +10. **主题切换**:点击左上角的 ☀️/🌙 图标切换深色/浅色主题 + +## 核心组件说明 + +### App.js - 主应用 +负责: +- 主题管理(深色/浅色切换,LocalStorage 持久化) +- 表列表加载和选择 +- 侧边栏 + 主内容区布局 + +### Sidebar.js - 侧边栏 +特性: +- 可展开的表列表(点击 ▶ 展开字段) +- 表统计信息(字段数) +- 字段列表显示(字段名、类型、索引图标) +- 选中状态高亮 + +### TableView.js - 表视图容器 +包含: +- 表名标题(悬停显示表注释) +- 行数统计(格式化显示 K/M) +- Manifest 按钮(打开弹窗) +- 列选择器按钮 +- DataTable 组件 +- ManifestModal 组件 + +### ColumnSelector.js - 列选择器 +特性: +- 下拉菜单式选择器 +- 多选复选框 +- 显示字段类型和索引图标 +- LocalStorage 持久化保存 +- 选中数量徽章显示 + +### DataTable.js - 数据表格 +功能: +- 响应式表格渲染 +- 表头 Tooltip(显示字段注释) +- 单元格 Popover(悬停显示完整数据) +- 时间格式化(`_time` 字段) +- 悬浮加载提示(顶部居中) +- Sticky 分页器(粘在底部) + +### TableRow.js - 表格行 +特性: +- 悬停高亮效果 +- 操作列眼睛图标(仅悬停时显示,使用 opacity 动画) +- 点击查看详情 + +### Pagination.js - 分页组件 +功能: +- 上一页/下一页导航 +- 每页行数选择(20/50/100/200) +- 跳页功能(PageJumper) +- Sticky 定位在底部 +- 显示当前页范围和总数 + +### RowDetailModal.js - 记录详情 +特性: +- 全屏模态框 +- 显示单条记录的所有字段 +- 系统字段标记(`_seq`, `_time`) +- JSON 格式化显示 +- ESC 键和点击遮罩层关闭 + +### ManifestModal.js - Manifest 弹窗 +特性: +- 90vw 宽度弹窗(最大 1200px) +- 包装 ManifestView 组件 +- ESC 键和点击外部关闭 +- 阻止背景滚动 + +### ManifestView.js - Manifest 视图 +展示: +- 统计卡片(总文件数、总大小、下一个文件号、最后序列号) +- 各层级详情(L0-L6+) + - 层级标识(彩色徽章) + - 文件数和总大小 + - Compaction Score(带进度条) + - 文件列表(文件号、大小、行数、Key 范围) +- Compaction 统计(合并次数、读写字节等) +- 只显示有文件的层级 + +## 自定义 Hooks + +### useCellPopover.js +用于单元格数据预览的 Popover: +- 使用 dom-align 智能定位 +- 自动处理溢出和边界 +- 延迟隐藏避免闪烁 +- 支持 JSON 格式化显示 + +### useTooltip.js +用于表头和表名的 Tooltip: +- 显示字段 comment 或表 comment +- 使用 dom-align 定位在元素下方 +- 无延迟即时显示 +- 最大宽度 300px,自动换行 + +## Preact + HTM 语法示例 + +### 基本组件 + +```javascript +import { html } from 'htm/preact'; + +export function MyComponent({ name }) { + return html` +
+

Hello, ${name}!

+
+ `; +} +``` + +### 使用 Hooks + +```javascript +import { html } from 'htm/preact'; +import { useState, useEffect } from 'preact/hooks'; + +export function Counter() { + const [count, setCount] = useState(0); + + useEffect(() => { + console.log('Count changed:', count); + }, [count]); + + return html` +
+

Count: ${count}

+ +
+ `; +} +``` + +### 条件渲染 + +```javascript +${loading + ? html`
Loading...
` + : html`
${data}
` +} +``` + +### 列表渲染 + +```javascript +${items.map(item => html` +
${item.name}
+`)} +``` + +## 主题切换 + +主题通过修改 `` 元素的 `data-theme` 属性实现: + +```javascript +// 切换到浅色主题 +document.documentElement.setAttribute('data-theme', 'light'); + +// 切换回深色主题 +document.documentElement.removeAttribute('data-theme'); +``` + +CSS 变量会根据主题自动切换: + +```css +:root { + --bg-main: #0f0f1a; /* 深色主题 */ +} + +:root[data-theme="light"] { + --bg-main: #f5f5f7; /* 浅色主题 */ +} +``` + +## API 接口 + +WebUI v2 使用的 API 端点: + +- `GET /api/tables` - 获取表列表(含行数统计) +- `GET /api/tables/:name/schema` - 获取表 Schema +- `GET /api/tables/:name/data?limit=100&offset=0` - 获取表数据(支持分页) +- `GET /api/tables/:name/data/:seq` - 获取单条记录详情(完整数据) +- `GET /api/tables/:name/manifest` - 获取 Manifest 信息(LSM-Tree 结构) + +## 开发建议 + +### 添加新组件 + +1. 在 `static/js/components/` 创建新文件 +2. 使用 `html` 标签模板语法 +3. 导出函数组件 + +```javascript +import { html } from 'htm/preact'; + +export function NewComponent({ prop1, prop2 }) { + return html` +
Your content here
+ `; +} +``` + +### 内联样式 vs CSS + +推荐使用内联样式(通过 `styles` 对象)以获得更好的组件隔离性和主题支持: + +```javascript +const styles = { + container: { + padding: '20px', + background: 'var(--bg-elevated)', + color: 'var(--text-primary)' + } +}; + +return html`
Content
`; +``` + +### 状态管理 + +使用 `useState` 和 `useEffect` 管理组件状态: + +```javascript +import { useState, useEffect } from 'preact/hooks'; + +// 本地状态 +const [count, setCount] = useState(0); + +// 副作用处理 +useEffect(() => { + // 组件挂载时执行 + console.log('Component mounted'); + + // 清理函数 + return () => { + console.log('Component unmounted'); + }; +}, []); // 空依赖数组表示只在挂载/卸载时执行 +``` + +## 参考资料 + +- [Preact 官方文档](https://preactjs.com/) +- [HTM 文档](https://github.com/developit/htm) +- [dom-align 文档](https://github.com/yiminghe/dom-align) +- [ESM.sh CDN](https://esm.sh/) + +## License + +MIT diff --git a/webui/static/css/styles.css b/webui/static/css/styles.css index a0020f2..e89c202 100644 --- a/webui/static/css/styles.css +++ b/webui/static/css/styles.css @@ -1,4 +1,4 @@ -/* SRDB WebUI - Modern Design with Lit */ +/* SRDB WebUI v2 - Preact Edition */ :root { /* 主色调 - 优雅的紫蓝色 */ @@ -30,12 +30,9 @@ /* 阴影 */ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); - --shadow-md: - 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3); - --shadow-lg: - 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3); - --shadow-xl: - 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.3); /* 圆角 */ --radius-sm: 6px; @@ -66,12 +63,9 @@ /* 阴影 - 浅色主题 */ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - --shadow-md: - 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); - --shadow-lg: - 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); - --shadow-xl: - 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); } * { @@ -80,19 +74,8 @@ padding: 0; } -:root { - /* 主题过渡动画 */ - transition: background-color 0.3s ease, color 0.3s ease; -} - body { - font-family: - "Inter", - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Roboto, - sans-serif; + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg-main); color: var(--text-primary); line-height: 1.6; @@ -105,3 +88,44 @@ body { overflow: hidden; transition: background-color 0.3s ease, color 0.3s ease; } + +#app { + height: 100vh; + display: flex; + flex-direction: column; +} + +/* 自定义滚动条 */ +*::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +*::-webkit-scrollbar-thumb:hover { + background: var(--border-hover); +} + +/* Loading 状态 */ +.loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-secondary); +} + +/* Empty 状态 */ +.empty { + text-align: center; + padding: 60px 20px; + color: var(--text-secondary); +} diff --git a/webui/static/index.html b/webui/static/index.html index e64ef7f..b9ee519 100644 --- a/webui/static/index.html +++ b/webui/static/index.html @@ -1,37 +1,30 @@ - + - - - - SRDB Web UI - - - - - - - - - - - + } + + + +
- - - - - - + + + diff --git a/webui/static/js/common/api.js b/webui/static/js/common/api.js deleted file mode 100644 index 56050c8..0000000 --- a/webui/static/js/common/api.js +++ /dev/null @@ -1,174 +0,0 @@ -/** - * API 请求管理模块 - * 统一管理所有后端接口请求 - */ - -const API_BASE = '/api'; - -/** - * 通用请求处理函数 - * @param {string} url - 请求 URL - * @param {RequestInit} options - fetch 选项 - * @returns {Promise} - */ -async function request(url, options = {}) { - try { - const response = await fetch(url, { - headers: { - 'Content-Type': 'application/json', - ...options.headers, - }, - ...options, - }); - - if (!response.ok) { - const error = new Error(`HTTP ${response.status}: ${response.statusText}`); - error.status = response.status; - error.response = response; - throw error; - } - - return await response.json(); - } catch (error) { - console.error('API request failed:', url, error); - throw error; - } -} - -/** - * 表相关 API - */ -export const tableAPI = { - /** - * 获取所有表列表 - * @returns {Promise} - */ - async list() { - return request(`${API_BASE}/tables`); - }, - - /** - * 获取表的 Schema - * @param {string} tableName - 表名 - * @returns {Promise} - */ - async getSchema(tableName) { - return request(`${API_BASE}/tables/${tableName}/schema`); - }, - - /** - * 获取表数据(分页) - * @param {string} tableName - 表名 - * @param {Object} params - 查询参数 - * @param {number} params.page - 页码 - * @param {number} params.pageSize - 每页大小 - * @param {string} params.select - 选择的列(逗号分隔) - * @returns {Promise} - */ - async getData(tableName, { page = 1, pageSize = 20, select = '' } = {}) { - const params = new URLSearchParams({ - page: page.toString(), - pageSize: pageSize.toString(), - }); - - if (select) { - params.append('select', select); - } - - return request(`${API_BASE}/tables/${tableName}/data?${params}`); - }, - - /** - * 获取单行数据详情 - * @param {string} tableName - 表名 - * @param {number} seq - 序列号 - * @returns {Promise} - */ - async getRow(tableName, seq) { - return request(`${API_BASE}/tables/${tableName}/data/${seq}`); - }, - - /** - * 获取表的 Manifest 信息 - * @param {string} tableName - 表名 - * @returns {Promise} - */ - async getManifest(tableName) { - return request(`${API_BASE}/tables/${tableName}/manifest`); - }, - - /** - * 插入数据 - * @param {string} tableName - 表名 - * @param {Object} data - 数据对象 - * @returns {Promise} - */ - async insert(tableName, data) { - return request(`${API_BASE}/tables/${tableName}/data`, { - method: 'POST', - body: JSON.stringify(data), - }); - }, - - /** - * 批量插入数据 - * @param {string} tableName - 表名 - * @param {Array} data - 数据数组 - * @returns {Promise} - */ - async batchInsert(tableName, data) { - return request(`${API_BASE}/tables/${tableName}/data/batch`, { - method: 'POST', - body: JSON.stringify(data), - }); - }, - - /** - * 删除表 - * @param {string} tableName - 表名 - * @returns {Promise} - */ - async delete(tableName) { - return request(`${API_BASE}/tables/${tableName}`, { - method: 'DELETE', - }); - }, - - /** - * 获取表统计信息 - * @param {string} tableName - 表名 - * @returns {Promise} - */ - async getStats(tableName) { - return request(`${API_BASE}/tables/${tableName}/stats`); - }, -}; - -/** - * 数据库相关 API - */ -export const databaseAPI = { - /** - * 获取数据库信息 - * @returns {Promise} - */ - async getInfo() { - return request(`${API_BASE}/database/info`); - }, - - /** - * 获取数据库统计信息 - * @returns {Promise} - */ - async getStats() { - return request(`${API_BASE}/database/stats`); - }, -}; - -/** - * 导出默认 API 对象 - */ -export default { - table: tableAPI, - database: databaseAPI, -}; diff --git a/webui/static/js/common/shared-styles.js b/webui/static/js/common/shared-styles.js deleted file mode 100644 index a0f5395..0000000 --- a/webui/static/js/common/shared-styles.js +++ /dev/null @@ -1,89 +0,0 @@ -import { css } from 'lit'; - -// 共享的基础样式 -export const sharedStyles = css` - * { - box-sizing: border-box; - margin: 0; - padding: 0; - } - - /* 自定义滚动条样式 */ - *::-webkit-scrollbar { - width: 8px; - height: 8px; - } - - *::-webkit-scrollbar-track { - background: transparent; - } - - *::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.1); - border-radius: 4px; - } - - *::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.15); - } - - /* 通用状态样式 */ - .empty { - text-align: center; - padding: 60px 20px; - color: var(--text-secondary); - } - - .loading { - text-align: center; - padding: 60px 20px; - color: var(--text-secondary); - } -`; - -// CSS 变量(可以在组件中使用,优先使用外部定义的变量) -export const cssVariables = css` - :host { - /* 主色调 - 优雅的紫蓝色 */ - --primary: var(--srdb-primary, #6366f1); - --primary-dark: var(--srdb-primary-dark, #4f46e5); - --primary-light: var(--srdb-primary-light, #818cf8); - --primary-bg: var(--srdb-primary-bg, rgba(99, 102, 241, 0.1)); - - /* 背景色 */ - --bg-main: var(--srdb-bg-main, #0f0f1a); - --bg-surface: var(--srdb-bg-surface, #1a1a2e); - --bg-elevated: var(--srdb-bg-elevated, #222236); - --bg-hover: var(--srdb-bg-hover, #2a2a3e); - - /* 文字颜色 */ - --text-primary: var(--srdb-text-primary, #ffffff); - --text-secondary: var(--srdb-text-secondary, #a0a0b0); - --text-tertiary: var(--srdb-text-tertiary, #6b6b7b); - - /* 边框和分隔线 */ - --border-color: var(--srdb-border-color, rgba(255, 255, 255, 0.1)); - --border-hover: var(--srdb-border-hover, rgba(255, 255, 255, 0.2)); - - /* 状态颜色 */ - --success: var(--srdb-success, #10b981); - --warning: var(--srdb-warning, #f59e0b); - --danger: var(--srdb-danger, #ef4444); - --info: var(--srdb-info, #3b82f6); - - /* 阴影 */ - --shadow-sm: var(--srdb-shadow-sm, 0 1px 2px 0 rgba(0, 0, 0, 0.3)); - --shadow-md: var(--srdb-shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3)); - --shadow-lg: var(--srdb-shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3)); - --shadow-xl: var(--srdb-shadow-xl, 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.3)); - - /* 圆角 */ - --radius-sm: var(--srdb-radius-sm, 6px); - --radius-md: var(--srdb-radius-md, 8px); - --radius-lg: var(--srdb-radius-lg, 12px); - --radius-xl: var(--srdb-radius-xl, 16px); - - /* 过渡 */ - --transition: var(--srdb-transition, all 0.2s cubic-bezier(0.4, 0, 0.2, 1)); - } -`; diff --git a/webui/static/js/components/App.js b/webui/static/js/components/App.js new file mode 100644 index 0000000..1604046 --- /dev/null +++ b/webui/static/js/components/App.js @@ -0,0 +1,148 @@ +import { html } from 'htm/preact'; +import { useState, useEffect } from 'preact/hooks'; +import { Sidebar } from './Sidebar.js'; +import { TableView } from './TableView.js'; + +export function App() { + const [theme, setTheme] = useState('dark'); + const [tables, setTables] = useState([]); + const [selectedTable, setSelectedTable] = useState(null); + const [loading, setLoading] = useState(true); + + // 初始化主题 + useEffect(() => { + const savedTheme = localStorage.getItem('srdb_theme') || 'dark'; + setTheme(savedTheme); + if (savedTheme === 'light') { + document.documentElement.setAttribute('data-theme', 'light'); + } + }, []); + + // 加载表列表 + useEffect(() => { + fetchTables(); + }, []); + + const fetchTables = async () => { + try { + setLoading(true); + const response = await fetch('/api/tables'); + if (response.ok) { + const data = await response.json(); + setTables(data.tables || []); + if (data.tables && data.tables.length > 0) { + setSelectedTable(data.tables[0].name); + } + } + } catch (error) { + console.error('Failed to fetch tables:', error); + } finally { + setLoading(false); + } + }; + + const toggleTheme = () => { + const newTheme = theme === 'dark' ? 'light' : 'dark'; + setTheme(newTheme); + localStorage.setItem('srdb_theme', newTheme); + + if (newTheme === 'light') { + document.documentElement.setAttribute('data-theme', 'light'); + } else { + document.documentElement.removeAttribute('data-theme'); + } + }; + + const styles = { + container: { + display: 'flex', + height: '100vh', + overflow: 'hidden' + }, + sidebar: { + width: '280px', + background: 'var(--bg-surface)', + borderRight: '1px solid var(--border-color)', + overflowY: 'auto', + overflowX: 'hidden', + padding: '16px 12px', + display: 'flex', + flexDirection: 'column', + gap: '8px' + }, + sidebarHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: '4px' + }, + sidebarTitle: { + fontSize: '18px', + fontWeight: 700, + letterSpacing: '-0.02em', + background: 'linear-gradient(135deg, #667eea, #764ba2)', + WebkitBackgroundClip: 'text', + WebkitTextFillColor: 'transparent', + margin: 0 + }, + themeToggle: { + width: '32px', + height: '32px', + background: 'var(--bg-elevated)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-md)', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '16px', + transition: 'var(--transition)' + }, + main: { + flex: 1, + overflowY: 'auto', + overflowX: 'hidden', + background: 'var(--bg-main)', + padding: '24px' + } + }; + + return html` +
+ +
+
+

SRDB Tables

+ +
+ <${Sidebar} + tables=${tables} + selectedTable=${selectedTable} + onSelectTable=${setSelectedTable} + loading=${loading} + /> +
+ + +
+ ${!selectedTable && html` +
+

Select a table from the sidebar

+
+ `} + + ${selectedTable && html` + <${TableView} tableName=${selectedTable} key=${selectedTable} /> + `} +
+
+ `; +} diff --git a/webui/static/js/components/ColumnSelector.js b/webui/static/js/components/ColumnSelector.js new file mode 100644 index 0000000..b869ab6 --- /dev/null +++ b/webui/static/js/components/ColumnSelector.js @@ -0,0 +1,186 @@ +import { html } from 'htm/preact'; +import { useState, useRef, useEffect } from 'preact/hooks'; + +const styles = { + container: { + position: 'relative', + display: 'inline-block' + }, + button: { + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '8px 16px', + background: 'var(--bg-elevated)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-md)', + color: 'var(--text-primary)', + fontSize: '14px', + fontWeight: 500, + cursor: 'pointer', + transition: 'var(--transition)' + }, + dropdown: (isOpen) => ({ + position: 'absolute', + top: 'calc(100% + 4px)', + right: 0, + minWidth: '240px', + maxHeight: '400px', + overflowY: 'auto', + background: 'var(--bg-elevated)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-md)', + boxShadow: 'var(--shadow-lg)', + zIndex: 100, + display: isOpen ? 'block' : 'none' + }), + menuItem: (isSelected) => ({ + display: 'flex', + alignItems: 'center', + gap: '12px', + padding: '10px 16px', + cursor: 'pointer', + transition: 'var(--transition)', + background: isSelected ? 'var(--primary-bg)' : 'transparent', + color: 'var(--text-primary)', + fontSize: '14px' + }), + checkbox: (isSelected) => ({ + width: '18px', + height: '18px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + color: isSelected ? 'var(--primary)' : 'transparent' + }), + fieldInfo: { + flex: 1, + minWidth: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '8px' + }, + fieldName: { + display: 'flex', + alignItems: 'center', + gap: '6px', + flex: 1, + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' + }, + fieldType: { + fontSize: '10px', + color: 'var(--primary)', + background: 'var(--primary-bg)', + padding: '2px 5px', + borderRadius: 'var(--radius-sm)', + fontFamily: '"Courier New", monospace', + flexShrink: 0 + } +}; + +export function ColumnSelector({ fields, selectedColumns, onToggle }) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + const buttonRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event) => { + if ( + dropdownRef.current && + buttonRef.current && + !dropdownRef.current.contains(event.target) && + !buttonRef.current.contains(event.target) + ) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + const selectedCount = selectedColumns.length; + const totalCount = fields?.length || 0; + + return html` +
+ + +
+ ${fields?.map(field => { + const isSelected = selectedColumns.includes(field.name); + return html` +
onToggle(field.name)} + onMouseEnter=${(e) => { + if (!isSelected) { + e.currentTarget.style.background = 'var(--bg-hover)'; + } + }} + onMouseLeave=${(e) => { + if (!isSelected) { + e.currentTarget.style.background = 'transparent'; + } + }} + > +
+ ${isSelected ? '✓' : ''} +
+
+
+ ${field.name} + ${field.indexed && html`🔍`} +
+
${field.type}
+
+
+ `; + })} +
+
+ `; +} diff --git a/webui/static/js/components/CompactionStats.js b/webui/static/js/components/CompactionStats.js new file mode 100644 index 0000000..975cdb8 --- /dev/null +++ b/webui/static/js/components/CompactionStats.js @@ -0,0 +1,52 @@ +import { html } from 'htm/preact'; + +const styles = { + compactionStats: { + background: 'var(--bg-elevated)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + padding: '16px' + }, + compactionTitle: { + fontSize: '15px', + fontWeight: 600, + color: 'var(--text-primary)', + marginBottom: '12px' + }, + compactionGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', + gap: '8px' + }, + compactionItem: { + display: 'flex', + alignItems: 'center', + padding: '6px 0', + fontSize: '13px', + gap: "24px" + } +}; + +function formatNumber(num) { + return num.toLocaleString('zh-CN'); +} + +export function CompactionStats({ stats }) { + if (!stats) return null; + + return html` +
+
Compaction 统计
+
+ ${Object.entries(stats).map(([key, value]) => html` +
+ ${key} + + ${typeof value === 'number' ? formatNumber(value) : value} + +
+ `)} +
+
+ `; +} diff --git a/webui/static/js/components/DataTable.js b/webui/static/js/components/DataTable.js new file mode 100644 index 0000000..4c06db6 --- /dev/null +++ b/webui/static/js/components/DataTable.js @@ -0,0 +1,203 @@ +import { html } from 'htm/preact'; +import { useState, useEffect } from 'preact/hooks'; +import { RowDetailModal } from './RowDetailModal.js'; +import { Pagination } from './Pagination.js'; +import { TableRow } from './TableRow.js'; +import { useCellPopover } from '../hooks/useCellPopover.js'; +import { useTooltip } from '../hooks/useTooltip.js'; + +const styles = { + container: { + display: 'flex', + flexDirection: 'column', + gap: '12px', + position: 'relative' + }, + loadingBar: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: '3px', + background: 'var(--primary)', + zIndex: 100, + animation: 'loading-slide 1.5s ease-in-out infinite' + }, + loadingOverlay: { + position: 'fixed', + top: '16px', + left: '50%', + transform: 'translateX(-50%)', + padding: '10px 20px', + background: 'var(--bg-elevated)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-md)', + boxShadow: 'var(--shadow-lg)', + zIndex: 999, + display: 'flex', + alignItems: 'center', + gap: '10px', + fontSize: '14px', + color: 'var(--text-primary)', + fontWeight: 500 + }, + tableWrapper: { + overflowX: 'auto', + background: 'var(--bg-surface)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-md)' + }, + table: { + width: '100%', + borderCollapse: 'collapse', + fontSize: '13px' + }, + th: { + background: 'var(--bg-elevated)', + color: 'var(--text-secondary)', + fontWeight: 600, + textAlign: 'left', + padding: '12px', + borderBottom: '1px solid var(--border-color)', + position: 'sticky', + top: 0, + zIndex: 1 + } +}; + +export function DataTable({ schema, tableName, totalRows, selectedColumns = [] }) { + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(20); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedSeq, setSelectedSeq] = useState(null); + + const { showPopover, hidePopover } = useCellPopover(); + const { showTooltip, hideTooltip } = useTooltip(); + + useEffect(() => { + fetchData(); + }, [tableName, page, pageSize]); + + const fetchData = async () => { + try { + setLoading(true); + const offset = page * pageSize; + const response = await fetch(`/api/tables/${tableName}/data?limit=${pageSize}&offset=${offset}`); + if (response.ok) { + const result = await response.json(); + setData(result.data || []); + } + } catch (error) { + console.error('Failed to fetch data:', error); + } finally { + setLoading(false); + } + }; + + const getColumns = () => { + let columns = []; + + if (selectedColumns && selectedColumns.length > 0) { + // 使用选中的列 + columns = [...selectedColumns]; + } else if (schema && schema.fields) { + // 没有选择时,显示所有字段 + columns = schema.fields.map(f => f.name); + } else { + return ['_seq', '_time']; + } + + // 过滤掉 _seq 和 _time(它们会被固定放到特定位置) + const filtered = columns.filter(c => c !== '_seq' && c !== '_time'); + + // _seq 在开头,其他字段在中间,_time 在倒数第二(Actions 列之前) + return ['_seq', ...filtered, '_time']; + }; + + const handleViewDetail = (seq) => { + setSelectedSeq(seq); + }; + + const handlePageSizeChange = (newPageSize) => { + setPageSize(newPageSize); + setPage(0); + }; + + const getFieldComment = (fieldName) => { + if (!schema || !schema.fields) return ''; + const field = schema.fields.find(f => f.name === fieldName); + return field?.comment || ''; + }; + + const columns = getColumns(); + + if (!data || data.length === 0) { + return html`

暂无数据

`; + } + + return html` +
+ ${loading && html` +
+ + 加载中... +
+ `} + +
+ + + + ${columns.map(col => { + const comment = getFieldComment(col); + return html` + + `; + })} + + + + + ${data.map((row, idx) => html` + <${TableRow} + key=${row._seq || idx} + row=${row} + columns=${columns} + onViewDetail=${handleViewDetail} + onShowPopover=${showPopover} + onHidePopover=${hidePopover} + /> + `)} + +
comment && showTooltip(e.currentTarget, comment)} + onMouseLeave=${hideTooltip} + > + ${col} + 操作
+
+ + + <${Pagination} + page=${page} + pageSize=${pageSize} + totalRows=${totalRows} + onPageChange=${setPage} + onPageSizeChange=${handlePageSizeChange} + onJumpToPage=${setPage} + /> + + + ${selectedSeq !== null && html` + <${RowDetailModal} + tableName=${tableName} + seq=${selectedSeq} + onClose=${() => setSelectedSeq(null)} + /> + `} +
+ `; +} diff --git a/webui/static/js/components/FieldList.js b/webui/static/js/components/FieldList.js new file mode 100644 index 0000000..1656e91 --- /dev/null +++ b/webui/static/js/components/FieldList.js @@ -0,0 +1,105 @@ +import { html } from 'htm/preact'; + +const styles = { + schemaFields: { + borderTop: '1px solid var(--border-color)', + position: 'relative', + transition: 'var(--transition)' + }, + verticalLine: { + content: '""', + position: 'absolute', + left: '16px', + top: 0, + bottom: '14px', + width: '1px', + background: 'var(--border-color)', + zIndex: 2 + }, + fieldItem: { + zIndex: 1, + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '4px 12px 4px 16px', + fontSize: '12px', + transition: 'var(--transition)', + position: 'relative' + }, + horizontalLine: { + width: '8px', + height: '1px', + background: 'var(--border-color)' + }, + fieldIndexIcon: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '12px', + flexShrink: 0 + }, + fieldName: { + fontWeight: 500, + color: 'var(--text-secondary)', + flex: 1, + display: 'flex', + alignItems: 'center', + gap: '4px' + }, + fieldType: { + fontFamily: '"Courier New", monospace', + fontSize: '11px', + padding: '2px 6px', + background: 'var(--primary-bg)', + color: 'var(--primary)', + borderRadius: 'var(--radius-sm)', + fontWeight: 600 + } +}; + +export function FieldList({ fields }) { + if (!fields || fields.length === 0) { + return null; + } + + return html` +
+ +
+ + ${fields.map((field) => { + return html` +
{ + e.currentTarget.style.background = 'var(--bg-hover)'; + }} + onMouseLeave=${(e) => { + e.currentTarget.style.background = 'transparent'; + }} + > + +
+ + + + ${field.name} + ${field.indexed && html` + + 🔍 + + `} + + + + ${field.type} +
+ `; + })} +
+ `; +} diff --git a/webui/static/js/components/FileCard.js b/webui/static/js/components/FileCard.js new file mode 100644 index 0000000..1f88289 --- /dev/null +++ b/webui/static/js/components/FileCard.js @@ -0,0 +1,90 @@ +import { html } from 'htm/preact'; + +const styles = { + fileCard: { + background: 'var(--bg-elevated)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-sm)', + padding: '10px', + transition: 'var(--transition)' + }, + fileName: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: '6px' + }, + fileNameText: { + fontFamily: '"Courier New", monospace', + fontSize: '13px', + color: 'var(--text-primary)', + fontWeight: 500 + }, + fileLevelBadge: { + fontSize: '11px', + padding: '2px 8px', + background: 'var(--primary-bg)', + color: 'var(--primary)', + borderRadius: 'var(--radius-sm)', + fontWeight: 600, + fontFamily: '"Courier New", monospace' + }, + fileDetail: { + display: 'flex', + flexDirection: 'column', + gap: '3px', + fontSize: '12px', + color: 'var(--text-secondary)' + }, + fileDetailRow: { + display: 'flex', + justifyContent: 'space-between' + } +}; + +function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i]; +} + +function formatNumber(num) { + return num.toLocaleString('zh-CN'); +} + +export function FileCard({ file }) { + return html` +
{ + e.currentTarget.style.borderColor = 'var(--primary)'; + e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)'; + }} + onMouseLeave=${(e) => { + e.currentTarget.style.borderColor = 'var(--border-color)'; + e.currentTarget.style.boxShadow = 'none'; + }} + > +
+ ${String(file.file_number).padStart(6, '0')}.sst + L${file.level} +
+
+
+ Size: + ${formatBytes(file.file_size)} +
+
+ Rows: + ${formatNumber(file.row_count)} +
+
+ Seq Range: + ${formatNumber(file.min_key)} - ${formatNumber(file.max_key)} +
+
+
+ `; +} diff --git a/webui/static/js/components/LevelCard.js b/webui/static/js/components/LevelCard.js new file mode 100644 index 0000000..97faf06 --- /dev/null +++ b/webui/static/js/components/LevelCard.js @@ -0,0 +1,139 @@ +import { html } from 'htm/preact'; +import { FileCard } from './FileCard.js'; + +const styles = { + levelSection: { + background: 'var(--bg-elevated)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + marginBottom: '8px', + overflow: 'hidden' + }, + levelHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + cursor: 'pointer', + padding: '14px 16px', + transition: 'var(--transition)', + }, + levelHeaderLeft: { + display: 'flex', + alignItems: 'center', + gap: '12px', + flex: 1 + }, + expandIcon: (isExpanded) => ({ + fontSize: '12px', + color: 'var(--text-secondary)', + transition: 'transform 0.2s ease', + userSelect: 'none', + transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' + }), + levelTitle: { + fontSize: '16px', + fontWeight: 600, + color: 'var(--text-primary)', + display: 'flex', + alignItems: 'center', + gap: '12px' + }, + levelBadge: (level) => { + const colors = ['#667eea', '#764ba2', '#f093fb', '#4facfe']; + const bgColor = colors[level] || '#667eea'; + return { + background: bgColor, + color: '#fff', + padding: '4px 10px', + borderRadius: 'var(--radius-sm)', + fontSize: '12px', + fontWeight: 600, + fontFamily: '"Courier New", monospace' + }; + }, + levelStats: { + display: 'flex', + gap: '20px', + fontSize: '13px', + color: 'var(--text-secondary)' + }, + scoreBadge: (score) => ({ + padding: '4px 8px', + borderRadius: 'var(--radius-sm)', + fontSize: '12px', + fontWeight: 600, + fontFamily: '"Courier New", monospace', + background: score > 1 ? '#fed7d7' : score > 0.8 ? '#feebc8' : '#c6f6d5', + color: score > 1 ? '#c53030' : score > 0.8 ? '#c05621' : '#22543d' + }), + filesContainer: (isExpanded) => ({ + display: isExpanded ? 'block' : 'none', + padding: '12px', + background: 'var(--bg-surface)', + borderTop: '1px solid var(--border-color)', + borderRadius: '0 0 var(--radius-lg) var(--radius-lg)' + }), + filesGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', + gap: '8px' + } +}; + +function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i]; +} + +export function LevelCard({ level, isExpanded, onToggle }) { + return html` +
+
{ + e.currentTarget.style.background = 'var(--bg-hover)'; + }} + onMouseLeave=${(e) => { + e.currentTarget.style.background = 'transparent'; + }} + > +
+ +
+ L${level.level} + Level ${level.level} +
+
+
+ 文件: ${level.file_count} + 大小: ${formatBytes(level.total_size)} + ${level.score > 0 && html` + + Score: ${(level.score * 100).toFixed(0)}% + + `} +
+
+ + ${level.files && level.files.length > 0 && html` +
+
+ ${level.files.map(file => html` + <${FileCard} key=${file.file_number} file=${file} /> + `)} +
+
+ `} + + ${(!level.files || level.files.length === 0) && html` +
+

此层级暂无文件

+
+ `} +
+ `; +} diff --git a/webui/static/js/components/ManifestModal.js b/webui/static/js/components/ManifestModal.js new file mode 100644 index 0000000..361787a --- /dev/null +++ b/webui/static/js/components/ManifestModal.js @@ -0,0 +1,118 @@ +import { html } from 'htm/preact'; +import { useEffect } from 'preact/hooks'; +import { ManifestView } from './ManifestView.js'; + +const styles = { + overlay: { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + background: 'rgba(0, 0, 0, 0.6)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 1000, + padding: '20px' + }, + modal: { + background: 'var(--bg-elevated)', + borderRadius: 'var(--radius-lg)', + boxShadow: 'var(--shadow-xl)', + width: '90vw', + maxWidth: '1200px', + maxHeight: '85vh', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden' + }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '20px 24px', + borderBottom: '1px solid var(--border-color)' + }, + title: { + fontSize: '20px', + fontWeight: 600, + color: 'var(--text-primary)', + margin: 0 + }, + closeButton: { + width: '32px', + height: '32px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: 'transparent', + border: 'none', + borderRadius: 'var(--radius-sm)', + cursor: 'pointer', + fontSize: '20px', + color: 'var(--text-secondary)', + transition: 'var(--transition)' + }, + content: { + flex: 1, + overflowY: 'auto', + padding: '20px 24px' + } +}; + +export function ManifestModal({ tableName, onClose }) { + // ESC 键关闭 + useEffect(() => { + const handleEscape = (e) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [onClose]); + + // 阻止背景滚动 + useEffect(() => { + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = ''; + }; + }, []); + + const handleOverlayClick = (e) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return html` +
+
+
+

Manifest - ${tableName}

+ +
+
+ <${ManifestView} tableName=${tableName} /> +
+
+
+ `; +} diff --git a/webui/static/js/components/ManifestView.js b/webui/static/js/components/ManifestView.js new file mode 100644 index 0000000..169065a --- /dev/null +++ b/webui/static/js/components/ManifestView.js @@ -0,0 +1,102 @@ +import { html } from 'htm/preact'; +import { useState, useEffect } from 'preact/hooks'; +import { LevelCard } from './LevelCard.js'; +import { StatCard } from './StatCard.js'; +import { CompactionStats } from './CompactionStats.js'; + +const styles = { + container: { + display: 'flex', + flexDirection: 'column', + gap: '12px' + }, + statsGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', + gap: '8px' + } +}; + +function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i]; +} + +function formatNumber(num) { + return num.toLocaleString('zh-CN'); +} + +export function ManifestView({ tableName }) { + const [manifest, setManifest] = useState(null); + const [loading, setLoading] = useState(true); + const [expandedLevels, setExpandedLevels] = useState(new Set()); + + useEffect(() => { + fetchManifest(); + const interval = setInterval(fetchManifest, 5000); // 每5秒刷新 + return () => clearInterval(interval); + }, [tableName]); + + const fetchManifest = async () => { + try { + const response = await fetch(`/api/tables/${tableName}/manifest`); + if (response.ok) { + const data = await response.json(); + setManifest(data); + } + } catch (error) { + console.error('Failed to fetch manifest:', error); + } finally { + setLoading(false); + } + }; + + const toggleLevel = (levelNum) => { + const newExpanded = new Set(expandedLevels); + if (newExpanded.has(levelNum)) { + newExpanded.delete(levelNum); + } else { + newExpanded.add(levelNum); + } + setExpandedLevels(newExpanded); + }; + + if (loading) { + return html`

加载中...

`; + } + + if (!manifest) { + return html`

无法加载 Manifest 数据

`; + } + + const totalFiles = manifest.levels?.reduce((sum, level) => sum + level.file_count, 0) || 0; + const totalSize = manifest.levels?.reduce((sum, level) => sum + level.total_size, 0) || 0; + + return html` +
+ +
+ <${StatCard} label="总文件数" value=${formatNumber(totalFiles)} /> + <${StatCard} label="总大小" value=${formatBytes(totalSize)} /> + <${StatCard} label="下一个文件号" value=${formatNumber(manifest.next_file_number || 0)} /> + <${StatCard} label="最后序列号" value=${formatNumber(manifest.last_sequence || 0)} /> +
+ + + <${CompactionStats} stats=${manifest.compaction_stats} /> + + + ${manifest.levels?.filter(level => level.file_count > 0).map(level => html` + <${LevelCard} + key=${level.level} + level=${level} + isExpanded=${expandedLevels.has(level.level)} + onToggle=${() => toggleLevel(level.level)} + /> + `)} +
+ `; +} diff --git a/webui/static/js/components/PageJumper.js b/webui/static/js/components/PageJumper.js new file mode 100644 index 0000000..ee0bdd9 --- /dev/null +++ b/webui/static/js/components/PageJumper.js @@ -0,0 +1,71 @@ +import { html } from 'htm/preact'; +import { Fragment } from 'preact'; +import { useState } from 'preact/hooks'; + +const styles = { + jumpInput: { + width: '80px', + padding: '6px 10px', + background: 'var(--bg-elevated)', + color: 'var(--text-primary)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-sm)', + fontSize: '13px' + }, + jumpButton: { + padding: '6px 12px', + background: 'var(--bg-elevated)', + color: 'var(--text-primary)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-sm)', + cursor: 'pointer', + fontSize: '13px', + transition: 'var(--transition)', + fontWeight: 500 + } +}; + +export function PageJumper({ totalPages, onJump }) { + const [inputValue, setInputValue] = useState(''); + + const handleJump = () => { + const num = parseInt(inputValue); + if (num >= 1 && num <= totalPages) { + onJump(num - 1); + setInputValue(''); + } + }; + + const handleKeyDown = (e) => { + if (e.key === 'Enter') { + handleJump(); + } + }; + + return html` + <${Fragment}> + setInputValue(e.target.value)} + onKeyDown=${handleKeyDown} + style=${styles.jumpInput} + /> + + + `; +} diff --git a/webui/static/js/components/Pagination.js b/webui/static/js/components/Pagination.js new file mode 100644 index 0000000..af29a85 --- /dev/null +++ b/webui/static/js/components/Pagination.js @@ -0,0 +1,122 @@ +import { html } from 'htm/preact'; +import { PageJumper } from './PageJumper.js'; + +const styles = { + pagination: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '12px', + background: 'var(--bg-surface)', + borderRadius: 'var(--radius-md)', + border: '1px solid var(--border-color)', + position: 'sticky', + bottom: 0, + zIndex: 10, + boxShadow: 'var(--shadow-md)' + }, + paginationInfo: { + fontSize: '13px', + color: 'var(--text-secondary)' + }, + paginationButtons: { + display: 'flex', + gap: '8px', + alignItems: 'center' + }, + pageButton: (disabled) => ({ + padding: '6px 12px', + background: disabled ? 'var(--bg-surface)' : 'var(--bg-elevated)', + color: disabled ? 'var(--text-tertiary)' : 'var(--text-primary)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-sm)', + cursor: disabled ? 'not-allowed' : 'pointer', + fontSize: '13px', + transition: 'var(--transition)', + fontWeight: 500 + }), + pageSizeSelect: { + padding: '6px 10px', + background: 'var(--bg-elevated)', + color: 'var(--text-primary)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-sm)', + fontSize: '13px', + cursor: 'pointer' + } +}; + +export function Pagination({ + page, + pageSize, + totalRows, + onPageChange, + onPageSizeChange, + onJumpToPage +}) { + const totalPages = Math.ceil(totalRows / pageSize); + const currentPage = page + 1; + const startRow = page * pageSize + 1; + const endRow = Math.min((page + 1) * pageSize, totalRows); + + const handlePrevPage = () => { + if (page > 0) { + onPageChange(page - 1); + } + }; + + const handleNextPage = () => { + if (page < totalPages - 1) { + onPageChange(page + 1); + } + }; + + const handlePageSizeChange = (e) => { + onPageSizeChange(Number(e.target.value)); + }; + + return html` +
+
+ 显示 ${startRow}-${endRow} / 共 ${totalRows} 行 +
+
+ + + + ${currentPage} / ${totalPages} + + <${PageJumper} + totalPages=${totalPages} + onJump=${onJumpToPage} + /> + +
+
+ `; +} diff --git a/webui/static/js/components/RowDetailModal.js b/webui/static/js/components/RowDetailModal.js new file mode 100644 index 0000000..afa9ae7 --- /dev/null +++ b/webui/static/js/components/RowDetailModal.js @@ -0,0 +1,224 @@ +import { html } from 'htm/preact'; +import { useState, useEffect, useRef } from 'preact/hooks'; + +const styles = { + overlay: { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + background: 'rgba(0, 0, 0, 0.5)', + backdropFilter: 'blur(4px)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 10000, + padding: '20px' + }, + modal: { + background: 'var(--bg-elevated)', + borderRadius: 'var(--radius-lg)', + boxShadow: 'var(--shadow-xl)', + maxWidth: '800px', + width: '100%', + maxHeight: '90vh', + display: 'flex', + flexDirection: 'column', + border: '1px solid var(--border-color)' + }, + header: { + padding: '20px', + borderBottom: '1px solid var(--border-color)', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between' + }, + title: { + fontSize: '18px', + fontWeight: 600, + color: 'var(--text-primary)', + margin: 0 + }, + closeButton: { + background: 'none', + border: 'none', + fontSize: '24px', + color: 'var(--text-secondary)', + cursor: 'pointer', + padding: '4px 8px', + borderRadius: 'var(--radius-sm)', + transition: 'var(--transition)', + lineHeight: 1 + }, + content: { + padding: '20px', + overflowY: 'auto', + flex: 1 + }, + fieldGroup: { + marginBottom: '16px', + padding: '12px', + background: 'var(--bg-surface)', + borderRadius: 'var(--radius-md)', + border: '1px solid var(--border-color)' + }, + fieldLabel: { + fontSize: '12px', + fontWeight: 600, + color: 'var(--text-secondary)', + textTransform: 'uppercase', + marginBottom: '4px', + display: 'flex', + alignItems: 'center', + gap: '6px' + }, + fieldValue: { + fontSize: '14px', + color: 'var(--text-primary)', + wordBreak: 'break-word', + fontFamily: '"Courier New", monospace', + whiteSpace: 'pre-wrap', + lineHeight: 1.5 + }, + metaTag: { + display: 'inline-block', + padding: '2px 6px', + fontSize: '10px', + fontWeight: 600, + borderRadius: 'var(--radius-sm)', + background: 'var(--primary-bg)', + color: 'var(--primary)', + marginLeft: '6px' + } +}; + +export function RowDetailModal({ tableName, seq, onClose }) { + const overlayRef = useRef(null); + const [rowData, setRowData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchRowData(); + }, [tableName, seq]); + + // ESC 键关闭 + useEffect(() => { + const handleKeyDown = (e) => { + if (e.key === 'Escape') { + onClose(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const fetchRowData = async () => { + try { + setLoading(true); + const response = await fetch(`/api/tables/${tableName}/data/${seq}`); + if (response.ok) { + const data = await response.json(); + setRowData(data); + } + } catch (error) { + console.error('Failed to fetch row data:', error); + } finally { + setLoading(false); + } + }; + + const handleOverlayClick = (e) => { + if (e.target === overlayRef.current) { + onClose(); + } + }; + + const formatTime = (nanoTime) => { + if (!nanoTime) return ''; + const date = new Date(nanoTime / 1000000); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); + }; + + const formatValue = (value, key) => { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + + if (key === '_time') { + return formatTime(value); + } + + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, 2); + } catch (e) { + return '[Object]'; + } + } + + return String(value); + }; + + const renderField = (key, value) => { + const isMeta = key === '_seq' || key === '_time'; + + return html` +
+
+ ${key} + ${isMeta && html`系统字段`} +
+
+ ${formatValue(value, key)} +
+
+ `; + }; + + return html` +
+
+
+

记录详情 - ${tableName}

+ +
+
+ ${loading && html` +
+

加载中...

+
+ `} + ${!loading && rowData && html` +
+ ${Object.entries(rowData).map(([key, value]) => renderField(key, value))} +
+ `} + ${!loading && !rowData && html` +
+

未找到数据

+
+ `} +
+
+
+ `; +} diff --git a/webui/static/js/components/Sidebar.js b/webui/static/js/components/Sidebar.js new file mode 100644 index 0000000..33d8a70 --- /dev/null +++ b/webui/static/js/components/Sidebar.js @@ -0,0 +1,31 @@ +import { html } from 'htm/preact'; +import { TableItem } from './TableItem.js'; + +export function Sidebar({ tables, selectedTable, onSelectTable, loading }) { + if (loading) { + return html` +
+

加载中...

+
+ `; + } + + if (tables.length === 0) { + return html` +
+

暂无数据表

+
+ `; + } + + return html` + ${tables.map(table => html` + <${TableItem} + key=${table.name} + table=${table} + isSelected=${selectedTable === table.name} + onSelect=${() => onSelectTable(table.name)} + /> + `)} + `; +} diff --git a/webui/static/js/components/StatCard.js b/webui/static/js/components/StatCard.js new file mode 100644 index 0000000..6d23950 --- /dev/null +++ b/webui/static/js/components/StatCard.js @@ -0,0 +1,33 @@ +import { html } from 'htm/preact'; + +const styles = { + statCard: { + background: 'var(--bg-elevated)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-md)', + padding: '12px', + transition: 'var(--transition)' + }, + statLabel: { + fontSize: '12px', + color: 'var(--text-secondary)', + marginBottom: '6px', + textTransform: 'uppercase', + fontWeight: 600 + }, + statValue: { + fontSize: '22px', + fontWeight: 600, + color: 'var(--primary)', + fontFamily: '"Courier New", monospace' + } +}; + +export function StatCard({ label, value }) { + return html` +
+
${label}
+
${value}
+
+ `; +} diff --git a/webui/static/js/components/TableCell.js b/webui/static/js/components/TableCell.js new file mode 100644 index 0000000..57c8c23 --- /dev/null +++ b/webui/static/js/components/TableCell.js @@ -0,0 +1,72 @@ +import { html } from 'htm/preact'; + +const styles = { + td: { + padding: '10px 12px', + borderBottom: '1px solid var(--border-color)', + color: 'var(--text-primary)', + maxWidth: '300px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + cursor: 'pointer' + } +}; + +function formatValue(value, col) { + if (col === '_time') { + return formatTime(value); + } + + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, 2); + } catch (e) { + return '[Object]'; + } + } + + return String(value); +} + +function formatTime(nanoTime) { + if (!nanoTime) return ''; + const date = new Date(nanoTime / 1000000); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + +export function TableCell({ value, column, isTruncated, onShowPopover, onHidePopover }) { + const formattedValue = formatValue(value, column); + + const handleMouseEnter = (e) => { + if (onShowPopover) { + onShowPopover(e, formattedValue); + } + }; + + const handleMouseLeave = () => { + if (onHidePopover) { + onHidePopover(); + } + }; + + return html` + + ${formattedValue} + ${isTruncated && html`✂️`} + + `; +} diff --git a/webui/static/js/components/TableItem.js b/webui/static/js/components/TableItem.js new file mode 100644 index 0000000..67e4aa6 --- /dev/null +++ b/webui/static/js/components/TableItem.js @@ -0,0 +1,130 @@ +import { html } from 'htm/preact'; +import { useState } from 'preact/hooks'; +import { FieldList } from './FieldList.js'; + +const styles = { + tableItem: (isSelected, isExpanded) => ({ + background: 'var(--bg-elevated)', + border: isSelected ? '1px solid var(--primary)' : '1px solid var(--border-color)', + borderRadius: 'var(--radius-md)', + overflow: 'hidden', + transition: 'var(--transition)', + boxShadow: isSelected ? '0 0 0 1px var(--primary)' : 'none' + }), + tableHeader: (isSelected) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + paddingLeft: '4px', + paddingRight: '12px', + cursor: 'pointer', + transition: 'var(--transition)', + background: isSelected ? 'var(--primary-bg)' : 'transparent' + }), + tableHeaderLeft: { + display: 'flex', + alignItems: 'center', + gap: '8px', + flex: 1, + minWidth: 0 + }, + expandIcon: (isExpanded) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + fontSize: '12px', + transition: 'var(--transition)', + flexShrink: 0, + borderRadius: 'var(--radius-sm)', + cursor: 'pointer', + color: isExpanded ? 'var(--primary)' : 'var(--text-secondary)', + transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)', + marginLeft: '-4px' + }), + tableName: { + fontWeight: 500, + color: 'var(--text-primary)', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis' + }, + tableCount: { + fontSize: '12px', + color: 'var(--text-tertiary)', + whiteSpace: 'nowrap', + flexShrink: 0 + } +}; + +export function TableItem({ table, isSelected, onSelect }) { + const [isExpanded, setIsExpanded] = useState(false); + + const toggleExpand = (e) => { + e.stopPropagation(); + setIsExpanded(!isExpanded); + }; + + return html` +
{ + if (!isSelected) { + e.currentTarget.style.borderColor = 'var(--border-hover)'; + } + }} + onMouseLeave=${(e) => { + if (!isSelected) { + e.currentTarget.style.borderColor = 'var(--border-color)'; + } + }} + > + +
{ + if (!isSelected) { + e.currentTarget.style.background = 'var(--bg-hover)'; + } + }} + onMouseLeave=${(e) => { + if (!isSelected) { + e.currentTarget.style.background = 'transparent'; + } + }} + > +
+ { + e.currentTarget.style.background = 'var(--bg-hover)'; + if (!isExpanded) { + e.currentTarget.style.color = 'var(--text-primary)'; + } + }} + onMouseLeave=${(e) => { + e.currentTarget.style.background = 'transparent'; + if (!isExpanded) { + e.currentTarget.style.color = 'var(--text-secondary)'; + } + }} + > + ▶ + + ${table.name} +
+ + ${table.fields?.length || 0} fields + +
+ + + ${isExpanded && html` + <${FieldList} fields=${table.fields} /> + `} +
+ `; +} diff --git a/webui/static/js/components/TableRow.js b/webui/static/js/components/TableRow.js new file mode 100644 index 0000000..308c537 --- /dev/null +++ b/webui/static/js/components/TableRow.js @@ -0,0 +1,68 @@ +import { html } from 'htm/preact'; +import { useState } from 'preact/hooks'; +import { TableCell } from './TableCell.js'; + +const styles = { + td: { + padding: '10px 12px', + borderBottom: '1px solid var(--border-color)', + color: 'var(--text-primary)', + textAlign: 'center' + }, + iconButton: (isRowHovered, isButtonHovered) => ({ + width: '28px', + height: '28px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: isButtonHovered ? 'var(--primary)' : 'var(--bg-elevated)', + color: isButtonHovered ? '#fff' : 'var(--primary)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-sm)', + cursor: 'pointer', + fontSize: '14px', + transition: 'all 0.2s ease', + opacity: isRowHovered ? 1 : 0, + pointerEvents: isRowHovered ? 'auto' : 'none' + }) +}; + +export function TableRow({ row, columns, onViewDetail, onShowPopover, onHidePopover }) { + const [isRowHovered, setIsRowHovered] = useState(false); + const [isButtonHovered, setIsButtonHovered] = useState(false); + + return html` + { + e.currentTarget.style.background = 'var(--bg-hover)'; + setIsRowHovered(true); + }} + onMouseLeave=${(e) => { + e.currentTarget.style.background = 'transparent'; + setIsRowHovered(false); + }} + > + ${columns.map(col => html` + <${TableCell} + key=${col} + value=${row[col]} + column=${col} + isTruncated=${row[col + '_truncated']} + onShowPopover=${onShowPopover} + onHidePopover=${onHidePopover} + /> + `)} + + + + + `; +} diff --git a/webui/static/js/components/TableView.js b/webui/static/js/components/TableView.js new file mode 100644 index 0000000..0a4247a --- /dev/null +++ b/webui/static/js/components/TableView.js @@ -0,0 +1,183 @@ +import { html } from 'htm/preact'; +import { useState, useEffect } from 'preact/hooks'; +import { DataTable } from './DataTable.js'; +import { ColumnSelector } from './ColumnSelector.js'; +import { ManifestModal } from './ManifestModal.js'; +import { useTooltip } from '../hooks/useTooltip.js'; + +const styles = { + container: { + display: 'flex', + flexDirection: 'column', + gap: '20px' + }, + section: { + display: 'flex', + flexDirection: 'column', + gap: '16px' + }, + sectionTitle: { + fontSize: '16px', + fontWeight: 600, + color: 'var(--text-primary)' + }, + manifestButton: { + display: 'flex', + alignItems: 'center', + gap: '6px', + padding: '8px 16px', + background: 'var(--bg-elevated)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-md)', + color: 'var(--text-primary)', + fontSize: '14px', + fontWeight: 500, + cursor: 'pointer', + transition: 'var(--transition)' + } +}; + +export function TableView({ tableName }) { + const [schema, setSchema] = useState(null); + const [totalRows, setTotalRows] = useState(0); + const [loading, setLoading] = useState(true); + const [selectedColumns, setSelectedColumns] = useState([]); + const [showManifest, setShowManifest] = useState(false); + + const { showTooltip, hideTooltip } = useTooltip(); + + useEffect(() => { + fetchTableInfo(); + }, [tableName]); + + useEffect(() => { + // 加载保存的列选择 + if (tableName && schema) { + const saved = loadSelectedColumns(); + if (saved && saved.length > 0) { + const validColumns = saved.filter(col => + schema.fields.some(field => field.name === col) + ); + if (validColumns.length > 0) { + setSelectedColumns(validColumns); + } + } + } + }, [tableName, schema]); + + const fetchTableInfo = async () => { + try { + setLoading(true); + + // 获取 Schema + const schemaResponse = await fetch(`/api/tables/${tableName}/schema`); + if (schemaResponse.ok) { + const schemaData = await schemaResponse.json(); + setSchema(schemaData); + } + + // 获取数据行数(通过一次小查询) + const dataResponse = await fetch(`/api/tables/${tableName}/data?limit=1`); + if (dataResponse.ok) { + const data = await dataResponse.json(); + setTotalRows(data.totalRows || 0); + } + } catch (error) { + console.error('Failed to fetch table info:', error); + } finally { + setLoading(false); + } + }; + + const toggleColumn = (columnName) => { + const index = selectedColumns.indexOf(columnName); + let newSelection; + if (index > -1) { + newSelection = selectedColumns.filter(c => c !== columnName); + } else { + newSelection = [...selectedColumns, columnName]; + } + setSelectedColumns(newSelection); + saveSelectedColumns(newSelection); + }; + + const saveSelectedColumns = (columns) => { + if (!tableName) return; + const key = `srdb_columns_${tableName}`; + localStorage.setItem(key, JSON.stringify(columns)); + }; + + const loadSelectedColumns = () => { + if (!tableName) return null; + const key = `srdb_columns_${tableName}`; + const saved = localStorage.getItem(key); + return saved ? JSON.parse(saved) : null; + }; + + if (loading) { + return html`

加载中...

`; + } + + return html` +
+
+
+
+ schema?.comment && showTooltip(e.currentTarget, schema.comment)} + onMouseLeave=${hideTooltip} + > + ${tableName} + + + (共 ${formatCount(totalRows)} 行) + +
+
+ + ${schema && html` + <${ColumnSelector} + fields=${schema.fields} + selectedColumns=${selectedColumns} + onToggle=${toggleColumn} + /> + `} +
+
+ <${DataTable} + schema=${schema} + tableName=${tableName} + totalRows=${totalRows} + selectedColumns=${selectedColumns} + /> +
+ + ${showManifest && html` + <${ManifestModal} + tableName=${tableName} + onClose=${() => setShowManifest(false)} + /> + `} +
+ `; +} + +function formatCount(count) { + if (count >= 1000000) return (count / 1000000).toFixed(1) + 'M'; + if (count >= 1000) return (count / 1000).toFixed(1) + 'K'; + return count.toString(); +} diff --git a/webui/static/js/components/app.js b/webui/static/js/components/app.js deleted file mode 100644 index addb0e4..0000000 --- a/webui/static/js/components/app.js +++ /dev/null @@ -1,145 +0,0 @@ -import { LitElement, html, css } from 'lit'; -import { sharedStyles, cssVariables } from '~/common/shared-styles.js'; - -export class AppContainer extends LitElement { - static properties = { - mobileMenuOpen: { type: Boolean } - }; - - constructor() { - super(); - this.mobileMenuOpen = false; - } - - toggleMobileMenu() { - this.mobileMenuOpen = !this.mobileMenuOpen; - } - - static styles = [ - sharedStyles, - cssVariables, - css` - :host { - display: block; - height: 100vh; - } - - .container { - display: flex; - height: 100%; - overflow: hidden; - } - - .sidebar { - width: 280px; - background: var(--bg-surface); - border-right: 1px solid var(--border-color); - overflow-y: auto; - overflow-x: hidden; - padding: 16px 12px; - display: flex; - flex-direction: column; - gap: 8px; - } - - .sidebar::-webkit-scrollbar { - width: 6px; - } - - .sidebar-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 4px; - } - - .sidebar h1 { - font-size: 18px; - font-weight: 700; - letter-spacing: -0.02em; - background: linear-gradient(135deg, var(--primary-light), var(--primary)); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - } - - .main { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - background: var(--bg-main); - display: flex; - flex-direction: column; - } - - - /* 移动端遮罩 */ - .mobile-overlay { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.5); - z-index: 999; - } - - @media (max-width: 768px) { - .mobile-overlay.show { - display: block; - } - - .container { - flex-direction: column; - } - - .sidebar { - position: fixed; - top: 0; - left: 0; - width: 280px; - height: 100vh; - border-right: 1px solid var(--border-color); - border-bottom: none; - transform: translateX(-100%); - transition: transform 0.3s ease; - z-index: 1000; - } - - .sidebar.open { - transform: translateX(0); - } - - .main { - padding-top: 0; - } - } - ` - ]; - - render() { - return html` - -
- -
- - - - -
- - -
-
- `; - } -} - -customElements.define('srdb-app', AppContainer); diff --git a/webui/static/js/components/badge.js b/webui/static/js/components/badge.js deleted file mode 100644 index c34781c..0000000 --- a/webui/static/js/components/badge.js +++ /dev/null @@ -1,106 +0,0 @@ -import { LitElement, html, css } from 'lit'; -import { sharedStyles, cssVariables } from '~/common/shared-styles.js'; - -export class Badge extends LitElement { - static properties = { - variant: { type: String }, // 'primary', 'success', 'warning', 'danger', 'info' - icon: { type: String }, - size: { type: String } // 'sm', 'md' - }; - - static styles = [ - sharedStyles, - cssVariables, - css` - :host { - display: inline-flex; - } - - .badge { - position: relative; - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 8px; - font-size: 11px; - font-weight: 600; - border-radius: var(--radius-sm); - white-space: nowrap; - } - - .badge.size-md { - padding: 4px 10px; - font-size: 12px; - } - - /* Primary variant */ - .badge.variant-primary { - --badge-border-color: rgba(99, 102, 241, 0.2); - background: rgba(99, 102, 241, 0.15); - color: var(--primary); - } - - /* Success variant */ - .badge.variant-success { - --badge-border-color: rgba(16, 185, 129, 0.2); - background: rgba(16, 185, 129, 0.15); - color: var(--success); - } - - /* Warning variant */ - .badge.variant-warning { - --badge-border-color: rgba(245, 158, 11, 0.2); - background: rgba(245, 158, 11, 0.15); - color: var(--warning); - } - - /* Danger variant */ - .badge.variant-danger { - --badge-border-color: rgba(239, 68, 68, 0.2); - background: rgba(239, 68, 68, 0.15); - color: var(--danger); - } - - /* Info variant */ - .badge.variant-info { - --badge-border-color: rgba(59, 130, 246, 0.2); - background: rgba(59, 130, 246, 0.15); - color: var(--info); - } - - /* Secondary variant */ - .badge.variant-secondary { - --badge-border-color: rgba(160, 160, 176, 0.2); - background: rgba(160, 160, 176, 0.15); - color: var(--text-secondary); - } - - .icon { - font-size: 12px; - line-height: 1; - } - - .badge.size-md .icon { - font-size: 14px; - } - ` - ]; - - constructor() { - super(); - this.variant = 'primary'; - this.icon = ''; - this.size = 'sm'; - } - - render() { - return html` - - ${this.icon ? html`${this.icon}` : ''} - - - `; - } -} - -customElements.define('srdb-badge', Badge); diff --git a/webui/static/js/components/data-view.js b/webui/static/js/components/data-view.js deleted file mode 100644 index 74c70b8..0000000 --- a/webui/static/js/components/data-view.js +++ /dev/null @@ -1,549 +0,0 @@ -import { LitElement, html, css } from 'lit'; -import { sharedStyles, cssVariables } from '~/common/shared-styles.js'; -import domAlign from 'dom-align'; - -export class DataView extends LitElement { - static properties = { - tableName: { type: String }, - schema: { type: Object }, - tableData: { type: Object }, - selectedColumns: { type: Array }, - loading: { type: Boolean } - }; - - static styles = [ - sharedStyles, - cssVariables, - css` - :host { - display: block; - } - - h3 { - font-size: 16px; - font-weight: 600; - margin: 20px 0 12px 0; - color: var(--text-primary); - } - - .schema-section { - margin-bottom: 24px; - } - - .schema-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 12px; - } - - .schema-field-card { - padding: 12px; - background: var(--bg-elevated); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - cursor: pointer; - transition: var(--transition); - } - - .schema-field-card:hover { - background: var(--bg-hover); - } - - .schema-field-card.selected { - border-color: var(--primary); - background: var(--primary-bg); - } - - .field-item { - display: flex; - flex-direction: column; - gap: 4px; - } - - .field-item-row { - display: flex; - align-items: center; - gap: 8px; - } - - .field-index-icon { - font-size: 14px; - flex-shrink: 0; - } - - .field-name { - font-weight: 500; - color: var(--text-primary); - font-size: 13px; - flex: 1; - } - - .field-type { - font-family: 'Courier New', monospace; - } - - .field-comment { - color: var(--text-tertiary); - font-size: 11px; - margin-top: 4px; - font-style: italic; - min-height: 16px; - } - - .field-comment:empty::before { - content: "No comment"; - opacity: 0.5; - } - - .table-wrapper { - overflow-x: auto; - background: var(--bg-elevated); - border-radius: var(--radius-md); - border: 1px solid var(--border-color); - } - - .data-table { - width: 100%; - border-collapse: collapse; - font-size: 13px; - } - - .data-table th { - background: var(--bg-surface); - color: var(--text-secondary); - font-weight: 600; - text-align: left; - padding: 12px; - border-bottom: 1px solid var(--border-color); - position: sticky; - top: 0; - z-index: 1; - } - - .data-table td { - padding: 10px 12px; - border-bottom: 1px solid var(--border-color); - color: var(--text-primary); - max-width: 300px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - cursor: pointer; - } - - .data-table tr:hover { - background: var(--bg-hover); - } - - .truncated-icon { - margin-left: 4px; - font-size: 10px; - } - - .row-detail-btn { - padding: 4px 12px; - background: var(--primary); - color: white; - border: none; - border-radius: var(--radius-sm); - cursor: pointer; - font-size: 12px; - transition: var(--transition); - } - - .row-detail-btn:hover { - background: var(--primary-dark); - } - ` - ]; - - constructor() { - super(); - this.tableName = ''; - this.schema = null; - this.tableData = null; - this.selectedColumns = []; - this.loading = false; - this.hidePopoverTimeout = null; - this.popoverElement = null; - this.themeObserver = null; - } - - connectedCallback() { - super.connectedCallback(); - // 创建 popover 元素并添加到 body - this.createPopoverElement(); - } - - disconnectedCallback() { - super.disconnectedCallback(); - // 移除 popover 元素 - this.removePopoverElement(); - } - - createPopoverElement() { - if (this.popoverElement) return; - - this.popoverElement = document.createElement('div'); - this.popoverElement.className = 'srdb-cell-popover'; - - // 从 CSS 变量中获取主题颜色 - this.updatePopoverTheme(); - - // 添加滚动条样式(使用 CSS 变量) - if (!document.getElementById('srdb-popover-scrollbar-style')) { - const style = document.createElement('style'); - style.id = 'srdb-popover-scrollbar-style'; - style.textContent = ` - .srdb-cell-popover::-webkit-scrollbar { - width: 8px; - height: 8px; - } - .srdb-cell-popover::-webkit-scrollbar-track { - background: var(--bg-surface); - border-radius: 4px; - } - .srdb-cell-popover::-webkit-scrollbar-thumb { - background: var(--border-color); - border-radius: 4px; - } - .srdb-cell-popover::-webkit-scrollbar-thumb:hover { - background: var(--border-hover); - } - `; - document.head.appendChild(style); - } - - this.popoverElement.addEventListener('mouseenter', () => this.keepPopover()); - this.popoverElement.addEventListener('mouseleave', () => this.hidePopover()); - - document.body.appendChild(this.popoverElement); - - // 监听主题变化 - this.themeObserver = new MutationObserver(() => { - this.updatePopoverTheme(); - }); - this.themeObserver.observe(document.documentElement, { - attributes: true, - attributeFilter: ['data-theme'] - }); - } - - updatePopoverTheme() { - if (!this.popoverElement) return; - - const rootStyles = getComputedStyle(document.documentElement); - const bgElevated = rootStyles.getPropertyValue('--bg-elevated').trim(); - const textPrimary = rootStyles.getPropertyValue('--text-primary').trim(); - const borderColor = rootStyles.getPropertyValue('--border-color').trim(); - const shadowMd = rootStyles.getPropertyValue('--shadow-md').trim(); - const radiusMd = rootStyles.getPropertyValue('--radius-md').trim(); - - this.popoverElement.style.cssText = ` - position: fixed; - z-index: 9999; - background: ${bgElevated}; - border: 1px solid ${borderColor}; - border-radius: ${radiusMd}; - box-shadow: ${shadowMd}; - padding: 12px; - max-width: 500px; - max-height: 400px; - overflow: auto; - font-size: 13px; - color: ${textPrimary}; - white-space: pre-wrap; - word-break: break-word; - font-family: 'Courier New', monospace; - opacity: 0; - transition: opacity 0.15s ease-in-out, background 0.3s ease, color 0.3s ease, border-color 0.3s ease; - display: none; - pointer-events: auto; - `; - } - - removePopoverElement() { - if (this.popoverElement) { - this.popoverElement.remove(); - this.popoverElement = null; - } - if (this.themeObserver) { - this.themeObserver.disconnect(); - this.themeObserver = null; - } - } - - updated(changedProperties) { - // 当 tableName 或 schema 改变时,尝试加载保存的列选择 - if ((changedProperties.has('tableName') || changedProperties.has('schema')) && this.tableName && this.schema) { - const saved = this.loadSelectedColumns(); - if (saved && saved.length > 0) { - // 验证保存的列是否仍然存在于当前 schema 中 - const validColumns = saved.filter(col => - this.schema.fields.some(field => field.name === col) - ); - if (validColumns.length > 0) { - this.selectedColumns = validColumns; - } - } - } - } - - toggleColumn(columnName) { - const index = this.selectedColumns.indexOf(columnName); - if (index > -1) { - this.selectedColumns = this.selectedColumns.filter(c => c !== columnName); - } else { - this.selectedColumns = [...this.selectedColumns, columnName]; - } - - // 持久化到 localStorage - this.saveSelectedColumns(); - - this.dispatchEvent(new CustomEvent('columns-changed', { - detail: { columns: this.selectedColumns }, - bubbles: true, - composed: true - })); - } - - saveSelectedColumns() { - if (!this.tableName) return; - const key = `srdb_columns_${this.tableName}`; - localStorage.setItem(key, JSON.stringify(this.selectedColumns)); - } - - loadSelectedColumns() { - if (!this.tableName) return null; - const key = `srdb_columns_${this.tableName}`; - const saved = localStorage.getItem(key); - return saved ? JSON.parse(saved) : null; - } - - showRowDetail(seq) { - this.dispatchEvent(new CustomEvent('show-row-detail', { - detail: { tableName: this.tableName, seq }, - bubbles: true, - composed: true - })); - } - - formatCount(count) { - if (count >= 1000000) return (count / 1000000).toFixed(1) + 'M'; - if (count >= 1000) return (count / 1000).toFixed(1) + 'K'; - return count.toString(); - } - - formatTime(nanoTime) { - if (!nanoTime) return ''; - // 将纳秒转换为毫秒 - const date = new Date(nanoTime / 1000000); - // 格式化为 YYYY-MM-DD HH:mm:ss - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - const seconds = String(date.getSeconds()).padStart(2, '0'); - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; - } - - formatValue(value) { - // 处理 null 和 undefined - if (value === null) return 'null'; - if (value === undefined) return 'undefined'; - - // 处理 Object 和 Array 类型 - if (typeof value === 'object') { - try { - return JSON.stringify(value); - } catch (e) { - return '[Object]'; - } - } - - // 其他类型直接返回 - return value; - } - - showPopover(event, value, col) { - if (!this.popoverElement) return; - - // 清除之前的隐藏定时器 - if (this.hidePopoverTimeout) { - clearTimeout(this.hidePopoverTimeout); - this.hidePopoverTimeout = null; - } - - // 格式化值 - let content = col === '_time' ? this.formatTime(value) : this.formatValue(value); - - // 对于 JSON 对象/数组,格式化显示 - if (typeof value === 'object' && value !== null) { - try { - content = JSON.stringify(value, null, 2); - } catch (e) { - content = String(content); - } - } else { - content = String(content); - } - - // 只在内容较长时显示 popover - if (content.length < 50) { - return; - } - - // 更新 popover 内容 - this.popoverElement.textContent = content; - this.popoverElement.style.display = 'block'; - - // 使用 dom-align 进行智能定位 - // 减小间隙到 2px,方便鼠标移入 - domAlign(this.popoverElement, event.target, { - points: ['tl', 'tr'], // popover左上角 对齐到 单元格右上角 - offset: [2, 0], // 右侧间距仅2px,便于鼠标移入 - overflow: { adjustX: true, adjustY: true } - }); - - // 使用 setTimeout 确保 dom-align 完成后再显示 - setTimeout(() => { - if (this.popoverElement) { - this.popoverElement.style.opacity = '1'; - } - }, 10); - } - - hidePopover() { - if (!this.popoverElement) return; - - // 延迟隐藏,给用户足够时间移动鼠标到 popover(300ms) - this.hidePopoverTimeout = setTimeout(() => { - if (this.popoverElement) { - this.popoverElement.style.opacity = '0'; - // 等待动画完成后再隐藏 - setTimeout(() => { - if (this.popoverElement) { - this.popoverElement.style.display = 'none'; - } - }, 150); - } - }, 300); - } - - keepPopover() { - // 鼠标进入 popover 时,取消隐藏 - if (this.hidePopoverTimeout) { - clearTimeout(this.hidePopoverTimeout); - this.hidePopoverTimeout = null; - } - } - - getColumns() { - let columns = []; - - if (this.selectedColumns.length > 0) { - columns = [...this.selectedColumns]; - } else { - columns = this.schema?.fields?.map(f => f.name) || []; - } - - // 确保系统字段的顺序:_seq 在开头,_time 在倒数第二 - const filtered = columns.filter(c => c !== '_seq' && c !== '_time'); - - // _seq 放开头 - const result = ['_seq', ...filtered]; - - // _time 放倒数第二(Actions 列之前) - result.push('_time'); - - return result; - } - - render() { - if (this.loading || !this.schema || !this.tableData) { - return html`
Loading data...
`; - } - - const columns = this.getColumns(); - - return html` - ${this.renderSchemaSection()} - -

Data (${this.formatCount(this.tableData.totalRows)} rows)

- - ${this.tableData.data.length === 0 ? html` -

No data available

- ` : html` -
- - - - ${columns.map(col => html``)} - - - - - ${this.tableData.data.map(row => html` - - ${columns.map(col => html` - - `)} - - - `)} - -
${col}Actions
this.showPopover(e, row[col], col)} - @mouseleave=${() => this.hidePopover()} - > - ${col === '_time' ? this.formatTime(row[col]) : this.formatValue(row[col])} - ${row[col + '_truncated'] ? html`✂️` : ''} - - -
-
- `} - `; - } - - renderSchemaSection() { - if (!this.schema || !this.schema.fields) return ''; - - return html` -
-

Schema (点击字段卡片选择要显示的列)

-
- ${this.schema.fields.map(field => html` -
this.toggleColumn(field.name)} - > -
-
- - ${field.name} - - ${field.type} - -
-
${field.comment || ''}
-
-
- `)} -
-
- `; - } -} - -customElements.define('srdb-data-view', DataView); diff --git a/webui/static/js/components/field-icon.js b/webui/static/js/components/field-icon.js deleted file mode 100644 index e369663..0000000 --- a/webui/static/js/components/field-icon.js +++ /dev/null @@ -1,58 +0,0 @@ -import { LitElement, html, css } from 'lit'; - -export class FieldIcon extends LitElement { - static properties = { - indexed: { type: Boolean } - }; - static styles = css` - :host { - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - } - - svg { - width: 16px; - height: 16px; - } - - .indexed { - fill: var(--success); - color: var(--success); - opacity: 1; - } - - .not-indexed { - fill: var(--text-secondary); - color: var(--text-secondary); - opacity: 0.6; - } - `; - - constructor() { - super(); - this.indexed = false; - } - - render() { - if (this.indexed) { - // 闪电图标 - 已索引(快速) - return html` - - - - `; - } else { - // 圆点图标 - 未索引 - return html` - - - - `; - } - } -} - -customElements.define('srdb-field-icon', FieldIcon); diff --git a/webui/static/js/components/manifest-view.js b/webui/static/js/components/manifest-view.js deleted file mode 100644 index 3953f0f..0000000 --- a/webui/static/js/components/manifest-view.js +++ /dev/null @@ -1,320 +0,0 @@ -import { LitElement, html, css } from 'lit'; -import { sharedStyles, cssVariables } from '~/common/shared-styles.js'; - -export class ManifestView extends LitElement { - static properties = { - manifestData: { type: Object }, - loading: { type: Boolean }, - expandedLevels: { type: Set } - }; - - static styles = [ - sharedStyles, - cssVariables, - css` - :host { - display: block; - } - - h3 { - font-size: 16px; - font-weight: 600; - margin: 20px 0 12px 0; - color: var(--text-primary); - } - - .manifest-stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 16px; - margin-bottom: 24px; - } - - .stat-card { - padding: 16px; - background: var(--bg-elevated); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - } - - .stat-label { - font-size: 12px; - color: var(--text-tertiary); - margin-bottom: 8px; - } - - .stat-value { - font-size: 20px; - font-weight: 600; - color: var(--text-primary); - } - - .level-card { - margin-bottom: 12px; - background: var(--bg-elevated); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - overflow: hidden; - } - - .level-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px; - cursor: pointer; - transition: var(--transition); - } - - .level-header:hover { - background: var(--bg-hover); - } - - .level-header-left { - display: flex; - align-items: center; - gap: 12px; - flex: 1; - } - - .expand-icon { - font-size: 12px; - color: var(--text-secondary); - transition: transform 0.2s ease; - user-select: none; - } - - .expand-icon.expanded { - transform: rotate(90deg); - } - - .level-title { - font-size: 16px; - font-weight: 600; - color: var(--text-primary); - } - - .level-stats { - display: flex; - gap: 16px; - font-size: 13px; - color: var(--text-secondary); - } - - .level-files { - padding: 16px; - background: var(--bg-surface); - border-top: 1px solid var(--border-color); - display: none; - } - - .level-files.expanded { - display: block; - } - - .file-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 12px; - } - - .file-item { - padding: 12px; - background: var(--bg-elevated); - border: 1px solid var(--border-color); - border-radius: var(--radius-sm); - transition: var(--transition); - } - - .file-item:hover { - border-color: var(--primary); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - } - - .file-name { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; - } - - .file-name-text { - font-family: 'Courier New', monospace; - font-size: 13px; - color: var(--text-primary); - font-weight: 500; - } - - .file-level-badge { - font-size: 11px; - padding: 2px 8px; - background: var(--primary-bg); - color: var(--primary); - border-radius: var(--radius-sm); - font-weight: 600; - font-family: 'Courier New', monospace; - } - - .file-detail { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 12px; - color: var(--text-secondary); - } - - .file-detail-row { - display: flex; - justify-content: space-between; - } - - @media (max-width: 768px) { - .file-list { - grid-template-columns: 1fr; - } - } - - .empty { - background: var(--bg-elevated); - border-radius: var(--radius-md); - border: 1px dashed var(--border-color); - margin-top: 24px; - } - - .empty p { - margin: 0; - } - ` - ]; - - constructor() { - super(); - this.manifestData = null; - this.loading = false; - this.expandedLevels = new Set(); - } - - toggleLevel(levelNum) { - if (this.expandedLevels.has(levelNum)) { - this.expandedLevels.delete(levelNum); - } else { - this.expandedLevels.add(levelNum); - } - this.requestUpdate(); - } - - formatSize(bytes) { - if (bytes >= 1024 * 1024 * 1024) return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; - if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; - if (bytes >= 1024) return (bytes / 1024).toFixed(2) + ' KB'; - return bytes + ' B'; - } - - getScoreVariant(score) { - if (score >= 0.8) return 'danger'; // 高分 = 需要紧急 compaction - if (score >= 0.5) return 'warning'; // 中分 = 需要关注 - return 'success'; // 低分 = 健康状态 - } - - render() { - if (this.loading || !this.manifestData) { - return html`
Loading manifest...
`; - } - - const totalFiles = this.manifestData.levels.reduce((sum, l) => sum + l.file_count, 0); - const totalSize = this.manifestData.levels.reduce((sum, l) => sum + l.total_size, 0); - const totalCompactions = this.manifestData.compaction_stats?.total_compactions || 0; - - return html` -

LSM-Tree Structure

- -
-
-
Active Levels
-
${this.manifestData.levels.filter(l => l.file_count > 0).length}
-
-
-
Total Files
-
${totalFiles}
-
-
-
Total Size
-
${this.formatSize(totalSize)}
-
-
-
Compactions
-
${totalCompactions}
-
-
- - ${this.manifestData.levels && this.manifestData.levels.length > 0 - ? this.manifestData.levels.map(level => this.renderLevelCard(level)) - : html` -
-

No SSTable files in this table yet.

-

Insert some data to see the LSM-Tree structure.

-
- ` - } - `; - } - - renderLevelCard(level) { - if (level.file_count === 0) return ''; - - const isExpanded = this.expandedLevels.has(level.level); - - return html` -
-
this.toggleLevel(level.level)}> -
- -
-
Level ${level.level}
-
- ${level.file_count} files - ${this.formatSize(level.total_size)} - ${level.score !== undefined ? html` - - Score: ${(level.score * 100).toFixed(0)}% - - ` : ''} -
-
-
-
- - ${level.files && level.files.length > 0 ? html` -
-
- ${level.files.map(file => html` -
-
- ${file.file_number}.sst - L${level.level} -
-
-
- Size: - ${this.formatSize(file.file_size)} -
-
- Rows: - ${file.row_count || 0} -
-
- Seq Range: - ${file.min_key} - ${file.max_key} -
-
-
- `)} -
-
- ` : ''} -
- `; - } -} - -customElements.define('srdb-manifest-view', ManifestView); diff --git a/webui/static/js/components/modal-dialog.js b/webui/static/js/components/modal-dialog.js deleted file mode 100644 index 6e19f65..0000000 --- a/webui/static/js/components/modal-dialog.js +++ /dev/null @@ -1,179 +0,0 @@ -import { LitElement, html, css } from 'lit'; -import { sharedStyles, cssVariables } from '~/common/shared-styles.js'; - -export class ModalDialog extends LitElement { - static properties = { - open: { type: Boolean }, - title: { type: String }, - content: { type: String } - }; - - static styles = [ - sharedStyles, - cssVariables, - css` - :host { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.7); - z-index: 1000; - align-items: center; - justify-content: center; - } - - :host([open]) { - display: flex; - } - - .modal-content { - background: var(--bg-surface); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-xl); - min-width: 324px; - max-width: 90vw; - max-height: 80vh; - width: fit-content; - display: flex; - flex-direction: column; - border: 1px solid var(--border-color); - } - - @media (max-width: 768px) { - .modal-content { - min-width: 300px; - max-width: 95vw; - } - } - - .modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px 24px; - border-bottom: 1px solid var(--border-color); - } - - .modal-header h3 { - margin: 0; - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - } - - .modal-close { - background: transparent; - border: none; - color: var(--text-secondary); - cursor: pointer; - padding: 4px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-sm); - transition: var(--transition); - } - - .modal-close:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - - .modal-body { - padding: 24px; - overflow-y: auto; - flex: 1; - } - - pre { - /*background: var(--bg-elevated);*/ - padding: 16px; - border-radius: var(--radius-md); - overflow-x: auto; - color: var(--text-primary); - font-family: 'Courier New', monospace; - font-size: 13px; - line-height: 1.5; - margin: 0; - white-space: pre-wrap; - word-wrap: break-word; - } - ` - ]; - - constructor() { - super(); - this.open = false; - this.title = 'Content'; - this.content = ''; - this._handleKeyDown = this._handleKeyDown.bind(this); - } - - connectedCallback() { - super.connectedCallback(); - document.addEventListener('keydown', this._handleKeyDown); - } - - disconnectedCallback() { - super.disconnectedCallback(); - document.removeEventListener('keydown', this._handleKeyDown); - } - - updated(changedProperties) { - if (changedProperties.has('open')) { - if (this.open) { - this.setAttribute('open', ''); - } else { - this.removeAttribute('open'); - } - } - } - - _handleKeyDown(e) { - if (this.open && e.key === 'Escape') { - this.close(); - } - } - - close() { - this.open = false; - this.dispatchEvent(new CustomEvent('modal-close', { - bubbles: true, - composed: true - })); - } - - render() { - return html` - - `; - } -} - -customElements.define('srdb-modal-dialog', ModalDialog); diff --git a/webui/static/js/components/page-header.js b/webui/static/js/components/page-header.js deleted file mode 100644 index 0364812..0000000 --- a/webui/static/js/components/page-header.js +++ /dev/null @@ -1,267 +0,0 @@ -import { LitElement, html, css } from 'lit'; -import { sharedStyles, cssVariables } from '~/common/shared-styles.js'; - -export class PageHeader extends LitElement { - static properties = { - tableName: { type: String }, - view: { type: String } - }; - - static styles = [ - sharedStyles, - cssVariables, - css` - :host { - display: block; - background: var(--bg-surface); - border-bottom: 1px solid var(--border-color); - position: sticky; - top: 0; - z-index: 10; - } - - .header-content { - padding: 16px 24px; - } - - .header-top { - display: flex; - align-items: center; - gap: 16px; - margin-bottom: 12px; - } - - .mobile-menu-btn { - display: none; - width: 40px; - height: 40px; - background: var(--primary); - border: none; - border-radius: var(--radius-md); - color: white; - cursor: pointer; - flex-shrink: 0; - transition: var(--transition); - } - - .mobile-menu-btn:hover { - background: var(--primary-dark); - } - - .mobile-menu-btn svg { - width: 20px; - height: 20px; - } - - h2 { - font-size: 24px; - font-weight: 600; - margin: 0; - color: var(--text-primary); - } - - .empty-state { - text-align: center; - padding: 20px; - color: var(--text-secondary); - } - - .view-tabs { - display: flex; - align-items: center; - gap: 8px; - border-bottom: 1px solid var(--border-color); - margin: 0 -12px; - padding: 0 24px; - } - - .refresh-btn { - margin-left: auto; - padding: 8px 12px; - background: transparent; - border: 1px solid var(--border-color); - border-radius: var(--radius-sm); - color: var(--text-secondary); - cursor: pointer; - transition: var(--transition); - display: flex; - align-items: center; - gap: 6px; - font-size: 13px; - } - - .refresh-btn:hover { - background: var(--bg-hover); - border-color: var(--border-hover); - color: var(--text-primary); - } - - .refresh-btn svg { - width: 16px; - height: 16px; - } - - .view-tab { - position: relative; - padding: 16px 20px; - background: transparent; - border: none; - color: var(--text-secondary); - cursor: pointer; - font-size: 14px; - font-weight: 500; - transition: var(--transition); - } - - .view-tab:hover { - color: var(--text-primary); - /*background: var(--bg-hover);*/ - } - - .view-tab.active { - color: var(--primary); - } - - .view-tab::before, - .view-tab::after { - position: absolute; - display: block; - content: ""; - z-index: 0; - opacity: 0; - inset-inline: 8px; - } - - .view-tab::before { - inset-block: 8px; - border-radius: var(--radius-md); - background: var(--bg-elevated); - } - - .view-tab::after { - border-radius: var(--radius-md); - background: var(--primary); - bottom: -2px; - height: 4px; - } - - .view-tab.active::before, - .view-tab.active::after, - .view-tab:hover::before { - opacity: 1; - } - - .view-tab span { - position: relative; - z-index: 1; - } - - @media (max-width: 768px) { - .mobile-menu-btn { - display: flex; - align-items: center; - justify-content: center; - } - - .header-content { - padding: 12px 16px; - } - - h2 { - font-size: 18px; - flex: 1; - } - - .view-tabs { - margin: 0 -16px; - padding: 0 16px; - } - - .view-tab { - padding: 8px 16px; - font-size: 13px; - } - } - ` - ]; - - constructor() { - super(); - this.tableName = ''; - this.view = 'data'; - } - - switchView(newView) { - this.view = newView; - this.dispatchEvent(new CustomEvent('view-changed', { - detail: { view: newView }, - bubbles: true, - composed: true - })); - } - - toggleMobileMenu() { - this.dispatchEvent(new CustomEvent('toggle-mobile-menu', { - bubbles: true, - composed: true - })); - } - - refreshView() { - this.dispatchEvent(new CustomEvent('refresh-view', { - detail: { view: this.view }, - bubbles: true, - composed: true - })); - } - - render() { - if (!this.tableName) { - return html` -
-
-

Select a table from the sidebar

-
-
- `; - } - - return html` -
-
- -

${this.tableName}

-
-
- -
- - - -
- `; - } -} - -customElements.define('srdb-page-header', PageHeader); diff --git a/webui/static/js/components/table-list.js b/webui/static/js/components/table-list.js deleted file mode 100644 index a252f87..0000000 --- a/webui/static/js/components/table-list.js +++ /dev/null @@ -1,283 +0,0 @@ -import { LitElement, html, css } from 'lit'; -import { sharedStyles, cssVariables } from '~/common/shared-styles.js'; -import { tableAPI } from '~/common/api.js'; - -export class TableList extends LitElement { - static properties = { - tables: { type: Array }, - selectedTable: { type: String }, - expandedTables: { type: Set } - }; - - static styles = [ - sharedStyles, - cssVariables, - css` - :host { - display: block; - width: 100%; - } - - .table-item { - margin-bottom: 8px; - background: var(--bg-elevated); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - overflow: hidden; - transition: var(--transition); - } - - .table-item:hover { - border-color: var(--border-hover); - } - - .table-item.selected { - border-color: var(--primary); - box-shadow: 0 0 0 1px var(--primary); - } - - .table-item.selected .table-header { - background: var(--primary-bg); - } - - .table-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 12px; - cursor: pointer; - transition: var(--transition); - } - - .table-header:hover { - background: var(--bg-hover); - } - - .table-item.has-expanded .table-header { - border-bottom-color: var(--border-color); - } - - .table-header-left { - display: flex; - align-items: center; - gap: 8px; - flex: 1; - min-width: 0; - } - - .expand-icon { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - font-size: 12px; - transition: var(--transition); - flex-shrink: 0; - border-radius: var(--radius-sm); - cursor: pointer; - color: var(--text-secondary); - } - - .expand-icon:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - - .expand-icon.expanded { - transform: rotate(90deg); - color: var(--primary); - } - - .table-name { - font-weight: 500; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - .table-count { - font-size: 12px; - color: var(--text-tertiary); - white-space: nowrap; - flex-shrink: 0; - } - - /* Schema 字段列表 */ - .schema-fields { - display: none; - flex-direction: column; - border-top: 1px solid transparent; - transition: var(--transition); - position: relative; - } - - .schema-fields.expanded { - display: block; - border-top-color: var(--border-color); - } - - /* 共享的垂直线 */ - .schema-fields.expanded::before { - z-index: 2; - content: ""; - position: absolute; - left: 24px; - top: 0; - bottom: 24px; - width: 1px; - background: var(--border-color); - } - - .field-item { - z-index: 1; - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px 8px 24px; - font-size: 12px; - transition: var(--transition); - position: relative; - } - - /* 每个字段的水平线 */ - .field-item::before { - content: ""; - width: 8px; - height: 1px; - background: var(--border-color); - } - - .field-item:hover { - background: var(--bg-hover); - } - - .field-item:last-child { - padding-bottom: 12px; - } - - .field-index-icon { - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - font-size: 14px; - flex-shrink: 0; - } - - .field-index-icon.indexed { - color: var(--success); - } - - .field-index-icon.not-indexed { - color: var(--text-tertiary); - opacity: 0.5; - } - - .field-name { - font-weight: 500; - color: var(--text-secondary); - flex: 1; - } - .field-type { - font-family: 'Courier New', monospace; - } - - .loading { - padding: 20px; - } - - .error { - text-align: center; - padding: 20px; - color: var(--danger); - } - ` - ]; - - constructor() { - super(); - this.tables = []; - this.selectedTable = ''; - this.expandedTables = new Set(); - } - - connectedCallback() { - super.connectedCallback(); - this.loadTables(); - } - - async loadTables() { - try { - this.tables = await tableAPI.list(); - } catch (error) { - console.error('Error loading tables:', error); - } - } - - toggleExpand(tableName, event) { - event.stopPropagation(); - if (this.expandedTables.has(tableName)) { - this.expandedTables.delete(tableName); - } else { - this.expandedTables.add(tableName); - } - this.requestUpdate(); - } - - selectTable(tableName) { - this.selectedTable = tableName; - this.dispatchEvent(new CustomEvent('table-selected', { - detail: { tableName }, - bubbles: true, - composed: true - })); - } - - render() { - if (this.tables.length === 0) { - return html`
Loading tables...
`; - } - - return html` - ${this.tables.map(table => html` -
-
this.selectTable(table.name)} - > -
- this.toggleExpand(table.name, e)} - > - ▶ - - ${table.name} -
- ${table.fields.length} fields -
- -
- ${table.fields.map(field => html` -
- - ${field.name} - - ${field.type} - -
- `)} -
-
- `)} - `; - } -} - -customElements.define('srdb-table-list', TableList); diff --git a/webui/static/js/components/table-view.js b/webui/static/js/components/table-view.js deleted file mode 100644 index 64f1df4..0000000 --- a/webui/static/js/components/table-view.js +++ /dev/null @@ -1,376 +0,0 @@ -import { LitElement, html, css } from 'lit'; -import { sharedStyles, cssVariables } from '~/common/shared-styles.js'; -import { tableAPI } from '~/common/api.js'; - -export class TableView extends LitElement { - static properties = { - tableName: { type: String }, - view: { type: String }, // 'data' or 'manifest' - schema: { type: Object }, - tableData: { type: Object }, - manifestData: { type: Object }, - selectedColumns: { type: Array }, - page: { type: Number }, - pageSize: { type: Number }, - loading: { type: Boolean } - }; - - static styles = [ - sharedStyles, - cssVariables, - css` - :host { - display: flex; - flex-direction: column; - width: 100%; - flex: 1; - position: relative; - overflow: hidden; - } - - .content-wrapper { - flex: 1; - overflow-y: auto; - padding: 24px; - padding-bottom: 80px; - } - - .pagination { - position: fixed; - bottom: 0; - left: 280px; - right: 0; - display: flex; - align-items: center; - justify-content: center; - gap: 12px; - padding: 16px 24px; - background: var(--bg-elevated); - border-top: 1px solid var(--border-color); - z-index: 10; - } - - @media (max-width: 768px) { - .pagination { - left: 0; - } - } - - .pagination button { - padding: 8px 16px; - background: var(--bg-surface); - color: var(--text-primary); - border: 1px solid var(--border-color); - border-radius: var(--radius-sm); - cursor: pointer; - transition: var(--transition); - } - - .pagination button:hover:not(:disabled) { - background: var(--bg-hover); - border-color: var(--border-hover); - } - - .pagination button:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - .pagination select, - .pagination input { - padding: 8px 12px; - background: var(--bg-surface); - color: var(--text-primary); - border: 1px solid var(--border-color); - border-radius: var(--radius-sm); - } - - .pagination input { - width: 80px; - } - - .pagination span { - color: var(--text-primary); - font-size: 14px; - } - ` - ]; - - constructor() { - super(); - this.tableName = ''; - this.view = 'data'; - this.schema = null; - this.tableData = null; - this.manifestData = null; - this.selectedColumns = []; - this.page = 1; - this.pageSize = 20; - this.loading = false; - } - - updated(changedProperties) { - if (changedProperties.has('tableName') && this.tableName) { - // 切换表时重置选中的列 - this.selectedColumns = []; - this.page = 1; - this.loadData(); - } - if (changedProperties.has('view') && this.tableName) { - this.loadData(); - } - } - - async loadData() { - if (!this.tableName) return; - - this.loading = true; - - try { - // Load schema - this.schema = await tableAPI.getSchema(this.tableName); - - // Initialize selected columns from localStorage or all by default - if (this.schema.fields) { - const saved = this.loadSelectedColumns(); - if (saved && saved.length > 0) { - // 验证保存的列是否仍然存在于当前 schema 中 - const validColumns = saved.filter(col => - this.schema.fields.some(field => field.name === col) - ); - this.selectedColumns = validColumns.length > 0 ? validColumns : this.schema.fields.map(f => f.name); - } else { - this.selectedColumns = this.schema.fields.map(f => f.name); - } - } - - if (this.view === 'data') { - await this.loadTableData(); - } else if (this.view === 'manifest') { - await this.loadManifestData(); - } - } catch (error) { - console.error('Error loading data:', error); - } finally { - this.loading = false; - } - } - - async loadTableData() { - this.tableData = await tableAPI.getData(this.tableName, { - page: this.page, - pageSize: this.pageSize, - select: this.selectedColumns.join(',') - }); - } - - async loadManifestData() { - this.manifestData = await tableAPI.getManifest(this.tableName); - } - - switchView(newView) { - this.view = newView; - } - - loadSelectedColumns() { - if (!this.tableName) return null; - const key = `srdb_columns_${this.tableName}`; - const saved = localStorage.getItem(key); - return saved ? JSON.parse(saved) : null; - } - - toggleColumn(columnName) { - const index = this.selectedColumns.indexOf(columnName); - if (index > -1) { - this.selectedColumns = this.selectedColumns.filter(c => c !== columnName); - } else { - this.selectedColumns = [...this.selectedColumns, columnName]; - } - this.loadTableData(); - } - - changePage(delta) { - this.page = Math.max(1, this.page + delta); - this.loadTableData(); - } - - changePageSize(newSize) { - this.pageSize = parseInt(newSize); - this.page = 1; - this.loadTableData(); - } - - jumpToPage(pageNum) { - const num = parseInt(pageNum); - if (num > 0 && this.tableData && num <= this.tableData.totalPages) { - this.page = num; - this.loadTableData(); - } - } - - showRowDetail(seq) { - this.dispatchEvent(new CustomEvent('show-row-detail', { - detail: { tableName: this.tableName, seq }, - bubbles: true, - composed: true - })); - } - - toggleLevel(level) { - const levelCard = this.shadowRoot.querySelector(`[data-level="${level}"]`); - if (levelCard) { - const fileList = levelCard.querySelector('.file-list'); - const icon = levelCard.querySelector('.expand-icon'); - if (fileList.classList.contains('expanded')) { - fileList.classList.remove('expanded'); - icon.style.transform = 'rotate(0deg)'; - } else { - fileList.classList.add('expanded'); - icon.style.transform = 'rotate(90deg)'; - } - } - } - - formatBytes(bytes) { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i]; - } - - formatCount(count) { - if (count >= 1000000) return (count / 1000000).toFixed(1) + 'M'; - if (count >= 1000) return (count / 1000).toFixed(1) + 'K'; - return count.toString(); - } - - render() { - if (!this.tableName) { - return html` -
-

Select a table to view data

-

Choose a table from the sidebar to get started

-
- `; - } - - if (this.loading) { - return html`
Loading...
`; - } - - return html` -
- ${this.view === 'data' ? html` - { - this.selectedColumns = e.detail.columns; - this.loadTableData(); - }} - @show-row-detail=${(e) => this.showRowDetail(e.detail.seq)} - > - ` : html` - - `} -
- - ${this.view === 'data' && this.tableData ? this.renderPagination() : ''} - `; - } - - renderPagination() { - return html` - - `; - } - - formatCount(count) { - if (count >= 1000000) return (count / 1000000).toFixed(1) + 'M'; - if (count >= 1000) return (count / 1000).toFixed(1) + 'K'; - return count.toString(); - } - - changePage(delta) { - this.page = Math.max(1, this.page + delta); - this.loadTableData(); - } - - changePageSize(newSize) { - this.pageSize = parseInt(newSize); - this.page = 1; - this.loadTableData(); - } - - jumpToPage(pageNum) { - const num = parseInt(pageNum); - if (num > 0 && this.tableData && num <= this.tableData.totalPages) { - this.page = num; - this.loadTableData(); - } - } - - showRowDetail(seq) { - this.dispatchEvent(new CustomEvent('show-row-detail', { - detail: { tableName: this.tableName, seq }, - bubbles: true, - composed: true - })); - } - - showCellContent(content) { - this.dispatchEvent(new CustomEvent('show-cell-content', { - detail: { content }, - bubbles: true, - composed: true - })); - } -} - -customElements.define('srdb-table-view', TableView); diff --git a/webui/static/js/components/theme-toggle.js b/webui/static/js/components/theme-toggle.js deleted file mode 100644 index 932ae7e..0000000 --- a/webui/static/js/components/theme-toggle.js +++ /dev/null @@ -1,123 +0,0 @@ -import { LitElement, html, css } from 'lit'; -import { sharedStyles } from '~/common/shared-styles.js'; - -export class ThemeToggle extends LitElement { - static properties = { - theme: { type: String } - }; - - static styles = [ - sharedStyles, - css` - :host { - display: inline-block; - } - - .theme-toggle { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - background: var(--bg-elevated); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - cursor: pointer; - transition: var(--transition); - font-size: 14px; - color: var(--text-primary); - } - - .theme-toggle:hover { - background: var(--bg-hover); - border-color: var(--border-hover); - } - - .icon { - font-size: 18px; - display: flex; - align-items: center; - } - - .label { - font-weight: 500; - } - ` - ]; - - constructor() { - super(); - // 从 localStorage 读取主题,默认为 dark - this.theme = localStorage.getItem('srdb-theme') || 'dark'; - this.applyTheme(); - } - - toggleTheme() { - this.theme = this.theme === 'dark' ? 'light' : 'dark'; - localStorage.setItem('srdb-theme', this.theme); - this.applyTheme(); - - // 触发主题变化事件 - this.dispatchEvent(new CustomEvent('theme-changed', { - detail: { theme: this.theme }, - bubbles: true, - composed: true - })); - } - - applyTheme() { - const root = document.documentElement; - - if (this.theme === 'light') { - // 浅色主题 - root.style.setProperty('--srdb-bg-main', '#ffffff'); - root.style.setProperty('--srdb-bg-surface', '#f5f5f5'); - root.style.setProperty('--srdb-bg-elevated', '#e5e5e5'); - root.style.setProperty('--srdb-bg-hover', '#d4d4d4'); - - root.style.setProperty('--srdb-text-primary', '#1a1a1a'); - root.style.setProperty('--srdb-text-secondary', '#666666'); - root.style.setProperty('--srdb-text-tertiary', '#999999'); - - root.style.setProperty('--srdb-border-color', 'rgba(0, 0, 0, 0.1)'); - root.style.setProperty('--srdb-border-hover', 'rgba(0, 0, 0, 0.2)'); - - root.style.setProperty('--srdb-shadow-sm', '0 1px 2px 0 rgba(0, 0, 0, 0.05)'); - root.style.setProperty('--srdb-shadow-md', '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)'); - root.style.setProperty('--srdb-shadow-lg', '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)'); - root.style.setProperty('--srdb-shadow-xl', '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)'); - } else { - // 深色主题(默认值) - root.style.setProperty('--srdb-bg-main', '#0f0f1a'); - root.style.setProperty('--srdb-bg-surface', '#1a1a2e'); - root.style.setProperty('--srdb-bg-elevated', '#222236'); - root.style.setProperty('--srdb-bg-hover', '#2a2a3e'); - - root.style.setProperty('--srdb-text-primary', '#ffffff'); - root.style.setProperty('--srdb-text-secondary', '#a0a0b0'); - root.style.setProperty('--srdb-text-tertiary', '#6b6b7b'); - - root.style.setProperty('--srdb-border-color', 'rgba(255, 255, 255, 0.1)'); - root.style.setProperty('--srdb-border-hover', 'rgba(255, 255, 255, 0.2)'); - - root.style.setProperty('--srdb-shadow-sm', '0 1px 2px 0 rgba(0, 0, 0, 0.3)'); - root.style.setProperty('--srdb-shadow-md', '0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3)'); - root.style.setProperty('--srdb-shadow-lg', '0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3)'); - root.style.setProperty('--srdb-shadow-xl', '0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.3)'); - } - } - - render() { - return html` - - `; - } -} - -customElements.define('srdb-theme-toggle', ThemeToggle); diff --git a/webui/static/js/hooks/useCellPopover.js b/webui/static/js/hooks/useCellPopover.js new file mode 100644 index 0000000..5464767 --- /dev/null +++ b/webui/static/js/hooks/useCellPopover.js @@ -0,0 +1,155 @@ +import { useEffect, useRef } from 'preact/hooks'; +import domAlign from 'dom-align'; + +export function useCellPopover() { + const popoverRef = useRef(null); + const hideTimeoutRef = useRef(null); + const targetCellRef = useRef(null); + + useEffect(() => { + // 创建 popover 元素 + const popover = document.createElement('div'); + popover.className = 'srdb-popover'; + updatePopoverStyles(popover); + document.body.appendChild(popover); + popoverRef.current = popover; + + // 添加样式 + addPopoverStyles(); + + // 监听主题变化 + const observer = new MutationObserver(() => { + updatePopoverStyles(popover); + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'] + }); + + // 清理 + return () => { + if (popover) { + popover.remove(); + } + observer.disconnect(); + }; + }, []); + + const updatePopoverStyles = (popover) => { + const rootStyles = getComputedStyle(document.documentElement); + const bgElevated = rootStyles.getPropertyValue('--bg-elevated').trim(); + const textPrimary = rootStyles.getPropertyValue('--text-primary').trim(); + const borderColor = rootStyles.getPropertyValue('--border-color').trim(); + const shadowMd = rootStyles.getPropertyValue('--shadow-md').trim(); + + popover.style.cssText = ` + position: fixed; + z-index: 9999; + background: ${bgElevated}; + border: 1px solid ${borderColor}; + border-radius: 8px; + box-shadow: ${shadowMd}; + padding: 12px; + max-width: 500px; + max-height: 400px; + overflow: auto; + font-size: 13px; + color: ${textPrimary}; + white-space: pre-wrap; + word-break: break-word; + font-family: 'Courier New', monospace; + opacity: 0; + transition: opacity 0.15s ease-in-out, background 0.3s ease, color 0.3s ease; + display: none; + pointer-events: auto; + `; + }; + + const addPopoverStyles = () => { + if (document.getElementById('srdb-popover-styles')) return; + + const style = document.createElement('style'); + style.id = 'srdb-popover-styles'; + style.textContent = ` + .srdb-popover::-webkit-scrollbar { + width: 8px; + height: 8px; + } + .srdb-popover::-webkit-scrollbar-track { + background: var(--bg-surface); + border-radius: 4px; + } + .srdb-popover::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; + } + .srdb-popover::-webkit-scrollbar-thumb:hover { + background: var(--border-hover); + } + `; + document.head.appendChild(style); + }; + + const showPopover = (e, content) => { + if (!popoverRef.current) return; + + // 清除隐藏定时器 + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + + // 只在内容较长时显示 + if (content.length < 50) { + return; + } + + // 更新 popover + popoverRef.current.textContent = content; + popoverRef.current.style.display = 'block'; + targetCellRef.current = e.target; + + // 使用 dom-align 定位 + domAlign(popoverRef.current, e.target, { + points: ['tl', 'tr'], + offset: [2, 0], + overflow: { adjustX: true, adjustY: true } + }); + + // 显示动画 + setTimeout(() => { + if (popoverRef.current) { + popoverRef.current.style.opacity = '1'; + } + }, 10); + + // 添加鼠标事件监听 + popoverRef.current.addEventListener('mouseenter', keepPopover); + popoverRef.current.addEventListener('mouseleave', hidePopover); + }; + + const hidePopover = () => { + hideTimeoutRef.current = setTimeout(() => { + if (popoverRef.current) { + popoverRef.current.style.opacity = '0'; + setTimeout(() => { + if (popoverRef.current) { + popoverRef.current.style.display = 'none'; + } + }, 150); + } + }, 300); + }; + + const keepPopover = () => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + }; + + return { + showPopover, + hidePopover + }; +} diff --git a/webui/static/js/hooks/useTooltip.js b/webui/static/js/hooks/useTooltip.js new file mode 100644 index 0000000..f38d65d --- /dev/null +++ b/webui/static/js/hooks/useTooltip.js @@ -0,0 +1,76 @@ +import { useRef, useEffect } from 'preact/hooks'; +import align from 'dom-align'; + +export function useTooltip() { + const tooltipRef = useRef(null); + const hideTimeoutRef = useRef(null); + + useEffect(() => { + // 创建 tooltip 元素 + const tooltip = document.createElement('div'); + tooltip.className = 'srdb-tooltip'; + tooltip.style.cssText = ` + position: absolute; + z-index: 1000; + background: var(--bg-elevated); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 8px 12px; + font-size: 13px; + color: var(--text-primary); + box-shadow: var(--shadow-lg); + max-width: 300px; + word-wrap: break-word; + display: none; + pointer-events: none; + `; + document.body.appendChild(tooltip); + tooltipRef.current = tooltip; + + return () => { + if (tooltipRef.current) { + document.body.removeChild(tooltipRef.current); + } + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + } + }; + }, []); + + const showTooltip = (target, comment) => { + if (!tooltipRef.current || !comment) return; + + // 清除之前的隐藏定时器 + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + + const tooltip = tooltipRef.current; + tooltip.textContent = comment; + tooltip.style.display = 'block'; + + // 使用 dom-align 对齐到目标元素下方 + align(tooltip, target, { + points: ['tc', 'bc'], + offset: [0, -8], + overflow: { + adjustX: true, + adjustY: true + } + }); + }; + + const hideTooltip = () => { + if (!tooltipRef.current) return; + + // 延迟隐藏,避免闪烁 + hideTimeoutRef.current = setTimeout(() => { + if (tooltipRef.current) { + tooltipRef.current.style.display = 'none'; + } + }, 100); + }; + + return { showTooltip, hideTooltip }; +} diff --git a/webui/static/js/main.js b/webui/static/js/main.js index 9180d36..d12d574 100644 --- a/webui/static/js/main.js +++ b/webui/static/js/main.js @@ -1,108 +1,6 @@ -import '~/components/app.js'; -import '~/components/table-list.js'; -import '~/components/table-view.js'; -import '~/components/modal-dialog.js'; -import '~/components/theme-toggle.js'; -import '~/components/badge.js'; -import '~/components/field-icon.js'; -import '~/components/data-view.js'; -import '~/components/manifest-view.js'; -import '~/components/page-header.js'; -import { tableAPI } from '~/common/api.js'; +import { render } from 'preact'; +import { html } from 'htm/preact'; +import { App } from './components/App.js'; -class App { - constructor() { - // 等待 srdb-app 组件渲染完成 - this.appContainer = document.querySelector('srdb-app'); - this.modal = document.querySelector('srdb-modal-dialog'); - - // 等待组件初始化 - if (this.appContainer) { - // 使用 updateComplete 等待组件渲染完成 - this.appContainer.updateComplete.then(() => { - this.tableList = this.appContainer.shadowRoot.querySelector('srdb-table-list'); - this.tableView = this.appContainer.shadowRoot.querySelector('srdb-table-view'); - this.pageHeader = this.appContainer.shadowRoot.querySelector('srdb-page-header'); - this.setupEventListeners(); - }); - } else { - // 如果组件还未定义,等待它被定义 - customElements.whenDefined('srdb-app').then(() => { - this.appContainer = document.querySelector('srdb-app'); - this.appContainer.updateComplete.then(() => { - this.tableList = this.appContainer.shadowRoot.querySelector('srdb-table-list'); - this.tableView = this.appContainer.shadowRoot.querySelector('srdb-table-view'); - this.pageHeader = this.appContainer.shadowRoot.querySelector('srdb-page-header'); - this.setupEventListeners(); - }); - }); - } - } - - setupEventListeners() { - // Listen for table selection - document.addEventListener('table-selected', (e) => { - const tableName = e.detail.tableName; - this.pageHeader.tableName = tableName; - this.pageHeader.view = 'data'; - this.tableView.tableName = tableName; - this.tableView.view = 'data'; - this.tableView.page = 1; - }); - - // Listen for view change from page-header - document.addEventListener('view-changed', (e) => { - this.tableView.view = e.detail.view; - }); - - // Listen for refresh request from page-header - document.addEventListener('refresh-view', (e) => { - this.tableView.loadData(); - }); - - // Listen for row detail request - document.addEventListener('show-row-detail', async (e) => { - const { tableName, seq } = e.detail; - await this.showRowDetail(tableName, seq); - }); - - // Listen for cell content request - document.addEventListener('show-cell-content', (e) => { - this.showCellContent(e.detail.content); - }); - - // Close modal on backdrop click - this.modal.addEventListener('click', () => { - this.modal.open = false; - }); - } - - async showRowDetail(tableName, seq) { - try { - const data = await tableAPI.getRow(tableName, seq); - const content = JSON.stringify(data, null, 2); - - this.modal.title = `Row Detail - Seq: ${seq}`; - this.modal.content = content; - this.modal.open = true; - } catch (error) { - console.error('Error loading row detail:', error); - this.modal.title = 'Error'; - this.modal.content = `Failed to load row detail: ${error.message}`; - this.modal.open = true; - } - } - - showCellContent(content) { - this.modal.title = 'Cell Content'; - this.modal.content = String(content); - this.modal.open = true; - } -} - -// Initialize app when DOM is ready -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => new App()); -} else { - new App(); -} +// 渲染应用 +render(html`<${App} />`, document.getElementById('app')); diff --git a/webui/webui.go b/webui/webui.go index b8cd1ba..32f35b1 100644 --- a/webui/webui.go +++ b/webui/webui.go @@ -17,13 +17,13 @@ import ( //go:embed static var staticFS embed.FS -// WebUI Web 界面处理器 +// WebUI Web 界面处理器 v2 (Preact) type WebUI struct { db *srdb.Database handler http.Handler } -// NewWebUI 创建 WebUI 实例 +// NewWebUI 创建 WebUI v2 实例 func NewWebUI(db *srdb.Database) *WebUI { ui := &WebUI{db: db} ui.handler = ui.setupHandler() @@ -34,7 +34,7 @@ func NewWebUI(db *srdb.Database) *WebUI { func (ui *WebUI) setupHandler() http.Handler { mux := http.NewServeMux() - // API endpoints - 纯 JSON API + // API endpoints - 纯 JSON API(与 v1 共享) mux.HandleFunc("/api/tables", ui.handleListTables) mux.HandleFunc("/api/tables/", ui.handleTableAPI) @@ -74,6 +74,7 @@ func (ui *WebUI) handleListTables(w http.ResponseWriter, r *http.Request) { type TableListItem struct { Name string `json:"name"` + RowCount int64 `json:"row_count,omitempty"` CreatedAt int64 `json:"created_at"` Fields []FieldInfo `json:"fields"` } @@ -92,8 +93,18 @@ func (ui *WebUI) handleListTables(w http.ResponseWriter, r *http.Request) { }) } + // 尝试获取行数(通过快速查询) + rowCount := int64(0) + if rows, err := table.Query().Rows(); err == nil { + for rows.Next() { + rowCount++ + } + rows.Close() + } + tables = append(tables, TableListItem{ Name: name, + RowCount: rowCount, CreatedAt: 0, // TODO: Table 不再有 createdAt 字段 Fields: fields, }) @@ -105,7 +116,9 @@ func (ui *WebUI) handleListTables(w http.ResponseWriter, r *http.Request) { }) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(tables) + json.NewEncoder(w).Encode(map[string]any{ + "tables": tables, + }) } // handleTableAPI 处理表相关的 API 请求 @@ -217,9 +230,9 @@ func (ui *WebUI) handleTableManifest(w http.ResponseWriter, r *http.Request, tab // 获取 Compaction Manager compactionMgr := table.GetCompactionManager() - levels := make([]LevelInfo, 0, 7) - for level := range 7 { - // 只调用一次 GetLevel,避免重复复制文件列表 + // 只显示 L0-L3 层 + levels := make([]LevelInfo, 0, 4) + for level := range 4 { files := version.GetLevel(level) totalSize := int64(0) @@ -236,11 +249,8 @@ func (ui *WebUI) handleTableManifest(w http.ResponseWriter, r *http.Request, tab }) } - // 使用已计算的 totalSize 和 fileCount 计算 score,避免再次调用 GetLevel score := 0.0 - if len(files) > 0 && level < 3 { // L3 是最后一层,不需要 compaction - // 直接计算 score,避免调用 picker.GetLevelScore(它会再次获取 files) - // 使用下一级的大小限制来计算得分(从 Options 配置读取) + if len(files) > 0 && level < 3 { nextLevelLimit := compactionMgr.GetLevelSizeLimit(level + 1) if nextLevelLimit > 0 { score = float64(totalSize) / float64(nextLevelLimit) @@ -320,23 +330,23 @@ func (ui *WebUI) handleTableData(w http.ResponseWriter, r *http.Request, tableNa return } - // 解析分页参数 - pageStr := r.URL.Query().Get("page") - pageSizeStr := r.URL.Query().Get("pageSize") + // 解析查询参数 + limitStr := r.URL.Query().Get("limit") + offsetStr := r.URL.Query().Get("offset") selectParam := r.URL.Query().Get("select") // 要选择的字段,逗号分隔 - page := 1 - pageSize := 20 + limit := 100 + offset := 0 - if pageStr != "" { - if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { - page = p + if limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 { + limit = l } } - if pageSizeStr != "" { - if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 && ps <= 1000 { - pageSize = ps + if offsetStr != "" { + if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 { + offset = o } } @@ -344,19 +354,17 @@ func (ui *WebUI) handleTableData(w http.ResponseWriter, r *http.Request, tableNa var selectedFields []string if selectParam != "" { selectedFields = strings.Split(selectParam, ",") - // 清理字段名(去除空格) for i := range selectedFields { selectedFields[i] = strings.TrimSpace(selectedFields[i]) } } - // 获取 schema 用于字段类型判断 + // 获取 schema tableSchema := table.GetSchema() - // 使用 Query API 获取数据,如果指定了字段则只查询指定字段(按字段压缩优化) + // 使用 Query API 获取数据 queryBuilder := table.Query() if len(selectedFields) > 0 { - // 确保 _seq 和 _time 总是被查询(用于构造响应) fieldsWithMeta := make([]string, 0, len(selectedFields)+2) hasSeq := false hasTime := false @@ -378,6 +386,7 @@ func (ui *WebUI) handleTableData(w http.ResponseWriter, r *http.Request, tableNa queryBuilder = queryBuilder.Select(fieldsWithMeta...) } + queryRows, err := queryBuilder.Rows() if err != nil { http.Error(w, fmt.Sprintf("Failed to query table: %v", err), http.StatusInternalServerError) @@ -385,26 +394,23 @@ func (ui *WebUI) handleTableData(w http.ResponseWriter, r *http.Request, tableNa } defer queryRows.Close() - // 计算分页范围 - offset := (page - 1) * pageSize + // 收集数据 + const maxStringLength = 100 + data := make([]map[string]any, 0, limit) currentIndex := 0 - - // 直接在遍历时进行分页和字段处理 - const maxStringLength = 100 // 最大字符串长度 - data := make([]map[string]any, 0, pageSize) totalRows := int64(0) for queryRows.Next() { totalRows++ - // 跳过不在当前页的数据 + // 跳过 offset 之前的数据 if currentIndex < offset { currentIndex++ continue } - // 已经收集够当前页的数据 - if len(data) >= pageSize { + // 已经收集够数据 + if len(data) >= limit { continue } @@ -440,11 +446,10 @@ func (ui *WebUI) handleTableData(w http.ResponseWriter, r *http.Request, tableNa } response := map[string]any{ - "data": data, - "page": page, - "pageSize": pageSize, - "totalRows": totalRows, - "totalPages": (totalRows + int64(pageSize) - 1) / int64(pageSize), + "data": data, + "limit": limit, + "offset": offset, + "totalRows": totalRows, } w.Header().Set("Content-Type", "application/json")