From aa4c54f42fec2a41452064b1c3604dc27bcc8c6d Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 4 Mar 2026 19:46:21 +0200 Subject: [PATCH 1/8] feat: add deploy button SVG for hosting Ivy apps on Sliplane --- .../sliplane-manage/Assets/deploy-button.svg | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 project-demos/sliplane-manage/Assets/deploy-button.svg diff --git a/project-demos/sliplane-manage/Assets/deploy-button.svg b/project-demos/sliplane-manage/Assets/deploy-button.svg new file mode 100644 index 00000000..199937af --- /dev/null +++ b/project-demos/sliplane-manage/Assets/deploy-button.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + Host your Ivy app on Sliplane + + From d88fef6faefcfe17507c149644c3082e7c904481 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 4 Mar 2026 20:52:34 +0200 Subject: [PATCH 2/8] feat: implement Sliplane deployment app with repository URL capture and deployment form --- .../sliplane-manage/Apps/SliplaneDeployApp.cs | 50 ++++ .../sliplane-manage/Apps/Views/DeployView.cs | 242 ++++++++++++++++++ project-demos/sliplane-manage/Program.cs | 3 + .../Services/DeploymentDraftStore.cs | 32 +++ .../Services/RepoCaptureFilter.cs | 35 +++ 5 files changed, 362 insertions(+) create mode 100644 project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs create mode 100644 project-demos/sliplane-manage/Apps/Views/DeployView.cs create mode 100644 project-demos/sliplane-manage/Services/DeploymentDraftStore.cs create mode 100644 project-demos/sliplane-manage/Services/RepoCaptureFilter.cs diff --git a/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs b/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs new file mode 100644 index 00000000..7e9494dd --- /dev/null +++ b/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs @@ -0,0 +1,50 @@ +namespace SliplaneManage.Apps; + +using SliplaneManage.Apps.Views; +using SliplaneManage.Services; + +/// +/// 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. +/// +[App( + id: "sliplane-deploy-app", + icon: Icons.Rocket, + title: "Deploy on Sliplane", + searchHints: ["deploy", "host", "sliplane"], + isVisible: true)] +public class SliplaneDeployApp : ViewBase +{ + public override object? Build() + { + var config = this.UseService(); + var auth = this.UseService(); + var session = auth.GetAuthSession(); + var apiToken = config["Sliplane:ApiToken"] + ?? session.AuthToken?.AccessToken + ?? string.Empty; + + // Repo from internal navigation args or last saved value + var args = this.UseArgs(); + var repoUrl = args?.Repo ?? DeploymentDraftStore.LastRepoUrl ?? string.Empty; + + 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}")) + | Text.Muted("No API token. Please sign in or configure Sliplane:ApiToken.")); + } + + return new DeployView(apiToken, repoUrl); + } +} + +/// Arguments for internal Ivy navigation to SliplaneDeployApp. +public record DeployArgs(string Repo); diff --git a/project-demos/sliplane-manage/Apps/Views/DeployView.cs b/project-demos/sliplane-manage/Apps/Views/DeployView.cs new file mode 100644 index 00000000..65a6bec2 --- /dev/null +++ b/project-demos/sliplane-manage/Apps/Views/DeployView.cs @@ -0,0 +1,242 @@ +namespace SliplaneManage.Apps.Views; + +using System.ComponentModel.DataAnnotations; +using SliplaneManage.Models; +using SliplaneManage.Services; +using SliplaneManage.Apps; + +public class DeployFormModel +{ + [Display(Name = "Project", Description = "Select an existing Sliplane project", Order = 1)] + [Required(ErrorMessage = "Select a project")] + public string ProjectId { get; set; } = ""; + + [Display(Name = "Service name", Order = 2)] + [Required(ErrorMessage = "Enter a service name")] + [MinLength(2, ErrorMessage = "Service name must be at least 2 characters")] + public string Name { get; set; } = ""; + + [Display(Name = "Server", Description = "Select a server to deploy on", Order = 3)] + [Required(ErrorMessage = "Select a server")] + public string ServerId { get; set; } = ""; + + [Display(GroupName = "Repository", Name = "Repository URL", Order = 4)] + [Required(ErrorMessage = "Enter a repository URL")] + public string GitRepo { get; set; } = ""; + + [Display(GroupName = "Repository", Name = "Branch", Order = 5)] + public string Branch { get; set; } = "main"; + + [Display(GroupName = "Build", Name = "Dockerfile path", Order = 6)] + public string DockerfilePath { get; set; } = "Dockerfile"; + + [Display(GroupName = "Build", Name = "Docker context", Order = 7)] + public string DockerContext { get; set; } = "."; + + [Display(GroupName = "Build", Name = "Auto-deploy on push", Order = 8)] + public bool AutoDeploy { get; set; } = true; + + [Display(GroupName = "Network", Name = "Public access", Order = 9)] + public bool NetworkPublic { get; set; } = true; + + [Display(GroupName = "Network", Name = "Protocol", Order = 10)] + public string NetworkProtocol { get; set; } = "http"; + + [Display(GroupName = "Optional", Name = "Health check path", Prompt = "/health", Order = 11)] + public string Healthcheck { get; set; } = "/"; + + [Display(GroupName = "Optional", Name = "Start command", Prompt = "e.g. npm start", Order = 12)] + public string? Cmd { get; set; } +} + +public class DeployView : ViewBase +{ + private readonly string _apiToken; + private readonly string _repoUrl; + + public DeployView(string apiToken, string repoUrl) + { + _apiToken = apiToken; + _repoUrl = repoUrl; + } + + public override object? Build() + { + var client = this.UseService(); + var refreshSender = this.CreateSignal(); + + var initialName = DeriveServiceName(_repoUrl); + + var model = this.UseState(() => new DeployFormModel + { + GitRepo = _repoUrl, + Name = initialName, + }); + + // Keep DeploymentDraftStore in sync as the user edits the repo URL + this.UseEffect(() => DeploymentDraftStore.SaveRepoUrl(model.Value.GitRepo), model); + + var success = this.UseState(false); + var createdService = this.UseState(() => null); + var envList = this.UseState>(() => new List()); + var showAddEnvDlg = this.UseState(false); + var addEnvKey = this.UseState(string.Empty); + var addEnvValue = this.UseState(string.Empty); + var reloadCounter = this.UseState(0); + + QueryResult[]> QueryProjects(IViewContext ctx, string q) => + ctx.UseQuery[], (string, string, int)>( + key: ("deploy-projects", q, reloadCounter.Value), + fetcher: async _ => + (await client.GetProjectsAsync(_apiToken)) + .Where(p => string.IsNullOrEmpty(q) || p.Name.Contains(q, StringComparison.OrdinalIgnoreCase)) + .Take(20).Select(p => new Option(p.Name, p.Id)).ToArray()); + + QueryResult?> LookupProject(IViewContext ctx, string? id) => + ctx.UseQuery?, (string, string?, int)>( + key: ("deploy-project-lookup", id, reloadCounter.Value), + fetcher: async _ => + { + if (string.IsNullOrEmpty(id)) return null; + var p = (await client.GetProjectsAsync(_apiToken)).FirstOrDefault(x => x.Id == id); + return p is null ? null : new Option(p.Name, p.Id); + }); + + QueryResult[]> QueryServers(IViewContext ctx, string q) => + ctx.UseQuery[], (string, string, int)>( + key: ("deploy-servers", q, reloadCounter.Value), + fetcher: async _ => + (await client.GetServersAsync(_apiToken)) + .Where(s => string.IsNullOrEmpty(q) || s.Name.Contains(q, StringComparison.OrdinalIgnoreCase)) + .Take(20).Select(s => new Option(s.Name, s.Id)).ToArray()); + + QueryResult?> LookupServer(IViewContext ctx, string? id) => + ctx.UseQuery?, (string, string?, int)>( + key: ("deploy-server-lookup", id, reloadCounter.Value), + fetcher: async _ => + { + if (string.IsNullOrEmpty(id)) return null; + var s = (await client.GetServersAsync(_apiToken)).FirstOrDefault(x => x.Id == id); + return s is null ? null : new Option(s.Name, s.Id); + }); + + var protocolOptions = new[] { new Option("HTTP", "http"), new Option("HTTPS", "https") }; + + var (onSubmit, formView, validationView, loading) = this.UseForm(() => model.ToForm("Deploy") + .Builder(m => m.ProjectId, s => s.ToAsyncSelectInput(QueryProjects, LookupProject, placeholder: "Search project...")) + .Builder(m => m.ServerId, s => s.ToAsyncSelectInput(QueryServers, LookupServer, placeholder: "Search server...")) + .Builder(m => m.NetworkProtocol, s => s.ToSelectInput(protocolOptions)) + .Required(m => m.ProjectId, m => m.Name, m => m.ServerId, m => m.GitRepo)); + + async ValueTask HandleDeploy() + { + if (!await onSubmit()) return; + var m = model.Value; + var svc = await client.CreateServiceAsync(_apiToken, m.ProjectId, + ServiceRequestFactory.BuildCreateRequest( + name: m.Name, serverId: m.ServerId, gitRepo: m.GitRepo, + branch: m.Branch, dockerfilePath: m.DockerfilePath, + dockerContext: m.DockerContext, autoDeploy: m.AutoDeploy, + networkPublic: m.NetworkPublic, networkProtocol: m.NetworkProtocol, + cmd: m.Cmd ?? string.Empty, healthcheck: m.Healthcheck, + env: envList.Value, volumeMounts: null)); + createdService.Set(svc); + success.Set(true); + await refreshSender.Send("services"); + } + + if (success.Value) + { + var svc = createdService.Value; + var domain = svc?.Network?.ManagedDomain ?? svc?.Network?.CustomDomains?.FirstOrDefault()?.Domain; + var url = string.IsNullOrWhiteSpace(domain) ? null + : domain.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? domain : "https://" + domain; + + return Layout.Center() + | (Layout.Vertical().Align(Align.Center).Gap(6) + | Icons.CircleCheck.ToIcon() + | Text.H2("Deployed Successfully! 🎉") + | Text.P($"Service \"{svc?.Name ?? model.Value.Name}\" is deploying.") + | (url != null + ? (object)new Button("Open App").Icon(Icons.ExternalLink).Primary().Url(url) + : Text.Muted("Your app will be available shortly.")) + | new Button("Deploy another").Icon(Icons.Plus).Variant(ButtonVariant.Outline) + .HandleClick(_ => + { + success.Set(false); + createdService.Set((SliplaneService?)null); + model.Set(new DeployFormModel { GitRepo = _repoUrl, Name = initialName }); + })); + } + + // Env variable table + var envItems = envList.Value; + var envHeaderRow = new TableRow( + new TableCell("Key").IsHeader(), + new TableCell("Value").IsHeader(), + new TableCell("").IsHeader().Width(Size.Fit())); + var envDataRows = envItems.Select((e, i) => new TableRow( + new TableCell(e.Key), + new TableCell(e.Value ?? ""), + new TableCell(new Button("Remove").Variant(ButtonVariant.Outline) + .HandleClick(_ => envList.Set(envList.Value.Where((_, j) => j != i).ToList()))) + .Width(Size.Fit()))).ToArray(); + + object envTable = envDataRows.Length == 0 + ? Text.Muted("No variables added.") + : new Table(new[] { envHeaderRow }.Concat(envDataRows).ToArray()).Width(Size.Full()); + + Dialog? addEnvDialog = null; + if (showAddEnvDlg.Value) + { + void SaveEnv() + { + if (string.IsNullOrWhiteSpace(addEnvKey.Value)) return; + envList.Set(envList.Value + .Append(new EnvironmentVariable(addEnvKey.Value.Trim(), addEnvValue.Value ?? string.Empty, false)) + .ToList()); + addEnvKey.Set(string.Empty); + addEnvValue.Set(string.Empty); + showAddEnvDlg.Set(false); + } + addEnvDialog = new Dialog( + onClose: (Event _) => showAddEnvDlg.Set(false), + header: new DialogHeader("Add environment variable"), + body: new DialogBody(Layout.Vertical() + | addEnvKey.ToTextInput().Placeholder("Key (e.g. DATABASE_URL)") + | addEnvValue.ToTextInput().Placeholder("Value")), + footer: new DialogFooter( + new Button("Save").Variant(ButtonVariant.Primary).HandleClick(_ => SaveEnv()), + new Button("Cancel").HandleClick(_ => showAddEnvDlg.Set(false)) + )).Width(Size.Units(220)); + } + + var page = Layout.Center() + | (Layout.Vertical().Gap(8) + | (Layout.Vertical().Align(Align.Center).Gap(4) + | Icons.Rocket.ToIcon() + | Text.H1("Deploy to Sliplane") + | Text.Lead("Configure and deploy your Ivy app in seconds.")) + | new Separator() + | formView + | (Layout.Vertical() + | Text.H4("Environment Variables") + | envTable + | new Button("Add variable").Icon(Icons.Plus).Variant(ButtonVariant.Outline) + .HandleClick(_ => showAddEnvDlg.Set(true))) + | (Layout.Horizontal() + | new Button("Deploy").Icon(Icons.Rocket).Primary().Large().Loading(loading) + .HandleClick(async _ => await HandleDeploy()) + | validationView)); + + return addEnvDialog != null ? new Fragment(page, addEnvDialog) : (object)page; + } + + private static string DeriveServiceName(string repoUrl) + { + if (string.IsNullOrWhiteSpace(repoUrl)) return string.Empty; + var seg = repoUrl.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/Program.cs b/project-demos/sliplane-manage/Program.cs index 853a5d04..4d188be9 100644 --- a/project-demos/sliplane-manage/Program.cs +++ b/project-demos/sliplane-manage/Program.cs @@ -24,6 +24,9 @@ options.Expires = DateTimeOffset.UtcNow.Add(TimeSpan.FromMinutes(30)); }; +// Captures ?repo= from the initial HTTP GET before Ivy SPA strips query params. +server.Services.AddSingleton(); + #if DEBUG server.UseHotReload(); #endif diff --git a/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs b/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs new file mode 100644 index 00000000..c0960c74 --- /dev/null +++ b/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs @@ -0,0 +1,32 @@ +namespace SliplaneManage.Services; + +/// +/// Very simple in-memory store for the last used deployment repository URL. +/// Lives for the lifetime of the Sliplane Manage process. +/// +public static class DeploymentDraftStore +{ + private static readonly object _lock = new(); + private static string? _lastRepoUrl; + + public static string? LastRepoUrl + { + get + { + lock (_lock) + return _lastRepoUrl; + } + } + + public static void SaveRepoUrl(string? repoUrl) + { + if (string.IsNullOrWhiteSpace(repoUrl)) + return; + + lock (_lock) + { + _lastRepoUrl = repoUrl; + } + } +} + diff --git a/project-demos/sliplane-manage/Services/RepoCaptureFilter.cs b/project-demos/sliplane-manage/Services/RepoCaptureFilter.cs new file mode 100644 index 00000000..a16c8e97 --- /dev/null +++ b/project-demos/sliplane-manage/Services/RepoCaptureFilter.cs @@ -0,0 +1,35 @@ +namespace SliplaneManage.Services; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; + +/// +/// ASP.NET Core startup filter that captures the ?repo= query parameter +/// from the initial HTTP GET request for /sliplane-deploy-app before the +/// Ivy SPA takes over and strips query params from the WebSocket connection. +/// Stores the value in DeploymentDraftStore so DeployView can pre-fill the form. +/// +public class RepoCaptureFilter : IStartupFilter +{ + public Action Configure(Action next) + { + return app => + { + app.Use(async (context, nextMiddleware) => + { + if (context.Request.Path.StartsWithSegments("/sliplane-deploy-app", + StringComparison.OrdinalIgnoreCase)) + { + var repo = context.Request.Query["repo"].ToString(); + if (!string.IsNullOrWhiteSpace(repo)) + DeploymentDraftStore.SaveRepoUrl(Uri.UnescapeDataString(repo)); + } + + await nextMiddleware(context); + }); + + next(app); + }; + } +} From a9d3c63cf14a77970aef112b9a041b09efd07ba7 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 4 Mar 2026 21:07:42 +0200 Subject: [PATCH 3/8] refactor: simplify display attributes and restructure DeployView layout for improved readability --- .../sliplane-manage/Apps/Views/DeployView.cs | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/project-demos/sliplane-manage/Apps/Views/DeployView.cs b/project-demos/sliplane-manage/Apps/Views/DeployView.cs index 65a6bec2..56125f5e 100644 --- a/project-demos/sliplane-manage/Apps/Views/DeployView.cs +++ b/project-demos/sliplane-manage/Apps/Views/DeployView.cs @@ -7,7 +7,7 @@ namespace SliplaneManage.Apps.Views; public class DeployFormModel { - [Display(Name = "Project", Description = "Select an existing Sliplane project", Order = 1)] + [Display(Name = "Project", Order = 1)] [Required(ErrorMessage = "Select a project")] public string ProjectId { get; set; } = ""; @@ -16,7 +16,7 @@ public class DeployFormModel [MinLength(2, ErrorMessage = "Service name must be at least 2 characters")] public string Name { get; set; } = ""; - [Display(Name = "Server", Description = "Select a server to deploy on", Order = 3)] + [Display(Name = "Server", Order = 3)] [Required(ErrorMessage = "Select a server")] public string ServerId { get; set; } = ""; @@ -211,23 +211,33 @@ void SaveEnv() )).Width(Size.Units(220)); } - var page = Layout.Center() - | (Layout.Vertical().Gap(8) - | (Layout.Vertical().Align(Align.Center).Gap(4) - | Icons.Rocket.ToIcon() - | Text.H1("Deploy to Sliplane") - | Text.Lead("Configure and deploy your Ivy app in seconds.")) + var headerSection = Layout.Vertical().Align(Align.Center).Gap(4) + | Icons.Rocket.ToIcon() + | Text.H1("Deploy to Sliplane") + | Text.Lead("Configure and deploy your Ivy app in seconds."); + + var envSection = Layout.Vertical() + | Text.H4("Environment Variables") + | envTable + | new Button("Add variable").Icon(Icons.Plus).Variant(ButtonVariant.Outline) + .HandleClick(_ => showAddEnvDlg.Set(true)); + + var actionsRow = Layout.Horizontal() + | new Button("Deploy").Icon(Icons.Rocket).Primary().Large().Loading(loading) + .HandleClick(async _ => await HandleDeploy()) + | validationView; + + var card = new Card( + Layout.Vertical().Gap(8) + | headerSection | new Separator() | formView - | (Layout.Vertical() - | Text.H4("Environment Variables") - | envTable - | new Button("Add variable").Icon(Icons.Plus).Variant(ButtonVariant.Outline) - .HandleClick(_ => showAddEnvDlg.Set(true))) - | (Layout.Horizontal() - | new Button("Deploy").Icon(Icons.Rocket).Primary().Large().Loading(loading) - .HandleClick(async _ => await HandleDeploy()) - | validationView)); + | envSection + | actionsRow) + .Width(Size.Fraction(0.5f)); + + var page = Layout.Vertical().Align(Align.TopCenter) + | card; return addEnvDialog != null ? new Fragment(page, addEnvDialog) : (object)page; } From 845499d734f50dcf40141dae069b6a5395ed26f9 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 4 Mar 2026 22:55:14 +0200 Subject: [PATCH 4/8] fix: update default application in Program.cs to SliplaneDeployApp and enhance DeployView prompts for better user guidance --- .../sliplane-manage/Apps/Views/DeployView.cs | 39 ++++++++++--------- project-demos/sliplane-manage/Program.cs | 2 +- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/project-demos/sliplane-manage/Apps/Views/DeployView.cs b/project-demos/sliplane-manage/Apps/Views/DeployView.cs index 56125f5e..c23c3e1d 100644 --- a/project-demos/sliplane-manage/Apps/Views/DeployView.cs +++ b/project-demos/sliplane-manage/Apps/Views/DeployView.cs @@ -7,39 +7,39 @@ namespace SliplaneManage.Apps.Views; public class DeployFormModel { - [Display(Name = "Project", Order = 1)] + [Display(Name = "Server", Order = 1, Prompt = "Select a server")] + [Required(ErrorMessage = "Select a server")] + public string ServerId { get; set; } = ""; + + [Display(Name = "Project", Order = 2, Prompt = "Select a project")] [Required(ErrorMessage = "Select a project")] public string ProjectId { get; set; } = ""; - [Display(Name = "Service name", Order = 2)] + [Display(Name = "Service name", Order = 3, Prompt = "my-ivy-service")] [Required(ErrorMessage = "Enter a service name")] [MinLength(2, ErrorMessage = "Service name must be at least 2 characters")] public string Name { get; set; } = ""; - [Display(Name = "Server", Order = 3)] - [Required(ErrorMessage = "Select a server")] - public string ServerId { get; set; } = ""; - - [Display(GroupName = "Repository", Name = "Repository URL", Order = 4)] + [Display(Name = "Repository URL", Order = 4, Prompt = "https://github.com/user/repo")] [Required(ErrorMessage = "Enter a repository URL")] public string GitRepo { get; set; } = ""; - [Display(GroupName = "Repository", Name = "Branch", Order = 5)] + [Display(Name = "Branch", Order = 5, Prompt = "main")] public string Branch { get; set; } = "main"; - [Display(GroupName = "Build", Name = "Dockerfile path", Order = 6)] + [Display(GroupName = "Build", Name = "Dockerfile path", Order = 6, Prompt = "Dockerfile")] public string DockerfilePath { get; set; } = "Dockerfile"; - [Display(GroupName = "Build", Name = "Docker context", Order = 7)] + [Display(GroupName = "Build", Name = "Docker context", Order = 7, Prompt = ".")] public string DockerContext { get; set; } = "."; - [Display(GroupName = "Build", Name = "Auto-deploy on push", Order = 8)] + [Display(GroupName = "Build", Name = "Auto-deploy on push", Order = 8, Prompt = "Enable auto-deploy on push")] public bool AutoDeploy { get; set; } = true; - [Display(GroupName = "Network", Name = "Public access", Order = 9)] + [Display(GroupName = "Network", Name = "Public access", Order = 9, Prompt = "Expose service publicly")] public bool NetworkPublic { get; set; } = true; - [Display(GroupName = "Network", Name = "Protocol", Order = 10)] + [Display(GroupName = "Network", Name = "Protocol", Order = 10, Prompt = "http or https")] public string NetworkProtocol { get; set; } = "http"; [Display(GroupName = "Optional", Name = "Health check path", Prompt = "/health", Order = 11)] @@ -216,11 +216,12 @@ void SaveEnv() | Text.H1("Deploy to Sliplane") | Text.Lead("Configure and deploy your Ivy app in seconds."); - var envSection = Layout.Vertical() - | Text.H4("Environment Variables") - | envTable - | new Button("Add variable").Icon(Icons.Plus).Variant(ButtonVariant.Outline) - .HandleClick(_ => showAddEnvDlg.Set(true)); + var envSection = new Expandable( + "Environment Variables", + Layout.Vertical() + | envTable + | new Button("Add variable").Icon(Icons.Plus).Variant(ButtonVariant.Outline) + .HandleClick(_ => showAddEnvDlg.Set(true))); var actionsRow = Layout.Horizontal() | new Button("Deploy").Icon(Icons.Rocket).Primary().Large().Loading(loading) @@ -228,7 +229,7 @@ void SaveEnv() | validationView; var card = new Card( - Layout.Vertical().Gap(8) + Layout.Vertical() | headerSection | new Separator() | formView diff --git a/project-demos/sliplane-manage/Program.cs b/project-demos/sliplane-manage/Program.cs index 4d188be9..fa1fb4b8 100644 --- a/project-demos/sliplane-manage/Program.cs +++ b/project-demos/sliplane-manage/Program.cs @@ -38,7 +38,7 @@ var chromeSettings = new ChromeSettings() .UseTabs(preventDuplicates: true) - .DefaultApp(); + .DefaultApp(); server.UseChrome(chromeSettings); await server.RunAsync(); \ No newline at end of file From 0bd1e97a271bdf99b2ddba044691bf6d6be677d1 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 4 Mar 2026 23:13:01 +0200 Subject: [PATCH 5/8] refactor: enhance SliplaneServicesApp initialization and streamline DeployView navigation logic --- .../Apps/SliplaneOverviewApp.cs | 2 +- .../sliplane-manage/Apps/Views/DeployView.cs | 32 ++----------------- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/project-demos/sliplane-manage/Apps/SliplaneOverviewApp.cs b/project-demos/sliplane-manage/Apps/SliplaneOverviewApp.cs index f489245c..ef8e4644 100644 --- a/project-demos/sliplane-manage/Apps/SliplaneOverviewApp.cs +++ b/project-demos/sliplane-manage/Apps/SliplaneOverviewApp.cs @@ -51,7 +51,7 @@ public class SliplaneProjectsApp : ViewBase /// /// Services app — list services, create, edit, pause, delete. /// -[App(icon: Icons.Box, title: "Services", searchHints: ["services", "deploy"])] +[App(id: "sliplane-services-app", icon: Icons.Box, title: "Services", searchHints: ["services", "deploy"])] public class SliplaneServicesApp : ViewBase { public override object? Build() diff --git a/project-demos/sliplane-manage/Apps/Views/DeployView.cs b/project-demos/sliplane-manage/Apps/Views/DeployView.cs index c23c3e1d..ba0e5382 100644 --- a/project-demos/sliplane-manage/Apps/Views/DeployView.cs +++ b/project-demos/sliplane-manage/Apps/Views/DeployView.cs @@ -76,8 +76,6 @@ public DeployView(string apiToken, string repoUrl) // Keep DeploymentDraftStore in sync as the user edits the repo URL this.UseEffect(() => DeploymentDraftStore.SaveRepoUrl(model.Value.GitRepo), model); - var success = this.UseState(false); - var createdService = this.UseState(() => null); var envList = this.UseState>(() => new List()); var showAddEnvDlg = this.UseState(false); var addEnvKey = this.UseState(string.Empty); @@ -122,6 +120,7 @@ QueryResult[]> QueryServers(IViewContext ctx, string q) => var protocolOptions = new[] { new Option("HTTP", "http"), new Option("HTTPS", "https") }; + var navigator = this.Context.UseNavigation(); var (onSubmit, formView, validationView, loading) = this.UseForm(() => model.ToForm("Deploy") .Builder(m => m.ProjectId, s => s.ToAsyncSelectInput(QueryProjects, LookupProject, placeholder: "Search project...")) .Builder(m => m.ServerId, s => s.ToAsyncSelectInput(QueryServers, LookupServer, placeholder: "Search server...")) @@ -132,7 +131,7 @@ async ValueTask HandleDeploy() { if (!await onSubmit()) return; var m = model.Value; - var svc = await client.CreateServiceAsync(_apiToken, m.ProjectId, + await client.CreateServiceAsync(_apiToken, m.ProjectId, ServiceRequestFactory.BuildCreateRequest( name: m.Name, serverId: m.ServerId, gitRepo: m.GitRepo, branch: m.Branch, dockerfilePath: m.DockerfilePath, @@ -140,33 +139,8 @@ async ValueTask HandleDeploy() networkPublic: m.NetworkPublic, networkProtocol: m.NetworkProtocol, cmd: m.Cmd ?? string.Empty, healthcheck: m.Healthcheck, env: envList.Value, volumeMounts: null)); - createdService.Set(svc); - success.Set(true); await refreshSender.Send("services"); - } - - if (success.Value) - { - var svc = createdService.Value; - var domain = svc?.Network?.ManagedDomain ?? svc?.Network?.CustomDomains?.FirstOrDefault()?.Domain; - var url = string.IsNullOrWhiteSpace(domain) ? null - : domain.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? domain : "https://" + domain; - - return Layout.Center() - | (Layout.Vertical().Align(Align.Center).Gap(6) - | Icons.CircleCheck.ToIcon() - | Text.H2("Deployed Successfully! 🎉") - | Text.P($"Service \"{svc?.Name ?? model.Value.Name}\" is deploying.") - | (url != null - ? (object)new Button("Open App").Icon(Icons.ExternalLink).Primary().Url(url) - : Text.Muted("Your app will be available shortly.")) - | new Button("Deploy another").Icon(Icons.Plus).Variant(ButtonVariant.Outline) - .HandleClick(_ => - { - success.Set(false); - createdService.Set((SliplaneService?)null); - model.Set(new DeployFormModel { GitRepo = _repoUrl, Name = initialName }); - })); + navigator.Navigate(typeof(SliplaneServicesApp)); } // Env variable table From e8c85d8ee976908b2bac6d756bc40d6d539c4117 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 4 Mar 2026 23:17:13 +0200 Subject: [PATCH 6/8] feat: add volume management functionality to DeployView with server volume retrieval and mount path configuration --- .../sliplane-manage/Apps/Views/DeployView.cs | 87 ++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/project-demos/sliplane-manage/Apps/Views/DeployView.cs b/project-demos/sliplane-manage/Apps/Views/DeployView.cs index ba0e5382..3f968367 100644 --- a/project-demos/sliplane-manage/Apps/Views/DeployView.cs +++ b/project-demos/sliplane-manage/Apps/Views/DeployView.cs @@ -81,6 +81,11 @@ public DeployView(string apiToken, string repoUrl) var addEnvKey = this.UseState(string.Empty); var addEnvValue = this.UseState(string.Empty); var reloadCounter = this.UseState(0); + var serverVolumes = this.UseState>(() => new List()); + var volumeMountsList = this.UseState>(() => new List<(string, string)>()); + var showAddVolumeDlg = this.UseState(false); + var addVolumeId = this.UseState(string.Empty); + var addMountPath = this.UseState(string.Empty); QueryResult[]> QueryProjects(IViewContext ctx, string q) => ctx.UseQuery[], (string, string, int)>( @@ -127,6 +132,25 @@ QueryResult[]> QueryServers(IViewContext ctx, string q) => .Builder(m => m.NetworkProtocol, s => s.ToSelectInput(protocolOptions)) .Required(m => m.ProjectId, m => m.Name, m => m.ServerId, m => m.GitRepo)); + this.UseEffect(async () => + { + var serverId = model.Value.ServerId; + if (string.IsNullOrWhiteSpace(serverId)) + { + serverVolumes.Set(new List()); + return; + } + try + { + var vols = await client.GetServerVolumesAsync(_apiToken, serverId); + serverVolumes.Set(vols ?? new List()); + } + catch + { + serverVolumes.Set(new List()); + } + }, model); + async ValueTask HandleDeploy() { if (!await onSubmit()) return; @@ -138,7 +162,7 @@ await client.CreateServiceAsync(_apiToken, m.ProjectId, dockerContext: m.DockerContext, autoDeploy: m.AutoDeploy, networkPublic: m.NetworkPublic, networkProtocol: m.NetworkProtocol, cmd: m.Cmd ?? string.Empty, healthcheck: m.Healthcheck, - env: envList.Value, volumeMounts: null)); + env: envList.Value, volumeMounts: volumeMountsList.Value)); await refreshSender.Send("services"); navigator.Navigate(typeof(SliplaneServicesApp)); } @@ -185,6 +209,33 @@ void SaveEnv() )).Width(Size.Units(220)); } + Dialog? addVolumeDialog = null; + if (showAddVolumeDlg.Value) + { + void SaveVolume() + { + if (string.IsNullOrWhiteSpace(addVolumeId.Value) || string.IsNullOrWhiteSpace(addMountPath.Value)) return; + volumeMountsList.Set(volumeMountsList.Value + .Append((addVolumeId.Value, addMountPath.Value.Trim())) + .ToList()); + addVolumeId.Set(string.Empty); + addMountPath.Set(string.Empty); + showAddVolumeDlg.Set(false); + } + var volumeOptionsForDialog = (serverVolumes.Value ?? new List()) + .Select(v => new Option($"{v.Name} ({v.MountPath})", v.Id)).ToArray(); + addVolumeDialog = new Dialog( + onClose: (Event _) => showAddVolumeDlg.Set(false), + header: new DialogHeader("Add volume mount"), + body: new DialogBody(Layout.Vertical() + | addVolumeId.ToSelectInput(volumeOptionsForDialog) + | addMountPath.ToTextInput().Placeholder("Mount path (e.g. /data)")), + footer: new DialogFooter( + new Button("Save").Variant(ButtonVariant.Primary).HandleClick(_ => SaveVolume()), + new Button("Cancel").HandleClick(_ => showAddVolumeDlg.Set(false)) + )).Width(Size.Units(220)); + } + var headerSection = Layout.Vertical().Align(Align.Center).Gap(4) | Icons.Rocket.ToIcon() | Text.H1("Deploy to Sliplane") @@ -197,6 +248,34 @@ void SaveEnv() | new Button("Add variable").Icon(Icons.Plus).Variant(ButtonVariant.Outline) .HandleClick(_ => showAddEnvDlg.Set(true))); + var vols = serverVolumes.Value ?? new List(); + var volItems = volumeMountsList.Value ?? new List<(string VolumeId, string MountPath)>(); + var volHeaderRow = new TableRow( + new TableCell("Volume").IsHeader(), + new TableCell("Mount path").IsHeader(), + new TableCell("").IsHeader().Width(Size.Fit())); + var volDataRows = volItems.Select((v, i) => + { + var index = i; + var volName = vols.FirstOrDefault(vol => vol.Id == v.VolumeId)?.Name ?? v.VolumeId; + return new TableRow( + new TableCell(volName), + new TableCell(v.MountPath), + new TableCell(new Button("Remove").Variant(ButtonVariant.Outline) + .HandleClick(_ => volumeMountsList.Set(volumeMountsList.Value.Where((_, j) => j != index).ToList()))) + .Width(Size.Fit())); + }).ToArray(); + object volTableContent = volDataRows.Length == 0 + ? (object)Text.Muted("No volume mounts. Select a server first, then add.") + : new Table(new[] { volHeaderRow }.Concat(volDataRows).ToArray()).Width(Size.Full()); + + var volumesSection = new Expandable( + "Volumes", + Layout.Vertical() + | volTableContent + | new Button("Add volume").Icon(Icons.Plus).Variant(ButtonVariant.Outline) + .HandleClick(_ => showAddVolumeDlg.Set(true))); + var actionsRow = Layout.Horizontal() | new Button("Deploy").Icon(Icons.Rocket).Primary().Large().Loading(loading) .HandleClick(async _ => await HandleDeploy()) @@ -208,13 +287,17 @@ void SaveEnv() | new Separator() | formView | envSection + | volumesSection | actionsRow) .Width(Size.Fraction(0.5f)); var page = Layout.Vertical().Align(Align.TopCenter) | card; - return addEnvDialog != null ? new Fragment(page, addEnvDialog) : (object)page; + if (addEnvDialog != null && addVolumeDialog != null) return new Fragment(page, addEnvDialog, addVolumeDialog); + if (addEnvDialog != null) return new Fragment(page, addEnvDialog); + if (addVolumeDialog != null) return new Fragment(page, addVolumeDialog); + return page; } private static string DeriveServiceName(string repoUrl) From aff0e96a49df6130ac0bde588310ce5f0ed51beb Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 4 Mar 2026 23:27:33 +0200 Subject: [PATCH 7/8] docs: add deploy button instructions to README for easy Ivy app deployment on Sliplane --- project-demos/sliplane-manage/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/project-demos/sliplane-manage/README.md b/project-demos/sliplane-manage/README.md index 0a6ed66a..4f7295b3 100644 --- a/project-demos/sliplane-manage/README.md +++ b/project-demos/sliplane-manage/README.md @@ -4,6 +4,19 @@ Sliplane Manage is a web application for managing your [Sliplane](https://sliplane.io) infrastructure: servers, projects, and services. It uses the Sliplane Control API to list and manage resources, deploy services from Git repositories or Docker images, view logs and events, and perform pause/resume and delete operations—all through an interactive UI built with the Ivy framework. +### Deploy button for your repo + +Add this to your project’s README so users can deploy your Ivy app to Sliplane in one click (replace the `repo` URL with your repository): + +

+ + Host your Ivy app on Sliplane + +

+ +For production, change `http://localhost:5010` to your deployed app URL. + ## Features - **Overview** – Dashboard with summary cards (Servers, Projects, Services) and counts; use the sidebar to jump to each section. From 95409d1d4e1715008f708fad57e93dc9aa190fce Mon Sep 17 00:00:00 2001 From: Artem Date: Thu, 5 Mar 2026 12:52:10 +0200 Subject: [PATCH 8/8] feat: implement per-user deployment draft store with IHttpContextAccessor for user-specific repo URL management --- .../sliplane-manage/Apps/SliplaneDeployApp.cs | 5 +- .../sliplane-manage/Apps/Views/DeployView.cs | 5 +- project-demos/sliplane-manage/Program.cs | 6 ++ .../Services/DeploymentDraftStore.cs | 74 ++++++++++++++----- .../Services/RepoCaptureFilter.cs | 15 ++-- 5 files changed, 77 insertions(+), 28 deletions(-) diff --git a/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs b/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs index 7e9494dd..2984265c 100644 --- a/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs +++ b/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs @@ -26,9 +26,10 @@ public class SliplaneDeployApp : ViewBase ?? session.AuthToken?.AccessToken ?? string.Empty; - // Repo from internal navigation args or last saved value + // Repo from internal navigation args or last saved value (per-user) + var draftStore = this.UseService(); var args = this.UseArgs(); - var repoUrl = args?.Repo ?? DeploymentDraftStore.LastRepoUrl ?? string.Empty; + var repoUrl = args?.Repo ?? draftStore.LastRepoUrl ?? string.Empty; 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 3f968367..f63e0fae 100644 --- a/project-demos/sliplane-manage/Apps/Views/DeployView.cs +++ b/project-demos/sliplane-manage/Apps/Views/DeployView.cs @@ -63,6 +63,7 @@ public DeployView(string apiToken, string repoUrl) public override object? Build() { var client = this.UseService(); + var draftStore = this.UseService(); var refreshSender = this.CreateSignal(); var initialName = DeriveServiceName(_repoUrl); @@ -73,8 +74,8 @@ public DeployView(string apiToken, string repoUrl) Name = initialName, }); - // Keep DeploymentDraftStore in sync as the user edits the repo URL - this.UseEffect(() => DeploymentDraftStore.SaveRepoUrl(model.Value.GitRepo), model); + // 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); diff --git a/project-demos/sliplane-manage/Program.cs b/project-demos/sliplane-manage/Program.cs index fa1fb4b8..cc43bdb1 100644 --- a/project-demos/sliplane-manage/Program.cs +++ b/project-demos/sliplane-manage/Program.cs @@ -16,6 +16,12 @@ // Ensure IConfiguration is available to apps server.Services.AddSingleton(server.Configuration); +// Register IHttpContextAccessor (required by DeploymentDraftStore for per-user isolation) +server.Services.AddHttpContextAccessor(); + +// Register per-user deployment draft store (scoped = one instance per HTTP request/connection) +server.Services.AddScoped(); + // Register Sliplane API client server.Services.AddScoped(); diff --git a/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs b/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs index c0960c74..e58d25f9 100644 --- a/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs +++ b/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs @@ -1,32 +1,72 @@ namespace SliplaneManage.Services; +using Microsoft.AspNetCore.Http; + /// -/// Very simple in-memory store for the last used deployment repository URL. -/// Lives for the lifetime of the Sliplane Manage process. +/// Per-user store for the deployment repo URL. +/// Key: access token (logged-in) or anonymous browser cookie (pre-login). /// -public static class DeploymentDraftStore +public class DeploymentDraftStore { - private static readonly object _lock = new(); - private static string? _lastRepoUrl; + private static readonly System.Collections.Concurrent.ConcurrentDictionary _store = new(); + + public const string CookieName = "sliplane-deploy-repo-key"; + + private readonly IHttpContextAccessor _httpContextAccessor; - public static string? LastRepoUrl + public DeploymentDraftStore(IHttpContextAccessor httpContextAccessor) { - get - { - lock (_lock) - return _lastRepoUrl; - } + _httpContextAccessor = httpContextAccessor; + } + + public string? LastRepoUrl => GetRepoUrl(); + + public void SaveRepoUrl(string? repoUrl) + { + if (string.IsNullOrWhiteSpace(repoUrl)) return; + _store[GetOrCreateKey()] = repoUrl; + } + + private string? GetRepoUrl() + { + var key = GetCurrentKey(); + return key is not null && _store.TryGetValue(key, out var url) ? url : null; + } + + private string? GetCurrentKey() + { + var ctx = _httpContextAccessor.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; } - public static void SaveRepoUrl(string? repoUrl) + private string GetOrCreateKey() { - if (string.IsNullOrWhiteSpace(repoUrl)) - return; + var ctx = _httpContextAccessor.HttpContext; - lock (_lock) + if (ctx is not null) { - _lastRepoUrl = repoUrl; + var token = ctx.Request.Cookies[".ivy.auth.token"]; + if (!string.IsNullOrWhiteSpace(token)) return "token:" + token; + + if (ctx.Request.Cookies.TryGetValue(CookieName, out var existing) && !string.IsNullOrWhiteSpace(existing)) + return existing; } + + var newKey = "anon:" + Guid.NewGuid().ToString("N"); + ctx?.Response.Cookies.Append(CookieName, newKey, new CookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Lax, + 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 a16c8e97..d4ddfb7b 100644 --- a/project-demos/sliplane-manage/Services/RepoCaptureFilter.cs +++ b/project-demos/sliplane-manage/Services/RepoCaptureFilter.cs @@ -3,12 +3,11 @@ namespace SliplaneManage.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; /// -/// ASP.NET Core startup filter that captures the ?repo= query parameter -/// from the initial HTTP GET request for /sliplane-deploy-app before the -/// Ivy SPA takes over and strips query params from the WebSocket connection. -/// Stores the value in DeploymentDraftStore so DeployView can pre-fill the form. +/// 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. /// public class RepoCaptureFilter : IStartupFilter { @@ -18,12 +17,14 @@ public Action Configure(Action next) { app.Use(async (context, nextMiddleware) => { - if (context.Request.Path.StartsWithSegments("/sliplane-deploy-app", - StringComparison.OrdinalIgnoreCase)) + if (context.Request.Path.StartsWithSegments("/sliplane-deploy-app", StringComparison.OrdinalIgnoreCase)) { var repo = context.Request.Query["repo"].ToString(); if (!string.IsNullOrWhiteSpace(repo)) - DeploymentDraftStore.SaveRepoUrl(Uri.UnescapeDataString(repo)); + { + var store = context.RequestServices.GetRequiredService(); + store.SaveRepoUrl(Uri.UnescapeDataString(repo)); + } } await nextMiddleware(context);