initial
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
// Package handlers provides the template renderer and all HTTP handlers.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"crowdsec-dashy/internal/crowdsec"
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Renderer
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Renderer holds pre-parsed templates, one per page.
|
||||
type Renderer struct {
|
||||
templates map[string]*template.Template
|
||||
funcMap template.FuncMap
|
||||
}
|
||||
|
||||
// NewRenderer parses all page templates against the base layout.
|
||||
func NewRenderer(templateDir string) (*Renderer, error) {
|
||||
r := &Renderer{
|
||||
templates: make(map[string]*template.Template),
|
||||
funcMap: buildFuncMap(),
|
||||
}
|
||||
|
||||
basePath := filepath.Join(templateDir, "layouts", "base.html")
|
||||
|
||||
pages, err := filepath.Glob(filepath.Join(templateDir, "pages", "*.html"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("renderer: glob pages: %w", err)
|
||||
}
|
||||
if len(pages) == 0 {
|
||||
return nil, fmt.Errorf("renderer: no page templates found in %s/pages/", templateDir)
|
||||
}
|
||||
|
||||
for _, page := range pages {
|
||||
name := templateName(page)
|
||||
tmpl, err := template.New("base").
|
||||
Funcs(r.funcMap).
|
||||
ParseFiles(basePath, page)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("renderer: parse %s: %w", name, err)
|
||||
}
|
||||
r.templates[name] = tmpl
|
||||
log.Printf("renderer: registered template %q", name)
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Render executes the named template into a buffer then writes to w.
|
||||
func (r *Renderer) Render(w http.ResponseWriter, name string, data any) {
|
||||
tmpl, ok := r.templates[name]
|
||||
if !ok {
|
||||
http.Error(w, fmt.Sprintf("template %q not found", name), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil {
|
||||
log.Printf("renderer: execute %q: %v", name, err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = buf.WriteTo(w)
|
||||
}
|
||||
|
||||
// RenderError renders the error page.
|
||||
func (r *Renderer) RenderError(w http.ResponseWriter, code int, msg string) {
|
||||
w.WriteHeader(code)
|
||||
r.Render(w, "error", ErrorData{
|
||||
PageData: PageData{CurrentPath: "/error", Title: fmt.Sprintf("Error %d", code)},
|
||||
Code: code,
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
|
||||
func templateName(path string) string {
|
||||
base := filepath.Base(path)
|
||||
return base[:len(base)-len(filepath.Ext(base))]
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// FuncMap
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
func buildFuncMap() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"inc": func(i int) int { return i + 1 },
|
||||
"dec": func(i int) int { return i - 1 },
|
||||
// dict builds a map for passing multiple values to a sub-template.
|
||||
// Usage: {{template "foo" dict "Key1" val1 "Key2" val2}}
|
||||
"dict": func(pairs ...any) (map[string]any, error) {
|
||||
if len(pairs)%2 != 0 {
|
||||
return nil, fmt.Errorf("dict: odd number of arguments")
|
||||
}
|
||||
m := make(map[string]any, len(pairs)/2)
|
||||
for i := 0; i < len(pairs); i += 2 {
|
||||
k, ok := pairs[i].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("dict: key %v is not a string", pairs[i])
|
||||
}
|
||||
m[k] = pairs[i+1]
|
||||
}
|
||||
return m, nil
|
||||
},
|
||||
// decisionTypeBadge returns a CSS class suffix for a decision type.
|
||||
"decisionBadgeClass": func(t string) string {
|
||||
switch strings.ToLower(t) {
|
||||
case "ban":
|
||||
return "badge-red"
|
||||
case "captcha":
|
||||
return "badge-amber"
|
||||
case "throttle":
|
||||
return "badge-blue"
|
||||
default:
|
||||
return "badge-gray"
|
||||
}
|
||||
},
|
||||
// originBadgeClass returns a CSS class for a decision origin.
|
||||
"originBadgeClass": func(o string) string {
|
||||
switch strings.ToLower(o) {
|
||||
case "crowdsec":
|
||||
return "badge-cyan"
|
||||
case "cscli":
|
||||
return "badge-purple"
|
||||
case "lists":
|
||||
return "badge-green"
|
||||
default:
|
||||
return "badge-gray"
|
||||
}
|
||||
},
|
||||
// hubStatusClass returns a CSS class for a hub item status.
|
||||
"hubStatusClass": func(s string) string {
|
||||
switch strings.ToLower(s) {
|
||||
case "enabled":
|
||||
return "badge-green"
|
||||
case "disabled":
|
||||
return "badge-gray"
|
||||
case "update-available":
|
||||
return "badge-amber"
|
||||
default:
|
||||
return "badge-gray"
|
||||
}
|
||||
},
|
||||
// safeHTML allows raw HTML in templates (use carefully).
|
||||
"safeHTML": func(s string) template.HTML {
|
||||
return template.HTML(s) //nolint:gosec
|
||||
},
|
||||
// boolIcon returns a UTF-8 icon for boolean display.
|
||||
"boolIcon": func(b bool) string {
|
||||
if b {
|
||||
return "✓"
|
||||
}
|
||||
return "✗"
|
||||
},
|
||||
// truncate shortens a string.
|
||||
"truncate": func(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
},
|
||||
// join joins a string slice.
|
||||
"join": strings.Join,
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Shared page data
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// NavItem describes one sidebar navigation entry.
|
||||
type NavItem struct {
|
||||
Path string
|
||||
Label string
|
||||
Icon string
|
||||
Divider bool // show divider before this item
|
||||
}
|
||||
|
||||
// SidebarNav returns the full navigation definition.
|
||||
var SidebarNav = []NavItem{
|
||||
{Path: "/", Label: "Dashboard", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>`},
|
||||
{Path: "/decisions", Label: "Decisions", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`, Divider: false},
|
||||
{Path: "/alerts", Label: "Alerts", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`},
|
||||
{Path: "/bouncers", Label: "Bouncers", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>`, Divider: true},
|
||||
{Path: "/machines", Label: "Machines", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>`},
|
||||
{Path: "/hub", Label: "Hub", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>`, Divider: true},
|
||||
{Path: "/metrics-ui", Label: "Metrics", Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>`},
|
||||
}
|
||||
|
||||
// PageData contains fields available to every template.
|
||||
type PageData struct {
|
||||
CurrentPath string
|
||||
Title string
|
||||
Nav []NavItem
|
||||
Flash FlashMessage
|
||||
CLIAvailable bool
|
||||
PollInterval int
|
||||
}
|
||||
|
||||
// FlashMessage is a one-shot notification shown on the next page load.
|
||||
type FlashMessage struct {
|
||||
Type string // success, error, warning, info
|
||||
Message string
|
||||
}
|
||||
|
||||
// ErrorData is passed to the error page template.
|
||||
type ErrorData struct {
|
||||
PageData
|
||||
Code int
|
||||
Message string
|
||||
}
|
||||
|
||||
// NewPageData creates PageData from the current request.
|
||||
func NewPageData(r *http.Request, title string, cliAvail bool, pollSec int) PageData {
|
||||
return PageData{
|
||||
CurrentPath: r.URL.Path,
|
||||
Title: title,
|
||||
Nav: SidebarNav,
|
||||
CLIAvailable: cliAvail,
|
||||
PollInterval: pollSec,
|
||||
}
|
||||
}
|
||||
|
||||
// WithFlash attaches a flash message to PageData.
|
||||
func (pd PageData) WithFlash(flashType, msg string) PageData {
|
||||
pd.Flash = FlashMessage{Type: flashType, Message: msg}
|
||||
return pd
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Shared handler dependencies
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Deps holds shared dependencies injected into every handler.
|
||||
type Deps struct {
|
||||
Renderer *Renderer
|
||||
LAPI *crowdsec.LAPIClient
|
||||
CLI *crowdsec.CLIClient
|
||||
CLIAvailable bool
|
||||
PollInterval int
|
||||
}
|
||||
Reference in New Issue
Block a user