Skip to content
25 changes: 5 additions & 20 deletions gearbox/internal/framework/dashboard/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,9 @@ func (s *Storage) CreateDefaultDashboard() error {
return nil
}

// Create default dashboard
// Create default dashboard with no widgets.
// Empty dashboards auto-redirect to edit mode with the widget palette open,
// so users can immediately start adding widgets.
Comment thread
sarg3nt marked this conversation as resolved.
Outdated
dashboard := &Dashboard{
Version: "1.0",
Name: "Dashboard",
Expand All @@ -221,25 +223,8 @@ func (s *Storage) CreateDefaultDashboard() error {
Columns: 12,
Gap: 4,
},
Widgets: []Widget{
{
ID: "welcome-1",
Type: "alert-banner",
Position: WidgetPosition{
Row: 1,
Column: 1,
Width: 12,
Height: "auto",
},
Config: map[string]interface{}{
"severity": "info",
"message": "Welcome to Gearbox! This is your default dashboard. You can customize it by adding widgets.",
"icon": "info",
"dismissible": true,
},
},
},
Slug: "dashboard",
Widgets: []Widget{},
Slug: "dashboard",
}

// Save dashboard
Expand Down
4 changes: 2 additions & 2 deletions gearbox/internal/framework/database/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ type Plugin struct {
SortOrder int `json:"sort_order"` // Order for display in UI (lower = higher priority)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
UpdatedBy *int64 `json:"updated_by,omitempty"`
UpdatedBy *string `json:"updated_by,omitempty"`
}

// DefaultPlugins returns the default integration configurations for a server.
Expand Down Expand Up @@ -302,7 +302,7 @@ func (d *DB) initPluginsSchema() error {
sort_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_by INTEGER,
updated_by TEXT,
UNIQUE(server_id, name),
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
);
Expand Down
5 changes: 4 additions & 1 deletion gearbox/internal/framework/handler/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,14 @@ func (h *DashboardHandler) EditDashboardPage(w http.ResponseWriter, r *http.Requ
// Get available widgets
widgets := h.widgetRegistry.List()

// Check if palette should be auto-opened (e.g., redirected from empty dashboard)
openPalette := r.URL.Query().Get("open_palette") == "1"

// Get user from context
user, _ := auth.GetUserFromContext(r.Context())

// Render editor with live content
component := pages.DashboardEditorPage(dash, content, widgets, user, r.URL.Path)
component := pages.DashboardEditorPage(dash, content, widgets, user, r.URL.Path, openPalette)
Comment thread
sarg3nt marked this conversation as resolved.
if err := component.Render(r.Context(), w); err != nil {
h.logger.Error("failed to render dashboard editor", "error", err)
http.Error(w, "Failed to render page", http.StatusInternalServerError)
Expand Down
5 changes: 3 additions & 2 deletions gearbox/internal/framework/handler/haproxy_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"

Expand Down Expand Up @@ -136,8 +137,8 @@ func (h *Handler) HAProxyBoxCreatePost(w http.ResponseWriter, r *http.Request) {
}
}

// Redirect to servers list page
http.Redirect(w, r, "/settings/boxes", http.StatusSeeOther)
// Redirect to plugins page so user can enable plugins for this new server
http.Redirect(w, r, "/settings/plugins?server="+url.QueryEscape(server.BoxID), http.StatusSeeOther)
}

// HAProxyBoxEditPage shows the form for editing a server.
Expand Down
57 changes: 55 additions & 2 deletions gearbox/internal/framework/templates/layouts/base.templ
Original file line number Diff line number Diff line change
Expand Up @@ -1213,10 +1213,12 @@ templ Sidebar(user *models.User, currentPath string) {
</div>
</div>

<!-- Navigation -->
<!-- Navigation (only show items when plugins are enabled) -->
<nav class="flex-1 py-4">
<ul id="sidebar-nav-list" class="space-y-1">
@SidebarLink("/", "Dashboard", SidebarIconDashboard(), currentPath)
if hasAnyEnabledIntegration(ctx) {
@SidebarLink("/", "Dashboard", SidebarIconDashboard(), currentPath)
}
Comment thread
sarg3nt marked this conversation as resolved.
Outdated
@OrderedIntegrationLinks(currentPath)
</ul>
</nav>
Expand Down Expand Up @@ -1570,6 +1572,57 @@ templ SidebarLinkWithIntegration(href string, label string, icon templ.Component
}
}

// hasAnyEnabledIntegration checks if any integration (plugin) is enabled in the current context.
func hasAnyEnabledIntegration(ctx context.Context) bool {
integrations, ok := auth.GetPluginOrderFromContext(ctx)
if !ok {
return false
}
for _, integration := range integrations {
if integration.Enabled {
return true
}
}
return false
}

// integrationPath returns the URL path for a given integration name.
func integrationPath(name string) string {
switch name {
case "metrics":
return "/history"
case "logs":
return "/logs"
case "services":
return "/services"
case "certificates":
return "/certificates"
case "traffic":
return "/traffic"
case "alerts":
return "/alerts"
case "os_updates":
return "/os-updates"
default:
return "/"
}
}

// firstEnabledIntegrationPath returns the URL path for the first enabled integration.
// Returns "/" if no integrations are enabled.
func firstEnabledIntegrationPath(ctx context.Context) string {
integrations, ok := auth.GetPluginOrderFromContext(ctx)
if !ok {
return "/"
}
for _, integration := range integrations {
if integration.Enabled {
return integrationPath(integration.Name)
}
}
return "/"
}

Comment thread
sarg3nt marked this conversation as resolved.
// canViewIntegration checks if the user has permission to view an integration.
// Maps integration names to component View permissions.
func canViewIntegration(ctx context.Context, integrationName string) bool {
Expand Down
14 changes: 11 additions & 3 deletions gearbox/internal/framework/templates/pages/dashboard_editor.templ
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
)

// DashboardEditorPage renders the visual dashboard editor with live widget preview
templ DashboardEditorPage(dash *dashboard.Dashboard, content templ.Component, widgets []*widget.WidgetDefinition, user *models.User, currentPath string) {
templ DashboardEditorPage(dash *dashboard.Dashboard, content templ.Component, widgets []*widget.WidgetDefinition, user *models.User, currentPath string, openPalette bool) {
@layouts.Base("Edit Dashboard", user, currentPath) {
<!-- Widget Palette CSS -->
<link rel="stylesheet" href="/static/css/dashboard/palette.css"/>
Expand Down Expand Up @@ -49,8 +49,8 @@ templ DashboardEditorPage(dash *dashboard.Dashboard, content templ.Component, wi
</div>
</div>

<!-- Widget Palette Panel (collapsible) -->
<div id="widget-palette-panel" class="hidden mb-4 bg-white dark:bg-slate-900 border border-gray-300 dark:border-slate-600 rounded-lg shadow-lg">
<!-- Widget Palette Panel (collapsible, auto-opened when dashboard is empty) -->
<div id="widget-palette-panel" class={ widgetPaletteClass(openPalette) }>
<!-- Fixed Header -->
<div class="p-4 border-b border-gray-200 dark:border-slate-700 sticky top-0 bg-white dark:bg-slate-900 z-10">
<div class="flex items-center justify-between mb-3">
Expand Down Expand Up @@ -393,6 +393,14 @@ templ DashboardEditorScript(dash *dashboard.Dashboard) {
}

// Helper functions

func widgetPaletteClass(openPalette bool) string {
if openPalette {
return "mb-4 bg-white dark:bg-slate-900 border border-gray-300 dark:border-slate-600 rounded-lg shadow-lg"
}
return "hidden mb-4 bg-white dark:bg-slate-900 border border-gray-300 dark:border-slate-600 rounded-lg shadow-lg"
}

func getWidgetStyle(pos dashboard.WidgetPosition) string {
return fmt.Sprintf("grid-column: span %d;", pos.Width)
}
Expand Down
10 changes: 10 additions & 0 deletions gearbox/internal/framework/templates/pages/plugins.templ
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ templ PluginsPage(user *models.User, servers []models.BoxConfig, currentServerID
}
</div>

<!-- Done button to navigate to main dashboard -->
<div class="mt-8 flex justify-end">
<a
href="/"
class="px-6 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
Done
</a>
</div>

<script src="/static/js/plugins/plugins-page.js"></script>
</div>
}
Expand Down
46 changes: 38 additions & 8 deletions gearbox/internal/plugins/dashboard/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package dashboard
import (
"net/http"

"github.com/sarg3nt/gearbox/internal/framework/auth"
"github.com/sarg3nt/gearbox/internal/framework/plugin"
"github.com/sarg3nt/gearbox/internal/framework/services"
)
Expand All @@ -19,8 +20,7 @@ func NewHandlers(deps plugin.Dependencies) *Handlers {
}

// OverviewPage serves the main dashboard page.
// It redirects to the default dashboard which uses the widget system.
// If no servers are configured, redirects to the servers settings page.
// It redirects to the first enabled plugin page, or to setup pages if not configured.
func (h *Handlers) OverviewPage(w http.ResponseWriter, r *http.Request) {
// Get enabled servers using the ServerAdapter
serverAdapter, ok := h.deps.Servers.(*services.ServerAdapter)
Expand All @@ -37,14 +37,44 @@ func (h *Handlers) OverviewPage(w http.ResponseWriter, r *http.Request) {
return
}

// Redirect to the default dashboard (widget-based dashboard)
http.Redirect(w, r, "/dashboards/dashboard", http.StatusSeeOther)
// Redirect to the first enabled plugin page if available
if integrations, ok := auth.GetPluginOrderFromContext(r.Context()); ok {
for _, integration := range integrations {
if integration.Enabled {
http.Redirect(w, r, integrationPathFromName(integration.Name), http.StatusSeeOther)
return
}
}
}

// No plugins enabled — send to plugins settings page
http.Redirect(w, r, "/settings/plugins", http.StatusSeeOther)
}

// integrationPathFromName maps an integration name to its URL path.
func integrationPathFromName(name string) string {
switch name {
case "metrics":
return "/history"
case "logs":
return "/logs"
case "services":
return "/services"
case "certificates":
return "/certificates"
case "traffic":
return "/traffic"
case "alerts":
return "/alerts"
case "os_updates":
return "/os-updates"
Comment thread
sarg3nt marked this conversation as resolved.
default:
return "/dashboards/dashboard"
}
Comment thread
sarg3nt marked this conversation as resolved.
}

// StatusGridPage serves the status grid page.
// For now, redirects to the main dashboard. In the future, this could
// render a different dashboard layout focused on status grid view.
// For now, redirects to the overview page which handles routing.
func (h *Handlers) StatusGridPage(w http.ResponseWriter, r *http.Request) {
// Redirect to main dashboard for now
http.Redirect(w, r, "/dashboards/dashboard", http.StatusSeeOther)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
11 changes: 11 additions & 0 deletions gearbox/static/js/dashboard/palette.js
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,17 @@ function toggleWidgetPalette() {
}
}

// Auto-initialize palette if it's already visible on page load
// (e.g., when redirected from an empty dashboard with open_palette=1)
document.addEventListener('DOMContentLoaded', function() {
const panel = document.getElementById('widget-palette-panel');
if (panel && !panel.classList.contains('hidden')) {
const dashboardElement = document.getElementById('dashboard-grid-editor');
const boxID = dashboardElement ? dashboardElement.dataset.boxId : '';
initializeWidgetPalette(boxID);
Comment thread
sarg3nt marked this conversation as resolved.
}
});

// Export functions for use in other scripts
window.initializeWidgetPalette = initializeWidgetPalette;
window.toggleWidgetPalette = toggleWidgetPalette;