Add WAL file for database and log instance deployment failures
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Security.Cryptography;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -35,18 +37,23 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
[ObservableProperty] private string _newtId = string.Empty;
|
||||
[ObservableProperty] private string _newtSecret = string.Empty;
|
||||
|
||||
// CIFS / SMB credentials (per-instance, defaults loaded from global settings)
|
||||
[ObservableProperty] private string _cifsServer = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareName = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareFolder = string.Empty;
|
||||
[ObservableProperty] private string _cifsUsername = string.Empty;
|
||||
[ObservableProperty] private string _cifsPassword = string.Empty;
|
||||
[ObservableProperty] private string _cifsExtraOptions = string.Empty;
|
||||
// NFS volume settings (per-instance, defaults loaded from global settings)
|
||||
[ObservableProperty] private string _nfsServer = string.Empty;
|
||||
[ObservableProperty] private string _nfsExport = string.Empty;
|
||||
[ObservableProperty] private string _nfsExportFolder = string.Empty;
|
||||
[ObservableProperty] private string _nfsExtraOptions = string.Empty;
|
||||
|
||||
// SSH host selection
|
||||
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
|
||||
[ObservableProperty] private SshHost? _selectedSshHost;
|
||||
|
||||
// YML preview
|
||||
[ObservableProperty] private string _previewYml = string.Empty;
|
||||
[ObservableProperty] private bool _isLoadingYml;
|
||||
|
||||
public bool HasPreviewYml => !string.IsNullOrEmpty(PreviewYml);
|
||||
partial void OnPreviewYmlChanged(string value) => OnPropertyChanged(nameof(HasPreviewYml));
|
||||
|
||||
// ── Derived preview properties ───────────────────────────────────────────
|
||||
|
||||
public string PreviewStackName => Valid ? $"{Abbrev}-cms-stack" : "—";
|
||||
@@ -55,14 +62,17 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
public string PreviewServiceChart => Valid ? $"{Abbrev}-quickchart" : "—";
|
||||
public string PreviewServiceNewt => Valid ? $"{Abbrev}-newt" : "—";
|
||||
public string PreviewNetwork => Valid ? $"{Abbrev}-net" : "—";
|
||||
public string PreviewVolCustom => Valid ? $"{Abbrev}-cms-custom" : "—";
|
||||
public string PreviewVolBackup => Valid ? $"{Abbrev}-cms-backup" : "—";
|
||||
public string PreviewVolLibrary => Valid ? $"{Abbrev}-cms-library" : "—";
|
||||
public string PreviewVolUserscripts => Valid ? $"{Abbrev}-cms-userscripts": "—";
|
||||
public string PreviewVolCaCerts => Valid ? $"{Abbrev}-cms-ca-certs" : "—";
|
||||
public string PreviewVolCustom => Valid ? $"{Abbrev}/cms-custom" : "—";
|
||||
public string PreviewVolBackup => Valid ? $"{Abbrev}/cms-backup" : "—";
|
||||
public string PreviewVolLibrary => Valid ? $"{Abbrev}/cms-library" : "—";
|
||||
public string PreviewVolUserscripts => Valid ? $"{Abbrev}/cms-userscripts": "—";
|
||||
public string PreviewVolCaCerts => Valid ? $"{Abbrev}/cms-ca-certs" : "—";
|
||||
public string PreviewSecret => Valid ? $"{Abbrev}-cms-db-password": "—";
|
||||
public string PreviewSecretUser => Valid ? $"{Abbrev}-cms-db-user" : "—";
|
||||
public string PreviewSecretHost => "global_mysql_host";
|
||||
public string PreviewSecretPort => "global_mysql_port";
|
||||
public string PreviewMySqlDb => Valid ? $"{Abbrev}_cms_db" : "—";
|
||||
public string PreviewMySqlUser => Valid ? $"{Abbrev}_cms" : "—";
|
||||
public string PreviewMySqlUser => Valid ? $"{Abbrev}_cms_user" : "—";
|
||||
public string PreviewCmsUrl => Valid ? $"https://{Abbrev}.ots-signs.com" : "—";
|
||||
|
||||
private string Abbrev => CustomerAbbrev.Trim().ToLowerInvariant();
|
||||
@@ -74,7 +84,7 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
{
|
||||
_services = services;
|
||||
_ = LoadHostsAsync();
|
||||
_ = LoadCifsDefaultsAsync();
|
||||
_ = LoadNfsDefaultsAsync();
|
||||
}
|
||||
|
||||
partial void OnCustomerAbbrevChanged(string value) => RefreshPreview();
|
||||
@@ -93,6 +103,9 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(PreviewVolUserscripts));
|
||||
OnPropertyChanged(nameof(PreviewVolCaCerts));
|
||||
OnPropertyChanged(nameof(PreviewSecret));
|
||||
OnPropertyChanged(nameof(PreviewSecretUser));
|
||||
OnPropertyChanged(nameof(PreviewSecretHost));
|
||||
OnPropertyChanged(nameof(PreviewSecretPort));
|
||||
OnPropertyChanged(nameof(PreviewMySqlDb));
|
||||
OnPropertyChanged(nameof(PreviewMySqlUser));
|
||||
OnPropertyChanged(nameof(PreviewCmsUrl));
|
||||
@@ -106,16 +119,138 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
AvailableHosts = new ObservableCollection<SshHost>(hosts);
|
||||
}
|
||||
|
||||
private async Task LoadCifsDefaultsAsync()
|
||||
private async Task LoadNfsDefaultsAsync()
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
CifsServer = await settings.GetAsync(SettingsService.CifsServer) ?? string.Empty;
|
||||
CifsShareName = await settings.GetAsync(SettingsService.CifsShareName) ?? string.Empty;
|
||||
CifsShareFolder = await settings.GetAsync(SettingsService.CifsShareFolder) ?? string.Empty;
|
||||
CifsUsername = await settings.GetAsync(SettingsService.CifsUsername) ?? string.Empty;
|
||||
CifsPassword = await settings.GetAsync(SettingsService.CifsPassword) ?? string.Empty;
|
||||
CifsExtraOptions = await settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
||||
NfsServer = await settings.GetAsync(SettingsService.NfsServer) ?? string.Empty;
|
||||
NfsExport = await settings.GetAsync(SettingsService.NfsExport) ?? string.Empty;
|
||||
NfsExportFolder = await settings.GetAsync(SettingsService.NfsExportFolder) ?? string.Empty;
|
||||
NfsExtraOptions = await settings.GetAsync(SettingsService.NfsOptions) ?? string.Empty;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadYmlPreviewAsync()
|
||||
{
|
||||
if (!Valid)
|
||||
{
|
||||
PreviewYml = "# Abbreviation must be exactly 3 lowercase letters (a-z) before loading the YML preview.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsLoadingYml = true;
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
var git = scope.ServiceProvider.GetRequiredService<GitTemplateService>();
|
||||
var composer = scope.ServiceProvider.GetRequiredService<ComposeRenderService>();
|
||||
|
||||
var repoUrl = await settings.GetAsync(SettingsService.GitRepoUrl);
|
||||
var repoPat = await settings.GetAsync(SettingsService.GitRepoPat);
|
||||
if (string.IsNullOrWhiteSpace(repoUrl))
|
||||
{
|
||||
PreviewYml = "# Git template repository URL is not configured. Set it in Settings → Git Repo URL.";
|
||||
return;
|
||||
}
|
||||
|
||||
var templateConfig = await git.FetchAsync(repoUrl, repoPat);
|
||||
|
||||
var abbrev = Abbrev;
|
||||
var stackName = $"{abbrev}-cms-stack";
|
||||
|
||||
var mySqlHost = await settings.GetAsync(SettingsService.MySqlHost, "localhost");
|
||||
var mySqlPort = await settings.GetAsync(SettingsService.MySqlPort, "3306");
|
||||
var mySqlDbName = (await settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
||||
var mySqlUser = (await settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev);
|
||||
|
||||
var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com")).Replace("{abbrev}", abbrev);
|
||||
var themePath = (await settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
|
||||
|
||||
var smtpServer = await settings.GetAsync(SettingsService.SmtpServer, string.Empty);
|
||||
var smtpUsername = await settings.GetAsync(SettingsService.SmtpUsername, string.Empty);
|
||||
var smtpPassword = await settings.GetAsync(SettingsService.SmtpPassword, string.Empty);
|
||||
var smtpUseTls = await settings.GetAsync(SettingsService.SmtpUseTls, "YES");
|
||||
var smtpUseStartTls = await settings.GetAsync(SettingsService.SmtpUseStartTls, "YES");
|
||||
var smtpRewriteDomain = await settings.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty);
|
||||
var smtpHostname = await settings.GetAsync(SettingsService.SmtpHostname, string.Empty);
|
||||
var smtpFromLineOverride = await settings.GetAsync(SettingsService.SmtpFromLineOverride, "NO");
|
||||
|
||||
var pangolinEndpoint = await settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
||||
|
||||
var cmsImage = await settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
|
||||
var newtImage = await settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
|
||||
var memcachedImage = await settings.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
|
||||
var quickChartImage = await settings.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
|
||||
|
||||
var phpPostMaxSize = await settings.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
|
||||
var phpUploadMaxFilesize = await settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||
var phpMaxExecutionTime = await settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
||||
|
||||
// Use form values; fall back to saved global settings
|
||||
var nfsServer = string.IsNullOrWhiteSpace(NfsServer) ? await settings.GetAsync(SettingsService.NfsServer) : NfsServer;
|
||||
var nfsExport = string.IsNullOrWhiteSpace(NfsExport) ? await settings.GetAsync(SettingsService.NfsExport) : NfsExport;
|
||||
var nfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? await settings.GetAsync(SettingsService.NfsExportFolder) : NfsExportFolder;
|
||||
var nfsOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? await settings.GetAsync(SettingsService.NfsOptions, string.Empty) : NfsExtraOptions;
|
||||
|
||||
var ctx = new RenderContext
|
||||
{
|
||||
CustomerName = CustomerName.Trim(),
|
||||
CustomerAbbrev = abbrev,
|
||||
StackName = stackName,
|
||||
CmsServerName = cmsServerName,
|
||||
HostHttpPort = 80,
|
||||
CmsImage = cmsImage,
|
||||
MemcachedImage = memcachedImage,
|
||||
QuickChartImage = quickChartImage,
|
||||
NewtImage = newtImage,
|
||||
ThemeHostPath = themePath,
|
||||
MySqlHost = mySqlHost,
|
||||
MySqlPort = mySqlPort,
|
||||
MySqlDatabase = mySqlDbName,
|
||||
MySqlUser = mySqlUser,
|
||||
SmtpServer = smtpServer,
|
||||
SmtpUsername = smtpUsername,
|
||||
SmtpPassword = smtpPassword,
|
||||
SmtpUseTls = smtpUseTls,
|
||||
SmtpUseStartTls = smtpUseStartTls,
|
||||
SmtpRewriteDomain = smtpRewriteDomain,
|
||||
SmtpHostname = smtpHostname,
|
||||
SmtpFromLineOverride = smtpFromLineOverride,
|
||||
PhpPostMaxSize = phpPostMaxSize,
|
||||
PhpUploadMaxFilesize = phpUploadMaxFilesize,
|
||||
PhpMaxExecutionTime = phpMaxExecutionTime,
|
||||
PangolinEndpoint = pangolinEndpoint,
|
||||
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
|
||||
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
|
||||
NfsServer = nfsServer,
|
||||
NfsExport = nfsExport,
|
||||
NfsExportFolder = nfsExportFolder,
|
||||
NfsExtraOptions = nfsOptions,
|
||||
};
|
||||
|
||||
PreviewYml = composer.Render(templateConfig.Yaml, ctx);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
PreviewYml = $"# Error rendering YML preview:\n# {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoadingYml = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CopyYmlAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(PreviewYml)) return;
|
||||
var mainWindow = (Application.Current?.ApplicationLifetime
|
||||
as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
|
||||
if (mainWindow is null) return;
|
||||
var clipboard = TopLevel.GetTopLevel(mainWindow)?.Clipboard;
|
||||
if (clipboard is not null)
|
||||
await clipboard.SetTextAsync(PreviewYml);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -145,7 +280,8 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
|
||||
try
|
||||
{
|
||||
// Wire SSH host into docker services
|
||||
// Wire SSH host into docker services (singletons must know the target host before
|
||||
// InstanceService uses them internally for secrets and CLI operations)
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(SelectedSshHost);
|
||||
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
|
||||
@@ -154,38 +290,12 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
using var scope = _services.CreateScope();
|
||||
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
|
||||
|
||||
// ── Step 1: Clone template repo ────────────────────────────────
|
||||
SetProgress(10, "Cloning template repository...");
|
||||
// Handled inside InstanceService.CreateInstanceAsync
|
||||
|
||||
// ── Step 2: Generate MySQL password → Docker secret ────────────
|
||||
SetProgress(20, "Generating secrets...");
|
||||
var mysqlPassword = GenerateRandomPassword(32);
|
||||
|
||||
// ── Step 3: Create MySQL database + user via direct TCP ────────
|
||||
SetProgress(35, "Creating MySQL database and user...");
|
||||
var (mysqlOk, mysqlMsg) = await instanceSvc.CreateMySqlDatabaseAsync(
|
||||
Abbrev,
|
||||
mysqlPassword);
|
||||
|
||||
AppendOutput($"[MySQL] {mysqlMsg}");
|
||||
if (!mysqlOk)
|
||||
{
|
||||
StatusMessage = mysqlMsg;
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step 4: Create Docker Swarm secret ────────────────────────
|
||||
SetProgress(50, "Creating Docker Swarm secrets...");
|
||||
var secretName = $"{Abbrev}-cms-db-password";
|
||||
var (_, secretId) = await dockerSecrets.EnsureSecretAsync(secretName, mysqlPassword);
|
||||
AppendOutput($"[Secret] {secretName} → {secretId}");
|
||||
|
||||
// Password is now ONLY on the Swarm — clear from memory
|
||||
mysqlPassword = string.Empty;
|
||||
|
||||
// ── Step 5: Deploy stack ──────────────────────────────────────
|
||||
SetProgress(70, "Rendering compose & deploying stack...");
|
||||
// InstanceService.CreateInstanceAsync handles the full provisioning flow:
|
||||
// 1. Clone template repo
|
||||
// 2. Generate MySQL password → create Docker Swarm secret
|
||||
// 3. Create MySQL database + SQL user (same password as the secret)
|
||||
// 4. Render compose YAML → deploy stack
|
||||
SetProgress(30, "Provisioning instance (MySQL user, secrets, stack)...");
|
||||
|
||||
var dto = new CreateInstanceDto
|
||||
{
|
||||
@@ -194,12 +304,10 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
SshHostId = SelectedSshHost.Id,
|
||||
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
|
||||
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
|
||||
CifsServer = string.IsNullOrWhiteSpace(CifsServer) ? null : CifsServer.Trim(),
|
||||
CifsShareName = string.IsNullOrWhiteSpace(CifsShareName) ? null : CifsShareName.Trim(),
|
||||
CifsShareFolder = string.IsNullOrWhiteSpace(CifsShareFolder) ? null : CifsShareFolder.Trim(),
|
||||
CifsUsername = string.IsNullOrWhiteSpace(CifsUsername) ? null : CifsUsername.Trim(),
|
||||
CifsPassword = string.IsNullOrWhiteSpace(CifsPassword) ? null : CifsPassword.Trim(),
|
||||
CifsExtraOptions = string.IsNullOrWhiteSpace(CifsExtraOptions) ? null : CifsExtraOptions.Trim(),
|
||||
NfsServer = string.IsNullOrWhiteSpace(NfsServer) ? null : NfsServer.Trim(),
|
||||
NfsExport = string.IsNullOrWhiteSpace(NfsExport) ? null : NfsExport.Trim(),
|
||||
NfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? null : NfsExportFolder.Trim(),
|
||||
NfsExtraOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? null : NfsExtraOptions.Trim(),
|
||||
};
|
||||
|
||||
var result = await instanceSvc.CreateInstanceAsync(dto);
|
||||
@@ -236,10 +344,5 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
DeployOutput += (DeployOutput.Length > 0 ? "\n" : "") + text;
|
||||
}
|
||||
|
||||
private static string GenerateRandomPassword(int length)
|
||||
{
|
||||
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
|
||||
return RandomNumberGenerator.GetString(chars, length);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
@@ -22,6 +23,8 @@ public partial class HostsViewModel : ObservableObject
|
||||
[ObservableProperty] private bool _isEditing;
|
||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private ObservableCollection<NodeInfo> _remoteNodes = new();
|
||||
[ObservableProperty] private string _nodesStatusMessage = string.Empty;
|
||||
|
||||
// Edit form fields
|
||||
[ObservableProperty] private string _editLabel = string.Empty;
|
||||
@@ -202,6 +205,36 @@ public partial class HostsViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ListNodesAsync()
|
||||
{
|
||||
if (SelectedHost == null)
|
||||
{
|
||||
NodesStatusMessage = "Select a host first.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
NodesStatusMessage = $"Listing nodes on {SelectedHost.Label}...";
|
||||
try
|
||||
{
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(SelectedHost);
|
||||
var nodes = await dockerCli.ListNodesAsync();
|
||||
RemoteNodes = new ObservableCollection<NodeInfo>(nodes);
|
||||
NodesStatusMessage = $"Found {nodes.Count} node(s) on {SelectedHost.Label}.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RemoteNodes.Clear();
|
||||
NodesStatusMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task TestConnectionAsync()
|
||||
{
|
||||
|
||||
@@ -6,181 +6,156 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
using OTSSignsOrchestrator.Desktop.Models;
|
||||
using OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for listing, viewing, and managing CMS instances.
|
||||
/// All data is fetched live from Docker Swarm hosts — nothing stored locally.
|
||||
/// </summary>
|
||||
public partial class InstancesViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
[ObservableProperty] private ObservableCollection<CmsInstance> _instances = new();
|
||||
[ObservableProperty] private CmsInstance? _selectedInstance;
|
||||
[ObservableProperty] private ObservableCollection<LiveStackItem> _instances = new();
|
||||
[ObservableProperty] private LiveStackItem? _selectedInstance;
|
||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private string _filterText = string.Empty;
|
||||
[ObservableProperty] private ObservableCollection<StackInfo> _remoteStacks = new();
|
||||
[ObservableProperty] private ObservableCollection<ServiceInfo> _selectedServices = new();
|
||||
|
||||
// Available SSH hosts for the dropdown
|
||||
// Available SSH hosts — loaded for display and used to scope operations
|
||||
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
|
||||
[ObservableProperty] private SshHost? _selectedSshHost;
|
||||
|
||||
public InstancesViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
_ = InitAsync();
|
||||
}
|
||||
|
||||
private async Task InitAsync()
|
||||
{
|
||||
await LoadHostsAsync();
|
||||
await LoadInstancesAsync();
|
||||
_ = RefreshAllAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates all SSH hosts, then calls docker stack ls on each to build the
|
||||
/// live instance list. Only stacks matching *-cms-stack are shown.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private async Task LoadHostsAsync()
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
|
||||
AvailableHosts = new ObservableCollection<SshHost>(hosts);
|
||||
}
|
||||
private async Task LoadInstancesAsync() => await RefreshAllAsync();
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadInstancesAsync()
|
||||
private async Task RefreshAllAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
StatusMessage = "Loading live instances from all hosts...";
|
||||
SelectedServices = new ObservableCollection<ServiceInfo>();
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
|
||||
AvailableHosts = new ObservableCollection<SshHost>(hosts);
|
||||
|
||||
var query = db.CmsInstances.Include(i => i.SshHost).AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(FilterText))
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
var all = new List<LiveStackItem>();
|
||||
var errors = new List<string>();
|
||||
|
||||
foreach (var host in hosts)
|
||||
{
|
||||
query = query.Where(i =>
|
||||
i.CustomerName.Contains(FilterText) ||
|
||||
i.StackName.Contains(FilterText));
|
||||
try
|
||||
{
|
||||
dockerCli.SetHost(host);
|
||||
var stacks = await dockerCli.ListStacksAsync();
|
||||
foreach (var stack in stacks.Where(s => s.Name.EndsWith("-cms-stack")))
|
||||
{
|
||||
all.Add(new LiveStackItem
|
||||
{
|
||||
StackName = stack.Name,
|
||||
CustomerAbbrev = stack.Name[..^10],
|
||||
ServiceCount = stack.ServiceCount,
|
||||
Host = host,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { errors.Add($"{host.Label}: {ex.Message}"); }
|
||||
}
|
||||
|
||||
var items = await query.OrderByDescending(i => i.CreatedAt).ToListAsync();
|
||||
Instances = new ObservableCollection<CmsInstance>(items);
|
||||
StatusMessage = $"Loaded {items.Count} instance(s).";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(FilterText))
|
||||
all = all.Where(i =>
|
||||
i.StackName.Contains(FilterText, StringComparison.OrdinalIgnoreCase) ||
|
||||
i.CustomerAbbrev.Contains(FilterText, StringComparison.OrdinalIgnoreCase) ||
|
||||
i.HostLabel.Contains(FilterText, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RefreshRemoteStacksAsync()
|
||||
{
|
||||
if (SelectedSshHost == null)
|
||||
{
|
||||
StatusMessage = "Select an SSH host first.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Listing stacks on {SelectedSshHost.Label}...";
|
||||
try
|
||||
{
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(SelectedSshHost);
|
||||
|
||||
var stacks = await dockerCli.ListStacksAsync();
|
||||
RemoteStacks = new ObservableCollection<StackInfo>(stacks);
|
||||
StatusMessage = $"Found {stacks.Count} stack(s) on {SelectedSshHost.Label}.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error listing stacks: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
Instances = new ObservableCollection<LiveStackItem>(all);
|
||||
var msg = $"Found {all.Count} instance(s) across {hosts.Count} host(s).";
|
||||
if (errors.Count > 0) msg += $" | Errors: {string.Join(" | ", errors)}";
|
||||
StatusMessage = msg;
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = $"Error: {ex.Message}"; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task InspectInstanceAsync()
|
||||
{
|
||||
if (SelectedInstance == null) return;
|
||||
if (SelectedSshHost == null && SelectedInstance.SshHost == null)
|
||||
{
|
||||
StatusMessage = "No SSH host associated with this instance.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Inspecting '{SelectedInstance.StackName}'...";
|
||||
try
|
||||
{
|
||||
var host = SelectedInstance.SshHost ?? SelectedSshHost!;
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(host);
|
||||
|
||||
dockerCli.SetHost(SelectedInstance.Host);
|
||||
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
|
||||
SelectedServices = new ObservableCollection<ServiceInfo>(services);
|
||||
StatusMessage = $"Found {services.Count} service(s) in stack '{SelectedInstance.StackName}'.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error inspecting: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = $"Error inspecting: {ex.Message}"; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeleteInstanceAsync()
|
||||
{
|
||||
if (SelectedInstance == null) return;
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Deleting {SelectedInstance.StackName}...";
|
||||
try
|
||||
{
|
||||
var host = SelectedInstance.SshHost ?? SelectedSshHost;
|
||||
if (host == null)
|
||||
{
|
||||
StatusMessage = "No SSH host available for deletion.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Wire up SSH-based docker services
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(host);
|
||||
dockerCli.SetHost(SelectedInstance.Host);
|
||||
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
|
||||
dockerSecrets.SetHost(host);
|
||||
|
||||
dockerSecrets.SetHost(SelectedInstance.Host);
|
||||
using var scope = _services.CreateScope();
|
||||
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
|
||||
|
||||
var result = await instanceSvc.DeleteInstanceAsync(SelectedInstance.Id);
|
||||
var result = await instanceSvc.DeleteInstanceAsync(
|
||||
SelectedInstance.StackName, SelectedInstance.CustomerAbbrev);
|
||||
StatusMessage = result.Success
|
||||
? $"Instance '{SelectedInstance.StackName}' deleted."
|
||||
: $"Delete failed: {result.ErrorMessage}";
|
||||
await RefreshAllAsync();
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = $"Error deleting: {ex.Message}"; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
await LoadInstancesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
[RelayCommand]
|
||||
private async Task RotateMySqlPasswordAsync()
|
||||
{
|
||||
if (SelectedInstance == null) return;
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Rotating MySQL password for {SelectedInstance.StackName}...";
|
||||
try
|
||||
{
|
||||
StatusMessage = $"Error deleting: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(SelectedInstance.Host);
|
||||
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
|
||||
dockerSecrets.SetHost(SelectedInstance.Host);
|
||||
using var scope = _services.CreateScope();
|
||||
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
|
||||
var (ok, msg) = await instanceSvc.RotateMySqlPasswordAsync(SelectedInstance.StackName);
|
||||
StatusMessage = ok ? $"Done: {msg}" : $"Failed: {msg}";
|
||||
await RefreshAllAsync();
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = $"Error rotating password: {ex.Message}"; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ public partial class LogsViewModel : ObservableObject
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
|
||||
var items = await db.OperationLogs
|
||||
.Include(l => l.Instance)
|
||||
.OrderByDescending(l => l.Timestamp)
|
||||
.Take(MaxEntries)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -2,13 +2,12 @@ using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MySqlConnector;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for the Settings page — manages Git, MySQL, SMTP, Pangolin, CIFS,
|
||||
/// ViewModel for the Settings page — manages Git, MySQL, SMTP, Pangolin, NFS,
|
||||
/// and Instance Defaults configuration, persisted via SettingsService.
|
||||
/// </summary>
|
||||
public partial class SettingsViewModel : ObservableObject
|
||||
@@ -41,13 +40,11 @@ public partial class SettingsViewModel : ObservableObject
|
||||
// ── Pangolin ────────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _pangolinEndpoint = "https://app.pangolin.net";
|
||||
|
||||
// ── CIFS ────────────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _cifsServer = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareName = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareFolder = string.Empty;
|
||||
[ObservableProperty] private string _cifsUsername = string.Empty;
|
||||
[ObservableProperty] private string _cifsPassword = string.Empty;
|
||||
[ObservableProperty] private string _cifsOptions = "file_mode=0777,dir_mode=0777";
|
||||
// ── NFS ─────────────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _nfsServer = string.Empty;
|
||||
[ObservableProperty] private string _nfsExport = string.Empty;
|
||||
[ObservableProperty] private string _nfsExportFolder = string.Empty;
|
||||
[ObservableProperty] private string _nfsOptions = string.Empty;
|
||||
|
||||
// ── Instance Defaults ───────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _defaultCmsImage = "ghcr.io/xibosignage/xibo-cms:release-4.2.3";
|
||||
@@ -57,7 +54,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
[ObservableProperty] private string _defaultCmsServerNameTemplate = "{abbrev}.ots-signs.com";
|
||||
[ObservableProperty] private string _defaultThemeHostPath = "/cms/{abbrev}-cms-theme-custom";
|
||||
[ObservableProperty] private string _defaultMySqlDbTemplate = "{abbrev}_cms_db";
|
||||
[ObservableProperty] private string _defaultMySqlUserTemplate = "{abbrev}_cms";
|
||||
[ObservableProperty] private string _defaultMySqlUserTemplate = "{abbrev}_cms_user";
|
||||
[ObservableProperty] private string _defaultPhpPostMaxSize = "10G";
|
||||
[ObservableProperty] private string _defaultPhpUploadMaxFilesize = "10G";
|
||||
[ObservableProperty] private string _defaultPhpMaxExecutionTime = "600";
|
||||
@@ -100,13 +97,11 @@ public partial class SettingsViewModel : ObservableObject
|
||||
// Pangolin
|
||||
PangolinEndpoint = await svc.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
||||
|
||||
// CIFS
|
||||
CifsServer = await svc.GetAsync(SettingsService.CifsServer, string.Empty);
|
||||
CifsShareName = await svc.GetAsync(SettingsService.CifsShareName, string.Empty);
|
||||
CifsShareFolder = await svc.GetAsync(SettingsService.CifsShareFolder, string.Empty);
|
||||
CifsUsername = await svc.GetAsync(SettingsService.CifsUsername, string.Empty);
|
||||
CifsPassword = await svc.GetAsync(SettingsService.CifsPassword, string.Empty);
|
||||
CifsOptions = await svc.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
||||
// NFS
|
||||
NfsServer = await svc.GetAsync(SettingsService.NfsServer, string.Empty);
|
||||
NfsExport = await svc.GetAsync(SettingsService.NfsExport, string.Empty);
|
||||
NfsExportFolder = await svc.GetAsync(SettingsService.NfsExportFolder, string.Empty);
|
||||
NfsOptions = await svc.GetAsync(SettingsService.NfsOptions, string.Empty);
|
||||
|
||||
// Instance Defaults
|
||||
DefaultCmsImage = await svc.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
|
||||
@@ -116,7 +111,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
DefaultCmsServerNameTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com");
|
||||
DefaultThemeHostPath = await svc.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/{abbrev}-cms-theme-custom");
|
||||
DefaultMySqlDbTemplate = await svc.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db");
|
||||
DefaultMySqlUserTemplate = await svc.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms");
|
||||
DefaultMySqlUserTemplate = await svc.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user");
|
||||
DefaultPhpPostMaxSize = await svc.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
|
||||
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||
DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
||||
@@ -167,13 +162,11 @@ public partial class SettingsViewModel : ObservableObject
|
||||
// Pangolin
|
||||
(SettingsService.PangolinEndpoint, NullIfEmpty(PangolinEndpoint), SettingsService.CatPangolin, false),
|
||||
|
||||
// CIFS
|
||||
(SettingsService.CifsServer, NullIfEmpty(CifsServer), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsShareName, NullIfEmpty(CifsShareName), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsShareFolder, NullIfEmpty(CifsShareFolder), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsUsername, NullIfEmpty(CifsUsername), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsPassword, NullIfEmpty(CifsPassword), SettingsService.CatCifs, true),
|
||||
(SettingsService.CifsOptions, NullIfEmpty(CifsOptions), SettingsService.CatCifs, false),
|
||||
// NFS
|
||||
(SettingsService.NfsServer, NullIfEmpty(NfsServer), SettingsService.CatNfs, false),
|
||||
(SettingsService.NfsExport, NullIfEmpty(NfsExport), SettingsService.CatNfs, false),
|
||||
(SettingsService.NfsExportFolder, NullIfEmpty(NfsExportFolder), SettingsService.CatNfs, false),
|
||||
(SettingsService.NfsOptions, NullIfEmpty(NfsOptions), SettingsService.CatNfs, false),
|
||||
|
||||
// Instance Defaults
|
||||
(SettingsService.DefaultCmsImage, DefaultCmsImage, SettingsService.CatDefaults, false),
|
||||
@@ -218,32 +211,21 @@ public partial class SettingsViewModel : ObservableObject
|
||||
if (!int.TryParse(MySqlPort, out var port))
|
||||
port = 3306;
|
||||
|
||||
var csb = new MySqlConnectionStringBuilder
|
||||
{
|
||||
Server = MySqlHost,
|
||||
Port = (uint)port,
|
||||
UserID = MySqlAdminUser,
|
||||
Password = MySqlAdminPassword,
|
||||
ConnectionTimeout = 10,
|
||||
SslMode = MySqlSslMode.Preferred,
|
||||
};
|
||||
|
||||
await using var connection = new MySqlConnection(csb.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
var docker = _services.GetRequiredService<IDockerCliService>();
|
||||
var (connection, tunnel) = await docker.OpenMySqlConnectionAsync(
|
||||
MySqlHost, port, MySqlAdminUser, MySqlAdminPassword);
|
||||
await using var _ = connection;
|
||||
using var __ = tunnel;
|
||||
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT 1";
|
||||
await cmd.ExecuteScalarAsync();
|
||||
|
||||
StatusMessage = $"MySQL connection successful ({MySqlHost}:{port}).";
|
||||
}
|
||||
catch (MySqlException ex)
|
||||
{
|
||||
StatusMessage = $"MySQL connection failed: {ex.Message}";
|
||||
StatusMessage = $"MySQL connection successful ({MySqlHost}:{port} via SSH tunnel).";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"MySQL test error: {ex.Message}";
|
||||
StatusMessage = $"MySQL connection failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user