Skip to content

Commit 10c70c9

Browse files
authored
Merge pull request #9 from sarg3nt/fix/haproxy-sidebar-nav
fix: add HAProxy plugin to sidebar navigation
2 parents ec83ffe + f31094e commit 10c70c9

10 files changed

Lines changed: 101 additions & 132 deletions

File tree

gearbox/cmd/server/main.go

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ import (
3535
// Import plugins - blank identifier triggers init() registration
3636
_ "github.com/sarg3nt/gearbox/internal/plugins/alerts"
3737
_ "github.com/sarg3nt/gearbox/internal/plugins/certificates"
38-
dashboardPlugin "github.com/sarg3nt/gearbox/internal/plugins/dashboard"
38+
_ "github.com/sarg3nt/gearbox/internal/plugins/dashboard"
39+
_ "github.com/sarg3nt/gearbox/internal/plugins/haproxy"
3940
_ "github.com/sarg3nt/gearbox/internal/plugins/logs"
4041
_ "github.com/sarg3nt/gearbox/internal/plugins/metrics"
4142
_ "github.com/sarg3nt/gearbox/internal/plugins/services"
@@ -371,11 +372,6 @@ func main() {
371372
log.Fatalf("Failed to register core widgets: %v", err)
372373
}
373374

374-
// Register HAProxy-specific widgets
375-
if err := dashboardPlugin.RegisterHAProxyWidgets(widgetRegistry); err != nil {
376-
log.Fatalf("Failed to register HAProxy widgets: %v", err)
377-
}
378-
379375
dashboardHandler := handler.NewDashboardHandler(
380376
dashboardStorage,
381377
widgetRegistry,
@@ -631,6 +627,7 @@ func main() {
631627
r.Get("/config/firewall/{boxID}", h.FirewallConfigPage)
632628

633629
// HTMX partial routes (return HTML fragments)
630+
r.Get("/htmx/sidebar-nav", h.SidebarNavPartialHandler)
634631
r.Get("/htmx/{boxID}/status-summary", h.StatusSummaryPartialHandler)
635632
r.Get("/htmx/{boxID}/backend-grid", h.BackendGridPartialHandler)
636633
r.Get("/htmx/{boxID}/stats", h.StatsPartialHandler)

gearbox/internal/framework/handler/dashboard.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,16 @@ func (h *DashboardHandler) ViewDashboard(w http.ResponseWriter, r *http.Request)
120120
return
121121
}
122122

123+
// Resolve server ID: use stored value, or fall back to first enabled server
124+
serverID := h.boxID
125+
if serverID == "" {
126+
if boxes, err := h.db.GetEnabledBoxes(); err == nil && len(boxes) > 0 {
127+
serverID = boxes[0].BoxID
128+
}
129+
}
130+
123131
// Render dashboard
124-
content, err := h.renderer.Render(r.Context(), dash, h.boxID, h.userID)
132+
content, err := h.renderer.Render(r.Context(), dash, serverID, h.userID)
125133
if err != nil {
126134
h.logger.Error("failed to render dashboard", "slug", slug, "error", err)
127135
http.Error(w, "Failed to render dashboard", http.StatusInternalServerError)

gearbox/internal/framework/handler/pages.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import (
44
"net/http"
55

66
"github.com/go-chi/chi/v5"
7-
"github.com/sarg3nt/gearbox/internal/framework/database"
87
"github.com/sarg3nt/gearbox/internal/framework/auth"
8+
"github.com/sarg3nt/gearbox/internal/framework/database"
99
"github.com/sarg3nt/gearbox/internal/framework/models"
10+
"github.com/sarg3nt/gearbox/internal/framework/templates/layouts"
1011
"github.com/sarg3nt/gearbox/internal/framework/templates/pages"
1112
)
1213

@@ -251,6 +252,15 @@ func (h *Handler) MetricsPartialHandler(w http.ResponseWriter, r *http.Request)
251252
}
252253
}
253254

255+
// SidebarNavPartialHandler returns just the sidebar nav list HTML for HTMX swapping.
256+
func (h *Handler) SidebarNavPartialHandler(w http.ResponseWriter, r *http.Request) {
257+
component := layouts.OrderedIntegrationLinks("")
258+
if err := component.Render(r.Context(), w); err != nil {
259+
h.logger.Error("Failed to render sidebar nav partial", "error", err)
260+
http.Error(w, "Internal server error", http.StatusInternalServerError)
261+
}
262+
}
263+
254264
// AdminDisabledEntitiesPage serves the admin page for managing disabled entities.
255265
func (h *Handler) AdminDisabledEntitiesPage(w http.ResponseWriter, r *http.Request) {
256266
user, _ := auth.GetUserFromContext(r.Context())

gearbox/internal/framework/templates/layouts/base.templ

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1443,6 +1443,12 @@ templ SidebarIconOSUpdates() {
14431443
</svg>
14441444
}
14451445

1446+
templ SidebarIconHAProxy() {
1447+
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1448+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
1449+
</svg>
1450+
}
1451+
14461452
templ SidebarLinkWithBadge(href string, label string, icon templ.Component, currentPath string, badgeID string) {
14471453
<li>
14481454
if isActivePath(href, currentPath) {
@@ -1586,6 +1592,8 @@ func hasAnyEnabledIntegration(ctx context.Context) bool {
15861592
// integrationPath returns the URL path for a given integration name.
15871593
func integrationPath(name string) string {
15881594
switch name {
1595+
case "haproxy":
1596+
return "/haproxy"
15891597
case "metrics":
15901598
return "/history"
15911599
case "logs":
@@ -1655,6 +1663,9 @@ templ OrderedIntegrationLinks(currentPath string) {
16551663
// If integrations list is empty (no server configured), show nothing
16561664
} else {
16571665
// Fallback to default order only if context doesn't exist at all (backwards compatibility)
1666+
if canViewIntegration(ctx, "haproxy") {
1667+
@SidebarLinkWithIntegration("/haproxy", "HAProxy", SidebarIconHAProxy(), currentPath, "haproxy")
1668+
}
16581669
if canViewIntegration(ctx, "metrics") {
16591670
@SidebarLinkWithIntegration("/history", "Metrics", SidebarIconMetrics(), currentPath, "metrics")
16601671
}
@@ -1682,6 +1693,8 @@ templ OrderedIntegrationLinks(currentPath string) {
16821693
// renderIntegrationLink renders a single integration link based on the integration name.
16831694
templ renderIntegrationLink(name string, currentPath string) {
16841695
switch name {
1696+
case "haproxy":
1697+
@SidebarLinkDraggable("/haproxy", "HAProxy", SidebarIconHAProxy(), currentPath, "haproxy")
16851698
case "metrics":
16861699
@SidebarLinkDraggable("/history", "Metrics", SidebarIconMetrics(), currentPath, "metrics")
16871700
case "logs":

gearbox/internal/framework/templates/pages/plugins.templ

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,10 @@ templ PluginsPage(user *models.User, servers []models.BoxConfig, currentServerID
7272
}
7373
</div>
7474

75-
<!-- Done button to navigate to main dashboard -->
75+
<!-- Done button to navigate to first enabled plugin page -->
7676
<div class="mt-8 flex justify-end">
7777
<a
78+
id="plugins-done-btn"
7879
href="/"
7980
class="px-6 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
8081
>

gearbox/internal/framework/widget/widget.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,11 @@ func (w *WidgetInstance) Render(ctx *WidgetRenderContext) (templ.Component, erro
121121
}
122122
}
123123

124+
// If widget config has no box_id set, use the server ID from the render context
125+
if boxID, _ := w.Config["box_id"].(string); boxID == "" && ctx.ServerID != "" {
126+
w.Config["box_id"] = ctx.ServerID
127+
}
128+
124129
// Render the widget
125130
return w.Definition.Renderer(ctx.Context, w.Config, data)
126131
}

gearbox/internal/plugins/haproxy/plugin.go

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ func (p *Plugin) PredefinedDashboards() []plugin.DashboardDefinition {
103103
DefaultEnabled: true,
104104
YAML: `version: "1.0"
105105
name: HAProxy Overview
106-
description: HAProxy monitoring dashboard with status summary, backend grid, and system metrics
106+
description: HAProxy monitoring dashboard with status summary and backend grid
107107
created_by: plugin:haproxy
108108
plugin_name: haproxy
109109
editable: false
@@ -131,16 +131,6 @@ widgets:
131131
default_collapsed: false
132132
server_id: ""
133133
show_filters: true
134-
- id: system-metrics-1
135-
type: system-metrics
136-
position:
137-
row: 3
138-
column: 1
139-
width: 12
140-
height: auto
141-
config:
142-
server_id: ""
143-
show_title: true
144134
`,
145135
},
146136
}

gearbox/internal/plugins/haproxy/widgets.go

Lines changed: 0 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,6 @@ func RegisterHAProxyWidgets(registry *widget.Registry) error {
1414
widgets := []*widget.WidgetDefinition{
1515
statusSummaryDoughnutsDefinition(),
1616
backendStatusGridDefinition(),
17-
systemMetricsWidgetDefinition(),
18-
serviceStatusWidgetDefinition(),
19-
certificateWarningsWidgetDefinition(),
2017
}
2118

2219
for _, w := range widgets {
@@ -100,92 +97,3 @@ func backendStatusGridDefinition() *widget.WidgetDefinition {
10097
}
10198
}
10299

103-
// systemMetricsWidgetDefinition creates the System Metrics widget.
104-
// Shows CPU, Memory, Disk, and Network metrics.
105-
func systemMetricsWidgetDefinition() *widget.WidgetDefinition {
106-
return &widget.WidgetDefinition{
107-
Type: "system-metrics",
108-
Name: "System Metrics",
109-
Description: "CPU, memory, disk, and network metrics with real-time updates",
110-
Category: "system-monitoring",
111-
PluginName: "metrics",
112-
Icon: "cpu",
113-
ConfigSchema: widget.ConfigSchema{
114-
Properties: map[string]widget.Property{
115-
"box_id": {
116-
Type: "string",
117-
Description: "Server ID to monitor (optional, uses current server if not specified)",
118-
},
119-
"show_title": {
120-
Type: "boolean",
121-
Description: "Show section title",
122-
Default: true,
123-
},
124-
},
125-
Required: []string{},
126-
},
127-
Renderer: func(ctx context.Context, config map[string]any, data any) (templ.Component, error) {
128-
boxID := getStringFromConfig(config, "box_id", "")
129-
return SystemMetricsWidgetHTMX(boxID), nil
130-
},
131-
}
132-
}
133-
134-
// serviceStatusWidgetDefinition creates the Service Status widget.
135-
// Shows status of HAProxy, gearbox-agent, nftables, and fail2ban services.
136-
func serviceStatusWidgetDefinition() *widget.WidgetDefinition {
137-
return &widget.WidgetDefinition{
138-
Type: "service-status",
139-
Name: "Service Status",
140-
Description: "Status indicators for critical system services",
141-
Category: "system-monitoring",
142-
PluginName: "services",
143-
Icon: "shield",
144-
ConfigSchema: widget.ConfigSchema{
145-
Properties: map[string]widget.Property{
146-
"box_id": {
147-
Type: "string",
148-
Description: "Server ID to monitor (optional, uses current server if not specified)",
149-
},
150-
},
151-
Required: []string{},
152-
},
153-
Renderer: func(ctx context.Context, config map[string]any, data any) (templ.Component, error) {
154-
boxID := getStringFromConfig(config, "box_id", "")
155-
// Service status is included in the metrics widget, so just show metrics
156-
return SystemMetricsWidgetHTMX(boxID), nil
157-
},
158-
}
159-
}
160-
161-
// certificateWarningsWidgetDefinition creates the Certificate Warnings widget.
162-
// Shows warnings for expired or expiring SSL/TLS certificates.
163-
func certificateWarningsWidgetDefinition() *widget.WidgetDefinition {
164-
return &widget.WidgetDefinition{
165-
Type: "certificate-warnings",
166-
Name: "Certificate Warnings",
167-
Description: "Warnings for expired or expiring SSL/TLS certificates",
168-
Category: "security-monitoring",
169-
PluginName: "certificates",
170-
Icon: "alert-triangle",
171-
ConfigSchema: widget.ConfigSchema{
172-
Properties: map[string]widget.Property{
173-
"box_id": {
174-
Type: "string",
175-
Description: "Server ID to monitor (optional, uses current server if not specified)",
176-
},
177-
"hide_when_empty": {
178-
Type: "boolean",
179-
Description: "Hide widget when there are no warnings",
180-
Default: true,
181-
},
182-
},
183-
Required: []string{},
184-
},
185-
Renderer: func(ctx context.Context, config map[string]any, data any) (templ.Component, error) {
186-
// Certificate warnings are loaded via JavaScript on the overview page
187-
// For now, return an empty component - this will be implemented with proper data sources later
188-
return templ.Raw("<!-- Certificate warnings loaded via JavaScript -->"), nil
189-
},
190-
}
191-
}

gearbox/internal/plugins/haproxy/widgets_htmx.templ

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -45,23 +45,3 @@ templ HAProxyBackendGridWidgetHTMX(serverID string) {
4545
</div>
4646
}
4747

48-
// SystemMetricsWidgetHTMX renders a placeholder that loads via HTMX
49-
templ SystemMetricsWidgetHTMX(serverID string) {
50-
<div
51-
class="system-metrics-widget"
52-
hx-get={ fmt.Sprintf("/htmx/%s/metrics", serverID) }
53-
hx-trigger="load"
54-
hx-swap="innerHTML"
55-
>
56-
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-300 dark:border-slate-600 p-6">
57-
<div class="animate-pulse">
58-
<div class="h-4 bg-gray-300 dark:bg-slate-700 rounded w-1/3 mb-4"></div>
59-
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
60-
<div class="h-24 bg-gray-300 dark:bg-slate-700 rounded"></div>
61-
<div class="h-24 bg-gray-300 dark:bg-slate-700 rounded"></div>
62-
<div class="h-24 bg-gray-300 dark:bg-slate-700 rounded"></div>
63-
</div>
64-
</div>
65-
</div>
66-
</div>
67-
}

gearbox/static/js/plugins/plugins-page.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ const serverSelector = document.getElementById('server-selector');
191191
if (serverSelector && window.BoxSelector) {
192192
window.BoxSelector.initFromSelect(serverSelector);
193193
}
194+
// Set Done button to first enabled plugin on load
195+
updateDoneButton();
194196
});
195197

196198
// Toggle integration via AJAX - allows multiple toasts without page reload
@@ -236,6 +238,10 @@ fetch('/settings/plugins/' + encodeURIComponent(pluginName) + '/toggle?server='
236238
if (window.showToast) {
237239
window.showToast(data.message || (displayName + ' has been ' + (newEnabled ? 'enabled' : 'disabled')), 'success', 3000);
238240
}
241+
// Refresh sidebar nav to reflect enabled/disabled state
242+
refreshSidebar();
243+
// Update Done button to point to first enabled plugin
244+
updateDoneButton();
239245
}
240246
})
241247
.catch(error => {
@@ -300,3 +306,54 @@ if (card) {
300306
}
301307
}
302308
}
309+
310+
// Plugin name to URL path mapping
311+
const pluginPaths = {
312+
'haproxy': '/haproxy',
313+
'metrics': '/history',
314+
'logs': '/logs',
315+
'services': '/services',
316+
'certificates': '/certificates',
317+
'traffic': '/traffic',
318+
'alerts': '/alerts',
319+
'os_updates': '/os-updates'
320+
};
321+
322+
// Refresh sidebar nav by fetching updated HTML from server
323+
function refreshSidebar() {
324+
const navList = document.getElementById('sidebar-nav-list');
325+
if (!navList) return;
326+
327+
fetch('/htmx/sidebar-nav')
328+
.then(response => {
329+
if (!response.ok) throw new Error('Failed to fetch sidebar');
330+
return response.text();
331+
})
332+
.then(html => {
333+
navList.innerHTML = html;
334+
})
335+
.catch(err => {
336+
console.error('Failed to refresh sidebar:', err);
337+
});
338+
}
339+
340+
// Update Done button href to point to first enabled plugin
341+
function updateDoneButton() {
342+
const doneBtn = document.getElementById('plugins-done-btn');
343+
if (!doneBtn) return;
344+
345+
// Read current toggle states from the plugin cards
346+
const cards = document.querySelectorAll('.plugin-card');
347+
for (const card of cards) {
348+
const toggle = card.querySelector('[data-enabled]');
349+
if (toggle && toggle.dataset.enabled === 'true') {
350+
const name = card.dataset.pluginName;
351+
if (name && pluginPaths[name]) {
352+
doneBtn.href = pluginPaths[name];
353+
return;
354+
}
355+
}
356+
}
357+
// No enabled plugins, fall back to root
358+
doneBtn.href = '/';
359+
}

0 commit comments

Comments
 (0)