Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions dbui/dbui.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ func handler(db *bbolt.DB) http.Handler {
mux.HandleFunc("/api/put", func(w http.ResponseWriter, r *http.Request) { put(db, w, r) })
mux.HandleFunc("/api/delete", func(w http.ResponseWriter, r *http.Request) { del(db, w, r) })
mux.HandleFunc("/api/clearbucket", func(w http.ResponseWriter, r *http.Request) { clearBucket(db, w, r) })
mux.HandleFunc("/api/export", func(w http.ResponseWriter, r *http.Request) { exportBucket(db, w, r) })
mux.HandleFunc("/api/import", func(w http.ResponseWriter, r *http.Request) { importBucket(db, w, r) })
return mux
}

Expand Down Expand Up @@ -244,6 +246,80 @@ func clearBucket(db *bbolt.DB, w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"ok": true, "deleted": count})
}

func exportBucket(db *bbolt.DB, w http.ResponseWriter, r *http.Request) {
var bucket = r.URL.Query().Get("bucket")
if bucket == "" {
http.Error(w, "bucket is required", http.StatusBadRequest)
return
}
var rows = []kvRow{}
var err = db.View(func(tx *bbolt.Tx) error {
var b = tx.Bucket([]byte(bucket))
if b == nil { return errNoBucket }
return b.ForEach(func(k, v []byte) error {
rows = append(rows, kvRow{encodeField(k), encodeField(v)})
return nil
})
})
if err != nil {
if err == errNoBucket { http.Error(w, err.Error(), http.StatusNotFound); return }
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"rows": rows})
}

func importBucket(db *bbolt.DB, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Bucket string `json:"bucket"`
Strategy string `json:"strategy"` // "skip" or "replace"
Rows []kvRow `json:"rows"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if body.Bucket == "" {
http.Error(w, "bucket is required", http.StatusBadRequest)
return
}
if body.Strategy != "skip" && body.Strategy != "replace" {
http.Error(w, "strategy must be 'skip' or 'replace'", http.StatusBadRequest)
return
}
var imported, skipped int
var err = db.Update(func(tx *bbolt.Tx) error {
var b = tx.Bucket([]byte(body.Bucket))
if b == nil { return errNoBucket }
for _, row := range body.Rows {
var key, kerr = decodeField(row.Key)
if kerr != nil { return kerr }
var value, verr = decodeField(row.Value)
if verr != nil { return verr }
var exists = b.Get(key) != nil
if exists && body.Strategy == "skip" {
skipped++
continue
}
if err := b.Put(key, value); err != nil { return err }
imported++
}
return nil
})
if err != nil {
var code = http.StatusBadRequest
if err == errNoBucket { code = http.StatusNotFound }
http.Error(w, err.Error(), code)
return
}
logging.Info("database UI: imported %d keys into %s (%d skipped)", imported, body.Bucket, skipped)
writeJSON(w, map[string]any{"ok": true, "imported": imported, "skipped": skipped})
}

func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
Expand Down
145 changes: 144 additions & 1 deletion dbui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@
#status.err { color: #dc2626; }
#status.ok { color: #16a34a; }
.empty { color: var(--muted); text-align: center; padding: 40px 0; }
.dialog-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.dialog-overlay[hidden] { display: none; }
.dialog {
background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
padding: 20px 24px; min-width: 320px; max-width: 420px;
}
.dialog p { margin: 0 0 12px; }
.dialog label { display: block; margin-bottom: 6px; cursor: pointer; }
.dialog input[type="radio"] { min-width: 0; margin-right: 6px; }
.dialog-buttons { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
</style>
</head>
<body>
Expand All @@ -95,7 +108,7 @@ <h1>bitnsbot database</h1>

<div id="data" class="panel active">
<table>
<thead><tr><th></th><th style="width:40%"><button id="clearBucket" class="trash" title="Clear bucket" style="margin-right:4px"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4h11"/><path d="M6.5 4V2.7c0-.4.3-.7.7-.7h1.6c.4 0 .7.3.7.7V4"/><path d="M3.5 4l.6 8.5c0 .5.5 1 1 1h5.8c.5 0 1-.5 1-1L13.5 4"/><path d="M6.5 6.5v5M9.5 6.5v5"/></svg></button>Key</th><th>Value</th></tr></thead>
<thead><tr><th><button id="clearBucket" class="trash" title="Clear bucket"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4h11"/><path d="M6.5 4V2.7c0-.4.3-.7.7-.7h1.6c.4 0 .7.3.7.7V4"/><path d="M3.5 4l.6 8.5c0 .5.5 1 1 1h5.8c.5 0 1-.5 1-1L13.5 4"/><path d="M6.5 6.5v5M9.5 6.5v5"/></svg></button></th><th style="width:40%">Key</th><th>Value</th></tr></thead>
<tbody id="rows"></tbody>
</table>
<template id="trashTemplate">
Expand All @@ -113,6 +126,21 @@ <h1>bitnsbot database</h1>
<button id="prev">Prev</button>
<span id="pageLabel">—</span>
<button id="next">Next</button>
<button id="exportBtn">Export</button>
<button id="importBtn">Import</button>
</div>
</div>

<div id="importDialog" class="dialog-overlay" hidden>
<div class="dialog">
<p>Import CSV into <strong id="importBucketLabel"></strong></p>
<label><input type="radio" name="strategy" value="skip" checked> Skip existing keys</label>
<label><input type="radio" name="strategy" value="replace"> Replace existing keys</label>
<div class="dialog-buttons">
<button id="importCancel">Cancel</button>
<button id="importGo" class="primary">Import</button>
</div>
<input type="file" id="importFileInput" accept=".csv" hidden>
</div>
</div>

Expand Down Expand Up @@ -277,12 +305,127 @@ <h1>bitnsbot database</h1>
.catch(function(e) { status("clear failed: " + e, "err"); });
}

function csvEscape(s) {
if (s.indexOf('"') === -1 && s.indexOf(',') === -1 && s.indexOf('\n') === -1 && s.indexOf('\r') === -1) return s;
return '"' + s.replace(/"/g, '""') + '"';
}

function exportBucket() {
var bucket = $("bucket").value;
if (!bucket) { status("no bucket selected", "err"); return; }
status("exporting...", "");
fetch("api/export?bucket=" + encodeURIComponent(bucket))
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(function(d) {
var lines = ["key,value"];
(d.rows || []).forEach(function(row) {
lines.push(csvEscape(row.key) + "," + csvEscape(row.value));
});
var csv = lines.join("\n");
var blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url; a.download = bucket + ".csv";
document.body.appendChild(a); a.click();
document.body.removeChild(a); URL.revokeObjectURL(url);
status("exported " + (d.rows ? d.rows.length : 0) + " keys", "ok");
})
.catch(function(e) { status("export failed: " + e, "err"); });
}

function parseCSV(text) {
var rows = [];
var row = [];
var field = "";
var inQuotes = false;
for (var i = 0; i < text.length; i++) {
var ch = text[i];
if (inQuotes) {
if (ch === '"') {
if (i + 1 < text.length && text[i + 1] === '"') {
field += '"'; i++;
} else {
inQuotes = false;
}
} else {
field += ch;
}
} else {
if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
row.push(field); field = "";
} else if (ch === '\n') {
row.push(field); field = "";
rows.push(row); row = [];
} else if (ch !== '\r') {
field += ch;
}
}
}
if (field !== "" || row.length > 0) {
row.push(field);
rows.push(row);
}
if (rows.length > 0 && rows[0].length === 2 && rows[0][0] === "key" && rows[0][1] === "value") {
rows.shift();
}
return rows;
}

function showImportDialog() {
var bucket = $("bucket").value;
if (!bucket) { status("no bucket selected", "err"); return; }
$("importBucketLabel").textContent = bucket;
$("importDialog").hidden = false;
}

function doImport() {
var bucket = $("bucket").value;
var strategy = document.querySelector('input[name="strategy"]:checked').value;
var file = $("importFileInput").files[0];
if (!file) { status("no file selected", "err"); return; }
status("importing...", "");
var reader = new FileReader();
reader.onload = function(e) {
var parsed = parseCSV(e.target.result);
var rows = [];
for (var i = 0; i < parsed.length; i++) {
if (parsed[i].length >= 2) {
rows.push({ key: parsed[i][0], value: parsed[i][1] });
}
}
fetch("api/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bucket: bucket, strategy: strategy, rows: rows })
})
.then(function(r) {
if (!r.ok) return r.text().then(function(t) { throw new Error(t.trim() || r.status); });
return r.json();
})
.then(function(d) {
status("imported " + d.imported + " keys" + (d.skipped ? ", skipped " + d.skipped : ""), "ok");
page = 0; return loadView();
})
.catch(function(e) { status("import failed: " + e, "err"); });
};
reader.readAsText(file, "utf-8");
$("importDialog").hidden = true;
$("importFileInput").value = "";
}

$("view").addEventListener("click", function() { page = 0; loadView(); });
$("get").addEventListener("click", loadValue);
$("put").addEventListener("click", putValue);
$("prev").addEventListener("click", function() { if (page > 0) { page--; loadView(); } });
$("next").addEventListener("click", function() { page++; loadView(); });
$("clearBucket").addEventListener("click", clearBucket);
$("exportBtn").addEventListener("click", exportBucket);
$("importBtn").addEventListener("click", showImportDialog);
$("importCancel").addEventListener("click", function() { $("importDialog").hidden = true; });
$("importGo").addEventListener("click", function() { $("importFileInput").click(); });
$("importFileInput").addEventListener("change", doImport);
$("empty").hidden = false;
loadBuckets();
</script>
Expand Down
Loading