feat: Implement Authentik group synchronization and add confirmation dialogs for service management

This commit is contained in:
Matt Batchelder
2026-03-04 21:33:29 -05:00
parent 56d48b6062
commit 9493bdb9df
19 changed files with 556 additions and 21 deletions

View File

@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Models.DTOs;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services;
using OTSSignsOrchestrator.Desktop.Models;
@@ -49,7 +50,15 @@ public partial class InstanceDetailsViewModel : ObservableObject
[ObservableProperty] private bool _isPendingSetup;
[ObservableProperty] private string _initClientId = string.Empty;
[ObservableProperty] private string _initClientSecret = string.Empty;
// ── Services (for restart) ─────────────────────────────────────────────────────
[ObservableProperty] private ObservableCollection<ServiceInfo> _stackServices = new();
[ObservableProperty] private bool _isLoadingServices;
/// <summary>
/// Callback the View wires up to show a confirmation dialog.
/// Parameters: (title, message) → returns true if the user confirmed.
/// </summary>
public Func<string, string, Task<bool>>? ConfirmAsync { get; set; }
// Cached instance — needed by InitializeCommand to reload after setup
private LiveStackItem? _currentInstance;
public InstanceDetailsViewModel(IServiceProvider services)
@@ -111,6 +120,9 @@ public partial class InstanceDetailsViewModel : ObservableObject
InitClientId = string.Empty;
InitClientSecret = string.Empty;
}
// ── Load stack services ───────────────────────────────────────
await LoadServicesAsync();
}
catch (Exception ex)
{
@@ -213,6 +225,64 @@ public partial class InstanceDetailsViewModel : ObservableObject
// Rotation
// ─────────────────────────────────────────────────────────────────────────
[RelayCommand]
private async Task RestartServiceAsync(ServiceInfo? service)
{
if (service is null || _currentInstance is null) return;
if (ConfirmAsync is not null)
{
var confirmed = await ConfirmAsync(
"Restart Service",
$"Are you sure you want to restart '{service.Name}'?\n\nThis will force-update the service, causing its tasks to be recreated.");
if (!confirmed) return;
}
IsBusy = true;
StatusMessage = $"Restarting service '{service.Name}'...";
try
{
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
var ok = await dockerCli.ForceUpdateServiceAsync(service.Name);
StatusMessage = ok
? $"Service '{service.Name}' restarted successfully."
: $"Failed to restart service '{service.Name}'.";
// Refresh service list to show updated replica status
await LoadServicesAsync();
}
catch (Exception ex)
{
StatusMessage = $"Error restarting service: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private async Task LoadServicesAsync()
{
if (_currentInstance is null) return;
IsLoadingServices = true;
try
{
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(_currentInstance.Host);
var services = await dockerCli.InspectStackServicesAsync(_currentInstance.StackName);
StackServices = new ObservableCollection<ServiceInfo>(services);
}
catch (Exception ex)
{
StatusMessage = $"Error loading services: {ex.Message}";
}
finally
{
IsLoadingServices = false;
}
}
[RelayCommand]
private async Task RotateAdminPasswordAsync()
{

View File

@@ -48,6 +48,12 @@ public partial class InstancesViewModel : ObservableObject
/// <summary>Raised when the instance details modal should be opened for the given ViewModel.</summary>
public event Action<InstanceDetailsViewModel>? OpenDetailsRequested;
/// <summary>
/// Callback the View wires up to show a confirmation dialog.
/// Parameters: (title, message) → returns true if the user confirmed.
/// </summary>
public Func<string, string, Task<bool>>? ConfirmAsync { get; set; }
private string? _pendingSelectAbbrev;
public InstancesViewModel(IServiceProvider services)
@@ -245,6 +251,79 @@ public partial class InstancesViewModel : ObservableObject
_logRefreshTimer = null;
}
// ── Restart Commands ────────────────────────────────────────────────
[RelayCommand]
private async Task RestartStackAsync()
{
if (SelectedInstance == null) return;
if (ConfirmAsync is not null)
{
var confirmed = await ConfirmAsync(
"Restart Stack",
$"Are you sure you want to restart all services in '{SelectedInstance.StackName}'?\n\nThis will force-update every service in the stack, causing brief downtime.");
if (!confirmed) return;
}
IsBusy = true;
StatusMessage = $"Restarting all services in '{SelectedInstance.StackName}'...";
try
{
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedInstance.Host);
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
var failures = new List<string>();
for (var i = 0; i < services.Count; i++)
{
var svc = services[i];
StatusMessage = $"Restarting service {i + 1}/{services.Count}: {svc.Name}...";
var ok = await dockerCli.ForceUpdateServiceAsync(svc.Name);
if (!ok) failures.Add(svc.Name);
}
StatusMessage = failures.Count == 0
? $"All {services.Count} service(s) in '{SelectedInstance.StackName}' restarted successfully."
: $"Restarted with errors — failed services: {string.Join(", ", failures)}";
}
catch (Exception ex) { StatusMessage = $"Error restarting stack: {ex.Message}"; }
finally { IsBusy = false; }
}
[RelayCommand]
private async Task RestartServiceAsync(ServiceInfo? service)
{
if (service is null || SelectedInstance is null) return;
if (ConfirmAsync is not null)
{
var confirmed = await ConfirmAsync(
"Restart Service",
$"Are you sure you want to restart '{service.Name}'?\n\nThis will force-update the service, causing its tasks to be recreated.");
if (!confirmed) return;
}
IsBusy = true;
StatusMessage = $"Restarting service '{service.Name}'...";
try
{
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedInstance.Host);
var ok = await dockerCli.ForceUpdateServiceAsync(service.Name);
StatusMessage = ok
? $"Service '{service.Name}' restarted successfully."
: $"Failed to restart service '{service.Name}'.";
// Refresh services to show updated replica status
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
SelectedServices = new ObservableCollection<ServiceInfo>(services);
}
catch (Exception ex) { StatusMessage = $"Error restarting service: {ex.Message}"; }
finally { IsBusy = false; }
}
[RelayCommand]
private async Task DeleteInstanceAsync()
{

View File

@@ -135,6 +135,7 @@ public partial class SettingsViewModel : ObservableObject
// ── Load all other settings from Bitwarden ──
using var scope = _services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
svc.InvalidateCache();
// Git
GitRepoUrl = await svc.GetAsync(SettingsService.GitRepoUrl, string.Empty);

View File

@@ -0,0 +1,27 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="OTSSignsOrchestrator.Desktop.Views.ConfirmationDialog"
Title="Confirm"
Width="420" Height="200"
MinWidth="320" MinHeight="160"
WindowStartupLocation="CenterOwner"
CanResize="False"
SizeToContent="Height">
<DockPanel Margin="24">
<!-- Buttons -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
HorizontalAlignment="Right" Spacing="10" Margin="0,16,0,0">
<Button Content="Cancel" Name="CancelButton" Width="90" />
<Button Content="Confirm" Name="ConfirmButton" Classes="accent" Width="90" />
</StackPanel>
<!-- Message -->
<StackPanel Spacing="8">
<TextBlock Name="TitleText" FontSize="16" FontWeight="SemiBold"
Foreground="{StaticResource AccentBrush}" />
<TextBlock Name="MessageText" FontSize="13" TextWrapping="Wrap"
Foreground="{StaticResource TextSecondaryBrush}" />
</StackPanel>
</DockPanel>
</Window>

View File

@@ -0,0 +1,50 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace OTSSignsOrchestrator.Desktop.Views;
/// <summary>
/// A simple Yes/No confirmation dialog that can be shown modally.
/// Use <see cref="ShowAsync"/> for a convenient one-liner.
/// </summary>
public partial class ConfirmationDialog : Window
{
public bool Result { get; private set; }
public ConfirmationDialog()
{
InitializeComponent();
}
public ConfirmationDialog(string title, string message) : this()
{
TitleText.Text = title;
MessageText.Text = message;
Title = title;
ConfirmButton.Click += OnConfirmClicked;
CancelButton.Click += OnCancelClicked;
}
private void OnConfirmClicked(object? sender, RoutedEventArgs e)
{
Result = true;
Close();
}
private void OnCancelClicked(object? sender, RoutedEventArgs e)
{
Result = false;
Close();
}
/// <summary>
/// Shows a modal confirmation dialog and returns true if the user confirmed.
/// </summary>
public static async Task<bool> ShowAsync(Window owner, string title, string message)
{
var dialog = new ConfirmationDialog(title, message);
await dialog.ShowDialog(owner);
return dialog.Result;
}
}

View File

@@ -1,11 +1,13 @@
<Window 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"
xmlns:svc="using:OTSSignsOrchestrator.Core.Services"
x:Class="OTSSignsOrchestrator.Desktop.Views.InstanceDetailsWindow"
x:DataType="vm:InstanceDetailsViewModel"
Title="Instance Details"
Width="620" Height="740"
MinWidth="520" MinHeight="600"
Width="620" Height="860"
MinWidth="520" MinHeight="700"
WindowStartupLocation="CenterOwner"
CanResize="True">
@@ -186,6 +188,52 @@
</StackPanel>
</Border>
<!-- ═══ Stack Services ═══ -->
<Border Classes="card">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
<Border Width="4" Height="20" CornerRadius="2" Background="#A78BFA" />
<TextBlock Text="Stack Services" FontSize="16" FontWeight="SemiBold"
Foreground="#A78BFA" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="Force-restart individual services within this stack."
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
TextWrapping="Wrap" />
<!-- Loading indicator -->
<TextBlock Text="Loading services..." FontSize="12"
Foreground="{StaticResource TextMutedBrush}"
IsVisible="{Binding IsLoadingServices}" />
<!-- Services list -->
<ItemsControl ItemsSource="{Binding StackServices}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="svc:ServiceInfo">
<Border Background="#232336" CornerRadius="6" Padding="12,10" Margin="0,3"
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" 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>
<Button Grid.Column="1" Content="Restart"
Command="{Binding $parent[ItemsControl].((vm:InstanceDetailsViewModel)DataContext).RestartServiceCommand}"
CommandParameter="{Binding}"
IsEnabled="{Binding $parent[ItemsControl].((vm:InstanceDetailsViewModel)DataContext).IsBusy, Converter={x:Static BoolConverters.Not}}"
VerticalAlignment="Center"
FontSize="12" Padding="10,6"
ToolTip.Tip="Force-restart this service" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</DockPanel>

View File

@@ -1,4 +1,5 @@
using Avalonia.Controls;
using OTSSignsOrchestrator.Desktop.ViewModels;
namespace OTSSignsOrchestrator.Desktop.Views;
@@ -7,5 +8,15 @@ public partial class InstanceDetailsWindow : Window
public InstanceDetailsWindow()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
if (DataContext is InstanceDetailsViewModel vm)
{
vm.ConfirmAsync = async (title, message) =>
await ConfirmationDialog.ShowAsync(this, title, message);
}
}
}

View File

@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
xmlns:dto="using:OTSSignsOrchestrator.Core.Models.DTOs"
xmlns:svc="using:OTSSignsOrchestrator.Core.Services"
x:Class="OTSSignsOrchestrator.Desktop.Views.InstancesView"
x:DataType="vm:InstancesViewModel">
@@ -21,6 +22,9 @@
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="View credentials and manage this instance." />
<Button Content="Inspect" Command="{Binding InspectInstanceCommand}" />
<Button Content="Restart Stack" Command="{Binding RestartStackCommand}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Force-restart all services in the selected stack." />
<Button Content="Delete" Classes="danger" Command="{Binding DeleteInstanceCommand}" />
<Border Width="1" Background="{StaticResource BorderSubtleBrush}" Margin="4,2" />
<Button Content="Rotate DB Password" Command="{Binding RotateMySqlPasswordCommand}"
@@ -69,16 +73,25 @@
Foreground="{StaticResource AccentBrush}" Margin="0,0,0,10" />
<ItemsControl ItemsSource="{Binding SelectedServices}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DataTemplate x:DataType="svc:ServiceInfo">
<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>
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" 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>
<Button Grid.Column="1" Content="Restart"
Command="{Binding $parent[ItemsControl].((vm:InstancesViewModel)DataContext).RestartServiceCommand}"
CommandParameter="{Binding}"
IsEnabled="{Binding $parent[ItemsControl].((vm:InstancesViewModel)DataContext).IsBusy, Converter={x:Static BoolConverters.Not}}"
VerticalAlignment="Center"
FontSize="12" Padding="10,6"
ToolTip.Tip="Force-restart this service" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>

View File

@@ -21,7 +21,17 @@ public partial class InstancesView : UserControl
_vm = DataContext as InstancesViewModel;
if (_vm is not null)
{
_vm.OpenDetailsRequested += OnOpenDetailsRequested;
_vm.ConfirmAsync = ShowConfirmationAsync;
}
}
private async Task<bool> ShowConfirmationAsync(string title, string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return false;
return await ConfirmationDialog.ShowAsync(owner, title, message);
}
private async void OnOpenDetailsRequested(InstanceDetailsViewModel detailsVm)