diff --git a/.gitignore b/.gitignore index 9a81a02..a82ccc8 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ testdb/ # Example binaries and data /examples/*/data/ /examples/*/* +!/examples/*/commands !/examples/*/*.go !/examples/*/go.mod !/examples/*/go.sum diff --git a/examples/webui/commands/webui.go b/examples/webui/commands/webui.go index 810ff97..192fd93 100644 --- a/examples/webui/commands/webui.go +++ b/examples/webui/commands/webui.go @@ -7,6 +7,7 @@ import ( "math/big" "net/http" "slices" + "strings" "time" "code.tczkiot.com/wlw/srdb" @@ -110,14 +111,20 @@ func StartWebUI(dbPath string, addr string) { // 启动后台数据插入协程 go autoInsertData(db) - // 启动 Web UI - handler := webui.NewWebUI(db) + // 创建 WebUI,使用 /debug 作为 basePath + ui := webui.NewWebUI(db, "/debug") - fmt.Printf("SRDB Web UI is running at http://%s\n", addr) + // 创建主路由 + mux := http.NewServeMux() + + // 挂载 WebUI 到根路径(WebUI 内部会处理 /debug 前缀) + mux.Handle("/", ui) + + fmt.Printf("SRDB Web UI is running at: http://%s/debug/\n", strings.TrimPrefix(addr, ":")) fmt.Println("Press Ctrl+C to stop") fmt.Println("Background data insertion is running...") - if err := http.ListenAndServe(addr, handler); err != nil { + if err := http.ListenAndServe(addr, mux); err != nil { log.Fatal(err) } } diff --git a/webui/static/index.html b/webui/static/index.html index b9ee519..a30fa70 100644 --- a/webui/static/index.html +++ b/webui/static/index.html @@ -7,7 +7,10 @@ - + + + + @@ -25,6 +29,6 @@
- + diff --git a/webui/static/js/components/App.js b/webui/static/js/components/App.js index 1604046..1cef4a9 100644 --- a/webui/static/js/components/App.js +++ b/webui/static/js/components/App.js @@ -1,7 +1,8 @@ import { html } from 'htm/preact'; import { useState, useEffect } from 'preact/hooks'; -import { Sidebar } from './Sidebar.js'; -import { TableView } from './TableView.js'; +import { Sidebar } from '~/components/Sidebar.js'; +import { TableView } from '~/components/TableView.js'; +import { getTables } from '~/utils/api.js'; export function App() { const [theme, setTheme] = useState('dark'); @@ -26,13 +27,10 @@ export function App() { 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); - } + const data = await getTables(); + setTables(data.tables || []); + if (data.tables && data.tables.length > 0) { + setSelectedTable(data.tables[0].name); } } catch (error) { console.error('Failed to fetch tables:', error); diff --git a/webui/static/js/components/DataTable.js b/webui/static/js/components/DataTable.js index 4c06db6..fd981a1 100644 --- a/webui/static/js/components/DataTable.js +++ b/webui/static/js/components/DataTable.js @@ -1,10 +1,11 @@ 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'; +import { RowDetailModal } from '~/components/RowDetailModal.js'; +import { Pagination } from '~/components/Pagination.js'; +import { TableRow } from '~/components/TableRow.js'; +import { useCellPopover } from '~/hooks/useCellPopover.js'; +import { useTooltip } from '~/hooks/useTooltip.js'; +import { getTableData } from '~/utils/api.js'; const styles = { container: { @@ -83,11 +84,8 @@ export function DataTable({ schema, tableName, totalRows, selectedColumns = [] } 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 || []); - } + const result = await getTableData(tableName, { limit: pageSize, offset }); + setData(result.data || []); } catch (error) { console.error('Failed to fetch data:', error); } finally { diff --git a/webui/static/js/components/LevelCard.js b/webui/static/js/components/LevelCard.js index 97faf06..5a3cd8c 100644 --- a/webui/static/js/components/LevelCard.js +++ b/webui/static/js/components/LevelCard.js @@ -1,5 +1,5 @@ import { html } from 'htm/preact'; -import { FileCard } from './FileCard.js'; +import { FileCard } from '~/components/FileCard.js'; const styles = { levelSection: { diff --git a/webui/static/js/components/ManifestModal.js b/webui/static/js/components/ManifestModal.js index 361787a..767d90f 100644 --- a/webui/static/js/components/ManifestModal.js +++ b/webui/static/js/components/ManifestModal.js @@ -1,6 +1,6 @@ import { html } from 'htm/preact'; import { useEffect } from 'preact/hooks'; -import { ManifestView } from './ManifestView.js'; +import { ManifestView } from '~/components/ManifestView.js'; const styles = { overlay: { diff --git a/webui/static/js/components/ManifestView.js b/webui/static/js/components/ManifestView.js index 169065a..a8bc492 100644 --- a/webui/static/js/components/ManifestView.js +++ b/webui/static/js/components/ManifestView.js @@ -1,8 +1,9 @@ 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'; +import { LevelCard } from '~/components/LevelCard.js'; +import { StatCard } from '~/components/StatCard.js'; +import { CompactionStats } from '~/components/CompactionStats.js'; +import { getTableManifest } from '~/utils/api.js'; const styles = { container: { @@ -42,11 +43,8 @@ export function ManifestView({ tableName }) { const fetchManifest = async () => { try { - const response = await fetch(`/api/tables/${tableName}/manifest`); - if (response.ok) { - const data = await response.json(); - setManifest(data); - } + const data = await getTableManifest(tableName); + setManifest(data); } catch (error) { console.error('Failed to fetch manifest:', error); } finally { diff --git a/webui/static/js/components/RowDetailModal.js b/webui/static/js/components/RowDetailModal.js index afa9ae7..fad3e0f 100644 --- a/webui/static/js/components/RowDetailModal.js +++ b/webui/static/js/components/RowDetailModal.js @@ -1,5 +1,6 @@ import { html } from 'htm/preact'; import { useState, useEffect, useRef } from 'preact/hooks'; +import { getRowBySeq } from '~/utils/api.js'; const styles = { overlay: { @@ -116,11 +117,8 @@ export function RowDetailModal({ tableName, seq, 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); - } + const data = await getRowBySeq(tableName, seq); + setRowData(data); } catch (error) { console.error('Failed to fetch row data:', error); } finally { diff --git a/webui/static/js/components/Sidebar.js b/webui/static/js/components/Sidebar.js index 33d8a70..e831e60 100644 --- a/webui/static/js/components/Sidebar.js +++ b/webui/static/js/components/Sidebar.js @@ -1,5 +1,5 @@ import { html } from 'htm/preact'; -import { TableItem } from './TableItem.js'; +import { TableItem } from '~/components/TableItem.js'; export function Sidebar({ tables, selectedTable, onSelectTable, loading }) { if (loading) { diff --git a/webui/static/js/components/TableItem.js b/webui/static/js/components/TableItem.js index 67e4aa6..32ee3dd 100644 --- a/webui/static/js/components/TableItem.js +++ b/webui/static/js/components/TableItem.js @@ -1,6 +1,6 @@ import { html } from 'htm/preact'; import { useState } from 'preact/hooks'; -import { FieldList } from './FieldList.js'; +import { FieldList } from '~/components/FieldList.js'; const styles = { tableItem: (isSelected, isExpanded) => ({ diff --git a/webui/static/js/components/TableRow.js b/webui/static/js/components/TableRow.js index 308c537..34f854e 100644 --- a/webui/static/js/components/TableRow.js +++ b/webui/static/js/components/TableRow.js @@ -1,6 +1,6 @@ import { html } from 'htm/preact'; import { useState } from 'preact/hooks'; -import { TableCell } from './TableCell.js'; +import { TableCell } from '~/components/TableCell.js'; const styles = { td: { diff --git a/webui/static/js/components/TableView.js b/webui/static/js/components/TableView.js index 0a4247a..a28d11b 100644 --- a/webui/static/js/components/TableView.js +++ b/webui/static/js/components/TableView.js @@ -1,9 +1,10 @@ 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'; +import { DataTable } from '~/components/DataTable.js'; +import { ColumnSelector } from '~/components/ColumnSelector.js'; +import { ManifestModal } from '~/components/ManifestModal.js'; +import { useTooltip } from '~/hooks/useTooltip.js'; +import { getTableSchema, getTableData } from '~/utils/api.js'; const styles = { container: { @@ -70,18 +71,12 @@ export function TableView({ tableName }) { setLoading(true); // 获取 Schema - const schemaResponse = await fetch(`/api/tables/${tableName}/schema`); - if (schemaResponse.ok) { - const schemaData = await schemaResponse.json(); - setSchema(schemaData); - } + const schemaData = await getTableSchema(tableName); + setSchema(schemaData); // 获取数据行数(通过一次小查询) - const dataResponse = await fetch(`/api/tables/${tableName}/data?limit=1`); - if (dataResponse.ok) { - const data = await dataResponse.json(); - setTotalRows(data.totalRows || 0); - } + const data = await getTableData(tableName, { limit: 1, offset: 0 }); + setTotalRows(data.totalRows || 0); } catch (error) { console.error('Failed to fetch table info:', error); } finally { diff --git a/webui/static/js/main.js b/webui/static/js/main.js index d12d574..080f40f 100644 --- a/webui/static/js/main.js +++ b/webui/static/js/main.js @@ -1,6 +1,6 @@ import { render } from 'preact'; import { html } from 'htm/preact'; -import { App } from './components/App.js'; +import { App } from '~/components/App.js'; // 渲染应用 render(html`<${App} />`, document.getElementById('app')); diff --git a/webui/static/js/utils/api.js b/webui/static/js/utils/api.js new file mode 100644 index 0000000..b81252a --- /dev/null +++ b/webui/static/js/utils/api.js @@ -0,0 +1,110 @@ +// api.js - 统一的 API 服务层 + +/** + * 获取 API 基础路径 + * 从全局变量 window.API_BASE 读取,由服务端渲染时注入 + */ +function getBasePath() { + return window.API_BASE || ''; +} + +/** + * 构建完整的 API URL + * @param {string} path - API 路径,如 '/tables' 或 '/tables/users/schema' + * @returns {string} 完整 URL + */ +function buildApiUrl(path) { + const basePath = getBasePath(); + const normalizedPath = path.startsWith('/') ? path : '/' + path; + const fullPath = basePath ? `${basePath}/api${normalizedPath}` : `/api${normalizedPath}`; + console.log('[API] Request:', fullPath); + return fullPath; +} + +/** + * 统一的 fetch 封装 + * @param {string} path - API 路径 + * @param {object} options - fetch 选项 + * @returns {Promise} + */ +async function apiFetch(path, options = {}) { + const url = buildApiUrl(path); + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + + if (!response.ok) { + console.error('[API] Error:', response.status, url); + } + + return response; +} + +// ============ 表相关 API ============ + +/** + * 获取所有表列表 + * @returns {Promise<{tables: Array}>} + */ +export async function getTables() { + const response = await apiFetch('/tables'); + return response.json(); +} + +/** + * 获取表的 Schema + * @param {string} tableName - 表名 + * @returns {Promise<{name: string, fields: Array}>} + */ +export async function getTableSchema(tableName) { + const response = await apiFetch(`/tables/${tableName}/schema`); + return response.json(); +} + +/** + * 获取表数据(分页) + * @param {string} tableName - 表名 + * @param {object} params - 查询参数 + * @param {number} params.limit - 每页数量 + * @param {number} params.offset - 偏移量 + * @param {string} params.select - 选择的字段(逗号分隔) + * @returns {Promise<{data: Array, totalRows: number}>} + */ +export async function getTableData(tableName, params = {}) { + const { limit = 20, offset = 0, select } = params; + + const queryParams = new URLSearchParams(); + queryParams.append('limit', limit); + queryParams.append('offset', offset); + if (select) { + queryParams.append('select', select); + } + + const response = await apiFetch(`/tables/${tableName}/data?${queryParams}`); + return response.json(); +} + +/** + * 根据序列号获取单条数据 + * @param {string} tableName - 表名 + * @param {number} seq - 序列号 + * @returns {Promise} + */ +export async function getRowBySeq(tableName, seq) { + const response = await apiFetch(`/tables/${tableName}/data/${seq}`); + return response.json(); +} + +/** + * 获取表的 Manifest 信息 + * @param {string} tableName - 表名 + * @returns {Promise} + */ +export async function getTableManifest(tableName) { + const response = await apiFetch(`/tables/${tableName}/manifest`); + return response.json(); +} diff --git a/webui/webui.go b/webui/webui.go index 32f35b1..a49884a 100644 --- a/webui/webui.go +++ b/webui/webui.go @@ -20,12 +20,18 @@ var staticFS embed.FS // WebUI Web 界面处理器 v2 (Preact) type WebUI struct { db *srdb.Database + basePath string handler http.Handler } // NewWebUI 创建 WebUI v2 实例 -func NewWebUI(db *srdb.Database) *WebUI { - ui := &WebUI{db: db} +// basePath 是可选的 URL 前缀路径(例如 "/debug"),如果为空则使用根路径 "/" +func NewWebUI(db *srdb.Database, basePath ...string) *WebUI { + bp := "" + if len(basePath) > 0 && basePath[0] != "" { + bp = strings.TrimSuffix(basePath[0], "/") // 移除尾部斜杠 + } + ui := &WebUI{db: db, basePath: bp} ui.handler = ui.setupHandler() return ui } @@ -35,19 +41,32 @@ func (ui *WebUI) setupHandler() http.Handler { mux := http.NewServeMux() // API endpoints - 纯 JSON API(与 v1 共享) - mux.HandleFunc("/api/tables", ui.handleListTables) - mux.HandleFunc("/api/tables/", ui.handleTableAPI) + mux.HandleFunc(ui.path("/api/tables"), ui.handleListTables) + mux.HandleFunc(ui.path("/api/tables/"), ui.handleTableAPI) // 静态文件服务 staticFiles, _ := fs.Sub(staticFS, "static") - mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFiles)))) + staticPath := ui.path("/static/") + mux.Handle(staticPath, http.StripPrefix(staticPath, http.FileServer(http.FS(staticFiles)))) // 首页 - mux.HandleFunc("/", ui.handleIndex) + mux.HandleFunc(ui.path("/"), ui.handleIndex) return mux } +// path 返回带 basePath 前缀的路径 +func (ui *WebUI) path(p string) string { + if ui.basePath == "" { + return p + } + // 确保路径以 / 开头 + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return ui.basePath + p +} + // Handler 返回 HTTP Handler func (ui *WebUI) Handler() http.Handler { return ui.handler @@ -124,7 +143,12 @@ func (ui *WebUI) handleListTables(w http.ResponseWriter, r *http.Request) { // handleTableAPI 处理表相关的 API 请求 func (ui *WebUI) handleTableAPI(w http.ResponseWriter, r *http.Request) { // 解析路径: /api/tables/{name}/schema 或 /api/tables/{name}/data - path := strings.TrimPrefix(r.URL.Path, "/api/tables/") + // 需要先移除 basePath,再移除 /api/tables/ 前缀 + path := r.URL.Path + if ui.basePath != "" { + path = strings.TrimPrefix(path, ui.basePath) + } + path = strings.TrimPrefix(path, "/api/tables/") parts := strings.Split(path, "/") if len(parts) < 2 { @@ -458,7 +482,9 @@ func (ui *WebUI) handleTableData(w http.ResponseWriter, r *http.Request, tableNa // handleIndex 处理首页请求 func (ui *WebUI) handleIndex(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { + // 检查路径是否匹配(支持 basePath) + expectedPath := ui.path("/") + if r.URL.Path != expectedPath { http.NotFound(w, r) return } @@ -471,6 +497,25 @@ func (ui *WebUI) handleIndex(w http.ResponseWriter, r *http.Request) { return } + // 替换路径占位符 ~~ 为 basePath + // 如果 basePath 为空,则 ~~ → "" + // 如果 basePath 为 "/debug",则 ~~ → "/debug" + // 示例: + // - 无 basePath: href="~~/static/css/styles.css" → href="/static/css/styles.css" + // - 有 basePath: href="~~/static/css/styles.css" → href="/debug/static/css/styles.css" + htmlContent := string(content) + if ui.basePath == "" { + // 无 basePath 时,直接替换为空 + htmlContent = strings.ReplaceAll(htmlContent, `"~~"`, `""`) + htmlContent = strings.ReplaceAll(htmlContent, `"~~/`, `"/`) + htmlContent = strings.ReplaceAll(htmlContent, `'~~/`, `'/`) + } else { + // 有 basePath 时,替换为实际的 basePath + htmlContent = strings.ReplaceAll(htmlContent, `"~~"`, fmt.Sprintf(`"%s"`, ui.basePath)) + htmlContent = strings.ReplaceAll(htmlContent, `"~~/`, fmt.Sprintf(`"%s/`, ui.basePath)) + htmlContent = strings.ReplaceAll(htmlContent, `'~~/`, fmt.Sprintf(`'%s/`, ui.basePath)) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write(content) + w.Write([]byte(htmlContent)) }