diff --git a/cmd/maintainarr/main.go b/cmd/maintainarr/main.go index 156a4fd..3016c34 100644 --- a/cmd/maintainarr/main.go +++ b/cmd/maintainarr/main.go @@ -3,7 +3,7 @@ package main import ( "log" - "github.com/spenc/maintainarr/internal/app" + "maintainarr/internal/app" ) func main() { diff --git a/go.mod b/go.mod index 1605197..bab1094 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/spenc/maintainarr +module maintainarr go 1.25.7 diff --git a/internal/app/app.go b/internal/app/app.go index 0746173..86b3d3b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -9,13 +9,13 @@ import ( "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" - "github.com/spenc/maintainarr/internal/config" - "github.com/spenc/maintainarr/internal/db" - "github.com/spenc/maintainarr/internal/handlers" - localmiddleware "github.com/spenc/maintainarr/internal/middleware" - "github.com/spenc/maintainarr/internal/models" - "github.com/spenc/maintainarr/internal/services" - "github.com/spenc/maintainarr/internal/views" + "maintainarr/internal/config" + "maintainarr/internal/db" + "maintainarr/internal/handlers" + localmiddleware "maintainarr/internal/middleware" + "maintainarr/internal/models" + "maintainarr/internal/services" + "maintainarr/internal/views" ) type App struct { @@ -53,14 +53,18 @@ func New() (*App, error) { if err != nil { return nil, err } + if org.ThemeMode == "" { + if err := repo.UpdateOrganizationTheme(ctx, org.Theme, cfg.DefaultMode); err != nil { + return nil, err + } + org.ThemeMode = cfg.DefaultMode + } if err := repo.SeedDefaults(ctx, org.ID); err != nil { return nil, err } - if cfg.BootstrapAdmin { - if err := bootstrapAdmin(ctx, repo, auth, org); err != nil { - return nil, err - } + if err := repo.EnsurePrimaryAdmin(ctx); err != nil { + return nil, err } handler := handlers.New(repo, auth, sessions, nodes, renderer, org, cfg.BaseURL) @@ -85,16 +89,23 @@ func New() (*App, error) { protected.Get("/setup-2fa", handler.SetupOTPPage) protected.Post("/setup-2fa", handler.SetupOTP) protected.Get("/dashboard", handler.Dashboard) + protected.Get("/dashboard/nodes", handler.DashboardNodes) protected.Get("/nodes/{nodeID}", handler.NodeOverview) + protected.Get("/nodes/{nodeID}/stats", handler.NodeStatsAPI) protected.Get("/nodes/{nodeID}/console", handler.NodeConsole) protected.Get("/nodes/{nodeID}/console/ws", handler.NodeConsoleWebSocket) protected.Get("/groups", handler.GroupsPage) protected.Get("/automations", handler.AutomationsPage) protected.Get("/settings", handler.SettingsPage) + protected.Post("/settings/theme", handler.UpdateTheme) protected.Group(func(editor chi.Router) { editor.Use(localmiddleware.RequireRole(models.RoleEditor)) + editor.Post("/groups", handler.CreateGroup) + editor.Post("/nodes", handler.CreateNode) editor.Post("/nodes/{nodeID}/actions/{action}", handler.NodeAction) + editor.Post("/nodes/{nodeID}/commands", handler.NodeQuickCommand) + editor.Post("/nodes/{nodeID}/delete", handler.DeleteNode) editor.Post("/automations", handler.CreateAutomation) }) }) @@ -120,30 +131,3 @@ func (a *App) Run() error { fmt.Printf("Maintainarr listening on %s\n", a.config.Address) return a.server.ListenAndServe() } - -func bootstrapAdmin(ctx context.Context, repo *services.Repository, auth *services.AuthService, org models.Organization) error { - count, err := repo.CountUsers(ctx) - if err != nil || count > 0 { - return err - } - - password, err := auth.HashPassword("admin123!") - if err != nil { - return err - } - - key, err := auth.NewOTP("admin@maintainarr.local") - if err != nil { - return err - } - - return repo.CreateUser(ctx, &models.User{ - Organization: org.ID, - Name: "Bootstrap Admin", - Email: "admin@maintainarr.local", - PasswordHash: password, - Role: models.RoleAdmin, - OTPSecret: key.Secret(), - OTPEnabled: false, - }) -} diff --git a/internal/config/config.go b/internal/config/config.go index e45d9bd..d0a6a93 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,28 +6,28 @@ import ( ) type Config struct { - Address string - DatabasePath string - SessionKey string - EncryptionKey string - OrgName string - BaseURL string - DefaultTheme string - RefreshCron string - BootstrapAdmin bool + Address string + DatabasePath string + SessionKey string + EncryptionKey string + OrgName string + BaseURL string + DefaultTheme string + DefaultMode string + RefreshCron string } func Load() Config { return Config{ - Address: env("MAINTAINARR_ADDR", ":8080"), - DatabasePath: env("MAINTAINARR_DB_PATH", filepath.Join("data", "maintainarr.db")), - SessionKey: env("MAINTAINARR_SESSION_KEY", "change-me-session-key-please"), - EncryptionKey: env("MAINTAINARR_ENCRYPTION_KEY", "change-me-encryption-key-32bytes"), - OrgName: env("MAINTAINARR_ORG_NAME", "Maintainarr"), - BaseURL: env("MAINTAINARR_BASE_URL", "http://localhost:8080"), - DefaultTheme: env("MAINTAINARR_THEME", "emerald"), - RefreshCron: env("MAINTAINARR_REFRESH_CRON", "@every 5m"), - BootstrapAdmin: env("MAINTAINARR_BOOTSTRAP_ADMIN", "true") == "true", + Address: env("MAINTAINARR_ADDR", ":8080"), + DatabasePath: env("MAINTAINARR_DB_PATH", filepath.Join("data", "maintainarr.db")), + SessionKey: env("MAINTAINARR_SESSION_KEY", "change-me-session-key-please"), + EncryptionKey: env("MAINTAINARR_ENCRYPTION_KEY", "change-me-encryption-key-32bytes"), + OrgName: env("MAINTAINARR_ORG_NAME", "Maintainarr"), + BaseURL: env("MAINTAINARR_BASE_URL", "http://localhost:8080"), + DefaultTheme: env("MAINTAINARR_THEME", "blue"), + DefaultMode: env("MAINTAINARR_THEME_MODE", "dark"), + RefreshCron: env("MAINTAINARR_REFRESH_CRON", "@every 5s"), } } diff --git a/internal/db/db.go b/internal/db/db.go index 9ffc9de..4962e3d 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -45,6 +45,7 @@ func migrate(ctx context.Context, database *sql.DB) error { id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, theme TEXT NOT NULL DEFAULT 'emerald', + theme_mode TEXT NOT NULL DEFAULT 'dark', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP );`, `CREATE TABLE IF NOT EXISTS users ( @@ -72,6 +73,7 @@ func migrate(ctx context.Context, database *sql.DB) error { id INTEGER PRIMARY KEY AUTOINCREMENT, organization_id INTEGER NOT NULL, group_id INTEGER, + tag TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, distro TEXT NOT NULL, hostname TEXT NOT NULL, @@ -80,6 +82,12 @@ func migrate(ctx context.Context, database *sql.DB) error { ssh_port INTEGER NOT NULL DEFAULT 22, ssh_username TEXT NOT NULL DEFAULT '', ssh_password TEXT NOT NULL DEFAULT '', + package_manager TEXT NOT NULL DEFAULT '', + architecture TEXT NOT NULL DEFAULT '', + kernel_version TEXT NOT NULL DEFAULT '', + cpu_model TEXT NOT NULL DEFAULT '', + memory_total_mb INTEGER NOT NULL DEFAULT 0, + disk_total_gb INTEGER NOT NULL DEFAULT 0, cpu_usage REAL NOT NULL DEFAULT 0, ram_usage REAL NOT NULL DEFAULT 0, disk_usage REAL NOT NULL DEFAULT 0, @@ -97,6 +105,7 @@ func migrate(ctx context.Context, database *sql.DB) error { organization_id INTEGER NOT NULL, node_id INTEGER, group_id INTEGER, + tag TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, trigger_type TEXT NOT NULL, schedule TEXT NOT NULL DEFAULT '', @@ -131,5 +140,21 @@ func migrate(ctx context.Context, database *sql.DB) error { } } + alterStatements := []string{ + `ALTER TABLE organizations ADD COLUMN theme_mode TEXT NOT NULL DEFAULT 'dark';`, + `ALTER TABLE nodes ADD COLUMN tag TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE nodes ADD COLUMN package_manager TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE nodes ADD COLUMN architecture TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE nodes ADD COLUMN kernel_version TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE nodes ADD COLUMN cpu_model TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE nodes ADD COLUMN memory_total_mb INTEGER NOT NULL DEFAULT 0;`, + `ALTER TABLE nodes ADD COLUMN disk_total_gb INTEGER NOT NULL DEFAULT 0;`, + `ALTER TABLE automation_jobs ADD COLUMN tag TEXT NOT NULL DEFAULT '';`, + } + + for _, statement := range alterStatements { + _, _ = database.ExecContext(ctx, statement) + } + return nil } diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 44cc3cd..54d23c0 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -4,8 +4,8 @@ import ( "context" "net/http" - "github.com/spenc/maintainarr/internal/models" - "github.com/spenc/maintainarr/internal/services" + "maintainarr/internal/models" + "maintainarr/internal/services" ) type contextKey string diff --git a/internal/models/models.go b/internal/models/models.go index 68aa37e..437d607 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -14,6 +14,7 @@ type Organization struct { ID int64 Name string Theme string + ThemeMode string CreatedAt time.Time } @@ -42,6 +43,8 @@ type Node struct { ID int64 OrganizationID int64 GroupID *int64 + GroupName string + Tag string Name string Distro string Hostname string @@ -50,6 +53,12 @@ type Node struct { SSHPort int SSHUsername string SSHPassword string + PackageManager string + Architecture string + KernelVersion string + CPUModel string + MemoryTotalMB int64 + DiskTotalGB int64 CPUUsage float64 RAMUsage float64 DiskUsage float64 @@ -66,6 +75,9 @@ type AutomationJob struct { OrganizationID int64 NodeID *int64 GroupID *int64 + NodeName string + GroupName string + Tag string Name string TriggerType string Schedule string @@ -77,13 +89,16 @@ type AutomationJob struct { } type CommandRun struct { - ID int64 + ID int64 JobID *int64 NodeID int64 + JobName string + NodeName string Action string Status string Output string TriggeredBy *int64 StartedAt time.Time FinishedAt *time.Time + DurationText string } diff --git a/internal/services/repository.go b/internal/services/repository.go index f52034f..f66b4a4 100644 --- a/internal/services/repository.go +++ b/internal/services/repository.go @@ -4,7 +4,7 @@ import ( "context" "database/sql" - "github.com/spenc/maintainarr/internal/models" + "maintainarr/internal/models" ) type Repository struct { @@ -22,10 +22,10 @@ func (r *Repository) DB() *sql.DB { func (r *Repository) EnsureOrganization(ctx context.Context, name, theme string) (models.Organization, error) { var organization models.Organization err := r.db.QueryRowContext(ctx, ` - SELECT id, name, theme, created_at + SELECT id, name, theme, theme_mode, created_at FROM organizations LIMIT 1 - `).Scan(&organization.ID, &organization.Name, &organization.Theme, &organization.CreatedAt) + `).Scan(&organization.ID, &organization.Name, &organization.Theme, &organization.ThemeMode, &organization.CreatedAt) if err == nil { return organization, nil } @@ -33,16 +33,36 @@ func (r *Repository) EnsureOrganization(ctx context.Context, name, theme string) return organization, err } - result, err := r.db.ExecContext(ctx, `INSERT INTO organizations (name, theme) VALUES (?, ?)`, name, theme) + result, err := r.db.ExecContext(ctx, `INSERT INTO organizations (name, theme, theme_mode) VALUES (?, ?, 'dark')`, name, theme) if err != nil { return organization, err } organization.ID, _ = result.LastInsertId() organization.Name = name organization.Theme = theme + organization.ThemeMode = "dark" return organization, nil } +func (r *Repository) GetOrganization(ctx context.Context) (models.Organization, error) { + var organization models.Organization + err := r.db.QueryRowContext(ctx, ` + SELECT id, name, theme, theme_mode, created_at + FROM organizations + LIMIT 1 + `).Scan(&organization.ID, &organization.Name, &organization.Theme, &organization.ThemeMode, &organization.CreatedAt) + return organization, err +} + +func (r *Repository) UpdateOrganizationTheme(ctx context.Context, theme, themeMode string) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE organizations + SET theme = ?, theme_mode = ? + WHERE id = (SELECT id FROM organizations LIMIT 1) + `, theme, themeMode) + return err +} + func (r *Repository) CountUsers(ctx context.Context) (int, error) { var count int err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count) @@ -101,54 +121,104 @@ func (r *Repository) EnableUserOTP(ctx context.Context, id int64) error { return err } -func (r *Repository) SeedDefaults(ctx context.Context, orgID int64) error { - var groups int - if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vm_groups`).Scan(&groups); err != nil { +func (r *Repository) EnsurePrimaryAdmin(ctx context.Context) error { + var adminCount int + if err := r.db.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM users + WHERE role = ? + `, models.RoleAdmin).Scan(&adminCount); err != nil { return err } - if groups == 0 { - if _, err := r.db.ExecContext(ctx, ` - INSERT INTO vm_groups (organization_id, name, description, color_token) - VALUES - (?, 'Core Infra', 'Hypervisors, DNS, auth, storage', 'primary'), - (?, 'Edge Fleet', 'Proxies, tunnel boxes, ingress points', 'accent'), - (?, 'Lab', 'Test nodes and staging sandboxes', 'warning') - `, orgID, orgID, orgID); err != nil { - return err + if adminCount > 0 { + return nil + } + + var userID int64 + err := r.db.QueryRowContext(ctx, ` + SELECT id + FROM users + ORDER BY created_at ASC, id ASC + LIMIT 1 + `).Scan(&userID) + if err == sql.ErrNoRows { + return nil + } + if err != nil { + return err + } + + _, err = r.db.ExecContext(ctx, ` + UPDATE users + SET role = ? + WHERE id = ? + `, models.RoleAdmin, userID) + return err +} + +func (r *Repository) ListGroups(ctx context.Context, orgID int64) ([]models.VMGroup, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, organization_id, name, description, color_token, created_at + FROM vm_groups + WHERE organization_id = ? + ORDER BY name ASC + `, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + + var groups []models.VMGroup + for rows.Next() { + var group models.VMGroup + if err := rows.Scan(&group.ID, &group.OrganizationID, &group.Name, &group.Description, &group.ColorToken, &group.CreatedAt); err != nil { + return nil, err } + groups = append(groups, group) } - var nodes int - if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM nodes`).Scan(&nodes); err != nil { - return err - } - if nodes == 0 { - _, err := r.db.ExecContext(ctx, ` - INSERT INTO nodes (organization_id, name, distro, hostname, ip_address, mac_address, ssh_port, cpu_usage, ram_usage, disk_usage, uptime_seconds, auto_updates_enabled, notes) - VALUES - (?, 'Aurora', 'Ubuntu', 'aurora.local', '192.168.1.20', 'AA:BB:CC:DD:EE:01', 22, 34.5, 62.4, 40.1, 864000, 1, 'Primary app host'), - (?, 'Forge', 'Debian', 'forge.local', '192.168.1.25', 'AA:BB:CC:DD:EE:02', 22, 12.8, 44.9, 71.2, 421920, 1, 'Build runner'), - (?, 'Nimbus', 'Arch', 'nimbus.local', '192.168.1.33', 'AA:BB:CC:DD:EE:03', 22, 73.1, 80.6, 55.8, 128800, 0, 'Bleeding-edge sandbox') - `, orgID, orgID, orgID) - if err != nil { - return err - } - } + return groups, rows.Err() +} - var jobs int - if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM automation_jobs`).Scan(&jobs); err != nil { - return err - } - if jobs == 0 { - _, err := r.db.ExecContext(ctx, ` - INSERT INTO automation_jobs (organization_id, node_id, name, trigger_type, schedule, command, enabled) - VALUES - (?, 1, 'Nightly apt upgrade', 'recurring', '0 3 * * *', 'sudo apt update && sudo apt upgrade -y && sudo apt autoremove -y', 1), - (?, 2, 'Weekly docker prune', 'recurring', '0 4 * * 0', 'docker system prune -af', 1), - (?, 3, 'High-load audit hook', 'triggered', '', 'journalctl -p 3 -n 200', 1) - `, orgID, orgID, orgID) +func (r *Repository) CreateGroup(ctx context.Context, group *models.VMGroup) error { + result, err := r.db.ExecContext(ctx, ` + INSERT INTO vm_groups (organization_id, name, description, color_token) + VALUES (?, ?, ?, ?) + `, group.OrganizationID, group.Name, group.Description, group.ColorToken) + if err != nil { return err } + group.ID, _ = result.LastInsertId() + return nil +} + +func (r *Repository) ListTags(ctx context.Context, orgID int64) ([]string, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT DISTINCT tag + FROM nodes + WHERE organization_id = ? AND tag <> '' + ORDER BY tag ASC + `, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + + var tags []string + for rows.Next() { + var tag string + if err := rows.Scan(&tag); err != nil { + return nil, err + } + tags = append(tags, tag) + } + + return tags, rows.Err() +} + +func (r *Repository) SeedDefaults(ctx context.Context, orgID int64) error { + _ = ctx + _ = orgID return nil }