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);
}
}