Files
srdb/webui/webui.go
bourdon 30c3e74bd2 feat: 完善 WebUI basePath 支持并简化示例代码
主要改动:

1. WebUI basePath 逻辑完善
   - NewWebUI 支持可变参数 basePath
   - 新增 path() 辅助方法统一路径处理
   - handleTableAPI 正确处理 basePath 前缀
   - handleIndex 根据 basePath 替换占位符

2. 简化示例代码
   - 删除反向代理实现(111行)
   - 直接使用带 basePath 的 WebUI
   - 代码量减少 33%,架构更清晰

3. 前端优化
   - 新增 api.js 统一 API 服务层
   - 所有组件使用统一的 API 调用
   - 支持通过 window.API_BASE 配置 basePath

4. 修复 .gitignore
   - 使用通用模式支持 commands 目录
   - 无需为新示例项目修改配置
2025-10-14 22:23:30 +08:00

522 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package webui
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"maps"
"net/http"
"sort"
"strconv"
"strings"
"code.tczkiot.com/wlw/srdb"
)
//go:embed static
var staticFS embed.FS
// WebUI Web 界面处理器 v2 (Preact)
type WebUI struct {
db *srdb.Database
basePath string
handler http.Handler
}
// NewWebUI 创建 WebUI v2 实例
// 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
}
// setupHandler 设置 HTTP Handler
func (ui *WebUI) setupHandler() http.Handler {
mux := http.NewServeMux()
// API endpoints - 纯 JSON API与 v1 共享)
mux.HandleFunc(ui.path("/api/tables"), ui.handleListTables)
mux.HandleFunc(ui.path("/api/tables/"), ui.handleTableAPI)
// 静态文件服务
staticFiles, _ := fs.Sub(staticFS, "static")
staticPath := ui.path("/static/")
mux.Handle(staticPath, http.StripPrefix(staticPath, http.FileServer(http.FS(staticFiles))))
// 首页
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
}
// ServeHTTP 实现 http.Handler 接口
func (ui *WebUI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ui.handler.ServeHTTP(w, r)
}
// handleListTables 处理获取表列表请求
func (ui *WebUI) handleListTables(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
type FieldInfo struct {
Name string `json:"name"`
Type string `json:"type"`
Indexed bool `json:"indexed"`
Comment string `json:"comment"`
}
type TableListItem struct {
Name string `json:"name"`
RowCount int64 `json:"row_count,omitempty"`
CreatedAt int64 `json:"created_at"`
Fields []FieldInfo `json:"fields"`
}
allTables := ui.db.GetAllTablesInfo()
tables := make([]TableListItem, 0, len(allTables))
for name, table := range allTables {
schema := table.GetSchema()
fields := make([]FieldInfo, 0, len(schema.Fields))
for _, field := range schema.Fields {
fields = append(fields, FieldInfo{
Name: field.Name,
Type: field.Type.String(),
Indexed: field.Indexed,
Comment: field.Comment,
})
}
// 尝试获取行数(通过快速查询)
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,
})
}
// 按表名排序
sort.Slice(tables, func(i, j int) bool {
return tables[i].Name < tables[j].Name
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"tables": tables,
})
}
// handleTableAPI 处理表相关的 API 请求
func (ui *WebUI) handleTableAPI(w http.ResponseWriter, r *http.Request) {
// 解析路径: /api/tables/{name}/schema 或 /api/tables/{name}/data
// 需要先移除 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 {
http.Error(w, "Invalid path", http.StatusBadRequest)
return
}
tableName := parts[0]
action := parts[1]
switch action {
case "schema":
ui.handleTableSchema(w, r, tableName)
case "data":
// 检查是否是单条数据查询: /api/tables/{name}/data/{seq}
if len(parts) >= 3 {
ui.handleTableDataBySeq(w, r, tableName, parts[2])
} else {
ui.handleTableData(w, r, tableName)
}
case "manifest":
ui.handleTableManifest(w, r, tableName)
default:
http.Error(w, "Unknown action", http.StatusBadRequest)
}
}
// handleTableSchema 处理获取表 schema 请求
func (ui *WebUI) handleTableSchema(w http.ResponseWriter, r *http.Request, tableName string) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
table, err := ui.db.GetTable(tableName)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
schema := table.GetSchema()
type FieldInfo struct {
Name string `json:"name"`
Type string `json:"type"`
Indexed bool `json:"indexed"`
Comment string `json:"comment"`
}
fields := make([]FieldInfo, 0, len(schema.Fields))
for _, field := range schema.Fields {
fields = append(fields, FieldInfo{
Name: field.Name,
Type: field.Type.String(),
Indexed: field.Indexed,
Comment: field.Comment,
})
}
response := map[string]any{
"name": schema.Name,
"fields": fields,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleTableManifest 处理获取表 manifest 信息请求
func (ui *WebUI) handleTableManifest(w http.ResponseWriter, r *http.Request, tableName string) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
table, err := ui.db.GetTable(tableName)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
versionSet := table.GetVersionSet()
version := versionSet.GetCurrent()
// 构建每层的信息
type FileInfo struct {
FileNumber int64 `json:"file_number"`
Level int `json:"level"`
FileSize int64 `json:"file_size"`
MinKey int64 `json:"min_key"`
MaxKey int64 `json:"max_key"`
RowCount int64 `json:"row_count"`
}
type LevelInfo struct {
Level int `json:"level"`
FileCount int `json:"file_count"`
TotalSize int64 `json:"total_size"`
Score float64 `json:"score"`
Files []FileInfo `json:"files"`
}
// 获取 Compaction Manager
compactionMgr := table.GetCompactionManager()
// 只显示 L0-L3 层
levels := make([]LevelInfo, 0, 4)
for level := range 4 {
files := version.GetLevel(level)
totalSize := int64(0)
fileInfos := make([]FileInfo, 0, len(files))
for _, f := range files {
totalSize += f.FileSize
fileInfos = append(fileInfos, FileInfo{
FileNumber: f.FileNumber,
Level: f.Level,
FileSize: f.FileSize,
MinKey: f.MinKey,
MaxKey: f.MaxKey,
RowCount: f.RowCount,
})
}
score := 0.0
if len(files) > 0 && level < 3 {
nextLevelLimit := compactionMgr.GetLevelSizeLimit(level + 1)
if nextLevelLimit > 0 {
score = float64(totalSize) / float64(nextLevelLimit)
}
}
levels = append(levels, LevelInfo{
Level: level,
FileCount: len(files),
TotalSize: totalSize,
Score: score,
Files: fileInfos,
})
}
// 获取 Compaction 统计
stats := compactionMgr.GetStats()
response := map[string]any{
"levels": levels,
"next_file_number": versionSet.GetNextFileNumber(),
"last_sequence": versionSet.GetLastSequence(),
"compaction_stats": stats,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleTableDataBySeq 处理获取单条数据请求
func (ui *WebUI) handleTableDataBySeq(w http.ResponseWriter, r *http.Request, tableName string, seqStr string) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
table, err := ui.db.GetTable(tableName)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// 解析 seq
seq, err := strconv.ParseInt(seqStr, 10, 64)
if err != nil {
http.Error(w, "Invalid seq parameter", http.StatusBadRequest)
return
}
// 获取数据
row, err := table.Get(seq)
if err != nil {
http.Error(w, fmt.Sprintf("Row not found: %v", err), http.StatusNotFound)
return
}
// 构造响应(不进行剪裁,返回完整数据)
rowData := make(map[string]any)
rowData["_seq"] = row.Seq
rowData["_time"] = row.Time
maps.Copy(rowData, row.Data)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(rowData)
}
// handleTableData 处理获取表数据请求(分页)
func (ui *WebUI) handleTableData(w http.ResponseWriter, r *http.Request, tableName string) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
table, err := ui.db.GetTable(tableName)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// 解析查询参数
limitStr := r.URL.Query().Get("limit")
offsetStr := r.URL.Query().Get("offset")
selectParam := r.URL.Query().Get("select") // 要选择的字段,逗号分隔
limit := 100
offset := 0
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {
limit = l
}
}
if offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
offset = o
}
}
// 解析要选择的字段
var selectedFields []string
if selectParam != "" {
selectedFields = strings.Split(selectParam, ",")
for i := range selectedFields {
selectedFields[i] = strings.TrimSpace(selectedFields[i])
}
}
// 获取 schema
tableSchema := table.GetSchema()
// 使用 Query API 获取数据
queryBuilder := table.Query()
if len(selectedFields) > 0 {
fieldsWithMeta := make([]string, 0, len(selectedFields)+2)
hasSeq := false
hasTime := false
for _, field := range selectedFields {
switch field {
case "_seq":
hasSeq = true
case "_time":
hasTime = true
}
}
if !hasSeq {
fieldsWithMeta = append(fieldsWithMeta, "_seq")
}
if !hasTime {
fieldsWithMeta = append(fieldsWithMeta, "_time")
}
fieldsWithMeta = append(fieldsWithMeta, selectedFields...)
queryBuilder = queryBuilder.Select(fieldsWithMeta...)
}
queryRows, err := queryBuilder.Rows()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to query table: %v", err), http.StatusInternalServerError)
return
}
defer queryRows.Close()
// 收集数据
const maxStringLength = 100
data := make([]map[string]any, 0, limit)
currentIndex := 0
totalRows := int64(0)
for queryRows.Next() {
totalRows++
// 跳过 offset 之前的数据
if currentIndex < offset {
currentIndex++
continue
}
// 已经收集够数据
if len(data) >= limit {
continue
}
row := queryRows.Row()
rowData := make(map[string]any)
rowData["_seq"] = row.Data()["_seq"]
rowData["_time"] = row.Data()["_time"]
// 遍历所有字段并进行字符串截断
for k, v := range row.Data() {
if k == "_seq" || k == "_time" {
continue
}
// 检查字段类型
field, err := tableSchema.GetField(k)
if err == nil && field.Type == srdb.String {
// 对字符串字段进行剪裁
if str, ok := v.(string); ok {
runes := []rune(str)
if len(runes) > maxStringLength {
rowData[k] = string(runes[:maxStringLength]) + "..."
rowData[k+"_truncated"] = true
continue
}
}
}
rowData[k] = v
}
data = append(data, rowData)
currentIndex++
}
response := map[string]any{
"data": data,
"limit": limit,
"offset": offset,
"totalRows": totalRows,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleIndex 处理首页请求
func (ui *WebUI) handleIndex(w http.ResponseWriter, r *http.Request) {
// 检查路径是否匹配(支持 basePath
expectedPath := ui.path("/")
if r.URL.Path != expectedPath {
http.NotFound(w, r)
return
}
// 读取 index.html
content, err := staticFS.ReadFile("static/index.html")
if err != nil {
http.Error(w, "Failed to load page", http.StatusInternalServerError)
fmt.Fprintf(w, "Error: %v", err)
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([]byte(htmlContent))
}