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 ────────────────── // ── 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) ── // ── 5. Ensure provider has a signing keypair (required for metadata) ──
@@ -175,6 +175,62 @@ public class AuthentikService : IAuthentikService
}).OrderBy(k => k.Name).ToList() ?? new(); }).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 // HTTP client setup
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@@ -640,7 +696,7 @@ public class AuthentikService : IAuthentikService
private async Task CreateApplicationAsync( private async Task CreateApplicationAsync(
HttpClient client, string baseUrl, HttpClient client, string baseUrl,
string abbrev, string slug, int providerId, string abbrev, string slug, int providerId,
CancellationToken ct) string instanceBaseUrl, CancellationToken ct)
{ {
_logger.LogInformation("[Authentik] Creating application '{Slug}' linked to provider {ProviderId}", slug, providerId); _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()}", ["name"] = $"OTS Signs — {abbrev.ToUpperInvariant()}",
["slug"] = slug, ["slug"] = slug,
["provider"] = providerId, ["provider"] = providerId,
["meta_launch_url"] = instanceBaseUrl.TrimEnd('/'),
}; };
var jsonBody = JsonSerializer.Serialize(payload); var jsonBody = JsonSerializer.Serialize(payload);

View File

@@ -42,4 +42,13 @@ public interface IAuthentikService
string? overrideUrl = null, string? overrideUrl = null,
string? overrideApiKey = null, string? overrideApiKey = null,
CancellationToken ct = default); 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 samlConfig != null
? $" (Authentik provider={samlConfig.ProviderId})" ? $" (Authentik provider={samlConfig.ProviderId})"
: " (without Authentik — needs manual IdP config)"); : " (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) 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 // Helpers
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────

View File

@@ -321,6 +321,60 @@ public class XiboApiService
// User groups // 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> /// <summary>
/// Creates a new user group and returns its numeric group ID. /// Creates a new user group and returns its numeric group ID.
/// </summary> /// </summary>
@@ -529,6 +583,12 @@ public class XiboTestResult
public int HttpStatus { get; set; } 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 class XiboAuthException : Exception
{ {
public int HttpStatus { get; } public int HttpStatus { get; }

View File

@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Models.DTOs;
using OTSSignsOrchestrator.Core.Models.Entities; using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services; using OTSSignsOrchestrator.Core.Services;
using OTSSignsOrchestrator.Desktop.Models; using OTSSignsOrchestrator.Desktop.Models;
@@ -49,7 +50,15 @@ public partial class InstanceDetailsViewModel : ObservableObject
[ObservableProperty] private bool _isPendingSetup; [ObservableProperty] private bool _isPendingSetup;
[ObservableProperty] private string _initClientId = string.Empty; [ObservableProperty] private string _initClientId = string.Empty;
[ObservableProperty] private string _initClientSecret = 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 // Cached instance — needed by InitializeCommand to reload after setup
private LiveStackItem? _currentInstance; private LiveStackItem? _currentInstance;
public InstanceDetailsViewModel(IServiceProvider services) public InstanceDetailsViewModel(IServiceProvider services)
@@ -111,6 +120,9 @@ public partial class InstanceDetailsViewModel : ObservableObject
InitClientId = string.Empty; InitClientId = string.Empty;
InitClientSecret = string.Empty; InitClientSecret = string.Empty;
} }
// ── Load stack services ───────────────────────────────────────
await LoadServicesAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -213,6 +225,64 @@ public partial class InstanceDetailsViewModel : ObservableObject
// Rotation // 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] [RelayCommand]
private async Task RotateAdminPasswordAsync() 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> /// <summary>Raised when the instance details modal should be opened for the given ViewModel.</summary>
public event Action<InstanceDetailsViewModel>? OpenDetailsRequested; 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; private string? _pendingSelectAbbrev;
public InstancesViewModel(IServiceProvider services) public InstancesViewModel(IServiceProvider services)
@@ -245,6 +251,79 @@ public partial class InstancesViewModel : ObservableObject
_logRefreshTimer = null; _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] [RelayCommand]
private async Task DeleteInstanceAsync() private async Task DeleteInstanceAsync()
{ {

View File

@@ -135,6 +135,7 @@ public partial class SettingsViewModel : ObservableObject
// ── Load all other settings from Bitwarden ── // ── Load all other settings from Bitwarden ──
using var scope = _services.CreateScope(); using var scope = _services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>(); var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
svc.InvalidateCache();
// Git // Git
GitRepoUrl = await svc.GetAsync(SettingsService.GitRepoUrl, string.Empty); 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" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels" 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:Class="OTSSignsOrchestrator.Desktop.Views.InstanceDetailsWindow"
x:DataType="vm:InstanceDetailsViewModel" x:DataType="vm:InstanceDetailsViewModel"
Title="Instance Details" Title="Instance Details"
Width="620" Height="740" Width="620" Height="860"
MinWidth="520" MinHeight="600" MinWidth="520" MinHeight="700"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
CanResize="True"> CanResize="True">
@@ -186,6 +188,52 @@
</StackPanel> </StackPanel>
</Border> </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> </StackPanel>
</ScrollViewer> </ScrollViewer>
</DockPanel> </DockPanel>

View File

@@ -1,4 +1,5 @@
using Avalonia.Controls; using Avalonia.Controls;
using OTSSignsOrchestrator.Desktop.ViewModels;
namespace OTSSignsOrchestrator.Desktop.Views; namespace OTSSignsOrchestrator.Desktop.Views;
@@ -7,5 +8,15 @@ public partial class InstanceDetailsWindow : Window
public InstanceDetailsWindow() public InstanceDetailsWindow()
{ {
InitializeComponent(); 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:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels" xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
xmlns:dto="using:OTSSignsOrchestrator.Core.Models.DTOs" xmlns:dto="using:OTSSignsOrchestrator.Core.Models.DTOs"
xmlns:svc="using:OTSSignsOrchestrator.Core.Services"
x:Class="OTSSignsOrchestrator.Desktop.Views.InstancesView" x:Class="OTSSignsOrchestrator.Desktop.Views.InstancesView"
x:DataType="vm:InstancesViewModel"> x:DataType="vm:InstancesViewModel">
@@ -21,6 +22,9 @@
IsEnabled="{Binding !IsBusy}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="View credentials and manage this instance." /> ToolTip.Tip="View credentials and manage this instance." />
<Button Content="Inspect" Command="{Binding InspectInstanceCommand}" /> <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}" /> <Button Content="Delete" Classes="danger" Command="{Binding DeleteInstanceCommand}" />
<Border Width="1" Background="{StaticResource BorderSubtleBrush}" Margin="4,2" /> <Border Width="1" Background="{StaticResource BorderSubtleBrush}" Margin="4,2" />
<Button Content="Rotate DB Password" Command="{Binding RotateMySqlPasswordCommand}" <Button Content="Rotate DB Password" Command="{Binding RotateMySqlPasswordCommand}"
@@ -69,16 +73,25 @@
Foreground="{StaticResource AccentBrush}" Margin="0,0,0,10" /> Foreground="{StaticResource AccentBrush}" Margin="0,0,0,10" />
<ItemsControl ItemsSource="{Binding SelectedServices}"> <ItemsControl ItemsSource="{Binding SelectedServices}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate x:DataType="svc:ServiceInfo">
<Border Background="#232336" CornerRadius="6" Padding="12,10" Margin="0,3" <Border Background="#232336" CornerRadius="6" Padding="12,10" Margin="0,3"
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1"> BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1">
<StackPanel Spacing="3"> <Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13" /> <StackPanel Grid.Column="0" Spacing="3">
<TextBlock Text="{Binding Image}" FontSize="11" <TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13" />
Foreground="{StaticResource TextMutedBrush}" /> <TextBlock Text="{Binding Image}" FontSize="11"
<TextBlock Text="{Binding Replicas, StringFormat='Replicas: {0}'}" Foreground="{StaticResource TextMutedBrush}" />
FontSize="11" Foreground="{StaticResource TextSecondaryBrush}" /> <TextBlock Text="{Binding Replicas, StringFormat='Replicas: {0}'}"
</StackPanel> 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> </Border>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>

View File

@@ -21,7 +21,17 @@ public partial class InstancesView : UserControl
_vm = DataContext as InstancesViewModel; _vm = DataContext as InstancesViewModel;
if (_vm is not null) if (_vm is not null)
{
_vm.OpenDetailsRequested += OnOpenDetailsRequested; _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) private async void OnOpenDetailsRequested(InstanceDetailsViewModel detailsVm)

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -38,12 +38,6 @@ services:
- {{ABBREV}}-cms-ca-certs:/var/www/cms/ca-certs - {{ABBREV}}-cms-ca-certs:/var/www/cms/ca-certs
ports: ports:
- "{{HOST_HTTP_PORT}}:80" - "{{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: networks:
{{ABBREV}}-net: {{ABBREV}}-net:
aliases: aliases: