save checkpoint
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Moq" />
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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: [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user