Files
Beszel/internal/hub/bulk/api.go
T
Tomas Dvorak 8011d487f1 Add public monitoring features and CI updates
- Add status pages, incidents, badges, maintenance, bulk ops, and metrics
- Add Docker packaging, env example, and frontend routes
- Refresh GitHub workflows and project metadata
2026-04-27 11:10:18 +02:00

309 lines
8.8 KiB
Go

package bulk
import (
"encoding/csv"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/henrygd/beszel/internal/entities/domain"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
// APIHandler handles bulk import/export requests
type APIHandler struct {
app core.App
}
// NewAPIHandler creates a new bulk API handler
func NewAPIHandler(app core.App) *APIHandler {
return &APIHandler{app: app}
}
// RegisterRoutes registers bulk API routes
func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
api := se.Router.Group("/api/beszel/bulk")
api.Bind(apis.RequireAuth())
// Import endpoints
api.POST("/import/domains", h.importDomains)
api.POST("/import/monitors", h.importMonitors)
// Export endpoints
api.GET("/export/domains", h.exportDomains)
api.GET("/export/monitors", h.exportMonitors)
}
// ImportResult represents the result of an import operation
type ImportResult struct {
Success int `json:"success"`
Failed int `json:"failed"`
Errors []string `json:"errors,omitempty"`
}
// importDomains handles bulk domain import from CSV
func (h *APIHandler) importDomains(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
// Parse multipart form
if err := e.Request.ParseMultipartForm(10 << 20); err != nil { // 10 MB max
return e.BadRequestError("failed to parse form", err)
}
file, _, err := e.Request.FormFile("file")
if err != nil {
return e.BadRequestError("missing file", err)
}
defer file.Close()
reader := csv.NewReader(file)
// Skip header
_, _ = reader.Read()
collection, err := h.app.FindCollectionByNameOrId("domains")
if err != nil {
return e.InternalServerError("failed to find collection", err)
}
result := ImportResult{}
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
result.Failed++
result.Errors = append(result.Errors, fmt.Sprintf("Read error: %v", err))
continue
}
if len(record) < 1 || record[0] == "" {
result.Failed++
result.Errors = append(result.Errors, "Empty domain name")
continue
}
domainName := strings.TrimSpace(record[0])
// Check if domain already exists
existing, _ := h.app.FindFirstRecordByFilter("domains", "domain_name = {:domain} && user = {:user}",
dbx.Params{"domain": domainName, "user": authRecord.Id})
if existing != nil {
result.Failed++
result.Errors = append(result.Errors, fmt.Sprintf("Domain already exists: %s", domainName))
continue
}
// Create domain record
newRecord := core.NewRecord(collection)
newRecord.Set("domain_name", domainName)
newRecord.Set("user", authRecord.Id)
newRecord.Set("status", domain.DomainStatusUnknown)
// Optional fields
if len(record) > 1 && record[1] != "" {
newRecord.Set("tags", strings.Split(record[1], ","))
}
if len(record) > 2 && record[2] != "" {
newRecord.Set("notes", record[2])
}
if len(record) > 3 && record[3] != "" {
newRecord.Set("auto_renew", record[3] == "true" || record[3] == "yes")
}
if err := h.app.Save(newRecord); err != nil {
result.Failed++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to save %s: %v", domainName, err))
} else {
result.Success++
}
}
return e.JSON(http.StatusOK, result)
}
// importMonitors handles bulk monitor import from CSV
func (h *APIHandler) importMonitors(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
if err := e.Request.ParseMultipartForm(10 << 20); err != nil {
return e.BadRequestError("failed to parse form", err)
}
file, _, err := e.Request.FormFile("file")
if err != nil {
return e.BadRequestError("missing file", err)
}
defer file.Close()
reader := csv.NewReader(file)
// Skip header
_, _ = reader.Read()
collection, err := h.app.FindCollectionByNameOrId("monitors")
if err != nil {
return e.InternalServerError("failed to find collection", err)
}
result := ImportResult{}
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
result.Failed++
result.Errors = append(result.Errors, fmt.Sprintf("Read error: %v", err))
continue
}
if len(record) < 2 || record[0] == "" || record[1] == "" {
result.Failed++
result.Errors = append(result.Errors, "Missing name or URL")
continue
}
name := strings.TrimSpace(record[0])
url := strings.TrimSpace(record[1])
// Check if monitor already exists
existing, _ := h.app.FindFirstRecordByFilter("monitors", "name = {:name} && user = {:user}",
dbx.Params{"name": name, "user": authRecord.Id})
if existing != nil {
result.Failed++
result.Errors = append(result.Errors, fmt.Sprintf("Monitor already exists: %s", name))
continue
}
// Create monitor record
newRecord := core.NewRecord(collection)
newRecord.Set("name", name)
newRecord.Set("url", url)
newRecord.Set("user", authRecord.Id)
newRecord.Set("status", "unknown")
newRecord.Set("type", monitor.TypeHTTP)
newRecord.Set("interval", 60)
newRecord.Set("retries", 3)
// Optional fields
if len(record) > 2 && record[2] != "" {
newRecord.Set("type", record[2])
}
if len(record) > 3 && record[3] != "" {
if interval, err := parseInt(record[3]); err == nil {
newRecord.Set("interval", interval)
}
}
if len(record) > 4 && record[4] != "" {
if retries, err := parseInt(record[4]); err == nil {
newRecord.Set("retries", retries)
}
}
if err := h.app.Save(newRecord); err != nil {
result.Failed++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to save %s: %v", name, err))
} else {
result.Success++
}
}
return e.JSON(http.StatusOK, result)
}
// exportDomains exports domains as JSON
func (h *APIHandler) exportDomains(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
records, err := h.app.FindAllRecords("domains",
dbx.NewExp("user = {:user}", dbx.Params{"user": authRecord.Id}),
)
if err != nil {
return e.InternalServerError("failed to fetch domains", err)
}
domains := make([]map[string]interface{}, 0, len(records))
for _, record := range records {
domains = append(domains, map[string]interface{}{
"domain_name": record.GetString("domain_name"),
"status": record.GetString("status"),
"expiry_date": record.GetDateTime("expiry_date").Time(),
"registrar_name": record.GetString("registrar_name"),
"ssl_issuer": record.GetString("ssl_issuer"),
"ssl_valid_to": record.GetDateTime("ssl_valid_to").Time(),
"ipv4_addresses": record.Get("ipv4_addresses"),
"ipv6_addresses": record.Get("ipv6_addresses"),
"name_servers": record.Get("name_servers"),
"mx_records": record.Get("mx_records"),
"tags": record.Get("tags"),
"auto_renew": record.GetBool("auto_renew"),
"notes": record.GetString("notes"),
"created": record.GetDateTime("created").Time(),
"updated": record.GetDateTime("updated").Time(),
})
}
e.Response.Header().Set("Content-Type", "application/json")
e.Response.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=domains_export_%s.json", time.Now().Format("2006-01-02")))
return json.NewEncoder(e.Response).Encode(domains)
}
// exportMonitors exports monitors as JSON
func (h *APIHandler) exportMonitors(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
records, err := h.app.FindAllRecords("monitors",
dbx.NewExp("user = {:user}", dbx.Params{"user": authRecord.Id}),
)
if err != nil {
return e.InternalServerError("failed to fetch monitors", err)
}
monitors := make([]map[string]interface{}, 0, len(records))
for _, record := range records {
monitors = append(monitors, map[string]interface{}{
"name": record.GetString("name"),
"url": record.GetString("url"),
"type": record.GetString("type"),
"status": record.GetString("status"),
"interval": record.GetInt("interval"),
"retries": record.GetInt("retries"),
"last_response_time": record.GetInt("last_response_time"),
"uptime_stats": record.GetString("uptime_stats"),
"tags": record.Get("tags"),
"created": record.GetDateTime("created").Time(),
"updated": record.GetDateTime("updated").Time(),
})
}
e.Response.Header().Set("Content-Type", "application/json")
e.Response.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=monitors_export_%s.json", time.Now().Format("2006-01-02")))
return json.NewEncoder(e.Response).Encode(monitors)
}
func parseInt(s string) (int, error) {
var result int
_, err := fmt.Sscanf(s, "%d", &result)
return result, err
}