Skip to content

Commit 405d586

Browse files
committed
feat(dashboard): implement remote contributor registration and management with auto-filtering for local services
1 parent 40b2ddc commit 405d586

8 files changed

Lines changed: 934 additions & 39 deletions

File tree

extensions/dashboard/contributor/manifest.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package contributor
22

3-
import "github.com/a-h/templ"
3+
import (
4+
"encoding/json"
5+
6+
"github.com/a-h/templ"
7+
)
48

59
// Manifest describes a contributor's capabilities, navigation, widgets, settings, and more.
610
type Manifest struct {
@@ -188,3 +192,25 @@ type AuthPageDef struct {
188192
Icon string `json:"icon"` // optional icon name
189193
Priority int `json:"priority"` // sort order
190194
}
195+
196+
// ManifestsEqual compares two manifests for change detection (used by remote
197+
// refresh paths). Implemented via JSON serialisation to avoid a reflect-deep
198+
// compare over templ.Component fields, which are nil on remote-fetched
199+
// manifests anyway and would compare unequally otherwise.
200+
func ManifestsEqual(a, b *Manifest) bool {
201+
if a == nil || b == nil {
202+
return a == b
203+
}
204+
205+
ab, err := json.Marshal(a)
206+
if err != nil {
207+
return false
208+
}
209+
210+
bb, err := json.Marshal(b)
211+
if err != nil {
212+
return false
213+
}
214+
215+
return string(ab) == string(bb)
216+
}

extensions/dashboard/discovery/integration.go

Lines changed: 67 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package discovery
22

33
import (
44
"context"
5-
"encoding/json"
65
"fmt"
76
"sync"
87
"time"
@@ -69,11 +68,19 @@ type Integration struct {
6968
proxyTimeout time.Duration
7069
logger forge.Logger
7170

71+
// localServiceIDs holds service IDs that belong to the host process. Any
72+
// instance whose ID matches is filtered out before registration so the
73+
// dashboard never tries to fetch a contributor manifest from itself
74+
// (memory-backed discovery surfaces the host's own service registration
75+
// alongside any peers it learns about).
76+
localServiceIDs map[string]struct{}
77+
7278
// tracked keeps track of remote contributors we've registered
7379
tracked map[string]string // service ID → contributor name
7480

75-
stopCh chan struct{}
76-
wg sync.WaitGroup
81+
stopOnce sync.Once
82+
stopCh chan struct{}
83+
wg sync.WaitGroup
7784
}
7885

7986
// NewIntegration creates a new discovery integration.
@@ -97,8 +104,30 @@ func NewIntegration(
97104
}
98105
}
99106

107+
// LocalServiceIDProvider is the optional interface a discovery service
108+
// implements when it can report the host process's own service ID. Used so
109+
// memory-backed discovery doesn't make the dashboard try to fetch a
110+
// contributor manifest from itself.
111+
type LocalServiceIDProvider interface {
112+
LocalServiceID() string
113+
}
114+
100115
// Start begins polling the discovery service for dashboard contributors.
101116
func (i *Integration) Start(ctx context.Context) {
117+
// Auto-filter the host's own service ID when the discovery service can
118+
// report it. Memory-backed discovery surfaces the local registration in
119+
// every poll; without this guard the dashboard would attempt to fetch a
120+
// contributor manifest from itself.
121+
if provider, ok := i.discovery.(LocalServiceIDProvider); ok {
122+
if id := provider.LocalServiceID(); id != "" {
123+
i.IgnoreLocalService(id)
124+
125+
i.logger.Debug("discovery integration: filtering local service ID",
126+
forge.F("local_service_id", id),
127+
)
128+
}
129+
}
130+
102131
i.wg.Add(1)
103132

104133
go i.pollLoop(ctx)
@@ -109,12 +138,34 @@ func (i *Integration) Start(ctx context.Context) {
109138
)
110139
}
111140

112-
// Stop stops the discovery integration.
141+
// Stop stops the discovery integration. Safe to call multiple times — the
142+
// dashboard extension's Stop() can fire more than once (forge calls Stop on
143+
// the extension and the DI container Stop walks the same instance again).
113144
func (i *Integration) Stop() {
114-
close(i.stopCh)
115-
i.wg.Wait()
145+
i.stopOnce.Do(func() {
146+
close(i.stopCh)
147+
i.wg.Wait()
148+
149+
i.logger.Info("discovery integration stopped")
150+
})
151+
}
152+
153+
// IgnoreLocalService instructs the integration to skip a service ID when
154+
// scanning discovery. This is used to filter the host's own self-registration
155+
// out of dashboard contributor candidates. Must be called before Start.
156+
func (i *Integration) IgnoreLocalService(serviceID string) {
157+
if serviceID == "" {
158+
return
159+
}
160+
161+
i.mu.Lock()
162+
defer i.mu.Unlock()
163+
164+
if i.localServiceIDs == nil {
165+
i.localServiceIDs = make(map[string]struct{})
166+
}
116167

117-
i.logger.Info("discovery integration stopped")
168+
i.localServiceIDs[serviceID] = struct{}{}
118169
}
119170

120171
// pollLoop periodically checks discovery for services with the dashboard tag.
@@ -165,6 +216,14 @@ func (i *Integration) reconcile(ctx context.Context) {
165216
continue
166217
}
167218

219+
i.mu.Lock()
220+
_, isLocal := i.localServiceIDs[inst.ID]
221+
i.mu.Unlock()
222+
223+
if isLocal {
224+
continue
225+
}
226+
168227
foundIDs[inst.ID] = true
169228

170229
i.mu.Lock()
@@ -269,7 +328,7 @@ func (i *Integration) refreshRemoteService(ctx context.Context, inst *ServiceIns
269328
}
270329

271330
current, ok := i.registry.GetManifest(contribName)
272-
if ok && manifestsEqual(current, manifest) {
331+
if ok && contributor.ManifestsEqual(current, manifest) {
273332
return
274333
}
275334

@@ -303,26 +362,6 @@ func (i *Integration) refreshRemoteService(ctx context.Context, inst *ServiceIns
303362
)
304363
}
305364

306-
// manifestsEqual compares two manifests via JSON serialisation. This avoids a
307-
// deep reflect equality check over templ.Component fields (which are nil on
308-
// remote-fetched manifests anyway).
309-
func manifestsEqual(a, b *contributor.Manifest) bool {
310-
if a == nil || b == nil {
311-
return a == b
312-
}
313-
314-
ab, err := json.Marshal(a)
315-
if err != nil {
316-
return false
317-
}
318-
319-
bb, err := json.Marshal(b)
320-
if err != nil {
321-
return false
322-
}
323-
324-
return string(ab) == string(bb)
325-
}
326365

327366
// TrackedCount returns the number of tracked remote contributors.
328367
func (i *Integration) TrackedCount() int {

extensions/dashboard/discovery/integration_test.go

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,22 @@ import (
2020
)
2121

2222
// fakeDiscovery is a deterministic DiscoveryService used to drive reconcile.
23+
// When localID is set the fake also implements LocalServiceIDProvider so the
24+
// auto-filter path in Start can be exercised.
2325
type fakeDiscovery struct {
2426
mu sync.Mutex
2527
services []string
2628
instances map[string][]*ServiceInstance // keyed by service name
29+
localID string
30+
}
31+
32+
// LocalServiceID makes fakeDiscovery satisfy LocalServiceIDProvider when
33+
// localID is non-empty.
34+
func (f *fakeDiscovery) LocalServiceID() string {
35+
f.mu.Lock()
36+
defer f.mu.Unlock()
37+
38+
return f.localID
2739
}
2840

2941
func newFakeDiscovery() *fakeDiscovery {
@@ -359,24 +371,79 @@ func TestIntegration_SkipsServicesWithoutTag(t *testing.T) {
359371
}
360372
}
361373

374+
func TestIntegration_AutoFiltersLocalServiceID(t *testing.T) {
375+
manifest := &contributor.Manifest{Name: "authsome", Layout: "extension"}
376+
ms := newManifestServer(t, manifest)
377+
378+
registry := contributor.NewContributorRegistry("/dashboard")
379+
380+
disc := newFakeDiscovery()
381+
disc.localID = "Portal-self-1"
382+
disc.set("Portal", instanceFor(t, ms, "Portal-self-1", true))
383+
disc.set("identity", instanceFor(t, ms, "identity-1", true))
384+
385+
integ := NewIntegration(
386+
disc,
387+
registry,
388+
"forge-dashboard-contributor",
389+
60*time.Second,
390+
2*time.Second,
391+
forge.NewNoopLogger(),
392+
)
393+
394+
// Mimic production wiring — Start() reads LocalServiceIDProvider and
395+
// calls IgnoreLocalService internally. Drive that path here, then
396+
// reconcile.
397+
ctx, cancel := context.WithCancel(context.Background())
398+
defer cancel()
399+
400+
integ.Start(ctx)
401+
defer integ.Stop()
402+
403+
// Force a reconcile pass; IgnoreLocalService was set inside Start.
404+
integ.reconcile(ctx)
405+
406+
if _, ok := registry.GetManifest("authsome"); !ok {
407+
t.Fatalf("identity should still be registered (only Portal-self-1 is filtered)")
408+
}
409+
410+
// The Portal-self-1 service used the same manifest server, so if the
411+
// filter weren't active, the registry would have a contributor named
412+
// "authsome" registered against Portal's service ID. Since the manifest
413+
// shares one name across services, we instead assert via the tracked
414+
// map that Portal-self-1 is NOT being polled.
415+
integ.mu.Lock()
416+
_, portalTracked := integ.tracked["Portal-self-1"]
417+
_, identityTracked := integ.tracked["identity-1"]
418+
integ.mu.Unlock()
419+
420+
if portalTracked {
421+
t.Errorf("Portal-self-1 was tracked despite local-service filter")
422+
}
423+
424+
if !identityTracked {
425+
t.Errorf("identity-1 should be tracked")
426+
}
427+
}
428+
362429
func TestManifestsEqual_DiffersOnNavChange(t *testing.T) {
363430
a := &contributor.Manifest{Name: "x", Nav: []contributor.NavItem{{Path: "/"}}}
364431
b := &contributor.Manifest{Name: "x", Nav: []contributor.NavItem{{Path: "/"}}}
365432
c := &contributor.Manifest{Name: "x", Nav: []contributor.NavItem{{Path: "/"}, {Path: "/users"}}}
366433

367-
if !manifestsEqual(a, b) {
434+
if !contributor.ManifestsEqual(a, b) {
368435
t.Errorf("identical manifests should be equal")
369436
}
370437

371-
if manifestsEqual(a, c) {
438+
if contributor.ManifestsEqual(a, c) {
372439
t.Errorf("manifests with different nav should not be equal")
373440
}
374441

375-
if !manifestsEqual(nil, nil) {
442+
if !contributor.ManifestsEqual(nil, nil) {
376443
t.Errorf("nil/nil should be equal")
377444
}
378445

379-
if manifestsEqual(a, nil) {
446+
if contributor.ManifestsEqual(a, nil) {
380447
t.Errorf("nil/non-nil should not be equal")
381448
}
382449
}

0 commit comments

Comments
 (0)