Skip to content

Commit ff57cdf

Browse files
[project-demos]: add Sliplane deploy flow from README button (#388)
* feat: add deploy button SVG for hosting Ivy apps on Sliplane * feat: implement Sliplane deployment app with repository URL capture and deployment form * refactor: simplify display attributes and restructure DeployView layout for improved readability * fix: update default application in Program.cs to SliplaneDeployApp and enhance DeployView prompts for better user guidance * refactor: enhance SliplaneServicesApp initialization and streamline DeployView navigation logic * feat: add volume management functionality to DeployView with server volume retrieval and mount path configuration * docs: add deploy button instructions to README for easy Ivy app deployment on Sliplane * feat: implement per-user deployment draft store with IHttpContextAccessor for user-specific repo URL management
1 parent bb834e8 commit ff57cdf

8 files changed

Lines changed: 518 additions & 2 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
namespace SliplaneManage.Apps;
2+
3+
using SliplaneManage.Apps.Views;
4+
using SliplaneManage.Services;
5+
6+
/// <summary>
7+
/// Route: /sliplane-deploy-app
8+
/// Opened via the "Host your Ivy app on Sliplane" button in GitHub READMEs.
9+
/// The ?repo= query param is captured by RepoCaptureFilter before Ivy SPA loads,
10+
/// stored in DeploymentDraftStore, and read here to pre-fill the deploy form.
11+
/// </summary>
12+
[App(
13+
id: "sliplane-deploy-app",
14+
icon: Icons.Rocket,
15+
title: "Deploy on Sliplane",
16+
searchHints: ["deploy", "host", "sliplane"],
17+
isVisible: true)]
18+
public class SliplaneDeployApp : ViewBase
19+
{
20+
public override object? Build()
21+
{
22+
var config = this.UseService<IConfiguration>();
23+
var auth = this.UseService<IAuthService>();
24+
var session = auth.GetAuthSession();
25+
var apiToken = config["Sliplane:ApiToken"]
26+
?? session.AuthToken?.AccessToken
27+
?? string.Empty;
28+
29+
// Repo from internal navigation args or last saved value (per-user)
30+
var draftStore = this.UseService<DeploymentDraftStore>();
31+
var args = this.UseArgs<DeployArgs>();
32+
var repoUrl = args?.Repo ?? draftStore.LastRepoUrl ?? string.Empty;
33+
34+
if (string.IsNullOrWhiteSpace(apiToken))
35+
{
36+
return Layout.Center()
37+
| (Layout.Vertical().Align(Align.Center).Gap(6)
38+
| Icons.Rocket.ToIcon()
39+
| Text.H2("Deploy to Sliplane")
40+
| (string.IsNullOrWhiteSpace(repoUrl)
41+
? Text.Muted("Sign in with Sliplane to deploy your Ivy app.")
42+
: Text.Muted($"Repository: {repoUrl}"))
43+
| Text.Muted("No API token. Please sign in or configure Sliplane:ApiToken."));
44+
}
45+
46+
return new DeployView(apiToken, repoUrl);
47+
}
48+
}
49+
50+
/// <summary>Arguments for internal Ivy navigation to SliplaneDeployApp.</summary>
51+
public record DeployArgs(string Repo);

project-demos/sliplane-manage/Apps/SliplaneOverviewApp.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public class SliplaneProjectsApp : ViewBase
5151
/// <summary>
5252
/// Services app — list services, create, edit, pause, delete.
5353
/// </summary>
54-
[App(icon: Icons.Box, title: "Services", searchHints: ["services", "deploy"])]
54+
[App(id: "sliplane-services-app", icon: Icons.Box, title: "Services", searchHints: ["services", "deploy"])]
5555
public class SliplaneServicesApp : ViewBase
5656
{
5757
public override object? Build()
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
namespace SliplaneManage.Apps.Views;
2+
3+
using System.ComponentModel.DataAnnotations;
4+
using SliplaneManage.Models;
5+
using SliplaneManage.Services;
6+
using SliplaneManage.Apps;
7+
8+
public class DeployFormModel
9+
{
10+
[Display(Name = "Server", Order = 1, Prompt = "Select a server")]
11+
[Required(ErrorMessage = "Select a server")]
12+
public string ServerId { get; set; } = "";
13+
14+
[Display(Name = "Project", Order = 2, Prompt = "Select a project")]
15+
[Required(ErrorMessage = "Select a project")]
16+
public string ProjectId { get; set; } = "";
17+
18+
[Display(Name = "Service name", Order = 3, Prompt = "my-ivy-service")]
19+
[Required(ErrorMessage = "Enter a service name")]
20+
[MinLength(2, ErrorMessage = "Service name must be at least 2 characters")]
21+
public string Name { get; set; } = "";
22+
23+
[Display(Name = "Repository URL", Order = 4, Prompt = "https://github.com/user/repo")]
24+
[Required(ErrorMessage = "Enter a repository URL")]
25+
public string GitRepo { get; set; } = "";
26+
27+
[Display(Name = "Branch", Order = 5, Prompt = "main")]
28+
public string Branch { get; set; } = "main";
29+
30+
[Display(GroupName = "Build", Name = "Dockerfile path", Order = 6, Prompt = "Dockerfile")]
31+
public string DockerfilePath { get; set; } = "Dockerfile";
32+
33+
[Display(GroupName = "Build", Name = "Docker context", Order = 7, Prompt = ".")]
34+
public string DockerContext { get; set; } = ".";
35+
36+
[Display(GroupName = "Build", Name = "Auto-deploy on push", Order = 8, Prompt = "Enable auto-deploy on push")]
37+
public bool AutoDeploy { get; set; } = true;
38+
39+
[Display(GroupName = "Network", Name = "Public access", Order = 9, Prompt = "Expose service publicly")]
40+
public bool NetworkPublic { get; set; } = true;
41+
42+
[Display(GroupName = "Network", Name = "Protocol", Order = 10, Prompt = "http or https")]
43+
public string NetworkProtocol { get; set; } = "http";
44+
45+
[Display(GroupName = "Optional", Name = "Health check path", Prompt = "/health", Order = 11)]
46+
public string Healthcheck { get; set; } = "/";
47+
48+
[Display(GroupName = "Optional", Name = "Start command", Prompt = "e.g. npm start", Order = 12)]
49+
public string? Cmd { get; set; }
50+
}
51+
52+
public class DeployView : ViewBase
53+
{
54+
private readonly string _apiToken;
55+
private readonly string _repoUrl;
56+
57+
public DeployView(string apiToken, string repoUrl)
58+
{
59+
_apiToken = apiToken;
60+
_repoUrl = repoUrl;
61+
}
62+
63+
public override object? Build()
64+
{
65+
var client = this.UseService<SliplaneApiClient>();
66+
var draftStore = this.UseService<DeploymentDraftStore>();
67+
var refreshSender = this.CreateSignal<SliplaneRefreshSignal, string, Unit>();
68+
69+
var initialName = DeriveServiceName(_repoUrl);
70+
71+
var model = this.UseState(() => new DeployFormModel
72+
{
73+
GitRepo = _repoUrl,
74+
Name = initialName,
75+
});
76+
77+
// Keep DeploymentDraftStore in sync as the user edits the repo URL (per-user)
78+
this.UseEffect(() => draftStore.SaveRepoUrl(model.Value.GitRepo), model);
79+
80+
var envList = this.UseState<List<EnvironmentVariable>>(() => new List<EnvironmentVariable>());
81+
var showAddEnvDlg = this.UseState(false);
82+
var addEnvKey = this.UseState(string.Empty);
83+
var addEnvValue = this.UseState(string.Empty);
84+
var reloadCounter = this.UseState(0);
85+
var serverVolumes = this.UseState<List<SliplaneVolume>>(() => new List<SliplaneVolume>());
86+
var volumeMountsList = this.UseState<List<(string VolumeId, string MountPath)>>(() => new List<(string, string)>());
87+
var showAddVolumeDlg = this.UseState(false);
88+
var addVolumeId = this.UseState(string.Empty);
89+
var addMountPath = this.UseState(string.Empty);
90+
91+
QueryResult<Option<string>[]> QueryProjects(IViewContext ctx, string q) =>
92+
ctx.UseQuery<Option<string>[], (string, string, int)>(
93+
key: ("deploy-projects", q, reloadCounter.Value),
94+
fetcher: async _ =>
95+
(await client.GetProjectsAsync(_apiToken))
96+
.Where(p => string.IsNullOrEmpty(q) || p.Name.Contains(q, StringComparison.OrdinalIgnoreCase))
97+
.Take(20).Select(p => new Option<string>(p.Name, p.Id)).ToArray());
98+
99+
QueryResult<Option<string>?> LookupProject(IViewContext ctx, string? id) =>
100+
ctx.UseQuery<Option<string>?, (string, string?, int)>(
101+
key: ("deploy-project-lookup", id, reloadCounter.Value),
102+
fetcher: async _ =>
103+
{
104+
if (string.IsNullOrEmpty(id)) return null;
105+
var p = (await client.GetProjectsAsync(_apiToken)).FirstOrDefault(x => x.Id == id);
106+
return p is null ? null : new Option<string>(p.Name, p.Id);
107+
});
108+
109+
QueryResult<Option<string>[]> QueryServers(IViewContext ctx, string q) =>
110+
ctx.UseQuery<Option<string>[], (string, string, int)>(
111+
key: ("deploy-servers", q, reloadCounter.Value),
112+
fetcher: async _ =>
113+
(await client.GetServersAsync(_apiToken))
114+
.Where(s => string.IsNullOrEmpty(q) || s.Name.Contains(q, StringComparison.OrdinalIgnoreCase))
115+
.Take(20).Select(s => new Option<string>(s.Name, s.Id)).ToArray());
116+
117+
QueryResult<Option<string>?> LookupServer(IViewContext ctx, string? id) =>
118+
ctx.UseQuery<Option<string>?, (string, string?, int)>(
119+
key: ("deploy-server-lookup", id, reloadCounter.Value),
120+
fetcher: async _ =>
121+
{
122+
if (string.IsNullOrEmpty(id)) return null;
123+
var s = (await client.GetServersAsync(_apiToken)).FirstOrDefault(x => x.Id == id);
124+
return s is null ? null : new Option<string>(s.Name, s.Id);
125+
});
126+
127+
var protocolOptions = new[] { new Option<string>("HTTP", "http"), new Option<string>("HTTPS", "https") };
128+
129+
var navigator = this.Context.UseNavigation();
130+
var (onSubmit, formView, validationView, loading) = this.UseForm(() => model.ToForm("Deploy")
131+
.Builder(m => m.ProjectId, s => s.ToAsyncSelectInput(QueryProjects, LookupProject, placeholder: "Search project..."))
132+
.Builder(m => m.ServerId, s => s.ToAsyncSelectInput(QueryServers, LookupServer, placeholder: "Search server..."))
133+
.Builder(m => m.NetworkProtocol, s => s.ToSelectInput(protocolOptions))
134+
.Required(m => m.ProjectId, m => m.Name, m => m.ServerId, m => m.GitRepo));
135+
136+
this.UseEffect(async () =>
137+
{
138+
var serverId = model.Value.ServerId;
139+
if (string.IsNullOrWhiteSpace(serverId))
140+
{
141+
serverVolumes.Set(new List<SliplaneVolume>());
142+
return;
143+
}
144+
try
145+
{
146+
var vols = await client.GetServerVolumesAsync(_apiToken, serverId);
147+
serverVolumes.Set(vols ?? new List<SliplaneVolume>());
148+
}
149+
catch
150+
{
151+
serverVolumes.Set(new List<SliplaneVolume>());
152+
}
153+
}, model);
154+
155+
async ValueTask HandleDeploy()
156+
{
157+
if (!await onSubmit()) return;
158+
var m = model.Value;
159+
await client.CreateServiceAsync(_apiToken, m.ProjectId,
160+
ServiceRequestFactory.BuildCreateRequest(
161+
name: m.Name, serverId: m.ServerId, gitRepo: m.GitRepo,
162+
branch: m.Branch, dockerfilePath: m.DockerfilePath,
163+
dockerContext: m.DockerContext, autoDeploy: m.AutoDeploy,
164+
networkPublic: m.NetworkPublic, networkProtocol: m.NetworkProtocol,
165+
cmd: m.Cmd ?? string.Empty, healthcheck: m.Healthcheck,
166+
env: envList.Value, volumeMounts: volumeMountsList.Value));
167+
await refreshSender.Send("services");
168+
navigator.Navigate(typeof(SliplaneServicesApp));
169+
}
170+
171+
// Env variable table
172+
var envItems = envList.Value;
173+
var envHeaderRow = new TableRow(
174+
new TableCell("Key").IsHeader(),
175+
new TableCell("Value").IsHeader(),
176+
new TableCell("").IsHeader().Width(Size.Fit()));
177+
var envDataRows = envItems.Select((e, i) => new TableRow(
178+
new TableCell(e.Key),
179+
new TableCell(e.Value ?? ""),
180+
new TableCell(new Button("Remove").Variant(ButtonVariant.Outline)
181+
.HandleClick(_ => envList.Set(envList.Value.Where((_, j) => j != i).ToList())))
182+
.Width(Size.Fit()))).ToArray();
183+
184+
object envTable = envDataRows.Length == 0
185+
? Text.Muted("No variables added.")
186+
: new Table(new[] { envHeaderRow }.Concat(envDataRows).ToArray()).Width(Size.Full());
187+
188+
Dialog? addEnvDialog = null;
189+
if (showAddEnvDlg.Value)
190+
{
191+
void SaveEnv()
192+
{
193+
if (string.IsNullOrWhiteSpace(addEnvKey.Value)) return;
194+
envList.Set(envList.Value
195+
.Append(new EnvironmentVariable(addEnvKey.Value.Trim(), addEnvValue.Value ?? string.Empty, false))
196+
.ToList());
197+
addEnvKey.Set(string.Empty);
198+
addEnvValue.Set(string.Empty);
199+
showAddEnvDlg.Set(false);
200+
}
201+
addEnvDialog = new Dialog(
202+
onClose: (Event<Dialog> _) => showAddEnvDlg.Set(false),
203+
header: new DialogHeader("Add environment variable"),
204+
body: new DialogBody(Layout.Vertical()
205+
| addEnvKey.ToTextInput().Placeholder("Key (e.g. DATABASE_URL)")
206+
| addEnvValue.ToTextInput().Placeholder("Value")),
207+
footer: new DialogFooter(
208+
new Button("Save").Variant(ButtonVariant.Primary).HandleClick(_ => SaveEnv()),
209+
new Button("Cancel").HandleClick(_ => showAddEnvDlg.Set(false))
210+
)).Width(Size.Units(220));
211+
}
212+
213+
Dialog? addVolumeDialog = null;
214+
if (showAddVolumeDlg.Value)
215+
{
216+
void SaveVolume()
217+
{
218+
if (string.IsNullOrWhiteSpace(addVolumeId.Value) || string.IsNullOrWhiteSpace(addMountPath.Value)) return;
219+
volumeMountsList.Set(volumeMountsList.Value
220+
.Append((addVolumeId.Value, addMountPath.Value.Trim()))
221+
.ToList());
222+
addVolumeId.Set(string.Empty);
223+
addMountPath.Set(string.Empty);
224+
showAddVolumeDlg.Set(false);
225+
}
226+
var volumeOptionsForDialog = (serverVolumes.Value ?? new List<SliplaneVolume>())
227+
.Select(v => new Option<string>($"{v.Name} ({v.MountPath})", v.Id)).ToArray();
228+
addVolumeDialog = new Dialog(
229+
onClose: (Event<Dialog> _) => showAddVolumeDlg.Set(false),
230+
header: new DialogHeader("Add volume mount"),
231+
body: new DialogBody(Layout.Vertical()
232+
| addVolumeId.ToSelectInput(volumeOptionsForDialog)
233+
| addMountPath.ToTextInput().Placeholder("Mount path (e.g. /data)")),
234+
footer: new DialogFooter(
235+
new Button("Save").Variant(ButtonVariant.Primary).HandleClick(_ => SaveVolume()),
236+
new Button("Cancel").HandleClick(_ => showAddVolumeDlg.Set(false))
237+
)).Width(Size.Units(220));
238+
}
239+
240+
var headerSection = Layout.Vertical().Align(Align.Center).Gap(4)
241+
| Icons.Rocket.ToIcon()
242+
| Text.H1("Deploy to Sliplane")
243+
| Text.Lead("Configure and deploy your Ivy app in seconds.");
244+
245+
var envSection = new Expandable(
246+
"Environment Variables",
247+
Layout.Vertical()
248+
| envTable
249+
| new Button("Add variable").Icon(Icons.Plus).Variant(ButtonVariant.Outline)
250+
.HandleClick(_ => showAddEnvDlg.Set(true)));
251+
252+
var vols = serverVolumes.Value ?? new List<SliplaneVolume>();
253+
var volItems = volumeMountsList.Value ?? new List<(string VolumeId, string MountPath)>();
254+
var volHeaderRow = new TableRow(
255+
new TableCell("Volume").IsHeader(),
256+
new TableCell("Mount path").IsHeader(),
257+
new TableCell("").IsHeader().Width(Size.Fit()));
258+
var volDataRows = volItems.Select((v, i) =>
259+
{
260+
var index = i;
261+
var volName = vols.FirstOrDefault(vol => vol.Id == v.VolumeId)?.Name ?? v.VolumeId;
262+
return new TableRow(
263+
new TableCell(volName),
264+
new TableCell(v.MountPath),
265+
new TableCell(new Button("Remove").Variant(ButtonVariant.Outline)
266+
.HandleClick(_ => volumeMountsList.Set(volumeMountsList.Value.Where((_, j) => j != index).ToList())))
267+
.Width(Size.Fit()));
268+
}).ToArray();
269+
object volTableContent = volDataRows.Length == 0
270+
? (object)Text.Muted("No volume mounts. Select a server first, then add.")
271+
: new Table(new[] { volHeaderRow }.Concat(volDataRows).ToArray()).Width(Size.Full());
272+
273+
var volumesSection = new Expandable(
274+
"Volumes",
275+
Layout.Vertical()
276+
| volTableContent
277+
| new Button("Add volume").Icon(Icons.Plus).Variant(ButtonVariant.Outline)
278+
.HandleClick(_ => showAddVolumeDlg.Set(true)));
279+
280+
var actionsRow = Layout.Horizontal()
281+
| new Button("Deploy").Icon(Icons.Rocket).Primary().Large().Loading(loading)
282+
.HandleClick(async _ => await HandleDeploy())
283+
| validationView;
284+
285+
var card = new Card(
286+
Layout.Vertical()
287+
| headerSection
288+
| new Separator()
289+
| formView
290+
| envSection
291+
| volumesSection
292+
| actionsRow)
293+
.Width(Size.Fraction(0.5f));
294+
295+
var page = Layout.Vertical().Align(Align.TopCenter)
296+
| card;
297+
298+
if (addEnvDialog != null && addVolumeDialog != null) return new Fragment(page, addEnvDialog, addVolumeDialog);
299+
if (addEnvDialog != null) return new Fragment(page, addEnvDialog);
300+
if (addVolumeDialog != null) return new Fragment(page, addVolumeDialog);
301+
return page;
302+
}
303+
304+
private static string DeriveServiceName(string repoUrl)
305+
{
306+
if (string.IsNullOrWhiteSpace(repoUrl)) return string.Empty;
307+
var seg = repoUrl.TrimEnd('/').Split('/').LastOrDefault() ?? string.Empty;
308+
if (seg.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) seg = seg[..^4];
309+
return string.IsNullOrWhiteSpace(seg) ? string.Empty : seg.ToLowerInvariant();
310+
}
311+
}
Lines changed: 24 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)