Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions gearbox/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,19 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
gearbox "github.com/sarg3nt/gearbox"
"github.com/sarg3nt/gearbox/internal/framework/collector"
"github.com/sarg3nt/gearbox/internal/framework/database"
"github.com/sarg3nt/gearbox/internal/framework/auth"
"github.com/sarg3nt/gearbox/internal/framework/collector"
"github.com/sarg3nt/gearbox/internal/framework/config"
"github.com/sarg3nt/gearbox/internal/framework/database"
"github.com/sarg3nt/gearbox/internal/framework/events"
"github.com/sarg3nt/gearbox/internal/framework/gear"
"github.com/sarg3nt/gearbox/internal/framework/handler"
gbmiddleware "github.com/sarg3nt/gearbox/internal/framework/middleware"
"github.com/sarg3nt/gearbox/internal/framework/models"
"github.com/sarg3nt/gearbox/internal/framework/services"
"github.com/sarg3nt/gearbox/internal/framework/services/alerts"
"github.com/sarg3nt/gearbox/internal/framework/services/crypto"
"github.com/sarg3nt/gearbox/internal/framework/services/email"
"github.com/sarg3nt/gearbox/internal/framework/handler"
gbmiddleware "github.com/sarg3nt/gearbox/internal/framework/middleware"
"github.com/sarg3nt/gearbox/internal/framework/models"

// Import gears - blank identifier triggers init() registration
_ "github.com/sarg3nt/gearbox/internal/gears/alerts"
Expand Down Expand Up @@ -383,13 +383,13 @@ func main() {
serverAdapter := services.NewServerAdapter(db, encryptor, servers, logger)

gearDeps := gear.Dependencies{
DB: db.GetDB(), // Get the underlying *sql.DB
Logger: logger,
EventHub: eventsAdapter,
Auth: authAdapter,
Servers: serverAdapter,
HTTPClient: http.DefaultClient,
Config: make(map[string]any),
DB: db.GetDB(), // Get the underlying *sql.DB
Logger: logger,
EventHub: eventsAdapter,
Auth: authAdapter,
Servers: serverAdapter,
HTTPClient: http.DefaultClient,
Config: make(map[string]any),
}

// Create gear manager (no store for now - will add database-backed store later)
Expand Down Expand Up @@ -540,7 +540,7 @@ func main() {
r.Group(func(r chi.Router) {
r.Use(authManager.RequireAuth)
r.Use(authManager.RequirePasswordChange) // Enforce password change before any other action
r.Use(h.InjectIntegrationStatus) // Add integration status to context for sidebar rendering
r.Use(h.InjectIntegrationStatus) // Add integration status to context for sidebar rendering
r.Use(middleware.Timeout(60 * time.Second))

// Logout
Expand Down Expand Up @@ -704,6 +704,14 @@ func main() {
r.Get("/{boxID}/metrics/source/{source}/summary", h.APIMetricsSourceSummaryHandler)
r.Get("/{boxID}/metrics/source/{source}/log-errors", h.APIMetricsSourceLogErrorsHandler)

// Per-user, per-box layout (issue #103). GET returns
// the user's saved GridStack positions for this box
// (or 204 = "use template defaults"); PATCH upserts;
// DELETE drops the saved row to reset to defaults.
r.Get("/{boxID}/metrics/layout", h.APIMetricsLayoutGetHandler)
r.Patch("/{boxID}/metrics/layout", h.APIMetricsLayoutPatchHandler)
r.Delete("/{boxID}/metrics/layout", h.APIMetricsLayoutDeleteHandler)

// Per-box capability manifest — exposes which agent gears probed
// available so the metrics gear (and future source-aware UI) can
// hide cards/KPIs that don't apply to this host.
Expand Down
6 changes: 6 additions & 0 deletions gearbox/internal/framework/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ func New(dbPath string, logger *slog.Logger) (*DB, error) {
return nil, fmt.Errorf("failed to initialize home schema: %w", err)
}

// Initialize metrics layouts (per-user, per-box GridStack
// positions for the metrics page — see issue #103).
if err := d.initMetricsLayoutsSchema(); err != nil {
return nil, fmt.Errorf("failed to initialize metrics layouts schema: %w", err)
}

// Run schema migrations AFTER all schemas are initialized
// (migrations may reference tables from any schema)
if err := d.runSchemaMigrations(); err != nil {
Expand Down
139 changes: 139 additions & 0 deletions gearbox/internal/framework/database/metrics_layouts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Package database — per-user, per-box layout persistence for the
// Metrics gear's GridStack-driven page (issue #103).
//
// The Metrics page renders a fixed set of chart cards (HAProxy
// stats, host metrics, plus capability-gated per-source cards).
// Operators rearrange/resize those cards in edit mode; the saved
// layout lives here. Storage shape: one JSON blob per (user, box)
// holding GridStack's `save()` output verbatim — array of
// `{id, x, y, w, h}` objects keyed by the stable tile DOM id
// (card-cpu, card-memory, card-nginx, …). JSON beats per-tile rows
// because the dashboard always reads/writes the whole layout at
// once and there's no querying inside it; the JSON column keeps
// the schema additive when we add tile shapes later.
package database

import (
"database/sql"
"errors"
"fmt"
"time"
)

// MetricsLayout is one persisted layout — the GridStack `save()`
// payload kept verbatim plus the (user, server) coordinates it
// belongs to.
//
// Layout is stored as []byte rather than a typed struct because
// GridStack's save format is the canonical form here: every field
// it emits round-trips back through `load()` unchanged. Typing it
// would mean keeping our Go shape in lockstep with GridStack's
// minor-version changes — not worth the maintenance cost for a
// JSON blob the dashboard never inspects.
type MetricsLayout struct {
UserID string `json:"user_id"`
ServerID string `json:"server_id"`
Layout []byte `json:"layout"`
UpdatedAt time.Time `json:"updated_at"`
}

// ErrNoMetricsLayout is returned by GetMetricsLayout when no row
// exists for (userID, serverID). Callers translate this into "use
// the default layout from the page template" rather than an HTTP
// 500 — first-visit users hit this path every time.
var ErrNoMetricsLayout = errors.New("no metrics_layouts row for the user/server pair")

// initMetricsLayoutsSchema creates the per-user-per-box layout
// table. Called from initSchema during database startup, alongside
// the other gear schemas. No foreign keys to users(id) intentionally:
// if a user is deleted we'd rather orphan their saved layouts than
// take an FK cascade — the orphans are tiny (one JSON blob each) and
// the simpler schema sidesteps a delete-cascade ordering question.
//
// (server_id is text because the dashboard's box / server addressing
// is string-keyed — server_id matches the convention used by
// stats_history, traffic_flows, etc.)
func (d *DB) initMetricsLayoutsSchema() error {
schema := `
CREATE TABLE IF NOT EXISTS metrics_layouts (
user_id TEXT NOT NULL,
server_id TEXT NOT NULL,
layout_json TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, server_id)
);

CREATE INDEX IF NOT EXISTS idx_metrics_layouts_user
ON metrics_layouts(user_id);
`
_, err := d.db.Exec(schema)
return err
}

// GetMetricsLayout returns the saved layout for one (user, box) pair.
// Returns ErrNoMetricsLayout when nothing's saved yet — callers
// (the handler) translate to "render the default layout from the
// page template" so first visits work without an extra round trip.
func (d *DB) GetMetricsLayout(userID string, serverID string) (*MetricsLayout, error) {
d.mu.RLock()
defer d.mu.RUnlock()

row := d.db.QueryRow(`
SELECT user_id, server_id, layout_json, updated_at
FROM metrics_layouts
WHERE user_id = ? AND server_id = ?`,
userID, serverID,
)
var (
out MetricsLayout
layout string
)
if err := row.Scan(&out.UserID, &out.ServerID, &layout, &out.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNoMetricsLayout
}
return nil, fmt.Errorf("scan metrics_layouts: %w", err)
}
out.Layout = []byte(layout)
return &out, nil
}

// SaveMetricsLayout upserts the layout for one (user, box) pair.
// `layoutJSON` must already be a valid JSON string — the handler
// is responsible for shape-checking before calling. We don't
// re-validate here because the canonical schema lives in
// GridStack's JS, and our agnostic-blob approach intentionally
// avoids tracking that schema in Go.
//
// updated_at is bumped server-side so the dashboard can show
// "last edited X minutes ago" without trusting client clocks.
func (d *DB) SaveMetricsLayout(userID string, serverID string, layoutJSON []byte) error {
d.mu.Lock()
defer d.mu.Unlock()

_, err := d.db.Exec(`
INSERT INTO metrics_layouts (user_id, server_id, layout_json, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, server_id) DO UPDATE SET
layout_json = excluded.layout_json,
updated_at = excluded.updated_at`,
userID, serverID, string(layoutJSON), time.Now().UTC(),
)
return err
}

// DeleteMetricsLayout removes the saved layout for one (user, box)
// pair, effectively reverting the user's view to the template
// default on next render. Used by the "reset to default" button in
// the edit-mode toolbar.
func (d *DB) DeleteMetricsLayout(userID string, serverID string) error {
d.mu.Lock()
defer d.mu.Unlock()

_, err := d.db.Exec(`
DELETE FROM metrics_layouts
WHERE user_id = ? AND server_id = ?`,
userID, serverID,
)
return err
}
141 changes: 141 additions & 0 deletions gearbox/internal/framework/database/metrics_layouts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package database

import (
"bytes"
"errors"
"testing"
)

func TestGetMetricsLayoutMissingReturnsSentinel(t *testing.T) {
// First read on an empty table must return ErrNoMetricsLayout so
// the handler can translate to 204 No Content — anything else
// would mask the "no saved layout, use template defaults" path.
db := setupTestDB(t)

_, err := db.GetMetricsLayout("user-1", "box-1")
if !errors.Is(err, ErrNoMetricsLayout) {
t.Errorf("expected ErrNoMetricsLayout, got %v", err)
}
}

func TestSaveAndGetMetricsLayout(t *testing.T) {
// Round-trip the JSON blob byte-for-byte — the storage layer is
// agnostic to GridStack's payload shape, so the bytes coming back
// out must match the bytes going in.
db := setupTestDB(t)

payload := []byte(`[{"id":"card-cpu","x":0,"y":0,"w":6,"h":4},{"id":"card-memory","x":6,"y":0,"w":6,"h":4}]`)
if err := db.SaveMetricsLayout("user-1", "box-1", payload); err != nil {
t.Fatalf("save: %v", err)
}

got, err := db.GetMetricsLayout("user-1", "box-1")
if err != nil {
t.Fatalf("get: %v", err)
}
if !bytes.Equal(got.Layout, payload) {
t.Errorf("layout round-trip = %s, want %s", got.Layout, payload)
}
if got.UserID != "user-1" || got.ServerID != "box-1" {
t.Errorf("metadata wrong: %+v", got)
}
if got.UpdatedAt.IsZero() {
t.Error("UpdatedAt should be set by SaveMetricsLayout")
}
}

func TestSaveMetricsLayoutUpserts(t *testing.T) {
// PK is (user_id, server_id); a second save for the same pair
// must replace the layout, not error or create a second row.
db := setupTestDB(t)

first := []byte(`[{"id":"card-cpu","x":0,"y":0,"w":6,"h":4}]`)
second := []byte(`[{"id":"card-cpu","x":6,"y":0,"w":6,"h":4}]`)

if err := db.SaveMetricsLayout("u", "b", first); err != nil {
t.Fatalf("first save: %v", err)
}
if err := db.SaveMetricsLayout("u", "b", second); err != nil {
t.Fatalf("second save: %v", err)
}
got, err := db.GetMetricsLayout("u", "b")
if err != nil {
t.Fatalf("get: %v", err)
}
if !bytes.Equal(got.Layout, second) {
t.Errorf("expected second save to win, got %s", got.Layout)
}
}

func TestMetricsLayoutIsolatedPerUserAndBox(t *testing.T) {
// (user_id, server_id) is the PK — different users on the same
// box, or the same user on different boxes, must keep separate
// layouts.
db := setupTestDB(t)

aliceBox1 := []byte(`[{"id":"card-cpu","x":1,"y":0,"w":6,"h":4}]`)
bobBox1 := []byte(`[{"id":"card-cpu","x":2,"y":0,"w":6,"h":4}]`)
aliceBox2 := []byte(`[{"id":"card-cpu","x":3,"y":0,"w":6,"h":4}]`)

if err := db.SaveMetricsLayout("alice", "box-1", aliceBox1); err != nil {
t.Fatal(err)
}
if err := db.SaveMetricsLayout("bob", "box-1", bobBox1); err != nil {
t.Fatal(err)
}
if err := db.SaveMetricsLayout("alice", "box-2", aliceBox2); err != nil {
t.Fatal(err)
}

cases := []struct {
user, box string
want []byte
}{
{"alice", "box-1", aliceBox1},
{"bob", "box-1", bobBox1},
{"alice", "box-2", aliceBox2},
}
for _, tc := range cases {
got, err := db.GetMetricsLayout(tc.user, tc.box)
if err != nil {
t.Errorf("(%s,%s) get: %v", tc.user, tc.box, err)
continue
}
if !bytes.Equal(got.Layout, tc.want) {
t.Errorf("(%s,%s) layout = %s, want %s", tc.user, tc.box, got.Layout, tc.want)
}
}
}

func TestDeleteMetricsLayoutResetsToDefault(t *testing.T) {
// Delete must drop the row so the subsequent Get returns
// ErrNoMetricsLayout — that's what drives the "fall back to
// template defaults" path after the operator hits Reset.
db := setupTestDB(t)

payload := []byte(`[{"id":"card-cpu","x":0,"y":0,"w":6,"h":4}]`)
if err := db.SaveMetricsLayout("u", "b", payload); err != nil {
t.Fatal(err)
}
if _, err := db.GetMetricsLayout("u", "b"); err != nil {
t.Fatalf("pre-delete get: %v", err)
}
if err := db.DeleteMetricsLayout("u", "b"); err != nil {
t.Fatalf("delete: %v", err)
}
_, err := db.GetMetricsLayout("u", "b")
if !errors.Is(err, ErrNoMetricsLayout) {
t.Errorf("expected ErrNoMetricsLayout after delete, got %v", err)
}
}

func TestDeleteMetricsLayoutIsNoOpWhenAbsent(t *testing.T) {
// Reset on a box with no saved layout shouldn't surface as an
// error — the user clicked the button; the desired state is
// "no row", which is already true.
db := setupTestDB(t)

if err := db.DeleteMetricsLayout("u", "never-saved"); err != nil {
t.Errorf("delete of absent row should be no-op, got %v", err)
}
}
Loading
Loading