package service import ( "errors" "sort" "strings" "time" "github.com/itworx/pulse/internal/reverseproxy" ) type TopologyNode struct { ID string `json:"id"` Label string `json:"label"` State string `json:"state"` Reason string `json:"reason,omitempty"` Known bool `json:"known"` Kind string `json:"kind,omitempty"` SourceID string `json:"sourceId,omitempty"` } type TopologyEdge struct { ID string `json:"id"` From string `json:"from"` To string `json:"to"` RelationType string `json:"relationType"` SourceID string `json:"sourceId,omitempty"` Confidence float64 `json:"confidence"` Confirmed bool `json:"confirmed"` Inferred bool `json:"inferred"` } type Topology struct { ContractVersion string `json:"contractVersion"` ObservedAt time.Time `json:"observedAt"` CapabilityState string `json:"capabilityState"` ConfigurationState string `json:"configurationState"` Reason string `json:"reason,omitempty"` Nodes []TopologyNode `json:"nodes"` Edges []TopologyEdge `json:"edges"` TotalNodes int `json:"totalNodes"` TotalEdges int `json:"totalEdges"` Truncated bool `json:"truncated"` } func BuildTopology(snapshot Snapshot, dependencies []Dependency, maxNodes, maxEdges int) (Topology, error) { return BuildTopologyWithRoutes(snapshot, dependencies, nil, maxNodes, maxEdges) } func BuildTopologyWithRoutes(snapshot Snapshot, dependencies []Dependency, routes []reverseproxy.Route, maxNodes, maxEdges int) (Topology, error) { if maxNodes < 1 || maxNodes > 1000 || maxEdges < 1 || maxEdges > 2000 { return Topology{}, errors.New("topology bounds are outside safe limits") } if err := ValidateDependencyGraph(dependencies); err != nil { return Topology{}, err } services := append([]ServiceStatus(nil), snapshot.Services...) sort.SliceStable(services, func(i, j int) bool { return services[i].ID < services[j].ID }) dependencies = append([]Dependency(nil), dependencies...) SortDependencies(dependencies) nodes := make([]TopologyNode, 0, minInt(maxNodes, len(services))) byID := make(map[string]struct{}, maxNodes) for _, item := range services { if strings.TrimSpace(item.ID) == "" { continue } if len(nodes) >= maxNodes { break } nodes = append(nodes, TopologyNode{ID: item.ID, Label: item.Name, State: item.State, Reason: item.Reason, Known: true, Kind: "service", SourceID: item.SourceID}) byID[item.ID] = struct{}{} } truncated := len(services) > len(nodes) edges := make([]TopologyEdge, 0, minInt(maxEdges, len(dependencies)+len(routes))) for _, dependency := range dependencies { if len(edges) >= maxEdges { truncated = true break } for _, id := range []string{dependency.ServiceID, dependency.DependsOnServiceID} { if _, exists := byID[id]; exists { continue } if len(nodes) >= maxNodes { truncated = true continue } nodes = append(nodes, TopologyNode{ID: id, Label: id, State: StateUnknown, Reason: "service_not_in_current_snapshot", Known: false, Kind: "service"}) byID[id] = struct{}{} } if _, exists := byID[dependency.ServiceID]; !exists { truncated = true continue } if _, exists := byID[dependency.DependsOnServiceID]; !exists { truncated = true continue } edges = append(edges, TopologyEdge{ID: dependency.ID, From: dependency.ServiceID, To: dependency.DependsOnServiceID, RelationType: dependency.RelationType, SourceID: dependency.SourceID, Confidence: dependency.Confidence, Confirmed: dependency.Confirmed, Inferred: !dependency.Confirmed}) } routeItems := append([]reverseproxy.Route(nil), routes...) sort.SliceStable(routeItems, func(i, j int) bool { if routeItems[i].ID != routeItems[j].ID { return routeItems[i].ID < routeItems[j].ID } return routeItems[i].Hostname < routeItems[j].Hostname }) for _, route := range routeItems { if len(edges) >= maxEdges { truncated = true break } routeNodeID := "reverse-proxy:" + route.ID if _, exists := byID[routeNodeID]; !exists { if len(nodes) >= maxNodes { truncated = true continue } reason := "reverse_proxy_route" if !route.Enabled { reason = "reverse_proxy_route_disabled" } nodes = append(nodes, TopologyNode{ID: routeNodeID, Label: route.URL, State: StateUnknown, Reason: reason, Known: true, Kind: "reverse_proxy", SourceID: route.SourceID}) byID[routeNodeID] = struct{}{} } if strings.TrimSpace(route.TargetServiceID) == "" { continue } if _, exists := byID[route.TargetServiceID]; !exists { if len(nodes) >= maxNodes { truncated = true continue } nodes = append(nodes, TopologyNode{ID: route.TargetServiceID, Label: route.TargetServiceID, State: StateUnknown, Reason: "service_not_in_current_snapshot", Known: false, Kind: "service"}) byID[route.TargetServiceID] = struct{}{} } edges = append(edges, TopologyEdge{ID: "reverse-proxy:" + route.ID, From: routeNodeID, To: route.TargetServiceID, RelationType: RelationExposes, SourceID: route.SourceID, Confidence: 1, Confirmed: route.Overridden, Inferred: !route.Overridden}) } sort.SliceStable(nodes, func(i, j int) bool { return nodes[i].ID < nodes[j].ID }) return Topology{ContractVersion: ContractVersion, ObservedAt: snapshot.ObservedAt.UTC(), CapabilityState: snapshot.CapabilityState, ConfigurationState: snapshot.ConfigurationState, Reason: snapshot.Reason, Nodes: nodes, Edges: edges, TotalNodes: len(nodes), TotalEdges: len(edges), Truncated: truncated}, nil } func minInt(left, right int) int { if left < right { return left } return right }