feat: Implement Authentik group synchronization and add confirmation dialogs for service management
This commit is contained in:
Submodule .template-cache/2dc03e2b2b45fef3 updated: eaf06cf624...0cc0da3c6e
18
OTSSignsOrchestrator.Core/Models/DTOs/AuthentikGroupItem.cs
Normal file
18
OTSSignsOrchestrator.Core/Models/DTOs/AuthentikGroupItem.cs
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user