feat: Implement Authentik group synchronization and add confirmation dialogs for service management
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
27
OTSSignsOrchestrator.Desktop/Views/ConfirmationDialog.axaml
Normal file
27
OTSSignsOrchestrator.Desktop/Views/ConfirmationDialog.axaml
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user