feat: Add main application views and structure
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled

- Implemented CreateInstanceView for creating new instances.
- Added HostsView for managing SSH hosts with CRUD operations.
- Created InstancesView for displaying and managing instances.
- Developed LogsView for viewing operation logs.
- Introduced SecretsView for managing secrets associated with hosts.
- Established SettingsView for configuring application settings.
- Created MainWindow as the main application window with navigation.
- Added app manifest and configuration files for logging and settings.
This commit is contained in:
Matt Batchelder
2026-02-18 10:43:27 -05:00
parent 29b8c23dbb
commit 45c94b6536
149 changed files with 6469 additions and 63498 deletions

View File

@@ -0,0 +1,244 @@
using System.Collections.ObjectModel;
using System.Security.Cryptography;
using CommunityToolkit.Mvvm.ComponentModel;
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.Core.Services;
using OTSSignsOrchestrator.Desktop.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for the Create Instance form.
/// Simplified: Customer Name, Abbreviation, SSH Host, and optional Newt credentials.
/// All other config comes from the Settings page.
/// </summary>
public partial class CreateInstanceViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _deployOutput = string.Empty;
[ObservableProperty] private double _progressPercent;
[ObservableProperty] private string _progressStep = string.Empty;
// Core form fields — only these two are required from the user
[ObservableProperty] private string _customerName = string.Empty;
[ObservableProperty] private string _customerAbbrev = string.Empty;
// Optional Pangolin/Newt credentials (per-instance)
[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 _cifsShareBasePath = string.Empty;
[ObservableProperty] private string _cifsUsername = string.Empty;
[ObservableProperty] private string _cifsPassword = string.Empty;
[ObservableProperty] private string _cifsExtraOptions = string.Empty;
// SSH host selection
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
[ObservableProperty] private SshHost? _selectedSshHost;
// ── Derived preview properties ───────────────────────────────────────────
public string PreviewStackName => Valid ? $"{Abbrev}-cms-stack" : "—";
public string PreviewServiceWeb => Valid ? $"{Abbrev}-web" : "—";
public string PreviewServiceCache => Valid ? $"{Abbrev}-memcached" : "—";
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 PreviewSecret => Valid ? $"{Abbrev}-cms-db-password": "—";
public string PreviewMySqlDb => Valid ? $"{Abbrev}_cms_db" : "—";
public string PreviewMySqlUser => Valid ? $"{Abbrev}_cms" : "—";
public string PreviewCmsUrl => Valid ? $"https://{Abbrev}.ots-signs.com" : "—";
private string Abbrev => CustomerAbbrev.Trim().ToLowerInvariant();
private bool Valid => Abbrev.Length == 3 && System.Text.RegularExpressions.Regex.IsMatch(Abbrev, "^[a-z]{3}$");
// ─────────────────────────────────────────────────────────────────────────
public CreateInstanceViewModel(IServiceProvider services)
{
_services = services;
_ = LoadHostsAsync();
_ = LoadCifsDefaultsAsync();
}
partial void OnCustomerAbbrevChanged(string value) => RefreshPreview();
private void RefreshPreview()
{
OnPropertyChanged(nameof(PreviewStackName));
OnPropertyChanged(nameof(PreviewServiceWeb));
OnPropertyChanged(nameof(PreviewServiceCache));
OnPropertyChanged(nameof(PreviewServiceChart));
OnPropertyChanged(nameof(PreviewServiceNewt));
OnPropertyChanged(nameof(PreviewNetwork));
OnPropertyChanged(nameof(PreviewVolCustom));
OnPropertyChanged(nameof(PreviewVolBackup));
OnPropertyChanged(nameof(PreviewVolLibrary));
OnPropertyChanged(nameof(PreviewVolUserscripts));
OnPropertyChanged(nameof(PreviewVolCaCerts));
OnPropertyChanged(nameof(PreviewSecret));
OnPropertyChanged(nameof(PreviewMySqlDb));
OnPropertyChanged(nameof(PreviewMySqlUser));
OnPropertyChanged(nameof(PreviewCmsUrl));
}
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 LoadCifsDefaultsAsync()
{
using var scope = _services.CreateScope();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
CifsServer = await settings.GetAsync(SettingsService.CifsServer) ?? string.Empty;
CifsShareBasePath = await settings.GetAsync(SettingsService.CifsShareBasePath) ?? 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");
}
[RelayCommand]
private async Task DeployAsync()
{
// ── Validation ───────────────────────────────────────────────────
if (SelectedSshHost == null)
{
StatusMessage = "Select an SSH host first.";
return;
}
if (string.IsNullOrWhiteSpace(CustomerName))
{
StatusMessage = "Customer Name is required.";
return;
}
if (!Valid)
{
StatusMessage = "Abbreviation must be exactly 3 lowercase letters (a-z).";
return;
}
IsBusy = true;
StatusMessage = "Starting deployment...";
DeployOutput = string.Empty;
ProgressPercent = 0;
try
{
// Wire SSH host into docker services
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedSshHost);
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
dockerSecrets.SetHost(SelectedSshHost);
var ssh = _services.GetRequiredService<SshConnectionService>();
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 SSH ───────────────
SetProgress(35, "Creating MySQL database and user...");
var (mysqlOk, mysqlMsg) = await instanceSvc.CreateMySqlDatabaseAsync(
Abbrev,
mysqlPassword,
cmd => ssh.RunCommandAsync(SelectedSshHost, cmd));
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...");
var dto = new CreateInstanceDto
{
CustomerName = CustomerName.Trim(),
CustomerAbbrev = Abbrev,
SshHostId = SelectedSshHost.Id,
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
CifsServer = string.IsNullOrWhiteSpace(CifsServer) ? null : CifsServer.Trim(),
CifsShareBasePath = string.IsNullOrWhiteSpace(CifsShareBasePath) ? null : CifsShareBasePath.Trim(),
CifsUsername = string.IsNullOrWhiteSpace(CifsUsername) ? null : CifsUsername.Trim(),
CifsPassword = string.IsNullOrWhiteSpace(CifsPassword) ? null : CifsPassword.Trim(),
CifsExtraOptions = string.IsNullOrWhiteSpace(CifsExtraOptions) ? null : CifsExtraOptions.Trim(),
};
var result = await instanceSvc.CreateInstanceAsync(dto);
AppendOutput(result.Output ?? string.Empty);
SetProgress(100, result.Success ? "Deployment complete!" : "Deployment failed.");
StatusMessage = result.Success
? $"Instance '{Abbrev}-cms-stack' deployed in {result.DurationMs}ms!"
: $"Deploy failed: {result.ErrorMessage}";
}
catch (Exception ex)
{
StatusMessage = $"Error: {ex.Message}";
AppendOutput(ex.ToString());
SetProgress(0, "Failed.");
}
finally
{
IsBusy = false;
}
}
private void SetProgress(double pct, string step)
{
ProgressPercent = pct;
ProgressStep = step;
AppendOutput($"[{pct:0}%] {step}");
}
private void AppendOutput(string text)
{
if (!string.IsNullOrWhiteSpace(text))
DeployOutput += (DeployOutput.Length > 0 ? "\n" : "") + text;
}
private static string GenerateRandomPassword(int length)
{
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
return RandomNumberGenerator.GetString(chars, length);
}
}

View File

@@ -0,0 +1,243 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Desktop.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for managing SSH host connections.
/// Allows adding, editing, testing, and removing remote Docker Swarm hosts.
/// </summary>
public partial class HostsViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty] private ObservableCollection<SshHost> _hosts = new();
[ObservableProperty] private SshHost? _selectedHost;
[ObservableProperty] private bool _isEditing;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
// Edit form fields
[ObservableProperty] private string _editLabel = string.Empty;
[ObservableProperty] private string _editHost = string.Empty;
[ObservableProperty] private int _editPort = 22;
[ObservableProperty] private string _editUsername = string.Empty;
[ObservableProperty] private string _editPrivateKeyPath = string.Empty;
[ObservableProperty] private string _editKeyPassphrase = string.Empty;
[ObservableProperty] private string _editPassword = string.Empty;
[ObservableProperty] private bool _editUseKeyAuth = true;
private Guid? _editingHostId;
public HostsViewModel(IServiceProvider services)
{
_services = services;
_ = LoadHostsAsync();
}
[RelayCommand]
private async Task LoadHostsAsync()
{
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
Hosts = new ObservableCollection<SshHost>(hosts);
StatusMessage = $"Loaded {hosts.Count} host(s).";
}
catch (Exception ex)
{
StatusMessage = $"Error loading hosts: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private void NewHost()
{
_editingHostId = null;
EditLabel = string.Empty;
EditHost = string.Empty;
EditPort = 22;
EditUsername = string.Empty;
EditPrivateKeyPath = string.Empty;
EditKeyPassphrase = string.Empty;
EditPassword = string.Empty;
EditUseKeyAuth = true;
IsEditing = true;
}
[RelayCommand]
private void EditSelectedHost()
{
if (SelectedHost == null) return;
_editingHostId = SelectedHost.Id;
EditLabel = SelectedHost.Label;
EditHost = SelectedHost.Host;
EditPort = SelectedHost.Port;
EditUsername = SelectedHost.Username;
EditPrivateKeyPath = SelectedHost.PrivateKeyPath ?? string.Empty;
EditKeyPassphrase = string.Empty; // Don't show existing passphrase
EditPassword = string.Empty; // Don't show existing password
EditUseKeyAuth = SelectedHost.UseKeyAuth;
IsEditing = true;
}
[RelayCommand]
private void CancelEdit()
{
IsEditing = false;
}
[RelayCommand]
private async Task SaveHostAsync()
{
if (string.IsNullOrWhiteSpace(EditLabel) || string.IsNullOrWhiteSpace(EditHost) || string.IsNullOrWhiteSpace(EditUsername))
{
StatusMessage = "Label, Host, and Username are required.";
return;
}
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
SshHost host;
if (_editingHostId.HasValue)
{
host = await db.SshHosts.FindAsync(_editingHostId.Value)
?? throw new KeyNotFoundException("Host not found.");
host.Label = EditLabel;
host.Host = EditHost;
host.Port = EditPort;
host.Username = EditUsername;
host.PrivateKeyPath = string.IsNullOrWhiteSpace(EditPrivateKeyPath) ? null : EditPrivateKeyPath;
host.UseKeyAuth = EditUseKeyAuth;
host.UpdatedAt = DateTime.UtcNow;
if (!string.IsNullOrEmpty(EditKeyPassphrase))
host.KeyPassphrase = EditKeyPassphrase;
if (!string.IsNullOrEmpty(EditPassword))
host.Password = EditPassword;
}
else
{
host = new SshHost
{
Label = EditLabel,
Host = EditHost,
Port = EditPort,
Username = EditUsername,
PrivateKeyPath = string.IsNullOrWhiteSpace(EditPrivateKeyPath) ? null : EditPrivateKeyPath,
KeyPassphrase = string.IsNullOrEmpty(EditKeyPassphrase) ? null : EditKeyPassphrase,
Password = string.IsNullOrEmpty(EditPassword) ? null : EditPassword,
UseKeyAuth = EditUseKeyAuth
};
db.SshHosts.Add(host);
}
await db.SaveChangesAsync();
IsEditing = false;
StatusMessage = $"Host '{host.Label}' saved.";
await LoadHostsAsync();
}
catch (Exception ex)
{
StatusMessage = $"Error saving host: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DeleteHostAsync()
{
if (SelectedHost == null) return;
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var host = await db.SshHosts.FindAsync(SelectedHost.Id);
if (host != null)
{
db.SshHosts.Remove(host);
await db.SaveChangesAsync();
}
// Disconnect if connected
var ssh = _services.GetRequiredService<SshConnectionService>();
ssh.Disconnect(SelectedHost.Id);
StatusMessage = $"Host '{SelectedHost.Label}' deleted.";
await LoadHostsAsync();
}
catch (Exception ex)
{
StatusMessage = $"Error deleting host: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task TestConnectionAsync()
{
if (SelectedHost == null) return;
IsBusy = true;
StatusMessage = $"Testing connection to {SelectedHost.Label}...";
try
{
var ssh = _services.GetRequiredService<SshConnectionService>();
var (success, message) = await ssh.TestConnectionAsync(SelectedHost);
// Update DB
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var host = await db.SshHosts.FindAsync(SelectedHost.Id);
if (host != null)
{
host.LastTestedAt = DateTime.UtcNow;
host.LastTestSuccess = success;
await db.SaveChangesAsync();
}
StatusMessage = success
? $"✓ {SelectedHost.Label}: {message}"
: $"✗ {SelectedHost.Label}: {message}";
await LoadHostsAsync();
}
catch (Exception ex)
{
StatusMessage = $"Connection test error: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View File

@@ -0,0 +1,186 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services;
using OTSSignsOrchestrator.Desktop.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for listing, viewing, and managing CMS instances.
/// </summary>
public partial class InstancesViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty] private ObservableCollection<CmsInstance> _instances = new();
[ObservableProperty] private CmsInstance? _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
[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();
}
[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);
}
[RelayCommand]
private async Task LoadInstancesAsync()
{
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var query = db.CmsInstances.Include(i => i.SshHost).AsQueryable();
if (!string.IsNullOrWhiteSpace(FilterText))
{
query = query.Where(i =>
i.CustomerName.Contains(FilterText) ||
i.StackName.Contains(FilterText));
}
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;
}
}
[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;
}
}
[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;
try
{
var host = SelectedInstance.SshHost ?? SelectedSshHost!;
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(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;
}
}
[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);
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
dockerSecrets.SetHost(host);
using var scope = _services.CreateScope();
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
var result = await instanceSvc.DeleteInstanceAsync(SelectedInstance.Id);
StatusMessage = result.Success
? $"Instance '{SelectedInstance.StackName}' deleted."
: $"Delete failed: {result.ErrorMessage}";
await LoadInstancesAsync();
}
catch (Exception ex)
{
StatusMessage = $"Error deleting: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View File

@@ -0,0 +1,56 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.Entities;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for viewing operation logs.
/// </summary>
public partial class LogsViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty] private ObservableCollection<OperationLog> _logs = new();
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private int _maxEntries = 100;
public LogsViewModel(IServiceProvider services)
{
_services = services;
_ = LoadLogsAsync();
}
[RelayCommand]
private async Task LoadLogsAsync()
{
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var items = await db.OperationLogs
.Include(l => l.Instance)
.OrderByDescending(l => l.Timestamp)
.Take(MaxEntries)
.ToListAsync();
Logs = new ObservableCollection<OperationLog>(items);
StatusMessage = $"Showing {items.Count} log entries.";
}
catch (Exception ex)
{
StatusMessage = $"Error loading logs: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View File

@@ -0,0 +1,60 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
public partial class MainWindowViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty]
private ObservableObject? _currentView;
[ObservableProperty]
private string _selectedNav = "Hosts";
[ObservableProperty]
private string _statusMessage = "Ready";
public ObservableCollection<string> NavItems { get; } = new()
{
"Hosts",
"Instances",
"Create Instance",
"Secrets",
"Settings",
"Logs"
};
public MainWindowViewModel(IServiceProvider services)
{
_services = services;
NavigateTo("Hosts");
}
partial void OnSelectedNavChanged(string value)
{
NavigateTo(value);
}
[RelayCommand]
private void NavigateTo(string page)
{
CurrentView = page switch
{
"Hosts" => (ObservableObject)_services.GetService(typeof(HostsViewModel))!,
"Instances" => (ObservableObject)_services.GetService(typeof(InstancesViewModel))!,
"Create Instance" => (ObservableObject)_services.GetService(typeof(CreateInstanceViewModel))!,
"Secrets" => (ObservableObject)_services.GetService(typeof(SecretsViewModel))!,
"Settings" => (ObservableObject)_services.GetService(typeof(SettingsViewModel))!,
"Logs" => (ObservableObject)_services.GetService(typeof(LogsViewModel))!,
_ => CurrentView
};
}
public void SetStatus(string message)
{
StatusMessage = message;
}
}

View File

@@ -0,0 +1,68 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services;
using OTSSignsOrchestrator.Desktop.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for viewing and managing Docker Swarm secrets on a remote host.
/// </summary>
public partial class SecretsViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty] private ObservableCollection<SecretListItem> _secrets = new();
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
[ObservableProperty] private SshHost? _selectedSshHost;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
public SecretsViewModel(IServiceProvider services)
{
_services = services;
_ = LoadHostsAsync();
}
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);
}
[RelayCommand]
private async Task LoadSecretsAsync()
{
if (SelectedSshHost == null)
{
StatusMessage = "Select an SSH host first.";
return;
}
IsBusy = true;
try
{
var secretsSvc = _services.GetRequiredService<SshDockerSecretsService>();
secretsSvc.SetHost(SelectedSshHost);
var items = await secretsSvc.ListSecretsAsync();
Secrets = new ObservableCollection<SecretListItem>(items);
StatusMessage = $"Found {items.Count} secret(s) on {SelectedSshHost.Label}.";
}
catch (Exception ex)
{
StatusMessage = $"Error: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View File

@@ -0,0 +1,248 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for the Settings page — manages Git, MySQL, SMTP, Pangolin, CIFS,
/// and Instance Defaults configuration, persisted via SettingsService.
/// </summary>
public partial class SettingsViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
// ── Git ──────────────────────────────────────────────────────────────────
[ObservableProperty] private string _gitRepoUrl = string.Empty;
[ObservableProperty] private string _gitRepoPat = string.Empty;
// ── MySQL Admin ─────────────────────────────────────────────────────────
[ObservableProperty] private string _mySqlHost = string.Empty;
[ObservableProperty] private string _mySqlPort = "3306";
[ObservableProperty] private string _mySqlAdminUser = string.Empty;
[ObservableProperty] private string _mySqlAdminPassword = string.Empty;
// ── SMTP ────────────────────────────────────────────────────────────────
[ObservableProperty] private string _smtpServer = string.Empty;
[ObservableProperty] private string _smtpUsername = string.Empty;
[ObservableProperty] private string _smtpPassword = string.Empty;
[ObservableProperty] private bool _smtpUseTls = true;
[ObservableProperty] private bool _smtpUseStartTls = true;
[ObservableProperty] private string _smtpRewriteDomain = string.Empty;
[ObservableProperty] private string _smtpHostname = string.Empty;
[ObservableProperty] private string _smtpFromLineOverride = "NO";
// ── Pangolin ────────────────────────────────────────────────────────────
[ObservableProperty] private string _pangolinEndpoint = "https://app.pangolin.net";
// ── CIFS ────────────────────────────────────────────────────────────────
[ObservableProperty] private string _cifsServer = string.Empty;
[ObservableProperty] private string _cifsShareBasePath = string.Empty;
[ObservableProperty] private string _cifsUsername = string.Empty;
[ObservableProperty] private string _cifsPassword = string.Empty;
[ObservableProperty] private string _cifsOptions = "file_mode=0777,dir_mode=0777";
// ── Instance Defaults ───────────────────────────────────────────────────
[ObservableProperty] private string _defaultCmsImage = "ghcr.io/xibosignage/xibo-cms:release-4.2.3";
[ObservableProperty] private string _defaultNewtImage = "fosrl/newt";
[ObservableProperty] private string _defaultMemcachedImage = "memcached:alpine";
[ObservableProperty] private string _defaultQuickChartImage = "ianw/quickchart";
[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 _defaultPhpPostMaxSize = "10G";
[ObservableProperty] private string _defaultPhpUploadMaxFilesize = "10G";
[ObservableProperty] private string _defaultPhpMaxExecutionTime = "600";
public SettingsViewModel(IServiceProvider services)
{
_services = services;
_ = LoadAsync();
}
[RelayCommand]
private async Task LoadAsync()
{
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
// Git
GitRepoUrl = await svc.GetAsync(SettingsService.GitRepoUrl, string.Empty);
GitRepoPat = await svc.GetAsync(SettingsService.GitRepoPat, string.Empty);
// MySQL
MySqlHost = await svc.GetAsync(SettingsService.MySqlHost, string.Empty);
MySqlPort = await svc.GetAsync(SettingsService.MySqlPort, "3306");
MySqlAdminUser = await svc.GetAsync(SettingsService.MySqlAdminUser, string.Empty);
MySqlAdminPassword = await svc.GetAsync(SettingsService.MySqlAdminPassword, string.Empty);
// SMTP
SmtpServer = await svc.GetAsync(SettingsService.SmtpServer, string.Empty);
SmtpUsername = await svc.GetAsync(SettingsService.SmtpUsername, string.Empty);
SmtpPassword = await svc.GetAsync(SettingsService.SmtpPassword, string.Empty);
SmtpUseTls = (await svc.GetAsync(SettingsService.SmtpUseTls, "YES")) == "YES";
SmtpUseStartTls = (await svc.GetAsync(SettingsService.SmtpUseStartTls, "YES")) == "YES";
SmtpRewriteDomain = await svc.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty);
SmtpHostname = await svc.GetAsync(SettingsService.SmtpHostname, string.Empty);
SmtpFromLineOverride = await svc.GetAsync(SettingsService.SmtpFromLineOverride, "NO");
// Pangolin
PangolinEndpoint = await svc.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
// CIFS
CifsServer = await svc.GetAsync(SettingsService.CifsServer, string.Empty);
CifsShareBasePath = await svc.GetAsync(SettingsService.CifsShareBasePath, 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");
// Instance Defaults
DefaultCmsImage = await svc.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
DefaultNewtImage = await svc.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
DefaultMemcachedImage = await svc.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
DefaultQuickChartImage = await svc.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
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");
DefaultPhpPostMaxSize = await svc.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
StatusMessage = "Settings loaded.";
}
catch (Exception ex)
{
StatusMessage = $"Error loading settings: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task SaveAsync()
{
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
var settings = new List<(string Key, string? Value, string Category, bool IsSensitive)>
{
// Git
(SettingsService.GitRepoUrl, NullIfEmpty(GitRepoUrl), SettingsService.CatGit, false),
(SettingsService.GitRepoPat, NullIfEmpty(GitRepoPat), SettingsService.CatGit, true),
// MySQL
(SettingsService.MySqlHost, NullIfEmpty(MySqlHost), SettingsService.CatMySql, false),
(SettingsService.MySqlPort, MySqlPort, SettingsService.CatMySql, false),
(SettingsService.MySqlAdminUser, NullIfEmpty(MySqlAdminUser), SettingsService.CatMySql, false),
(SettingsService.MySqlAdminPassword, NullIfEmpty(MySqlAdminPassword), SettingsService.CatMySql, true),
// SMTP
(SettingsService.SmtpServer, NullIfEmpty(SmtpServer), SettingsService.CatSmtp, false),
(SettingsService.SmtpUsername, NullIfEmpty(SmtpUsername), SettingsService.CatSmtp, false),
(SettingsService.SmtpPassword, NullIfEmpty(SmtpPassword), SettingsService.CatSmtp, true),
(SettingsService.SmtpUseTls, SmtpUseTls ? "YES" : "NO", SettingsService.CatSmtp, false),
(SettingsService.SmtpUseStartTls, SmtpUseStartTls ? "YES" : "NO", SettingsService.CatSmtp, false),
(SettingsService.SmtpRewriteDomain, NullIfEmpty(SmtpRewriteDomain), SettingsService.CatSmtp, false),
(SettingsService.SmtpHostname, NullIfEmpty(SmtpHostname), SettingsService.CatSmtp, false),
(SettingsService.SmtpFromLineOverride, SmtpFromLineOverride, SettingsService.CatSmtp, false),
// Pangolin
(SettingsService.PangolinEndpoint, NullIfEmpty(PangolinEndpoint), SettingsService.CatPangolin, false),
// CIFS
(SettingsService.CifsServer, NullIfEmpty(CifsServer), SettingsService.CatCifs, false),
(SettingsService.CifsShareBasePath, NullIfEmpty(CifsShareBasePath), SettingsService.CatCifs, false),
(SettingsService.CifsUsername, NullIfEmpty(CifsUsername), SettingsService.CatCifs, false),
(SettingsService.CifsPassword, NullIfEmpty(CifsPassword), SettingsService.CatCifs, true),
(SettingsService.CifsOptions, NullIfEmpty(CifsOptions), SettingsService.CatCifs, false),
// Instance Defaults
(SettingsService.DefaultCmsImage, DefaultCmsImage, SettingsService.CatDefaults, false),
(SettingsService.DefaultNewtImage, DefaultNewtImage, SettingsService.CatDefaults, false),
(SettingsService.DefaultMemcachedImage, DefaultMemcachedImage, SettingsService.CatDefaults, false),
(SettingsService.DefaultQuickChartImage, DefaultQuickChartImage, SettingsService.CatDefaults, false),
(SettingsService.DefaultCmsServerNameTemplate, DefaultCmsServerNameTemplate, SettingsService.CatDefaults, false),
(SettingsService.DefaultThemeHostPath, DefaultThemeHostPath, SettingsService.CatDefaults, false),
(SettingsService.DefaultMySqlDbTemplate, DefaultMySqlDbTemplate, SettingsService.CatDefaults, false),
(SettingsService.DefaultMySqlUserTemplate, DefaultMySqlUserTemplate, SettingsService.CatDefaults, false),
(SettingsService.DefaultPhpPostMaxSize, DefaultPhpPostMaxSize, SettingsService.CatDefaults, false),
(SettingsService.DefaultPhpUploadMaxFilesize, DefaultPhpUploadMaxFilesize, SettingsService.CatDefaults, false),
(SettingsService.DefaultPhpMaxExecutionTime, DefaultPhpMaxExecutionTime, SettingsService.CatDefaults, false),
};
await svc.SaveManyAsync(settings);
StatusMessage = "Settings saved successfully.";
}
catch (Exception ex)
{
StatusMessage = $"Error saving settings: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task TestMySqlConnectionAsync()
{
if (string.IsNullOrWhiteSpace(MySqlHost) || string.IsNullOrWhiteSpace(MySqlAdminUser))
{
StatusMessage = "MySQL Host and Admin User are required for connection test.";
return;
}
IsBusy = true;
StatusMessage = "Testing MySQL connection via SSH...";
try
{
// The test runs a mysql --version or a simple SELECT 1 query via SSH
// We need an SshHost to route through — use the first available
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OTSSignsOrchestrator.Core.Data.XiboContext>();
var host = await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions
.FirstOrDefaultAsync(db.SshHosts);
if (host == null)
{
StatusMessage = "No SSH hosts configured. Add one in the Hosts page first.";
return;
}
var ssh = _services.GetRequiredService<Services.SshConnectionService>();
var port = int.TryParse(MySqlPort, out var p) ? p : 3306;
var cmd = $"mysql -h {MySqlHost} -P {port} -u {MySqlAdminUser} -p'{MySqlAdminPassword.Replace("'", "'\\''")}' -e 'SELECT 1' 2>&1";
var (exitCode, stdout, stderr) = await ssh.RunCommandAsync(host, cmd);
StatusMessage = exitCode == 0
? $"MySQL connection successful via {host.Label}."
: $"MySQL connection failed: {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr).Trim()}";
}
catch (Exception ex)
{
StatusMessage = $"MySQL test error: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private static string? NullIfEmpty(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}