diff --git a/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs b/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs new file mode 100644 index 00000000..2984265c --- /dev/null +++ b/project-demos/sliplane-manage/Apps/SliplaneDeployApp.cs @@ -0,0 +1,51 @@ +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 (per-user) + var draftStore = this.UseService(); + var args = this.UseArgs(); + var repoUrl = args?.Repo ?? draftStore.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/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 new file mode 100644 index 00000000..f63e0fae --- /dev/null +++ b/project-demos/sliplane-manage/Apps/Views/DeployView.cs @@ -0,0 +1,311 @@ +namespace SliplaneManage.Apps.Views; + +using System.ComponentModel.DataAnnotations; +using SliplaneManage.Models; +using SliplaneManage.Services; +using SliplaneManage.Apps; + +public class DeployFormModel +{ + [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 = 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 = "Repository URL", Order = 4, Prompt = "https://github.com/user/repo")] + [Required(ErrorMessage = "Enter a repository URL")] + public string GitRepo { get; set; } = ""; + + [Display(Name = "Branch", Order = 5, Prompt = "main")] + public string Branch { get; set; } = "main"; + + [Display(GroupName = "Build", Name = "Dockerfile path", Order = 6, Prompt = "Dockerfile")] + public string DockerfilePath { get; set; } = "Dockerfile"; + + [Display(GroupName = "Build", Name = "Docker context", Order = 7, Prompt = ".")] + public string DockerContext { get; set; } = "."; + + [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, Prompt = "Expose service publicly")] + public bool NetworkPublic { get; set; } = true; + + [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)] + 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 draftStore = 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 (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); + 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)>( + 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 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...")) + .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; + var m = model.Value; + 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: volumeMountsList.Value)); + await refreshSender.Send("services"); + navigator.Navigate(typeof(SliplaneServicesApp)); + } + + // 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)); + } + + 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") + | Text.Lead("Configure and deploy your Ivy app in seconds."); + + var envSection = new Expandable( + "Environment Variables", + Layout.Vertical() + | envTable + | 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()) + | validationView; + + var card = new Card( + Layout.Vertical() + | headerSection + | new Separator() + | formView + | envSection + | volumesSection + | actionsRow) + .Width(Size.Fraction(0.5f)); + + var page = Layout.Vertical().Align(Align.TopCenter) + | card; + + 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) + { + 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/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 + + diff --git a/project-demos/sliplane-manage/Program.cs b/project-demos/sliplane-manage/Program.cs index 853a5d04..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(); @@ -24,6 +30,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 @@ -35,7 +44,7 @@ var chromeSettings = new ChromeSettings() .UseTabs(preventDuplicates: true) - .DefaultApp(); + .DefaultApp(); server.UseChrome(chromeSettings); await server.RunAsync(); \ No newline at end of file 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. diff --git a/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs b/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs new file mode 100644 index 00000000..e58d25f9 --- /dev/null +++ b/project-demos/sliplane-manage/Services/DeploymentDraftStore.cs @@ -0,0 +1,72 @@ +namespace SliplaneManage.Services; + +using Microsoft.AspNetCore.Http; + +/// +/// Per-user store for the deployment repo URL. +/// Key: access token (logged-in) or anonymous browser cookie (pre-login). +/// +public class DeploymentDraftStore +{ + private static readonly System.Collections.Concurrent.ConcurrentDictionary _store = new(); + + public const string CookieName = "sliplane-deploy-repo-key"; + + private readonly IHttpContextAccessor _httpContextAccessor; + + public DeploymentDraftStore(IHttpContextAccessor httpContextAccessor) + { + _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; + } + + private string GetOrCreateKey() + { + var ctx = _httpContextAccessor.HttpContext; + + if (ctx is not null) + { + 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 new file mode 100644 index 00000000..d4ddfb7b --- /dev/null +++ b/project-demos/sliplane-manage/Services/RepoCaptureFilter.cs @@ -0,0 +1,36 @@ +namespace SliplaneManage.Services; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +/// +/// 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 +{ + 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)) + { + var store = context.RequestServices.GetRequiredService(); + store.SaveRepoUrl(Uri.UnescapeDataString(repo)); + } + } + + await nextMiddleware(context); + }); + + next(app); + }; + } +}