Added backlite UI to the admin panel.

This commit is contained in:
mikestefanello 2025-05-02 10:18:15 -04:00
parent 52f87580a0
commit 3cfcb43031
8 changed files with 99 additions and 13 deletions

View file

@ -88,6 +88,7 @@
* [Tasks](#tasks) * [Tasks](#tasks)
* [Queues](#queues) * [Queues](#queues)
* [Dispatcher](#dispatcher) * [Dispatcher](#dispatcher)
* [Monitoring tasks and queues](#monitoring-tasks-and-queues)
* [Cron](#cron) * [Cron](#cron)
* [Files](#files) * [Files](#files)
* [Static files](#static-files) * [Static files](#static-files)
@ -154,6 +155,10 @@ Originally, Postgres and Redis were chosen as defaults but since the aim of this
<img src="https://raw.githubusercontent.com/mikestefanello/readmeimages/main/pagoda/admin-user_edit.png" alt="User entity edit"/> <img src="https://raw.githubusercontent.com/mikestefanello/readmeimages/main/pagoda/admin-user_edit.png" alt="User entity edit"/>
#### Monitor task queues (provided by Backlite via the admin panel)
<img src="https://raw.githubusercontent.com/mikestefanello/readmeimages/main/backlite/failed.png" alt="Manage task queues"/>
## Getting started ## Getting started
### Dependencies ### Dependencies
@ -401,9 +406,13 @@ To generate a new verification token, the `AuthClient` has a method `GenerateEma
The admin panel functionality is considered to be in _beta_ and remains under active development, though all features described here are expected to be fully-functional. Please use caution when using these features and be sure to report any issues you encounter. The admin panel functionality is considered to be in _beta_ and remains under active development, though all features described here are expected to be fully-functional. Please use caution when using these features and be sure to report any issues you encounter.
What is currently included in the _admin panel_ is a completely dynamic UI to manage all entities defined by _Ent_ (see [screenshots](#screenshots)). There are no separate templates or interfaces for the admin section. The _admin panel_ currently includes:
* A completely dynamic UI to manage all entities defined by _Ent_.
* A section to monitor all [background tasks and queues](#tasks).
Users with admin [access](#access) will see additional links on the default sidebar for each defined entity type. As with all default UI components, you can easily move these pages and links to a dedicated section, layout, etc. Clicking on the link for any given entity type will provide a pageable table of entities and the ability to add/edit/delete. There are no separate templates or interfaces for the admin section (see [screenshots](#screenshots)).
Users with admin [access](#access) will see additional links on the default sidebar at the bottom. As with all default UI components, you can easily move these pages and links to a dedicated section, layout, etc. Clicking on the link for any given entity type will provide a pageable table of entities and the ability to add/edit/delete.
### Code generation ### Code generation
@ -431,7 +440,6 @@ Since the generated code is completely dynamic, all entity functionality related
* Exposed filters. * Exposed filters.
* Support all field types (types such as _JSON_ as currently not supported). * Support all field types (types such as _JSON_ as currently not supported).
* Control which fields appear in the entity list table. * Control which fields appear in the entity list table.
* More features than just entity management (ie, including the [Backlite](https://github.com/mikestefanello/backlite#screenshots) UI).
## Routes ## Routes
@ -989,6 +997,10 @@ The app [configuration](#configuration) contains values to configure the client
When the app is shutdown, the dispatcher is given 10 seconds to wait for any in-progress tasks to finish execution. This can be changed in `cmd/web/main.go`. When the app is shutdown, the dispatcher is given 10 seconds to wait for any in-progress tasks to finish execution. This can be changed in `cmd/web/main.go`.
### Monitoring tasks and queues
The [admin panel](#admin-panel) contains the UI provided by [Backlite](https://github.com/mikestefanello/backlite) in order to fully monitor all tasks and queues from within your browser.
## Cron ## Cron
By default, no cron solution is provided because it's very easy to add yourself if you need this. You can either use a [ticker](https://pkg.go.dev/time#Ticker) or a [library](https://github.com/robfig/cron). By default, no cron solution is provided because it's very easy to add yourself if you need this. You can either use a [ticker](https://pkg.go.dev/time#Ticker) or a [library](https://github.com/robfig/cron).

2
go.mod
View file

@ -12,7 +12,7 @@ require (
github.com/labstack/echo/v4 v4.13.3 github.com/labstack/echo/v4 v4.13.3
github.com/mattn/go-sqlite3 v1.14.28 github.com/mattn/go-sqlite3 v1.14.28
github.com/maypok86/otter v1.2.4 github.com/maypok86/otter v1.2.4
github.com/mikestefanello/backlite v0.4.0 github.com/mikestefanello/backlite v0.5.0
github.com/spf13/afero v1.14.0 github.com/spf13/afero v1.14.0
github.com/spf13/viper v1.20.1 github.com/spf13/viper v1.20.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0

4
go.sum
View file

@ -78,8 +78,8 @@ github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEu
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc= github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc=
github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4= github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
github.com/mikestefanello/backlite v0.4.0 h1:+OTiErTfwydXzeDS8H8iAdbmrQYJ7XuwxFApFORVy+k= github.com/mikestefanello/backlite v0.5.0 h1:6lKZdEYgutdmwV4Id8/t9E/NY14U3fS4/RhiOkFDD4c=
github.com/mikestefanello/backlite v0.4.0/go.mod h1:yvGKIFQxscmVYW8dtvlmT3UzfmM0NX2OigwNGsbR46o= github.com/mikestefanello/backlite v0.5.0/go.mod h1:gx6UKLUQY5OVXQkIm3AzNkyPn9OzoKHKuwM4JGrY4tQ=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=

View file

@ -10,6 +10,7 @@ import (
"entgo.io/ent/entc/gen" "entgo.io/ent/entc/gen"
"entgo.io/ent/entc/load" "entgo.io/ent/entc/load"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mikestefanello/backlite/ui"
"github.com/mikestefanello/pagoda/ent" "github.com/mikestefanello/pagoda/ent"
"github.com/mikestefanello/pagoda/ent/admin" "github.com/mikestefanello/pagoda/ent/admin"
"github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/context"
@ -23,9 +24,10 @@ import (
) )
type Admin struct { type Admin struct {
orm *ent.Client orm *ent.Client
graph *gen.Graph graph *gen.Graph
admin *admin.Handler admin *admin.Handler
backlite *ui.Handler
} }
func init() { func init() {
@ -33,6 +35,7 @@ func init() {
} }
func (h *Admin) Init(c *services.Container) error { func (h *Admin) Init(c *services.Container) error {
var err error
h.graph = c.Graph h.graph = c.Graph
h.orm = c.ORM h.orm = c.ORM
h.admin = admin.NewHandler(h.orm, admin.HandlerConfig{ h.admin = admin.NewHandler(h.orm, admin.HandlerConfig{
@ -40,12 +43,19 @@ func (h *Admin) Init(c *services.Container) error {
PageQueryKey: pager.QueryKey, PageQueryKey: pager.QueryKey,
TimeFormat: time.DateTime, TimeFormat: time.DateTime,
}) })
return nil h.backlite, err = ui.NewHandler(ui.Config{
DB: c.Database,
BasePath: "/admin/tasks",
ItemsPerPage: 25,
ReleaseAfter: c.Config.Tasks.ReleaseAfter,
})
return err
} }
func (h *Admin) Routes(g *echo.Group) { func (h *Admin) Routes(g *echo.Group) {
entities := g.Group("/admin/entity", middleware.RequireAdmin) ag := g.Group("/admin", middleware.RequireAdmin)
entities := ag.Group("/entity")
for _, n := range h.graph.Nodes { for _, n := range h.graph.Nodes {
ng := entities.Group(fmt.Sprintf("/%s", strings.ToLower(n.Name))) ng := entities.Group(fmt.Sprintf("/%s", strings.ToLower(n.Name)))
ng.GET("", h.EntityList(n)). ng.GET("", h.EntityList(n)).
@ -63,6 +73,14 @@ func (h *Admin) Routes(g *echo.Group) {
ng.POST("/:id/delete", h.EntityDeleteSubmit(n), h.middlewareEntityLoad(n)). ng.POST("/:id/delete", h.EntityDeleteSubmit(n), h.middlewareEntityLoad(n)).
Name = routenames.AdminEntityDeleteSubmit(n.Name) Name = routenames.AdminEntityDeleteSubmit(n.Name)
} }
tasks := ag.Group("/tasks")
tasks.GET("", h.Backlite(h.backlite.Running)).Name = routenames.AdminTasks
tasks.GET("/succeeded", h.Backlite(h.backlite.Succeeded))
tasks.GET("/failed", h.Backlite(h.backlite.Failed))
tasks.GET("/upcoming", h.Backlite(h.backlite.Upcoming))
tasks.GET("/task/:id", h.Backlite(h.backlite.Task))
tasks.GET("/completed/:id", h.Backlite(h.backlite.TaskCompleted))
} }
// middlewareEntityLoad is middleware to extract the entity ID and attempt to load the given entity. // middlewareEntityLoad is middleware to extract the entity ID and attempt to load the given entity.
@ -182,3 +200,12 @@ func (h *Admin) getEntitySchema(n *gen.Type) *load.Schema {
} }
return nil return nil
} }
func (h *Admin) Backlite(handler func(http.ResponseWriter, *http.Request) error) echo.HandlerFunc {
return func(c echo.Context) error {
if id := c.Param("id"); id != "" {
c.Request().SetPathValue("task", id)
}
return handler(c.Response().Writer, c.Request())
}
}

View file

@ -26,6 +26,7 @@ const (
CacheSubmit = "cache.submit" CacheSubmit = "cache.submit"
Files = "files" Files = "files"
FilesSubmit = "files.submit" FilesSubmit = "files.submit"
AdminTasks = "admin:tasks"
) )
func AdminEntityList(entityTypeName string) string { func AdminEntityList(entityTypeName string) string {

View file

@ -149,6 +149,20 @@ func sidebarMenu(r *ui.Request) Node {
Class("menu-list"), Class("menu-list"),
entityTypeLinks, entityTypeLinks,
), ),
P(
Class("menu-label"),
Text("Monitoring"),
),
Ul(
Class("menu-list"),
Li(
A(
Href(r.Path(routenames.AdminTasks)),
Text("Tasks"),
Target("_blank"),
),
),
),
} }
} }

View file

@ -10,6 +10,7 @@ import (
"github.com/mikestefanello/pagoda/pkg/ui/layouts" "github.com/mikestefanello/pagoda/pkg/ui/layouts"
"github.com/mikestefanello/pagoda/pkg/ui/models" "github.com/mikestefanello/pagoda/pkg/ui/models"
. "maragu.dev/gomponents" . "maragu.dev/gomponents"
. "maragu.dev/gomponents/components"
. "maragu.dev/gomponents/html" . "maragu.dev/gomponents/html"
) )
@ -38,7 +39,7 @@ func Home(ctx echo.Context, posts *models.Posts) error {
headerMsg := func() Node { headerMsg := func() Node {
return Group{ return Group{
Section( Section(
Class("hero is-info welcome is-small mb-5"), Class("hero is-info welcome is-small mb-3"),
Div( Div(
Class("hero-body"), Class("hero-body"),
Div( Div(
@ -58,6 +59,28 @@ func Home(ctx echo.Context, posts *models.Posts) error {
), ),
), ),
), ),
Section(
Class("hero is-light is-small mb-5"),
Div(
Class("hero-body"),
Div(
Class("container"),
B(Text("Admin status: ")),
Span(
Classes{
"tag": true,
"is-success": r.IsAdmin,
"is-danger": !r.IsAdmin,
},
Text(fmt.Sprint(r.IsAdmin)),
),
If(!r.IsAdmin, Span(
Class("is-size-7 ml-3"),
Raw(`(<a href="https://github.com/mikestefanello/pagoda#create-an-admin-account">click here</a> for instructions to make an admin account)`),
)),
),
),
),
H2(Class("title"), Text("Recent posts")), H2(Class("title"), Text("Recent posts")),
H3(Class("subtitle"), Text("Below is an example of both paging and AJAX fetching using HTMX")), H3(Class("subtitle"), Text("Below is an example of both paging and AJAX fetching using HTMX")),
} }

View file

@ -22,10 +22,19 @@ func AddTask(ctx echo.Context, form *forms.Task) error {
"", "",
Group{ Group{
P(Raw("Submitting this form will create an <i>ExampleTask</i> in the task queue. After the specified delay, the message will be logged by the queue processor.")), P(Raw("Submitting this form will create an <i>ExampleTask</i> in the task queue. After the specified delay, the message will be logged by the queue processor.")),
P(Text("See pkg/tasks and the README for more information.")), P(Raw("See <i>pkg/tasks</i> and the README for more information.")),
}) })
}), }),
form.Render(r), form.Render(r),
Iff(r.Htmx.Target != "task", func() Node {
return components.Message(
"is-warning",
"",
Group{
If(!r.IsAdmin, P(Text("Log in as an admin in order to access the task and queue monitoring UI."))),
If(r.IsAdmin, P(Text("View all queued tasks by clicking on the Tasks link in the sidebar."))),
})
}),
} }
return r.Render(layouts.Primary, g) return r.Render(layouts.Primary, g)