Update frame work and workspace codebase
This commit is contained in:
258
internal/http/admin_handlers.go
Normal file
258
internal/http/admin_handlers.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/example/storage-appliance/internal/auth"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// UsersHandler shows the users management page
|
||||
func (a *App) UsersHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := templateData(r, map[string]interface{}{
|
||||
"Title": "User Management",
|
||||
})
|
||||
if err := templates.ExecuteTemplate(w, "users", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HXUsersHandler returns HTMX partial for users list
|
||||
func (a *App) HXUsersHandler(w http.ResponseWriter, r *http.Request) {
|
||||
rbacStore := auth.NewRBACStore(a.DB)
|
||||
|
||||
// Get all users (simplified - in production, you'd want pagination)
|
||||
rows, err := a.DB.QueryContext(r.Context(), `SELECT id, username, created_at FROM users ORDER BY username`)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type UserWithRoles struct {
|
||||
ID string
|
||||
Username string
|
||||
CreatedAt string
|
||||
Roles []auth.Role
|
||||
}
|
||||
|
||||
var users []UserWithRoles
|
||||
for rows.Next() {
|
||||
var u UserWithRoles
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
roles, _ := rbacStore.GetUserRoles(r.Context(), u.ID)
|
||||
u.Roles = roles
|
||||
users = append(users, u)
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Users": users,
|
||||
}
|
||||
if err := templates.ExecuteTemplate(w, "hx_users", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateUserHandler creates a new user
|
||||
func (a *App) CreateUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
|
||||
if username == "" || password == "" {
|
||||
http.Error(w, "username and password required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
userStore := auth.NewUserStore(a.DB)
|
||||
_, err := userStore.CreateUser(r.Context(), username, password)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Return HTMX partial or redirect
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
http.Redirect(w, r, "/admin/users", http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteUserHandler deletes a user
|
||||
func (a *App) DeleteUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "id")
|
||||
|
||||
_, err := a.DB.ExecContext(r.Context(), `DELETE FROM users WHERE id = ?`, userID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
http.Redirect(w, r, "/admin/users", http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateUserRolesHandler updates roles for a user
|
||||
func (a *App) UpdateUserRolesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
RoleIDs []string `json:"role_ids"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
rbacStore := auth.NewRBACStore(a.DB)
|
||||
|
||||
// Get current roles
|
||||
currentRoles, _ := rbacStore.GetUserRoles(r.Context(), userID)
|
||||
|
||||
// Remove all current roles
|
||||
for _, role := range currentRoles {
|
||||
rbacStore.RemoveRoleFromUser(r.Context(), userID, role.ID)
|
||||
}
|
||||
|
||||
// Add new roles
|
||||
for _, roleID := range req.RoleIDs {
|
||||
rbacStore.AssignRoleToUser(r.Context(), userID, roleID)
|
||||
}
|
||||
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
http.Redirect(w, r, "/admin/users", http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
// RolesHandler shows the roles management page
|
||||
func (a *App) RolesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := templateData(r, map[string]interface{}{
|
||||
"Title": "Role Management",
|
||||
})
|
||||
if err := templates.ExecuteTemplate(w, "roles", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HXRolesHandler returns HTMX partial for roles list
|
||||
func (a *App) HXRolesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
rbacStore := auth.NewRBACStore(a.DB)
|
||||
|
||||
roles, err := rbacStore.GetAllRoles(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type RoleWithPermissions struct {
|
||||
auth.Role
|
||||
Permissions []auth.Permission
|
||||
}
|
||||
|
||||
var rolesWithPerms []RoleWithPermissions
|
||||
for _, role := range roles {
|
||||
rwp := RoleWithPermissions{Role: role}
|
||||
perms, _ := rbacStore.GetRolePermissions(r.Context(), role.ID)
|
||||
rwp.Permissions = perms
|
||||
rolesWithPerms = append(rolesWithPerms, rwp)
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Roles": rolesWithPerms,
|
||||
}
|
||||
if err := templates.ExecuteTemplate(w, "hx_roles", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRoleHandler creates a new role
|
||||
func (a *App) CreateRoleHandler(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.FormValue("name")
|
||||
description := r.FormValue("description")
|
||||
|
||||
if name == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
roleID := name // Using name as ID for simplicity
|
||||
_, err := a.DB.ExecContext(r.Context(),
|
||||
`INSERT INTO roles (id, name, description) VALUES (?, ?, ?)`,
|
||||
roleID, name, description)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
http.Redirect(w, r, "/admin/roles", http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteRoleHandler deletes a role
|
||||
func (a *App) DeleteRoleHandler(w http.ResponseWriter, r *http.Request) {
|
||||
roleID := chi.URLParam(r, "id")
|
||||
|
||||
_, err := a.DB.ExecContext(r.Context(), `DELETE FROM roles WHERE id = ?`, roleID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
http.Redirect(w, r, "/admin/roles", http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateRolePermissionsHandler updates permissions for a role
|
||||
func (a *App) UpdateRolePermissionsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
roleID := chi.URLParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
PermissionIDs []string `json:"permission_ids"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
rbacStore := auth.NewRBACStore(a.DB)
|
||||
|
||||
// Get current permissions
|
||||
currentPerms, _ := rbacStore.GetRolePermissions(r.Context(), roleID)
|
||||
|
||||
// Remove all current permissions
|
||||
for _, perm := range currentPerms {
|
||||
rbacStore.RemovePermissionFromRole(r.Context(), roleID, perm.ID)
|
||||
}
|
||||
|
||||
// Add new permissions
|
||||
for _, permID := range req.PermissionIDs {
|
||||
rbacStore.AssignPermissionToRole(r.Context(), roleID, permID)
|
||||
}
|
||||
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
http.Redirect(w, r, "/admin/roles", http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
125
internal/http/auth_handlers.go
Normal file
125
internal/http/auth_handlers.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/example/storage-appliance/internal/auth"
|
||||
)
|
||||
|
||||
// LoginHandler handles user login
|
||||
func (a *App) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" {
|
||||
// Show login page
|
||||
data := map[string]interface{}{
|
||||
"Title": "Login",
|
||||
}
|
||||
if err := templates.ExecuteTemplate(w, "login", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle POST login
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
if r.Header.Get("Content-Type") == "application/json" {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
req.Username = r.FormValue("username")
|
||||
req.Password = r.FormValue("password")
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
userStore := auth.NewUserStore(a.DB)
|
||||
user, err := userStore.Authenticate(r.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`<div class="text-red-600">Invalid username or password</div>`))
|
||||
} else {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Create session
|
||||
sessionStore := auth.NewSessionStore(a.DB)
|
||||
session, err := sessionStore.CreateSession(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to create session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set session cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.SessionCookieName,
|
||||
Value: session.Token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: false, // Set to true in production with HTTPS
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(auth.SessionDuration.Seconds()),
|
||||
})
|
||||
|
||||
// Set CSRF token cookie
|
||||
csrfToken := generateCSRFToken()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "csrf_token",
|
||||
Value: csrfToken,
|
||||
Path: "/",
|
||||
HttpOnly: false, // Needed for HTMX to read it
|
||||
Secure: false,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(auth.SessionDuration.Seconds()),
|
||||
})
|
||||
|
||||
// Redirect or return success
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Redirect", "/dashboard")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
// LogoutHandler handles user logout
|
||||
func (a *App) LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Get session token from cookie
|
||||
cookie, err := r.Cookie(auth.SessionCookieName)
|
||||
if err == nil {
|
||||
// Delete session
|
||||
sessionStore := auth.NewSessionStore(a.DB)
|
||||
sessionStore.DeleteSession(r.Context(), cookie.Value)
|
||||
}
|
||||
|
||||
// Clear cookies
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.SessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
})
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "csrf_token",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: false,
|
||||
MaxAge: -1,
|
||||
})
|
||||
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Redirect", "/login")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
}
|
||||
121
internal/http/monitoring_handlers.go
Normal file
121
internal/http/monitoring_handlers.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/example/storage-appliance/internal/monitoring"
|
||||
)
|
||||
|
||||
// MetricsHandler serves Prometheus metrics
|
||||
func (a *App) MetricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Create collectors
|
||||
collectors := []monitoring.Collector{
|
||||
monitoring.NewZFSCollector(a.ZFSSvc, a.Runner),
|
||||
monitoring.NewSMARTCollector(a.Runner),
|
||||
monitoring.NewServiceCollector(a.Runner),
|
||||
monitoring.NewHostCollector(),
|
||||
}
|
||||
|
||||
// Export metrics
|
||||
exporter := monitoring.NewPrometheusExporter(collectors...)
|
||||
metrics := exporter.Export(ctx)
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(metrics))
|
||||
}
|
||||
|
||||
// MonitoringHandler shows the monitoring dashboard
|
||||
func (a *App) MonitoringHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := templateData(r, map[string]interface{}{
|
||||
"Title": "Monitoring",
|
||||
})
|
||||
if err := templates.ExecuteTemplate(w, "monitoring", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HXMonitoringHandler returns HTMX partial for monitoring metrics
|
||||
func (a *App) HXMonitoringHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Create collectors
|
||||
collectors := []monitoring.Collector{
|
||||
monitoring.NewZFSCollector(a.ZFSSvc, a.Runner),
|
||||
monitoring.NewSMARTCollector(a.Runner),
|
||||
monitoring.NewServiceCollector(a.Runner),
|
||||
monitoring.NewHostCollector(),
|
||||
}
|
||||
|
||||
// Export for UI
|
||||
exporter := monitoring.NewUIExporter(collectors...)
|
||||
groups := exporter.Export(ctx)
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Groups": groups,
|
||||
}
|
||||
|
||||
if err := templates.ExecuteTemplate(w, "hx_monitoring", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HXMonitoringGroupHandler returns HTMX partial for a specific metric group
|
||||
func (a *App) HXMonitoringGroupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
groupName := r.URL.Query().Get("group")
|
||||
if groupName == "" {
|
||||
http.Error(w, "group parameter required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Create the specific collector (normalize group name)
|
||||
var collector monitoring.Collector
|
||||
groupLower := strings.ToLower(groupName)
|
||||
switch groupLower {
|
||||
case "zfs":
|
||||
collector = monitoring.NewZFSCollector(a.ZFSSvc, a.Runner)
|
||||
case "smart":
|
||||
collector = monitoring.NewSMARTCollector(a.Runner)
|
||||
case "services", "service":
|
||||
collector = monitoring.NewServiceCollector(a.Runner)
|
||||
case "host":
|
||||
collector = monitoring.NewHostCollector()
|
||||
default:
|
||||
// Try to match by collector name
|
||||
if strings.Contains(groupLower, "zfs") {
|
||||
collector = monitoring.NewZFSCollector(a.ZFSSvc, a.Runner)
|
||||
} else if strings.Contains(groupLower, "smart") {
|
||||
collector = monitoring.NewSMARTCollector(a.Runner)
|
||||
} else if strings.Contains(groupLower, "service") {
|
||||
collector = monitoring.NewServiceCollector(a.Runner)
|
||||
} else if strings.Contains(groupLower, "host") {
|
||||
collector = monitoring.NewHostCollector()
|
||||
} else {
|
||||
http.Error(w, "unknown group", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Export for UI
|
||||
exporter := monitoring.NewUIExporter(collector)
|
||||
groups := exporter.Export(ctx)
|
||||
|
||||
if len(groups) == 0 {
|
||||
http.Error(w, "no data", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Group": groups[0],
|
||||
}
|
||||
|
||||
if err := templates.ExecuteTemplate(w, "hx_monitoring_group", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
20
internal/http/template_helpers.go
Normal file
20
internal/http/template_helpers.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// templateData adds CSRF token and other common data to template context
|
||||
func templateData(r *http.Request, data map[string]interface{}) map[string]interface{} {
|
||||
if data == nil {
|
||||
data = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// Get CSRF token from cookie
|
||||
if cookie, err := r.Cookie("csrf_token"); err == nil {
|
||||
data["CSRFToken"] = cookie.Value
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
438
internal/monitoring/collectors.go
Normal file
438
internal/monitoring/collectors.go
Normal file
@@ -0,0 +1,438 @@
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/example/storage-appliance/internal/infra/osexec"
|
||||
"github.com/example/storage-appliance/internal/service"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// MetricValue represents a single metric value
|
||||
type MetricValue struct {
|
||||
Name string
|
||||
Labels map[string]string
|
||||
Value float64
|
||||
Type string // "gauge" or "counter"
|
||||
}
|
||||
|
||||
// MetricCollection represents a collection of metrics
|
||||
type MetricCollection struct {
|
||||
Metrics []MetricValue
|
||||
Errors []string
|
||||
}
|
||||
|
||||
// Collector interface for different metric collectors
|
||||
type Collector interface {
|
||||
Collect(ctx context.Context) MetricCollection
|
||||
Name() string
|
||||
}
|
||||
|
||||
// ZFSCollector collects ZFS pool health and scrub status
|
||||
type ZFSCollector struct {
|
||||
ZFSSvc service.ZFSService
|
||||
Runner osexec.Runner
|
||||
}
|
||||
|
||||
func NewZFSCollector(zfsSvc service.ZFSService, runner osexec.Runner) *ZFSCollector {
|
||||
return &ZFSCollector{ZFSSvc: zfsSvc, Runner: runner}
|
||||
}
|
||||
|
||||
func (c *ZFSCollector) Name() string {
|
||||
return "zfs"
|
||||
}
|
||||
|
||||
func (c *ZFSCollector) Collect(ctx context.Context) MetricCollection {
|
||||
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
|
||||
defer cancel()
|
||||
|
||||
collection := MetricCollection{
|
||||
Metrics: []MetricValue{},
|
||||
Errors: []string{},
|
||||
}
|
||||
|
||||
// Get pool list
|
||||
pools, err := c.ZFSSvc.ListPools(ctx)
|
||||
if err != nil {
|
||||
collection.Errors = append(collection.Errors, fmt.Sprintf("failed to list pools: %v", err))
|
||||
return collection
|
||||
}
|
||||
|
||||
for _, pool := range pools {
|
||||
// Pool health metric (1 = ONLINE, 0.5 = DEGRADED, 0 = FAULTED/OFFLINE)
|
||||
healthValue := 0.0
|
||||
switch strings.ToUpper(pool.Health) {
|
||||
case "ONLINE":
|
||||
healthValue = 1.0
|
||||
case "DEGRADED":
|
||||
healthValue = 0.5
|
||||
case "FAULTED", "OFFLINE", "UNAVAIL":
|
||||
healthValue = 0.0
|
||||
}
|
||||
|
||||
collection.Metrics = append(collection.Metrics, MetricValue{
|
||||
Name: "zfs_pool_health",
|
||||
Labels: map[string]string{"pool": pool.Name},
|
||||
Value: healthValue,
|
||||
Type: "gauge",
|
||||
})
|
||||
|
||||
// Get scrub status
|
||||
scrubStatus, err := c.getScrubStatus(ctx, pool.Name)
|
||||
if err != nil {
|
||||
collection.Errors = append(collection.Errors, fmt.Sprintf("failed to get scrub status for %s: %v", pool.Name, err))
|
||||
continue
|
||||
}
|
||||
|
||||
// Scrub in progress (1 = yes, 0 = no)
|
||||
scrubInProgress := 0.0
|
||||
if strings.Contains(scrubStatus, "scan: scrub in progress") {
|
||||
scrubInProgress = 1.0
|
||||
}
|
||||
|
||||
collection.Metrics = append(collection.Metrics, MetricValue{
|
||||
Name: "zfs_pool_scrub_in_progress",
|
||||
Labels: map[string]string{"pool": pool.Name},
|
||||
Value: scrubInProgress,
|
||||
Type: "gauge",
|
||||
})
|
||||
}
|
||||
|
||||
return collection
|
||||
}
|
||||
|
||||
func (c *ZFSCollector) getScrubStatus(ctx context.Context, pool string) (string, error) {
|
||||
out, _, _, err := osexec.ExecWithRunner(c.Runner, ctx, "zpool", "status", pool)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.Contains(line, "scan:") {
|
||||
return strings.TrimSpace(line), nil
|
||||
}
|
||||
}
|
||||
return "no-scan", nil
|
||||
}
|
||||
|
||||
// SMARTCollector collects SMART health status
|
||||
type SMARTCollector struct {
|
||||
Runner osexec.Runner
|
||||
}
|
||||
|
||||
func NewSMARTCollector(runner osexec.Runner) *SMARTCollector {
|
||||
return &SMARTCollector{Runner: runner}
|
||||
}
|
||||
|
||||
func (c *SMARTCollector) Name() string {
|
||||
return "smart"
|
||||
}
|
||||
|
||||
func (c *SMARTCollector) Collect(ctx context.Context) MetricCollection {
|
||||
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
|
||||
defer cancel()
|
||||
|
||||
collection := MetricCollection{
|
||||
Metrics: []MetricValue{},
|
||||
Errors: []string{},
|
||||
}
|
||||
|
||||
// List all disks (simplified - try common devices)
|
||||
// In a real implementation, you'd scan /dev/ or use lsblk
|
||||
commonDisks := []string{"sda", "sdb", "sdc", "nvme0n1", "nvme1n1"}
|
||||
disks := []string{}
|
||||
for _, d := range commonDisks {
|
||||
disks = append(disks, fmt.Sprintf("/dev/%s", d))
|
||||
}
|
||||
|
||||
// Check SMART health for each disk
|
||||
for _, disk := range disks {
|
||||
health, err := c.getSMARTHealth(ctx, disk)
|
||||
if err != nil {
|
||||
// Skip devices that don't exist or don't support SMART
|
||||
continue
|
||||
}
|
||||
|
||||
// SMART health: 1 = PASSED, 0 = FAILED
|
||||
healthValue := 0.0
|
||||
if strings.Contains(strings.ToUpper(health), "PASSED") {
|
||||
healthValue = 1.0
|
||||
}
|
||||
|
||||
collection.Metrics = append(collection.Metrics, MetricValue{
|
||||
Name: "smart_health",
|
||||
Labels: map[string]string{"device": disk},
|
||||
Value: healthValue,
|
||||
Type: "gauge",
|
||||
})
|
||||
}
|
||||
|
||||
return collection
|
||||
}
|
||||
|
||||
func (c *SMARTCollector) getSMARTHealth(ctx context.Context, device string) (string, error) {
|
||||
// Use smartctl -H to get health status
|
||||
out, _, code, err := osexec.ExecWithRunner(c.Runner, ctx, "smartctl", "-H", device)
|
||||
if err != nil || code != 0 {
|
||||
return "", fmt.Errorf("smartctl failed: %v", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ServiceCollector collects service states
|
||||
type ServiceCollector struct {
|
||||
Runner osexec.Runner
|
||||
}
|
||||
|
||||
func NewServiceCollector(runner osexec.Runner) *ServiceCollector {
|
||||
return &ServiceCollector{Runner: runner}
|
||||
}
|
||||
|
||||
func (c *ServiceCollector) Name() string {
|
||||
return "services"
|
||||
}
|
||||
|
||||
func (c *ServiceCollector) Collect(ctx context.Context) MetricCollection {
|
||||
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
|
||||
defer cancel()
|
||||
|
||||
collection := MetricCollection{
|
||||
Metrics: []MetricValue{},
|
||||
Errors: []string{},
|
||||
}
|
||||
|
||||
services := []string{"nfs-server", "smbd", "iscsid", "iscsi", "minio"}
|
||||
|
||||
for _, svc := range services {
|
||||
status, err := c.getServiceStatus(ctx, svc)
|
||||
if err != nil {
|
||||
collection.Errors = append(collection.Errors, fmt.Sprintf("failed to check %s: %v", svc, err))
|
||||
continue
|
||||
}
|
||||
|
||||
// Service state: 1 = active/running, 0 = inactive/stopped
|
||||
stateValue := 0.0
|
||||
if strings.Contains(strings.ToLower(status), "active") || strings.Contains(strings.ToLower(status), "running") {
|
||||
stateValue = 1.0
|
||||
}
|
||||
|
||||
collection.Metrics = append(collection.Metrics, MetricValue{
|
||||
Name: "service_state",
|
||||
Labels: map[string]string{"service": svc},
|
||||
Value: stateValue,
|
||||
Type: "gauge",
|
||||
})
|
||||
}
|
||||
|
||||
return collection
|
||||
}
|
||||
|
||||
func (c *ServiceCollector) getServiceStatus(ctx context.Context, service string) (string, error) {
|
||||
// Try systemctl first
|
||||
out, _, code, err := osexec.ExecWithRunner(c.Runner, ctx, "systemctl", "is-active", service)
|
||||
if err == nil && code == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Fallback to checking process
|
||||
out, _, code, err = osexec.ExecWithRunner(c.Runner, ctx, "pgrep", "-f", service)
|
||||
if err == nil && code == 0 && strings.TrimSpace(out) != "" {
|
||||
return "running", nil
|
||||
}
|
||||
|
||||
return "inactive", nil
|
||||
}
|
||||
|
||||
// HostCollector collects host metrics from /proc
|
||||
type HostCollector struct{}
|
||||
|
||||
func NewHostCollector() *HostCollector {
|
||||
return &HostCollector{}
|
||||
}
|
||||
|
||||
func (c *HostCollector) Name() string {
|
||||
return "host"
|
||||
}
|
||||
|
||||
func (c *HostCollector) Collect(ctx context.Context) MetricCollection {
|
||||
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
|
||||
defer cancel()
|
||||
|
||||
collection := MetricCollection{
|
||||
Metrics: []MetricValue{},
|
||||
Errors: []string{},
|
||||
}
|
||||
|
||||
// Load average
|
||||
loadavg, err := c.readLoadAvg()
|
||||
if err != nil {
|
||||
collection.Errors = append(collection.Errors, fmt.Sprintf("failed to read loadavg: %v", err))
|
||||
} else {
|
||||
collection.Metrics = append(collection.Metrics, MetricValue{
|
||||
Name: "host_load1",
|
||||
Labels: map[string]string{},
|
||||
Value: loadavg.Load1,
|
||||
Type: "gauge",
|
||||
})
|
||||
collection.Metrics = append(collection.Metrics, MetricValue{
|
||||
Name: "host_load5",
|
||||
Labels: map[string]string{},
|
||||
Value: loadavg.Load5,
|
||||
Type: "gauge",
|
||||
})
|
||||
collection.Metrics = append(collection.Metrics, MetricValue{
|
||||
Name: "host_load15",
|
||||
Labels: map[string]string{},
|
||||
Value: loadavg.Load15,
|
||||
Type: "gauge",
|
||||
})
|
||||
}
|
||||
|
||||
// Memory info
|
||||
meminfo, err := c.readMemInfo()
|
||||
if err != nil {
|
||||
collection.Errors = append(collection.Errors, fmt.Sprintf("failed to read meminfo: %v", err))
|
||||
} else {
|
||||
collection.Metrics = append(collection.Metrics, MetricValue{
|
||||
Name: "host_memory_total_bytes",
|
||||
Labels: map[string]string{},
|
||||
Value: meminfo.MemTotal,
|
||||
Type: "gauge",
|
||||
})
|
||||
collection.Metrics = append(collection.Metrics, MetricValue{
|
||||
Name: "host_memory_free_bytes",
|
||||
Labels: map[string]string{},
|
||||
Value: meminfo.MemFree,
|
||||
Type: "gauge",
|
||||
})
|
||||
collection.Metrics = append(collection.Metrics, MetricValue{
|
||||
Name: "host_memory_available_bytes",
|
||||
Labels: map[string]string{},
|
||||
Value: meminfo.MemAvailable,
|
||||
Type: "gauge",
|
||||
})
|
||||
}
|
||||
|
||||
// Disk IO (simplified - read from /proc/diskstats)
|
||||
diskIO, err := c.readDiskIO()
|
||||
if err != nil {
|
||||
collection.Errors = append(collection.Errors, fmt.Sprintf("failed to read disk IO: %v", err))
|
||||
} else {
|
||||
for device, io := range diskIO {
|
||||
collection.Metrics = append(collection.Metrics, MetricValue{
|
||||
Name: "host_disk_reads_completed",
|
||||
Labels: map[string]string{"device": device},
|
||||
Value: io.ReadsCompleted,
|
||||
Type: "counter",
|
||||
})
|
||||
collection.Metrics = append(collection.Metrics, MetricValue{
|
||||
Name: "host_disk_writes_completed",
|
||||
Labels: map[string]string{"device": device},
|
||||
Value: io.WritesCompleted,
|
||||
Type: "counter",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return collection
|
||||
}
|
||||
|
||||
type LoadAvg struct {
|
||||
Load1 float64
|
||||
Load5 float64
|
||||
Load15 float64
|
||||
}
|
||||
|
||||
func (c *HostCollector) readLoadAvg() (LoadAvg, error) {
|
||||
data, err := os.ReadFile("/proc/loadavg")
|
||||
if err != nil {
|
||||
return LoadAvg{}, err
|
||||
}
|
||||
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) < 3 {
|
||||
return LoadAvg{}, fmt.Errorf("invalid loadavg format")
|
||||
}
|
||||
|
||||
load1, _ := strconv.ParseFloat(fields[0], 64)
|
||||
load5, _ := strconv.ParseFloat(fields[1], 64)
|
||||
load15, _ := strconv.ParseFloat(fields[2], 64)
|
||||
|
||||
return LoadAvg{Load1: load1, Load5: load5, Load15: load15}, nil
|
||||
}
|
||||
|
||||
type MemInfo struct {
|
||||
MemTotal float64
|
||||
MemFree float64
|
||||
MemAvailable float64
|
||||
}
|
||||
|
||||
func (c *HostCollector) readMemInfo() (MemInfo, error) {
|
||||
data, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return MemInfo{}, err
|
||||
}
|
||||
|
||||
info := MemInfo{}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSuffix(fields[0], ":")
|
||||
value, _ := strconv.ParseFloat(fields[1], 64)
|
||||
// Values are in KB, convert to bytes
|
||||
valueBytes := value * 1024
|
||||
|
||||
switch key {
|
||||
case "MemTotal":
|
||||
info.MemTotal = valueBytes
|
||||
case "MemFree":
|
||||
info.MemFree = valueBytes
|
||||
case "MemAvailable":
|
||||
info.MemAvailable = valueBytes
|
||||
}
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
type DiskIO struct {
|
||||
ReadsCompleted float64
|
||||
WritesCompleted float64
|
||||
}
|
||||
|
||||
func (c *HostCollector) readDiskIO() (map[string]DiskIO, error) {
|
||||
data, err := os.ReadFile("/proc/diskstats")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]DiskIO)
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 14 {
|
||||
continue
|
||||
}
|
||||
device := fields[2]
|
||||
reads, _ := strconv.ParseFloat(fields[3], 64)
|
||||
writes, _ := strconv.ParseFloat(fields[7], 64)
|
||||
|
||||
result[device] = DiskIO{
|
||||
ReadsCompleted: reads,
|
||||
WritesCompleted: writes,
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
60
internal/monitoring/prometheus.go
Normal file
60
internal/monitoring/prometheus.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PrometheusExporter exports metrics in Prometheus format
|
||||
type PrometheusExporter struct {
|
||||
Collectors []Collector
|
||||
}
|
||||
|
||||
func NewPrometheusExporter(collectors ...Collector) *PrometheusExporter {
|
||||
return &PrometheusExporter{Collectors: collectors}
|
||||
}
|
||||
|
||||
// Export collects all metrics and formats them as Prometheus text format
|
||||
func (e *PrometheusExporter) Export(ctx context.Context) string {
|
||||
var builder strings.Builder
|
||||
allErrors := []string{}
|
||||
|
||||
for _, collector := range e.Collectors {
|
||||
collection := collector.Collect(ctx)
|
||||
allErrors = append(allErrors, collection.Errors...)
|
||||
|
||||
for _, metric := range collection.Metrics {
|
||||
// Format: metric_name{label1="value1",label2="value2"} value
|
||||
builder.WriteString(metric.Name)
|
||||
if len(metric.Labels) > 0 {
|
||||
builder.WriteString("{")
|
||||
first := true
|
||||
for k, v := range metric.Labels {
|
||||
if !first {
|
||||
builder.WriteString(",")
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf(`%s="%s"`, k, escapeLabelValue(v)))
|
||||
first = false
|
||||
}
|
||||
builder.WriteString("}")
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf(" %v\n", metric.Value))
|
||||
}
|
||||
}
|
||||
|
||||
// Add error metrics if any
|
||||
if len(allErrors) > 0 {
|
||||
builder.WriteString(fmt.Sprintf("monitoring_collector_errors_total %d\n", len(allErrors)))
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func escapeLabelValue(v string) string {
|
||||
v = strings.ReplaceAll(v, "\\", "\\\\")
|
||||
v = strings.ReplaceAll(v, "\"", "\\\"")
|
||||
v = strings.ReplaceAll(v, "\n", "\\n")
|
||||
return v
|
||||
}
|
||||
|
||||
149
internal/monitoring/ui.go
Normal file
149
internal/monitoring/ui.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UIMetric represents a metric for UI display
|
||||
type UIMetric struct {
|
||||
Name string
|
||||
Value string
|
||||
Status string // "ok", "warning", "error"
|
||||
Timestamp time.Time
|
||||
Error string
|
||||
}
|
||||
|
||||
// UIMetricGroup represents a group of metrics for UI display
|
||||
type UIMetricGroup struct {
|
||||
Title string
|
||||
Metrics []UIMetric
|
||||
Errors []string
|
||||
}
|
||||
|
||||
// UIExporter exports metrics in a format suitable for UI display
|
||||
type UIExporter struct {
|
||||
Collectors []Collector
|
||||
}
|
||||
|
||||
func NewUIExporter(collectors ...Collector) *UIExporter {
|
||||
return &UIExporter{Collectors: collectors}
|
||||
}
|
||||
|
||||
// Export collects all metrics and formats them for UI
|
||||
func (e *UIExporter) Export(ctx context.Context) []UIMetricGroup {
|
||||
groups := []UIMetricGroup{}
|
||||
|
||||
for _, collector := range e.Collectors {
|
||||
collection := collector.Collect(ctx)
|
||||
// Capitalize first letter
|
||||
name := collector.Name()
|
||||
if len(name) > 0 {
|
||||
name = strings.ToUpper(name[:1]) + name[1:]
|
||||
}
|
||||
group := UIMetricGroup{
|
||||
Title: name,
|
||||
Metrics: []UIMetric{},
|
||||
Errors: collection.Errors,
|
||||
}
|
||||
|
||||
for _, metric := range collection.Metrics {
|
||||
status := "ok"
|
||||
value := formatMetricValue(metric)
|
||||
|
||||
// Determine status based on metric type and value
|
||||
if metric.Name == "zfs_pool_health" {
|
||||
if metric.Value == 0.0 {
|
||||
status = "error"
|
||||
} else if metric.Value == 0.5 {
|
||||
status = "warning"
|
||||
}
|
||||
} else if metric.Name == "smart_health" {
|
||||
if metric.Value == 0.0 {
|
||||
status = "error"
|
||||
}
|
||||
} else if metric.Name == "service_state" {
|
||||
if metric.Value == 0.0 {
|
||||
status = "error"
|
||||
}
|
||||
} else if strings.HasPrefix(metric.Name, "host_load") {
|
||||
if metric.Value > 10.0 {
|
||||
status = "warning"
|
||||
}
|
||||
if metric.Value > 20.0 {
|
||||
status = "error"
|
||||
}
|
||||
}
|
||||
|
||||
group.Metrics = append(group.Metrics, UIMetric{
|
||||
Name: formatMetricName(metric),
|
||||
Value: value,
|
||||
Status: status,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
groups = append(groups, group)
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
func formatMetricName(metric MetricValue) string {
|
||||
name := metric.Name
|
||||
if len(metric.Labels) > 0 {
|
||||
labels := []string{}
|
||||
for k, v := range metric.Labels {
|
||||
labels = append(labels, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
name = fmt.Sprintf("%s{%s}", name, strings.Join(labels, ", "))
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func formatMetricValue(metric MetricValue) string {
|
||||
switch metric.Name {
|
||||
case "zfs_pool_health":
|
||||
if metric.Value == 1.0 {
|
||||
return "ONLINE"
|
||||
} else if metric.Value == 0.5 {
|
||||
return "DEGRADED"
|
||||
}
|
||||
return "FAULTED"
|
||||
case "zfs_pool_scrub_in_progress":
|
||||
if metric.Value == 1.0 {
|
||||
return "In Progress"
|
||||
}
|
||||
return "Idle"
|
||||
case "smart_health":
|
||||
if metric.Value == 1.0 {
|
||||
return "PASSED"
|
||||
}
|
||||
return "FAILED"
|
||||
case "service_state":
|
||||
if metric.Value == 1.0 {
|
||||
return "Running"
|
||||
}
|
||||
return "Stopped"
|
||||
case "host_load1", "host_load5", "host_load15":
|
||||
return fmt.Sprintf("%.2f", metric.Value)
|
||||
case "host_memory_total_bytes", "host_memory_free_bytes", "host_memory_available_bytes":
|
||||
return formatBytes(metric.Value)
|
||||
default:
|
||||
return fmt.Sprintf("%.2f", metric.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func formatBytes(bytes float64) string {
|
||||
units := []string{"B", "KB", "MB", "GB", "TB"}
|
||||
value := bytes
|
||||
unit := 0
|
||||
for value >= 1024 && unit < len(units)-1 {
|
||||
value /= 1024
|
||||
unit++
|
||||
}
|
||||
return fmt.Sprintf("%.2f %s", value, units[unit])
|
||||
}
|
||||
|
||||
68
internal/templates/hx_monitoring.html
Normal file
68
internal/templates/hx_monitoring.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{{define "hx_monitoring"}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{{range .Groups}}
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold">{{.Title}}</h2>
|
||||
<button hx-get="/hx/monitoring/group?group={{.Title}}"
|
||||
hx-target="closest .bg-white"
|
||||
hx-swap="outerHTML"
|
||||
class="text-blue-600 hover:text-blue-800 text-sm">
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{if .Errors}}
|
||||
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-4">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-yellow-700">
|
||||
<strong>Warnings:</strong>
|
||||
<ul class="list-disc list-inside mt-1">
|
||||
{{range .Errors}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="space-y-3">
|
||||
{{range .Metrics}}
|
||||
<div class="flex justify-between items-center p-3 {{if eq .Status "error"}}bg-red-50{{else if eq .Status "warning"}}bg-yellow-50{{else}}bg-gray-50{{end}} rounded">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm">{{.Name}}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">{{.Timestamp.Format "15:04:05"}}</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-lg font-semibold">{{.Value}}</span>
|
||||
{{if eq .Status "error"}}
|
||||
<span class="text-red-600">⚠️</span>
|
||||
{{else if eq .Status "warning"}}
|
||||
<span class="text-yellow-600">⚡</span>
|
||||
{{else}}
|
||||
<span class="text-green-600">✓</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-center text-gray-500 py-4">No metrics available</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="col-span-2 bg-yellow-50 border-l-4 border-yellow-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-yellow-700">
|
||||
<strong>Warning:</strong> No monitoring data available. Some collectors may have failed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
54
internal/templates/hx_monitoring_group.html
Normal file
54
internal/templates/hx_monitoring_group.html
Normal file
@@ -0,0 +1,54 @@
|
||||
{{define "hx_monitoring_group"}}
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold">{{.Group.Title}}</h2>
|
||||
<button hx-get="/hx/monitoring/group?group={{.Group.Title}}"
|
||||
hx-target="closest .bg-white"
|
||||
hx-swap="outerHTML"
|
||||
class="text-blue-600 hover:text-blue-800 text-sm">
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{if .Group.Errors}}
|
||||
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-4">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-yellow-700">
|
||||
<strong>Warnings:</strong>
|
||||
<ul class="list-disc list-inside mt-1">
|
||||
{{range .Group.Errors}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="space-y-3">
|
||||
{{range .Group.Metrics}}
|
||||
<div class="flex justify-between items-center p-3 {{if eq .Status "error"}}bg-red-50{{else if eq .Status "warning"}}bg-yellow-50{{else}}bg-gray-50{{end}} rounded">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm">{{.Name}}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">{{.Timestamp.Format "15:04:05"}}</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-lg font-semibold">{{.Value}}</span>
|
||||
{{if eq .Status "error"}}
|
||||
<span class="text-red-600">⚠️</span>
|
||||
{{else if eq .Status "warning"}}
|
||||
<span class="text-yellow-600">⚡</span>
|
||||
{{else}}
|
||||
<span class="text-green-600">✓</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-center text-gray-500 py-4">No metrics available</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
41
internal/templates/hx_roles.html
Normal file
41
internal/templates/hx_roles.html
Normal file
@@ -0,0 +1,41 @@
|
||||
{{define "hx_roles"}}
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Role Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Permissions</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{{range .Roles}}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{.Name}}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">{{.Description}}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
{{range .Permissions}}
|
||||
<span class="inline-block bg-green-100 text-green-800 text-xs px-2 py-1 rounded mr-1 mb-1">{{.Name}}</span>
|
||||
{{else}}
|
||||
<span class="text-gray-400">No permissions</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<button hx-post="/admin/roles/{{.ID}}/delete"
|
||||
hx-confirm="Are you sure you want to delete role {{.Name}}?"
|
||||
hx-target="#roles-list"
|
||||
hx-swap="outerHTML"
|
||||
class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-4 text-center text-gray-500">No roles found</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
41
internal/templates/hx_users.html
Normal file
41
internal/templates/hx_users.html
Normal file
@@ -0,0 +1,41 @@
|
||||
{{define "hx_users"}}
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Username</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Roles</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{.Username}}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{range .Roles}}
|
||||
<span class="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded mr-1">{{.Name}}</span>
|
||||
{{else}}
|
||||
<span class="text-gray-400">No roles</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{.CreatedAt}}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<button hx-post="/admin/users/{{.ID}}/delete"
|
||||
hx-confirm="Are you sure you want to delete user {{.Username}}?"
|
||||
hx-target="#users-list"
|
||||
hx-swap="outerHTML"
|
||||
class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-4 text-center text-gray-500">No users found</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
34
internal/templates/login.html
Normal file
34
internal/templates/login.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{{define "login"}}
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
|
||||
<title>Login - Storage Appliance</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-gray-100 flex items-center justify-center min-h-screen">
|
||||
<div class="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
|
||||
<h1 class="text-2xl font-bold mb-6 text-center">Storage Appliance</h1>
|
||||
<form hx-post="/login" hx-target="#error-message" hx-swap="innerHTML">
|
||||
<div class="mb-4">
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-2">Username</label>
|
||||
<input type="text" id="username" name="username" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
||||
<input type="password" id="password" name="password" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div id="error-message" class="mb-4"></div>
|
||||
<button type="submit" class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
16
internal/templates/monitoring.html
Normal file
16
internal/templates/monitoring.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{{define "monitoring"}}
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div class="container mx-auto p-4">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold">Monitoring Dashboard</h1>
|
||||
<a href="/dashboard" class="text-blue-600 hover:underline">← Back to Dashboard</a>
|
||||
</div>
|
||||
|
||||
<div id="monitoring-content" hx-get="/hx/monitoring" hx-trigger="load" hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">Loading metrics...</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
37
internal/templates/roles.html
Normal file
37
internal/templates/roles.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{{define "roles"}}
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div class="container mx-auto p-4">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold">Role Management</h1>
|
||||
<a href="/dashboard" class="text-blue-600 hover:underline">← Back to Dashboard</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Create New Role</h2>
|
||||
<form hx-post="/admin/roles/create" hx-target="#roles-list" hx-swap="outerHTML" hx-trigger="submit" hx-on::after-request="this.reset()">
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Role Name</label>
|
||||
<input type="text" id="name" name="name" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
</div>
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<input type="text" id="description" name="description"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700">
|
||||
Create Role
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="roles-list" hx-get="/admin/hx/roles" hx-trigger="load">
|
||||
<div class="text-center py-8 text-gray-500">Loading roles...</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
37
internal/templates/users.html
Normal file
37
internal/templates/users.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{{define "users"}}
|
||||
{{template "base" .}}
|
||||
{{define "content"}}
|
||||
<div class="container mx-auto p-4">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold">User Management</h1>
|
||||
<a href="/dashboard" class="text-blue-600 hover:underline">← Back to Dashboard</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Create New User</h2>
|
||||
<form hx-post="/admin/users/create" hx-target="#users-list" hx-swap="outerHTML" hx-trigger="submit" hx-on::after-request="this.reset()">
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">Username</label>
|
||||
<input type="text" id="username" name="username" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input type="password" id="password" name="password" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700">
|
||||
Create User
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="users-list" hx-get="/admin/hx/users" hx-trigger="load">
|
||||
<div class="text-center py-8 text-gray-500">Loading users...</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user