Skip to content
GantryCD

Stack Group Topology

A high-level map of how the logical groups of an org’s stacks connect via their cross-stack dependencies. Where the dependency graph shows individual stacks and resources, the topology graph zooms out: boxes are groups of stacks, arrows are the dependencies between them, and expanding a box reveals the stacks inside it and the arrows they participate in.

It is a read-side projection — no new tables, no schema changes, no runner work. Groups are a label convention; the arrows come from the same dependency_edges the dependency graph uses. See datastores.md for those stores.

Grouping stacks with labels

A stack joins a group by adding a label:

gantrycd:group:<view> = <name>
  • <view> (the label key suffix) names a partition of the org’s stacks — an independent way of slicing them. It is a free-form slug.
  • <name> (the label value) is the group the stack belongs to in that view.

Because the view is the map key, a stack is in at most one group per view, so the boxes never overlap. A stack can belong to different views at once:

gantrycd:group:all  = networking   # the "all" view: this stack is in group "networking"
gantrycd:group:team = platform     # the "team" view: this stack is in group "platform"

Views are independent — there is no enforced hierarchy between them. The all view is a naming convention for “the canonical full picture”; it carries no special backend meaning beyond being the default the selector lands on when present. Validation lives in pkg/domain/labels.go (the group namespace); StackGroupsFromLabels derives the view → name map (mirrored in web/src/utils/labels.ts). Group names must be non-empty.

The graph

The graph is served per view (the UI shows one at a time) and per viewer (redacted to readable stacks), on demand — there is no cache or sweep.

  • Group box — one per distinct group name in the view. Collapsed, it shows the group and its stack count; expanded, it reveals its member stacks.
  • Loose node — a stack with no group label for the selected view renders as its own top-level node beside the boxes.
  • Edges — the raw cross-stack dependencies (dependent → dependency, arrow on the dependency, so A ← B reads “B depends on A”). The frontend resolves each edge’s endpoints against what is expanded:
    • both endpoints in collapsed groups → one group → group arrow, weighted by how many distinct stack pairs it sums (two stacks each declaring three dependencies on one stack count 2, not 6 — the wire carries the pair projection, not the declarations behind it);
    • an endpoint inside an expanded group → the arrow attaches to that specific member stack, so you can see which stack reaches out to another group (a boundary-crossing edge);
    • an intra-group dependency appears only when that group is expanded (member ↔ member); collapsed, it is hidden inside the box.

Clicking a stack deep-links to its Dependencies tab (/{orgId}/stacks/{stackId}/dependencies).

Where it appears

  • Topology (/{org}/topology?group_view=<view>) — org-wide.
  • Stacks → Graph tab (?tab=graph&group_view=<view>) — the same graph on the stacks list.
  • Stacks list → Group filter — a view→group picker that narrows the list. It is a friendly front for the existing label filter: it emits the gantrycd:group:<view>=<name> pair, matched by JSONB containment. One group per view (OR-within-a-view is not yet supported); the list filter is not applied to the Graph tab (a partial filter would draw misleading arrows).

Backend

GET /api/v1/orgs/{org_id}/explore/topology?view=<view>, gated by WithUserCanListStacks() — same scope as the stack listing.

TopologyService.GetTopology (internal/backend/services/topology_service.go):

  1. resolves the caller’s readable-stack scope (StackService.ScopePatterns → the readable set via StackRepository.List);
  2. loads every org stack (names + labels) and the org’s dependency graph already reduced to distinct cross-stack pairs (DependencyEdgeRepository.OrgStackPairs, bounded at maxOrgStackPairs — a bound on backend memory). It carries no trigger policy: this is a connectivity view, and one value per stack pair cannot say what actually happens, since a stack-level declaration shadows resource-level ones and a resource-scoped policy only fires when that resource changed. Per-edge policy lives on the per-stack Dependencies view;
  3. aggregates with the pure domain.AggregateTopology (drop endpoints whose stack is gone, group by the view’s label, decompose into cycles);
  4. redacts stacks the viewer cannot read into stable ~restricted-N tokens, mirroring the dependency-graph redaction: a group with at least one readable member keeps its name (only its unreadable members are masked); a group with no readable member collapses to an opaque box whose members and size are hidden.

If the org’s stack-pair set exceeds the budget, the response carries the groups and stacks but no edges and truncated: true; the UI says connections could not be computed rather than implying the groups are unconnected.

Frontend

The graph is a React Flow view (like the dependency graph), lazily loaded:

  • web/src/components/topology/groupTopologyLayout.ts — pure, unit-tested two-level dagre layout (top-level boxes/loose nodes; a mini-layout inside each expanded box) plus the expand-aware edge resolver.
  • web/src/components/topology/GroupTopologyGraph.tsx — the canvas, expand/collapse state, deep-linking, and re-fit on toggle.
  • web/src/components/topology/TopologyPanel.tsx — the shared wrapper (view selector + loading/empty/disabled states) used by both surfaces.

Limitations

  • Arrows cover declared dependencies (gantrycd:dependency: comments) and inferred ones — a data source reading a cloud object another stack manages. An arrow whose every underlying pair is inferred is drawn dotted and labelled inferred — beside the pair count when a collapsed group arrow has one, as 7 · inferred; one declaration anywhere in the bundle makes it an ordinary arrow, because at this granularity “nobody declared any of this” is the only provenance statement that stays true. A dependency that leaves no shared cloud object behind — an output reference via gantrycd_state_outputs, a pure ordering constraint — appears only if someone declared it.
  • Untagged stacks show as loose nodes; in a large, partly-tagged org that can be noisy (a “hide unconnected” toggle is a possible future refinement).
  • Group filtering is one group per view; scoping the Graph tab to the list filter is deferred (it needs server-side re-aggregation to keep weights correct).