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.
This commit is contained in:
Matt Batchelder
2026-02-25 17:39:17 -05:00
parent a1c987ff21
commit 90eb649940
35 changed files with 1807 additions and 621 deletions

View File

@@ -22,6 +22,7 @@ namespace OTSSignsOrchestrator.Desktop.ViewModels;
public partial class CreateInstanceViewModel : ObservableObject
{
private readonly IServiceProvider _services;
private readonly MainWindowViewModel _mainVm;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
@@ -43,6 +44,9 @@ public partial class CreateInstanceViewModel : ObservableObject
[ObservableProperty] private string _nfsExportFolder = string.Empty;
[ObservableProperty] private string _nfsExtraOptions = string.Empty;
/// <summary>When enabled, existing Docker volumes for the stack are removed before deploying.</summary>
[ObservableProperty] private bool _purgeStaleVolumes = false;
// SSH host selection
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
[ObservableProperty] private SshHost? _selectedSshHost;
@@ -80,9 +84,10 @@ public partial class CreateInstanceViewModel : ObservableObject
// ─────────────────────────────────────────────────────────────────────────
public CreateInstanceViewModel(IServiceProvider services)
public CreateInstanceViewModel(IServiceProvider services, MainWindowViewModel mainVm)
{
_services = services;
_mainVm = mainVm;
_ = LoadHostsAsync();
_ = LoadNfsDefaultsAsync();
}
@@ -304,20 +309,29 @@ public partial class CreateInstanceViewModel : ObservableObject
SshHostId = SelectedSshHost.Id,
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.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(),
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(),
PurgeStaleVolumes = PurgeStaleVolumes,
};
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}";
if (result.Success)
{
SetProgress(100, "Stack deployed successfully.");
StatusMessage = $"Instance '{Abbrev}-cms-stack' deployed in {result.DurationMs}ms. " +
"Open the details pane on the Instances page to complete setup.";
_mainVm.NavigateToInstancesWithSelection(Abbrev);
}
else
{
SetProgress(0, "Deployment failed.");
StatusMessage = $"Deploy failed: {result.ErrorMessage}";
}
}
catch (Exception ex)
{

View File

@@ -45,7 +45,13 @@ public partial class InstanceDetailsViewModel : ObservableObject
// ── 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;
// Cached instance — needed by InitializeCommand to reload after setup
private LiveStackItem? _currentInstance;
public InstanceDetailsViewModel(IServiceProvider services)
{
_services = services;
@@ -58,6 +64,7 @@ public partial class InstanceDetailsViewModel : ObservableObject
/// <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;
@@ -75,7 +82,7 @@ public partial class InstanceDetailsViewModel : ObservableObject
var serverTemplate = await settings.GetAsync(
SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com");
var serverName = serverTemplate.Replace("{abbrev}", instance.CustomerAbbrev);
InstanceUrl = $"https://{serverName}";
InstanceUrl = $"https://{serverName}/{instance.CustomerAbbrev.Trim().ToLowerInvariant()}";
// ── Admin credentials ─────────────────────────────────────────
var creds = await postInit.GetCredentialsAsync(instance.CustomerAbbrev);
@@ -95,7 +102,15 @@ public partial class InstanceDetailsViewModel : ObservableObject
StatusMessage = creds.HasAdminPassword
? "Credentials loaded."
: "Credentials not yet available — post-install setup may still be running.";
: "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;
}
}
catch (Exception ex)
{
@@ -107,6 +122,42 @@ public partial class InstanceDetailsViewModel : ObservableObject
}
}
// ─────────────────────────────────────────────────────────────────────────
// 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
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -1,9 +1,11 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
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.Models;
@@ -30,15 +32,37 @@ public partial class InstancesViewModel : ObservableObject
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
[ObservableProperty] private SshHost? _selectedSshHost;
// ── Container Logs ──────────────────────────────────────────────────────
[ObservableProperty] private ObservableCollection<ServiceLogEntry> _logEntries = new();
[ObservableProperty] private ObservableCollection<string> _logServiceFilter = new();
[ObservableProperty] private string _selectedLogService = "All Services";
[ObservableProperty] private bool _isLogsPanelVisible;
[ObservableProperty] private bool _isLogsAutoRefresh = true;
[ObservableProperty] private bool _isLoadingLogs;
[ObservableProperty] private string _logsStatusMessage = string.Empty;
[ObservableProperty] private int _logTailLines = 200;
private DispatcherTimer? _logRefreshTimer;
private bool _isLogRefreshRunning;
/// <summary>Raised when the instance details modal should be opened for the given ViewModel.</summary>
public event Action<InstanceDetailsViewModel>? OpenDetailsRequested;
private string? _pendingSelectAbbrev;
public InstancesViewModel(IServiceProvider services)
{
_services = services;
_ = RefreshAllAsync();
}
/// <summary>
/// Queues an abbreviation to be auto-selected once the next live refresh completes.
/// Call immediately after construction (before <see cref="RefreshAllAsync"/> finishes).
/// </summary>
public void SetPendingSelection(string abbrev)
=> _pendingSelectAbbrev = abbrev;
/// <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.
@@ -89,6 +113,15 @@ public partial class InstancesViewModel : ObservableObject
i.HostLabel.Contains(FilterText, StringComparison.OrdinalIgnoreCase)).ToList();
Instances = new ObservableCollection<LiveStackItem>(all);
// Auto-select a pending instance (e.g. just deployed from Create Instance page)
if (_pendingSelectAbbrev is not null)
{
SelectedInstance = all.FirstOrDefault(i =>
i.CustomerAbbrev.Equals(_pendingSelectAbbrev, StringComparison.OrdinalIgnoreCase));
_pendingSelectAbbrev = null;
}
var msg = $"Found {all.Count} instance(s) across {hosts.Count} host(s).";
if (errors.Count > 0) msg += $" | Errors: {string.Join(" | ", errors)}";
StatusMessage = msg;
@@ -110,11 +143,108 @@ public partial class InstancesViewModel : ObservableObject
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
SelectedServices = new ObservableCollection<ServiceInfo>(services);
StatusMessage = $"Found {services.Count} service(s) in stack '{SelectedInstance.StackName}'.";
// Populate service filter dropdown and show logs panel
var filterItems = new List<string> { "All Services" };
filterItems.AddRange(services.Select(s => s.Name));
LogServiceFilter = new ObservableCollection<string>(filterItems);
SelectedLogService = "All Services";
IsLogsPanelVisible = true;
// Fetch initial logs and start auto-refresh
await FetchLogsInternalAsync();
StartLogAutoRefresh();
}
catch (Exception ex) { StatusMessage = $"Error inspecting: {ex.Message}"; }
finally { IsBusy = false; }
}
// ── Container Log Commands ──────────────────────────────────────────────
[RelayCommand]
private async Task RefreshLogsAsync()
{
await FetchLogsInternalAsync();
}
[RelayCommand]
private void ToggleLogsAutoRefresh()
{
IsLogsAutoRefresh = !IsLogsAutoRefresh;
if (IsLogsAutoRefresh)
StartLogAutoRefresh();
else
StopLogAutoRefresh();
}
[RelayCommand]
private void CloseLogsPanel()
{
StopLogAutoRefresh();
IsLogsPanelVisible = false;
LogEntries = new ObservableCollection<ServiceLogEntry>();
LogsStatusMessage = string.Empty;
}
partial void OnSelectedLogServiceChanged(string value)
{
// When user changes the service filter, refresh logs immediately
if (IsLogsPanelVisible)
_ = FetchLogsInternalAsync();
}
private async Task FetchLogsInternalAsync()
{
if (SelectedInstance == null || _isLogRefreshRunning) return;
_isLogRefreshRunning = true;
IsLoadingLogs = true;
try
{
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedInstance.Host);
string? serviceFilter = SelectedLogService == "All Services" ? null : SelectedLogService;
var entries = await dockerCli.GetServiceLogsAsync(
SelectedInstance.StackName, serviceFilter, LogTailLines);
LogEntries = new ObservableCollection<ServiceLogEntry>(entries);
LogsStatusMessage = $"{entries.Count} log line(s) · last fetched {DateTime.Now:HH:mm:ss}";
}
catch (Exception ex)
{
LogsStatusMessage = $"Error fetching logs: {ex.Message}";
}
finally
{
IsLoadingLogs = false;
_isLogRefreshRunning = false;
}
}
private void StartLogAutoRefresh()
{
StopLogAutoRefresh();
if (!IsLogsAutoRefresh) return;
_logRefreshTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(5)
};
_logRefreshTimer.Tick += async (_, _) =>
{
if (IsLogsPanelVisible && IsLogsAutoRefresh && !_isLogRefreshRunning)
await FetchLogsInternalAsync();
};
_logRefreshTimer.Start();
}
private void StopLogAutoRefresh()
{
_logRefreshTimer?.Stop();
_logRefreshTimer = null;
}
[RelayCommand]
private async Task DeleteInstanceAsync()
{

View File

@@ -53,6 +53,17 @@ public partial class MainWindowViewModel : ObservableObject
};
}
/// <summary>
/// Navigates to the Instances page and auto-selects the instance with the given abbreviation
/// once the live refresh completes.
/// </summary>
public void NavigateToInstancesWithSelection(string abbrev)
{
SelectedNav = "Instances"; // triggers OnSelectedNavChanged → NavigateTo("Instances")
if (CurrentView is InstancesViewModel instancesVm)
instancesVm.SetPendingSelection(abbrev);
}
public void SetStatus(string message)
{
StatusMessage = message;

View File

@@ -1,7 +1,11 @@
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;
@@ -65,6 +69,7 @@ public partial class SettingsViewModel : ObservableObject
[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;
@@ -76,12 +81,34 @@ public partial class SettingsViewModel : ObservableObject
_ = 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>();
@@ -127,18 +154,11 @@ public partial class SettingsViewModel : ObservableObject
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
// Bitwarden
BitwardenIdentityUrl = await svc.GetAsync(SettingsService.BitwardenIdentityUrl, "https://identity.bitwarden.com");
BitwardenApiUrl = await svc.GetAsync(SettingsService.BitwardenApiUrl, "https://api.bitwarden.com");
BitwardenAccessToken = await svc.GetAsync(SettingsService.BitwardenAccessToken, string.Empty);
BitwardenOrganizationId = await svc.GetAsync(SettingsService.BitwardenOrganizationId, string.Empty);
BitwardenProjectId = await svc.GetAsync(SettingsService.BitwardenProjectId, string.Empty);
// Xibo Bootstrap
XiboBootstrapClientId = await svc.GetAsync(SettingsService.XiboBootstrapClientId, string.Empty);
XiboBootstrapClientSecret = await svc.GetAsync(SettingsService.XiboBootstrapClientSecret, string.Empty);
StatusMessage = "Settings loaded.";
StatusMessage = "Settings loaded from Bitwarden.";
}
catch (Exception ex)
{
@@ -156,8 +176,23 @@ public partial class SettingsViewModel : ObservableObject
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)>
{
@@ -203,20 +238,13 @@ public partial class SettingsViewModel : ObservableObject
(SettingsService.DefaultPhpUploadMaxFilesize, DefaultPhpUploadMaxFilesize, SettingsService.CatDefaults, false),
(SettingsService.DefaultPhpMaxExecutionTime, DefaultPhpMaxExecutionTime, SettingsService.CatDefaults, false),
// Bitwarden
(SettingsService.BitwardenIdentityUrl, NullIfEmpty(BitwardenIdentityUrl), SettingsService.CatBitwarden, false),
(SettingsService.BitwardenApiUrl, NullIfEmpty(BitwardenApiUrl), SettingsService.CatBitwarden, false),
(SettingsService.BitwardenAccessToken, NullIfEmpty(BitwardenAccessToken), SettingsService.CatBitwarden, true),
(SettingsService.BitwardenOrganizationId, NullIfEmpty(BitwardenOrganizationId), SettingsService.CatBitwarden, false),
(SettingsService.BitwardenProjectId, NullIfEmpty(BitwardenProjectId), SettingsService.CatBitwarden, false),
// Xibo Bootstrap
(SettingsService.XiboBootstrapClientId, NullIfEmpty(XiboBootstrapClientId), SettingsService.CatXibo, false),
(SettingsService.XiboBootstrapClientSecret, NullIfEmpty(XiboBootstrapClientSecret), SettingsService.CatXibo, true),
};
await svc.SaveManyAsync(settings);
StatusMessage = "Settings saved successfully.";
StatusMessage = "Settings saved to Bitwarden.";
}
catch (Exception ex)
{
@@ -238,16 +266,21 @@ public partial class SettingsViewModel : ObservableObject
}
IsBusy = true;
StatusMessage = "Testing Bitwarden Secrets Manager connection...";
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
@@ -324,6 +357,33 @@ public partial class SettingsViewModel : ObservableObject
}
}
/// <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();
}