diff --git a/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs b/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs index 2984265c..147b6f48 100644 --- a/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs +++ b/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs @@ -2,12 +2,12 @@ namespace SliplaneManage.Apps; using SliplaneManage.Apps.Views; using SliplaneManage.Services; +using SliplaneManage.Models; /// /// 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 +26,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; draft is consumed once (one-shot pre-fill) + var draft = args is not null + ? DeploymentDraftStore.ParseGitHubUrl(args.Repo) + : draftStore.ReadAndClearDraft(); if (string.IsNullOrWhiteSpace(apiToken)) { @@ -37,13 +40,26 @@ 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); + 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 f63e0fae..53352105 100644 --- a/project-demos/sliplane-manage/Apps/Views/DeployView.cs +++ b/project-demos/sliplane-manage/Apps/Views/DeployView.cs @@ -52,31 +52,38 @@ public class DeployFormModel public class DeployView : ViewBase { private readonly string _apiToken; - private readonly string _repoUrl; + private readonly DeployDraft _draft; + private readonly string _defaultServerId; + private readonly string _defaultProjectId; - public DeployView(string apiToken, string repoUrl) + public DeployView(string apiToken, DeployDraft draft, string defaultServerId = "", string defaultProjectId = "") { - _apiToken = apiToken; - _repoUrl = repoUrl; + _apiToken = apiToken; + _draft = draft; + _defaultServerId = defaultServerId; + _defaultProjectId = defaultProjectId; } public override object? Build() { var client = this.UseService(); - var draftStore = this.UseService(); var refreshSender = this.CreateSignal(); - var initialName = DeriveServiceName(_repoUrl); - var model = this.UseState(() => new DeployFormModel { - GitRepo = _repoUrl, - Name = initialName, + 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 = "/", }); - // Keep DeploymentDraftStore in sync as the user edits the repo URL (per-user) - this.UseEffect(() => draftStore.SaveRepoUrl(model.Value.GitRepo), model); - var envList = this.UseState>(() => new List()); var showAddEnvDlg = this.UseState(false); var addEnvKey = this.UseState(string.Empty); @@ -301,10 +308,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/Apps/Views/ServicesView.cs b/project-demos/sliplane-manage/Apps/Views/ServicesView.cs index ae4302f1..8e217025 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,106 @@ 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 (fallback when we don't get useful events). + 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); + } + + // 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), + options: new QueryOptions + { + RefreshInterval = TimeSpan.FromSeconds(5), + KeepPrevious = true + }); + + var events = eventsQuery.Value ?? new List(); + if (events.Count > 0) + { + 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) + .Duration(1), + "pending"); + } + + // 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"); + } + + return MapBase(svc.Status); + } + private static object[] BuildServiceCards( + IViewContext ctx, + SliplaneApiClient client, + string apiToken, List<(string ProjectId, string ProjectName, SliplaneService Service)> currentServices, List serverList, Action showSheet) @@ -112,14 +211,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; diff --git a/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs b/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs index e58d25f9..ff7f9350 100644 --- a/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs +++ b/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs @@ -1,54 +1,106 @@ 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 http) => _http = http; - public DeploymentDraftStore(IHttpContextAccessor httpContextAccessor) + public DeployDraft? LastDraft => GetDraft(); + + /// + /// Returns the draft and immediately removes it from the store (one-shot pre-fill). + /// + public DeployDraft? ReadAndClearDraft() { - _httpContextAccessor = httpContextAccessor; + var key = GetCurrentKey(); + if (key is null) return null; + _store.TryRemove(key, out var draft); + return draft; } - public string? LastRepoUrl => GetRepoUrl(); + public void SaveDraft(DeployDraft draft) + { + _store[GetOrCreateKey()] = draft; + } - 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 +116,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); } }