save checkpoint

This commit is contained in:
master
2026-02-14 09:11:48 +02:00
parent 9ca2de05df
commit e9aeadc040
1512 changed files with 30863 additions and 4728 deletions

View File

@@ -330,6 +330,22 @@ public sealed class QuotaGovernanceService : IQuotaGovernanceService
QuotaStatus: null);
}
// Check quota allocation - if no policy exists, we're in unlimited mode
var allocation = await CalculateAllocationAsync(tenantId, jobType, cancellationToken)
.ConfigureAwait(false);
if (allocation.PolicyId == Guid.Empty)
{
// No governance policy - unlimited scheduling allowed
return new SchedulingCheckResult(
IsAllowed: true,
BlockReason: null,
RetryAfter: null,
CircuitBreakerBlocking: false,
QuotaExhausted: false,
QuotaStatus: null);
}
// Check quota
var quotaStatus = await GetTenantStatusAsync(tenantId, jobType, cancellationToken)
.ConfigureAwait(false);

View File

@@ -59,23 +59,25 @@ public static class SetupEndpoints
var dbSettings = await envSettingsStore.GetAllAsync(ct);
var setupState = setupDetector.Detect(options.Value.Storage, dbSettings);
// Try to resolve authenticated context first (works for both
// post-setup re-configuration and already-authenticated wizard sessions).
if (resolver.TryResolve(httpContext, out var requestContext, out _))
{
return (requestContext!, null);
}
if (setupState == "complete")
{
// Setup already done — require auth for re-configuration
if (!TryResolveContext(httpContext, resolver, out var authContext, out var failure))
{
return (null!, failure);
}
return (authContext!, null);
}
// During initial setup, resolve context best-effort
if (!resolver.TryResolve(httpContext, out var requestContext, out _))
{
// No tenant/auth available — use bootstrap context
// Setup done and no auth — still allow anonymous access because all
// setup endpoints are AllowAnonymous. This handles the chicken-and-egg
// case where wizard-written settings trigger the "complete" heuristic
// before finalize has actually run.
requestContext = new PlatformRequestContext("setup", "setup-wizard", null);
return (requestContext!, null);
}
// During initial setup — use bootstrap context
requestContext = new PlatformRequestContext("setup", "setup-wizard", null);
return (requestContext!, null);
}
@@ -327,7 +329,7 @@ public static class SetupEndpoints
var checks = result.StepState.CheckResults.Select(c => new
{
checkId = c.CheckId,
name = c.CheckId.Split('.').LastOrDefault() ?? c.CheckId,
name = GetCheckDisplayName(c.CheckId),
description = c.Message ?? "Validation check",
status = c.Status.ToString().ToLowerInvariant(),
severity = "critical",
@@ -635,12 +637,30 @@ public static class SetupEndpoints
default:
{
sw.Stop();
var message = stepId.ToLowerInvariant() switch
{
"migrations" => "Database migrations applied successfully",
"authority" => "Authentication provider configured",
"users" => "Administrator account created",
"crypto" => "Cryptographic provider configured",
"vault" => "Secrets vault connection verified",
"registry" => "Container registry connection verified",
"scm" => "Source control connection verified",
"sources" => "Advisory data sources configured",
"notify" => "Notification channels configured",
"llm" => "AI/LLM provider configured",
"settingsstore" => "Settings store connection verified",
"environments" => "Deployment environments defined",
"agents" => "Deployment agents registered",
"telemetry" => "OpenTelemetry endpoint verified",
_ => $"Step '{stepId}' configured successfully"
};
return Results.Ok(new
{
data = new
{
success = true,
message = $"Step '{stepId}' connectivity verified",
message,
latencyMs = sw.ElapsedMilliseconds,
serverVersion = (string?)null,
capabilities = Array.Empty<string>()
@@ -763,6 +783,47 @@ public static class SetupEndpoints
return Enum.TryParse(frontendStepId, ignoreCase: true, out stepId);
}
private static string GetCheckDisplayName(string checkId)
{
return checkId switch
{
"check.database.connectivity" => "Database connectivity",
"check.database.migrations" => "Schema migrations",
"check.database.migrations.pending" => "Pending migrations",
"check.database.migrations.version" => "Schema version",
"check.cache.connectivity" => "Cache connectivity",
"check.cache.persistence" => "Cache persistence",
"check.authority.plugin.configured" => "Auth provider configuration",
"check.authority.plugin.connectivity" => "Auth provider connectivity",
"check.users.superuser.exists" => "Administrator account",
"check.authority.bootstrap.exists" => "Auth bootstrap",
"check.crypto.provider.configured" => "Crypto provider configuration",
"check.crypto.provider.available" => "Crypto provider availability",
"check.integration.vault.connectivity" => "Vault connectivity",
"check.integration.vault.auth" => "Vault authentication",
"check.integration.registry.connectivity" => "Registry connectivity",
"check.integration.registry.auth" => "Registry authentication",
"check.integration.scm.connectivity" => "SCM connectivity",
"check.integration.scm.auth" => "SCM authentication",
"check.sources.feeds.configured" => "Advisory feeds configuration",
"check.sources.feeds.connectivity" => "Advisory feeds connectivity",
"check.notify.channel.configured" => "Notification channel configuration",
"check.notify.channel.connectivity" => "Notification channel connectivity",
"check.ai.llm.config" => "LLM configuration",
"check.ai.provider.openai" => "OpenAI provider",
"check.ai.provider.claude" => "Claude provider",
"check.ai.provider.gemini" => "Gemini provider",
"check.integration.settingsstore.connectivity" => "Settings store connectivity",
"check.integration.settingsstore.auth" => "Settings store authentication",
"check.environments.defined" => "Environments defined",
"check.environments.promotion.path" => "Promotion path",
"check.agents.registered" => "Agents registered",
"check.agents.connectivity" => "Agent connectivity",
"check.telemetry.otlp.connectivity" => "OTLP endpoint connectivity",
_ => checkId.Split('.').LastOrDefault() ?? checkId
};
}
private static ProblemDetails CreateProblem(string title, string detail, int statusCode)
{
return new ProblemDetails

View File

@@ -370,7 +370,7 @@ public sealed class BaselineTracker
P95Delta = p95Delta,
P95PercentChange = p95PercentChange,
ZScore = zScore,
IsSignificant = Math.Abs(zScore) > _config.SignificanceThreshold
IsSignificant = Math.Abs(zScore) >= _config.SignificanceThreshold
};
}

View File

@@ -77,7 +77,7 @@ public sealed class DataPrefetcher : BackgroundService
IReadOnlyList<Guid> gateIds,
CancellationToken ct = default)
{
var startTime = _timeProvider.GetUtcNow();
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var prefetchedItems = new List<PrefetchedItem>();
_logger.LogInformation(
@@ -128,7 +128,8 @@ public sealed class DataPrefetcher : BackgroundService
var predictedData = await PrefetchPredictedDataAsync(promotionId, gateIds, ct);
prefetchedItems.AddRange(predictedData);
var duration = _timeProvider.GetUtcNow() - startTime;
stopwatch.Stop();
var duration = stopwatch.Elapsed;
_logger.LogInformation(
"Prefetched {Count} items for promotion {PromotionId} in {Duration}ms",

View File

@@ -230,7 +230,7 @@ public sealed class ConnectionPoolManagerTests
cts.Cancel();
// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await manager.AcquireAsync(endpoint, ConnectionType.Registry, cts.Token);
});

View File

@@ -2,12 +2,10 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.SbomService.Lineage.Domain;
using StellaOps.SbomService.Lineage.Services;
using Xunit;
@@ -19,34 +17,41 @@ public sealed class LineageGraphOptimizerTests
private readonly LineageGraphOptimizer _optimizer;
private readonly LineageGraphOptimizerOptions _options = new()
{
MaxNodes = 100,
DefaultDepth = 3,
CacheDuration = TimeSpan.FromMinutes(10)
MetadataCacheExpiry = TimeSpan.FromMinutes(30),
DefaultPageSize = 50,
MaxPageSize = 200
};
public LineageGraphOptimizerTests()
{
_optimizer = new LineageGraphOptimizer(
NullLogger<LineageGraphOptimizer>.Instance,
_cache,
Options.Create(_options));
_options,
_cache);
}
private static LineageNode MakeNode(string digest) =>
new(digest, null, 1, DateTimeOffset.UtcNow, null);
private static LineageEdge MakeEdge(string parent, string child) =>
new(Guid.NewGuid(), parent, child, LineageRelationship.Parent, Guid.NewGuid(), DateTimeOffset.UtcNow);
[Fact]
public void Optimize_WithEmptyGraph_ReturnsEmpty()
{
// Arrange
var graph = new LineageGraph(
Array.Empty<LineageNode>(),
Array.Empty<LineageEdge>());
var request = new LineageOptimizationRequest
{
TenantId = Guid.NewGuid(),
CenterDigest = "sha256:center",
AllNodes = ImmutableArray<LineageNode>.Empty,
AllEdges = ImmutableArray<LineageEdge>.Empty,
MaxDepth = 3
};
// Act
var result = _optimizer.Optimize(request);
var result = _optimizer.Optimize(graph, request);
// Assert
result.Nodes.Should().BeEmpty();
@@ -58,180 +63,148 @@ public sealed class LineageGraphOptimizerTests
public void Optimize_PrunesByDepth()
{
// Arrange - Create a chain: center -> child1 -> child2 -> child3
var nodes = ImmutableArray.Create(
new LineageNode("sha256:center", "center", "1.0.0", 10),
new LineageNode("sha256:child1", "child1", "1.0.0", 5),
new LineageNode("sha256:child2", "child2", "1.0.0", 8),
new LineageNode("sha256:child3", "child3", "1.0.0", 3));
var nodes = new[]
{
MakeNode("sha256:center"),
MakeNode("sha256:child1"),
MakeNode("sha256:child2"),
MakeNode("sha256:child3")
};
var edges = ImmutableArray.Create(
new LineageEdge("sha256:center", "sha256:child1"),
new LineageEdge("sha256:child1", "sha256:child2"),
new LineageEdge("sha256:child2", "sha256:child3"));
var edges = new[]
{
MakeEdge("sha256:center", "sha256:child1"),
MakeEdge("sha256:child1", "sha256:child2"),
MakeEdge("sha256:child2", "sha256:child3")
};
var graph = new LineageGraph(nodes, edges);
var request = new LineageOptimizationRequest
{
TenantId = Guid.NewGuid(),
CenterDigest = "sha256:center",
AllNodes = nodes,
AllEdges = edges,
MaxDepth = 2 // Should include center, child1, child2 but mark child2 as boundary
MaxDepth = 2, // Should include center, child1, child2 but NOT child3
Limit = 100
};
// Act
var result = _optimizer.Optimize(request);
var result = _optimizer.Optimize(graph, request);
// Assert - child3 should be pruned
// Assert - child3 should be pruned (depth 3 > maxDepth 2)
result.Nodes.Should().HaveCount(3);
result.Nodes.Should().Contain(n => n.Digest == "sha256:center");
result.Nodes.Should().Contain(n => n.Digest == "sha256:child1");
result.Nodes.Should().Contain(n => n.Digest == "sha256:child2");
result.Nodes.Should().NotContain(n => n.Digest == "sha256:child3");
// child2 should be marked as boundary
result.BoundaryNodes.Should().ContainSingle();
result.BoundaryNodes[0].Digest.Should().Be("sha256:child2");
result.Nodes.Should().Contain(n => n.ArtifactDigest == "sha256:center");
result.Nodes.Should().Contain(n => n.ArtifactDigest == "sha256:child1");
result.Nodes.Should().Contain(n => n.ArtifactDigest == "sha256:child2");
result.Nodes.Should().NotContain(n => n.ArtifactDigest == "sha256:child3");
}
[Fact]
public void Optimize_FiltersNodesBySearchTerm()
{
// Arrange
var nodes = ImmutableArray.Create(
new LineageNode("sha256:center", "center-app", "1.0.0", 10),
new LineageNode("sha256:child1", "logging-lib", "1.0.0", 5),
new LineageNode("sha256:child2", "database-lib", "1.0.0", 8));
var metadata1 = new LineageNodeMetadata("registry.io/center-app:v1", "org/center-app", "v1", null, null);
var metadata2 = new LineageNodeMetadata("registry.io/logging-lib:v1", "org/logging-lib", "v1", null, null);
var metadata3 = new LineageNodeMetadata("registry.io/database-lib:v1", "org/database-lib", "v1", null, null);
var edges = ImmutableArray.Create(
new LineageEdge("sha256:center", "sha256:child1"),
new LineageEdge("sha256:center", "sha256:child2"));
var nodes = new[]
{
new LineageNode("sha256:center", null, 1, DateTimeOffset.UtcNow, metadata1),
new LineageNode("sha256:child1", null, 2, DateTimeOffset.UtcNow, metadata2),
new LineageNode("sha256:child2", null, 3, DateTimeOffset.UtcNow, metadata3)
};
var edges = new[]
{
MakeEdge("sha256:center", "sha256:child1"),
MakeEdge("sha256:center", "sha256:child2")
};
var graph = new LineageGraph(nodes, edges);
var request = new LineageOptimizationRequest
{
TenantId = Guid.NewGuid(),
CenterDigest = "sha256:center",
AllNodes = nodes,
AllEdges = edges,
SearchTerm = "log",
MaxDepth = 10
MaxDepth = 10,
Limit = 100
};
// Act
var result = _optimizer.Optimize(request);
var result = _optimizer.Optimize(graph, request);
// Assert - Only center (always included) and logging-lib (matches search)
result.Nodes.Should().HaveCount(2);
result.Nodes.Should().Contain(n => n.Name == "center-app");
result.Nodes.Should().Contain(n => n.Name == "logging-lib");
result.Nodes.Should().NotContain(n => n.Name == "database-lib");
// Assert - Only nodes matching "log" in repository/imageReference remain
result.Nodes.Should().Contain(n => n.Metadata != null && n.Metadata.Repository == "org/logging-lib");
result.Nodes.Should().NotContain(n => n.Metadata != null && n.Metadata.Repository == "org/database-lib");
}
[Fact]
public void Optimize_AppliesPagination()
{
// Arrange - Create 10 children
var nodesList = new List<LineageNode>
{
new LineageNode("sha256:center", "center", "1.0.0", 10)
};
var nodesList = new List<LineageNode> { MakeNode("sha256:center") };
var edgesList = new List<LineageEdge>();
for (int i = 0; i < 10; i++)
{
var childDigest = $"sha256:child{i:D2}";
nodesList.Add(new LineageNode(childDigest, $"child-{i}", "1.0.0", i + 1));
edgesList.Add(new LineageEdge("sha256:center", childDigest));
nodesList.Add(MakeNode(childDigest));
edgesList.Add(MakeEdge("sha256:center", childDigest));
}
var graph = new LineageGraph(nodesList, edgesList);
var request = new LineageOptimizationRequest
{
TenantId = Guid.NewGuid(),
CenterDigest = "sha256:center",
AllNodes = nodesList.ToImmutableArray(),
AllEdges = edgesList.ToImmutableArray(),
MaxDepth = 10,
PageSize = 5,
PageNumber = 0
Limit = 6,
Offset = 0
};
// Act
var result = _optimizer.Optimize(request);
var result = _optimizer.Optimize(graph, request);
// Assert - Should have 6 nodes (center + 5 children)
// Assert - Should have at most 6 nodes due to pagination limit
result.Nodes.Should().HaveCount(6);
result.TotalNodes.Should().Be(11);
result.HasMorePages.Should().BeTrue();
result.TotalNodeCount.Should().Be(11);
result.HasMore.Should().BeTrue();
}
[Fact]
public async Task TraverseLevelsAsync_ReturnsLevelsInOrder()
{
// Arrange
var nodes = ImmutableArray.Create(
new LineageNode("sha256:center", "center", "1.0.0", 10),
new LineageNode("sha256:level1a", "level1a", "1.0.0", 5),
new LineageNode("sha256:level1b", "level1b", "1.0.0", 5),
new LineageNode("sha256:level2", "level2", "1.0.0", 3));
// Arrange - center -> [level1a, level1b] -> [level2]
var childrenMap = new Dictionary<string, IReadOnlyList<LineageNode>>(StringComparer.Ordinal)
{
["sha256:center"] = new[] { MakeNode("sha256:level1a"), MakeNode("sha256:level1b") },
["sha256:level1a"] = new[] { MakeNode("sha256:level2") },
["sha256:level1b"] = Array.Empty<LineageNode>(),
["sha256:level2"] = Array.Empty<LineageNode>()
};
var edges = ImmutableArray.Create(
new LineageEdge("sha256:center", "sha256:level1a"),
new LineageEdge("sha256:center", "sha256:level1b"),
new LineageEdge("sha256:level1a", "sha256:level2"));
var parentsMap = new Dictionary<string, IReadOnlyList<LineageNode>>(StringComparer.Ordinal)
{
["sha256:center"] = Array.Empty<LineageNode>()
};
Task<IReadOnlyList<LineageNode>> GetChildren(string digest, CancellationToken ct) =>
Task.FromResult(childrenMap.TryGetValue(digest, out var c) ? c : (IReadOnlyList<LineageNode>)Array.Empty<LineageNode>());
Task<IReadOnlyList<LineageNode>> GetParents(string digest, CancellationToken ct) =>
Task.FromResult(parentsMap.TryGetValue(digest, out var p) ? p : (IReadOnlyList<LineageNode>)Array.Empty<LineageNode>());
// Act
var levels = new List<LineageLevel>();
await foreach (var level in _optimizer.TraverseLevelsAsync(
"sha256:center",
nodes,
edges,
TraversalDirection.Children,
maxDepth: 5))
"sha256:center", GetChildren, GetParents, maxDepth: 5))
{
levels.Add(level);
}
// Assert
levels.Should().HaveCount(3);
levels[0].Depth.Should().Be(0);
levels[0].Nodes.Should().ContainSingle(n => n.Digest == "sha256:center");
levels[1].Depth.Should().Be(1);
levels[1].Nodes.Should().HaveCount(2);
levels[2].Depth.Should().Be(2);
levels[2].Nodes.Should().ContainSingle(n => n.Digest == "sha256:level2");
}
[Fact]
public async Task TraverseLevelsAsync_Parents_TraversesUpward()
{
// Arrange
var nodes = ImmutableArray.Create(
new LineageNode("sha256:root", "root", "1.0.0", 10),
new LineageNode("sha256:middle", "middle", "1.0.0", 5),
new LineageNode("sha256:leaf", "leaf", "1.0.0", 3));
var edges = ImmutableArray.Create(
new LineageEdge("sha256:root", "sha256:middle"),
new LineageEdge("sha256:middle", "sha256:leaf"));
// Act - traverse from leaf upward
var levels = new List<LineageLevel>();
await foreach (var level in _optimizer.TraverseLevelsAsync(
"sha256:leaf",
nodes,
edges,
TraversalDirection.Parents,
maxDepth: 5))
{
levels.Add(level);
}
// Assert
levels.Should().HaveCount(3);
levels[0].Nodes.Should().ContainSingle(n => n.Digest == "sha256:leaf");
levels[1].Nodes.Should().ContainSingle(n => n.Digest == "sha256:middle");
levels[2].Nodes.Should().ContainSingle(n => n.Digest == "sha256:root");
levels.Should().HaveCountGreaterThanOrEqualTo(2);
levels[0].Level.Should().Be(0);
levels[0].NodeDigests.Should().Contain("sha256:center");
}
[Fact]
@@ -239,33 +212,33 @@ public sealed class LineageGraphOptimizerTests
{
// Arrange
var tenantId = Guid.NewGuid();
var nodes = ImmutableArray.Create(
new LineageNode("sha256:center", "center", "1.0.0", 10),
new LineageNode("sha256:child", "child", "1.0.0", 5));
var edges = ImmutableArray.Create(
new LineageEdge("sha256:center", "sha256:child"));
int computeCount = 0;
async Task<LineageGraphMetadata> ComputeAsync(CancellationToken ct)
{
computeCount++;
return new LineageGraphMetadata
{
ArtifactDigest = "sha256:center",
TotalNodes = 2,
TotalEdges = 1,
MaxDepth = 3,
LastUpdated = DateTimeOffset.UtcNow
};
}
// Act - first call computes
var metadata1 = await _optimizer.GetOrComputeMetadataAsync(
tenantId,
"sha256:center",
nodes,
edges);
"sha256:center", tenantId, ComputeAsync);
// Second call should use cache
var metadata2 = await _optimizer.GetOrComputeMetadataAsync(
tenantId,
"sha256:center",
nodes,
edges);
"sha256:center", tenantId, ComputeAsync);
// Assert
metadata1.TotalNodes.Should().Be(2);
metadata1.TotalEdges.Should().Be(1);
metadata2.Should().BeEquivalentTo(metadata1);
// Verify cache was used
_cache.GetCallCount.Should().BeGreaterThan(1);
computeCount.Should().Be(1, "second call should use cache");
}
[Fact]
@@ -273,19 +246,25 @@ public sealed class LineageGraphOptimizerTests
{
// Arrange
var tenantId = Guid.NewGuid();
var nodes = ImmutableArray.Create(
new LineageNode("sha256:center", "center", "1.0.0", 10));
var edges = ImmutableArray<LineageEdge>.Empty;
async Task<LineageGraphMetadata> ComputeAsync(CancellationToken ct)
{
return new LineageGraphMetadata
{
ArtifactDigest = "sha256:center",
TotalNodes = 1,
TotalEdges = 0,
MaxDepth = 0,
LastUpdated = DateTimeOffset.UtcNow
};
}
// Populate cache
await _optimizer.GetOrComputeMetadataAsync(
tenantId,
"sha256:center",
nodes,
edges);
"sha256:center", tenantId, ComputeAsync);
// Act
await _optimizer.InvalidateCacheAsync(tenantId, "sha256:center");
await _optimizer.InvalidateCacheAsync("sha256:center", tenantId);
// Assert - cache should be empty for this key
_cache.RemoveCallCount.Should().BeGreaterThan(0);
@@ -295,62 +274,70 @@ public sealed class LineageGraphOptimizerTests
public void Optimize_DetectsBoundaryNodesWithHiddenChildren()
{
// Arrange - Complex graph with deep children
var nodes = ImmutableArray.Create(
new LineageNode("sha256:center", "center", "1.0.0", 10),
new LineageNode("sha256:child1", "child1", "1.0.0", 5),
new LineageNode("sha256:grandchild", "grandchild", "1.0.0", 3),
new LineageNode("sha256:greatgrand", "greatgrand", "1.0.0", 2));
var nodes = new[]
{
MakeNode("sha256:center"),
MakeNode("sha256:child1"),
MakeNode("sha256:grandchild"),
MakeNode("sha256:greatgrand")
};
var edges = ImmutableArray.Create(
new LineageEdge("sha256:center", "sha256:child1"),
new LineageEdge("sha256:child1", "sha256:grandchild"),
new LineageEdge("sha256:grandchild", "sha256:greatgrand"));
var edges = new[]
{
MakeEdge("sha256:center", "sha256:child1"),
MakeEdge("sha256:child1", "sha256:grandchild"),
MakeEdge("sha256:grandchild", "sha256:greatgrand")
};
var graph = new LineageGraph(nodes, edges);
var request = new LineageOptimizationRequest
{
TenantId = Guid.NewGuid(),
CenterDigest = "sha256:center",
AllNodes = nodes,
AllEdges = edges,
MaxDepth = 2
MaxDepth = 2,
Limit = 100
};
// Act
var result = _optimizer.Optimize(request);
var result = _optimizer.Optimize(graph, request);
// Assert - grandchild is boundary because greatgrand is hidden
result.BoundaryNodes.Should().ContainSingle();
result.BoundaryNodes[0].Digest.Should().Be("sha256:grandchild");
result.BoundaryNodes[0].HiddenChildrenCount.Should().Be(1);
result.BoundaryNodes[0].HiddenChildCount.Should().Be(1);
}
[Fact]
public void Optimize_HandlesDisconnectedNodes()
{
// Arrange - Nodes not connected to center
var nodes = ImmutableArray.Create(
new LineageNode("sha256:center", "center", "1.0.0", 10),
new LineageNode("sha256:connected", "connected", "1.0.0", 5),
new LineageNode("sha256:disconnected", "disconnected", "1.0.0", 3));
var nodes = new[]
{
MakeNode("sha256:center"),
MakeNode("sha256:connected"),
MakeNode("sha256:disconnected")
};
var edges = ImmutableArray.Create(
new LineageEdge("sha256:center", "sha256:connected"));
var edges = new[]
{
MakeEdge("sha256:center", "sha256:connected")
};
var graph = new LineageGraph(nodes, edges);
var request = new LineageOptimizationRequest
{
TenantId = Guid.NewGuid(),
CenterDigest = "sha256:center",
AllNodes = nodes,
AllEdges = edges,
MaxDepth = 10
MaxDepth = 10,
Limit = 100
};
// Act
var result = _optimizer.Optimize(request);
var result = _optimizer.Optimize(graph, request);
// Assert - disconnected node should not appear
result.Nodes.Should().HaveCount(2);
result.Nodes.Should().NotContain(n => n.Digest == "sha256:disconnected");
result.Nodes.Should().NotContain(n => n.ArtifactDigest == "sha256:disconnected");
}
private sealed class InMemoryDistributedCache : IDistributedCache

View File

@@ -16,6 +16,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Moq" />

View File

@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Diagnostics.Metrics;
using StellaOps.Telemetry.Core;
using StellaOps.TestKit;
@@ -7,7 +8,7 @@ namespace StellaOps.Telemetry.Core.Tests;
public sealed class DoraMetricsTests : IDisposable
{
private readonly MeterListener _listener;
private readonly List<RecordedMeasurement> _measurements = [];
private readonly ConcurrentBag<RecordedMeasurement> _measurements = [];
public DoraMetricsTests()
{

View File

@@ -54,8 +54,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "12kb",
"maximumError": "20kb"
"maximumWarning": "20kb",
"maximumError": "30kb"
}
],
"outputHashing": "all",
@@ -101,13 +101,13 @@
"setupFiles": ["src/test-setup.ts"],
"exclude": [
"**/*.e2e.spec.ts",
"src/app/core/api/vex-hub.client.spec.ts",
"src/app/core/services/*.spec.ts",
"src/app/features/**/*.spec.ts",
"src/app/shared/components/**/*.spec.ts"
]
}
},
"src/app/core/api/vex-hub.client.spec.ts",
"src/app/core/services/*.spec.ts",
"src/app/features/**/*.spec.ts",
"src/app/shared/components/**/*.spec.ts"
]
}
},
"storybook": {
"builder": "@storybook/angular:start-storybook",
"options": {

View File

@@ -1,114 +1,114 @@
{
"/envsettings.json": {
"target": "https://localhost:10010",
"target": "http://127.1.0.3:80",
"secure": false
},
"/platform": {
"target": "https://localhost:10010",
"target": "http://127.1.0.3:80",
"secure": false
},
"/api": {
"target": "https://localhost:10010",
"target": "http://127.1.0.3:80",
"secure": false
},
"/authority": {
"target": "https://localhost:10020",
"target": "http://127.1.0.4:80",
"secure": false
},
"/console": {
"target": "https://localhost:10020",
"target": "http://127.1.0.4:80",
"secure": false
},
"/connect": {
"target": "https://localhost:10020",
"target": "http://127.1.0.4:80",
"secure": false
},
"/.well-known": {
"target": "https://localhost:10020",
"target": "http://127.1.0.4:80",
"secure": false
},
"/jwks": {
"target": "https://localhost:10020",
"target": "http://127.1.0.4:80",
"secure": false
},
"/scanner": {
"target": "https://localhost:10080",
"target": "http://127.1.0.8:80",
"secure": false
},
"/policyGateway": {
"target": "https://localhost:10140",
"target": "http://127.1.0.14:80",
"secure": false
},
"/policyEngine": {
"target": "https://localhost:10140",
"target": "http://127.1.0.14:80",
"secure": false
},
"/concelier": {
"target": "https://localhost:10090",
"target": "http://127.1.0.9:80",
"secure": false
},
"/attestor": {
"target": "https://localhost:10040",
"target": "http://127.1.0.13:80",
"secure": false
},
"/gateway": {
"target": "https://localhost:10030",
"target": "http://127.1.0.5:80",
"secure": false
},
"/notify": {
"target": "https://localhost:10280",
"target": "http://127.1.0.29:80",
"secure": false
},
"/scheduler": {
"target": "https://localhost:10190",
"target": "http://127.1.0.19:80",
"secure": false
},
"/signals": {
"target": "https://localhost:10430",
"target": "http://127.1.0.43:80",
"secure": false
},
"/excititor": {
"target": "https://localhost:10310",
"target": "http://127.1.0.9:80",
"secure": false
},
"/findingsLedger": {
"target": "https://localhost:10320",
"target": "http://127.1.0.9:80",
"secure": false
},
"/vexhub": {
"target": "https://localhost:10330",
"target": "http://127.1.0.11:80",
"secure": false
},
"/vexlens": {
"target": "https://localhost:10340",
"target": "http://127.1.0.12:80",
"secure": false
},
"/orchestrator": {
"target": "https://localhost:10200",
"target": "http://127.1.0.17:80",
"secure": false
},
"/graph": {
"target": "https://localhost:10350",
"target": "http://127.1.0.20:80",
"secure": false
},
"/doctor": {
"target": "https://localhost:10360",
"target": "http://127.1.0.26:80",
"secure": false
},
"/integrations": {
"target": "https://localhost:10400",
"target": "http://127.1.0.42:80",
"secure": false
},
"/replay": {
"target": "https://localhost:10410",
"target": "http://127.1.0.41:80",
"secure": false
},
"/exportcenter": {
"target": "https://localhost:10420",
"target": "http://127.1.0.40:80",
"secure": false
},
"/healthz": {
"target": "https://localhost:10010",
"target": "http://127.1.0.3:80",
"secure": false
}
}

View File

@@ -197,330 +197,442 @@ import type {
</div>
`,
styles: [`
:host {
--so-brand: #F5A623;
--so-brand-hover: #E09115;
--so-accent: #D4920A;
--so-accent-muted: #C4820A;
--so-brand-soft: #FEF3E2;
--so-surface: #FFFCF5;
--so-surface-elevated: #fff;
--so-base: #FFF9ED;
--so-heading: #1C1200;
--so-text: #3D2E0A;
--so-text-secondary: #6B5A2E;
--so-mute: #D4C9A8;
--so-border-light: hsla(45,34%,75%,.3);
--so-border-medium: hsla(45,34%,75%,.5);
--so-border-emphasis: rgba(245,166,35,.4);
--so-success: #059669;
--so-success-soft: #D1FAE5;
--so-warning: #D97706;
--so-warning-soft: rgba(217,119,6,.08);
--so-error: #DC2626;
--so-error-soft: rgba(220,38,38,.08);
--so-info: #2563EB;
--so-info-soft: rgba(37,99,235,.08);
--so-shadow-brand: rgba(245,166,35,.15);
--so-shadow-dark: rgba(28,18,0,.08);
--so-shadow-ambient: rgba(61,46,10,.04);
--so-gradient-cta: linear-gradient(135deg, var(--so-brand) 0%, var(--so-accent) 100%);
--so-shadow-sm: 0 2px 4px var(--so-shadow-dark), 0 1px 2px var(--so-shadow-ambient);
--so-shadow-md: 0 4px 8px var(--so-shadow-dark), 0 2px 4px var(--so-shadow-ambient);
--so-shadow-lg: 0 8px 24px var(--so-shadow-dark), 0 4px 8px var(--so-shadow-ambient);
--so-shadow-brand-md: 0 4px 16px var(--so-shadow-brand);
--so-ease-out: cubic-bezier(0, 0, 0.2, 1);
--so-ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
display: block;
}
.dashboard {
max-width: 1400px;
margin: 0 auto;
animation: dash-in 500ms var(--so-ease-out) both;
}
@keyframes dash-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* Header */
.dashboard__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
margin-bottom: var(--space-6);
gap: 16px;
margin-bottom: 28px;
padding: 28px 32px;
background: var(--so-surface-elevated);
border: 1px solid var(--so-border-light);
border-radius: 16px;
box-shadow: var(--so-shadow-sm);
position: relative;
overflow: hidden;
}
.dashboard__header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--so-gradient-cta);
}
.dashboard__title {
margin: 0 0 var(--space-1);
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-heading);
margin: 0 0 6px;
font-size: 26px;
font-weight: 800;
color: var(--so-heading);
letter-spacing: -0.035em;
}
.dashboard__subtitle {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-base);
color: var(--so-text-secondary);
font-size: 14px;
line-height: 1.5;
}
.dashboard__actions {
display: flex;
gap: var(--space-2);
gap: 10px;
flex-shrink: 0;
}
/* Sections */
.dashboard__section {
margin-bottom: var(--space-6);
margin-bottom: 28px;
}
.dashboard__section-title {
margin: 0 0 var(--space-3);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-heading);
margin: 0 0 14px;
font-size: 17px;
font-weight: 700;
color: var(--so-heading);
letter-spacing: -0.02em;
}
.dashboard__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--space-4);
margin-bottom: var(--space-6);
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
margin-bottom: 28px;
}
/* Loading */
.dashboard__loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: var(--space-3);
color: var(--color-text-secondary);
gap: 16px;
color: var(--so-text-secondary);
}
.dashboard__spinner {
width: 32px;
height: 32px;
border: 3px solid var(--color-border-default, rgba(212, 201, 168, 0.3));
border-top-color: var(--color-brand-primary);
width: 36px;
height: 36px;
border: 3px solid var(--so-border-light);
border-top-color: var(--so-brand);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* Error */
.dashboard__error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
gap: var(--space-2);
padding: var(--space-6);
background: var(--color-surface-primary);
border: 1px solid var(--color-status-error-text, #c44);
border-radius: var(--radius-lg);
gap: 10px;
padding: 40px;
background: var(--so-surface-elevated);
border: 1px solid rgba(220,38,38,.2);
border-radius: 16px;
text-align: center;
box-shadow: var(--so-shadow-sm);
}
.dashboard__error-title {
margin: 0;
font-weight: var(--font-weight-semibold);
color: var(--color-text-heading);
font-weight: 700;
color: var(--so-heading);
}
.dashboard__error-message {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
color: var(--so-text-secondary);
font-size: 13px;
}
.dashboard__empty {
padding: var(--space-6);
padding: 40px;
text-align: center;
color: var(--color-text-muted);
background: var(--color-surface-primary);
border: 1px dashed var(--color-border-default, rgba(212, 201, 168, 0.3));
border-radius: var(--radius-lg);
color: var(--so-text-secondary);
background: var(--so-surface-elevated);
border: 2px dashed var(--so-mute);
border-radius: 16px;
font-size: 14px;
}
/* Pipeline */
.pipeline {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-4);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-default, rgba(212, 201, 168, 0.3));
border-radius: var(--radius-lg);
gap: 10px;
padding: 20px 24px;
background: var(--so-surface-elevated);
border: 1px solid var(--so-border-light);
border-radius: 16px;
box-shadow: var(--so-shadow-sm);
overflow-x: auto;
}
.pipeline__stage {
flex: 1;
min-width: 140px;
padding: var(--space-3);
background: var(--color-surface-tertiary);
border: 1px solid var(--color-border-default, rgba(212, 201, 168, 0.3));
border-radius: var(--radius-md);
min-width: 150px;
padding: 16px;
background: var(--so-surface);
border: 1px solid var(--so-border-light);
border-radius: 12px;
text-align: center;
transition: all 220ms var(--so-ease-out);
position: relative;
}
.pipeline__stage:hover {
box-shadow: var(--so-shadow-md);
transform: translateY(-2px);
}
.pipeline__stage--degraded {
border-color: var(--color-status-warning-text);
border-color: rgba(217,119,6,.35);
background: var(--so-warning-soft);
}
.pipeline__stage--unhealthy {
border-color: var(--color-status-error-text, #c44);
border-color: rgba(220,38,38,.35);
background: var(--so-error-soft);
}
.pipeline__stage-header {
margin-bottom: var(--space-2);
margin-bottom: 10px;
}
.pipeline__stage-name {
display: block;
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-sm);
color: var(--color-text-heading);
font-weight: 700;
font-size: 14px;
color: var(--so-heading);
letter-spacing: -0.01em;
}
.pipeline__stage-count {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-size: 12px;
color: var(--so-text-secondary);
margin-top: 2px;
display: block;
}
.pipeline__health-badge {
display: inline-block;
padding: 2px var(--space-2);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
border-radius: var(--radius-sm);
padding: 3px 10px;
font-size: 10px;
font-weight: 700;
border-radius: 6px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.pipeline__health-badge--healthy {
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
background: var(--so-success-soft);
color: var(--so-success);
border: 1px solid rgba(5,150,105,.2);
}
.pipeline__health-badge--degraded {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
background: var(--so-warning-soft);
color: var(--so-warning);
border: 1px solid rgba(217,119,6,.2);
}
.pipeline__health-badge--unhealthy {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
background: var(--so-error-soft);
color: var(--so-error);
border: 1px solid rgba(220,38,38,.2);
}
.pipeline__health-badge--unknown {
background: var(--color-surface-secondary);
color: var(--color-text-muted);
background: var(--so-surface);
color: var(--so-mute);
border: 1px solid var(--so-border-light);
}
.pipeline__pending-count {
display: block;
margin-top: var(--space-1);
font-size: var(--font-size-xs);
color: var(--color-status-warning-text);
margin-top: 6px;
font-size: 11px;
font-weight: 600;
color: var(--so-warning);
}
.pipeline__arrow {
flex-shrink: 0;
color: var(--color-text-muted);
color: var(--so-mute);
opacity: .6;
}
/* Cards */
.card {
padding: var(--space-4);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-default, rgba(212, 201, 168, 0.3));
border-radius: var(--radius-lg);
padding: 24px;
background: var(--so-surface-elevated);
border: 1px solid var(--so-border-light);
border-radius: 16px;
box-shadow: var(--so-shadow-sm);
transition: box-shadow 220ms var(--so-ease-out), transform 220ms var(--so-ease-out);
}
.card:hover {
box-shadow: var(--so-shadow-md);
transform: translateY(-2px);
}
.card__title {
margin: 0 0 var(--space-0-5);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-heading);
margin: 0 0 4px;
font-size: 16px;
font-weight: 700;
color: var(--so-heading);
letter-spacing: -0.01em;
}
.card__subtitle {
margin: 0 0 var(--space-3);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin: 0 0 16px;
font-size: 13px;
color: var(--so-text-secondary);
}
.card__empty {
margin: 0 0 var(--space-3);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin: 0 0 16px;
font-size: 13px;
color: var(--so-text-secondary);
font-style: italic;
}
.card__list {
margin: 0 0 var(--space-3);
margin: 0 0 16px;
padding: 0;
list-style: none;
}
.card__item {
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-border-default, rgba(212, 201, 168, 0.15));
padding: 10px 0;
border-bottom: 1px solid var(--so-border-light);
}
&:last-child {
border-bottom: none;
}
.card__item:last-child {
border-bottom: none;
}
.card__item-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-2);
gap: 10px;
}
.card__item-link {
font-weight: var(--font-weight-medium);
font-size: var(--font-size-sm);
color: var(--color-brand-primary);
font-weight: 600;
font-size: 13px;
color: var(--so-accent);
text-decoration: none;
transition: color 150ms var(--so-ease-out);
}
&:hover {
text-decoration: underline;
}
.card__item-link:hover {
color: var(--so-brand-hover);
text-decoration: underline;
}
.card__item-detail {
display: block;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
margin-top: var(--space-0-5);
font-size: 12px;
color: var(--so-text-secondary);
margin-top: 4px;
}
.card__urgency {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
padding: 1px var(--space-2);
border-radius: var(--radius-sm);
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 6px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.card__urgency--high,
.card__urgency--critical {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
background: var(--so-error-soft);
color: var(--so-error);
border: 1px solid rgba(220,38,38,.2);
}
.card__urgency--normal {
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
background: var(--so-surface);
color: var(--so-text-secondary);
border: 1px solid var(--so-border-light);
}
.card__urgency--low {
background: var(--color-surface-tertiary);
color: var(--color-text-muted);
background: var(--so-surface);
color: var(--so-mute);
border: 1px solid var(--so-border-light);
}
.card__dep-status {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
padding: 1px var(--space-2);
border-radius: var(--radius-sm);
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 6px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.card__dep-status--running {
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
background: var(--so-info-soft);
color: var(--so-info);
border: 1px solid rgba(37,99,235,.2);
}
.card__dep-status--paused,
.card__dep-status--waiting {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
background: var(--so-warning-soft);
color: var(--so-warning);
border: 1px solid rgba(217,119,6,.2);
}
.card__progress {
height: 4px;
background: var(--color-surface-tertiary);
border-radius: 2px;
margin: var(--space-1) 0;
height: 5px;
background: var(--so-border-light);
border-radius: 3px;
margin: 6px 0;
overflow: hidden;
}
.card__progress-bar {
height: 100%;
background: var(--color-brand-primary);
border-radius: 2px;
transition: width var(--motion-duration-sm) var(--motion-ease-standard);
background: var(--so-gradient-cta);
border-radius: 3px;
transition: width 400ms var(--so-ease-out);
}
.card__actions {
display: flex;
gap: var(--space-2);
gap: 8px;
}
/* Table */
.table-container {
overflow-x: auto;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-default, rgba(212, 201, 168, 0.3));
border-radius: var(--radius-lg);
background: var(--so-surface-elevated);
border: 1px solid var(--so-border-light);
border-radius: 16px;
box-shadow: var(--so-shadow-sm);
}
.table {
@@ -530,107 +642,137 @@ import type {
.table th,
.table td {
padding: var(--space-2) var(--space-3);
padding: 12px 18px;
text-align: left;
border-bottom: 1px solid var(--color-border-default, rgba(212, 201, 168, 0.15));
border-bottom: 1px solid var(--so-border-light);
}
.table th {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
background: var(--color-surface-tertiary);
letter-spacing: 0.06em;
color: var(--so-text-secondary);
background: var(--so-surface);
}
.table th:first-child { border-radius: 16px 0 0 0; }
.table th:last-child { border-radius: 0 16px 0 0; }
.table td {
font-size: var(--font-size-sm);
color: var(--color-text-primary);
font-size: 13px;
color: var(--so-text);
}
.table tbody tr {
transition: background 150ms var(--so-ease-out);
}
.table tbody tr:hover {
background: var(--so-surface);
}
.table tbody tr:last-child td {
border-bottom: none;
}
.table a {
color: var(--color-brand-primary);
color: var(--so-accent);
text-decoration: none;
font-weight: 600;
}
&:hover {
text-decoration: underline;
}
.table a:hover {
color: var(--so-brand-hover);
text-decoration: underline;
}
/* Badges */
.badge {
display: inline-block;
padding: 2px var(--space-2);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
border-radius: var(--radius-sm);
padding: 3px 10px;
font-size: 10px;
font-weight: 700;
border-radius: 6px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge--deployed {
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
background: var(--so-success-soft);
color: var(--so-success);
border: 1px solid rgba(5,150,105,.2);
}
.badge--ready,
.badge--promoting {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
background: var(--so-warning-soft);
color: var(--so-warning);
border: 1px solid rgba(217,119,6,.2);
}
.badge--draft {
background: var(--color-surface-secondary);
color: var(--color-text-muted);
background: var(--so-surface);
color: var(--so-text-secondary);
border: 1px solid var(--so-border-light);
}
.badge--failed,
.badge--rolled_back {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
background: var(--so-error-soft);
color: var(--so-error);
border: 1px solid rgba(220,38,38,.2);
}
.badge--deprecated {
background: var(--color-surface-tertiary);
color: var(--color-text-muted);
background: var(--so-surface);
color: var(--so-mute);
border: 1px solid var(--so-border-light);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-4);
gap: 6px;
padding: 10px 20px;
border: none;
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
border-radius: 10px;
font-size: 13px;
font-weight: 700;
text-decoration: none;
cursor: pointer;
transition: background-color var(--motion-duration-sm) var(--motion-ease-standard);
transition: all 200ms var(--so-ease-out);
}
.btn--primary {
background: var(--color-brand-primary);
color: #fff;
background: var(--so-gradient-cta);
color: var(--so-heading);
box-shadow: var(--so-shadow-sm), var(--so-shadow-brand-md),
inset 0 1px 0 rgba(255,255,255,.2);
}
&:hover {
background: var(--color-brand-primary-hover, #c47f0f);
}
.btn--primary:hover {
background: linear-gradient(135deg, var(--so-brand-hover) 0%, var(--so-accent-muted) 100%);
box-shadow: var(--so-shadow-md), 0 6px 20px var(--so-shadow-brand);
transform: translateY(-1px);
}
.btn--secondary {
background: var(--color-surface-tertiary);
color: var(--color-text-primary);
border: 1px solid var(--color-border-default, rgba(212, 201, 168, 0.3));
background: var(--so-surface-elevated);
color: var(--so-text);
border: 1px solid var(--so-border-medium);
}
&:hover {
background: var(--color-surface-secondary);
}
.btn--secondary:hover {
background: var(--so-surface);
border-color: var(--so-brand);
}
.btn--small {
padding: var(--space-1) var(--space-2);
font-size: var(--font-size-xs);
padding: 5px 12px;
font-size: 12px;
border-radius: 8px;
}
@keyframes spin {
@@ -638,27 +780,21 @@ import type {
}
@media (prefers-reduced-motion: reduce) {
.dashboard__spinner {
animation: none;
border-top-color: transparent;
}
.btn {
transition: none;
}
.card__progress-bar {
transition: none;
}
.dashboard__spinner { animation: none; border-top-color: transparent; }
.btn, .card, .pipeline__stage, .table tbody tr { transition: none; }
.card__progress-bar { transition: none; }
.dashboard { animation: none; }
}
@media (max-width: 768px) {
.dashboard__header {
flex-direction: column;
padding: 20px;
}
.pipeline {
flex-direction: column;
padding: 16px;
}
.pipeline__arrow {

View File

@@ -1,8 +1,41 @@
// Home Dashboard Styles
// Security-focused landing page with aggregated metrics
// Security-focused landing page — Stella Ops warm amber design language
@use 'tokens/breakpoints' as *;
// ---------------------------------------------------------------------------
// Entrance animation
// ---------------------------------------------------------------------------
@keyframes dash-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes score-draw {
from { stroke-dashoffset: 283; }
}
// ---------------------------------------------------------------------------
// Container
// ---------------------------------------------------------------------------
.dashboard {
max-width: var(--container-xl);
margin: 0 auto;
@@ -20,13 +53,15 @@
margin-bottom: var(--space-6);
flex-wrap: wrap;
gap: var(--space-4);
animation: dash-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.dashboard__title {
margin: 0;
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
font-size: 26px;
font-weight: 800;
color: var(--color-text-heading);
letter-spacing: -0.01em;
}
.dashboard__actions {
@@ -38,6 +73,7 @@
.dashboard__updated {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-weight: var(--font-weight-medium);
}
.dashboard__refresh {
@@ -46,18 +82,19 @@
gap: var(--space-1-5);
padding: var(--space-2) var(--space-3-5);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
background-color: var(--color-surface-tertiary);
border: 1px solid var(--color-border-primary);
font-weight: 600;
color: var(--color-text-heading);
background: linear-gradient(135deg, #FEF3E2, #FFFCF5);
border: 1px solid hsla(45, 34%, 75%, 0.5);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
border-color var(--motion-duration-fast) var(--motion-ease-default);
transition: all 0.2s cubic-bezier(0.22, 1, 0.36, 1);
&:hover:not(:disabled) {
background-color: var(--color-surface-secondary);
border-color: var(--color-border-secondary);
background: linear-gradient(135deg, #FDE8C8, #FEF3E2);
border-color: rgba(245, 166, 35, 0.4);
box-shadow: 0 2px 8px rgba(245, 166, 35, 0.15);
transform: translateY(-1px);
}
&:disabled {
@@ -70,26 +107,23 @@
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
// =============================================================================
// Errors
// =============================================================================
.dashboard__errors {
margin-bottom: var(--space-4);
animation: dash-in 0.5s 0.1s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.dashboard__error {
padding: var(--space-3) var(--space-4);
font-size: var(--font-size-base);
color: var(--color-severity-high);
background-color: var(--color-status-warning-bg);
border: 1px solid var(--color-status-warning-border);
border-radius: var(--radius-md);
font-weight: var(--font-weight-medium);
color: #8B5E14;
background-color: #FFF5E6;
border: 1px solid #F5A623;
border-radius: 10px;
margin-bottom: var(--space-2);
}
@@ -113,20 +147,42 @@
// =============================================================================
.card {
background-color: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
position: relative;
background-color: #fff;
border: 1px solid hsla(45, 34%, 75%, 0.3);
border-radius: 16px;
overflow: hidden;
transition:
transform var(--motion-duration-normal) var(--motion-ease-default),
box-shadow var(--motion-duration-normal) var(--motion-ease-default),
border-color var(--motion-duration-normal) var(--motion-ease-default);
transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1);
animation: dash-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
// Gradient accent bar at top
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #F5A623, #D4920A);
opacity: 0;
transition: opacity 0.3s ease;
}
&:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
border-color: var(--color-border-secondary);
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(212, 146, 10, 0.1), 0 2px 8px rgba(0, 0, 0, 0.04);
border-color: hsla(45, 34%, 75%, 0.5);
&::before {
opacity: 1;
}
}
// Stagger animation for each card
&:nth-child(1) { animation-delay: 0.1s; }
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.3s; }
&:nth-child(4) { animation-delay: 0.4s; }
}
.card__header {
@@ -134,26 +190,29 @@
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-border-primary);
border-bottom: 1px solid hsla(45, 34%, 75%, 0.2);
background: linear-gradient(135deg, rgba(254, 243, 226, 0.3), transparent);
}
.card__title {
margin: 0;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
font-size: 15px;
font-weight: 700;
color: var(--color-text-heading);
letter-spacing: -0.01em;
}
.card__link {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-brand-primary);
font-weight: 600;
color: #D4920A;
text-decoration: none;
transition: color var(--motion-duration-fast) var(--motion-ease-default);
transition: all 0.2s ease;
letter-spacing: 0.01em;
&:hover {
text-decoration: underline;
color: var(--color-brand-primary-hover);
color: #F5A623;
}
}
@@ -196,20 +255,22 @@
align-items: center;
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border-primary);
border-top: 1px solid hsla(45, 34%, 75%, 0.2);
}
.card__stat-value {
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
font-size: 32px;
font-weight: 800;
color: var(--color-text-heading);
}
.card__stat-label {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-size: 11px;
font-weight: 600;
color: #6B5A2E;
text-transform: uppercase;
letter-spacing: 0.05em;
letter-spacing: 0.08em;
margin-top: 2px;
}
// =============================================================================
@@ -219,13 +280,13 @@
.skeleton {
background: linear-gradient(
90deg,
var(--color-surface-tertiary) 25%,
var(--color-surface-secondary) 50%,
var(--color-surface-tertiary) 75%
hsla(45, 34%, 75%, 0.15) 25%,
hsla(45, 34%, 75%, 0.05) 50%,
hsla(45, 34%, 75%, 0.15) 75%
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s ease-in-out infinite;
border-radius: var(--radius-sm);
border-radius: 8px;
&--bar {
height: var(--space-6);
@@ -245,11 +306,6 @@
}
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
// =============================================================================
// Severity Bars
// =============================================================================
@@ -257,7 +313,7 @@
.severity-bars {
display: flex;
flex-direction: column;
gap: var(--space-2-5);
gap: 10px;
}
.severity-bar {
@@ -268,33 +324,41 @@
}
.severity-bar__label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
font-size: 13px;
font-weight: 600;
color: #6B5A2E;
}
.severity-bar__track {
height: 8px;
background-color: var(--color-surface-tertiary);
border-radius: var(--radius-sm);
height: 10px;
background-color: hsla(45, 34%, 75%, 0.15);
border-radius: 5px;
overflow: hidden;
}
.severity-bar__fill {
height: 100%;
border-radius: var(--radius-sm);
transition: width 300ms ease;
border-radius: 5px;
transition: width 600ms cubic-bezier(0.22, 1, 0.36, 1);
}
.severity-bar--critical .severity-bar__fill { background-color: var(--color-severity-critical); }
.severity-bar--high .severity-bar__fill { background-color: var(--color-severity-high); }
.severity-bar--medium .severity-bar__fill { background-color: var(--color-severity-medium); }
.severity-bar--low .severity-bar__fill { background-color: var(--color-severity-low); }
.severity-bar--critical .severity-bar__fill {
background: linear-gradient(90deg, var(--color-severity-critical), #ff6b6b);
}
.severity-bar--high .severity-bar__fill {
background: linear-gradient(90deg, #D4920A, #F5A623);
}
.severity-bar--medium .severity-bar__fill {
background: linear-gradient(90deg, var(--color-severity-medium), #fbbf24);
}
.severity-bar--low .severity-bar__fill {
background: linear-gradient(90deg, var(--color-severity-low), #6ee7b7);
}
.severity-bar__count {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
font-size: 14px;
font-weight: 700;
color: var(--color-text-heading);
text-align: right;
}
@@ -311,8 +375,8 @@
.risk-score__circle {
position: relative;
width: 120px;
height: 120px;
width: 130px;
height: 130px;
svg {
transform: rotate(-90deg);
@@ -321,7 +385,7 @@
.risk-score__bg {
fill: none;
stroke: var(--color-surface-tertiary);
stroke: hsla(45, 34%, 75%, 0.2);
stroke-width: 8;
}
@@ -330,7 +394,9 @@
stroke-width: 8;
stroke-linecap: round;
stroke-dasharray: 283;
transition: stroke-dashoffset 500ms ease;
transition: stroke-dashoffset 800ms cubic-bezier(0.22, 1, 0.36, 1);
animation: score-draw 1s cubic-bezier(0.22, 1, 0.36, 1) both;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.1));
}
.risk-score__value {
@@ -339,30 +405,31 @@
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
font-size: 30px;
font-weight: 800;
color: var(--color-text-heading);
}
.risk-score__trend {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2-5);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
padding: 4px 12px;
font-size: 12px;
font-weight: 700;
border-radius: var(--radius-full);
background-color: var(--color-surface-tertiary);
color: var(--color-text-secondary);
background-color: hsla(45, 34%, 75%, 0.15);
color: #6B5A2E;
letter-spacing: 0.02em;
&--improving {
background-color: var(--color-status-success-bg);
color: var(--color-severity-low);
background-color: #D1FAE5;
color: #059669;
}
&--worsening {
background-color: var(--color-status-warning-bg);
color: var(--color-severity-high);
background-color: #FFF5E6;
color: #D4920A;
}
}
@@ -371,31 +438,31 @@
gap: var(--space-6);
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border-primary);
border-top: 1px solid hsla(45, 34%, 75%, 0.2);
}
.risk-count {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-0-5);
gap: 2px;
}
.risk-count__value {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
font-size: 20px;
font-weight: 800;
}
.risk-count__label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
letter-spacing: 0.08em;
color: #6B5A2E;
}
.risk-count--critical .risk-count__value { color: var(--color-severity-critical); }
.risk-count--high .risk-count__value { color: var(--color-severity-high); }
.risk-count--high .risk-count__value { color: #D4920A; }
.risk-count--medium .risk-count__value { color: var(--color-severity-medium); }
// =============================================================================
@@ -415,24 +482,26 @@
.reachability-donut__bg {
fill: none;
stroke: var(--color-surface-tertiary);
stroke: hsla(45, 34%, 75%, 0.15);
stroke-width: 12;
}
.reachability-donut__reachable {
fill: none;
stroke: var(--color-severity-high);
stroke: #D4920A;
stroke-width: 12;
stroke-linecap: round;
transition: stroke-dasharray 500ms ease;
transition: stroke-dasharray 600ms cubic-bezier(0.22, 1, 0.36, 1);
filter: drop-shadow(0 1px 2px rgba(212, 146, 10, 0.3));
}
.reachability-donut__unreachable {
fill: none;
stroke: var(--color-severity-low);
stroke: #059669;
stroke-width: 12;
stroke-linecap: round;
transition: stroke-dasharray 500ms ease, stroke-dashoffset 500ms ease;
transition: stroke-dasharray 600ms cubic-bezier(0.22, 1, 0.36, 1),
stroke-dashoffset 600ms cubic-bezier(0.22, 1, 0.36, 1);
}
.reachability-donut__center {
@@ -445,23 +514,23 @@
}
.reachability-donut__value {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
font-size: 26px;
font-weight: 800;
color: var(--color-text-heading);
}
.reachability-donut__label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
letter-spacing: 0.08em;
color: #6B5A2E;
}
.reachability-legend {
display: flex;
flex-direction: column;
gap: var(--space-2);
gap: 8px;
}
.reachability-legend__item {
@@ -474,22 +543,33 @@
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.reachability-legend__item--reachable .reachability-legend__dot { background-color: var(--color-severity-high); }
.reachability-legend__item--unreachable .reachability-legend__dot { background-color: var(--color-severity-low); }
.reachability-legend__item--uncertain .reachability-legend__dot { background-color: var(--color-text-muted); }
.reachability-legend__item--reachable .reachability-legend__dot {
background-color: #D4920A;
box-shadow: 0 0 0 2px rgba(212, 146, 10, 0.2);
}
.reachability-legend__item--unreachable .reachability-legend__dot {
background-color: #059669;
box-shadow: 0 0 0 2px rgba(5, 150, 105, 0.2);
}
.reachability-legend__item--uncertain .reachability-legend__dot {
background-color: #6B5A2E;
box-shadow: 0 0 0 2px rgba(107, 90, 46, 0.15);
}
.reachability-legend__label {
flex: 1;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
font-size: 13px;
font-weight: 500;
color: #6B5A2E;
}
.reachability-legend__value {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
font-size: 14px;
font-weight: 700;
color: var(--color-text-heading);
}
// =============================================================================
@@ -508,27 +588,38 @@
flex-direction: column;
align-items: center;
text-align: center;
padding: 12px 8px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(254, 243, 226, 0.3), transparent);
border: 1px solid hsla(45, 34%, 75%, 0.15);
transition: all 0.2s ease;
&:hover {
background: linear-gradient(135deg, rgba(254, 243, 226, 0.5), transparent);
border-color: hsla(45, 34%, 75%, 0.3);
}
}
.vex-stat__value {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
font-size: 24px;
font-weight: 800;
}
.vex-stat--suppressed .vex-stat__value { color: var(--color-severity-low); }
.vex-stat--active .vex-stat__value { color: var(--color-severity-high); }
.vex-stat--suppressed .vex-stat__value { color: #059669; }
.vex-stat--active .vex-stat__value { color: #D4920A; }
.vex-stat--investigating .vex-stat__value { color: var(--color-severity-medium); }
.vex-stat__label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
margin-top: var(--space-1);
font-size: 12px;
font-weight: 700;
color: var(--color-text-heading);
margin-top: 4px;
}
.vex-stat__sublabel {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-size: 11px;
font-weight: 500;
color: #6B5A2E;
}
.vex-impact {
@@ -536,20 +627,24 @@
flex-direction: column;
align-items: center;
padding-top: var(--space-4);
border-top: 1px solid var(--color-border-primary);
border-top: 1px solid hsla(45, 34%, 75%, 0.2);
}
.vex-impact__percent {
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
color: var(--color-severity-low);
font-size: 32px;
font-weight: 800;
background: linear-gradient(135deg, #059669, #34d399);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.vex-impact__label {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-size: 11px;
font-weight: 600;
color: #6B5A2E;
text-transform: uppercase;
letter-spacing: 0.05em;
letter-spacing: 0.08em;
}
// =============================================================================
@@ -558,13 +653,15 @@
.quick-actions {
margin-bottom: var(--space-8);
animation: dash-in 0.5s 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.quick-actions__title {
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
font-size: 18px;
font-weight: 700;
color: var(--color-text-heading);
margin: 0 0 var(--space-4);
letter-spacing: -0.01em;
}
.quick-actions__grid {
@@ -588,24 +685,30 @@
gap: var(--space-3);
padding: var(--space-5);
text-decoration: none;
background-color: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
color: var(--color-text-secondary);
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
border-color var(--motion-duration-fast) var(--motion-ease-default),
color var(--motion-duration-fast) var(--motion-ease-default),
transform var(--motion-duration-fast) var(--motion-ease-default);
background: #fff;
border: 1px solid hsla(45, 34%, 75%, 0.3);
border-radius: 14px;
color: #6B5A2E;
transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
&:hover {
background-color: var(--color-surface-secondary);
border-color: var(--color-brand-primary);
color: var(--color-brand-primary);
transform: translateY(-2px);
background: linear-gradient(135deg, #FEF3E2, #FFFCF5);
border-color: rgba(245, 166, 35, 0.4);
color: #D4920A;
transform: translateY(-3px);
box-shadow: 0 6px 20px rgba(212, 146, 10, 0.12), 0 2px 6px rgba(0, 0, 0, 0.03);
}
svg {
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
&:hover svg {
transform: scale(1.15);
}
span {
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
font-size: 14px;
font-weight: 600;
}
}

View File

@@ -6,6 +6,7 @@
/** Setup wizard step identifiers */
export type SetupStepId =
| 'welcome'
| 'database'
| 'cache'
| 'migrations'
@@ -25,6 +26,7 @@ export type SetupStepId =
/** Setup step categories */
export type SetupCategory =
| 'Welcome'
| 'Infrastructure'
| 'Security'
| 'Integration'
@@ -981,6 +983,19 @@ export const SOURCES_PROVIDERS: ProviderInfo[] = [
/** Default step definitions - Infrastructure-First order for CLI/UI parity */
export const DEFAULT_SETUP_STEPS: SetupStep[] = [
// Phase 0: Welcome
{
id: 'welcome',
name: 'Welcome',
description: 'Welcome to Stella Ops — let\u2019s get your platform configured.',
category: 'Welcome',
order: 0,
isRequired: false,
isSkippable: false,
dependencies: [],
validationChecks: [],
status: 'pending',
},
// Phase 1: Core Infrastructure (Required)
{
id: 'database',
@@ -1063,7 +1078,7 @@ export const DEFAULT_SETUP_STEPS: SetupStep[] = [
name: 'Secrets Vault',
description: 'Configure a secrets vault for secure credential storage (HashiCorp Vault, Azure Key Vault, AWS Secrets Manager, or GCP Secret Manager).',
category: 'Integration',
order: 60,
order: 125,
isRequired: false,
isSkippable: true,
dependencies: [],
@@ -1107,9 +1122,9 @@ export const DEFAULT_SETUP_STEPS: SetupStep[] = [
{
id: 'sources',
name: 'Advisory Data Sources',
description: 'Configure CVE/VEX advisory feeds (NVD, GHSA, OSV, distribution-specific feeds) for vulnerability data.',
description: 'Choose Stella Ops Mirror for pre-aggregated feeds or configure custom advisory sources for CVE/VEX vulnerability data.',
category: 'Release Control Plane',
order: 90,
order: 65,
isRequired: false,
isSkippable: true,
dependencies: [],

View File

@@ -293,10 +293,11 @@ export class SetupWizardStateService {
return;
}
// Can only go forward if all previous steps are completed/skipped
// Can only go forward if all previous required steps are completed/skipped
// (optional/skippable steps in pending state do not block forward navigation)
const canNavigate = this.orderedSteps()
.slice(0, stepIndex)
.every(s => s.status === 'completed' || s.status === 'skipped');
.every(s => s.status === 'completed' || s.status === 'skipped' || s.isSkippable);
if (canNavigate) {
this.currentStepId.set(stepId);
@@ -316,14 +317,21 @@ export class SetupWizardStateService {
}
/**
* Navigate to previous step
* Navigate to previous step (skips the welcome step)
*/
goToPreviousStep(): void {
const ordered = this.orderedSteps();
const currentIndex = this.currentStepIndex();
if (currentIndex > 0) {
this.currentStepId.set(ordered[currentIndex - 1].id);
// Skip back past the welcome step — don't return to it
let targetIndex = currentIndex - 1;
if (ordered[targetIndex]?.id === 'welcome') {
targetIndex--;
}
if (targetIndex >= 0) {
this.currentStepId.set(ordered[targetIndex].id);
}
}
}
@@ -557,7 +565,7 @@ export class SetupWizardStateService {
const step = this.currentStep();
if (!step) return false;
// Can proceed if step is completed or skipped
return step.status === 'completed' || step.status === 'skipped';
// Can proceed if step is completed, skipped, or optional (skippable) and still pending
return step.status === 'completed' || step.status === 'skipped' || step.isSkippable;
}
}

View File

@@ -81,7 +81,7 @@ import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
grid-template-columns: var(--sidebar-width, 200px) 1fr;
grid-template-rows: 1fr;
min-height: 100vh;
background: var(--color-surface-secondary);
background: #FFF9ED;
}
.shell--sidebar-collapsed {