diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index 52b25e7..255f312 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -21,6 +21,43 @@ func (h *Handler) handleCanvasGet(w http.ResponseWriter, _ *http.Request) { locals, _ := h.Store.ListLocals() remotes, _ := h.Store.ListRemotes() links, _ := h.Store.ListLinks() + + // Merge ring topology entries not in the local store so every node's + // canvas shows the FULL cluster picture. This is a read-time merge — + // nothing is written back to SQLite (avoids the per-forward stop/start + // issue that broke the old topology-derived-canvas model). + if h.Ring != nil { + snap := h.Ring.Snapshot() + localNames := make(map[string]bool, len(locals)) + for _, l := range locals { + localNames[l.Name] = true + } + remoteNames := make(map[string]bool, len(remotes)) + for _, r := range remotes { + remoteNames[r.Name] = true + } + type linkKey struct{ local, remote string; port int } + linkSeen := make(map[linkKey]bool, len(links)) + for _, l := range links { + linkSeen[linkKey{l.Local, l.Remote, l.RemotePort}] = true + } + for _, t := range snap.Topology { + if !localNames[t.Local.Name] { + locals = append(locals, t.Local) + localNames[t.Local.Name] = true + } + if !remoteNames[t.Remote.Name] { + remotes = append(remotes, t.Remote) + remoteNames[t.Remote.Name] = true + } + k := linkKey{t.Link.Local, t.Link.Remote, t.Link.RemotePort} + if !linkSeen[k] { + links = append(links, t.Link) + linkSeen[k] = true + } + } + } + writeJSON(w, http.StatusOK, canvasData{Locals: locals, Remotes: remotes, Links: links}) } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 1b5b69e..666c3d9 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -237,10 +237,12 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { } type topoKey struct{ local, remote string; port int } ownerOf := map[topoKey]string{} + topoEntries := []*cluster.TopoEntry{} // full topology for canvas/status merge selfID := h.SelfAddr if h.Ring != nil { snap := h.Ring.Snapshot() selfID = snap.SelfID + topoEntries = snap.Topology for _, e := range snap.Topology { k := topoKey{e.Local.Name, e.Remote.Name, e.Link.RemotePort} if _, ok := ownerOf[k]; !ok { @@ -327,7 +329,10 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { LocalProto string `json:"localProto,omitempty"` } links, _ := h.Store.ListLinks() - forwards := make([]forwardStatus, 0, len(links)) + forwards := make([]forwardStatus, 0, len(links)+len(topoEntries)) + // Track which (local,remote,remotePort) triples are already in forwards + // so the topology-merge loop below doesn't duplicate them. + seenFwd := map[topoKey]bool{} for _, ln := range links { loc, ok := localByName[ln.Local] if !ok { @@ -350,8 +355,24 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { fs.OwnerID = owner fs.Active = !ln.Disabled && inTopo } + seenFwd[topoKey{ln.Local, ln.Remote, ln.RemotePort}] = true forwards = append(forwards, fs) } + // Merge cluster forwards from ring topology that aren't in the local store. + // This makes every node's status page show the FULL cluster picture, not + // just the forwards this node happens to have in its SQLite links table. + for _, t := range topoEntries { + k := topoKey{t.Local.Name, t.Remote.Name, t.Link.RemotePort} + if seenFwd[k] { + continue + } + forwards = append(forwards, forwardStatus{ + Local: t.Local.Name, Remote: t.Remote.Name, RemotePort: t.Link.RemotePort, + LocalOnly: false, Kind: "remote", OwnerID: t.OwnerID, Active: t.Active, + LocalIP: t.Local.IP, LocalPort: t.Local.Port, LocalProto: t.Local.Protocol, + }) + seenFwd[k] = true + } resp := map[string]any{ "version": "0.1.0",