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

Submodule .template-cache/2dc03e2b2b45fef3 updated: eaf06cf624...0cc0da3c6e

View File

@@ -0,0 +1,18 @@
namespace OTSSignsOrchestrator.Core.Models.DTOs;
/// <summary>
/// Represents an Authentik group for display and sync operations.
/// </summary>
public class AuthentikGroupItem
{
public string Pk { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public int MemberCount { get; set; }
/// <summary>Display text for UI: "Group Name (N members)".</summary>
public string DisplayText => MemberCount > 0
? $"{Name} ({MemberCount} member{(MemberCount == 1 ? "" : "s")})"
: Name;
public override string ToString() => DisplayText;
}

View File

@@ -95,7 +95,7 @@ public class AuthentikService : IAuthentikService
}
// ── 4. Create application linked to provider ──────────────────
await CreateApplicationAsync(client, baseUrl, instanceAbbrev, slug, providerId, ct);
await CreateApplicationAsync(client, baseUrl, instanceAbbrev, slug, providerId, instanceBaseUrl, ct);
}
// ── 5. Ensure provider has a signing keypair (required for metadata) ──
@@ -175,6 +175,62 @@ public class AuthentikService : IAuthentikService
}).OrderBy(k => k.Name).ToList() ?? new();
}
/// <inheritdoc />
public async Task<List<AuthentikGroupItem>> ListGroupsAsync(
string? overrideUrl = null, string? overrideApiKey = null,
CancellationToken ct = default)
{
var (_, client) = await CreateAuthenticatedClientAsync(overrideUrl, overrideApiKey);
var groups = new List<AuthentikGroupItem>();
var nextUrl = "/api/v3/core/groups/?page_size=200";
while (!string.IsNullOrEmpty(nextUrl))
{
var resp = await client.GetAsync(nextUrl, ct);
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
if (root.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array)
{
foreach (var g in results.EnumerateArray())
{
var pk = g.TryGetProperty("pk", out var pkProp) ? pkProp.GetString() ?? "" : "";
var name = g.TryGetProperty("name", out var nProp) ? nProp.GetString() ?? "" : "";
var memberCount = g.TryGetProperty("users_obj", out var usersObj) && usersObj.ValueKind == JsonValueKind.Array
? usersObj.GetArrayLength()
: (g.TryGetProperty("users", out var users) && users.ValueKind == JsonValueKind.Array
? users.GetArrayLength()
: 0);
// Skip Authentik built-in groups (authentik Admins, etc.)
if (!string.IsNullOrEmpty(name) && !name.StartsWith("authentik ", StringComparison.OrdinalIgnoreCase))
{
groups.Add(new AuthentikGroupItem
{
Pk = pk,
Name = name,
MemberCount = memberCount,
});
}
}
}
// Handle pagination
nextUrl = root.TryGetProperty("pagination", out var pagination) &&
pagination.TryGetProperty("next", out var nextProp) &&
nextProp.ValueKind == JsonValueKind.Number
? $"/api/v3/core/groups/?page_size=200&page={nextProp.GetInt32()}"
: null;
}
_logger.LogInformation("[Authentik] Found {Count} group(s)", groups.Count);
return groups.OrderBy(g => g.Name).ToList();
}
// ─────────────────────────────────────────────────────────────────────────
// HTTP client setup
// ─────────────────────────────────────────────────────────────────────────
@@ -640,7 +696,7 @@ public class AuthentikService : IAuthentikService
private async Task CreateApplicationAsync(
HttpClient client, string baseUrl,
string abbrev, string slug, int providerId,
CancellationToken ct)
string instanceBaseUrl, CancellationToken ct)
{
_logger.LogInformation("[Authentik] Creating application '{Slug}' linked to provider {ProviderId}", slug, providerId);
@@ -649,6 +705,7 @@ public class AuthentikService : IAuthentikService
["name"] = $"OTS Signs — {abbrev.ToUpperInvariant()}",
["slug"] = slug,
["provider"] = providerId,
["meta_launch_url"] = instanceBaseUrl.TrimEnd('/'),
};
var jsonBody = JsonSerializer.Serialize(payload);

View File

@@ -42,4 +42,13 @@ public interface IAuthentikService
string? overrideUrl = null,
string? overrideApiKey = null,
CancellationToken ct = default);
/// <summary>
/// Returns all groups from Authentik, optionally filtered to those with
/// at least one member. Used for syncing groups to Xibo instances.
/// </summary>
Task<List<AuthentikGroupItem>> ListGroupsAsync(
string? overrideUrl = null,
string? overrideApiKey = null,
CancellationToken ct = default);
}

View File

@@ -460,6 +460,11 @@ public class PostInstanceInitService
samlConfig != null
? $" (Authentik provider={samlConfig.ProviderId})"
: " (without Authentik — needs manual IdP config)");
// ── 5. Sync Authentik groups to Xibo ──────────────────────────────
// Pre-create Authentik groups as Xibo user groups so they're available
// immediately (before any user logs in via SSO).
await SyncGroupsFromAuthentikAsync(abbrev, instanceUrl, settings, ct);
}
catch (Exception ex)
{
@@ -469,6 +474,89 @@ public class PostInstanceInitService
}
}
/// <summary>
/// Fetches all groups from Authentik and creates matching user groups in the
/// specified Xibo instance. Groups that already exist in Xibo are skipped.
/// This ensures that groups are available in Xibo for permission assignment
/// before any user logs in via SAML SSO.
/// </summary>
public async Task<int> SyncGroupsFromAuthentikAsync(
string abbrev,
string instanceUrl,
SettingsService settings,
CancellationToken ct = default)
{
var synced = 0;
try
{
_logger.LogInformation("[GroupSync] Syncing Authentik groups to Xibo for {Abbrev}", abbrev);
using var scope = _services.CreateScope();
var authentik = scope.ServiceProvider.GetRequiredService<IAuthentikService>();
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
// ── 1. Fetch groups from Authentik ────────────────────────────────
var authentikGroups = await authentik.ListGroupsAsync(ct: ct);
if (authentikGroups.Count == 0)
{
_logger.LogInformation("[GroupSync] No groups found in Authentik — nothing to sync");
return 0;
}
_logger.LogInformation("[GroupSync] Found {Count} Authentik group(s) to sync", authentikGroups.Count);
// ── 2. Authenticate to Xibo ───────────────────────────────────────
var oauthClientId = await settings.GetAsync(SettingsService.InstanceOAuthClientId(abbrev));
var oauthSecretId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev));
if (string.IsNullOrWhiteSpace(oauthClientId) || string.IsNullOrWhiteSpace(oauthSecretId))
{
_logger.LogWarning("[GroupSync] No OAuth credentials for {Abbrev} — cannot sync groups", abbrev);
return 0;
}
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
var oauthSecret = await bws.GetSecretAsync(oauthSecretId);
var accessToken = await xibo.LoginAsync(instanceUrl, oauthClientId, oauthSecret.Value);
// ── 3. List existing Xibo groups ──────────────────────────────────
var existingGroups = await xibo.ListUserGroupsAsync(instanceUrl, accessToken);
var existingNames = new HashSet<string>(
existingGroups.Select(g => g.Group),
StringComparer.OrdinalIgnoreCase);
// ── 4. Create missing groups in Xibo ──────────────────────────────
foreach (var group in authentikGroups)
{
if (existingNames.Contains(group.Name))
{
_logger.LogDebug("[GroupSync] Group '{Name}' already exists in Xibo", group.Name);
continue;
}
try
{
var groupId = await xibo.CreateUserGroupAsync(instanceUrl, accessToken, group.Name);
_logger.LogInformation("[GroupSync] Created Xibo group '{Name}' (id={Id})", group.Name, groupId);
synced++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[GroupSync] Failed to create Xibo group '{Name}'", group.Name);
}
}
_logger.LogInformation("[GroupSync] Sync complete for {Abbrev}: {Synced} group(s) created", abbrev, synced);
}
catch (Exception ex)
{
_logger.LogError(ex, "[GroupSync] Group sync failed for {Abbrev}: {Message}", abbrev, ex.Message);
// Don't rethrow — group sync failure should not block other operations
}
return synced;
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -321,6 +321,60 @@ public class XiboApiService
// User groups
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Lists all user groups in the Xibo instance and returns their names and IDs.
/// </summary>
public async Task<List<XiboGroupInfo>> ListUserGroupsAsync(
string instanceUrl,
string accessToken)
{
var client = _httpClientFactory.CreateClient("XiboApi");
var baseUrl = instanceUrl.TrimEnd('/');
SetBearer(client, accessToken);
var response = await client.GetAsync($"{baseUrl}/api/group");
await EnsureSuccessAsync(response, "list Xibo user groups");
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
var groups = new List<XiboGroupInfo>();
foreach (var el in doc.RootElement.EnumerateArray())
{
groups.Add(new XiboGroupInfo
{
GroupId = el.GetProperty("groupId").GetInt32(),
Group = el.GetProperty("group").GetString() ?? "",
});
}
return groups;
}
/// <summary>
/// Finds an existing Xibo group by name or creates it if it doesn't exist.
/// Returns the group ID.
/// </summary>
public async Task<int> GetOrCreateUserGroupAsync(
string instanceUrl,
string accessToken,
string groupName)
{
// Try to find existing group first
var existing = await ListUserGroupsAsync(instanceUrl, accessToken);
var match = existing.FirstOrDefault(g =>
string.Equals(g.Group, groupName, StringComparison.OrdinalIgnoreCase));
if (match != null)
{
_logger.LogDebug("Xibo group '{Name}' already exists (id={Id})", groupName, match.GroupId);
return match.GroupId;
}
// Create new group
return await CreateUserGroupAsync(instanceUrl, accessToken, groupName);
}
/// <summary>
/// Creates a new user group and returns its numeric group ID.
/// </summary>
@@ -529,6 +583,12 @@ public class XiboTestResult
public int HttpStatus { get; set; }
}
public class XiboGroupInfo
{
public int GroupId { get; set; }
public string Group { get; set; } = string.Empty;
}
public class XiboAuthException : Exception
{
public int HttpStatus { get; }

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)

Binary file not shown.

Binary file not shown.

View File

@@ -15,8 +15,8 @@ $samlSettings = [
],
'group' => 'Users',
'matchGroups' => [
'enabled' => false,
'attribute' => null,
'enabled' => true,
'attribute' => 'http://schemas.goauthentik.io/2021/02/saml/groups',
'extractionRegEx' => null,
],
],

View File

@@ -38,12 +38,6 @@ services:
- {{ABBREV}}-cms-ca-certs:/var/www/cms/ca-certs
ports:
- "{{HOST_HTTP_PORT}}:80"
healthcheck:
test: ["CMD-SHELL", "curl -fsS --max-time 5 http://web:80/about | grep -Eo 'v?[0-9]+(\\.[0-9]+)+' >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
{{ABBREV}}-net:
aliases: