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)) }