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 ;
using Microsoft.EntityFrameworkCore ;
using Microsoft.Extensions.DependencyInjection ;
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.
2026-02-18 10:43:27 -05:00
/// </summary>
public partial class InstancesViewModel : ObservableObject
{
private readonly IServiceProvider _services ;
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 ;
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 ; }
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 ;
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 ;
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
}
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
[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
}