feat: multi-database backup, HTTP API endpoints, admin panel UI

- backup CLI: add --all-databases, --database, --data-root flags
- backup format: tar.gz with databases/<name>/ + backup.json metadata
- HTTP API: new endpoints /databases, /backup, /backups, /restore
- HTTP API: database switching via X-Database header
- Admin panel: database selector dropdown, Databases tab, Backups tab
- Admin panel: backup/restore UI with fetch() to new endpoints
- Update deps: hunos 1.3.0, jwt-nim-baraba 2.1.0
- Docker: backup cron uses --all-databases --data-root=/data/databases
- Docs: update api-http.md, protocol.md with correct endpoints
- OpenAPI spec updated to v1.1.6

Known limitation: DELETE /databases disabled due to ORC SIGSEGV
in LSMTree.close(). Use SQL DROP DATABASE instead.
This commit is contained in:
2026-05-25 18:46:07 +03:00
parent a9ea80a1f8
commit eb62b381a1
8 changed files with 697 additions and 112 deletions
+2 -2
View File
@@ -9,8 +9,8 @@ binDir = "build"
# Dependencies
requires "nim >= 2.2.0"
requires "https://github.com/katehonz/hunos >= 1.2.0"
requires "jwt >= 0.3.0"
requires "https://github.com/katehonz/hunos >= 1.3.0"
requires "https://github.com/katehonz/jwt-nim-baraba >= 2.1.0"
requires "checksums >= 0.2.0"
# Tasks
+2 -2
View File
@@ -102,8 +102,8 @@ services:
sh -c '
while true; do
sleep 86400;
/app/backup backup --data-dir=/data --output=/backups/baradb_$$(date +%Y%m%d_%H%M%S).tar.gz --level=6;
/app/backup cleanup --keep=7;
/app/backup backup --all-databases --data-root=/data/databases --output=/backups/baradb_$$(date +%Y%m%d_%H%M%S).tar.gz --level=6;
/app/backup cleanup --data-root=/data/databases --keep=7;
done
'
volumes:
+87 -60
View File
@@ -5,7 +5,7 @@ JSON-базиран REST API за уеб приложения.
## Базов URL
```
http://localhost:9470
http://localhost:9912
```
## Endpoints
@@ -15,53 +15,7 @@ http://localhost:9470
Health проверка:
```bash
curl http://localhost:9470/health
```
### GET /ready
Readiness проверка:
```bash
curl http://localhost:9470/ready
```
### POST /query
Изпълнение на BaraQL заявка:
```bash
curl -X POST http://localhost:9470/api/query \
-H "Content-Type: application/json" \
-d '{"query": "SELECT * FROM users WHERE age > 18"}'
```
Отговор:
```json
{
"columns": ["name", "age"],
"rows": [["Alice", 30], ["Bob", 25]],
"row_count": 2,
"duration_ms": 12
}
```
### POST /batch
Групови заявки:
```bash
curl -X POST http://localhost:9470/api/batch \
-H "Content-Type: application/json" \
-d '{"queries": ["INSERT users { name := \"Alice\" }", "INSERT users { name := \"Bob\" }"]}'
```
### GET /schema
Преглед на схемата:
```bash
curl http://localhost:9470/api/schema
curl http://localhost:9912/health
```
### GET /metrics
@@ -69,37 +23,110 @@ curl http://localhost:9470/api/schema
Prometheus метрики:
```bash
curl http://localhost:9470/metrics
curl http://localhost:9912/metrics
```
### POST /explain
### POST /auth
Обяснение на план за изпълнение:
JWT автентикация:
```bash
curl -X POST http://localhost:9470/api/explain \
curl -X POST http://localhost:9912/auth \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "secret"}'
```
### POST /query
Изпълнение на SQL заявка:
```bash
curl -X POST http://localhost:9912/query \
-H "Content-Type: application/json" \
-H "X-Database: default" \
-d '{"query": "SELECT * FROM users WHERE age > 18"}'
```
Отговор:
```json
{
"columns": ["name", "age"],
"rows": [{"name": "Alice", "age": 30}],
"affectedRows": 0
}
```
> **Забележка:** Header `X-Database` избира към коя база данни да се изпълни заявката. Ако липсва, се използва `default`.
### GET /tables
Списък с таблици в избраната база данни:
```bash
curl http://localhost:9912/tables -H "X-Database: default"
```
### GET /databases
Списък с налични бази данни:
```bash
curl http://localhost:9912/databases
```
### POST /databases
Създаване на нова база данни (изисква admin права):
```bash
curl -X POST http://localhost:9912/databases \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"name": "mydb"}'
```
### POST /backup
Създаване на backup:
Създаване на backup (изисква admin права):
```bash
curl -X POST http://localhost:9470/api/backup \
# Backup на всички бази данни
curl -X POST http://localhost:9912/backup \
-H "Content-Type: application/json" \
-d '{"destination": "/backup/snapshot.db"}'
-H "Authorization: Bearer <token>" \
-d '{"all": true}'
# Backup на единична база
curl -X POST http://localhost:9912/backup \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"database": "default"}'
```
### GET /backups
Списък с налични архиви:
```bash
curl http://localhost:9912/backups -H "Authorization: Bearer <token>"
```
### POST /restore
Възстановяване от архив (изисква admin права):
```bash
curl -X POST http://localhost:9912/restore \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"input": "backup_1234567890.tar.gz", "all": true}'
```
## Грешки
```json
{
"error": {
"code": "INVALID_QUERY",
"message": "Грешка в синтаксиса"
}
"error": "Unauthorized"
}
```
@@ -107,5 +134,5 @@ curl -X POST http://localhost:9470/api/backup \
```bash
curl -H "Authorization: Bearer <token>" \
http://localhost:9470/api/users
http://localhost:9912/metrics
```
+11 -1
View File
@@ -240,9 +240,19 @@ Content-Type: application/json
```http
POST /backup
Content-Type: application/json
Authorization: Bearer <token>
{
"destination": "/backup/snapshot.db"
"all": true
}
```
Отговор:
```json
{
"success": true,
"output": "backup_1234567890.tar.gz",
"message": "Backup created"
}
```
+11 -1
View File
@@ -290,9 +290,19 @@ Response:
```http
POST /backup
Content-Type: application/json
Authorization: Bearer <token>
{
"destination": "/backup/snapshot.db"
"all": true
}
```
Response:
```json
{
"success": true,
"output": "backup_1234567890.tar.gz",
"message": "Backup created"
}
```
+245 -3
View File
@@ -38,9 +38,11 @@ type
const
DEFAULT_DATA_DIR = "data/server"
DEFAULT_DATA_ROOT = "data/databases"
DEFAULT_KEEP_COUNT = 5
DEFAULT_COMPRESSION = 6
HISTORY_FILE = "backup_history.log"
BACKUP_META_FILE = "backup.json"
HELP_TEXT = """
BaraDB Backup Manager — Archive and restore your database safely
================================================================
@@ -73,20 +75,33 @@ COMMANDS:
help Show this help message.
OPTIONS:
-d, --data-dir <DIR> Path to data directory (default: data/server)
-d, --data-dir <DIR> Path to single data directory (default: data/server)
-r, --data-root <DIR> Path to multi-database root (default: data/databases)
-o, --output <FILE> Destination path for new backup archive
-i, --input <FILE> Source archive for restore or verify
-k, --keep <N> Retention count for cleanup (default: 5)
-e, --exclude <PAT> Exclude files matching pattern (repeatable)
-l, --level <0-9> Gzip compression level (default: 6, max: 9)
--all-databases Backup/restore all databases under --data-root
--database <NAME> Backup/restore a specific database under --data-root
--online Create consistent backup via checkpoint (freeze + flush)
--dry-run Show what restore would do without changing anything
-f, --force Skip confirmation prompts (use with caution!)
-v, --verbose Print detailed progress information
EXAMPLES:
# Quick backup with default settings
# Quick backup of single directory (legacy)
backup backup
# Backup all databases (recommended for multi-DB setups)
backup backup --all-databases --output=all_backup.tar.gz
# Backup a specific database
backup backup --database=default --output=default_backup.tar.gz
# Restore all databases
backup restore --input=all_backup.tar.gz --all-databases
# Backup with maximum compression and custom name
backup backup --output=prod_backup_$(date +%F).tar.gz --level=9
@@ -481,6 +496,168 @@ proc incrementalBackupDataDir*(dataDir: string, output: string, verbose: bool =
echo " Files: ", filesToInclude.len
return true
proc discoverDatabases*(dataRoot: string): seq[string] =
## Scan dataRoot for database directories
result = @[]
if not dirExists(dataRoot):
return
for kind, path in walkDir(dataRoot):
if kind == pcDir:
let dbName = lastPathPart(path)
if dbName.len > 0 and dbName notin [".", ".."]:
result.add(dbName)
result.sort()
proc backupAllDatabases*(dataRoot: string, output: string, excludes: seq[string] = @[], compression: int = DEFAULT_COMPRESSION, verbose: bool = false): bool =
## Create a tar.gz backup of all databases under dataRoot.
## Archive layout: databases/<name>/... + backup.json
if not dirExists(dataRoot):
echo "ERROR: Data root directory not found: ", dataRoot
return false
let databases = discoverDatabases(dataRoot)
if databases.len == 0:
echo "ERROR: No databases found in ", dataRoot
return false
if fileExists(output):
echo "WARNING: Overwriting existing file: ", output
let workDir = getCurrentDir()
let tempDir = getTempDir() / "baradb_backup_" & $getTime().toUnix()
createDir(tempDir / "databases")
var meta = %*{
"version": 1,
"timestamp": getTime().toUnix(),
"databases": databases,
"createdBy": "baradb-backup"
}
# Copy each database into temp staging area
for dbName in databases:
let src = dataRoot / dbName
let dst = tempDir / "databases" / dbName
if verbose:
echo "Staging database: ", dbName
copyDir(src, dst)
# Write metadata
writeFile(tempDir / BACKUP_META_FILE, $meta)
var excludeArgs = ""
for pattern in excludes:
excludeArgs.add(" --exclude=" & quoteShell(pattern))
let tarCmd = "tar -cf -" & excludeArgs & " -C " & quoteShell(tempDir) & " ."
let gzipCmd = "gzip -" & $compression
let cmd = tarCmd & " | " & gzipCmd & " > " & quoteShell(output)
if verbose:
echo "Running: ", cmd
echo "Databases: ", databases.join(", ")
echo "Output: ", output
let (outputStr, exitCode) = execCmdEx("bash -c " & quoteShell(cmd))
removeDir(tempDir)
if exitCode != 0:
echo "ERROR: tar command failed with exit code ", exitCode
if outputStr.len > 0:
echo outputStr
return false
let size = getFileSize(output)
echo "Multi-database backup created successfully:"
echo " File: ", output
echo " Size: ", formatBytes(size)
echo " Databases: ", databases.len, " (", databases.join(", "), ")"
return true
proc restoreAllDatabases*(input: string, dataRoot: string, verbose: bool = false, dryRun: bool = false): bool =
## Restore all databases from a multi-database archive.
if not fileExists(input):
echo "ERROR: Backup file not found: ", input
return false
let archiveSize = getFileSize(input)
let freeSpace = getFreeSpace(parentDir(dataRoot))
let oldBackupPath = dataRoot & ".old_" & $getTime().toUnix()
if dryRun:
echo "DRY-RUN: The following actions would be performed:"
echo " 1. Verify archive integrity: ", input
echo " 2. Move existing data root to: ", oldBackupPath
echo " 3. Extract archive to: ", dataRoot
echo " Archive size: ", formatBytes(archiveSize)
if freeSpace >= 0:
echo " Free space: ", formatBytes(freeSpace)
else:
echo " Free space: unable to determine"
return true
# Check free space
if freeSpace >= 0 and freeSpace < archiveSize * 2:
echo "WARNING: Free space (", formatBytes(freeSpace), ") may be insufficient."
echo " Archive is ", formatBytes(archiveSize), " — at least 2x is recommended."
if dirExists(dataRoot):
if verbose:
echo "Moving existing data root to: ", oldBackupPath
moveDir(dataRoot, oldBackupPath)
createDir(dataRoot)
let cmd = "tar -xzf " & quoteShell(input) & " -C " & quoteShell(dataRoot)
if verbose:
echo "Running: ", cmd
let (outputStr, exitCode) = execCmdEx(cmd)
if exitCode != 0:
echo "ERROR: tar extraction failed with exit code ", exitCode
if outputStr.len > 0:
echo outputStr
# Attempt rollback
if dirExists(oldBackupPath):
echo "Attempting rollback..."
removeDir(dataRoot)
moveDir(oldBackupPath, dataRoot)
echo "Rollback complete. Data restored to previous state."
return false
# Verify metadata
let metaPath = dataRoot / BACKUP_META_FILE
if fileExists(metaPath):
try:
let meta = parseJson(readFile(metaPath))
let dbList = meta{"databases"}
if dbList != nil and dbList.kind == JArray:
echo "Restored databases: ", dbList.len
for db in dbList:
echo " - ", db.getStr()
except CatchableError as e:
echo "WARNING: Could not parse backup metadata: ", e.msg
echo "Restored successfully from: ", input
echo " Target: ", dataRoot
if dirExists(oldBackupPath):
echo " Old data preserved at: ", oldBackupPath
return true
proc readBackupMeta*(input: string): JsonNode =
## Read backup metadata without full extraction.
## Returns nil if not a multi-db archive or no metadata.
result = nil
if not fileExists(input):
return
let cmd = "tar -xzf " & quoteShell(input) & " -O ./" & BACKUP_META_FILE & " 2>/dev/null"
let (outStr, exitCode) = execCmdEx(cmd)
if exitCode == 0 and outStr.len > 0:
try:
result = parseJson(outStr)
except CatchableError:
discard
# =============================================================================
# CLI Entry Point
# =============================================================================
@@ -488,6 +665,7 @@ when isMainModule:
var
command = ""
dataDir = DEFAULT_DATA_DIR
dataRoot = DEFAULT_DATA_ROOT
target = ""
keepCount = DEFAULT_KEEP_COUNT
excludes: seq[string] = @[]
@@ -496,6 +674,8 @@ when isMainModule:
dryRun = false
force = false
online = false
allDatabases = false
databaseName = ""
for kind, key, val in getopt():
case kind
@@ -507,6 +687,7 @@ when isMainModule:
of cmdLongOption, cmdShortOption:
case key
of "data-dir", "d": dataDir = val
of "data-root", "r": dataRoot = val
of "output", "o": target = val
of "input", "i": target = val
of "keep", "k":
@@ -522,6 +703,8 @@ when isMainModule:
of "dry-run": dryRun = true
of "force", "f": force = true
of "online": online = true
of "all-databases": allDatabases = true
of "database": databaseName = val
of "verbose", "v": verbose = true
of "help", "h":
echo HELP_TEXT
@@ -537,6 +720,33 @@ when isMainModule:
case command
of "backup":
let outputFile = if target.len > 0: target else: "backup_" & $getTime().toUnix() & ".tar.gz"
if allDatabases:
let ok = backupAllDatabases(dataRoot, outputFile, excludes, compression, verbose)
if not ok:
quit("Multi-database backup failed", 1)
elif databaseName.len > 0:
let dbDir = dataRoot / databaseName
if online:
echo "Creating online backup with checkpoint..."
echo " Database: ", databaseName
echo " Data dir: ", dbDir
echo " Output: ", outputFile
try:
var db = newLSMTree(dbDir)
db.checkpoint()
db.close()
echo "Checkpoint complete."
except CatchableError as e:
echo "ERROR: Checkpoint failed: ", e.msg
quit(1)
let ok = incrementalBackupDataDir(dbDir, outputFile, verbose)
if not ok:
quit("Online backup failed", 1)
else:
let ok = backupDataDir(dbDir, outputFile, excludes, compression, verbose)
if not ok:
quit("Backup failed", 1)
else:
if online:
echo "Creating online backup with checkpoint..."
echo " Data dir: ", dataDir
@@ -559,6 +769,12 @@ when isMainModule:
of "incremental":
let outputFile = if target.len > 0: target else: "backup_inc_" & $getTime().toUnix() & ".tar.gz"
if databaseName.len > 0:
let dbDir = dataRoot / databaseName
let ok = incrementalBackupDataDir(dbDir, outputFile, verbose)
if not ok:
quit("Incremental backup failed", 1)
else:
let ok = incrementalBackupDataDir(dataDir, outputFile, verbose)
if not ok:
quit("Incremental backup failed", 1)
@@ -567,6 +783,16 @@ when isMainModule:
if target.len == 0:
quit("ERROR: restore requires --input=<file.tar.gz>\nUse 'backup help' for usage.", 1)
# Detect archive type from metadata
let meta = readBackupMeta(target)
let isMultiDb = meta != nil and meta{"databases"} != nil
if isMultiDb and not allDatabases and databaseName.len == 0:
echo "Detected multi-database archive containing:"
for db in meta{"databases"}:
echo " - ", db.getStr()
echo "Use --all-databases to restore all, or --database=<name> for a single database."
# Always verify first unless dry-run
if not dryRun:
echo "Verifying archive before restore..."
@@ -575,6 +801,20 @@ when isMainModule:
logRestore(target, dataDir, false)
quit("Restore aborted: archive verification failed.", 1)
if allDatabases or isMultiDb:
if not dryRun and not force:
echo "WARNING: This will REPLACE all databases in: ", dataRoot
echo "Continue? [y/N] "
let answer = readLine(stdin)
if answer.toLowerAscii() notin ["y", "yes"]:
echo "Restore cancelled."
logRestore(target, dataRoot, false, dryRun = false)
quit(0)
let ok = restoreAllDatabases(target, dataRoot, verbose, dryRun)
logRestore(target, dataRoot, ok, dryRun)
if not ok and not dryRun:
quit("Restore failed", 1)
else:
if not dryRun and not force:
echo "WARNING: This will REPLACE the data in: ", dataDir
echo "Continue? [y/N] "
@@ -583,7 +823,6 @@ when isMainModule:
echo "Restore cancelled."
logRestore(target, dataDir, false, dryRun = false)
quit(0)
let ok = restoreDataDir(target, dataDir, verbose, dryRun)
logRestore(target, dataDir, ok, dryRun)
if not ok and not dryRun:
@@ -601,6 +840,9 @@ when isMainModule:
quit("Verification failed", 1)
of "cleanup":
if allDatabases:
cleanupOldBackups(dataRoot, keepCount, verbose)
else:
cleanupOldBackups(dataDir, keepCount, verbose)
of "history":
+301 -8
View File
@@ -7,6 +7,7 @@ import json
import tables
import strutils
import times
import os
import std/asyncdispatch
import config
import ../query/lexer
@@ -21,6 +22,7 @@ import jwt as jwtlib
import ../protocol/auth
import ../protocol/ratelimit
import ../core/registry
import ../core/backup
type
HttpServer* = ref object
@@ -121,6 +123,36 @@ proc checkAuth(server: HttpServer, request: Request, ctx: Context): bool =
return false
return true
proc checkAdmin(server: HttpServer, request: Request, ctx: Context): bool =
if not server.config.authEnabled:
return true
let authHeader = request.headers["Authorization"]
if authHeader.len == 0 or not authHeader.startsWith("Bearer "):
ctx.json(%*{"error": "Unauthorized"}, 401)
return false
let tokenStr = authHeader[7..^1]
let (valid, _, role) = server.verifyToken(tokenStr)
if not valid:
ctx.json(%*{"error": "Unauthorized"}, 401)
return false
if role != "admin":
ctx.json(%*{"error": "Forbidden: admin role required"}, 403)
return false
return true
proc getRequestDatabaseContext(server: HttpServer, request: Request): ExecutionContext =
## Return execution context for the requested database (via X-Database header)
## Falls back to server.ctx (default database) if not specified.
let dbName = request.headers["X-Database"]
if dbName.len > 0 and server.registry != nil:
try:
let dbInfo = getOrCreateDatabase(server.registry, dbName)
if dbInfo != nil and dbInfo.db != nil:
return newExecutionContext(dbInfo.db, server.registry)
except CatchableError:
discard
return newExecutionContext(server.db, server.registry)
# ----------------------------------------------------------------------
# Handlers
# ----------------------------------------------------------------------
@@ -164,7 +196,7 @@ proc queryHandler(server: HttpServer): RequestHandler =
ctx.json(%*{"error": "Empty query"}, 400)
return
var reqCtx = cloneForConnection(server.ctx)
var reqCtx = getRequestDatabaseContext(server, request)
reqCtx.currentUser = userId
reqCtx.currentRole = role
let tokens = tokenize(queryStr)
@@ -297,11 +329,14 @@ proc openApiHandler(): RequestHandler =
let ctx = newContext(request)
ctx.json(%*{
"openapi": "3.0.0",
"info": {"title": "BaraDB API", "version": "0.1.0"},
"info": {"title": "BaraDB API", "version": "1.1.6"},
"paths": {
"/query": {
"post": {
"summary": "Execute SQL query",
"parameters": [
{"name": "X-Database", "in": "header", "schema": {"type": "string"}, "description": "Target database (default: default)"}
],
"requestBody": {
"content": {
"application/json": {
@@ -315,6 +350,20 @@ proc openApiHandler(): RequestHandler =
}
}
},
"/tables": {"get": {"summary": "List tables", "parameters": [{"name": "X-Database", "in": "header", "schema": {"type": "string"}}]}},
"/databases": {
"get": {"summary": "List databases"},
"post": {"summary": "Create database (admin)"}
},
"/backup": {
"post": {"summary": "Create backup (admin)"}
},
"/backups": {
"get": {"summary": "List backups"}
},
"/restore": {
"post": {"summary": "Restore from backup (admin)"}
},
"/health": {"get": {"summary": "Health check"}},
"/metrics": {"get": {"summary": "Prometheus metrics"}},
"/auth": {"post": {"summary": "Authenticate and get JWT token"}}
@@ -327,8 +376,9 @@ proc tablesHandler(server: HttpServer): RequestHandler =
let ctx = newContext(request)
if not server.checkAuth(request, ctx):
return
let reqCtx = getRequestDatabaseContext(server, request)
var tables = newJArray()
for name, tbl in server.ctx.tables:
for name, tbl in reqCtx.tables:
var cols = newJArray()
for col in tbl.columns:
cols.add(%*{"name": col.name, "type": col.colType,
@@ -337,6 +387,162 @@ proc tablesHandler(server: HttpServer): RequestHandler =
"pkColumns": tbl.pkColumns, "fkCount": tbl.foreignKeys.len})
ctx.json(%*{"tables": tables})
proc databasesHandler(server: HttpServer): RequestHandler =
return proc(request: Request) {.gcsafe.} =
{.cast(gcsafe).}:
let ctx = newContext(request)
if not server.checkAuth(request, ctx):
return
let dbs = server.registry.listDatabases()
var arr = newJArray()
for dbName in dbs:
var obj = newJObject()
obj["name"] = %dbName
try:
let dbInfo = getDatabaseInfo(server.registry, dbName)
if dbInfo != nil and dbInfo.ctx != nil:
let dbCtx = cast[ExecutionContext](cast[pointer](dbInfo.ctx))
obj["tables"] = %dbCtx.tables.len
obj["connections"] = %getConnectionCount(server.registry, dbName)
else:
obj["tables"] = %0
obj["connections"] = %0
except CatchableError:
obj["tables"] = %0
obj["connections"] = %0
arr.add(obj)
ctx.json(%*{"databases": arr})
proc createDatabaseHandler(server: HttpServer): RequestHandler =
return proc(request: Request) {.gcsafe.} =
{.cast(gcsafe).}:
let ctx = newContext(request)
if not server.checkAdmin(request, ctx):
return
let body = parseJson(request.body)
if body == nil or "name" notin body:
ctx.json(%*{"error": "Missing 'name' in request body"}, 400)
return
let dbName = body["name"].getStr()
if dbName.len == 0:
ctx.json(%*{"error": "Empty database name"}, 400)
return
try:
discard getOrCreateDatabase(server.registry, dbName)
ctx.json(%*{"success": true, "name": dbName, "message": "Database created"})
except CatchableError as e:
ctx.json(%*{"error": e.msg}, 400)
proc dropDatabaseHandler(server: HttpServer): RequestHandler =
return proc(request: Request) {.gcsafe.} =
{.cast(gcsafe).}:
let ctx = newContext(request)
if not server.checkAdmin(request, ctx):
return
let dbName = request.pathParams.getOrDefault("name", "")
if dbName.len == 0:
ctx.json(%*{"error": "Missing database name"}, 400)
return
try:
let ok = dropDatabase(server.registry, dbName)
if ok:
ctx.json(%*{"success": true, "name": dbName, "message": "Database dropped"})
else:
ctx.json(%*{"error": "Database not found"}, 404)
except CatchableError as e:
ctx.json(%*{"error": e.msg}, 400)
proc backupHandler(server: HttpServer): RequestHandler =
return proc(request: Request) {.gcsafe.} =
{.cast(gcsafe).}:
let ctx = newContext(request)
if not server.checkAdmin(request, ctx):
return
let body = parseJson(request.body)
let dataRoot = server.registry.dataRoot
let allDatabases = if body != nil and "all" in body: body["all"].getBool() else: false
let dbName = if body != nil and "database" in body: body["database"].getStr() else: ""
let outputFile = if body != nil and "output" in body: body["output"].getStr() else: "backup_" & $getTime().toUnix() & ".tar.gz"
let compression = if body != nil and "level" in body: body["level"].getInt() else: 6
try:
var ok = false
if allDatabases:
ok = backupAllDatabases(dataRoot, outputFile, @[], compression, false)
elif dbName.len > 0:
let dbDir = dataRoot / dbName
ok = backupDataDir(dbDir, outputFile, @[], compression, false)
else:
ok = backupAllDatabases(dataRoot, outputFile, @[], compression, false)
if ok:
ctx.json(%*{"success": true, "output": outputFile, "message": "Backup created"})
else:
ctx.json(%*{"error": "Backup failed"}, 500)
except CatchableError as e:
ctx.json(%*{"error": e.msg}, 500)
proc listBackupsHandler(server: HttpServer): RequestHandler =
return proc(request: Request) {.gcsafe.} =
{.cast(gcsafe).}:
let ctx = newContext(request)
if not server.checkAuth(request, ctx):
return
let dataRoot = server.registry.dataRoot
let backups = listBackups(dataRoot)
var arr = newJArray()
for b in backups:
var meta: JsonNode = nil
try:
meta = readBackupMeta(b.path)
except CatchableError:
discard
var obj = newJObject()
obj["path"] = %b.path
obj["size"] = %b.size
obj["sizeHuman"] = %formatBytes(b.size)
obj["timestamp"] = %b.timestamp
obj["compressed"] = %b.compressed
if meta != nil and meta{"databases"} != nil:
obj["databases"] = meta{"databases"}
obj["type"] = %"multi"
else:
obj["type"] = %"single"
arr.add(obj)
ctx.json(%*{"backups": arr})
proc restoreHandler(server: HttpServer): RequestHandler =
return proc(request: Request) {.gcsafe.} =
{.cast(gcsafe).}:
let ctx = newContext(request)
if not server.checkAdmin(request, ctx):
return
let body = parseJson(request.body)
if body == nil or "input" notin body:
ctx.json(%*{"error": "Missing 'input' in request body"}, 400)
return
let inputFile = body["input"].getStr()
let allDatabases = if body != nil and "all" in body: body["all"].getBool() else: false
let dbName = if body != nil and "database" in body: body["database"].getStr() else: ""
let dataRoot = server.registry.dataRoot
try:
let meta = readBackupMeta(inputFile)
let isMultiDb = meta != nil and meta{"databases"} != nil
var ok = false
if isMultiDb or allDatabases:
ok = restoreAllDatabases(inputFile, dataRoot, false, false)
elif dbName.len > 0:
let dbDir = dataRoot / dbName
ok = restoreDataDir(inputFile, dbDir, false, false)
else:
ok = restoreAllDatabases(inputFile, dataRoot, false, false)
if ok:
# Reload databases after restore
server.registry.loadExistingDatabases()
ctx.json(%*{"success": true, "message": "Restore completed"})
else:
ctx.json(%*{"error": "Restore failed"}, 500)
except CatchableError as e:
ctx.json(%*{"error": e.msg}, 500)
proc adminHandler(server: HttpServer): RequestHandler =
return proc(request: Request) {.gcsafe.} =
let html = """
@@ -389,7 +595,7 @@ tr:hover td{background:#1a2744}
<button onclick='doLogin()' style='width:100%'>Login</button>
<p class='status' id='login-status'></p>
</div></div>
<header><h1>BaraDB Admin</h1><span id='user-info'></span></header>
<header><h1>BaraDB Admin</h1><div style='display:flex;align-items:center;gap:12px'><select id='db-select' onchange='switchDatabase()' style='width:auto;padding:4px 8px;font-size:13px'><option value=''>Loading...</option></select><span id='user-info'></span></div></header>
<div id='tabs'>
<span class='tab active' onclick='showTab(0)'>SQL Playground</span>
<span class='tab' onclick='showTab(1)'>Tables</span>
@@ -397,6 +603,8 @@ tr:hover td{background:#1a2744}
<span class='tab' onclick='showTab(3)'>Live</span>
<span class='tab' onclick='showTab(4)'>Metrics</span>
<span class='tab' onclick='showTab(5)'>Cluster</span>
<span class='tab' onclick='showTab(6)'>Databases</span>
<span class='tab' onclick='showTab(7)'>Backups</span>
</div>
<div class='panel active'>
<div class='card'><textarea id='sql' placeholder='SELECT * FROM users'></textarea>
@@ -434,10 +642,33 @@ tr:hover td{background:#1a2744}
<div class='panel'>
<div class='card'><h3>Cluster Status</h3><div id='cluster-data'>Cluster status not available</div></div>
</div>
<div class='panel'>
<div class='card'><h3>Databases</h3>
<div style='display:flex;gap:8px;margin-bottom:12px'>
<input id='new-db-name' placeholder='Database name' style='flex:1'>
<button onclick='createDatabase()'>Create</button>
</div>
<div id='db-list'>Loading...</div>
</div>
</div>
<div class='panel'>
<div class='card'><h3>Backups</h3>
<div style='display:flex;gap:8px;margin-bottom:12px'>
<button onclick='createBackup()'>Backup All</button>
<button class='secondary' onclick='createBackupSingle()'>Backup Current DB</button>
</div>
<div id='backup-list'>Loading...</div>
</div>
</div>
<script>
let token = localStorage.getItem('baradb_token') || ''
let currentDatabase = localStorage.getItem('baradb_db') || 'default'
let ws = null
function authHeader(){ return token ? {'Authorization':'Bearer '+token,'Content-Type':'application/json'} : {'Content-Type':'application/json'} }
function authHeader(){
let h = token ? {'Authorization':'Bearer '+token,'Content-Type':'application/json'} : {'Content-Type':'application/json'}
if(currentDatabase) h['X-Database'] = currentDatabase
return h
}
async function api(path, body, method='POST') {
const r = await fetch(path, {method, headers:authHeader(), body: body?JSON.stringify(body):undefined})
return r.json().catch(() => r.text())
@@ -446,13 +677,67 @@ async function doLogin(){
const u = document.getElementById('username').value
const p = document.getElementById('password').value
const r = await api('/auth', {username:u, password:p})
if(r.token){ token = r.token; localStorage.setItem('baradb_token', token); hideLogin(); showUser(r.user); connectWS() }
if(r.token){ token = r.token; localStorage.setItem('baradb_token', token); hideLogin(); showUser(r.user); connectWS(); loadDatabases() }
else { document.getElementById('login-status').textContent = r.error || 'Login failed' }
}
function hideLogin(){ document.getElementById('login-overlay').style.display='none' }
function showUser(u){ document.getElementById('user-info').innerHTML = 'User: <b>'+u+'</b> <a href="#" onclick="logout()">logout</a>' }
function logout(){ token=''; localStorage.removeItem('baradb_token'); location.reload() }
if(token){ hideLogin(); showUser('...'); api('/health',null,'GET').then(()=>showUser('authed')).catch(()=>logout()); connectWS() }
function logout(){ token=''; localStorage.removeItem('baradb_token'); localStorage.removeItem('baradb_db'); location.reload() }
if(token){ hideLogin(); showUser('...'); api('/health',null,'GET').then(()=>{showUser('authed'); loadDatabases()}).catch(()=>logout()); connectWS() }
function switchDatabase(){
currentDatabase = document.getElementById('db-select').value
localStorage.setItem('baradb_db', currentDatabase)
loadTables()
loadSchema()
}
async function loadDatabases(){
try{
const r = await api('/databases', null, 'GET')
const sel = document.getElementById('db-select')
if(!r.databases){ sel.innerHTML = '<option value="default">default</option>'; return }
sel.innerHTML = r.databases.map(d => '<option value="'+d.name+'"'+(d.name===currentDatabase?' selected':'')+'>'+d.name+' ('+d.tables+' tables)</option>').join('')
// Databases tab
const el = document.getElementById('db-list')
el.innerHTML = '<table><tr><th>Name</th><th>Tables</th><th>Connections</th><th>Action</th></tr>'+r.databases.map(d => '<tr><td>'+d.name+'</td><td>'+d.tables+'</td><td>'+d.connections+'</td><td>'+(d.name!=="default"?'<button class="secondary" onclick="dropDatabase(\''+d.name+'\')">Drop</button>':'')+'</td></tr>').join('')+'</table>'
}catch(e){}
}
async function createDatabase(){
const name = document.getElementById('new-db-name').value.trim()
if(!name) return alert('Enter database name')
const r = await api('/databases', {name:name})
if(r.success){ document.getElementById('new-db-name').value=''; loadDatabases() }
else alert(r.error || 'Failed')
}
async function dropDatabase(name){
if(!confirm('Drop database '+name+'?')) return
const r = await api('/databases/'+name, null, 'DELETE')
if(r.success){ loadDatabases() }
else alert(r.error || 'Failed')
}
async function createBackup(){
const r = await api('/backup', {all:true})
if(r.success){ alert('Backup created: '+r.output); loadBackups() }
else alert(r.error || 'Failed')
}
async function createBackupSingle(){
const r = await api('/backup', {database:currentDatabase})
if(r.success){ alert('Backup created: '+r.output); loadBackups() }
else alert(r.error || 'Failed')
}
async function loadBackups(){
try{
const r = await api('/backups', null, 'GET')
const el = document.getElementById('backup-list')
if(!r.backups || !r.backups.length){ el.innerHTML = 'No backups'; return }
el.innerHTML = '<table><tr><th>File</th><th>Type</th><th>Size</th><th>Date</th><th>Databases</th><th>Action</th></tr>'+r.backups.map(b => '<tr><td>'+b.path.split('/').pop()+'</td><td>'+b.type+'</td><td>'+b.sizeHuman+'</td><td>'+new Date(b.timestamp*1000).toLocaleString()+'</td><td>'+(b.databases?b.databases.join(', '):'-')+'</td><td><button class="secondary" onclick="restoreBackup(\''+b.path+'\')">Restore</button></td></tr>').join('')+'</table>'
}catch(e){}
}
async function restoreBackup(path){
if(!confirm('Restore from '+path+'? This replaces current data.')) return
const r = await api('/restore', {input:path, all:true})
if(r.success){ alert('Restore completed'); loadDatabases(); loadBackups() }
else alert(r.error || 'Failed')
}
async function runQuery(){
const sql = document.getElementById('sql').value
const res = await api('/query', {query: sql})
@@ -548,6 +833,8 @@ function showTab(idx){
if(idx===1) loadTables()
if(idx===2) loadSchema()
if(idx===4) loadMetrics()
if(idx===6) loadDatabases()
if(idx===7) loadBackups()
}
setInterval(() => { if(document.querySelectorAll('.panel')[4].classList.contains('active')) loadMetrics() }, 5000)
</script>
@@ -567,6 +854,12 @@ proc run*(server: HttpServer, port: int = 9470) =
router.post("/auth/scram/finish", server.scramFinishHandler())
router.get("/tables", server.tablesHandler())
router.get("/api", openApiHandler())
router.get("/databases", server.databasesHandler())
router.post("/databases", server.createDatabaseHandler())
# router.delete("/databases/@name", server.dropDatabaseHandler()) -- disabled due to ORC memory issue with LSMTree close
router.post("/backup", server.backupHandler())
router.get("/backups", server.listBackupsHandler())
router.post("/restore", server.restoreHandler())
var stack = newMiddlewareStack(router)
stack.use(corsMiddleware())
+5 -2
View File
@@ -158,13 +158,16 @@ proc dropDatabase*(reg: DatabaseRegistry, name: string): bool =
"Cannot drop database '" & name & "': " &
$info.activeConnections & " active connections")
# Copy needed references before deletion
let db = info.db
let dbDir = reg.dataRoot / name
# Remove from registry first so no new references can be obtained
reg.databases.del(name)
release(reg.lock)
# Close LSMTree and remove directory outside the lock
info.db.close()
let dbDir = reg.dataRoot / name
db.close()
if dirExists(dbDir):
removeDir(dbDir)
true