Files
OTSSignsOrchestrator/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs
Matt Batchelder 90eb649940 feat: Implement container logs functionality in InstancesViewModel
- Added properties for managing container logs, including log entries, service filters, and auto-refresh options.
- Introduced commands for refreshing logs, toggling auto-refresh, and closing the logs panel.
- Implemented log fetching logic with error handling and status messages.
- Integrated log display in the InstancesView with a dedicated logs panel.

feat: Enhance navigation to Instances page with auto-selection

- Added method to navigate to the Instances page and auto-select an instance based on abbreviation.

feat: Update SettingsViewModel to load and save Bitwarden configuration

- Integrated Bitwarden configuration loading from IOptions and saving to appsettings.json.
- Added properties for Bitwarden instance project ID and connection status.
- Updated UI to reflect Bitwarden settings and connection status.

feat: Add advanced options for instance creation

- Introduced a new expander in CreateInstanceView for advanced options, including purging stale volumes.

feat: Improve InstanceDetailsWindow with pending setup banner

- Added a banner to indicate pending setup for Xibo OAuth credentials, with editable fields for client ID and secret.

fix: Update appsettings.json to include Bitwarden configuration structure

- Added Bitwarden section to appsettings.json for storing configuration values.

chore: Update Docker Compose template with health checks

- Added health check configuration for web service in template.yml to ensure service availability.

refactor: Drop AppSettings table from database

- Removed AppSettings table and related migration files as part of database cleanup.

feat: Create ServiceLogEntry DTO for log management

- Added ServiceLogEntry class to represent individual log entries from Docker services.
2026-02-25 17:39:17 -05:00

390 lines
20 KiB
C#

using System.Collections.ObjectModel;
using System.Text.Json;
using System.Text.Json.Nodes;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Core.Configuration;
using OTSSignsOrchestrator.Core.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for the Settings page — manages Git, MySQL, SMTP, Pangolin, NFS,
/// 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";
// ── 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";
[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_user";
[ObservableProperty] private string _defaultPhpPostMaxSize = "10G";
[ObservableProperty] private string _defaultPhpUploadMaxFilesize = "10G";
[ObservableProperty] private string _defaultPhpMaxExecutionTime = "600";
// ── Bitwarden Secrets Manager ─────────────────────────────────
[ObservableProperty] private string _bitwardenIdentityUrl = "https://identity.bitwarden.com";
[ObservableProperty] private string _bitwardenApiUrl = "https://api.bitwarden.com";
[ObservableProperty] private string _bitwardenAccessToken = string.Empty;
[ObservableProperty] private string _bitwardenOrganizationId = string.Empty;
[ObservableProperty] private string _bitwardenProjectId = string.Empty;
[ObservableProperty] private string _bitwardenInstanceProjectId = string.Empty;
// ── Xibo Bootstrap OAuth2 ─────────────────────────────────────
[ObservableProperty] private string _xiboBootstrapClientId = string.Empty;
[ObservableProperty] private string _xiboBootstrapClientSecret = string.Empty;
public SettingsViewModel(IServiceProvider services)
{
_services = services;
_ = LoadAsync();
}
/// <summary>Whether Bitwarden is configured and reachable.</summary>
[ObservableProperty] private bool _isBitwardenConfigured;
[RelayCommand]
private async Task LoadAsync()
{
IsBusy = true;
try
{
// ── Load Bitwarden bootstrap config from IOptions<BitwardenOptions> ──
var bwOptions = _services.GetRequiredService<IOptions<BitwardenOptions>>().Value;
BitwardenIdentityUrl = bwOptions.IdentityUrl;
BitwardenApiUrl = bwOptions.ApiUrl;
BitwardenAccessToken = bwOptions.AccessToken;
BitwardenOrganizationId = bwOptions.OrganizationId;
BitwardenProjectId = bwOptions.ProjectId;
BitwardenInstanceProjectId = bwOptions.InstanceProjectId;
IsBitwardenConfigured = !string.IsNullOrWhiteSpace(bwOptions.AccessToken)
&& !string.IsNullOrWhiteSpace(bwOptions.OrganizationId);
if (!IsBitwardenConfigured)
{
StatusMessage = "Bitwarden is not configured. Fill in the Bitwarden section and save to get started.";
return;
}
// ── Load all other settings from Bitwarden ──
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");
// 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");
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_user");
DefaultPhpPostMaxSize = await svc.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
// Xibo Bootstrap
XiboBootstrapClientId = await svc.GetAsync(SettingsService.XiboBootstrapClientId, string.Empty);
XiboBootstrapClientSecret = await svc.GetAsync(SettingsService.XiboBootstrapClientSecret, string.Empty);
StatusMessage = "Settings loaded from Bitwarden.";
}
catch (Exception ex)
{
StatusMessage = $"Error loading settings: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task SaveAsync()
{
IsBusy = true;
try
{
// ── 1. Save Bitwarden bootstrap config to appsettings.json ──
await SaveBitwardenConfigToFileAsync();
// Check if Bitwarden is now configured
IsBitwardenConfigured = !string.IsNullOrWhiteSpace(BitwardenAccessToken)
&& !string.IsNullOrWhiteSpace(BitwardenOrganizationId);
if (!IsBitwardenConfigured)
{
StatusMessage = "Bitwarden config saved to appsettings.json. Fill in Access Token and Org ID to enable all settings.";
return;
}
// ── 2. Save all other settings to Bitwarden ──
using var scope = _services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
svc.InvalidateCache(); // force re-read after config change
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),
// 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),
(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),
// Xibo Bootstrap
(SettingsService.XiboBootstrapClientId, NullIfEmpty(XiboBootstrapClientId), SettingsService.CatXibo, false),
(SettingsService.XiboBootstrapClientSecret, NullIfEmpty(XiboBootstrapClientSecret), SettingsService.CatXibo, true),
};
await svc.SaveManyAsync(settings);
StatusMessage = "Settings saved to Bitwarden.";
}
catch (Exception ex)
{
StatusMessage = $"Error saving settings: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task TestBitwardenConnectionAsync()
{
if (string.IsNullOrWhiteSpace(BitwardenAccessToken) || string.IsNullOrWhiteSpace(BitwardenOrganizationId))
{
StatusMessage = "Bitwarden Access Token and Organization ID are required.";
return;
}
IsBusy = true;
StatusMessage = "Saving Bitwarden config and testing connection...";
try
{
// Save to appsettings.json first so the service picks up fresh values
await SaveBitwardenConfigToFileAsync();
using var scope = _services.CreateScope();
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
var secrets = await bws.ListSecretsAsync();
IsBitwardenConfigured = true;
StatusMessage = $"Bitwarden connected. Found {secrets.Count} secret(s) in project.";
}
catch (Exception ex)
{
IsBitwardenConfigured = false;
StatusMessage = $"Bitwarden connection failed: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task TestXiboBootstrapAsync()
{
if (string.IsNullOrWhiteSpace(XiboBootstrapClientId) || string.IsNullOrWhiteSpace(XiboBootstrapClientSecret))
{
StatusMessage = "Xibo Bootstrap Client ID and Secret are required.";
return;
}
IsBusy = true;
StatusMessage = "Testing Xibo bootstrap credentials...";
try
{
using var scope = _services.CreateScope();
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
var cmsServerTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com");
// Use a placeholder URL — user must configure a live instance for full test
StatusMessage = "Xibo bootstrap credentials saved. Connect test requires a live instance URL — use 'Details' on an instance to verify.";
}
catch (Exception ex)
{
StatusMessage = $"Error: {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...";
try
{
if (!int.TryParse(MySqlPort, out var port))
port = 3306;
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} via SSH tunnel).";
}
catch (Exception ex)
{
StatusMessage = $"MySQL connection failed: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
/// <summary>
/// Persists Bitwarden bootstrap credentials to appsettings.json so they survive restarts.
/// </summary>
private async Task SaveBitwardenConfigToFileAsync()
{
var path = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
var json = await File.ReadAllTextAsync(path);
var doc = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip })!;
var bw = doc["Bitwarden"]?.AsObject();
if (bw == null)
{
bw = new JsonObject();
doc.AsObject()["Bitwarden"] = bw;
}
bw["IdentityUrl"] = BitwardenIdentityUrl;
bw["ApiUrl"] = BitwardenApiUrl;
bw["AccessToken"] = BitwardenAccessToken;
bw["OrganizationId"] = BitwardenOrganizationId;
bw["ProjectId"] = BitwardenProjectId;
bw["InstanceProjectId"] = BitwardenInstanceProjectId;
var options = new JsonSerializerOptions { WriteIndented = true };
await File.WriteAllTextAsync(path, doc.ToJsonString(options));
}
private static string? NullIfEmpty(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}