2026-02-18 10:43:27 -05:00
using System.Collections.ObjectModel ;
2026-02-25 17:39:17 -05:00
using Avalonia.Threading ;
2026-02-18 10:43:27 -05:00
using CommunityToolkit.Mvvm.ComponentModel ;
using CommunityToolkit.Mvvm.Input ;
feat: Implement provisioning pipelines for subscription management
- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes.
- Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging.
- Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR.
- Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes.
- Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots.
- Introduce XiboFeatureManifests for hardcoded feature ACLs per role.
- Add docker-compose.dev.yml for local development with PostgreSQL setup.
2026-03-18 10:27:26 -04:00
using CommunityToolkit.Mvvm.Messaging ;
2026-02-18 10:43:27 -05:00
using Microsoft.EntityFrameworkCore ;
using Microsoft.Extensions.DependencyInjection ;
feat: Implement provisioning pipelines for subscription management
- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes.
- Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging.
- Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR.
- Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes.
- Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots.
- Introduce XiboFeatureManifests for hardcoded feature ACLs per role.
- Add docker-compose.dev.yml for local development with PostgreSQL setup.
2026-03-18 10:27:26 -04:00
using Microsoft.Extensions.Logging ;
2026-02-18 10:43:27 -05:00
using OTSSignsOrchestrator.Core.Data ;
2026-02-25 17:39:17 -05:00
using OTSSignsOrchestrator.Core.Models.DTOs ;
2026-02-18 10:43:27 -05:00
using OTSSignsOrchestrator.Core.Models.Entities ;
using OTSSignsOrchestrator.Core.Services ;
2026-02-19 08:27:54 -05:00
using OTSSignsOrchestrator.Desktop.Models ;
2026-02-18 10:43:27 -05:00
using OTSSignsOrchestrator.Desktop.Services ;
namespace OTSSignsOrchestrator.Desktop.ViewModels ;
/// <summary>
/// ViewModel for listing, viewing, and managing CMS instances.
2026-02-19 08:27:54 -05:00
/// All data is fetched live from Docker Swarm hosts — nothing stored locally.
feat: Implement provisioning pipelines for subscription management
- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes.
- Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging.
- Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR.
- Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes.
- Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots.
- Introduce XiboFeatureManifests for hardcoded feature ACLs per role.
- Add docker-compose.dev.yml for local development with PostgreSQL setup.
2026-03-18 10:27:26 -04:00
/// Server operations (decommission, suspend, reactivate) go through the REST API.
/// Real-time updates arrive via SignalR → WeakReferenceMessenger.
2026-02-18 10:43:27 -05:00
/// </summary>
feat: Implement provisioning pipelines for subscription management
- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes.
- Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging.
- Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR.
- Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes.
- Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots.
- Introduce XiboFeatureManifests for hardcoded feature ACLs per role.
- Add docker-compose.dev.yml for local development with PostgreSQL setup.
2026-03-18 10:27:26 -04:00
public partial class InstancesViewModel : ObservableObject ,
IRecipient < AlertRaisedMessage > ,
IRecipient < InstanceStatusChangedMessage > ,
IRecipient < JobCreatedMessage > ,
IRecipient < JobCompletedMessage >
2026-02-18 10:43:27 -05:00
{
private readonly IServiceProvider _services ;
feat: Implement provisioning pipelines for subscription management
- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes.
- Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging.
- Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR.
- Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes.
- Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots.
- Introduce XiboFeatureManifests for hardcoded feature ACLs per role.
- Add docker-compose.dev.yml for local development with PostgreSQL setup.
2026-03-18 10:27:26 -04:00
private readonly ILogger < InstancesViewModel > _logger ;
private readonly IServerApiClient ? _serverApi ;
2026-02-18 10:43:27 -05:00
2026-02-19 08:27:54 -05:00
[ObservableProperty] private ObservableCollection < LiveStackItem > _instances = new ( ) ;
[ObservableProperty] private LiveStackItem ? _selectedInstance ;
2026-02-18 10:43:27 -05:00
[ObservableProperty] private string _statusMessage = string . Empty ;
[ObservableProperty] private bool _isBusy ;
[ObservableProperty] private string _filterText = string . Empty ;
[ObservableProperty] private ObservableCollection < ServiceInfo > _selectedServices = new ( ) ;
2026-02-19 08:27:54 -05:00
// Available SSH hosts — loaded for display and used to scope operations
2026-02-18 10:43:27 -05:00
[ObservableProperty] private ObservableCollection < SshHost > _availableHosts = new ( ) ;
[ObservableProperty] private SshHost ? _selectedSshHost ;
feat: Implement provisioning pipelines for subscription management
- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes.
- Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging.
- Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR.
- Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes.
- Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots.
- Introduce XiboFeatureManifests for hardcoded feature ACLs per role.
- Add docker-compose.dev.yml for local development with PostgreSQL setup.
2026-03-18 10:27:26 -04:00
// ── P1 Authentik Banner ──────────────────────────────────────────────────
[ObservableProperty] private bool _isAuthentikP1BannerVisible ;
[ObservableProperty] private string _authentikP1Message = string . Empty ;
2026-02-25 17:39:17 -05:00
// ── Container Logs ──────────────────────────────────────────────────────
[ObservableProperty] private ObservableCollection < ServiceLogEntry > _logEntries = new ( ) ;
[ObservableProperty] private ObservableCollection < string > _logServiceFilter = new ( ) ;
[ObservableProperty] private string _selectedLogService = "All Services" ;
[ObservableProperty] private bool _isLogsPanelVisible ;
[ObservableProperty] private bool _isLogsAutoRefresh = true ;
[ObservableProperty] private bool _isLoadingLogs ;
[ObservableProperty] private string _logsStatusMessage = string . Empty ;
[ObservableProperty] private int _logTailLines = 200 ;
private DispatcherTimer ? _logRefreshTimer ;
private bool _isLogRefreshRunning ;
2026-02-25 08:05:44 -05:00
/// <summary>Raised when the instance details modal should be opened for the given ViewModel.</summary>
public event Action < InstanceDetailsViewModel > ? OpenDetailsRequested ;
2026-03-04 21:33:29 -05:00
/// <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 ; }
feat: Implement provisioning pipelines for subscription management
- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes.
- Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging.
- Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR.
- Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes.
- Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots.
- Introduce XiboFeatureManifests for hardcoded feature ACLs per role.
- Add docker-compose.dev.yml for local development with PostgreSQL setup.
2026-03-18 10:27:26 -04:00
/// <summary>
/// Callback the View wires up to show a multi-step confirmation dialog for decommission.
/// Parameters: (abbreviation) → returns true if confirmed through all steps.
/// </summary>
public Func < string , Task < bool > > ? ConfirmDecommissionAsync { get ; set ; }
2026-02-25 17:39:17 -05:00
private string? _pendingSelectAbbrev ;
2026-02-18 10:43:27 -05:00
public InstancesViewModel ( IServiceProvider services )
{
_services = services ;
feat: Implement provisioning pipelines for subscription management
- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes.
- Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging.
- Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR.
- Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes.
- Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots.
- Introduce XiboFeatureManifests for hardcoded feature ACLs per role.
- Add docker-compose.dev.yml for local development with PostgreSQL setup.
2026-03-18 10:27:26 -04:00
_logger = services . GetRequiredService < ILogger < InstancesViewModel > > ( ) ;
_serverApi = services . GetService < IServerApiClient > ( ) ;
// Register for SignalR messages via WeakReferenceMessenger
WeakReferenceMessenger . Default . Register < AlertRaisedMessage > ( this ) ;
WeakReferenceMessenger . Default . Register < InstanceStatusChangedMessage > ( this ) ;
WeakReferenceMessenger . Default . Register < JobCreatedMessage > ( this ) ;
WeakReferenceMessenger . Default . Register < JobCompletedMessage > ( this ) ;
2026-02-19 08:27:54 -05:00
_ = RefreshAllAsync ( ) ;
2026-02-18 10:43:27 -05:00
}
2026-02-25 17:39:17 -05:00
/// <summary>
/// Queues an abbreviation to be auto-selected once the next live refresh completes.
/// Call immediately after construction (before <see cref="RefreshAllAsync"/> finishes).
/// </summary>
public void SetPendingSelection ( string abbrev )
= > _pendingSelectAbbrev = abbrev ;
feat: Implement provisioning pipelines for subscription management
- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes.
- Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging.
- Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR.
- Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes.
- Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots.
- Introduce XiboFeatureManifests for hardcoded feature ACLs per role.
- Add docker-compose.dev.yml for local development with PostgreSQL setup.
2026-03-18 10:27:26 -04:00
// ── SignalR Message Handlers ─────────────────────────────────────────────
// These are called on the UI thread (SignalR handlers dispatch via Dispatcher.UIThread).
void IRecipient < AlertRaisedMessage > . Receive ( AlertRaisedMessage message )
{
var ( severity , msg ) = message . Value ;
if ( string . Equals ( severity , "Critical" , StringComparison . OrdinalIgnoreCase ) & &
msg . Contains ( "Authentik" , StringComparison . OrdinalIgnoreCase ) )
{
AuthentikP1Message = msg ;
IsAuthentikP1BannerVisible = true ;
}
}
void IRecipient < InstanceStatusChangedMessage > . Receive ( InstanceStatusChangedMessage message )
{
var ( customerId , status ) = message . Value ;
_logger . LogInformation ( "Instance status changed: customer={CustomerId} status={Status}" , customerId , status ) ;
StatusMessage = $"Instance {customerId} status → {status}" ;
// Refresh the list to pick up the new status
_ = RefreshAllAsync ( ) ;
}
void IRecipient < JobCreatedMessage > . Receive ( JobCreatedMessage message )
{
var ( jobId , abbrev , jobType ) = message . Value ;
_logger . LogInformation ( "Job created: {JobId} type={JobType} abbrev={Abbrev}" , jobId , jobType , abbrev ) ;
StatusMessage = $"Job '{jobType}' created for {abbrev} (id: {jobId[..8]}…)" ;
}
void IRecipient < JobCompletedMessage > . Receive ( JobCompletedMessage message )
{
var ( jobId , success , summary ) = message . Value ;
_logger . LogInformation ( "Job completed: {JobId} success={Success} summary={Summary}" , jobId , success , summary ) ;
StatusMessage = success
? $"Job {jobId[..8]}… completed: {summary}"
: $"Job {jobId[..8]}… failed: {summary}" ;
// Refresh the instance list to reflect changes from the completed job
_ = RefreshAllAsync ( ) ;
}
// ── Load / Refresh ──────────────────────────────────────────────────────
2026-02-19 08:27:54 -05:00
/// <summary>
/// Enumerates all SSH hosts, then calls docker stack ls on each to build the
/// live instance list. Only stacks matching *-cms-stack are shown.
/// </summary>
2026-02-18 10:43:27 -05:00
[RelayCommand]
2026-02-19 08:27:54 -05:00
private async Task LoadInstancesAsync ( ) = > await RefreshAllAsync ( ) ;
2026-02-18 10:43:27 -05:00
2026-02-19 08:27:54 -05:00
private async Task RefreshAllAsync ( )
2026-02-18 10:43:27 -05:00
{
IsBusy = true ;
2026-02-19 08:27:54 -05:00
StatusMessage = "Loading live instances from all hosts..." ;
SelectedServices = new ObservableCollection < ServiceInfo > ( ) ;
2026-02-18 10:43:27 -05:00
try
{
using var scope = _services . CreateScope ( ) ;
var db = scope . ServiceProvider . GetRequiredService < XiboContext > ( ) ;
2026-02-19 08:27:54 -05:00
var hosts = await db . SshHosts . OrderBy ( h = > h . Label ) . ToListAsync ( ) ;
AvailableHosts = new ObservableCollection < SshHost > ( hosts ) ;
2026-02-18 10:43:27 -05:00
2026-02-19 08:27:54 -05:00
var dockerCli = _services . GetRequiredService < SshDockerCliService > ( ) ;
var all = new List < LiveStackItem > ( ) ;
var errors = new List < string > ( ) ;
foreach ( var host in hosts )
2026-02-18 10:43:27 -05:00
{
2026-02-19 08:27:54 -05:00
try
{
dockerCli . SetHost ( host ) ;
var stacks = await dockerCli . ListStacksAsync ( ) ;
foreach ( var stack in stacks . Where ( s = > s . Name . EndsWith ( "-cms-stack" ) ) )
{
all . Add ( new LiveStackItem
{
StackName = stack . Name ,
CustomerAbbrev = stack . Name [ . . ^ 10 ] ,
ServiceCount = stack . ServiceCount ,
Host = host ,
} ) ;
}
}
catch ( Exception ex ) { errors . Add ( $"{host.Label}: {ex.Message}" ) ; }
2026-02-18 10:43:27 -05:00
}
feat: Implement provisioning pipelines for subscription management
- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes.
- Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging.
- Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR.
- Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes.
- Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots.
- Introduce XiboFeatureManifests for hardcoded feature ACLs per role.
- Add docker-compose.dev.yml for local development with PostgreSQL setup.
2026-03-18 10:27:26 -04:00
// Enrich with server-side customer IDs if the server API is available
if ( _serverApi is not null )
{
try
{
var fleet = await _serverApi . GetFleetAsync ( ) ;
var lookup = fleet . ToDictionary ( f = > f . Abbreviation , f = > f . CustomerId , StringComparer . OrdinalIgnoreCase ) ;
foreach ( var item in all )
{
if ( lookup . TryGetValue ( item . CustomerAbbrev , out var customerId ) )
item . CustomerId = customerId ;
}
}
catch ( Exception ex )
{
_logger . LogWarning ( ex , "Could not enrich instances with server fleet data" ) ;
}
}
2026-02-19 08:27:54 -05:00
if ( ! string . IsNullOrWhiteSpace ( FilterText ) )
all = all . Where ( i = >
i . StackName . Contains ( FilterText , StringComparison . OrdinalIgnoreCase ) | |
i . CustomerAbbrev . Contains ( FilterText , StringComparison . OrdinalIgnoreCase ) | |
i . HostLabel . Contains ( FilterText , StringComparison . OrdinalIgnoreCase ) ) . ToList ( ) ;
Instances = new ObservableCollection < LiveStackItem > ( all ) ;
2026-02-25 17:39:17 -05:00
// Auto-select a pending instance (e.g. just deployed from Create Instance page)
if ( _pendingSelectAbbrev is not null )
{
SelectedInstance = all . FirstOrDefault ( i = >
i . CustomerAbbrev . Equals ( _pendingSelectAbbrev , StringComparison . OrdinalIgnoreCase ) ) ;
_pendingSelectAbbrev = null ;
}
2026-02-19 08:27:54 -05:00
var msg = $"Found {all.Count} instance(s) across {hosts.Count} host(s)." ;
if ( errors . Count > 0 ) msg + = $" | Errors: {string.Join(" | ", errors)}" ;
StatusMessage = msg ;
2026-02-18 10:43:27 -05:00
}
2026-02-19 08:27:54 -05:00
catch ( Exception ex ) { StatusMessage = $"Error: {ex.Message}" ; }
finally { IsBusy = false ; }
2026-02-18 10:43:27 -05:00
}
[RelayCommand]
private async Task InspectInstanceAsync ( )
{
if ( SelectedInstance = = null ) return ;
IsBusy = true ;
2026-02-19 08:27:54 -05:00
StatusMessage = $"Inspecting '{SelectedInstance.StackName}'..." ;
2026-02-18 10:43:27 -05:00
try
{
var dockerCli = _services . GetRequiredService < SshDockerCliService > ( ) ;
2026-02-19 08:27:54 -05:00
dockerCli . SetHost ( SelectedInstance . Host ) ;
2026-02-18 10:43:27 -05:00
var services = await dockerCli . InspectStackServicesAsync ( SelectedInstance . StackName ) ;
SelectedServices = new ObservableCollection < ServiceInfo > ( services ) ;
StatusMessage = $"Found {services.Count} service(s) in stack '{SelectedInstance.StackName}'." ;
2026-02-25 17:39:17 -05:00
// Populate service filter dropdown and show logs panel
var filterItems = new List < string > { "All Services" } ;
filterItems . AddRange ( services . Select ( s = > s . Name ) ) ;
LogServiceFilter = new ObservableCollection < string > ( filterItems ) ;
SelectedLogService = "All Services" ;
IsLogsPanelVisible = true ;
// Fetch initial logs and start auto-refresh
await FetchLogsInternalAsync ( ) ;
StartLogAutoRefresh ( ) ;
2026-02-18 10:43:27 -05:00
}
2026-02-19 08:27:54 -05:00
catch ( Exception ex ) { StatusMessage = $"Error inspecting: {ex.Message}" ; }
finally { IsBusy = false ; }
2026-02-18 10:43:27 -05:00
}
2026-02-25 17:39:17 -05:00
// ── Container Log Commands ──────────────────────────────────────────────
[RelayCommand]
private async Task RefreshLogsAsync ( )
{
await FetchLogsInternalAsync ( ) ;
}
[RelayCommand]
private void ToggleLogsAutoRefresh ( )
{
IsLogsAutoRefresh = ! IsLogsAutoRefresh ;
if ( IsLogsAutoRefresh )
StartLogAutoRefresh ( ) ;
else
StopLogAutoRefresh ( ) ;
}
[RelayCommand]
private void CloseLogsPanel ( )
{
StopLogAutoRefresh ( ) ;
IsLogsPanelVisible = false ;
LogEntries = new ObservableCollection < ServiceLogEntry > ( ) ;
LogsStatusMessage = string . Empty ;
}
partial void OnSelectedLogServiceChanged ( string value )
{
// When user changes the service filter, refresh logs immediately
if ( IsLogsPanelVisible )
_ = FetchLogsInternalAsync ( ) ;
}
private async Task FetchLogsInternalAsync ( )
{
if ( SelectedInstance = = null | | _isLogRefreshRunning ) return ;
_isLogRefreshRunning = true ;
IsLoadingLogs = true ;
try
{
var dockerCli = _services . GetRequiredService < SshDockerCliService > ( ) ;
dockerCli . SetHost ( SelectedInstance . Host ) ;
string? serviceFilter = SelectedLogService = = "All Services" ? null : SelectedLogService ;
var entries = await dockerCli . GetServiceLogsAsync (
SelectedInstance . StackName , serviceFilter , LogTailLines ) ;
LogEntries = new ObservableCollection < ServiceLogEntry > ( entries ) ;
LogsStatusMessage = $"{entries.Count} log line(s) · last fetched {DateTime.Now:HH:mm:ss}" ;
}
catch ( Exception ex )
{
LogsStatusMessage = $"Error fetching logs: {ex.Message}" ;
}
finally
{
IsLoadingLogs = false ;
_isLogRefreshRunning = false ;
}
}
private void StartLogAutoRefresh ( )
{
StopLogAutoRefresh ( ) ;
if ( ! IsLogsAutoRefresh ) return ;
_logRefreshTimer = new DispatcherTimer
{
Interval = TimeSpan . FromSeconds ( 5 )
} ;
_logRefreshTimer . Tick + = async ( _ , _ ) = >
{
if ( IsLogsPanelVisible & & IsLogsAutoRefresh & & ! _isLogRefreshRunning )
await FetchLogsInternalAsync ( ) ;
} ;
_logRefreshTimer . Start ( ) ;
}
private void StopLogAutoRefresh ( )
{
_logRefreshTimer ? . Stop ( ) ;
_logRefreshTimer = null ;
}
2026-03-04 21:33:29 -05:00
// ── 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 ; }
}
2026-02-18 10:43:27 -05:00
[RelayCommand]
private async Task DeleteInstanceAsync ( )
{
if ( SelectedInstance = = null ) return ;
IsBusy = true ;
StatusMessage = $"Deleting {SelectedInstance.StackName}..." ;
try
{
var dockerCli = _services . GetRequiredService < SshDockerCliService > ( ) ;
2026-02-19 08:27:54 -05:00
dockerCli . SetHost ( SelectedInstance . Host ) ;
2026-02-18 10:43:27 -05:00
var dockerSecrets = _services . GetRequiredService < SshDockerSecretsService > ( ) ;
2026-02-19 08:27:54 -05:00
dockerSecrets . SetHost ( SelectedInstance . Host ) ;
2026-02-18 10:43:27 -05:00
using var scope = _services . CreateScope ( ) ;
var instanceSvc = scope . ServiceProvider . GetRequiredService < InstanceService > ( ) ;
2026-02-19 08:27:54 -05:00
var result = await instanceSvc . DeleteInstanceAsync (
SelectedInstance . StackName , SelectedInstance . CustomerAbbrev ) ;
2026-02-18 10:43:27 -05:00
StatusMessage = result . Success
? $"Instance '{SelectedInstance.StackName}' deleted."
: $"Delete failed: {result.ErrorMessage}" ;
2026-02-19 08:27:54 -05:00
await RefreshAllAsync ( ) ;
2026-02-18 10:43:27 -05:00
}
2026-02-19 08:27:54 -05:00
catch ( Exception ex ) { StatusMessage = $"Error deleting: {ex.Message}" ; }
finally { IsBusy = false ; }
}
[RelayCommand]
private async Task RotateMySqlPasswordAsync ( )
{
if ( SelectedInstance = = null ) return ;
IsBusy = true ;
StatusMessage = $"Rotating MySQL password for {SelectedInstance.StackName}..." ;
try
2026-02-18 10:43:27 -05:00
{
2026-02-19 08:27:54 -05:00
var dockerCli = _services . GetRequiredService < SshDockerCliService > ( ) ;
dockerCli . SetHost ( SelectedInstance . Host ) ;
var dockerSecrets = _services . GetRequiredService < SshDockerSecretsService > ( ) ;
dockerSecrets . SetHost ( SelectedInstance . Host ) ;
using var scope = _services . CreateScope ( ) ;
var instanceSvc = scope . ServiceProvider . GetRequiredService < InstanceService > ( ) ;
var ( ok , msg ) = await instanceSvc . RotateMySqlPasswordAsync ( SelectedInstance . StackName ) ;
StatusMessage = ok ? $"Done: {msg}" : $"Failed: {msg}" ;
await RefreshAllAsync ( ) ;
2026-02-18 10:43:27 -05:00
}
2026-02-19 08:27:54 -05:00
catch ( Exception ex ) { StatusMessage = $"Error rotating password: {ex.Message}" ; }
finally { IsBusy = false ; }
2026-02-18 10:43:27 -05:00
}
2026-02-25 08:05:44 -05:00
feat: Implement provisioning pipelines for subscription management
- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes.
- Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging.
- Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR.
- Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes.
- Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots.
- Introduce XiboFeatureManifests for hardcoded feature ACLs per role.
- Add docker-compose.dev.yml for local development with PostgreSQL setup.
2026-03-18 10:27:26 -04:00
// ── P1 Banner Commands ────────────────────────────────────────────────
[RelayCommand]
private void DismissP1Banner ( )
{
IsAuthentikP1BannerVisible = false ;
AuthentikP1Message = string . Empty ;
}
/// <summary>
/// Called from a SignalR <c>AlertRaised</c> handler (runs on a background thread).
/// CRITICAL: wraps all property updates with <see cref="Dispatcher.UIThread"/> to
/// avoid silent cross-thread exceptions in Avalonia.
/// </summary>
public void HandleAlertRaised ( string severity , string message )
{
if ( string . Equals ( severity , "Critical" , StringComparison . OrdinalIgnoreCase ) & &
message . Contains ( "Authentik" , StringComparison . OrdinalIgnoreCase ) )
{
Dispatcher . UIThread . InvokeAsync ( ( ) = >
{
AuthentikP1Message = message ;
IsAuthentikP1BannerVisible = true ;
} ) ;
}
}
// ── Server-side Job Commands (decommission, suspend, reactivate) ────────
// Desktop has NO direct infrastructure access — all operations go through the server REST API.
[RelayCommand]
private async Task DecommissionAsync ( LiveStackItem ? instance )
{
instance ? ? = SelectedInstance ;
if ( instance is null ) return ;
if ( instance . CustomerId is null )
{
StatusMessage = "Cannot decommission: no server-side customer ID available for this instance." ;
return ;
}
// Multi-step confirmation: user must type the abbreviation to confirm
if ( ConfirmDecommissionAsync is not null )
{
var confirmed = await ConfirmDecommissionAsync ( instance . CustomerAbbrev ) ;
if ( ! confirmed ) return ;
}
else if ( ConfirmAsync is not null )
{
var confirmed = await ConfirmAsync (
"Decommission Instance" ,
$"Are you sure you want to decommission '{instance.CustomerAbbrev}'?\n\n" +
"This will:\n" +
" • Remove all Docker services and stack\n" +
" • Delete Docker secrets\n" +
" • Remove NFS volumes and data\n" +
" • Revoke Authentik provider\n" +
" • Mark the customer as decommissioned\n\n" +
"This action is IRREVERSIBLE." ) ;
if ( ! confirmed ) return ;
}
await CreateServerJobAsync ( instance , "decommission" ) ;
}
[RelayCommand]
private async Task SuspendInstanceAsync ( LiveStackItem ? instance )
{
instance ? ? = SelectedInstance ;
if ( instance is null ) return ;
if ( instance . CustomerId is null )
{
StatusMessage = "Cannot suspend: no server-side customer ID available for this instance." ;
return ;
}
if ( ConfirmAsync is not null )
{
var confirmed = await ConfirmAsync (
"Suspend Instance" ,
$"Are you sure you want to suspend '{instance.CustomerAbbrev}'?\n\n" +
"The instance will be scaled to zero replicas. Data will be preserved." ) ;
if ( ! confirmed ) return ;
}
await CreateServerJobAsync ( instance , "suspend" ) ;
}
[RelayCommand]
private async Task ReactivateInstanceAsync ( LiveStackItem ? instance )
{
instance ? ? = SelectedInstance ;
if ( instance is null ) return ;
if ( instance . CustomerId is null )
{
StatusMessage = "Cannot reactivate: no server-side customer ID available for this instance." ;
return ;
}
await CreateServerJobAsync ( instance , "reactivate" ) ;
}
private async Task CreateServerJobAsync ( LiveStackItem instance , string jobType )
{
if ( _serverApi is null )
{
StatusMessage = "Server API client is not configured." ;
return ;
}
IsBusy = true ;
StatusMessage = $"Requesting '{jobType}' for {instance.CustomerAbbrev}..." ;
try
{
var response = await _serverApi . CreateJobAsync (
new CreateJobRequest ( instance . CustomerId ! . Value , jobType , null ) ) ;
StatusMessage = $"Job '{jobType}' created (id: {response.Id.ToString()[..8]}…). Status: {response.Status}" ;
_logger . LogInformation ( "Server job created: {JobId} type={JobType} customer={CustomerId}" ,
response . Id , jobType , instance . CustomerId ) ;
}
catch ( Refit . ApiException ex )
{
StatusMessage = $"Server error creating '{jobType}' job: {ex.StatusCode} — {ex.Content}" ;
_logger . LogError ( ex , "Failed to create {JobType} job for customer {CustomerId}" , jobType , instance . CustomerId ) ;
}
catch ( Exception ex )
{
StatusMessage = $"Error creating '{jobType}' job: {ex.Message}" ;
_logger . LogError ( ex , "Failed to create {JobType} job for customer {CustomerId}" , jobType , instance . CustomerId ) ;
}
finally { IsBusy = false ; }
}
// ── Details ─────────────────────────────────────────────────────────────
2026-02-25 08:05:44 -05:00
[RelayCommand]
private async Task OpenDetailsAsync ( )
{
if ( SelectedInstance = = null ) return ;
IsBusy = true ;
StatusMessage = $"Loading details for '{SelectedInstance.StackName}'..." ;
try
{
// Set the SSH host on singleton Docker services so modal operations target the right host
var dockerCli = _services . GetRequiredService < SshDockerCliService > ( ) ;
dockerCli . SetHost ( SelectedInstance . Host ) ;
var dockerSecrets = _services . GetRequiredService < SshDockerSecretsService > ( ) ;
dockerSecrets . SetHost ( SelectedInstance . Host ) ;
var detailsVm = _services . GetRequiredService < InstanceDetailsViewModel > ( ) ;
await detailsVm . LoadAsync ( SelectedInstance ) ;
OpenDetailsRequested ? . Invoke ( detailsVm ) ;
StatusMessage = string . Empty ;
}
catch ( Exception ex ) { StatusMessage = $"Error opening details: {ex.Message}" ; }
finally { IsBusy = false ; }
}
2026-02-18 10:43:27 -05:00
}