- Add nginx proxy blocks for /api/v1/release-orchestrator/, /api/v1/release-control/, /api/v2/releases/, /api/v1/releases/, /api/v1/registries/ in Dockerfile.console - All release UI calls now reach JobEngine (401 not 404) - Registry search reaches Scanner service - Pipeline page uses ReleaseManagementStore (real API, no mock data) - Deployment wizard uses BundleOrganizerApi for create/seal - Inline version/hotfix creation in deployment wizard wired to API - Version detail shows "not found" error instead of blank screen - Version wizard has promotion lane + duplicate component detection - Sprint plan for 41 missing backend endpoints created Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
259 lines
11 KiB
C#
259 lines
11 KiB
C#
namespace StellaOps.ElkSharp;
|
|
|
|
internal static class ElkEdgeChannels
|
|
{
|
|
internal static Dictionary<string, EdgeChannel> ComputeEdgeChannels(
|
|
IReadOnlyCollection<ElkEdge> edges,
|
|
IReadOnlyDictionary<string, ElkPositionedNode> positionedNodes,
|
|
ElkLayoutDirection direction,
|
|
IReadOnlyDictionary<string, LayerBoundary> layerBoundariesByNodeId)
|
|
{
|
|
var channels = new Dictionary<string, EdgeChannel>(edges.Count, StringComparer.Ordinal);
|
|
var backwardEdges = new List<ElkEdge>();
|
|
var forwardEdgesBySource = new Dictionary<string, List<ElkEdge>>(StringComparer.Ordinal);
|
|
var outgoingCounts = edges
|
|
.GroupBy(edge => edge.SourceNodeId, StringComparer.Ordinal)
|
|
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
|
|
|
|
foreach (var edge in edges)
|
|
{
|
|
var source = positionedNodes[edge.SourceNodeId];
|
|
var target = positionedNodes[edge.TargetNodeId];
|
|
var isBackward = direction == ElkLayoutDirection.LeftToRight
|
|
? (target.X + (target.Width / 2d)) < (source.X + (source.Width / 2d))
|
|
: (target.Y + (target.Height / 2d)) < (source.Y + (source.Height / 2d));
|
|
|
|
if (isBackward)
|
|
{
|
|
backwardEdges.Add(edge);
|
|
}
|
|
else
|
|
{
|
|
if (!forwardEdgesBySource.TryGetValue(edge.SourceNodeId, out var list))
|
|
{
|
|
list = [];
|
|
forwardEdgesBySource[edge.SourceNodeId] = list;
|
|
}
|
|
|
|
list.Add(edge);
|
|
}
|
|
}
|
|
|
|
var backwardGroups = backwardEdges
|
|
.GroupBy(edge => edge.TargetNodeId, StringComparer.Ordinal)
|
|
.SelectMany(targetGroup =>
|
|
{
|
|
var families = targetGroup
|
|
.GroupBy(edge => ElkEdgeChannelBands.ResolveLaneFamilyKey(edge.Label), StringComparer.Ordinal)
|
|
.OrderBy(group => ElkEdgeChannelBands.ResolveLaneFamilyPriority(group.First().Label))
|
|
.ThenBy(group => group.Key, StringComparer.Ordinal)
|
|
.ToArray();
|
|
return families.Select((familyGroup, familyIndex) => new
|
|
{
|
|
TargetNodeId = targetGroup.Key,
|
|
FamilyKey = familyGroup.Key,
|
|
FamilyIndex = familyIndex,
|
|
FamilyCount = families.Length,
|
|
Edges = familyGroup.ToArray(),
|
|
SharedOuterX = familyGroup.Max(edge =>
|
|
{
|
|
var s = positionedNodes[edge.SourceNodeId];
|
|
return s.X + s.Width;
|
|
}) + 56d,
|
|
Priority = ElkEdgeChannelBands.ResolveLaneFamilyPriority(familyGroup.First().Label),
|
|
Span = familyGroup.Max(edge =>
|
|
{
|
|
var s = positionedNodes[edge.SourceNodeId];
|
|
var t = positionedNodes[edge.TargetNodeId];
|
|
return direction == ElkLayoutDirection.LeftToRight
|
|
? Math.Abs((s.X + (s.Width / 2d)) - (t.X + (t.Width / 2d)))
|
|
: Math.Abs((s.Y + (s.Height / 2d)) - (t.Y + (t.Height / 2d)));
|
|
}),
|
|
});
|
|
})
|
|
.OrderByDescending(group => group.Span)
|
|
.ThenBy(group => group.Priority)
|
|
.ThenBy(group => group.TargetNodeId, StringComparer.Ordinal)
|
|
.ToArray();
|
|
|
|
var reservedHorizontalBands = new List<double>();
|
|
for (var laneIndex = 0; laneIndex < backwardGroups.Length; laneIndex++)
|
|
{
|
|
var group = backwardGroups[laneIndex];
|
|
var useSourceCollector = string.Equals(group.FamilyKey, "repeat", StringComparison.Ordinal);
|
|
var preferredOuterY = double.NaN;
|
|
if (direction == ElkLayoutDirection.LeftToRight && useSourceCollector)
|
|
{
|
|
var lowerCorridorY = ElkEdgeChannelCorridors.ResolveBackwardLowerCorridorY(group.Edges, positionedNodes);
|
|
preferredOuterY = !double.IsNaN(lowerCorridorY)
|
|
? lowerCorridorY
|
|
: ElkEdgeChannelCorridors.ResolveBackwardCorridorY(group.Edges, positionedNodes);
|
|
}
|
|
|
|
if (!double.IsNaN(preferredOuterY))
|
|
{
|
|
reservedHorizontalBands.Add(preferredOuterY);
|
|
}
|
|
|
|
foreach (var edge in group.Edges)
|
|
{
|
|
channels[edge.Id] = new EdgeChannel(
|
|
EdgeRouteMode.BackwardOuter,
|
|
laneIndex,
|
|
group.FamilyIndex,
|
|
group.FamilyCount,
|
|
0,
|
|
1,
|
|
0,
|
|
1,
|
|
-1,
|
|
0,
|
|
group.SharedOuterX,
|
|
preferredOuterY,
|
|
useSourceCollector,
|
|
double.NaN);
|
|
}
|
|
}
|
|
|
|
var forwardEdgesByTarget = new Dictionary<string, List<ElkEdge>>(StringComparer.Ordinal);
|
|
foreach (var sourceEdges in forwardEdgesBySource.Values)
|
|
{
|
|
foreach (var edge in sourceEdges)
|
|
{
|
|
if (!forwardEdgesByTarget.TryGetValue(edge.TargetNodeId, out var list))
|
|
{
|
|
list = [];
|
|
forwardEdgesByTarget[edge.TargetNodeId] = list;
|
|
}
|
|
|
|
list.Add(edge);
|
|
}
|
|
}
|
|
|
|
var sinkBandsByEdgeId = new Dictionary<string, (int BandIndex, int BandCount, double SharedOuterX, double PreferredOuterY)>(StringComparer.Ordinal);
|
|
if (direction == ElkLayoutDirection.LeftToRight)
|
|
{
|
|
var reservedSinkBands = new List<double>(reservedHorizontalBands);
|
|
foreach (var targetEdges in forwardEdgesByTarget)
|
|
{
|
|
var targetNode = positionedNodes[targetEdges.Key];
|
|
var isSinkTarget = string.Equals(targetNode.Kind, "End", StringComparison.OrdinalIgnoreCase)
|
|
|| !outgoingCounts.ContainsKey(targetEdges.Key);
|
|
if (!isSinkTarget || targetEdges.Value.Count < 2)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var sinkBands = targetEdges.Value
|
|
.GroupBy(edge => ElkEdgeChannelBands.ResolveLaneFamilyKey(edge.Label), StringComparer.Ordinal)
|
|
.OrderBy(group => ElkEdgeChannelBands.ResolveSinkLanePriority(group.First().Label))
|
|
.ThenBy(group => group.Key, StringComparer.Ordinal)
|
|
.ToArray();
|
|
for (var bandIndex = 0; bandIndex < sinkBands.Length; bandIndex++)
|
|
{
|
|
var sinkBandEdges = sinkBands[bandIndex].ToArray();
|
|
var sharedOuterX = sinkBands[bandIndex].Max(edge =>
|
|
{
|
|
var s = positionedNodes[edge.SourceNodeId];
|
|
return s.X + s.Width;
|
|
}) + 56d;
|
|
var familyKey = ElkEdgeChannelBands.ResolveLaneFamilyKey(sinkBands[bandIndex].First().Label);
|
|
var preferredOuterY = familyKey is "failure" or "timeout"
|
|
? ElkEdgeChannelSinkCorridors.ResolveSinkCorridorY(sinkBandEdges, positionedNodes, reservedSinkBands)
|
|
: double.NaN;
|
|
if (!double.IsNaN(preferredOuterY))
|
|
{
|
|
reservedSinkBands.Add(preferredOuterY + (bandIndex * 24d));
|
|
}
|
|
|
|
foreach (var edge in sinkBandEdges)
|
|
{
|
|
sinkBandsByEdgeId[edge.Id] = (bandIndex, sinkBands.Length, sharedOuterX, preferredOuterY);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var sourceEdges in forwardEdgesBySource.Values)
|
|
{
|
|
var sorted = sourceEdges
|
|
.OrderBy(e =>
|
|
{
|
|
var t = positionedNodes[e.TargetNodeId];
|
|
return direction == ElkLayoutDirection.LeftToRight
|
|
? t.Y + (t.Height / 2d)
|
|
: t.X + (t.Width / 2d);
|
|
})
|
|
.ToArray();
|
|
|
|
for (var index = 0; index < sorted.Length; index++)
|
|
{
|
|
var targetEdges = forwardEdgesByTarget.GetValueOrDefault(sorted[index].TargetNodeId);
|
|
var targetIncomingIndex = 0;
|
|
var targetIncomingCount = 1;
|
|
if (targetEdges is not null && targetEdges.Count > 1)
|
|
{
|
|
var sortedBySourceY = targetEdges
|
|
.OrderBy(e =>
|
|
{
|
|
var s = positionedNodes[e.SourceNodeId];
|
|
return direction == ElkLayoutDirection.LeftToRight
|
|
? s.Y + (s.Height / 2d)
|
|
: s.X + (s.Width / 2d);
|
|
})
|
|
.ToList();
|
|
targetIncomingIndex = sortedBySourceY.FindIndex(e => string.Equals(e.Id, sorted[index].Id, StringComparison.Ordinal));
|
|
targetIncomingCount = sortedBySourceY.Count;
|
|
}
|
|
|
|
var sinkBand = sinkBandsByEdgeId.GetValueOrDefault(sorted[index].Id, (-1, 0, 0d, double.NaN));
|
|
var routeMode = EdgeRouteMode.Direct;
|
|
if (sinkBandsByEdgeId.ContainsKey(sorted[index].Id))
|
|
{
|
|
var sourceNode = positionedNodes[sorted[index].SourceNodeId];
|
|
var hasOtherForwardEdge = sorted.Length > 1
|
|
|| forwardEdgesBySource.TryGetValue(sorted[index].SourceNodeId, out var allSourceEdges)
|
|
&& allSourceEdges.Any(e => !sinkBandsByEdgeId.ContainsKey(e.Id));
|
|
var isNonChainSource = !string.Equals(sourceNode.Kind, "Task", StringComparison.OrdinalIgnoreCase);
|
|
if (hasOtherForwardEdge && isNonChainSource)
|
|
{
|
|
sinkBand = (-1, 0, 0d, double.NaN);
|
|
}
|
|
else
|
|
{
|
|
var familyKey = ElkEdgeChannelBands.ResolveLaneFamilyKey(sorted[index].Label);
|
|
if (familyKey is "failure" or "timeout")
|
|
{
|
|
routeMode = EdgeRouteMode.SinkOuterTop;
|
|
}
|
|
else
|
|
{
|
|
routeMode = EdgeRouteMode.SinkOuter;
|
|
}
|
|
}
|
|
}
|
|
|
|
channels[sorted[index].Id] = new EdgeChannel(
|
|
routeMode,
|
|
-1,
|
|
0,
|
|
1,
|
|
index,
|
|
sorted.Length,
|
|
targetIncomingIndex,
|
|
targetIncomingCount,
|
|
sinkBand.Item1,
|
|
sinkBand.Item2,
|
|
sinkBand.Item3,
|
|
sinkBand.Item4,
|
|
false,
|
|
double.NaN);
|
|
}
|
|
}
|
|
|
|
ElkEdgeChannelBands.AllocateDirectForwardChannelBands(edges, positionedNodes, layerBoundariesByNodeId, channels, direction);
|
|
|
|
return channels;
|
|
}
|
|
}
|