Update frame work and workspace codebase

This commit is contained in:
2025-12-13 17:44:42 +00:00
parent 8100f87686
commit 72b5c18f29
16 changed files with 1499 additions and 0 deletions

BIN
appliance Executable file

Binary file not shown.

View 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)
}
}

View 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)
}
}

View 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)
}
}

View 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
}

View 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
}

View 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
View 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])
}

View 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}}

View 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}}

View 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}}

View 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}}

View 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}}

View 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}}

View 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}}

View 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}}