|
| 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 | +} |
0 commit comments