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:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user