388 lines
18 KiB
C#
388 lines
18 KiB
C#
using System.Collections.ObjectModel;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using OTSSignsOrchestrator.Core.Models.DTOs;
|
|
using OTSSignsOrchestrator.Core.Models.Entities;
|
|
using OTSSignsOrchestrator.Core.Services;
|
|
using OTSSignsOrchestrator.Desktop.Models;
|
|
using OTSSignsOrchestrator.Desktop.Services;
|
|
|
|
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
|
|
|
/// <summary>
|
|
/// ViewModel for the instance details modal.
|
|
/// Shows admin credentials, DB credentials, and OAuth2 app details
|
|
/// with options to rotate passwords.
|
|
/// </summary>
|
|
public partial class InstanceDetailsViewModel : ObservableObject
|
|
{
|
|
private readonly IServiceProvider _services;
|
|
|
|
// ── Instance metadata ─────────────────────────────────────────────────────
|
|
[ObservableProperty] private string _stackName = string.Empty;
|
|
[ObservableProperty] private string _customerAbbrev = string.Empty;
|
|
[ObservableProperty] private string _hostLabel = string.Empty;
|
|
[ObservableProperty] private string _instanceUrl = string.Empty;
|
|
|
|
// ── OTS admin credentials ─────────────────────────────────────────────────
|
|
[ObservableProperty] private string _adminUsername = string.Empty;
|
|
[ObservableProperty] private string _adminPassword = string.Empty;
|
|
[ObservableProperty] private bool _adminPasswordVisible = false;
|
|
[ObservableProperty] private string _adminPasswordDisplay = "••••••••";
|
|
|
|
// ── Database credentials ──────────────────────────────────────────────────
|
|
[ObservableProperty] private string _dbUsername = string.Empty;
|
|
[ObservableProperty] private string _dbPassword = string.Empty;
|
|
[ObservableProperty] private bool _dbPasswordVisible = false;
|
|
[ObservableProperty] private string _dbPasswordDisplay = "••••••••";
|
|
|
|
// ── OAuth2 application ────────────────────────────────────────────────────
|
|
[ObservableProperty] private string _oAuthClientId = string.Empty;
|
|
[ObservableProperty] private string _oAuthClientSecret = string.Empty;
|
|
[ObservableProperty] private bool _oAuthSecretVisible = false;
|
|
[ObservableProperty] private string _oAuthSecretDisplay = "••••••••";
|
|
|
|
// ── Status ────────────────────────────────────────────────────────────────
|
|
[ObservableProperty] private string _statusMessage = string.Empty;
|
|
[ObservableProperty] private bool _isBusy;
|
|
// ── Pending-setup inputs (shown when instance hasn't been initialised yet) ────────────
|
|
[ObservableProperty] private bool _isPendingSetup;
|
|
[ObservableProperty] private string _initClientId = string.Empty;
|
|
[ObservableProperty] private string _initClientSecret = string.Empty;
|
|
// ── Services (for restart) ─────────────────────────────────────────────────────
|
|
[ObservableProperty] private ObservableCollection<ServiceInfo> _stackServices = new();
|
|
[ObservableProperty] private bool _isLoadingServices;
|
|
|
|
/// <summary>
|
|
/// Callback the View wires up to show a confirmation dialog.
|
|
/// Parameters: (title, message) → returns true if the user confirmed.
|
|
/// </summary>
|
|
public Func<string, string, Task<bool>>? ConfirmAsync { get; set; }
|
|
// Cached instance — needed by InitializeCommand to reload after setup
|
|
private LiveStackItem? _currentInstance;
|
|
public InstanceDetailsViewModel(IServiceProvider services)
|
|
{
|
|
_services = services;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Load
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Populates the ViewModel from a live <see cref="LiveStackItem"/>.</summary>
|
|
public async Task LoadAsync(LiveStackItem instance)
|
|
{
|
|
_currentInstance = instance;
|
|
StackName = instance.StackName;
|
|
CustomerAbbrev = instance.CustomerAbbrev;
|
|
HostLabel = instance.HostLabel;
|
|
|
|
IsBusy = true;
|
|
StatusMessage = "Loading credentials...";
|
|
|
|
try
|
|
{
|
|
using var scope = _services.CreateScope();
|
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
|
var postInit = scope.ServiceProvider.GetRequiredService<PostInstanceInitService>();
|
|
|
|
// Derive the instance URL from the CMS server name template
|
|
var serverTemplate = await settings.GetAsync(
|
|
SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com");
|
|
var serverName = serverTemplate.Replace("{abbrev}", instance.CustomerAbbrev);
|
|
InstanceUrl = $"https://{serverName}/{instance.CustomerAbbrev.Trim().ToLowerInvariant()}";
|
|
|
|
// ── Admin credentials ─────────────────────────────────────────
|
|
var creds = await postInit.GetCredentialsAsync(instance.CustomerAbbrev);
|
|
AdminUsername = creds.AdminUsername;
|
|
SetAdminPassword(creds.AdminPassword ?? string.Empty);
|
|
|
|
OAuthClientId = creds.OAuthClientId ?? string.Empty;
|
|
SetOAuthSecret(creds.OAuthClientSecret ?? string.Empty);
|
|
|
|
// ── DB credentials ────────────────────────────────────────────
|
|
var mySqlUserTemplate = await settings.GetAsync(
|
|
SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user");
|
|
DbUsername = mySqlUserTemplate.Replace("{abbrev}", instance.CustomerAbbrev);
|
|
|
|
var dbPw = await settings.GetAsync(SettingsService.InstanceMySqlPassword(instance.CustomerAbbrev));
|
|
SetDbPassword(dbPw ?? string.Empty);
|
|
|
|
StatusMessage = creds.HasAdminPassword
|
|
? "Credentials loaded."
|
|
: "Pending setup — enter your Xibo OAuth credentials below to initialise this instance.";
|
|
|
|
IsPendingSetup = !creds.HasAdminPassword;
|
|
// Clear any previous init inputs when re-loading
|
|
if (IsPendingSetup)
|
|
{
|
|
InitClientId = string.Empty;
|
|
InitClientSecret = string.Empty;
|
|
}
|
|
|
|
// ── Load stack services ───────────────────────────────────────
|
|
await LoadServicesAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Error loading credentials: {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
IsBusy = false;
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Initialise (pending setup)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
[RelayCommand]
|
|
private async Task InitializeAsync()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(InitClientId) || string.IsNullOrWhiteSpace(InitClientSecret))
|
|
{
|
|
StatusMessage = "Both Client ID and Client Secret are required.";
|
|
return;
|
|
}
|
|
if (_currentInstance is null) return;
|
|
|
|
IsBusy = true;
|
|
StatusMessage = "Waiting for Xibo and running initialisation (this may take several minutes)...";
|
|
try
|
|
{
|
|
var postInit = _services.GetRequiredService<PostInstanceInitService>();
|
|
await postInit.InitializeWithOAuthAsync(
|
|
CustomerAbbrev,
|
|
InstanceUrl,
|
|
InitClientId.Trim(),
|
|
InitClientSecret.Trim());
|
|
|
|
// Reload credentials — IsPendingSetup will flip to false
|
|
IsBusy = false;
|
|
await LoadAsync(_currentInstance);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Initialisation failed: {ex.Message}";
|
|
IsBusy = false;
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Visibility toggles
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
[RelayCommand]
|
|
private void ToggleAdminPasswordVisibility()
|
|
{
|
|
AdminPasswordVisible = !AdminPasswordVisible;
|
|
AdminPasswordDisplay = AdminPasswordVisible
|
|
? AdminPassword
|
|
: (AdminPassword.Length > 0 ? "••••••••" : "(not set)");
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ToggleDbPasswordVisibility()
|
|
{
|
|
DbPasswordVisible = !DbPasswordVisible;
|
|
DbPasswordDisplay = DbPasswordVisible
|
|
? DbPassword
|
|
: (DbPassword.Length > 0 ? "••••••••" : "(not set)");
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ToggleOAuthSecretVisibility()
|
|
{
|
|
OAuthSecretVisible = !OAuthSecretVisible;
|
|
OAuthSecretDisplay = OAuthSecretVisible
|
|
? OAuthClientSecret
|
|
: (OAuthClientSecret.Length > 0 ? "••••••••" : "(not set)");
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Clipboard
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
[RelayCommand]
|
|
private async Task CopyAdminPasswordAsync()
|
|
=> await CopyToClipboardAsync(AdminPassword, "Admin password");
|
|
|
|
[RelayCommand]
|
|
private async Task CopyDbPasswordAsync()
|
|
=> await CopyToClipboardAsync(DbPassword, "DB password");
|
|
|
|
[RelayCommand]
|
|
private async Task CopyOAuthClientIdAsync()
|
|
=> await CopyToClipboardAsync(OAuthClientId, "OAuth client ID");
|
|
|
|
[RelayCommand]
|
|
private async Task CopyOAuthSecretAsync()
|
|
=> await CopyToClipboardAsync(OAuthClientSecret, "OAuth client secret");
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Rotation
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
[RelayCommand]
|
|
private async Task RestartServiceAsync(ServiceInfo? service)
|
|
{
|
|
if (service is null || _currentInstance is null) return;
|
|
|
|
if (ConfirmAsync is not null)
|
|
{
|
|
var confirmed = await ConfirmAsync(
|
|
"Restart Service",
|
|
$"Are you sure you want to restart '{service.Name}'?\n\nThis will force-update the service, causing its tasks to be recreated.");
|
|
if (!confirmed) return;
|
|
}
|
|
|
|
IsBusy = true;
|
|
StatusMessage = $"Restarting service '{service.Name}'...";
|
|
try
|
|
{
|
|
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
|
var ok = await dockerCli.ForceUpdateServiceAsync(service.Name);
|
|
StatusMessage = ok
|
|
? $"Service '{service.Name}' restarted successfully."
|
|
: $"Failed to restart service '{service.Name}'.";
|
|
|
|
// Refresh service list to show updated replica status
|
|
await LoadServicesAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Error restarting service: {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
IsBusy = false;
|
|
}
|
|
}
|
|
|
|
private async Task LoadServicesAsync()
|
|
{
|
|
if (_currentInstance is null) return;
|
|
|
|
IsLoadingServices = true;
|
|
try
|
|
{
|
|
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
|
dockerCli.SetHost(_currentInstance.Host);
|
|
var services = await dockerCli.InspectStackServicesAsync(_currentInstance.StackName);
|
|
StackServices = new ObservableCollection<ServiceInfo>(services);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Error loading services: {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
IsLoadingServices = false;
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task RotateAdminPasswordAsync()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(CustomerAbbrev)) return;
|
|
|
|
IsBusy = true;
|
|
StatusMessage = "Rotating OTS admin password...";
|
|
try
|
|
{
|
|
var postInit = _services.GetRequiredService<PostInstanceInitService>();
|
|
var newPassword = await postInit.RotateAdminPasswordAsync(CustomerAbbrev, InstanceUrl);
|
|
SetAdminPassword(newPassword);
|
|
StatusMessage = "Admin password rotated successfully.";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Error rotating admin password: {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
IsBusy = false;
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task RotateDbPasswordAsync()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(CustomerAbbrev)) return;
|
|
|
|
IsBusy = true;
|
|
StatusMessage = $"Rotating MySQL password for {StackName}...";
|
|
try
|
|
{
|
|
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
|
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
|
|
|
|
// We need the Host — retrieve from the HostLabel lookup
|
|
using var scope = _services.CreateScope();
|
|
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
|
|
|
|
// Get the host from the loaded stack — caller must have set the SSH host before
|
|
var (ok, msg) = await instanceSvc.RotateMySqlPasswordAsync(StackName);
|
|
if (ok)
|
|
{
|
|
// Reload DB password
|
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
|
var dbPw = await settings.GetAsync(SettingsService.InstanceMySqlPassword(CustomerAbbrev));
|
|
SetDbPassword(dbPw ?? string.Empty);
|
|
StatusMessage = $"DB password rotated: {msg}";
|
|
}
|
|
else
|
|
{
|
|
StatusMessage = $"DB rotation failed: {msg}";
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Error rotating DB password: {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
IsBusy = false;
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Helpers
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
private void SetAdminPassword(string value)
|
|
{
|
|
AdminPassword = value;
|
|
AdminPasswordVisible = false;
|
|
AdminPasswordDisplay = value.Length > 0 ? "••••••••" : "(not set)";
|
|
}
|
|
|
|
private void SetDbPassword(string value)
|
|
{
|
|
DbPassword = value;
|
|
DbPasswordVisible = false;
|
|
DbPasswordDisplay = value.Length > 0 ? "••••••••" : "(not set)";
|
|
}
|
|
|
|
private void SetOAuthSecret(string value)
|
|
{
|
|
OAuthClientSecret = value;
|
|
OAuthSecretVisible = false;
|
|
OAuthSecretDisplay = value.Length > 0 ? "••••••••" : "(not set)";
|
|
}
|
|
|
|
private static async Task CopyToClipboardAsync(string text, string label)
|
|
{
|
|
if (string.IsNullOrEmpty(text)) return;
|
|
var topLevel = Avalonia.Application.Current?.ApplicationLifetime is
|
|
Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime dt
|
|
? dt.MainWindow
|
|
: null;
|
|
var clipboard = topLevel is not null ? Avalonia.Controls.TopLevel.GetTopLevel(topLevel)?.Clipboard : null;
|
|
if (clipboard is not null)
|
|
await clipboard.SetTextAsync(text);
|
|
}
|
|
}
|