From a359160e7d5651c8cd13c65801c4b2d54640bbc9 Mon Sep 17 00:00:00 2001 From: Artem Date: Thu, 5 Mar 2026 19:41:06 +0200 Subject: [PATCH 1/5] refactor: enhance Sliplane deployment logic with DeployDraft for improved repository handling --- .../sliplane-manage/Apps/SliplaneDeployApp.cs | 22 +++--- .../sliplane-manage/Apps/Views/DeployView.cs | 31 +++++--- .../Services/DeploymentDraftStore.cs | 77 ++++++++++++++----- .../Services/RepoCaptureFilter.cs | 9 ++- 4 files changed, 95 insertions(+), 44 deletions(-) diff --git a/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs b/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs index 2984265c..de8f2c60 100644 --- a/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs +++ b/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs @@ -5,9 +5,8 @@ namespace SliplaneManage.Apps; /// /// Route: /sliplane-deploy-app -/// Opened via the "Host your Ivy app on Sliplane" button in GitHub READMEs. -/// The ?repo= query param is captured by RepoCaptureFilter before Ivy SPA loads, -/// stored in DeploymentDraftStore, and read here to pre-fill the deploy form. +/// Opened via the "Host your Ivy app on Sliplane" button. +/// ?repo= is captured by RepoCaptureFilter, parsed into a DeployDraft, and pre-fills the form. /// [App( id: "sliplane-deploy-app", @@ -26,10 +25,13 @@ public class SliplaneDeployApp : ViewBase ?? session.AuthToken?.AccessToken ?? string.Empty; - // Repo from internal navigation args or last saved value (per-user) var draftStore = this.UseService(); - var args = this.UseArgs(); - var repoUrl = args?.Repo ?? draftStore.LastRepoUrl ?? string.Empty; + var args = this.UseArgs(); + + // Args from internal navigation take priority over the stored draft + var draft = args is not null + ? DeploymentDraftStore.ParseGitHubUrl(args.Repo) + : draftStore.LastDraft; if (string.IsNullOrWhiteSpace(apiToken)) { @@ -37,13 +39,13 @@ public class SliplaneDeployApp : ViewBase | (Layout.Vertical().Align(Align.Center).Gap(6) | Icons.Rocket.ToIcon() | Text.H2("Deploy to Sliplane") - | (string.IsNullOrWhiteSpace(repoUrl) - ? Text.Muted("Sign in with Sliplane to deploy your Ivy app.") - : Text.Muted($"Repository: {repoUrl}")) + | (draft is not null + ? Text.Muted($"Repository: {draft.RepoUrl}") + : Text.Muted("Sign in with Sliplane to deploy your Ivy app.")) | Text.Muted("No API token. Please sign in or configure Sliplane:ApiToken.")); } - return new DeployView(apiToken, repoUrl); + return new DeployView(apiToken, draft ?? new DeployDraft(string.Empty)); } } diff --git a/project-demos/sliplane-manage/Apps/Views/DeployView.cs b/project-demos/sliplane-manage/Apps/Views/DeployView.cs index f63e0fae..bf98f842 100644 --- a/project-demos/sliplane-manage/Apps/Views/DeployView.cs +++ b/project-demos/sliplane-manage/Apps/Views/DeployView.cs @@ -52,12 +52,12 @@ public class DeployFormModel public class DeployView : ViewBase { private readonly string _apiToken; - private readonly string _repoUrl; + private readonly DeployDraft _draft; - public DeployView(string apiToken, string repoUrl) + public DeployView(string apiToken, DeployDraft draft) { _apiToken = apiToken; - _repoUrl = repoUrl; + _draft = draft; } public override object? Build() @@ -66,16 +66,17 @@ public DeployView(string apiToken, string repoUrl) var draftStore = this.UseService(); var refreshSender = this.CreateSignal(); - var initialName = DeriveServiceName(_repoUrl); - var model = this.UseState(() => new DeployFormModel { - GitRepo = _repoUrl, - Name = initialName, + GitRepo = _draft.RepoUrl, + Branch = _draft.Branch, + DockerContext = _draft.DockerContext, + DockerfilePath = _draft.DockerfilePath, + Name = DeriveServiceName(_draft.RepoUrl, _draft.DockerContext), }); - // Keep DeploymentDraftStore in sync as the user edits the repo URL (per-user) - this.UseEffect(() => draftStore.SaveRepoUrl(model.Value.GitRepo), model); + // Keep draft in sync as the user edits the repo URL + this.UseEffect(() => draftStore.SaveDraft(DeploymentDraftStore.ParseGitHubUrl(model.Value.GitRepo)), model); var envList = this.UseState>(() => new List()); var showAddEnvDlg = this.UseState(false); @@ -301,10 +302,16 @@ void SaveVolume() return page; } - private static string DeriveServiceName(string repoUrl) + // Prefer the last segment of dockerContext (e.g. "packages-demos/yamldotnet" → "yamldotnet"), + // falling back to the last segment of the repo URL. + private static string DeriveServiceName(string repoUrl, string dockerContext = ".") { - if (string.IsNullOrWhiteSpace(repoUrl)) return string.Empty; - var seg = repoUrl.TrimEnd('/').Split('/').LastOrDefault() ?? string.Empty; + var source = (!string.IsNullOrWhiteSpace(dockerContext) && dockerContext != ".") + ? dockerContext + : repoUrl; + + if (string.IsNullOrWhiteSpace(source)) return string.Empty; + var seg = source.TrimEnd('/').Split('/').LastOrDefault() ?? string.Empty; if (seg.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) seg = seg[..^4]; return string.IsNullOrWhiteSpace(seg) ? string.Empty : seg.ToLowerInvariant(); } diff --git a/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs b/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs index e58d25f9..6923de91 100644 --- a/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs +++ b/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs @@ -1,54 +1,95 @@ namespace SliplaneManage.Services; +using System.Collections.Concurrent; +using System.Text.RegularExpressions; using Microsoft.AspNetCore.Http; /// -/// Per-user store for the deployment repo URL. -/// Key: access token (logged-in) or anonymous browser cookie (pre-login). +/// Parsed info from a GitHub URL (repo page or tree URL). +/// +public record DeployDraft( + string RepoUrl, + string Branch = "main", + string DockerContext = ".", + string DockerfilePath = "Dockerfile"); + +/// +/// Per-user store for the last deploy draft. +/// Key: Sliplane access-token cookie (logged-in) or anonymous browser cookie (pre-login). /// public class DeploymentDraftStore { - private static readonly System.Collections.Concurrent.ConcurrentDictionary _store = new(); + private static readonly ConcurrentDictionary _store = new(); public const string CookieName = "sliplane-deploy-repo-key"; - private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IHttpContextAccessor _http; - public DeploymentDraftStore(IHttpContextAccessor httpContextAccessor) + public DeploymentDraftStore(IHttpContextAccessor http) => _http = http; + + public DeployDraft? LastDraft => GetDraft(); + + public void SaveDraft(DeployDraft draft) { - _httpContextAccessor = httpContextAccessor; + _store[GetOrCreateKey()] = draft; } - public string? LastRepoUrl => GetRepoUrl(); - - public void SaveRepoUrl(string? repoUrl) + /// + /// Parses a GitHub URL into a . + /// Supports: + /// https://github.com/{owner}/{repo}/tree/{branch}/{subpath} → repo + branch + docker context + /// https://github.com/{owner}/{repo} → repo only + /// + public static DeployDraft ParseGitHubUrl(string input) { - if (string.IsNullOrWhiteSpace(repoUrl)) return; - _store[GetOrCreateKey()] = repoUrl; + input = input.Trim().TrimEnd('/'); + + // https://github.com/owner/repo/tree/branch/sub/path + var treeMatch = Regex.Match(input, + @"^https://github\.com/(?[^/]+)/(?[^/]+)/tree/(?[^/]+)(?:/(?.+))?$"); + + if (treeMatch.Success) + { + var repoUrl = $"https://github.com/{treeMatch.Groups["owner"].Value}/{treeMatch.Groups["repo"].Value}"; + var branch = treeMatch.Groups["branch"].Value; + var subPath = treeMatch.Groups["path"].Value.TrimEnd('/'); + + if (string.IsNullOrWhiteSpace(subPath)) + return new DeployDraft(repoUrl, branch); + + return new DeployDraft( + RepoUrl: repoUrl, + Branch: branch, + DockerContext: subPath, + DockerfilePath: $"{subPath}/Dockerfile"); + } + + // Plain repo URL: https://github.com/owner/repo + return new DeployDraft(input); } - private string? GetRepoUrl() + // ── private helpers ────────────────────────────────────────────────────── + + private DeployDraft? GetDraft() { var key = GetCurrentKey(); - return key is not null && _store.TryGetValue(key, out var url) ? url : null; + return key is not null && _store.TryGetValue(key, out var d) ? d : null; } private string? GetCurrentKey() { - var ctx = _httpContextAccessor.HttpContext; + var ctx = _http.HttpContext; if (ctx is null) return null; - // Prefer access token (per authenticated user) var token = ctx.Request.Cookies[".ivy.auth.token"]; if (!string.IsNullOrWhiteSpace(token)) return "token:" + token; - // Fall back to anonymous browser cookie return ctx.Request.Cookies.TryGetValue(CookieName, out var k) && !string.IsNullOrWhiteSpace(k) ? k : null; } private string GetOrCreateKey() { - var ctx = _httpContextAccessor.HttpContext; + var ctx = _http.HttpContext; if (ctx is not null) { @@ -64,7 +105,7 @@ private string GetOrCreateKey() { HttpOnly = true, SameSite = SameSiteMode.Lax, - Expires = DateTimeOffset.UtcNow.AddHours(2), + Expires = DateTimeOffset.UtcNow.AddHours(2), }); return newKey; diff --git a/project-demos/sliplane-manage/Services/RepoCaptureFilter.cs b/project-demos/sliplane-manage/Services/RepoCaptureFilter.cs index d4ddfb7b..428c6ab2 100644 --- a/project-demos/sliplane-manage/Services/RepoCaptureFilter.cs +++ b/project-demos/sliplane-manage/Services/RepoCaptureFilter.cs @@ -7,7 +7,7 @@ namespace SliplaneManage.Services; /// /// Captures ?repo= from the initial GET /sliplane-deploy-app before Ivy SPA strips query params. -/// Stores it in so DeployView can pre-fill the form. +/// Parses GitHub URLs (including /tree/branch/subpath) and stores a . /// public class RepoCaptureFilter : IStartupFilter { @@ -19,11 +19,12 @@ public Action Configure(Action next) { if (context.Request.Path.StartsWithSegments("/sliplane-deploy-app", StringComparison.OrdinalIgnoreCase)) { - var repo = context.Request.Query["repo"].ToString(); - if (!string.IsNullOrWhiteSpace(repo)) + var raw = context.Request.Query["repo"].ToString(); + if (!string.IsNullOrWhiteSpace(raw)) { + var draft = DeploymentDraftStore.ParseGitHubUrl(Uri.UnescapeDataString(raw)); var store = context.RequestServices.GetRequiredService(); - store.SaveRepoUrl(Uri.UnescapeDataString(repo)); + store.SaveDraft(draft); } } From 5e893744d9b0018f15e4cd07d5ba2d3f6cb48dbc Mon Sep 17 00:00:00 2001 From: Artem Date: Thu, 5 Mar 2026 19:59:04 +0200 Subject: [PATCH 2/5] refactor: streamline draft handling in SliplaneDeployApp and DeployView for improved user experience --- .../sliplane-manage/Apps/SliplaneDeployApp.cs | 4 ++-- .../sliplane-manage/Apps/Views/DeployView.cs | 4 ---- .../sliplane-manage/Services/DeploymentDraftStore.cs | 11 +++++++++++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs b/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs index de8f2c60..db9b0381 100644 --- a/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs +++ b/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs @@ -28,10 +28,10 @@ public class SliplaneDeployApp : ViewBase var draftStore = this.UseService(); var args = this.UseArgs(); - // Args from internal navigation take priority over the stored draft + // Args from internal navigation take priority; draft is consumed once (one-shot pre-fill) var draft = args is not null ? DeploymentDraftStore.ParseGitHubUrl(args.Repo) - : draftStore.LastDraft; + : draftStore.ReadAndClearDraft(); if (string.IsNullOrWhiteSpace(apiToken)) { diff --git a/project-demos/sliplane-manage/Apps/Views/DeployView.cs b/project-demos/sliplane-manage/Apps/Views/DeployView.cs index bf98f842..745a84f4 100644 --- a/project-demos/sliplane-manage/Apps/Views/DeployView.cs +++ b/project-demos/sliplane-manage/Apps/Views/DeployView.cs @@ -63,7 +63,6 @@ public DeployView(string apiToken, DeployDraft draft) public override object? Build() { var client = this.UseService(); - var draftStore = this.UseService(); var refreshSender = this.CreateSignal(); var model = this.UseState(() => new DeployFormModel @@ -75,9 +74,6 @@ public DeployView(string apiToken, DeployDraft draft) Name = DeriveServiceName(_draft.RepoUrl, _draft.DockerContext), }); - // Keep draft in sync as the user edits the repo URL - this.UseEffect(() => draftStore.SaveDraft(DeploymentDraftStore.ParseGitHubUrl(model.Value.GitRepo)), model); - var envList = this.UseState>(() => new List()); var showAddEnvDlg = this.UseState(false); var addEnvKey = this.UseState(string.Empty); diff --git a/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs b/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs index 6923de91..ff7f9350 100644 --- a/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs +++ b/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs @@ -29,6 +29,17 @@ public class DeploymentDraftStore public DeployDraft? LastDraft => GetDraft(); + /// + /// Returns the draft and immediately removes it from the store (one-shot pre-fill). + /// + public DeployDraft? ReadAndClearDraft() + { + var key = GetCurrentKey(); + if (key is null) return null; + _store.TryRemove(key, out var draft); + return draft; + } + public void SaveDraft(DeployDraft draft) { _store[GetOrCreateKey()] = draft; From e089159774a7822085d4df3e7ed5fc8bb51de9bc Mon Sep 17 00:00:00 2001 From: Artem Date: Thu, 5 Mar 2026 20:28:38 +0200 Subject: [PATCH 3/5] refactor: enhance ServicesView with improved service status visualization and context handling --- .../Apps/Views/ServicesView.cs | 91 +++++++++++++++++-- 1 file changed, 82 insertions(+), 9 deletions(-) diff --git a/project-demos/sliplane-manage/Apps/Views/ServicesView.cs b/project-demos/sliplane-manage/Apps/Views/ServicesView.cs index ae4302f1..6eacc02b 100644 --- a/project-demos/sliplane-manage/Apps/Views/ServicesView.cs +++ b/project-demos/sliplane-manage/Apps/Views/ServicesView.cs @@ -85,7 +85,7 @@ void ShowServiceSheet(string projectId, string projectName, SliplaneService svc) } else { - var cards = BuildServiceCards(currentServices, servers, ShowServiceSheet); + var cards = BuildServiceCards(this.Context, client, _apiToken, currentServices, servers, ShowServiceSheet); content = Layout.Vertical() | headerRow | (Layout.Grid().Columns(3) | cards); @@ -99,7 +99,87 @@ void ShowServiceSheet(string projectId, string projectName, SliplaneService svc) ); } + private static (object Icon, string Label) GetStatusVisual( + IViewContext ctx, + SliplaneApiClient client, + string apiToken, + string projectId, + SliplaneService svc) + { + // Base mapping from raw status + static (object Icon, string Label) MapBase(string? status) + { + if (string.IsNullOrWhiteSpace(status)) + return (Icons.MonitorStop.ToIcon(), "—"); + + if (string.Equals(status, "live", StringComparison.OrdinalIgnoreCase)) + return (Icons.Play.ToIcon(), "live"); + + if (string.Equals(status, "suspended", StringComparison.OrdinalIgnoreCase) + || string.Equals(status, "paused", StringComparison.OrdinalIgnoreCase)) + return (Icons.Pause.ToIcon(), status); + + if (string.Equals(status, "error", StringComparison.OrdinalIgnoreCase) + || string.Equals(status, "failed", StringComparison.OrdinalIgnoreCase)) + return (Icons.CircleX.ToIcon(), status); + + return (Icons.MonitorStop.ToIcon(), status); + } + + // Only pending status needs deeper inspection + if (!string.Equals(svc.Status, "pending", StringComparison.OrdinalIgnoreCase)) + return MapBase(svc.Status); + + var eventsQuery = ctx.UseQuery?, (string, string, string)>( + key: ("service-events-status", projectId, svc.Id), + fetcher: async _ => await client.GetServiceEventsAsync(apiToken, projectId, svc.Id), + options: new QueryOptions + { + RefreshInterval = TimeSpan.FromSeconds(5), + KeepPrevious = true + }); + + var events = eventsQuery.Value ?? new List(); + if (events.Count == 0) + { + // Still no events – keep pending spinner + return (Icons.LoaderCircle.ToIcon() + .WithAnimation(AnimationType.Rotate) + .Trigger(AnimationTrigger.Auto) + .Duration(1), + "pending"); + } + + var last = events.OrderByDescending(e => e.CreatedAt).First(); + var type = last.Type?.ToLowerInvariant() ?? string.Empty; + var msg = last.Message?.ToLowerInvariant() ?? string.Empty; + + bool IsError() => + type.Contains("error") || type.Contains("fail") || type.Contains("failed") || + msg.Contains("error") || msg.Contains("fail") || msg.Contains("failed"); + + bool IsSuccess() => + type.Contains("live") || type.Contains("ready") || type.Contains("success") + || type.Contains("deployed") || type.Contains("healthy"); + + if (IsError()) + return (Icons.CircleX.ToIcon(), "error"); + + if (IsSuccess()) + return (Icons.CircleCheck.ToIcon(), "live"); + + // Unknown event type – keep pending spinner + return (Icons.LoaderCircle.ToIcon() + .WithAnimation(AnimationType.Rotate) + .Trigger(AnimationTrigger.Auto) + .Duration(1), + "pending"); + } + private static object[] BuildServiceCards( + IViewContext ctx, + SliplaneApiClient client, + string apiToken, List<(string ProjectId, string ProjectName, SliplaneService Service)> currentServices, List serverList, Action showSheet) @@ -112,14 +192,7 @@ private static object[] BuildServiceCards( var serverLabel = string.IsNullOrWhiteSpace(svc.ServerId) ? "—" : (serverList.FirstOrDefault(s => s.Id == svc.ServerId)?.Name ?? svc.ServerId); - var statusLabel = string.IsNullOrWhiteSpace(svc.Status) ? "—" : svc.Status; - object statusIcon = string.Equals(svc.Status, "pending", StringComparison.OrdinalIgnoreCase) - ? Icons.LoaderCircle.ToIcon().WithAnimation(AnimationType.Rotate).Trigger(AnimationTrigger.Auto).Duration(1) - : string.Equals(svc.Status, "live", StringComparison.OrdinalIgnoreCase) - ? Icons.Play.ToIcon() - : string.Equals(svc.Status, "suspended", StringComparison.OrdinalIgnoreCase) - ? Icons.Pause.ToIcon() - : Icons.MonitorStop.ToIcon(); + var (statusIcon, statusLabel) = GetStatusVisual(ctx, client, apiToken, projectId, svc); var siteUrl = svc.Network?.CustomDomains?.FirstOrDefault()?.Domain ?? svc.Network?.ManagedDomain ?? string.Empty; From 92793afdb3a3c9f3229e33915c1fcbdb5e68e1a8 Mon Sep 17 00:00:00 2001 From: Artem Date: Thu, 5 Mar 2026 22:54:52 +0200 Subject: [PATCH 4/5] refactor: enhance SliplaneDeployApp and DeployView to pre-fill server and project IDs based on user context --- .../sliplane-manage/Apps/SliplaneDeployApp.cs | 16 +++++++++++- .../sliplane-manage/Apps/Views/DeployView.cs | 26 +++++++++++++------ 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs b/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs index db9b0381..147b6f48 100644 --- a/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs +++ b/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs @@ -2,6 +2,7 @@ namespace SliplaneManage.Apps; using SliplaneManage.Apps.Views; using SliplaneManage.Services; +using SliplaneManage.Models; /// /// Route: /sliplane-deploy-app @@ -45,7 +46,20 @@ public class SliplaneDeployApp : ViewBase | Text.Muted("No API token. Please sign in or configure Sliplane:ApiToken.")); } - return new DeployView(apiToken, draft ?? new DeployDraft(string.Empty)); + var client = this.UseService(); + var firstServerQuery = this.UseQuery( + key: ("deploy-default-server", apiToken), + fetcher: async (_, ct) => (await client.GetServersAsync(apiToken)).FirstOrDefault()); + var firstProjectQuery = this.UseQuery( + key: ("deploy-default-project", apiToken), + fetcher: async (_, ct) => (await client.GetProjectsAsync(apiToken)).FirstOrDefault()); + + // Pre-fill server/project only when we came from the deploy button (draft present). + // On refresh or opening Deploy from sidebar without repo → draft is empty → form starts blank. + var defaultServerId = draft is not null ? (firstServerQuery.Value?.Id ?? "") : ""; + var defaultProjectId = draft is not null ? (firstProjectQuery.Value?.Id ?? "") : ""; + + return new DeployView(apiToken, draft ?? new DeployDraft(string.Empty), defaultServerId, defaultProjectId); } } diff --git a/project-demos/sliplane-manage/Apps/Views/DeployView.cs b/project-demos/sliplane-manage/Apps/Views/DeployView.cs index 745a84f4..53352105 100644 --- a/project-demos/sliplane-manage/Apps/Views/DeployView.cs +++ b/project-demos/sliplane-manage/Apps/Views/DeployView.cs @@ -53,11 +53,15 @@ public class DeployView : ViewBase { private readonly string _apiToken; private readonly DeployDraft _draft; + private readonly string _defaultServerId; + private readonly string _defaultProjectId; - public DeployView(string apiToken, DeployDraft draft) + public DeployView(string apiToken, DeployDraft draft, string defaultServerId = "", string defaultProjectId = "") { - _apiToken = apiToken; - _draft = draft; + _apiToken = apiToken; + _draft = draft; + _defaultServerId = defaultServerId; + _defaultProjectId = defaultProjectId; } public override object? Build() @@ -67,11 +71,17 @@ public DeployView(string apiToken, DeployDraft draft) var model = this.UseState(() => new DeployFormModel { - GitRepo = _draft.RepoUrl, - Branch = _draft.Branch, - DockerContext = _draft.DockerContext, - DockerfilePath = _draft.DockerfilePath, - Name = DeriveServiceName(_draft.RepoUrl, _draft.DockerContext), + ServerId = _defaultServerId, + ProjectId = _defaultProjectId, + GitRepo = _draft.RepoUrl, + Branch = string.IsNullOrWhiteSpace(_draft.Branch) ? "main" : _draft.Branch, + DockerContext = string.IsNullOrWhiteSpace(_draft.DockerContext) ? "." : _draft.DockerContext, + DockerfilePath = string.IsNullOrWhiteSpace(_draft.DockerfilePath) ? "Dockerfile" : _draft.DockerfilePath, + Name = DeriveServiceName(_draft.RepoUrl, _draft.DockerContext), + AutoDeploy = true, + NetworkPublic = true, + NetworkProtocol = "http", + Healthcheck = "/", }); var envList = this.UseState>(() => new List()); From 6b5097643dd8b85ccc524be4dfb41b212d219b07 Mon Sep 17 00:00:00 2001 From: Artem Date: Fri, 6 Mar 2026 10:19:24 +0200 Subject: [PATCH 5/5] refactor: improve service status handling in ServicesView with enhanced event processing and fallback logic --- .../Apps/Views/ServicesView.cs | 79 ++++++++++++------- 1 file changed, 49 insertions(+), 30 deletions(-) diff --git a/project-demos/sliplane-manage/Apps/Views/ServicesView.cs b/project-demos/sliplane-manage/Apps/Views/ServicesView.cs index 6eacc02b..8e217025 100644 --- a/project-demos/sliplane-manage/Apps/Views/ServicesView.cs +++ b/project-demos/sliplane-manage/Apps/Views/ServicesView.cs @@ -106,7 +106,7 @@ private static (object Icon, string Label) GetStatusVisual( string projectId, SliplaneService svc) { - // Base mapping from raw status + // Base mapping from raw status (fallback when we don't get useful events). static (object Icon, string Label) MapBase(string? status) { if (string.IsNullOrWhiteSpace(status)) @@ -126,10 +126,7 @@ private static (object Icon, string Label) GetStatusVisual( return (Icons.MonitorStop.ToIcon(), status); } - // Only pending status needs deeper inspection - if (!string.Equals(svc.Status, "pending", StringComparison.OrdinalIgnoreCase)) - return MapBase(svc.Status); - + // Always look at events (with polling) and derive status from the latest event. var eventsQuery = ctx.UseQuery?, (string, string, string)>( key: ("service-events-status", projectId, svc.Id), fetcher: async _ => await client.GetServiceEventsAsync(apiToken, projectId, svc.Id), @@ -140,9 +137,44 @@ private static (object Icon, string Label) GetStatusVisual( }); var events = eventsQuery.Value ?? new List(); - if (events.Count == 0) + if (events.Count > 0) { - // Still no events – keep pending spinner + var ordered = events.OrderByDescending(e => e.CreatedAt).ToList(); + var latest = ordered.First(); + var latestType = latest.Type?.ToLowerInvariant() ?? string.Empty; + + // Most recent action wins for suspend: if the last event is a suspend, + // show the service as suspended regardless of previous build failures. + if (latestType.Contains("suspend")) + return (Icons.Pause.ToIcon(), "suspended"); + + // For all other cases, look for the latest *meaningful* event: ignore + // suspend/resume toggles so a previous build failure still shows as + // error even after a resume, until there is an actual successful deploy. + var lastMeaningful = ordered.FirstOrDefault(e => + { + var t = e.Type?.ToLowerInvariant() ?? string.Empty; + return !t.Contains("suspend") && !t.Contains("resume"); + }) ?? latest; + + var type = lastMeaningful.Type?.ToLowerInvariant() ?? string.Empty; + var msg = lastMeaningful.Message?.ToLowerInvariant() ?? string.Empty; + + bool IsError() => + type.Contains("error") || type.Contains("fail") || type.Contains("failed") || + msg.Contains("error") || msg.Contains("fail") || msg.Contains("failed"); + + bool IsSuccess() => + type.Contains("live") || type.Contains("ready") || type.Contains("success") + || type.Contains("deployed") || type.Contains("healthy"); + + if (IsError()) + return (Icons.CircleX.ToIcon(), "error"); + + if (IsSuccess()) + return (Icons.CircleCheck.ToIcon(), "live"); + + // Unknown event type – show pending spinner while actions are in-flight. return (Icons.LoaderCircle.ToIcon() .WithAnimation(AnimationType.Rotate) .Trigger(AnimationTrigger.Auto) @@ -150,30 +182,17 @@ private static (object Icon, string Label) GetStatusVisual( "pending"); } - var last = events.OrderByDescending(e => e.CreatedAt).First(); - var type = last.Type?.ToLowerInvariant() ?? string.Empty; - var msg = last.Message?.ToLowerInvariant() ?? string.Empty; - - bool IsError() => - type.Contains("error") || type.Contains("fail") || type.Contains("failed") || - msg.Contains("error") || msg.Contains("fail") || msg.Contains("failed"); - - bool IsSuccess() => - type.Contains("live") || type.Contains("ready") || type.Contains("success") - || type.Contains("deployed") || type.Contains("healthy"); - - if (IsError()) - return (Icons.CircleX.ToIcon(), "error"); - - if (IsSuccess()) - return (Icons.CircleCheck.ToIcon(), "live"); + // No events yet – fall back to raw status (includes paused/suspended/error etc.). + if (string.Equals(svc.Status, "pending", StringComparison.OrdinalIgnoreCase)) + { + return (Icons.LoaderCircle.ToIcon() + .WithAnimation(AnimationType.Rotate) + .Trigger(AnimationTrigger.Auto) + .Duration(1), + "pending"); + } - // Unknown event type – keep pending spinner - return (Icons.LoaderCircle.ToIcon() - .WithAnimation(AnimationType.Rotate) - .Trigger(AnimationTrigger.Auto) - .Duration(1), - "pending"); + return MapBase(svc.Status); } private static object[] BuildServiceCards(