Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ namespace SliplaneManage.Apps;

using SliplaneManage.Apps.Views;
using SliplaneManage.Services;
using SliplaneManage.Models;

/// <summary>
/// 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.
/// </summary>
[App(
id: "sliplane-deploy-app",
Expand All @@ -26,24 +26,40 @@ public class SliplaneDeployApp : ViewBase
?? session.AuthToken?.AccessToken
?? string.Empty;

// Repo from internal navigation args or last saved value (per-user)
var draftStore = this.UseService<DeploymentDraftStore>();
var args = this.UseArgs<DeployArgs>();
var repoUrl = args?.Repo ?? draftStore.LastRepoUrl ?? string.Empty;
var args = this.UseArgs<DeployArgs>();

// 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))
{
return Layout.Center()
| (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<SliplaneApiClient>();
var firstServerQuery = this.UseQuery<SliplaneServer?, (string, string)>(
key: ("deploy-default-server", apiToken),
fetcher: async (_, ct) => (await client.GetServersAsync(apiToken)).FirstOrDefault());
var firstProjectQuery = this.UseQuery<SliplaneProject?, (string, string)>(
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);
}
}

Expand Down
43 changes: 28 additions & 15 deletions project-demos/sliplane-manage/Apps/Views/DeployView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SliplaneApiClient>();
var draftStore = this.UseService<DeploymentDraftStore>();
var refreshSender = this.CreateSignal<SliplaneRefreshSignal, string, Unit>();

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<List<EnvironmentVariable>>(() => new List<EnvironmentVariable>());
var showAddEnvDlg = this.UseState(false);
var addEnvKey = this.UseState(string.Empty);
Expand Down Expand Up @@ -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();
}
Expand Down
110 changes: 101 additions & 9 deletions project-demos/sliplane-manage/Apps/Views/ServicesView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<List<SliplaneServiceEvent>?, (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<SliplaneServiceEvent>();
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<SliplaneServer> serverList,
Action<string, string, SliplaneService> showSheet)
Expand All @@ -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;
Expand Down
86 changes: 69 additions & 17 deletions project-demos/sliplane-manage/Services/DeploymentDraftStore.cs
Original file line number Diff line number Diff line change
@@ -1,54 +1,106 @@
namespace SliplaneManage.Services;

using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Http;

/// <summary>
/// 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).
/// </summary>
public record DeployDraft(
string RepoUrl,
string Branch = "main",
string DockerContext = ".",
string DockerfilePath = "Dockerfile");

/// <summary>
/// Per-user store for the last deploy draft.
/// Key: Sliplane access-token cookie (logged-in) or anonymous browser cookie (pre-login).
/// </summary>
public class DeploymentDraftStore
{
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, string> _store = new();
private static readonly ConcurrentDictionary<string, DeployDraft> _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();

/// <summary>
/// Returns the draft and immediately removes it from the store (one-shot pre-fill).
/// </summary>
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)
/// <summary>
/// Parses a GitHub URL into a <see cref="DeployDraft"/>.
/// Supports:
/// https://github.com/{owner}/{repo}/tree/{branch}/{subpath} → repo + branch + docker context
/// https://github.com/{owner}/{repo} → repo only
/// </summary>
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/(?<owner>[^/]+)/(?<repo>[^/]+)/tree/(?<branch>[^/]+)(?:/(?<path>.+))?$");

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)
{
Expand All @@ -64,7 +116,7 @@ private string GetOrCreateKey()
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddHours(2),
Expires = DateTimeOffset.UtcNow.AddHours(2),
});

return newKey;
Expand Down
Loading