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, soA ← Breads “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):
- resolves the caller’s readable-stack scope
(
StackService.ScopePatterns→ the readable set viaStackRepository.List); - loads every org stack (names + labels) and the org’s dependency graph already
reduced to distinct cross-stack pairs (
DependencyEdgeRepository.OrgStackPairs, bounded atmaxOrgStackPairs— 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; - aggregates with the pure
domain.AggregateTopology(drop endpoints whose stack is gone, group by the view’s label, decompose into cycles); - redacts stacks the viewer cannot read into stable
~restricted-Ntokens, 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-leveldagrelayout (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 labelledinferred— beside the pair count when a collapsed group arrow has one, as7 · 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 viagantrycd_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).