From 1e126734b017180f7a8114f3d97523f05ad6d240 Mon Sep 17 00:00:00 2001 From: Marek Goc Date: Fri, 13 Feb 2026 23:27:54 +0100 Subject: [PATCH] fix --- go.mod | 1 + go.sum | 2 + internal/server/admin_handlers.go | 51 +- internal/server/server.go | 3 + internal/server/static.go | 14 + .../server/templates/components/layout.templ | 265 ++++++ .../templates/components/layout_templ.go | 881 ++++++++++++++++++ internal/server/templates/pages/admin.templ | 257 +++++ .../server/templates/pages/admin_templ.go | 565 +++++++++++ internal/server/templates/pages/login.templ | 49 + .../server/templates/pages/login_templ.go | 73 ++ internal/server/templates/static/admin.js | 562 +++++++++++ internal/server/templates/static/login.js | 23 + 13 files changed, 2743 insertions(+), 3 deletions(-) create mode 100644 internal/server/static.go create mode 100644 internal/server/templates/components/layout.templ create mode 100644 internal/server/templates/components/layout_templ.go create mode 100644 internal/server/templates/pages/admin.templ create mode 100644 internal/server/templates/pages/admin_templ.go create mode 100644 internal/server/templates/pages/login.templ create mode 100644 internal/server/templates/pages/login_templ.go create mode 100644 internal/server/templates/static/admin.js create mode 100644 internal/server/templates/static/login.js diff --git a/go.mod b/go.mod index ff4be20..9a7f327 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( ) require ( + github.com/a-h/templ v0.3.977 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-ini/ini v1.67.0 // indirect diff --git a/go.sum b/go.sum index a341d36..454fcdc 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg= +github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= diff --git a/internal/server/admin_handlers.go b/internal/server/admin_handlers.go index 90ec6da..7dce9ba 100644 --- a/internal/server/admin_handlers.go +++ b/internal/server/admin_handlers.go @@ -6,8 +6,11 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io/fs" "net/http" "time" + + "git.ma-al.com/goc_marek/zfs/internal/server/templates/pages" ) // Admin authentication and management handlers @@ -679,9 +682,51 @@ func (s *Server) handleAdminChangePassword(w http.ResponseWriter, r *http.Reques // handleAdminUI serves the admin panel UI func (s *Server) handleAdminUI(w http.ResponseWriter, r *http.Request) { - // Serve the embedded admin UI HTML - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(adminPanelHTML)) + // Check if admin is authenticated + admin, _ := s.authenticateAdmin(r) + + if admin != nil { + // Serve the admin panel using templ + w.Header().Set("Content-Type", "text/html; charset=utf-8") + pages.AdminPage(admin.Username).Render(r.Context(), w) + } else { + // Serve the login page using templ + w.Header().Set("Content-Type", "text/html; charset=utf-8") + pages.LoginPage().Render(r.Context(), w) + } +} + +// handleAdminStatic serves static files for the admin panel +func (s *Server) handleAdminStatic(w http.ResponseWriter, r *http.Request) { + // Extract the file path from the URL + // URL format: /admin/static/ + path := r.URL.Path[len("/admin/static/"):] + + // Get the embedded filesystem + staticFS, err := GetStaticFS() + if err != nil { + http.Error(w, "Failed to load static files", http.StatusInternalServerError) + return + } + + // Serve the file + content, err := fs.ReadFile(staticFS, path) + if err != nil { + http.NotFound(w, r) + return + } + + // Set content type based on file extension + switch { + case len(path) >= 3 && path[len(path)-3:] == ".js": + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + case len(path) >= 4 && path[len(path)-4:] == ".css": + w.Header().Set("Content-Type", "text/css; charset=utf-8") + default: + w.Header().Set("Content-Type", "application/octet-stream") + } + + w.Write(content) } // handleAdminResetClientPassword resets a client's API key to a new random value diff --git a/internal/server/server.go b/internal/server/server.go index 5748f2d..1428cb1 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -527,6 +527,9 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/admin/admin/delete", s.handleAdminDeleteAdmin) mux.HandleFunc("/admin/admin/password", s.handleAdminChangePassword) + // Admin static files + mux.HandleFunc("/admin/static/", s.handleAdminStatic) + // Admin UI (static files served from /admin/) mux.HandleFunc("/admin/", s.handleAdminUI) } diff --git a/internal/server/static.go b/internal/server/static.go new file mode 100644 index 0000000..cbcd3c5 --- /dev/null +++ b/internal/server/static.go @@ -0,0 +1,14 @@ +package server + +import ( + "embed" + "io/fs" +) + +//go:embed templates/static/* +var staticFS embed.FS + +// GetStaticFS returns the embedded static filesystem +func GetStaticFS() (fs.FS, error) { + return fs.Sub(staticFS, "templates/static") +} diff --git a/internal/server/templates/components/layout.templ b/internal/server/templates/components/layout.templ new file mode 100644 index 0000000..d416467 --- /dev/null +++ b/internal/server/templates/components/layout.templ @@ -0,0 +1,265 @@ +package components + +import "fmt" + +// Layout is the base HTML layout for the admin panel +templ Layout(title string, username string) { + + + + + + { title } + + + + + { children... } + + + +} + +// Header renders the admin header with navigation +templ Header(username string) { +
+
+
+
+ +
+
+

ZFS Backup

+

Admin Panel

+
+
+
+
+ + { username } +
+ +
+
+
+} + +// StatsCard renders a single statistics card +templ StatsCard(title string, value string) { +
+
+ { title } + +
+
{ value }
+
+} + +// statsIcon returns the Font Awesome icon for a stats card +func statsIcon(title string) string { + switch title { + case "Clients": + return "fa-users" + case "Total Snapshots": + return "fa-camera" + case "Total Storage": + return "fa-hard-drive" + default: + return "fa-chart-bar" + } +} + +// TabButton renders a tab navigation button +templ TabButton(id string, label string, active bool) { + +} + +// tabIcon returns the Font Awesome icon for a tab +func tabIcon(id string) string { + switch id { + case "clients": + return "fa-users" + case "snapshots": + return "fa-images" + case "admins": + return "fa-user-shield" + default: + return "fa-folder" + } +} + +// tabButtonClass returns the CSS class for a tab button +func tabButtonClass(active bool) string { + if active { + return "bg-primary text-white shadow-lg shadow-primary/25" + } + return "bg-surface text-gray-400 hover:bg-surface-lighter hover:text-white" +} + +// Modal renders a modal dialog +templ Modal(id string, title string) { + +} + +// FormInput renders a form input field +templ FormInput(id string, label string, inputType string, placeholder string, required bool) { +
+ + +
+} + +// FormSelect renders a form select field +templ FormSelect(id string, label string, options []SelectOption) { +
+ + +
+} + +// SelectOption represents an option in a select field +type SelectOption struct { + Value string + Label string + Selected bool +} + +// FormCheckbox renders a form checkbox +templ FormCheckbox(id string, label string, checked bool) { +
+ + +
+} + +// Button renders a styled button +templ Button(label string, variant string) { + +} + +// buttonVariantClass returns the CSS class for a button variant +func buttonVariantClass(variant string) string { + switch variant { + case "primary": + return "bg-primary hover:bg-primary-dark text-white shadow-lg shadow-primary/25" + case "danger": + return "bg-red-500 hover:bg-red-600 text-white shadow-lg shadow-red-500/25" + case "success": + return "bg-emerald-500 hover:bg-emerald-600 text-white shadow-lg shadow-emerald-500/25" + case "warning": + return "bg-amber-500 hover:bg-amber-600 text-white shadow-lg shadow-amber-500/25" + case "purple": + return "bg-purple-500 hover:bg-purple-600 text-white shadow-lg shadow-purple-500/25" + case "orange": + return "bg-orange-500 hover:bg-orange-600 text-white shadow-lg shadow-orange-500/25" + default: + return "bg-gray-500 hover:bg-gray-600 text-white" + } +} + +// Badge renders a status badge +templ Badge(text string, variant string) { + + { text } + +} + +// badgeVariantClass returns the CSS class for a badge variant +func badgeVariantClass(variant string) string { + switch variant { + case "success": + return "bg-emerald-500/20 text-emerald-400" + case "danger": + return "bg-red-500/20 text-red-400" + case "info": + return "bg-blue-500/20 text-blue-400" + case "warning": + return "bg-amber-500/20 text-amber-400" + default: + return "bg-gray-500/20 text-gray-400" + } +} + +// ProgressBar renders a progress bar +templ ProgressBar(percent float64) { +
+
+
+} diff --git a/internal/server/templates/components/layout_templ.go b/internal/server/templates/components/layout_templ.go new file mode 100644 index 0000000..2eb5a24 --- /dev/null +++ b/internal/server/templates/components/layout_templ.go @@ -0,0 +1,881 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package components + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "fmt" + +// Layout is the base HTML layout for the admin panel +func Layout(title string, username string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/server/templates/components/layout.templ`, Line: 12, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// Header renders the admin header with navigation +func Header(username string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

ZFS Backup Admin Panel

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(username) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/server/templates/components/layout.templ`, Line: 41, Col: 19} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// StatsCard renders a single statistics card +func StatsCard(title string, value string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/server/templates/components/layout.templ`, Line: 55, Col: 48} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(value) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/server/templates/components/layout.templ`, Line: 56, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// TabButton renders a tab navigation button +func TabButton(id string, label string, active bool) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var templ_7745c5c3_Var9 = []any{"px-4 py-2 rounded transition-colors " + tabButtonClass(active)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// tabButtonClass returns the CSS class for a tab button +func tabButtonClass(active bool) string { + if active { + return "bg-primary text-white" + } + return "bg-white text-gray-600 hover:bg-gray-50" +} + +// Modal renders a modal dialog +func Modal(id string, title string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var13 := templ.GetChildren(ctx) + if templ_7745c5c3_Var13 == nil { + templ_7745c5c3_Var13 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// FormInput renders a form input field +func FormInput(id string, label string, inputType string, placeholder string, required bool) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var18 := templ.GetChildren(ctx) + if templ_7745c5c3_Var18 == nil { + templ_7745c5c3_Var18 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// FormSelect renders a form select field +func FormSelect(id string, label string, options []SelectOption) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var25 := templ.GetChildren(ctx) + if templ_7745c5c3_Var25 == nil { + templ_7745c5c3_Var25 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// SelectOption represents an option in a select field +type SelectOption struct { + Value string + Label string + Selected bool +} + +// FormCheckbox renders a form checkbox +func FormCheckbox(id string, label string, checked bool) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var32 := templ.GetChildren(ctx) + if templ_7745c5c3_Var32 == nil { + templ_7745c5c3_Var32 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// Button renders a styled button +func Button(label string, variant string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var37 := templ.GetChildren(ctx) + if templ_7745c5c3_Var37 == nil { + templ_7745c5c3_Var37 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var templ_7745c5c3_Var38 = []any{"px-3 py-1.5 text-sm rounded transition-colors " + buttonVariantClass(variant)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var38...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// buttonVariantClass returns the CSS class for a button variant +func buttonVariantClass(variant string) string { + switch variant { + case "primary": + return "bg-primary hover:bg-blue-600 text-white" + case "danger": + return "bg-danger hover:bg-red-600 text-white" + case "success": + return "bg-success hover:bg-green-600 text-white" + case "warning": + return "bg-warning hover:bg-yellow-600 text-white" + case "purple": + return "bg-purple-500 hover:bg-purple-600 text-white" + case "orange": + return "bg-orange-500 hover:bg-orange-600 text-white" + default: + return "bg-gray-500 hover:bg-gray-600 text-white" + } +} + +// Badge renders a status badge +func Badge(text string, variant string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var41 := templ.GetChildren(ctx) + if templ_7745c5c3_Var41 == nil { + templ_7745c5c3_Var41 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var templ_7745c5c3_Var42 = []any{"px-2 py-0.5 rounded text-xs font-semibold " + badgeVariantClass(variant)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var42...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(text) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/server/templates/components/layout.templ`, Line: 181, Col: 8} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// badgeVariantClass returns the CSS class for a badge variant +func badgeVariantClass(variant string) string { + switch variant { + case "success": + return "bg-green-100 text-green-800" + case "danger": + return "bg-red-100 text-red-800" + case "info": + return "bg-blue-100 text-blue-800" + case "warning": + return "bg-yellow-100 text-yellow-800" + default: + return "bg-gray-100 text-gray-800" + } +} + +// ProgressBar renders a progress bar +func ProgressBar(percent float64) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var45 := templ.GetChildren(ctx) + if templ_7745c5c3_Var45 == nil { + templ_7745c5c3_Var45 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/server/templates/pages/admin.templ b/internal/server/templates/pages/admin.templ new file mode 100644 index 0000000..2c33980 --- /dev/null +++ b/internal/server/templates/pages/admin.templ @@ -0,0 +1,257 @@ +package pages + +import ( + "git.ma-al.com/goc_marek/zfs/internal/server/templates/components" +) + +// AdminPage renders the main admin panel +templ AdminPage(username string) { + @components.Layout("ZFS Backup Admin Panel", username) { +
+ @components.Header(username) + + +
+ @components.StatsCard("Clients", "Loading...") + @components.StatsCard("Total Snapshots", "Loading...") + @components.StatsCard("Total Storage", "Loading...") +
+ + +
+ @components.TabButton("clients", "Clients", true) + @components.TabButton("snapshots", "Snapshots", false) + @components.TabButton("admins", "Admins", false) +
+ + +
+
+
+

+ + Clients +

+ +
+
+ + + + + + + + + + + + + +
Client IDStorage TypeQuotaUsedSnapshotsStatusActions
+
+
+
+ + + + + + +
+ + + @AddClientModal() + @EditClientModal() + @AddAdminModal() + @ChangePasswordModal() + @ClientPasswordModal() + + @AdminScripts() + } +} + +// AddClientModal renders the add client modal +templ AddClientModal() { + @components.Modal("add-client-modal", "Add New Client") { +
+ @components.FormInput("new-client-id", "Client ID", "text", "", true) + @components.FormInput("new-client-apikey", "API Key", "text", "", true) + @components.FormSelect("new-client-storage", "Storage Type", []components.SelectOption{ + {Value: "s3", Label: "S3", Selected: true}, + {Value: "local", Label: "Local ZFS", Selected: false}, + }) + @components.FormInput("new-client-dataset", "Target Dataset (for local storage)", "text", "backup/client1", false) + @components.FormInput("new-client-quota", "Quota (GB)", "number", "100", true) + @components.FormCheckbox("new-client-enabled", "Enabled", true) + +

+ + Rotation Policy +

+
+ @components.FormInput("new-client-hourly", "Keep Hourly", "number", "24", false) + @components.FormInput("new-client-daily", "Keep Daily", "number", "7", false) + @components.FormInput("new-client-weekly", "Keep Weekly", "number", "4", false) + @components.FormInput("new-client-monthly", "Keep Monthly", "number", "12", false) +
+ + +
+ } +} + +// EditClientModal renders the edit client modal +templ EditClientModal() { + @components.Modal("edit-client-modal", "Edit Client") { +
+ + @components.FormInput("edit-client-apikey", "New API Key (leave empty to keep current)", "text", "Leave empty to keep current", false) + @components.FormSelect("edit-client-storage", "Storage Type", []components.SelectOption{ + {Value: "s3", Label: "S3", Selected: true}, + {Value: "local", Label: "Local ZFS", Selected: false}, + }) + @components.FormInput("edit-client-dataset", "Target Dataset", "text", "", false) + @components.FormInput("edit-client-quota", "Quota (GB)", "number", "", true) + @components.FormCheckbox("edit-client-enabled", "Enabled", false) + +

+ + Rotation Policy +

+
+ @components.FormInput("edit-client-hourly", "Keep Hourly", "number", "", false) + @components.FormInput("edit-client-daily", "Keep Daily", "number", "", false) + @components.FormInput("edit-client-weekly", "Keep Weekly", "number", "", false) + @components.FormInput("edit-client-monthly", "Keep Monthly", "number", "", false) +
+ + +
+ } +} + +// AddAdminModal renders the add admin modal +templ AddAdminModal() { + @components.Modal("add-admin-modal", "Add New Admin") { +
+ @components.FormInput("new-admin-username", "Username", "text", "", true) + @components.FormInput("new-admin-password", "Password", "password", "", true) + @components.FormSelect("new-admin-role", "Role", []components.SelectOption{ + {Value: "admin", Label: "Admin", Selected: true}, + }) + +
+ } +} + +// ChangePasswordModal renders the change password modal for admins +templ ChangePasswordModal() { + @components.Modal("change-password-modal", "Change Password") { +
+ + @components.FormInput("change-password-username", "Admin Username", "text", "", true) + @components.FormInput("change-password-new", "New Password", "password", "", true) + @components.FormInput("change-password-confirm", "Confirm New Password", "password", "", true) + +
+ } +} + +// ClientPasswordModal renders the set client API key modal +templ ClientPasswordModal() { + @components.Modal("client-password-modal", "Set Client API Key") { +
+ + @components.FormInput("client-password-client-name", "Client ID", "text", "", true) + @components.FormInput("client-password-new", "New API Key", "text", "", true) + @components.FormInput("client-password-confirm", "Confirm API Key", "text", "", true) + +
+ } +} + +// AdminScripts renders the JavaScript for the admin panel +templ AdminScripts() { + +} diff --git a/internal/server/templates/pages/admin_templ.go b/internal/server/templates/pages/admin_templ.go new file mode 100644 index 0000000..eb1b583 --- /dev/null +++ b/internal/server/templates/pages/admin_templ.go @@ -0,0 +1,565 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package pages + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "git.ma-al.com/goc_marek/zfs/internal/server/templates/components" +) + +// AdminPage renders the main admin panel +func AdminPage(username string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.Header(username).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.StatsCard("Clients", "Loading...").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.StatsCard("Total Snapshots", "Loading...").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.StatsCard("Total Storage", "Loading...").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.TabButton("clients", "Clients", true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.TabButton("snapshots", "Snapshots", false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.TabButton("admins", "Admins", false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

Clients

Client IDStorage TypeQuotaUsedSnapshotsStatusActions

Snapshots

ClientSnapshot IDTimestampSizeTypeActions

Admin Users

IDUsernameRoleCreatedActions
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = AddClientModal().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = EditClientModal().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = AddAdminModal().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ChangePasswordModal().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ClientPasswordModal().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = AdminScripts().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = components.Layout("ZFS Backup Admin Panel", username).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// AddClientModal renders the add client modal +func AddClientModal() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var4 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("new-client-id", "Client ID", "text", "", true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("new-client-apikey", "API Key", "text", "", true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormSelect("new-client-storage", "Storage Type", []components.SelectOption{ + {Value: "s3", Label: "S3", Selected: true}, + {Value: "local", Label: "Local ZFS", Selected: false}, + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("new-client-dataset", "Target Dataset (for local storage)", "text", "backup/client1", false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("new-client-quota", "Quota (GB)", "number", "100", true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormCheckbox("new-client-enabled", "Enabled", true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

Rotation Policy

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("new-client-hourly", "Keep Hourly", "number", "24", false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("new-client-daily", "Keep Daily", "number", "7", false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("new-client-weekly", "Keep Weekly", "number", "4", false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("new-client-monthly", "Keep Monthly", "number", "12", false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = components.Modal("add-client-modal", "Add New Client").Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// EditClientModal renders the edit client modal +func EditClientModal() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var6 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("edit-client-apikey", "New API Key (leave empty to keep current)", "text", "Leave empty to keep current", false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormSelect("edit-client-storage", "Storage Type", []components.SelectOption{ + {Value: "s3", Label: "S3", Selected: true}, + {Value: "local", Label: "Local ZFS", Selected: false}, + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("edit-client-dataset", "Target Dataset", "text", "", false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("edit-client-quota", "Quota (GB)", "number", "", true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormCheckbox("edit-client-enabled", "Enabled", false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

Rotation Policy

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("edit-client-hourly", "Keep Hourly", "number", "", false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("edit-client-daily", "Keep Daily", "number", "", false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("edit-client-weekly", "Keep Weekly", "number", "", false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("edit-client-monthly", "Keep Monthly", "number", "", false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = components.Modal("edit-client-modal", "Edit Client").Render(templ.WithChildren(ctx, templ_7745c5c3_Var6), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// AddAdminModal renders the add admin modal +func AddAdminModal() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var7 := templ.GetChildren(ctx) + if templ_7745c5c3_Var7 == nil { + templ_7745c5c3_Var7 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var8 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("new-admin-username", "Username", "text", "", true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("new-admin-password", "Password", "password", "", true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormSelect("new-admin-role", "Role", []components.SelectOption{ + {Value: "admin", Label: "Admin", Selected: true}, + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = components.Modal("add-admin-modal", "Add New Admin").Render(templ.WithChildren(ctx, templ_7745c5c3_Var8), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// ChangePasswordModal renders the change password modal for admins +func ChangePasswordModal() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var10 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("change-password-username", "Admin Username", "text", "", true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("change-password-new", "New Password", "password", "", true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("change-password-confirm", "Confirm New Password", "password", "", true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = components.Modal("change-password-modal", "Change Password").Render(templ.WithChildren(ctx, templ_7745c5c3_Var10), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// ClientPasswordModal renders the set client API key modal +func ClientPasswordModal() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var11 := templ.GetChildren(ctx) + if templ_7745c5c3_Var11 == nil { + templ_7745c5c3_Var11 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var12 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("client-password-client-name", "Client ID", "text", "", true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("client-password-new", "New API Key", "text", "", true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("client-password-confirm", "Confirm API Key", "text", "", true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = components.Modal("client-password-modal", "Set Client API Key").Render(templ.WithChildren(ctx, templ_7745c5c3_Var12), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// AdminScripts renders the JavaScript for the admin panel +func AdminScripts() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var13 := templ.GetChildren(ctx) + if templ_7745c5c3_Var13 == nil { + templ_7745c5c3_Var13 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/server/templates/pages/login.templ b/internal/server/templates/pages/login.templ new file mode 100644 index 0000000..8baa6bf --- /dev/null +++ b/internal/server/templates/pages/login.templ @@ -0,0 +1,49 @@ +package pages + +import "git.ma-al.com/goc_marek/zfs/internal/server/templates/components" + +// LoginPage renders the login page +templ LoginPage() { + @components.Layout("Admin Login", "") { +
+
+ +
+
+ +
+

ZFS Backup Admin

+

Sign in to manage your backups

+
+ + +
+
+ @components.FormInput("username", "Username", "text", "", true) + @components.FormInput("password", "Password", "password", "", true) + +
+ +
+

+ + Default: admin / admin123 +

+
+
+ + +

+ Powered by ZFS Backup System +

+
+
+ + } +} diff --git a/internal/server/templates/pages/login_templ.go b/internal/server/templates/pages/login_templ.go new file mode 100644 index 0000000..49497b3 --- /dev/null +++ b/internal/server/templates/pages/login_templ.go @@ -0,0 +1,73 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package pages + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "git.ma-al.com/goc_marek/zfs/internal/server/templates/components" + +// LoginPage renders the login page +func LoginPage() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Admin Login

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("username", "Username", "text", "", true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormInput("password", "Password", "password", "", true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

Default: admin / admin123

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = components.Layout("Admin Login", "").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/server/templates/static/admin.js b/internal/server/templates/static/admin.js new file mode 100644 index 0000000..f27de81 --- /dev/null +++ b/internal/server/templates/static/admin.js @@ -0,0 +1,562 @@ +let currentTab = 'clients'; + +// Check authentication on load +async function checkAuth() { + try { + const res = await fetch('/admin/check'); + const data = await res.json(); + if (!data.authenticated) { + window.location.href = '/admin/'; + } + } catch (e) { + console.error('Auth check failed:', e); + } +} + +// Logout +async function logout() { + await fetch('/admin/logout', { method: 'POST' }); + location.reload(); +} + +// Load stats +async function loadStats() { + try { + const res = await fetch('/admin/stats'); + const data = await res.json(); + document.getElementById('stats-grid').innerHTML = + '

Clients

' + data.client_count + '
' + + '

Total Snapshots

' + data.total_snapshots + '
' + + '

Total Storage

' + data.total_storage_gb.toFixed(2) + ' GB
'; + } catch (e) { + console.error('Failed to load stats:', e); + } +} + +// Load clients +async function loadClients() { + try { + const res = await fetch('/admin/clients'); + const clients = await res.json(); + + const tbody = document.getElementById('clients-table'); + tbody.innerHTML = clients.map(c => { + const usedPercent = c.max_size_bytes > 0 ? (c.current_usage / c.max_size_bytes * 100).toFixed(1) : 0; + const usedGB = (c.current_usage / (1024*1024*1024)).toFixed(2); + const maxGB = (c.max_size_bytes / (1024*1024*1024)).toFixed(0); + return '' + + '' + c.client_id + '' + + '' + c.storage_type + '' + + '' + maxGB + ' GB' + + '' + + '
' + usedGB + ' GB (' + usedPercent + '%)
' + + '
' + + '' + + '' + c.snapshot_count + '' + + '' + (c.enabled ? 'Enabled' : 'Disabled') + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }).join(''); + + // Update snapshot filter + const filter = document.getElementById('snapshot-client-filter'); + filter.innerHTML = '' + + clients.map(c => '').join(''); + } catch (e) { + console.error('Failed to load clients:', e); + } +} + +// Load snapshots +async function loadSnapshots() { + const clientId = document.getElementById('snapshot-client-filter').value; + const url = '/admin/snapshots' + (clientId ? '?client_id=' + clientId : ''); + + try { + const res = await fetch(url); + const snapshots = await res.json(); + + const tbody = document.getElementById('snapshots-table'); + tbody.innerHTML = snapshots.map(s => { + const sizeGB = (s.size_bytes / (1024*1024*1024)).toFixed(2); + return '' + + '' + s.client_id + '' + + '' + s.snapshot_id + '' + + '' + new Date(s.timestamp).toLocaleString() + '' + + '' + sizeGB + ' GB' + + '' + + (s.incremental ? 'Incremental' : 'Full') + + (s.compressed ? ' LZ4' : '') + + '' + + '' + + ''; + }).join(''); + } catch (e) { + console.error('Failed to load snapshots:', e); + } +} + +// Load admins +async function loadAdmins() { + try { + const res = await fetch('/admin/admins'); + const admins = await res.json(); + + const tbody = document.getElementById('admins-table'); + tbody.innerHTML = admins.map(a => + '' + + '' + a.id + '' + + '' + a.username + '' + + '' + a.role + '' + + '' + new Date(a.created_at).toLocaleDateString() + '' + + '' + + '' + + '' + + '' + + '' + ).join(''); + } catch (e) { + console.error('Failed to load admins:', e); + } +} + +// Tab switching +function showTab(tab) { + currentTab = tab; + document.querySelectorAll('[data-tab]').forEach(t => { + t.classList.remove('bg-primary', 'text-white'); + t.classList.add('bg-white', 'text-gray-600'); + }); + document.querySelector('[data-tab="' + tab + '"]').classList.remove('bg-white', 'text-gray-600'); + document.querySelector('[data-tab="' + tab + '"]').classList.add('bg-primary', 'text-white'); + + document.getElementById('clients-tab').classList.add('hidden'); + document.getElementById('snapshots-tab').classList.add('hidden'); + document.getElementById('admins-tab').classList.add('hidden'); + document.getElementById(tab + '-tab').classList.remove('hidden'); + + if (tab === 'snapshots') loadSnapshots(); + if (tab === 'admins') loadAdmins(); +} + +// Modal functions +function showModal(id) { + const modal = document.getElementById(id); + modal.classList.remove('hidden'); + modal.classList.add('flex'); +} + +function closeModal(id) { + const modal = document.getElementById(id); + modal.classList.add('hidden'); + modal.classList.remove('flex'); +} + +// Edit client +async function editClient(clientId) { + try { + const res = await fetch('/admin/client?client_id=' + clientId); + const data = await res.json(); + const c = data.client; + + document.getElementById('edit-client-id').value = c.client_id; + document.getElementById('edit-client-apikey').value = ''; + document.getElementById('edit-client-storage').value = c.storage_type; + document.getElementById('edit-client-dataset').value = c.dataset; + document.getElementById('edit-client-quota').value = Math.round(c.max_size_bytes / (1024*1024*1024)); + document.getElementById('edit-client-enabled').checked = c.enabled; + + if (c.rotation_policy) { + document.getElementById('edit-client-hourly').value = c.rotation_policy.keep_hourly || 0; + document.getElementById('edit-client-daily').value = c.rotation_policy.keep_daily || 0; + document.getElementById('edit-client-weekly').value = c.rotation_policy.keep_weekly || 0; + document.getElementById('edit-client-monthly').value = c.rotation_policy.keep_monthly || 0; + } + + showModal('edit-client-modal'); + } catch (e) { + alert('Failed to load client data'); + } +} + +// Delete client +async function deleteClient(clientId) { + if (!confirm('Are you sure you want to delete client "' + clientId + '"? This will also delete all snapshots.')) { + return; + } + + try { + const res = await fetch('/admin/client/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: clientId }) + }); + const data = await res.json(); + + if (data.success) { + loadClients(); + loadStats(); + } else { + alert(data.message || 'Failed to delete client'); + } + } catch (e) { + alert('Failed to delete client'); + } +} + +// Delete snapshot +async function deleteSnapshot(clientId, snapshotId) { + if (!confirm('Are you sure you want to delete snapshot "' + snapshotId + '"?')) { + return; + } + + try { + const res = await fetch('/admin/snapshot/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: clientId, snapshot_id: snapshotId }) + }); + const data = await res.json(); + + if (data.success) { + loadSnapshots(); + loadStats(); + } else { + alert(data.message || 'Failed to delete snapshot'); + } + } catch (e) { + alert('Failed to delete snapshot'); + } +} + +// Delete admin +async function deleteAdmin(adminId) { + if (!confirm('Are you sure you want to delete this admin?')) { + return; + } + + try { + const res = await fetch('/admin/admin/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ admin_id: adminId }) + }); + const data = await res.json(); + + if (data.success) { + loadAdmins(); + } else { + alert(data.message || 'Failed to delete admin'); + } + } catch (e) { + alert('Failed to delete admin'); + } +} + +// Show change password modal +function showChangePassword(adminId, username) { + document.getElementById('change-password-admin-id').value = adminId; + document.getElementById('change-password-username').value = username; + document.getElementById('change-password-new').value = ''; + document.getElementById('change-password-confirm').value = ''; + showModal('change-password-modal'); +} + +// Show client change password modal +function showClientChangePassword(clientId) { + document.getElementById('client-password-client-id').value = clientId; + document.getElementById('client-password-client-name').value = clientId; + document.getElementById('client-password-new').value = ''; + document.getElementById('client-password-confirm').value = ''; + showModal('client-password-modal'); +} + +// Reset client password +async function resetClientPassword(clientId) { + if (!confirm('Are you sure you want to reset the API key for "' + clientId + '"? A new random key will be generated.')) { + return; + } + + try { + const res = await fetch('/admin/client/reset-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: clientId }) + }); + const data = await res.json(); + + if (data.success) { + alert('New API key for ' + clientId + ': ' + data.api_key + '\n\nPlease save this key as it will not be shown again.'); + } else { + alert(data.message || 'Failed to reset API key'); + } + } catch (e) { + alert('Failed to reset API key'); + } +} + +// Event delegation for click handlers +document.addEventListener('click', function(e) { + const target = e.target; + + // Handle modal content clicks - stop propagation + if (target.classList.contains('modal-content') || target.closest('.modal-content')) { + // Don't process if clicking on close button + if (!target.classList.contains('modal-close')) { + return; + } + } + + // Handle modal close buttons + if (target.classList.contains('modal-close')) { + const modalId = target.getAttribute('data-modal-id'); + if (modalId) { + closeModal(modalId); + } + return; + } + + // Handle modal overlay clicks (close on backdrop click) + if (target.classList.contains('fixed') && target.hasAttribute('data-modal-id')) { + closeModal(target.id); + return; + } + + // Handle data-action buttons + const action = target.getAttribute('data-action'); + if (!action) return; + + switch (action) { + case 'show-modal': + const modalId = target.getAttribute('data-modal'); + if (modalId) showModal(modalId); + break; + case 'edit-client': + const clientId = target.getAttribute('data-client-id'); + if (clientId) editClient(clientId); + break; + case 'delete-client': + const delClientId = target.getAttribute('data-client-id'); + if (delClientId) deleteClient(delClientId); + break; + case 'set-client-key': + const setKeyClientId = target.getAttribute('data-client-id'); + if (setKeyClientId) showClientChangePassword(setKeyClientId); + break; + case 'reset-client-key': + const resetKeyClientId = target.getAttribute('data-client-id'); + if (resetKeyClientId) resetClientPassword(resetKeyClientId); + break; + case 'delete-snapshot': + const snapClientId = target.getAttribute('data-client-id'); + const snapshotId = target.getAttribute('data-snapshot-id'); + if (snapClientId && snapshotId) deleteSnapshot(snapClientId, snapshotId); + break; + case 'change-admin-password': + const adminId = target.getAttribute('data-admin-id'); + const adminUsername = target.getAttribute('data-admin-username'); + if (adminId && adminUsername) showChangePassword(adminId, adminUsername); + break; + case 'delete-admin': + const delAdminId = target.getAttribute('data-admin-id'); + if (delAdminId) deleteAdmin(parseInt(delAdminId)); + break; + } +}); + +// Tab click handler +document.addEventListener('click', function(e) { + const tab = e.target.getAttribute('data-tab'); + if (tab) { + showTab(tab); + } +}); + +// Snapshot filter change handler +document.addEventListener('change', function(e) { + if (e.target.id === 'snapshot-client-filter') { + loadSnapshots(); + } +}); + +// Add client form +document.getElementById('add-client-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const client = { + client_id: document.getElementById('new-client-id').value, + api_key: document.getElementById('new-client-apikey').value, + storage_type: document.getElementById('new-client-storage').value, + dataset: document.getElementById('new-client-dataset').value, + max_size_bytes: parseInt(document.getElementById('new-client-quota').value) * 1024 * 1024 * 1024, + enabled: document.getElementById('new-client-enabled').checked, + rotation_policy: { + keep_hourly: parseInt(document.getElementById('new-client-hourly').value) || 0, + keep_daily: parseInt(document.getElementById('new-client-daily').value) || 0, + keep_weekly: parseInt(document.getElementById('new-client-weekly').value) || 0, + keep_monthly: parseInt(document.getElementById('new-client-monthly').value) || 0 + } + }; + + try { + const res = await fetch('/admin/client/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(client) + }); + const data = await res.json(); + + if (data.success) { + closeModal('add-client-modal'); + document.getElementById('add-client-form').reset(); + loadClients(); + loadStats(); + } else { + alert(data.message || 'Failed to create client'); + } + } catch (e) { + alert('Failed to create client'); + } +}); + +// Edit client form +document.getElementById('edit-client-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const client = { + client_id: document.getElementById('edit-client-id').value, + api_key: document.getElementById('edit-client-apikey').value, + storage_type: document.getElementById('edit-client-storage').value, + dataset: document.getElementById('edit-client-dataset').value, + max_size_bytes: parseInt(document.getElementById('edit-client-quota').value) * 1024 * 1024 * 1024, + enabled: document.getElementById('edit-client-enabled').checked, + rotation_policy: { + keep_hourly: parseInt(document.getElementById('edit-client-hourly').value) || 0, + keep_daily: parseInt(document.getElementById('edit-client-daily').value) || 0, + keep_weekly: parseInt(document.getElementById('edit-client-weekly').value) || 0, + keep_monthly: parseInt(document.getElementById('edit-client-monthly').value) || 0 + } + }; + + try { + const res = await fetch('/admin/client/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(client) + }); + const data = await res.json(); + + if (data.success) { + closeModal('edit-client-modal'); + loadClients(); + loadStats(); + } else { + alert(data.message || 'Failed to update client'); + } + } catch (e) { + alert('Failed to update client'); + } +}); + +// Add admin form +document.getElementById('add-admin-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const admin = { + username: document.getElementById('new-admin-username').value, + password: document.getElementById('new-admin-password').value, + role: document.getElementById('new-admin-role').value + }; + + try { + const res = await fetch('/admin/admin/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(admin) + }); + const data = await res.json(); + + if (data.success) { + closeModal('add-admin-modal'); + document.getElementById('add-admin-form').reset(); + loadAdmins(); + } else { + alert(data.message || 'Failed to create admin'); + } + } catch (e) { + alert('Failed to create admin'); + } +}); + +// Change password form +document.getElementById('change-password-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const adminId = document.getElementById('change-password-admin-id').value; + const newPassword = document.getElementById('change-password-new').value; + const confirmPassword = document.getElementById('change-password-confirm').value; + + if (newPassword !== confirmPassword) { + alert('Passwords do not match'); + return; + } + + try { + const res = await fetch('/admin/admin/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ admin_id: parseInt(adminId), new_password: newPassword }) + }); + const data = await res.json(); + + if (data.success) { + closeModal('change-password-modal'); + alert('Password changed successfully'); + } else { + alert(data.message || 'Failed to change password'); + } + } catch (e) { + alert('Failed to change password'); + } +}); + +// Client password form +document.getElementById('client-password-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const clientId = document.getElementById('client-password-client-id').value; + const newKey = document.getElementById('client-password-new').value; + const confirmKey = document.getElementById('client-password-confirm').value; + + if (newKey !== confirmKey) { + alert('API keys do not match'); + return; + } + + try { + const res = await fetch('/admin/client/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: clientId, api_key: newKey }) + }); + const data = await res.json(); + + if (data.success) { + closeModal('client-password-modal'); + alert('API key changed successfully for ' + clientId); + } else { + alert(data.message || 'Failed to change API key'); + } + } catch (e) { + alert('Failed to change API key'); + } +}); + +// Initialize +loadStats(); +loadClients(); diff --git a/internal/server/templates/static/login.js b/internal/server/templates/static/login.js new file mode 100644 index 0000000..5ada252 --- /dev/null +++ b/internal/server/templates/static/login.js @@ -0,0 +1,23 @@ +// Login form handler +document.getElementById('login-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + + try { + const res = await fetch('/admin/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + const data = await res.json(); + + if (data.success) { + location.reload(); + } else { + alert(data.message || 'Login failed'); + } + } catch (e) { + alert('Connection error'); + } +}); \ No newline at end of file