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:
@@ -44,6 +44,28 @@ public class App : Application
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
Log.Information("Creating MainWindow...");
|
||||
|
||||
// Import existing instance secrets from Bitwarden (fire-and-forget, non-blocking)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Pre-load config settings from Bitwarden so they're available immediately
|
||||
using var scope = Services.CreateScope();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
await settings.PreloadCacheAsync();
|
||||
Log.Information("Bitwarden config settings pre-loaded");
|
||||
|
||||
// Import existing instance secrets that aren't yet tracked
|
||||
var postInit = Services.GetRequiredService<PostInstanceInitService>();
|
||||
await postInit.ImportExistingInstanceSecretsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to pre-load settings or import instance secrets on startup");
|
||||
}
|
||||
});
|
||||
|
||||
var vm = Services.GetRequiredService<MainWindowViewModel>();
|
||||
Log.Information("MainWindowViewModel resolved");
|
||||
|
||||
@@ -75,10 +97,10 @@ public class App : Application
|
||||
|
||||
private static void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
// Configuration
|
||||
// Configuration (reloadOnChange so runtime writes to appsettings.json are picked up)
|
||||
var config = new ConfigurationBuilder()
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: false)
|
||||
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
|
||||
.Build();
|
||||
|
||||
services.AddSingleton<IConfiguration>(config);
|
||||
@@ -89,6 +111,7 @@ public class App : Application
|
||||
services.Configure<XiboOptions>(config.GetSection(XiboOptions.SectionName));
|
||||
services.Configure<DatabaseOptions>(config.GetSection(DatabaseOptions.SectionName));
|
||||
services.Configure<FileLoggingOptions>(config.GetSection(FileLoggingOptions.SectionName));
|
||||
services.Configure<BitwardenOptions>(config.GetSection(BitwardenOptions.SectionName));
|
||||
|
||||
// Logging
|
||||
services.AddLogging(builder =>
|
||||
@@ -115,7 +138,6 @@ public class App : Application
|
||||
services.AddHttpClient();
|
||||
services.AddHttpClient("XiboApi");
|
||||
services.AddHttpClient("XiboHealth");
|
||||
services.AddHttpClient("Bitwarden");
|
||||
|
||||
// SSH services (singletons — maintain connections)
|
||||
services.AddSingleton<SshConnectionService>();
|
||||
@@ -137,7 +159,7 @@ public class App : Application
|
||||
services.AddSingleton<PostInstanceInitService>();
|
||||
|
||||
// ViewModels
|
||||
services.AddTransient<MainWindowViewModel>();
|
||||
services.AddSingleton<MainWindowViewModel>(); // singleton: one main window, nav state shared
|
||||
services.AddTransient<HostsViewModel>();
|
||||
services.AddTransient<InstancesViewModel>();
|
||||
services.AddTransient<InstanceDetailsViewModel>();
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
<!-- Ensure the Bitwarden SDK native runtime libraries are included on publish -->
|
||||
<RuntimeIdentifiers>linux-x64;win-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -6,6 +6,7 @@ using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
using ServiceLogEntry = OTSSignsOrchestrator.Core.Models.DTOs.ServiceLogEntry;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
@@ -441,6 +442,121 @@ public class SshDockerCliService : IDockerCliService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<List<ServiceLogEntry>> GetServiceLogsAsync(string stackName, string? serviceName = null, int tailLines = 200)
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
// Determine which services to fetch logs for
|
||||
List<string> serviceNames;
|
||||
if (!string.IsNullOrEmpty(serviceName))
|
||||
{
|
||||
serviceNames = new List<string> { serviceName };
|
||||
}
|
||||
else
|
||||
{
|
||||
var services = await InspectStackServicesAsync(stackName);
|
||||
serviceNames = services.Select(s => s.Name).ToList();
|
||||
}
|
||||
|
||||
var allEntries = new List<ServiceLogEntry>();
|
||||
foreach (var svcName in serviceNames)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cmd = $"docker service logs --timestamps --no-trunc --tail {tailLines} {svcName} 2>&1";
|
||||
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(_currentHost!, cmd, TimeSpan.FromSeconds(15));
|
||||
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
|
||||
{
|
||||
_logger.LogDebug("No logs returned for service {Service} (exit={ExitCode})", svcName, exitCode);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse each line. Docker service logs format with --timestamps:
|
||||
// <timestamp> <service>.<replica>.<taskid>@<node> | <message>
|
||||
// or sometimes just:
|
||||
// <timestamp> <service>.<replica>.<taskid> <message>
|
||||
foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var entry = ParseLogLine(line, svcName, stackName);
|
||||
if (entry != null)
|
||||
allEntries.Add(entry);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch logs for service {Service}", svcName);
|
||||
}
|
||||
}
|
||||
|
||||
return allEntries.OrderBy(e => e.Timestamp).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a single line from <c>docker service logs --timestamps</c> output.
|
||||
/// </summary>
|
||||
private static ServiceLogEntry? ParseLogLine(string line, string serviceName, string stackName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
return null;
|
||||
|
||||
// Format: "2026-02-25T14:30:45.123456789Z service.replica.taskid@node | message"
|
||||
// The timestamp is always the first space-delimited token when --timestamps is used.
|
||||
var firstSpace = line.IndexOf(' ');
|
||||
if (firstSpace <= 0)
|
||||
return new ServiceLogEntry
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Source = serviceName,
|
||||
ServiceName = StripStackPrefix(serviceName, stackName),
|
||||
Message = line
|
||||
};
|
||||
|
||||
var timestampStr = line[..firstSpace];
|
||||
var rest = line[(firstSpace + 1)..].TrimStart();
|
||||
|
||||
// Try to parse the timestamp
|
||||
if (!DateTimeOffset.TryParse(timestampStr, out var timestamp))
|
||||
{
|
||||
// If timestamp parsing fails, treat the whole line as the message
|
||||
return new ServiceLogEntry
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Source = serviceName,
|
||||
ServiceName = StripStackPrefix(serviceName, stackName),
|
||||
Message = line
|
||||
};
|
||||
}
|
||||
|
||||
// Split source and message on the pipe separator
|
||||
var source = serviceName;
|
||||
var message = rest;
|
||||
var pipeIndex = rest.IndexOf('|');
|
||||
if (pipeIndex >= 0)
|
||||
{
|
||||
source = rest[..pipeIndex].Trim();
|
||||
message = rest[(pipeIndex + 1)..].TrimStart();
|
||||
}
|
||||
|
||||
return new ServiceLogEntry
|
||||
{
|
||||
Timestamp = timestamp,
|
||||
Source = source,
|
||||
ServiceName = StripStackPrefix(serviceName, stackName),
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips the stack name prefix from a fully-qualified service name.
|
||||
/// e.g. "acm-cms-stack_acm-web" → "acm-web"
|
||||
/// </summary>
|
||||
private static string StripStackPrefix(string serviceName, string stackName)
|
||||
{
|
||||
var prefix = stackName + "_";
|
||||
return serviceName.StartsWith(prefix) ? serviceName[prefix.Length..] : serviceName;
|
||||
}
|
||||
|
||||
private void EnsureHost()
|
||||
{
|
||||
if (_currentHost == null)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -78,6 +78,20 @@
|
||||
</Border>
|
||||
</Expander>
|
||||
|
||||
<!-- Advanced options -->
|
||||
<Expander Header="Advanced options">
|
||||
<Border Classes="card" Margin="0,8,0,0">
|
||||
<StackPanel Spacing="8">
|
||||
<CheckBox IsChecked="{Binding PurgeStaleVolumes}">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="Purge stale volumes before deploying" FontSize="12" />
|
||||
<TextBlock Text="Removes existing Docker volumes for this stack so fresh volumes are created. Only needed if volumes were created with wrong settings." FontSize="11" Foreground="{StaticResource TextMutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Expander>
|
||||
|
||||
<!-- Deploy button + progress -->
|
||||
<Button Content="Deploy Instance"
|
||||
Classes="accent"
|
||||
|
||||
@@ -31,6 +31,22 @@
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="16">
|
||||
|
||||
<!-- ═══ Pending Setup Banner ═══ -->
|
||||
<Border IsVisible="{Binding IsPendingSetup}"
|
||||
Background="#1F2A1A" BorderBrush="#4ADE80" BorderThickness="1"
|
||||
CornerRadius="8" Padding="14,10">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Text="⚙" FontSize="18" VerticalAlignment="Center" Foreground="#4ADE80" />
|
||||
<StackPanel>
|
||||
<TextBlock Text="Pending Setup" FontSize="14" FontWeight="SemiBold"
|
||||
Foreground="#4ADE80" />
|
||||
<TextBlock FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="Enter your Xibo OAuth credentials below to complete instance initialisation." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ OTS Admin Account ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
@@ -116,27 +132,57 @@
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Text="Client ID" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding OAuthClientId}" IsReadOnly="True"
|
||||
FontFamily="Consolas,monospace" />
|
||||
<Button Grid.Column="1" Content="Copy" Margin="4,0,0,0"
|
||||
Command="{Binding CopyOAuthClientIdCommand}" />
|
||||
</Grid>
|
||||
<!-- ── Pending: editable credential input ── -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding IsPendingSetup}">
|
||||
<TextBlock FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="Log into the Xibo CMS as xibo_admin (password: password), go to Administration → Applications, create a client_credentials app, then paste the credentials here." />
|
||||
|
||||
<TextBlock Text="Client Secret" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding OAuthSecretDisplay}" IsReadOnly="True"
|
||||
FontFamily="Consolas,monospace" />
|
||||
<Button Grid.Column="1" Content="Show" Margin="4,0,0,0"
|
||||
IsVisible="{Binding !OAuthSecretVisible}"
|
||||
Command="{Binding ToggleOAuthSecretVisibilityCommand}" />
|
||||
<Button Grid.Column="2" Content="Hide" Margin="4,0,0,0"
|
||||
IsVisible="{Binding OAuthSecretVisible}"
|
||||
Command="{Binding ToggleOAuthSecretVisibilityCommand}" />
|
||||
<Button Grid.Column="3" Content="Copy" Margin="4,0,0,0"
|
||||
Command="{Binding CopyOAuthSecretCommand}" />
|
||||
</Grid>
|
||||
<TextBlock Text="Client ID" FontSize="12"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
|
||||
<TextBox Text="{Binding InitClientId}" Watermark="OAuth2 Client ID" />
|
||||
|
||||
<TextBlock Text="Client Secret" FontSize="12"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding InitClientSecret}" PasswordChar="●"
|
||||
Watermark="(paste from Xibo Applications page)" />
|
||||
|
||||
<Button Content="Initialize Instance"
|
||||
Command="{Binding InitializeCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Classes="accent"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Padding="14,10" FontSize="14"
|
||||
Margin="0,8,0,0" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- ── Initialized: read-only display ── -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding !IsPendingSetup}">
|
||||
<TextBlock Text="Client ID" FontSize="12"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding OAuthClientId}" IsReadOnly="True"
|
||||
FontFamily="Consolas,monospace" />
|
||||
<Button Grid.Column="1" Content="Copy" Margin="4,0,0,0"
|
||||
Command="{Binding CopyOAuthClientIdCommand}" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="Client Secret" FontSize="12"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding OAuthSecretDisplay}" IsReadOnly="True"
|
||||
FontFamily="Consolas,monospace" />
|
||||
<Button Grid.Column="1" Content="Show" Margin="4,0,0,0"
|
||||
IsVisible="{Binding !OAuthSecretVisible}"
|
||||
Command="{Binding ToggleOAuthSecretVisibilityCommand}" />
|
||||
<Button Grid.Column="2" Content="Hide" Margin="4,0,0,0"
|
||||
IsVisible="{Binding OAuthSecretVisible}"
|
||||
Command="{Binding ToggleOAuthSecretVisibilityCommand}" />
|
||||
<Button Grid.Column="3" Content="Copy" Margin="4,0,0,0"
|
||||
Command="{Binding CopyOAuthSecretCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
xmlns:dto="using:OTSSignsOrchestrator.Core.Models.DTOs"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.InstancesView"
|
||||
x:DataType="vm:InstancesViewModel">
|
||||
|
||||
@@ -37,45 +38,118 @@
|
||||
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Classes="status"
|
||||
Margin="0,10,0,0" />
|
||||
|
||||
<!-- Services panel (shown when inspecting) -->
|
||||
<Border DockPanel.Dock="Right" Width="360"
|
||||
IsVisible="{Binding SelectedServices.Count}"
|
||||
Classes="card" Margin="16,0,0,0">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Stack Services" Classes="sectionTitle"
|
||||
Foreground="{StaticResource AccentBrush}" Margin="0,0,0,10" />
|
||||
<ItemsControl ItemsSource="{Binding SelectedServices}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#232336" CornerRadius="6" Padding="12,10" Margin="0,3"
|
||||
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13" />
|
||||
<TextBlock Text="{Binding Image}" FontSize="11"
|
||||
Foreground="{StaticResource TextMutedBrush}" />
|
||||
<TextBlock Text="{Binding Replicas, StringFormat='Replicas: {0}'}"
|
||||
FontSize="11" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Main content: split into upper (grid + services) and lower (logs) -->
|
||||
<Grid RowDefinitions="*,Auto,Auto">
|
||||
|
||||
<!-- Instance list -->
|
||||
<DataGrid ItemsSource="{Binding Instances}"
|
||||
SelectedItem="{Binding SelectedInstance}"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal"
|
||||
CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Stack" Binding="{Binding StackName}" Width="150" />
|
||||
<DataGridTextColumn Header="Abbrev" Binding="{Binding CustomerAbbrev}" Width="70" />
|
||||
<DataGridTextColumn Header="Services" Binding="{Binding ServiceCount}" Width="70" />
|
||||
<DataGridTextColumn Header="Host" Binding="{Binding HostLabel}" Width="150" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<!-- Upper area: instance list + services side panel -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
|
||||
<!-- Instance list -->
|
||||
<DataGrid Grid.Column="0"
|
||||
ItemsSource="{Binding Instances}"
|
||||
SelectedItem="{Binding SelectedInstance}"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal"
|
||||
CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Stack" Binding="{Binding StackName}" Width="150" />
|
||||
<DataGridTextColumn Header="Abbrev" Binding="{Binding CustomerAbbrev}" Width="70" />
|
||||
<DataGridTextColumn Header="Services" Binding="{Binding ServiceCount}" Width="70" />
|
||||
<DataGridTextColumn Header="Host" Binding="{Binding HostLabel}" Width="150" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<!-- Services panel (shown when inspecting) -->
|
||||
<Border Grid.Column="1" Width="360"
|
||||
IsVisible="{Binding SelectedServices.Count}"
|
||||
Classes="card" Margin="16,0,0,0">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Stack Services" Classes="sectionTitle"
|
||||
Foreground="{StaticResource AccentBrush}" Margin="0,0,0,10" />
|
||||
<ItemsControl ItemsSource="{Binding SelectedServices}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#232336" CornerRadius="6" Padding="12,10" Margin="0,3"
|
||||
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13" />
|
||||
<TextBlock Text="{Binding Image}" FontSize="11"
|
||||
Foreground="{StaticResource TextMutedBrush}" />
|
||||
<TextBlock Text="{Binding Replicas, StringFormat='Replicas: {0}'}"
|
||||
FontSize="11" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Grid splitter between instances and logs -->
|
||||
<GridSplitter Grid.Row="1" Height="6" HorizontalAlignment="Stretch"
|
||||
IsVisible="{Binding IsLogsPanelVisible}"
|
||||
Background="Transparent" />
|
||||
|
||||
<!-- Container Logs Panel -->
|
||||
<Border Grid.Row="2" Classes="card" Margin="0,4,0,0"
|
||||
IsVisible="{Binding IsLogsPanelVisible}"
|
||||
MinHeight="180" MaxHeight="400">
|
||||
<DockPanel>
|
||||
<!-- Logs toolbar -->
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,8">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="Container Logs" Classes="sectionTitle"
|
||||
Foreground="{StaticResource AccentBrush}" />
|
||||
<ComboBox ItemsSource="{Binding LogServiceFilter}"
|
||||
SelectedItem="{Binding SelectedLogService}"
|
||||
MinWidth="200" FontSize="12"
|
||||
ToolTip.Tip="Filter logs by service" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="1" Text="{Binding LogsStatusMessage}"
|
||||
FontSize="11" Foreground="{StaticResource TextMutedBrush}"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Center" />
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6"
|
||||
VerticalAlignment="Center">
|
||||
<Button Content="Refresh" Command="{Binding RefreshLogsCommand}"
|
||||
FontSize="11" Padding="8,4"
|
||||
ToolTip.Tip="Fetch latest logs" />
|
||||
<ToggleButton IsChecked="{Binding IsLogsAutoRefresh}"
|
||||
Content="Auto"
|
||||
FontSize="11" Padding="8,4"
|
||||
ToolTip.Tip="Toggle auto-refresh (every 5 seconds)"
|
||||
Command="{Binding ToggleLogsAutoRefreshCommand}" />
|
||||
<Button Content="✕" Command="{Binding CloseLogsPanelCommand}"
|
||||
FontSize="11" Padding="6,4"
|
||||
ToolTip.Tip="Close logs panel" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Log entries list -->
|
||||
<Border Background="#1a1a2e" CornerRadius="4" Padding="8"
|
||||
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding LogEntries}"
|
||||
x:DataType="vm:InstancesViewModel">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="dto:ServiceLogEntry">
|
||||
<TextBlock Text="{Binding DisplayLine}"
|
||||
FontFamily="Cascadia Mono,Consolas,Menlo,monospace"
|
||||
FontSize="11" Padding="0,1"
|
||||
TextWrapping="NoWrap"
|
||||
Foreground="#cccccc" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
|
||||
@@ -29,6 +29,59 @@
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="16" Margin="0,0,16,16" MaxWidth="820">
|
||||
|
||||
<!-- ═══ Bitwarden Secrets Manager (Bootstrap — always shown first) ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#818CF8" />
|
||||
<TextBlock Text="Bitwarden Secrets Manager" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#818CF8" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="All application settings are stored in Bitwarden. Configure these credentials first — they are saved to appsettings.json on disk."
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<Grid ColumnDefinitions="1*,12,1*">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="Identity URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenIdentityUrl}"
|
||||
Watermark="https://identity.bitwarden.com" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Text="API URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenApiUrl}"
|
||||
Watermark="https://api.bitwarden.com" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="Machine Account Access Token" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenAccessToken}" PasswordChar="●"
|
||||
Watermark="0.xxxxxxxx.yyyyyyy:zzzzzz" />
|
||||
|
||||
<TextBlock Text="Organization ID" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenOrganizationId}"
|
||||
Watermark="00000000-0000-0000-0000-000000000000" />
|
||||
|
||||
<TextBlock Text="Project ID (required — config secrets are stored in this project)" FontSize="12"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenProjectId}"
|
||||
Watermark="00000000-0000-0000-0000-000000000000" />
|
||||
|
||||
<TextBlock Text="Instance Project ID (optional — instance secrets like DB passwords go here; falls back to Project ID if empty)" FontSize="12"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" TextWrapping="Wrap" />
|
||||
<TextBox Text="{Binding BitwardenInstanceProjectId}"
|
||||
Watermark="00000000-0000-0000-0000-000000000000 (leave empty to use default project)" />
|
||||
|
||||
<Button Content="Test Bitwarden Connection"
|
||||
Command="{Binding TestBitwardenConnectionCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Margin="0,6,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Remaining settings — disabled until Bitwarden is configured ═══ -->
|
||||
<StackPanel Spacing="16" IsEnabled="{Binding IsBitwardenConfigured}">
|
||||
|
||||
<!-- ═══ Git Repository ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
@@ -231,51 +284,6 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Bitwarden Secrets Manager ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#818CF8" />
|
||||
<TextBlock Text="Bitwarden Secrets Manager" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#818CF8" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="Stores per-instance admin passwords and OAuth2 secrets. Uses a machine account access token."
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<Grid ColumnDefinitions="1*,12,1*">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="Identity URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenIdentityUrl}"
|
||||
Watermark="https://identity.bitwarden.com" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Text="API URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenApiUrl}"
|
||||
Watermark="https://api.bitwarden.com" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="Machine Account Access Token" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenAccessToken}" PasswordChar="●"
|
||||
Watermark="0.xxxxxxxx.yyyyyyy:zzzzzz" />
|
||||
|
||||
<TextBlock Text="Organization ID" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenOrganizationId}"
|
||||
Watermark="00000000-0000-0000-0000-000000000000" />
|
||||
|
||||
<TextBlock Text="Project ID (optional — secrets are organized into this project)" FontSize="12"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenProjectId}"
|
||||
Watermark="00000000-0000-0000-0000-000000000000" />
|
||||
|
||||
<Button Content="Test Bitwarden Connection"
|
||||
Command="{Binding TestBitwardenConnectionCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Margin="0,6,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Xibo Bootstrap OAuth2 ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
@@ -302,6 +310,8 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel> <!-- end of IsBitwardenConfigured wrapper -->
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
|
||||
@@ -31,6 +31,14 @@
|
||||
"Database": {
|
||||
"Provider": "Sqlite"
|
||||
},
|
||||
"Bitwarden": {
|
||||
"IdentityUrl": "https://identity.bitwarden.com",
|
||||
"ApiUrl": "https://api.bitwarden.com",
|
||||
"AccessToken": "",
|
||||
"OrganizationId": "",
|
||||
"ProjectId": "",
|
||||
"InstanceProjectId": ""
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"Default": "Data Source=otssigns-desktop.db"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user