Files
srdb/webui/htmx.go
bourdon ae87c38776 Initial commit: SRDB - High-performance LSM-Tree database
- Core engine with MemTable, SST, WAL
- B+Tree indexing for SST files  
- Leveled compaction strategy
- Multi-table database management
- Schema validation and secondary indexes
- Query builder with complex conditions
- Web UI with HTMX for data visualization
- Command-line tools for diagnostics
2025-10-08 06:38:28 +08:00

553 lines
16 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 (
"bytes"
"fmt"
"html"
"strings"
)
// HTML 渲染辅助函数
// renderTablesHTML 渲染表列表 HTML
func renderTablesHTML(tables []TableListItem) string {
var buf bytes.Buffer
for _, table := range tables {
buf.WriteString(`<div class="table-item" data-table="`)
buf.WriteString(html.EscapeString(table.Name))
buf.WriteString(`">`)
buf.WriteString(`<div class="table-header" onclick="selectTable('`)
buf.WriteString(html.EscapeString(table.Name))
buf.WriteString(`')">`)
// 左侧:展开图标和表名
buf.WriteString(`<div class="table-header-left">`)
buf.WriteString(`<span class="expand-icon" onclick="event.stopPropagation(); toggleExpand('`)
buf.WriteString(html.EscapeString(table.Name))
buf.WriteString(`')">▶</span>`)
buf.WriteString(`<span class="table-name">`)
buf.WriteString(html.EscapeString(table.Name))
buf.WriteString(`</span></div>`)
// 右侧:字段数量
buf.WriteString(`<span class="table-count">`)
buf.WriteString(formatCount(int64(len(table.Fields))))
buf.WriteString(` fields</span>`)
buf.WriteString(`</div>`)
// Schema 字段列表(默认隐藏)
if len(table.Fields) > 0 {
buf.WriteString(`<div class="schema-fields">`)
for _, field := range table.Fields {
buf.WriteString(`<div class="field-item">`)
buf.WriteString(`<span class="field-name">`)
buf.WriteString(html.EscapeString(field.Name))
buf.WriteString(`</span>`)
buf.WriteString(`<span class="field-type">`)
buf.WriteString(html.EscapeString(field.Type))
buf.WriteString(`</span>`)
if field.Indexed {
buf.WriteString(`<span class="field-indexed">●indexed</span>`)
}
buf.WriteString(`</div>`)
}
buf.WriteString(`</div>`)
}
buf.WriteString(`</div>`)
}
return buf.String()
}
// renderDataViewHTML 渲染数据视图 HTML
func renderDataViewHTML(tableName string, schema SchemaInfo, tableData TableDataResponse) string {
var buf bytes.Buffer
// 标题
buf.WriteString(`<h2>`)
buf.WriteString(html.EscapeString(tableName))
buf.WriteString(`</h2>`)
// 视图切换标签
buf.WriteString(`<div class="view-tabs">`)
buf.WriteString(`<button class="view-tab active" onclick="switchView('`)
buf.WriteString(html.EscapeString(tableName))
buf.WriteString(`', 'data')">Data</button>`)
buf.WriteString(`<button class="view-tab" onclick="switchView('`)
buf.WriteString(html.EscapeString(tableName))
buf.WriteString(`', 'manifest')">Manifest / LSM-Tree</button>`)
buf.WriteString(`</div>`)
// Schema 部分
if len(schema.Fields) > 0 {
buf.WriteString(`<div class="schema-section">`)
buf.WriteString(`<h3>Schema <span style="font-size: 12px; font-weight: 400; color: var(--text-secondary);">(点击字段卡片选择要显示的列)</span></h3>`)
buf.WriteString(`<div class="schema-grid">`)
for _, field := range schema.Fields {
buf.WriteString(`<div class="schema-field-card selected" data-column="`)
buf.WriteString(html.EscapeString(field.Name))
buf.WriteString(`" onclick="toggleColumn('`)
buf.WriteString(html.EscapeString(field.Name))
buf.WriteString(`')">`)
buf.WriteString(`<div class="field-item">`)
buf.WriteString(`<span class="field-name">`)
buf.WriteString(html.EscapeString(field.Name))
buf.WriteString(`</span>`)
buf.WriteString(`<span class="field-type">`)
buf.WriteString(html.EscapeString(field.Type))
buf.WriteString(`</span>`)
if field.Indexed {
buf.WriteString(`<span class="field-indexed">●indexed</span>`)
}
buf.WriteString(`</div>`)
buf.WriteString(`<div class="field-comment">`)
if field.Comment != "" {
buf.WriteString(html.EscapeString(field.Comment))
}
buf.WriteString(`</div>`)
buf.WriteString(`</div>`)
}
buf.WriteString(`</div>`)
buf.WriteString(`</div>`)
}
// 数据表格
buf.WriteString(`<h3>Data (`)
buf.WriteString(formatCount(tableData.TotalRows))
buf.WriteString(` rows)</h3>`)
if len(tableData.Data) == 0 {
buf.WriteString(`<div class="empty"><p>No data available</p></div>`)
return buf.String()
}
// 获取列并排序_seq 第1列_time 倒数第2列
columns := []string{}
otherColumns := []string{}
hasSeq := false
hasTime := false
if len(tableData.Data) > 0 {
for key := range tableData.Data[0] {
if !strings.HasSuffix(key, "_truncated") {
if key == "_seq" {
hasSeq = true
} else if key == "_time" {
hasTime = true
} else {
otherColumns = append(otherColumns, key)
}
}
}
}
// 按顺序组装_seq, 其他列, _time
if hasSeq {
columns = append(columns, "_seq")
}
columns = append(columns, otherColumns...)
if hasTime {
columns = append(columns, "_time")
}
// 表格
buf.WriteString(`<div class="table-wrapper">`)
buf.WriteString(`<table class="data-table">`)
buf.WriteString(`<thead><tr>`)
for _, col := range columns {
buf.WriteString(`<th data-column="`)
buf.WriteString(html.EscapeString(col))
buf.WriteString(`" title="`)
buf.WriteString(html.EscapeString(col))
buf.WriteString(`">`)
buf.WriteString(html.EscapeString(col))
buf.WriteString(`</th>`)
}
buf.WriteString(`<th style="width: 80px;">Actions</th>`)
buf.WriteString(`</tr></thead>`)
buf.WriteString(`<tbody>`)
for _, row := range tableData.Data {
buf.WriteString(`<tr>`)
for _, col := range columns {
value := row[col]
buf.WriteString(`<td data-column="`)
buf.WriteString(html.EscapeString(col))
buf.WriteString(`" onclick="showCellContent('`)
buf.WriteString(escapeJSString(fmt.Sprintf("%v", value)))
buf.WriteString(`')" title="Click to view full content">`)
buf.WriteString(html.EscapeString(fmt.Sprintf("%v", value)))
// 检查是否被截断
if truncated, ok := row[col+"_truncated"]; ok && truncated == true {
buf.WriteString(`<span class="truncated-icon" title="This field is truncated">✂️</span>`)
}
buf.WriteString(`</td>`)
}
// Actions 列
buf.WriteString(`<td style="text-align: center;">`)
buf.WriteString(`<button class="row-detail-btn" onclick="showRowDetail('`)
buf.WriteString(html.EscapeString(tableName))
buf.WriteString(`', `)
buf.WriteString(fmt.Sprintf("%v", row["_seq"]))
buf.WriteString(`)" title="View full row data">Detail</button>`)
buf.WriteString(`</td>`)
buf.WriteString(`</tr>`)
}
buf.WriteString(`</tbody>`)
buf.WriteString(`</table>`)
buf.WriteString(`</div>`)
// 分页
buf.WriteString(renderPagination(tableData))
return buf.String()
}
// renderManifestViewHTML 渲染 Manifest 视图 HTML
func renderManifestViewHTML(tableName string, manifest ManifestResponse) string {
var buf bytes.Buffer
// 标题
buf.WriteString(`<h2>`)
buf.WriteString(html.EscapeString(tableName))
buf.WriteString(`</h2>`)
// 视图切换标签
buf.WriteString(`<div class="view-tabs">`)
buf.WriteString(`<button class="view-tab" onclick="switchView('`)
buf.WriteString(html.EscapeString(tableName))
buf.WriteString(`', 'data')">Data</button>`)
buf.WriteString(`<button class="view-tab active" onclick="switchView('`)
buf.WriteString(html.EscapeString(tableName))
buf.WriteString(`', 'manifest')">Manifest / LSM-Tree</button>`)
buf.WriteString(`</div>`)
// 标题和控制按钮
buf.WriteString(`<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">`)
buf.WriteString(`<h3>LSM-Tree Structure</h3>`)
buf.WriteString(`<div class="control-buttons">`)
buf.WriteString(`<button>📖 Expand All</button>`)
buf.WriteString(`<button>📕 Collapse All</button>`)
buf.WriteString(`</div>`)
buf.WriteString(`</div>`)
// 统计卡片
totalLevels := len(manifest.Levels)
totalFiles := 0
totalSize := int64(0)
for _, level := range manifest.Levels {
totalFiles += level.FileCount
totalSize += level.TotalSize
}
buf.WriteString(`<div class="manifest-stats">`)
// Active Levels
buf.WriteString(`<div class="stat-card">`)
buf.WriteString(`<div class="stat-label">Active Levels</div>`)
buf.WriteString(`<div class="stat-value">`)
buf.WriteString(fmt.Sprintf("%d", totalLevels))
buf.WriteString(`</div></div>`)
// Total Files
buf.WriteString(`<div class="stat-card">`)
buf.WriteString(`<div class="stat-label">Total Files</div>`)
buf.WriteString(`<div class="stat-value">`)
buf.WriteString(fmt.Sprintf("%d", totalFiles))
buf.WriteString(`</div></div>`)
// Total Size
buf.WriteString(`<div class="stat-card">`)
buf.WriteString(`<div class="stat-label">Total Size</div>`)
buf.WriteString(`<div class="stat-value">`)
buf.WriteString(formatBytes(totalSize))
buf.WriteString(`</div></div>`)
// Next File Number
buf.WriteString(`<div class="stat-card">`)
buf.WriteString(`<div class="stat-label">Next File Number</div>`)
buf.WriteString(`<div class="stat-value">`)
buf.WriteString(fmt.Sprintf("%d", manifest.NextFileNumber))
buf.WriteString(`</div></div>`)
// Last Sequence
buf.WriteString(`<div class="stat-card">`)
buf.WriteString(`<div class="stat-label">Last Sequence</div>`)
buf.WriteString(`<div class="stat-value">`)
buf.WriteString(fmt.Sprintf("%d", manifest.LastSequence))
buf.WriteString(`</div></div>`)
// Total Compactions
buf.WriteString(`<div class="stat-card">`)
buf.WriteString(`<div class="stat-label">Total Compactions</div>`)
buf.WriteString(`<div class="stat-value">`)
totalCompactions := 0
if manifest.CompactionStats != nil {
if tc, ok := manifest.CompactionStats["total_compactions"]; ok {
if tcInt, ok := tc.(float64); ok {
totalCompactions = int(tcInt)
}
}
}
buf.WriteString(fmt.Sprintf("%d", totalCompactions))
buf.WriteString(`</div></div>`)
buf.WriteString(`</div>`)
// 渲染所有层级L0-L6
for i := 0; i <= 6; i++ {
var level *LevelInfo
for j := range manifest.Levels {
if manifest.Levels[j].Level == i {
level = &manifest.Levels[j]
break
}
}
if level == nil {
// 创建空层级
level = &LevelInfo{
Level: i,
FileCount: 0,
TotalSize: 0,
Score: 0,
Files: []FileInfo{},
}
}
buf.WriteString(renderLevelCard(*level))
}
return buf.String()
}
// renderLevelCard 渲染层级卡片
func renderLevelCard(level LevelInfo) string {
var buf bytes.Buffer
scoreClass := "normal"
if level.Score >= 1.0 {
scoreClass = "critical"
} else if level.Score >= 0.8 {
scoreClass = "warning"
}
buf.WriteString(`<div class="level-card" data-level="`)
buf.WriteString(fmt.Sprintf("%d", level.Level))
buf.WriteString(`">`)
buf.WriteString(`<div class="level-header" onclick="toggleLevel(`)
buf.WriteString(fmt.Sprintf("%d", level.Level))
buf.WriteString(`)">`)
// 左侧:展开图标和标题
buf.WriteString(`<div style="display: flex; align-items: center; gap: 10px;">`)
buf.WriteString(`<span class="expand-icon">▶</span>`)
buf.WriteString(`<div class="level-title">Level `)
buf.WriteString(fmt.Sprintf("%d", level.Level))
buf.WriteString(`</div></div>`)
// 右侧:统计信息
buf.WriteString(`<div class="level-stats">`)
buf.WriteString(`<span>`)
buf.WriteString(fmt.Sprintf("%d", level.FileCount))
buf.WriteString(` files</span>`)
buf.WriteString(`<span>`)
buf.WriteString(formatBytes(level.TotalSize))
buf.WriteString(`</span>`)
buf.WriteString(`<span class="score-badge `)
buf.WriteString(scoreClass)
buf.WriteString(`">Score: `)
buf.WriteString(fmt.Sprintf("%.2f", level.Score))
buf.WriteString(`</span>`)
buf.WriteString(`</div>`)
buf.WriteString(`</div>`)
// 文件列表(默认隐藏)
buf.WriteString(`<div class="file-list">`)
if len(level.Files) == 0 {
buf.WriteString(`<div class="empty-files">No files in this level</div>`)
} else {
for _, file := range level.Files {
buf.WriteString(`<div class="file-card">`)
buf.WriteString(`<div class="file-header">`)
buf.WriteString(`<span>File #`)
buf.WriteString(fmt.Sprintf("%d", file.FileNumber))
buf.WriteString(`</span>`)
buf.WriteString(`<b>`)
buf.WriteString(formatBytes(file.FileSize))
buf.WriteString(`</b>`)
buf.WriteString(`</div>`)
buf.WriteString(`<div class="file-detail">`)
buf.WriteString(`<span>Key Range:</span>`)
buf.WriteString(`<span>`)
buf.WriteString(fmt.Sprintf("%d - %d", file.MinKey, file.MaxKey))
buf.WriteString(`</span></div>`)
buf.WriteString(`<div class="file-detail">`)
buf.WriteString(`<span>Rows:</span>`)
buf.WriteString(`<span>`)
buf.WriteString(formatCount(file.RowCount))
buf.WriteString(`</span></div>`)
buf.WriteString(`</div>`)
}
}
buf.WriteString(`</div>`)
buf.WriteString(`</div>`)
return buf.String()
}
// renderPagination 渲染分页 HTML
func renderPagination(data TableDataResponse) string {
var buf bytes.Buffer
buf.WriteString(`<div class="pagination">`)
// 页大小选择器
buf.WriteString(`<select onchange="changePageSize(this.value)">`)
for _, size := range []int{10, 20, 50, 100} {
buf.WriteString(`<option value="`)
buf.WriteString(fmt.Sprintf("%d", size))
buf.WriteString(`"`)
if int64(size) == data.PageSize {
buf.WriteString(` selected`)
}
buf.WriteString(`>`)
buf.WriteString(fmt.Sprintf("%d", size))
buf.WriteString(` / page</option>`)
}
buf.WriteString(`</select>`)
// 上一页按钮
buf.WriteString(`<button onclick="changePage(-1)"`)
if data.Page <= 1 {
buf.WriteString(` disabled`)
}
buf.WriteString(`>Previous</button>`)
// 页码信息
buf.WriteString(`<span>Page `)
buf.WriteString(fmt.Sprintf("%d", data.Page))
buf.WriteString(` of `)
buf.WriteString(fmt.Sprintf("%d", data.TotalPages))
buf.WriteString(` (`)
buf.WriteString(formatCount(data.TotalRows))
buf.WriteString(` rows)</span>`)
// 跳转输入框
buf.WriteString(`<input type="number" min="1" max="`)
buf.WriteString(fmt.Sprintf("%d", data.TotalPages))
buf.WriteString(`" placeholder="Jump to" onkeydown="if(event.key==='Enter') jumpToPage(this.value)">`)
// Go 按钮
buf.WriteString(`<button onclick="jumpToPage(this.previousElementSibling.value)">Go</button>`)
// 下一页按钮
buf.WriteString(`<button onclick="changePage(1)"`)
if data.Page >= data.TotalPages {
buf.WriteString(` disabled`)
}
buf.WriteString(`>Next</button>`)
buf.WriteString(`</div>`)
return buf.String()
}
// formatBytes 格式化字节数
func formatBytes(bytes int64) string {
if bytes == 0 {
return "0 B"
}
const k = 1024
sizes := []string{"B", "KB", "MB", "GB", "TB"}
i := 0
size := float64(bytes)
for size >= k && i < len(sizes)-1 {
size /= k
i++
}
return fmt.Sprintf("%.2f %s", size, sizes[i])
}
// formatCount 格式化数量K/M
func formatCount(count int64) string {
if count >= 1000000 {
return fmt.Sprintf("%.1fM", float64(count)/1000000)
}
if count >= 1000 {
return fmt.Sprintf("%.1fK", float64(count)/1000)
}
return fmt.Sprintf("%d", count)
}
// escapeJSString 转义 JavaScript 字符串
func escapeJSString(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `'`, `\'`)
s = strings.ReplaceAll(s, `"`, `\"`)
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
s = strings.ReplaceAll(s, "\t", `\t`)
return s
}
// 数据结构定义
type TableListItem struct {
Name string `json:"name"`
CreatedAt int64 `json:"created_at"`
Fields []FieldInfo `json:"fields"`
}
type FieldInfo struct {
Name string `json:"name"`
Type string `json:"type"`
Indexed bool `json:"indexed"`
Comment string `json:"comment"`
}
type SchemaInfo struct {
Name string `json:"name"`
Fields []FieldInfo `json:"fields"`
}
type TableDataResponse struct {
Data []map[string]any `json:"data"`
Page int64 `json:"page"`
PageSize int64 `json:"pageSize"`
TotalRows int64 `json:"totalRows"`
TotalPages int64 `json:"totalPages"`
}
type ManifestResponse struct {
Levels []LevelInfo `json:"levels"`
NextFileNumber int64 `json:"next_file_number"`
LastSequence int64 `json:"last_sequence"`
CompactionStats map[string]any `json:"compaction_stats"`
}
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"`
}
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"`
}