Skip to content

Commit 7737d5f

Browse files
sarg3ntclaude
andauthored
feat(#79): HAProxy dashboard stack-layout redesign (#85)
* docs(#79): add HAProxy dashboard stack-layout research Captures current-state analysis, best-practice survey (Portainer / Lens / ArgoCD / Datadog / HAProxy stats / Grafana / NN/g), three layout options with ASCII mockups, and a recommendation (stack-as-section + compact backend cards). Includes live screenshot of the current dashboard with the duplicated 6-container Arr stack and live-inspection notes on the bottom-of-page widgets (which render correctly in current dev — likely already fixed since the issue was filed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(#79): hoist stack topology out of repeated backend cards Previously every backend card re-rendered the full docker-compose stack's container topology with one container highlighted. A 5-route Arr stack with 6 containers drew the same 6-container diagram 5 times — visual noise, wasted vertical space, easy to misread as duplicate state. Group backends by parsed `BackendGroupInfo.Group` (the docker-compose project prefix). Multi-backend stacks now render as a labeled `<section>` with: - "STACK" badge + capitalized stack name + "N backends · M containers" - The merged container topology rendered ONCE (IsBackend OR'd across all members so every actually-routed container shows blue) - A grid of compact backend cards below — no inline ContainerDiagram Singleton non-hardware backends and hardware backends fold into trailing synthetic Standalone / Hardware buckets, which render as a flat card grid with no wrapper chrome — preserving the existing look-and-feel for trivial cases (vaultwarden, gitea, IPMI hosts, etc). Tooltip text in BackendCard gets a new "Stack Member" branch so cards inside a stack section no longer mislabel themselves as "Hardware Service". Hardware check moved to the top of the chain to avoid the empty-containers fall-through. Verified against the live light-hugger agent: - HTML for /htmx/light-hugger/stats contains exactly two `<section>` blocks: Arr (5 backends · 6 containers) and Qbittorrent (2 backends · 2 containers). All 26 backends present in the DOM. - arr_sonarr_backend's card no longer contains a ContainerDiagram (the bg-blue-100 / bg-gray-100 chips for sibling containers are gone) - vaultwarden_vaultwarden_backend's standalone card still renders its single container topology as before. - `go build -tags dev`, `go build`, `go vet -tags dev ./...`, and `go vet ./...` all pass. See docs/research/haproxy-dashboard-stack-layout.md for the full research, layout-option comparison, and recommendation that this commit implements (Option 1: stack-as-section + compact backend cards). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: enhance StackSection rendering for multi-backend stacks and add stackBucketHeader and stackTopologyChip templates * feat: enhance GetUserPermissions to prefer user from context for improved auth handling * feat: refactor StackGroup and StackSection to consolidate standalone and hardware backends into a flat bucket * chore(#79): drop dead singleContainerDiagram; document final layout singleContainerDiagram was reachable only when BackendCard called ContainerDiagram with len(containers) == 1, but the per-card diagram is now only drawn for multi-container topology (len > 1) — the singleton case renders the IP inline in the card header instead. The len == 1 branch inside ContainerDiagram became unreachable, and the helper that backed it was dead. Removed both; ContainerDiagram now collapses to "VPN gateway or simple multi-container," which matches its actual scope. Also extended docs/research/haproxy-dashboard-stack-layout.md with a "What shipped" section so the doc isn't stuck at "no code changes yet" — captures the refinements that landed after the initial Option 1 design (headerless flat bucket, inStackSection title flag, cursor-help removal, ACTIVE pill suppression, tooltip anchor flip, baseline alignment). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2dbed9b commit 7737d5f

7 files changed

Lines changed: 812 additions & 61 deletions

File tree

docs/research/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ This directory contains active design documents and architectural research for t
1616
- Comparison of architectural approaches
1717
- Implementation recommendations and trade-offs
1818

19+
### Gear UX
20+
21+
- **[haproxy-dashboard-stack-layout.md](haproxy-dashboard-stack-layout.md)**
22+
- Redesign proposal for the HAProxy dashboard's stack-with-multiple-containers layout ([#79](https://github.com/sarg3nt/gearbox/issues/79))
23+
- Best-practice survey (Portainer, Lens, ArgoCD, Datadog, HAProxy stats, Grafana, NN/g)
24+
- Three layout options with ASCII mockups, recommendation, and implementation outline
25+
1926
## Purpose
2027

2128
These documents serve as:

docs/research/haproxy-dashboard-stack-layout.md

Lines changed: 513 additions & 0 deletions
Large diffs are not rendered by default.
1.09 MB
Loading

gearbox/internal/framework/auth/auth.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,7 +638,16 @@ func (m *Manager) GetDB() *database.DB {
638638
}
639639

640640
// GetUserPermissions retrieves permissions for the current user.
641+
//
642+
// Prefers the user placed in the request context by RequireAuth (which is
643+
// also where the dev-loopback bypass injects its synthetic user). Falls back
644+
// to the session-cookie lookup so callers that build a request without the
645+
// auth middleware in front of them — e.g. SSE setup, tests — still resolve.
641646
func (m *Manager) GetUserPermissions(r *http.Request) (*models.UserPermissions, error) {
647+
if user, ok := GetUserFromContext(r.Context()); ok && user != nil {
648+
return m.db.GetUserPermissions(user.ID)
649+
}
650+
642651
user, err := m.GetUser(r)
643652
if err != nil {
644653
return nil, err

gearbox/internal/framework/templates/components/container_diagram.templ

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,19 @@ import (
55
"strings"
66
)
77

8-
// ContainerDiagram displays all containers in a backend with their relationships
8+
// ContainerDiagram renders a multi-container backend's topology — a stack
9+
// of boxes, blue for the container HAProxy routes to and grey for siblings
10+
// that share its network or support it. Intended only for backends with
11+
// real topology to show (VPN gateway or multi-container service); single-
12+
// container backends render their IP inline in the card header instead
13+
// and do not call this component. See BackendCard's `len(containers) > 1`
14+
// guard.
915
templ ContainerDiagram(containers []models.Container, serverAddress string) {
1016
if len(containers) > 0 {
1117
<div class="mt-3">
1218
if hasVPNGatewayPattern(containers) {
13-
<!-- VPN Gateway Pattern: Stacked visualization -->
1419
@vpnGatewayDiagram(containers, serverAddress)
15-
} else if len(containers) == 1 {
16-
<!-- Single Container: Taller box with IP on second line -->
17-
@singleContainerDiagram(containers[0], serverAddress)
1820
} else {
19-
<!-- Multi Container Pattern: Stacked visualization -->
2021
@simpleContainerDiagram(containers, serverAddress)
2122
}
2223
</div>
@@ -57,20 +58,6 @@ func getNestedContainers(containers []models.Container) []models.Container {
5758
return nested
5859
}
5960

60-
// singleContainerDiagram renders a single container with IP on second line
61-
templ singleContainerDiagram(container models.Container, serverAddress string) {
62-
<div class="px-4 py-3 bg-blue-100 dark:bg-blue-900 border-2 border-blue-500 rounded-lg">
63-
<div class="text-sm font-semibold text-blue-900 dark:text-blue-100">
64-
{ container.Name }
65-
</div>
66-
if serverAddress != "" {
67-
<div class="text-xs text-blue-700 dark:text-blue-300 font-mono mt-1">
68-
{ serverAddress }
69-
</div>
70-
}
71-
</div>
72-
}
73-
7461
// vpnGatewayDiagram renders the VPN gateway pattern as stacked rows
7562
templ vpnGatewayDiagram(containers []models.Container, serverAddress string) {
7663
if gateway := getGatewayContainer(containers); gateway != nil {

gearbox/internal/framework/templates/pages/helpers.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,140 @@ func GetServiceURL(backendName string, metadata *models.Metadata) string {
350350
return "https://" + backendMeta.Hostname
351351
}
352352

353+
// StackGroup is a group of HAProxy backends that share a single
354+
// docker-compose stack (a "stack" being the leading underscore-separated
355+
// segment of the backend name, e.g. `arr` in `arr_sonarr_backend`).
356+
//
357+
// Existed to fix #79: previously every backend card re-rendered the full
358+
// stack topology with one container highlighted, so a 5-backend Arr stack
359+
// drew 5 copies of the same 6-container diagram. With StackGroup the
360+
// topology is hoisted to the stack level and rendered once; each backend
361+
// card inside the stack is compact (no per-card diagram).
362+
//
363+
// Singleton stacks (one backend per group) and hardware backends are
364+
// collected into a trailing flat bucket (IsFlat=true) so the UI doesn't
365+
// grow section chrome for trivial cases — those still render as flat
366+
// cards. Their type (container vs hardware) is conveyed by the per-card
367+
// CT / HW badge, so a section label would only repeat that information.
368+
type StackGroup struct {
369+
StackName string // the parsed group key, e.g. "arr" — empty for the synthetic flat bucket
370+
DisplayName string // capitalized label used in the section header
371+
IsFlat bool // synthetic bucket for singleton + hardware backends; rendered headerless
372+
Backends []models.Backend // backends in this stack, sorted alphabetically
373+
Topology []models.Container // merged container list — IsBackend OR'd across members
374+
}
375+
376+
// GroupBackendsByStack groups backends within a frontend by their parsed
377+
// `BackendGroupInfo.Group` (the docker-compose project prefix). Multi-
378+
// backend non-hardware groups become their own StackGroup with a merged
379+
// container topology; singleton non-hardware groups and all hardware
380+
// backends fold into a single trailing flat bucket (IsFlat=true). The
381+
// returned slice is ordered: real stacks alphabetically, then the flat
382+
// bucket — so the visually heavy multi-container stacks lead and the
383+
// cheap rows trail. Within the flat bucket, singletons come before
384+
// hardware so the container-y stuff stays grouped.
385+
//
386+
// metadata may be nil; in that case Topology is empty for every group.
387+
func GroupBackendsByStack(backends []models.Backend, metadata *models.Metadata) []StackGroup {
388+
type bucket struct {
389+
stackName string
390+
backends []models.Backend
391+
containers []models.Container
392+
isBackend map[string]bool // container name -> true if it serves at least one backend in this group
393+
}
394+
395+
byKey := map[string]*bucket{}
396+
keyOrder := []string{}
397+
398+
for _, b := range backends {
399+
info := ParseBackendName(b.Name)
400+
key := info.Group
401+
if key == "" {
402+
key = "__none__"
403+
}
404+
bk, ok := byKey[key]
405+
if !ok {
406+
bk = &bucket{stackName: info.Group, isBackend: map[string]bool{}}
407+
byKey[key] = bk
408+
keyOrder = append(keyOrder, key)
409+
}
410+
bk.backends = append(bk.backends, b)
411+
if metadata == nil {
412+
continue
413+
}
414+
bm := metadata.GetMetadataForBackend(b.Name)
415+
if bm == nil {
416+
continue
417+
}
418+
for _, c := range bm.Containers {
419+
if c.IsBackend {
420+
bk.isBackend[c.Name] = true
421+
}
422+
// dedupe by name, preserve first-seen order
423+
already := false
424+
for _, existing := range bk.containers {
425+
if existing.Name == c.Name {
426+
already = true
427+
break
428+
}
429+
}
430+
if !already {
431+
bk.containers = append(bk.containers, c)
432+
}
433+
}
434+
}
435+
436+
// Apply the OR-merged IsBackend flag back onto the topology list and
437+
// sort each bucket's backends for stable display.
438+
for _, bk := range byKey {
439+
for i := range bk.containers {
440+
bk.containers[i].IsBackend = bk.isBackend[bk.containers[i].Name]
441+
}
442+
sort.Slice(bk.backends, func(i, j int) bool { return bk.backends[i].Name < bk.backends[j].Name })
443+
}
444+
445+
// Partition into real multi-backend stacks vs the trailing flat
446+
// bucket. Real stacks are alphabetized. The flat bucket collects
447+
// singletons first (in keyOrder), then hardware (also keyOrder) —
448+
// hardware trails so a visual scan from top to bottom moves from
449+
// container-y stuff to the on-iron services.
450+
var (
451+
realStacks []StackGroup
452+
flatBackends []models.Backend
453+
hardwareLast []models.Backend
454+
)
455+
456+
for _, key := range keyOrder {
457+
bk := byKey[key]
458+
if key == "hardware" {
459+
hardwareLast = append(hardwareLast, bk.backends...)
460+
continue
461+
}
462+
if len(bk.backends) <= 1 {
463+
flatBackends = append(flatBackends, bk.backends...)
464+
continue
465+
}
466+
display := bk.stackName
467+
if display != "" {
468+
display = strings.ToUpper(display[:1]) + display[1:]
469+
}
470+
realStacks = append(realStacks, StackGroup{
471+
StackName: bk.stackName,
472+
DisplayName: display,
473+
Backends: bk.backends,
474+
Topology: bk.containers,
475+
})
476+
}
477+
sort.Slice(realStacks, func(i, j int) bool { return realStacks[i].StackName < realStacks[j].StackName })
478+
479+
flatBackends = append(flatBackends, hardwareLast...)
480+
481+
result := realStacks
482+
if len(flatBackends) > 0 {
483+
result = append(result, StackGroup{IsFlat: true, Backends: flatBackends})
484+
}
485+
return result
486+
}
353487

354488
// StatusSummary holds summary statistics for status doughnuts
355489
type StatusSummary struct {

0 commit comments

Comments
 (0)