feat: Add main application views and structure
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
- Implemented CreateInstanceView for creating new instances. - Added HostsView for managing SSH hosts with CRUD operations. - Created InstancesView for displaying and managing instances. - Developed LogsView for viewing operation logs. - Introduced SecretsView for managing secrets associated with hosts. - Established SettingsView for configuring application settings. - Created MainWindow as the main application window with navigation. - Added app manifest and configuration files for logging and settings.
This commit is contained in:
9
OTSSignsOrchestrator.Desktop/App.axaml
Normal file
9
OTSSignsOrchestrator.Desktop/App.axaml
Normal file
@@ -0,0 +1,9 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.App"
|
||||
RequestedThemeVariant="Dark">
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
144
OTSSignsOrchestrator.Desktop/App.axaml.cs
Normal file
144
OTSSignsOrchestrator.Desktop/App.axaml.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Serilog;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
using OTSSignsOrchestrator.Desktop.Services;
|
||||
using OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
using OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop;
|
||||
|
||||
public class App : Application
|
||||
{
|
||||
public static IServiceProvider Services { get; private set; } = null!;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
ConfigureServices(services);
|
||||
Services = services.BuildServiceProvider();
|
||||
|
||||
// Apply migrations
|
||||
using (var scope = Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
|
||||
Log.Information("ApplicationLifetime type: {Type}", ApplicationLifetime?.GetType().FullName ?? "null");
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
Log.Information("Creating MainWindow...");
|
||||
var vm = Services.GetRequiredService<MainWindowViewModel>();
|
||||
Log.Information("MainWindowViewModel resolved");
|
||||
|
||||
var window = new MainWindow
|
||||
{
|
||||
DataContext = vm
|
||||
};
|
||||
|
||||
desktop.MainWindow = window;
|
||||
Log.Information("MainWindow assigned to lifetime");
|
||||
|
||||
window.Show();
|
||||
window.Activate();
|
||||
Log.Information("MainWindow Show() + Activate() called");
|
||||
|
||||
desktop.ShutdownRequested += (_, _) =>
|
||||
{
|
||||
var ssh = Services.GetService<SshConnectionService>();
|
||||
ssh?.Dispose();
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("ApplicationLifetime is NOT IClassicDesktopStyleApplicationLifetime — window cannot be shown");
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private static void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
// Configuration
|
||||
var config = new ConfigurationBuilder()
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: false)
|
||||
.Build();
|
||||
|
||||
services.AddSingleton<IConfiguration>(config);
|
||||
|
||||
// Options
|
||||
services.Configure<GitOptions>(config.GetSection(GitOptions.SectionName));
|
||||
services.Configure<DockerOptions>(config.GetSection(DockerOptions.SectionName));
|
||||
services.Configure<XiboOptions>(config.GetSection(XiboOptions.SectionName));
|
||||
services.Configure<DatabaseOptions>(config.GetSection(DatabaseOptions.SectionName));
|
||||
services.Configure<FileLoggingOptions>(config.GetSection(FileLoggingOptions.SectionName));
|
||||
|
||||
// Logging
|
||||
services.AddLogging(builder =>
|
||||
{
|
||||
builder.ClearProviders();
|
||||
builder.AddSerilog(dispose: true);
|
||||
});
|
||||
|
||||
// Data Protection
|
||||
var keysDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"OTSSignsOrchestrator", "keys");
|
||||
Directory.CreateDirectory(keysDir);
|
||||
|
||||
services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(keysDir))
|
||||
.SetApplicationName("OTSSignsOrchestrator");
|
||||
|
||||
// Database
|
||||
var connStr = config.GetConnectionString("Default") ?? "Data Source=otssigns-desktop.db";
|
||||
services.AddDbContext<XiboContext>(options => options.UseSqlite(connStr));
|
||||
|
||||
// HTTP
|
||||
services.AddHttpClient();
|
||||
services.AddHttpClient("XiboApi");
|
||||
|
||||
// SSH services (singletons — maintain connections)
|
||||
services.AddSingleton<SshConnectionService>();
|
||||
|
||||
// Docker services via SSH (scoped so they get fresh per-operation context)
|
||||
services.AddTransient<SshDockerCliService>();
|
||||
services.AddTransient<SshDockerSecretsService>();
|
||||
services.AddTransient<IDockerCliService>(sp => sp.GetRequiredService<SshDockerCliService>());
|
||||
services.AddTransient<IDockerSecretsService>(sp => sp.GetRequiredService<SshDockerSecretsService>());
|
||||
|
||||
// Core services
|
||||
services.AddTransient<SettingsService>();
|
||||
services.AddTransient<GitTemplateService>();
|
||||
services.AddTransient<ComposeRenderService>();
|
||||
services.AddTransient<ComposeValidationService>();
|
||||
services.AddTransient<XiboApiService>();
|
||||
services.AddTransient<InstanceService>();
|
||||
|
||||
// ViewModels
|
||||
services.AddTransient<MainWindowViewModel>();
|
||||
services.AddTransient<HostsViewModel>();
|
||||
services.AddTransient<InstancesViewModel>();
|
||||
services.AddTransient<CreateInstanceViewModel>();
|
||||
services.AddTransient<SecretsViewModel>();
|
||||
services.AddTransient<SettingsViewModel>();
|
||||
services.AddTransient<LogsViewModel>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.3" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.2" />
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="SSH.NET" Version="2024.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OTSSignsOrchestrator.Core\OTSSignsOrchestrator.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
40
OTSSignsOrchestrator.Desktop/Program.cs
Normal file
40
OTSSignsOrchestrator.Desktop/Program.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using Avalonia;
|
||||
using Avalonia.ReactiveUI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Serilog;
|
||||
using System;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop;
|
||||
|
||||
sealed class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Debug()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 7)
|
||||
.CreateLogger();
|
||||
|
||||
try
|
||||
{
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Application terminated unexpectedly");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace()
|
||||
.UseReactiveUI();
|
||||
}
|
||||
186
OTSSignsOrchestrator.Desktop/Services/SshConnectionService.cs
Normal file
186
OTSSignsOrchestrator.Desktop/Services/SshConnectionService.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using Renci.SshNet;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages SSH connections to remote Docker Swarm hosts.
|
||||
/// Creates and caches SshClient instances with key or password authentication.
|
||||
/// </summary>
|
||||
public class SshConnectionService : IDisposable
|
||||
{
|
||||
private readonly ILogger<SshConnectionService> _logger;
|
||||
private readonly Dictionary<Guid, SshClient> _clients = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public SshConnectionService(ILogger<SshConnectionService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get or create a connected SshClient for a given SshHost.
|
||||
/// </summary>
|
||||
public SshClient GetClient(SshHost host)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_clients.TryGetValue(host.Id, out var existing) && existing.IsConnected)
|
||||
return existing;
|
||||
|
||||
// Dispose old client if disconnected
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Dispose();
|
||||
_clients.Remove(host.Id);
|
||||
}
|
||||
|
||||
var client = CreateClient(host);
|
||||
client.Connect();
|
||||
_clients[host.Id] = client;
|
||||
|
||||
_logger.LogInformation("SSH connected to {Host}:{Port} as {User}", host.Host, host.Port, host.Username);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test the SSH connection to a host. Returns (success, message).
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string Message)> TestConnectionAsync(SshHost host)
|
||||
{
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = CreateClient(host);
|
||||
client.ConnectionInfo.Timeout = TimeSpan.FromSeconds(10);
|
||||
client.Connect();
|
||||
|
||||
if (client.IsConnected)
|
||||
{
|
||||
// Quick test: run a simple command
|
||||
using var cmd = client.RunCommand("docker --version");
|
||||
client.Disconnect();
|
||||
|
||||
if (cmd.ExitStatus == 0)
|
||||
return (true, $"Connected. {cmd.Result.Trim()}");
|
||||
else
|
||||
return (true, $"Connected but docker not available: {cmd.Error}");
|
||||
}
|
||||
|
||||
return (false, "Failed to connect.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "SSH connection test failed for {Host}:{Port}", host.Host, host.Port);
|
||||
return (false, $"Connection failed: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run a command on the remote host and return (exitCode, stdout, stderr).
|
||||
/// </summary>
|
||||
public async Task<(int ExitCode, string Stdout, string Stderr)> RunCommandAsync(SshHost host, string command)
|
||||
{
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var client = GetClient(host);
|
||||
using var cmd = client.RunCommand(command);
|
||||
return (cmd.ExitStatus ?? -1, cmd.Result, cmd.Error);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run a command that requires stdin input (e.g., docker stack deploy --compose-file -).
|
||||
/// </summary>
|
||||
public async Task<(int ExitCode, string Stdout, string Stderr)> RunCommandWithStdinAsync(
|
||||
SshHost host, string command, string stdinContent)
|
||||
{
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var client = GetClient(host);
|
||||
|
||||
// Use shell stream approach for stdin piping
|
||||
// We pipe via: echo '<content>' | <command>
|
||||
// But for large YAML, use a heredoc approach
|
||||
var safeContent = stdinContent.Replace("'", "'\\''");
|
||||
var fullCommand = $"printf '%s' '{safeContent}' | {command}";
|
||||
|
||||
using var cmd = client.RunCommand(fullCommand);
|
||||
return (cmd.ExitStatus ?? -1, cmd.Result, cmd.Error);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disconnect and remove a cached client.
|
||||
/// </summary>
|
||||
public void Disconnect(Guid hostId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_clients.TryGetValue(hostId, out var client))
|
||||
{
|
||||
client.Disconnect();
|
||||
client.Dispose();
|
||||
_clients.Remove(hostId);
|
||||
_logger.LogInformation("SSH disconnected from host {HostId}", hostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SshClient CreateClient(SshHost host)
|
||||
{
|
||||
var authMethods = new List<AuthenticationMethod>();
|
||||
|
||||
if (host.UseKeyAuth && !string.IsNullOrEmpty(host.PrivateKeyPath))
|
||||
{
|
||||
var keyFile = string.IsNullOrEmpty(host.KeyPassphrase)
|
||||
? new PrivateKeyFile(host.PrivateKeyPath)
|
||||
: new PrivateKeyFile(host.PrivateKeyPath, host.KeyPassphrase);
|
||||
|
||||
authMethods.Add(new PrivateKeyAuthenticationMethod(host.Username, keyFile));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(host.Password))
|
||||
{
|
||||
authMethods.Add(new PasswordAuthenticationMethod(host.Username, host.Password));
|
||||
}
|
||||
|
||||
if (authMethods.Count == 0)
|
||||
{
|
||||
// Fall back to default SSH agent / key in ~/.ssh/
|
||||
var defaultKeyPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
|
||||
|
||||
if (File.Exists(defaultKeyPath))
|
||||
{
|
||||
authMethods.Add(new PrivateKeyAuthenticationMethod(host.Username, new PrivateKeyFile(defaultKeyPath)));
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"No authentication method configured for SSH host '{host.Label}'. " +
|
||||
"Provide a private key path or password.");
|
||||
}
|
||||
}
|
||||
|
||||
var connInfo = new ConnectionInfo(host.Host, host.Port, host.Username, authMethods.ToArray());
|
||||
return new SshClient(connInfo);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var client in _clients.Values)
|
||||
{
|
||||
try { client.Disconnect(); } catch { }
|
||||
client.Dispose();
|
||||
}
|
||||
_clients.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
159
OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs
Normal file
159
OTSSignsOrchestrator.Desktop/Services/SshDockerCliService.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Docker CLI service that executes docker commands on a remote host over SSH.
|
||||
/// Requires an SshHost to be set before use via SetHost().
|
||||
/// </summary>
|
||||
public class SshDockerCliService : IDockerCliService
|
||||
{
|
||||
private readonly SshConnectionService _ssh;
|
||||
private readonly DockerOptions _options;
|
||||
private readonly ILogger<SshDockerCliService> _logger;
|
||||
private SshHost? _currentHost;
|
||||
|
||||
public SshDockerCliService(
|
||||
SshConnectionService ssh,
|
||||
IOptions<DockerOptions> options,
|
||||
ILogger<SshDockerCliService> logger)
|
||||
{
|
||||
_ssh = ssh;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the SSH host to use for Docker commands.
|
||||
/// </summary>
|
||||
public void SetHost(SshHost host)
|
||||
{
|
||||
_currentHost = host;
|
||||
}
|
||||
|
||||
public SshHost? CurrentHost => _currentHost;
|
||||
|
||||
public async Task<DeploymentResultDto> DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false)
|
||||
{
|
||||
EnsureHost();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
var args = "docker stack deploy --compose-file -";
|
||||
if (resolveImage)
|
||||
args += " --resolve-image changed";
|
||||
args += $" {stackName}";
|
||||
|
||||
_logger.LogInformation("Deploying stack via SSH: {StackName} on {Host}", stackName, _currentHost!.Label);
|
||||
|
||||
var (exitCode, stdout, stderr) = await _ssh.RunCommandWithStdinAsync(_currentHost, args, composeYaml);
|
||||
sw.Stop();
|
||||
|
||||
var result = new DeploymentResultDto
|
||||
{
|
||||
StackName = stackName,
|
||||
Success = exitCode == 0,
|
||||
ExitCode = exitCode,
|
||||
Output = stdout,
|
||||
ErrorMessage = stderr,
|
||||
Message = exitCode == 0 ? "Success" : "Failed",
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
|
||||
if (result.Success)
|
||||
_logger.LogInformation("Stack deployed via SSH: {StackName} | duration={DurationMs}ms", stackName, result.DurationMs);
|
||||
else
|
||||
_logger.LogError("Stack deploy failed via SSH: {StackName} | error={Error}", stackName, result.ErrorMessage);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<DeploymentResultDto> RemoveStackAsync(string stackName)
|
||||
{
|
||||
EnsureHost();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
_logger.LogInformation("Removing stack via SSH: {StackName} on {Host}", stackName, _currentHost!.Label);
|
||||
|
||||
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost, $"docker stack rm {stackName}");
|
||||
sw.Stop();
|
||||
|
||||
var result = new DeploymentResultDto
|
||||
{
|
||||
StackName = stackName,
|
||||
Success = exitCode == 0,
|
||||
ExitCode = exitCode,
|
||||
Output = stdout,
|
||||
ErrorMessage = stderr,
|
||||
Message = exitCode == 0 ? "Success" : "Failed",
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
|
||||
if (result.Success)
|
||||
_logger.LogInformation("Stack removed via SSH: {StackName}", stackName);
|
||||
else
|
||||
_logger.LogError("Stack remove failed via SSH: {StackName} | error={Error}", stackName, result.ErrorMessage);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<List<StackInfo>> ListStacksAsync()
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
|
||||
_currentHost!, "docker stack ls --format '{{.Name}}\\t{{.Services}}'");
|
||||
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
|
||||
return new List<StackInfo>();
|
||||
|
||||
return stdout
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line =>
|
||||
{
|
||||
var parts = line.Split('\t', 2);
|
||||
return new StackInfo
|
||||
{
|
||||
Name = parts[0].Trim(),
|
||||
ServiceCount = parts.Length > 1 && int.TryParse(parts[1].Trim(), out var c) ? c : 0
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<List<ServiceInfo>> InspectStackServicesAsync(string stackName)
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
|
||||
_currentHost!, $"docker stack services {stackName} --format '{{{{.Name}}}}\\t{{{{.Image}}}}\\t{{{{.Replicas}}}}'");
|
||||
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
|
||||
return new List<ServiceInfo>();
|
||||
|
||||
return stdout
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line =>
|
||||
{
|
||||
var parts = line.Split('\t', 3);
|
||||
return new ServiceInfo
|
||||
{
|
||||
Name = parts.Length > 0 ? parts[0].Trim() : "",
|
||||
Image = parts.Length > 1 ? parts[1].Trim() : "",
|
||||
Replicas = parts.Length > 2 ? parts[2].Trim() : ""
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void EnsureHost()
|
||||
{
|
||||
if (_currentHost == null)
|
||||
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands.");
|
||||
}
|
||||
}
|
||||
140
OTSSignsOrchestrator.Desktop/Services/SshDockerSecretsService.cs
Normal file
140
OTSSignsOrchestrator.Desktop/Services/SshDockerSecretsService.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Docker Swarm secrets management over SSH.
|
||||
/// Uses docker CLI commands executed remotely instead of Docker.DotNet.
|
||||
/// </summary>
|
||||
public class SshDockerSecretsService : IDockerSecretsService
|
||||
{
|
||||
private readonly SshConnectionService _ssh;
|
||||
private readonly ILogger<SshDockerSecretsService> _logger;
|
||||
private SshHost? _currentHost;
|
||||
|
||||
public SshDockerSecretsService(SshConnectionService ssh, ILogger<SshDockerSecretsService> logger)
|
||||
{
|
||||
_ssh = ssh;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void SetHost(SshHost host) => _currentHost = host;
|
||||
public SshHost? CurrentHost => _currentHost;
|
||||
|
||||
public async Task<(bool Created, string SecretId)> EnsureSecretAsync(string name, string value, bool rotate = false)
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
_logger.LogInformation("Ensuring secret exists via SSH: {SecretName}", name);
|
||||
|
||||
// Check if secret already exists
|
||||
var existing = await FindSecretAsync(name);
|
||||
|
||||
if (existing != null && !rotate)
|
||||
{
|
||||
_logger.LogDebug("Secret already exists: {SecretName} (id={SecretId})", name, existing.Value.id);
|
||||
return (false, existing.Value.id);
|
||||
}
|
||||
|
||||
if (existing != null && rotate)
|
||||
{
|
||||
_logger.LogInformation("Rotating secret via SSH: {SecretName} (old id={SecretId})", name, existing.Value.id);
|
||||
await _ssh.RunCommandAsync(_currentHost!, $"docker secret rm {name}");
|
||||
}
|
||||
|
||||
// Create secret via stdin
|
||||
var safeValue = value.Replace("'", "'\\''");
|
||||
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(
|
||||
_currentHost!, $"printf '%s' '{safeValue}' | docker secret create {name} -");
|
||||
|
||||
if (exitCode != 0)
|
||||
{
|
||||
_logger.LogError("Failed to create secret via SSH: {SecretName} | error={Error}", name, stderr);
|
||||
return (false, string.Empty);
|
||||
}
|
||||
|
||||
var secretId = stdout.Trim();
|
||||
_logger.LogInformation("Secret created via SSH: {SecretName} (id={SecretId})", name, secretId);
|
||||
return (true, secretId);
|
||||
}
|
||||
|
||||
public async Task<List<SecretListItem>> ListSecretsAsync()
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
|
||||
_currentHost!, "docker secret ls --format '{{.ID}}\\t{{.Name}}\\t{{.CreatedAt}}'");
|
||||
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
|
||||
return new List<SecretListItem>();
|
||||
|
||||
return stdout
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line =>
|
||||
{
|
||||
var parts = line.Split('\t', 3);
|
||||
return new SecretListItem
|
||||
{
|
||||
Id = parts.Length > 0 ? parts[0].Trim() : "",
|
||||
Name = parts.Length > 1 ? parts[1].Trim() : "",
|
||||
CreatedAt = parts.Length > 2 && DateTime.TryParse(parts[2].Trim(), CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt)
|
||||
? dt
|
||||
: DateTime.MinValue
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteSecretAsync(string name)
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
var existing = await FindSecretAsync(name);
|
||||
if (existing == null)
|
||||
{
|
||||
_logger.LogDebug("Secret not found for deletion: {SecretName}", name);
|
||||
return true; // idempotent
|
||||
}
|
||||
|
||||
var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, $"docker secret rm {name}");
|
||||
if (exitCode != 0)
|
||||
{
|
||||
_logger.LogError("Failed to delete secret via SSH: {SecretName} | error={Error}", name, stderr);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Secret deleted via SSH: {SecretName}", name);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<(string id, string name)?> FindSecretAsync(string name)
|
||||
{
|
||||
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
|
||||
_currentHost!, $"docker secret ls --filter 'name={name}' --format '{{{{.ID}}}}\\t{{{{.Name}}}}'");
|
||||
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
|
||||
return null;
|
||||
|
||||
var line = stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.FirstOrDefault(l =>
|
||||
{
|
||||
var parts = l.Split('\t', 2);
|
||||
return parts.Length > 1 && string.Equals(parts[1].Trim(), name, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
|
||||
if (line == null) return null;
|
||||
|
||||
var p = line.Split('\t', 2);
|
||||
return (p[0].Trim(), p[1].Trim());
|
||||
}
|
||||
|
||||
private void EnsureHost()
|
||||
{
|
||||
if (_currentHost == null)
|
||||
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker secrets.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Security.Cryptography;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
using OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for the Create Instance form.
|
||||
/// Simplified: Customer Name, Abbreviation, SSH Host, and optional Newt credentials.
|
||||
/// All other config comes from the Settings page.
|
||||
/// </summary>
|
||||
public partial class CreateInstanceViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private string _deployOutput = string.Empty;
|
||||
[ObservableProperty] private double _progressPercent;
|
||||
[ObservableProperty] private string _progressStep = string.Empty;
|
||||
|
||||
// Core form fields — only these two are required from the user
|
||||
[ObservableProperty] private string _customerName = string.Empty;
|
||||
[ObservableProperty] private string _customerAbbrev = string.Empty;
|
||||
|
||||
// Optional Pangolin/Newt credentials (per-instance)
|
||||
[ObservableProperty] private string _newtId = string.Empty;
|
||||
[ObservableProperty] private string _newtSecret = string.Empty;
|
||||
|
||||
// CIFS / SMB credentials (per-instance, defaults loaded from global settings)
|
||||
[ObservableProperty] private string _cifsServer = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareBasePath = string.Empty;
|
||||
[ObservableProperty] private string _cifsUsername = string.Empty;
|
||||
[ObservableProperty] private string _cifsPassword = string.Empty;
|
||||
[ObservableProperty] private string _cifsExtraOptions = string.Empty;
|
||||
|
||||
// SSH host selection
|
||||
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
|
||||
[ObservableProperty] private SshHost? _selectedSshHost;
|
||||
|
||||
// ── Derived preview properties ───────────────────────────────────────────
|
||||
|
||||
public string PreviewStackName => Valid ? $"{Abbrev}-cms-stack" : "—";
|
||||
public string PreviewServiceWeb => Valid ? $"{Abbrev}-web" : "—";
|
||||
public string PreviewServiceCache => Valid ? $"{Abbrev}-memcached" : "—";
|
||||
public string PreviewServiceChart => Valid ? $"{Abbrev}-quickchart" : "—";
|
||||
public string PreviewServiceNewt => Valid ? $"{Abbrev}-newt" : "—";
|
||||
public string PreviewNetwork => Valid ? $"{Abbrev}-net" : "—";
|
||||
public string PreviewVolCustom => Valid ? $"{Abbrev}-cms-custom" : "—";
|
||||
public string PreviewVolBackup => Valid ? $"{Abbrev}-cms-backup" : "—";
|
||||
public string PreviewVolLibrary => Valid ? $"{Abbrev}-cms-library" : "—";
|
||||
public string PreviewVolUserscripts => Valid ? $"{Abbrev}-cms-userscripts": "—";
|
||||
public string PreviewVolCaCerts => Valid ? $"{Abbrev}-cms-ca-certs" : "—";
|
||||
public string PreviewSecret => Valid ? $"{Abbrev}-cms-db-password": "—";
|
||||
public string PreviewMySqlDb => Valid ? $"{Abbrev}_cms_db" : "—";
|
||||
public string PreviewMySqlUser => Valid ? $"{Abbrev}_cms" : "—";
|
||||
public string PreviewCmsUrl => Valid ? $"https://{Abbrev}.ots-signs.com" : "—";
|
||||
|
||||
private string Abbrev => CustomerAbbrev.Trim().ToLowerInvariant();
|
||||
private bool Valid => Abbrev.Length == 3 && System.Text.RegularExpressions.Regex.IsMatch(Abbrev, "^[a-z]{3}$");
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public CreateInstanceViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
_ = LoadHostsAsync();
|
||||
_ = LoadCifsDefaultsAsync();
|
||||
}
|
||||
|
||||
partial void OnCustomerAbbrevChanged(string value) => RefreshPreview();
|
||||
|
||||
private void RefreshPreview()
|
||||
{
|
||||
OnPropertyChanged(nameof(PreviewStackName));
|
||||
OnPropertyChanged(nameof(PreviewServiceWeb));
|
||||
OnPropertyChanged(nameof(PreviewServiceCache));
|
||||
OnPropertyChanged(nameof(PreviewServiceChart));
|
||||
OnPropertyChanged(nameof(PreviewServiceNewt));
|
||||
OnPropertyChanged(nameof(PreviewNetwork));
|
||||
OnPropertyChanged(nameof(PreviewVolCustom));
|
||||
OnPropertyChanged(nameof(PreviewVolBackup));
|
||||
OnPropertyChanged(nameof(PreviewVolLibrary));
|
||||
OnPropertyChanged(nameof(PreviewVolUserscripts));
|
||||
OnPropertyChanged(nameof(PreviewVolCaCerts));
|
||||
OnPropertyChanged(nameof(PreviewSecret));
|
||||
OnPropertyChanged(nameof(PreviewMySqlDb));
|
||||
OnPropertyChanged(nameof(PreviewMySqlUser));
|
||||
OnPropertyChanged(nameof(PreviewCmsUrl));
|
||||
}
|
||||
|
||||
private async Task LoadHostsAsync()
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
|
||||
AvailableHosts = new ObservableCollection<SshHost>(hosts);
|
||||
}
|
||||
|
||||
private async Task LoadCifsDefaultsAsync()
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
CifsServer = await settings.GetAsync(SettingsService.CifsServer) ?? string.Empty;
|
||||
CifsShareBasePath = await settings.GetAsync(SettingsService.CifsShareBasePath) ?? string.Empty;
|
||||
CifsUsername = await settings.GetAsync(SettingsService.CifsUsername) ?? string.Empty;
|
||||
CifsPassword = await settings.GetAsync(SettingsService.CifsPassword) ?? string.Empty;
|
||||
CifsExtraOptions = await settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeployAsync()
|
||||
{
|
||||
// ── Validation ───────────────────────────────────────────────────
|
||||
if (SelectedSshHost == null)
|
||||
{
|
||||
StatusMessage = "Select an SSH host first.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(CustomerName))
|
||||
{
|
||||
StatusMessage = "Customer Name is required.";
|
||||
return;
|
||||
}
|
||||
if (!Valid)
|
||||
{
|
||||
StatusMessage = "Abbreviation must be exactly 3 lowercase letters (a-z).";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = "Starting deployment...";
|
||||
DeployOutput = string.Empty;
|
||||
ProgressPercent = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// Wire SSH host into docker services
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(SelectedSshHost);
|
||||
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
|
||||
dockerSecrets.SetHost(SelectedSshHost);
|
||||
var ssh = _services.GetRequiredService<SshConnectionService>();
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
|
||||
|
||||
// ── Step 1: Clone template repo ────────────────────────────────
|
||||
SetProgress(10, "Cloning template repository...");
|
||||
// Handled inside InstanceService.CreateInstanceAsync
|
||||
|
||||
// ── Step 2: Generate MySQL password → Docker secret ────────────
|
||||
SetProgress(20, "Generating secrets...");
|
||||
var mysqlPassword = GenerateRandomPassword(32);
|
||||
|
||||
// ── Step 3: Create MySQL database + user via SSH ───────────────
|
||||
SetProgress(35, "Creating MySQL database and user...");
|
||||
var (mysqlOk, mysqlMsg) = await instanceSvc.CreateMySqlDatabaseAsync(
|
||||
Abbrev,
|
||||
mysqlPassword,
|
||||
cmd => ssh.RunCommandAsync(SelectedSshHost, cmd));
|
||||
|
||||
AppendOutput($"[MySQL] {mysqlMsg}");
|
||||
if (!mysqlOk)
|
||||
{
|
||||
StatusMessage = mysqlMsg;
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step 4: Create Docker Swarm secret ────────────────────────
|
||||
SetProgress(50, "Creating Docker Swarm secrets...");
|
||||
var secretName = $"{Abbrev}-cms-db-password";
|
||||
var (_, secretId) = await dockerSecrets.EnsureSecretAsync(secretName, mysqlPassword);
|
||||
AppendOutput($"[Secret] {secretName} → {secretId}");
|
||||
|
||||
// Password is now ONLY on the Swarm — clear from memory
|
||||
mysqlPassword = string.Empty;
|
||||
|
||||
// ── Step 5: Deploy stack ──────────────────────────────────────
|
||||
SetProgress(70, "Rendering compose & deploying stack...");
|
||||
|
||||
var dto = new CreateInstanceDto
|
||||
{
|
||||
CustomerName = CustomerName.Trim(),
|
||||
CustomerAbbrev = Abbrev,
|
||||
SshHostId = SelectedSshHost.Id,
|
||||
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
|
||||
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
|
||||
CifsServer = string.IsNullOrWhiteSpace(CifsServer) ? null : CifsServer.Trim(),
|
||||
CifsShareBasePath = string.IsNullOrWhiteSpace(CifsShareBasePath) ? null : CifsShareBasePath.Trim(),
|
||||
CifsUsername = string.IsNullOrWhiteSpace(CifsUsername) ? null : CifsUsername.Trim(),
|
||||
CifsPassword = string.IsNullOrWhiteSpace(CifsPassword) ? null : CifsPassword.Trim(),
|
||||
CifsExtraOptions = string.IsNullOrWhiteSpace(CifsExtraOptions) ? null : CifsExtraOptions.Trim(),
|
||||
};
|
||||
|
||||
var result = await instanceSvc.CreateInstanceAsync(dto);
|
||||
|
||||
AppendOutput(result.Output ?? string.Empty);
|
||||
SetProgress(100, result.Success ? "Deployment complete!" : "Deployment failed.");
|
||||
|
||||
StatusMessage = result.Success
|
||||
? $"Instance '{Abbrev}-cms-stack' deployed in {result.DurationMs}ms!"
|
||||
: $"Deploy failed: {result.ErrorMessage}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error: {ex.Message}";
|
||||
AppendOutput(ex.ToString());
|
||||
SetProgress(0, "Failed.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetProgress(double pct, string step)
|
||||
{
|
||||
ProgressPercent = pct;
|
||||
ProgressStep = step;
|
||||
AppendOutput($"[{pct:0}%] {step}");
|
||||
}
|
||||
|
||||
private void AppendOutput(string text)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
DeployOutput += (DeployOutput.Length > 0 ? "\n" : "") + text;
|
||||
}
|
||||
|
||||
private static string GenerateRandomPassword(int length)
|
||||
{
|
||||
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
|
||||
return RandomNumberGenerator.GetString(chars, length);
|
||||
}
|
||||
}
|
||||
|
||||
243
OTSSignsOrchestrator.Desktop/ViewModels/HostsViewModel.cs
Normal file
243
OTSSignsOrchestrator.Desktop/ViewModels/HostsViewModel.cs
Normal file
@@ -0,0 +1,243 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for managing SSH host connections.
|
||||
/// Allows adding, editing, testing, and removing remote Docker Swarm hosts.
|
||||
/// </summary>
|
||||
public partial class HostsViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
[ObservableProperty] private ObservableCollection<SshHost> _hosts = new();
|
||||
[ObservableProperty] private SshHost? _selectedHost;
|
||||
[ObservableProperty] private bool _isEditing;
|
||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
|
||||
// Edit form fields
|
||||
[ObservableProperty] private string _editLabel = string.Empty;
|
||||
[ObservableProperty] private string _editHost = string.Empty;
|
||||
[ObservableProperty] private int _editPort = 22;
|
||||
[ObservableProperty] private string _editUsername = string.Empty;
|
||||
[ObservableProperty] private string _editPrivateKeyPath = string.Empty;
|
||||
[ObservableProperty] private string _editKeyPassphrase = string.Empty;
|
||||
[ObservableProperty] private string _editPassword = string.Empty;
|
||||
[ObservableProperty] private bool _editUseKeyAuth = true;
|
||||
private Guid? _editingHostId;
|
||||
|
||||
public HostsViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
_ = LoadHostsAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadHostsAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
|
||||
|
||||
Hosts = new ObservableCollection<SshHost>(hosts);
|
||||
StatusMessage = $"Loaded {hosts.Count} host(s).";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error loading hosts: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void NewHost()
|
||||
{
|
||||
_editingHostId = null;
|
||||
EditLabel = string.Empty;
|
||||
EditHost = string.Empty;
|
||||
EditPort = 22;
|
||||
EditUsername = string.Empty;
|
||||
EditPrivateKeyPath = string.Empty;
|
||||
EditKeyPassphrase = string.Empty;
|
||||
EditPassword = string.Empty;
|
||||
EditUseKeyAuth = true;
|
||||
IsEditing = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void EditSelectedHost()
|
||||
{
|
||||
if (SelectedHost == null) return;
|
||||
|
||||
_editingHostId = SelectedHost.Id;
|
||||
EditLabel = SelectedHost.Label;
|
||||
EditHost = SelectedHost.Host;
|
||||
EditPort = SelectedHost.Port;
|
||||
EditUsername = SelectedHost.Username;
|
||||
EditPrivateKeyPath = SelectedHost.PrivateKeyPath ?? string.Empty;
|
||||
EditKeyPassphrase = string.Empty; // Don't show existing passphrase
|
||||
EditPassword = string.Empty; // Don't show existing password
|
||||
EditUseKeyAuth = SelectedHost.UseKeyAuth;
|
||||
IsEditing = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CancelEdit()
|
||||
{
|
||||
IsEditing = false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SaveHostAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(EditLabel) || string.IsNullOrWhiteSpace(EditHost) || string.IsNullOrWhiteSpace(EditUsername))
|
||||
{
|
||||
StatusMessage = "Label, Host, and Username are required.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
|
||||
SshHost host;
|
||||
if (_editingHostId.HasValue)
|
||||
{
|
||||
host = await db.SshHosts.FindAsync(_editingHostId.Value)
|
||||
?? throw new KeyNotFoundException("Host not found.");
|
||||
|
||||
host.Label = EditLabel;
|
||||
host.Host = EditHost;
|
||||
host.Port = EditPort;
|
||||
host.Username = EditUsername;
|
||||
host.PrivateKeyPath = string.IsNullOrWhiteSpace(EditPrivateKeyPath) ? null : EditPrivateKeyPath;
|
||||
host.UseKeyAuth = EditUseKeyAuth;
|
||||
host.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
if (!string.IsNullOrEmpty(EditKeyPassphrase))
|
||||
host.KeyPassphrase = EditKeyPassphrase;
|
||||
if (!string.IsNullOrEmpty(EditPassword))
|
||||
host.Password = EditPassword;
|
||||
}
|
||||
else
|
||||
{
|
||||
host = new SshHost
|
||||
{
|
||||
Label = EditLabel,
|
||||
Host = EditHost,
|
||||
Port = EditPort,
|
||||
Username = EditUsername,
|
||||
PrivateKeyPath = string.IsNullOrWhiteSpace(EditPrivateKeyPath) ? null : EditPrivateKeyPath,
|
||||
KeyPassphrase = string.IsNullOrEmpty(EditKeyPassphrase) ? null : EditKeyPassphrase,
|
||||
Password = string.IsNullOrEmpty(EditPassword) ? null : EditPassword,
|
||||
UseKeyAuth = EditUseKeyAuth
|
||||
};
|
||||
db.SshHosts.Add(host);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
IsEditing = false;
|
||||
StatusMessage = $"Host '{host.Label}' saved.";
|
||||
await LoadHostsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error saving host: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeleteHostAsync()
|
||||
{
|
||||
if (SelectedHost == null) return;
|
||||
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
|
||||
var host = await db.SshHosts.FindAsync(SelectedHost.Id);
|
||||
if (host != null)
|
||||
{
|
||||
db.SshHosts.Remove(host);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Disconnect if connected
|
||||
var ssh = _services.GetRequiredService<SshConnectionService>();
|
||||
ssh.Disconnect(SelectedHost.Id);
|
||||
|
||||
StatusMessage = $"Host '{SelectedHost.Label}' deleted.";
|
||||
await LoadHostsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error deleting host: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task TestConnectionAsync()
|
||||
{
|
||||
if (SelectedHost == null) return;
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Testing connection to {SelectedHost.Label}...";
|
||||
try
|
||||
{
|
||||
var ssh = _services.GetRequiredService<SshConnectionService>();
|
||||
var (success, message) = await ssh.TestConnectionAsync(SelectedHost);
|
||||
|
||||
// Update DB
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
var host = await db.SshHosts.FindAsync(SelectedHost.Id);
|
||||
if (host != null)
|
||||
{
|
||||
host.LastTestedAt = DateTime.UtcNow;
|
||||
host.LastTestSuccess = success;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
StatusMessage = success
|
||||
? $"✓ {SelectedHost.Label}: {message}"
|
||||
: $"✗ {SelectedHost.Label}: {message}";
|
||||
|
||||
await LoadHostsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Connection test error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
186
OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs
Normal file
186
OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
using OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for listing, viewing, and managing CMS instances.
|
||||
/// </summary>
|
||||
public partial class InstancesViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
[ObservableProperty] private ObservableCollection<CmsInstance> _instances = new();
|
||||
[ObservableProperty] private CmsInstance? _selectedInstance;
|
||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private string _filterText = string.Empty;
|
||||
[ObservableProperty] private ObservableCollection<StackInfo> _remoteStacks = new();
|
||||
[ObservableProperty] private ObservableCollection<ServiceInfo> _selectedServices = new();
|
||||
|
||||
// Available SSH hosts for the dropdown
|
||||
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
|
||||
[ObservableProperty] private SshHost? _selectedSshHost;
|
||||
|
||||
public InstancesViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
_ = InitAsync();
|
||||
}
|
||||
|
||||
private async Task InitAsync()
|
||||
{
|
||||
await LoadHostsAsync();
|
||||
await LoadInstancesAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadHostsAsync()
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
|
||||
AvailableHosts = new ObservableCollection<SshHost>(hosts);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadInstancesAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
|
||||
var query = db.CmsInstances.Include(i => i.SshHost).AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(FilterText))
|
||||
{
|
||||
query = query.Where(i =>
|
||||
i.CustomerName.Contains(FilterText) ||
|
||||
i.StackName.Contains(FilterText));
|
||||
}
|
||||
|
||||
var items = await query.OrderByDescending(i => i.CreatedAt).ToListAsync();
|
||||
Instances = new ObservableCollection<CmsInstance>(items);
|
||||
StatusMessage = $"Loaded {items.Count} instance(s).";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RefreshRemoteStacksAsync()
|
||||
{
|
||||
if (SelectedSshHost == null)
|
||||
{
|
||||
StatusMessage = "Select an SSH host first.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Listing stacks on {SelectedSshHost.Label}...";
|
||||
try
|
||||
{
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(SelectedSshHost);
|
||||
|
||||
var stacks = await dockerCli.ListStacksAsync();
|
||||
RemoteStacks = new ObservableCollection<StackInfo>(stacks);
|
||||
StatusMessage = $"Found {stacks.Count} stack(s) on {SelectedSshHost.Label}.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error listing stacks: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task InspectInstanceAsync()
|
||||
{
|
||||
if (SelectedInstance == null) return;
|
||||
if (SelectedSshHost == null && SelectedInstance.SshHost == null)
|
||||
{
|
||||
StatusMessage = "No SSH host associated with this instance.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var host = SelectedInstance.SshHost ?? SelectedSshHost!;
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(host);
|
||||
|
||||
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
|
||||
SelectedServices = new ObservableCollection<ServiceInfo>(services);
|
||||
StatusMessage = $"Found {services.Count} service(s) in stack '{SelectedInstance.StackName}'.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error inspecting: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeleteInstanceAsync()
|
||||
{
|
||||
if (SelectedInstance == null) return;
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Deleting {SelectedInstance.StackName}...";
|
||||
try
|
||||
{
|
||||
var host = SelectedInstance.SshHost ?? SelectedSshHost;
|
||||
if (host == null)
|
||||
{
|
||||
StatusMessage = "No SSH host available for deletion.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Wire up SSH-based docker services
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(host);
|
||||
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
|
||||
dockerSecrets.SetHost(host);
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
|
||||
|
||||
var result = await instanceSvc.DeleteInstanceAsync(SelectedInstance.Id);
|
||||
StatusMessage = result.Success
|
||||
? $"Instance '{SelectedInstance.StackName}' deleted."
|
||||
: $"Delete failed: {result.ErrorMessage}";
|
||||
|
||||
await LoadInstancesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error deleting: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
56
OTSSignsOrchestrator.Desktop/ViewModels/LogsViewModel.cs
Normal file
56
OTSSignsOrchestrator.Desktop/ViewModels/LogsViewModel.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for viewing operation logs.
|
||||
/// </summary>
|
||||
public partial class LogsViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
[ObservableProperty] private ObservableCollection<OperationLog> _logs = new();
|
||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private int _maxEntries = 100;
|
||||
|
||||
public LogsViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
_ = LoadLogsAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadLogsAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
|
||||
var items = await db.OperationLogs
|
||||
.Include(l => l.Instance)
|
||||
.OrderByDescending(l => l.Timestamp)
|
||||
.Take(MaxEntries)
|
||||
.ToListAsync();
|
||||
|
||||
Logs = new ObservableCollection<OperationLog>(items);
|
||||
StatusMessage = $"Showing {items.Count} log entries.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error loading logs: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableObject? _currentView;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _selectedNav = "Hosts";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusMessage = "Ready";
|
||||
|
||||
public ObservableCollection<string> NavItems { get; } = new()
|
||||
{
|
||||
"Hosts",
|
||||
"Instances",
|
||||
"Create Instance",
|
||||
"Secrets",
|
||||
"Settings",
|
||||
"Logs"
|
||||
};
|
||||
|
||||
public MainWindowViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
NavigateTo("Hosts");
|
||||
}
|
||||
|
||||
partial void OnSelectedNavChanged(string value)
|
||||
{
|
||||
NavigateTo(value);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void NavigateTo(string page)
|
||||
{
|
||||
CurrentView = page switch
|
||||
{
|
||||
"Hosts" => (ObservableObject)_services.GetService(typeof(HostsViewModel))!,
|
||||
"Instances" => (ObservableObject)_services.GetService(typeof(InstancesViewModel))!,
|
||||
"Create Instance" => (ObservableObject)_services.GetService(typeof(CreateInstanceViewModel))!,
|
||||
"Secrets" => (ObservableObject)_services.GetService(typeof(SecretsViewModel))!,
|
||||
"Settings" => (ObservableObject)_services.GetService(typeof(SettingsViewModel))!,
|
||||
"Logs" => (ObservableObject)_services.GetService(typeof(LogsViewModel))!,
|
||||
_ => CurrentView
|
||||
};
|
||||
}
|
||||
|
||||
public void SetStatus(string message)
|
||||
{
|
||||
StatusMessage = message;
|
||||
}
|
||||
}
|
||||
68
OTSSignsOrchestrator.Desktop/ViewModels/SecretsViewModel.cs
Normal file
68
OTSSignsOrchestrator.Desktop/ViewModels/SecretsViewModel.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
using OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for viewing and managing Docker Swarm secrets on a remote host.
|
||||
/// </summary>
|
||||
public partial class SecretsViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
[ObservableProperty] private ObservableCollection<SecretListItem> _secrets = new();
|
||||
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
|
||||
[ObservableProperty] private SshHost? _selectedSshHost;
|
||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
|
||||
public SecretsViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
_ = LoadHostsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadHostsAsync()
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
|
||||
AvailableHosts = new ObservableCollection<SshHost>(hosts);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadSecretsAsync()
|
||||
{
|
||||
if (SelectedSshHost == null)
|
||||
{
|
||||
StatusMessage = "Select an SSH host first.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var secretsSvc = _services.GetRequiredService<SshDockerSecretsService>();
|
||||
secretsSvc.SetHost(SelectedSshHost);
|
||||
|
||||
var items = await secretsSvc.ListSecretsAsync();
|
||||
Secrets = new ObservableCollection<SecretListItem>(items);
|
||||
StatusMessage = $"Found {items.Count} secret(s) on {SelectedSshHost.Label}.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
248
OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs
Normal file
248
OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs
Normal file
@@ -0,0 +1,248 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for the Settings page — manages Git, MySQL, SMTP, Pangolin, CIFS,
|
||||
/// and Instance Defaults configuration, persisted via SettingsService.
|
||||
/// </summary>
|
||||
public partial class SettingsViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
|
||||
// ── Git ──────────────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _gitRepoUrl = string.Empty;
|
||||
[ObservableProperty] private string _gitRepoPat = string.Empty;
|
||||
|
||||
// ── MySQL Admin ─────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _mySqlHost = string.Empty;
|
||||
[ObservableProperty] private string _mySqlPort = "3306";
|
||||
[ObservableProperty] private string _mySqlAdminUser = string.Empty;
|
||||
[ObservableProperty] private string _mySqlAdminPassword = string.Empty;
|
||||
|
||||
// ── SMTP ────────────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _smtpServer = string.Empty;
|
||||
[ObservableProperty] private string _smtpUsername = string.Empty;
|
||||
[ObservableProperty] private string _smtpPassword = string.Empty;
|
||||
[ObservableProperty] private bool _smtpUseTls = true;
|
||||
[ObservableProperty] private bool _smtpUseStartTls = true;
|
||||
[ObservableProperty] private string _smtpRewriteDomain = string.Empty;
|
||||
[ObservableProperty] private string _smtpHostname = string.Empty;
|
||||
[ObservableProperty] private string _smtpFromLineOverride = "NO";
|
||||
|
||||
// ── Pangolin ────────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _pangolinEndpoint = "https://app.pangolin.net";
|
||||
|
||||
// ── CIFS ────────────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _cifsServer = string.Empty;
|
||||
[ObservableProperty] private string _cifsShareBasePath = string.Empty;
|
||||
[ObservableProperty] private string _cifsUsername = string.Empty;
|
||||
[ObservableProperty] private string _cifsPassword = string.Empty;
|
||||
[ObservableProperty] private string _cifsOptions = "file_mode=0777,dir_mode=0777";
|
||||
|
||||
// ── Instance Defaults ───────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _defaultCmsImage = "ghcr.io/xibosignage/xibo-cms:release-4.2.3";
|
||||
[ObservableProperty] private string _defaultNewtImage = "fosrl/newt";
|
||||
[ObservableProperty] private string _defaultMemcachedImage = "memcached:alpine";
|
||||
[ObservableProperty] private string _defaultQuickChartImage = "ianw/quickchart";
|
||||
[ObservableProperty] private string _defaultCmsServerNameTemplate = "{abbrev}.ots-signs.com";
|
||||
[ObservableProperty] private string _defaultThemeHostPath = "/cms/{abbrev}-cms-theme-custom";
|
||||
[ObservableProperty] private string _defaultMySqlDbTemplate = "{abbrev}_cms_db";
|
||||
[ObservableProperty] private string _defaultMySqlUserTemplate = "{abbrev}_cms";
|
||||
[ObservableProperty] private string _defaultPhpPostMaxSize = "10G";
|
||||
[ObservableProperty] private string _defaultPhpUploadMaxFilesize = "10G";
|
||||
[ObservableProperty] private string _defaultPhpMaxExecutionTime = "600";
|
||||
|
||||
public SettingsViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
_ = LoadAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
|
||||
// Git
|
||||
GitRepoUrl = await svc.GetAsync(SettingsService.GitRepoUrl, string.Empty);
|
||||
GitRepoPat = await svc.GetAsync(SettingsService.GitRepoPat, string.Empty);
|
||||
|
||||
// MySQL
|
||||
MySqlHost = await svc.GetAsync(SettingsService.MySqlHost, string.Empty);
|
||||
MySqlPort = await svc.GetAsync(SettingsService.MySqlPort, "3306");
|
||||
MySqlAdminUser = await svc.GetAsync(SettingsService.MySqlAdminUser, string.Empty);
|
||||
MySqlAdminPassword = await svc.GetAsync(SettingsService.MySqlAdminPassword, string.Empty);
|
||||
|
||||
// SMTP
|
||||
SmtpServer = await svc.GetAsync(SettingsService.SmtpServer, string.Empty);
|
||||
SmtpUsername = await svc.GetAsync(SettingsService.SmtpUsername, string.Empty);
|
||||
SmtpPassword = await svc.GetAsync(SettingsService.SmtpPassword, string.Empty);
|
||||
SmtpUseTls = (await svc.GetAsync(SettingsService.SmtpUseTls, "YES")) == "YES";
|
||||
SmtpUseStartTls = (await svc.GetAsync(SettingsService.SmtpUseStartTls, "YES")) == "YES";
|
||||
SmtpRewriteDomain = await svc.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty);
|
||||
SmtpHostname = await svc.GetAsync(SettingsService.SmtpHostname, string.Empty);
|
||||
SmtpFromLineOverride = await svc.GetAsync(SettingsService.SmtpFromLineOverride, "NO");
|
||||
|
||||
// Pangolin
|
||||
PangolinEndpoint = await svc.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
||||
|
||||
// CIFS
|
||||
CifsServer = await svc.GetAsync(SettingsService.CifsServer, string.Empty);
|
||||
CifsShareBasePath = await svc.GetAsync(SettingsService.CifsShareBasePath, string.Empty);
|
||||
CifsUsername = await svc.GetAsync(SettingsService.CifsUsername, string.Empty);
|
||||
CifsPassword = await svc.GetAsync(SettingsService.CifsPassword, string.Empty);
|
||||
CifsOptions = await svc.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
|
||||
|
||||
// Instance Defaults
|
||||
DefaultCmsImage = await svc.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
|
||||
DefaultNewtImage = await svc.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
|
||||
DefaultMemcachedImage = await svc.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
|
||||
DefaultQuickChartImage = await svc.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
|
||||
DefaultCmsServerNameTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com");
|
||||
DefaultThemeHostPath = await svc.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/{abbrev}-cms-theme-custom");
|
||||
DefaultMySqlDbTemplate = await svc.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db");
|
||||
DefaultMySqlUserTemplate = await svc.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms");
|
||||
DefaultPhpPostMaxSize = await svc.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
|
||||
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||
DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
||||
|
||||
StatusMessage = "Settings loaded.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error loading settings: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
|
||||
var settings = new List<(string Key, string? Value, string Category, bool IsSensitive)>
|
||||
{
|
||||
// Git
|
||||
(SettingsService.GitRepoUrl, NullIfEmpty(GitRepoUrl), SettingsService.CatGit, false),
|
||||
(SettingsService.GitRepoPat, NullIfEmpty(GitRepoPat), SettingsService.CatGit, true),
|
||||
|
||||
// MySQL
|
||||
(SettingsService.MySqlHost, NullIfEmpty(MySqlHost), SettingsService.CatMySql, false),
|
||||
(SettingsService.MySqlPort, MySqlPort, SettingsService.CatMySql, false),
|
||||
(SettingsService.MySqlAdminUser, NullIfEmpty(MySqlAdminUser), SettingsService.CatMySql, false),
|
||||
(SettingsService.MySqlAdminPassword, NullIfEmpty(MySqlAdminPassword), SettingsService.CatMySql, true),
|
||||
|
||||
// SMTP
|
||||
(SettingsService.SmtpServer, NullIfEmpty(SmtpServer), SettingsService.CatSmtp, false),
|
||||
(SettingsService.SmtpUsername, NullIfEmpty(SmtpUsername), SettingsService.CatSmtp, false),
|
||||
(SettingsService.SmtpPassword, NullIfEmpty(SmtpPassword), SettingsService.CatSmtp, true),
|
||||
(SettingsService.SmtpUseTls, SmtpUseTls ? "YES" : "NO", SettingsService.CatSmtp, false),
|
||||
(SettingsService.SmtpUseStartTls, SmtpUseStartTls ? "YES" : "NO", SettingsService.CatSmtp, false),
|
||||
(SettingsService.SmtpRewriteDomain, NullIfEmpty(SmtpRewriteDomain), SettingsService.CatSmtp, false),
|
||||
(SettingsService.SmtpHostname, NullIfEmpty(SmtpHostname), SettingsService.CatSmtp, false),
|
||||
(SettingsService.SmtpFromLineOverride, SmtpFromLineOverride, SettingsService.CatSmtp, false),
|
||||
|
||||
// Pangolin
|
||||
(SettingsService.PangolinEndpoint, NullIfEmpty(PangolinEndpoint), SettingsService.CatPangolin, false),
|
||||
|
||||
// CIFS
|
||||
(SettingsService.CifsServer, NullIfEmpty(CifsServer), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsShareBasePath, NullIfEmpty(CifsShareBasePath), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsUsername, NullIfEmpty(CifsUsername), SettingsService.CatCifs, false),
|
||||
(SettingsService.CifsPassword, NullIfEmpty(CifsPassword), SettingsService.CatCifs, true),
|
||||
(SettingsService.CifsOptions, NullIfEmpty(CifsOptions), SettingsService.CatCifs, false),
|
||||
|
||||
// Instance Defaults
|
||||
(SettingsService.DefaultCmsImage, DefaultCmsImage, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultNewtImage, DefaultNewtImage, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultMemcachedImage, DefaultMemcachedImage, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultQuickChartImage, DefaultQuickChartImage, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultCmsServerNameTemplate, DefaultCmsServerNameTemplate, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultThemeHostPath, DefaultThemeHostPath, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultMySqlDbTemplate, DefaultMySqlDbTemplate, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultMySqlUserTemplate, DefaultMySqlUserTemplate, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultPhpPostMaxSize, DefaultPhpPostMaxSize, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultPhpUploadMaxFilesize, DefaultPhpUploadMaxFilesize, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultPhpMaxExecutionTime, DefaultPhpMaxExecutionTime, SettingsService.CatDefaults, false),
|
||||
};
|
||||
|
||||
await svc.SaveManyAsync(settings);
|
||||
StatusMessage = "Settings saved successfully.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error saving settings: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task TestMySqlConnectionAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(MySqlHost) || string.IsNullOrWhiteSpace(MySqlAdminUser))
|
||||
{
|
||||
StatusMessage = "MySQL Host and Admin User are required for connection test.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = "Testing MySQL connection via SSH...";
|
||||
try
|
||||
{
|
||||
// The test runs a mysql --version or a simple SELECT 1 query via SSH
|
||||
// We need an SshHost to route through — use the first available
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OTSSignsOrchestrator.Core.Data.XiboContext>();
|
||||
var host = await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions
|
||||
.FirstOrDefaultAsync(db.SshHosts);
|
||||
|
||||
if (host == null)
|
||||
{
|
||||
StatusMessage = "No SSH hosts configured. Add one in the Hosts page first.";
|
||||
return;
|
||||
}
|
||||
|
||||
var ssh = _services.GetRequiredService<Services.SshConnectionService>();
|
||||
var port = int.TryParse(MySqlPort, out var p) ? p : 3306;
|
||||
var cmd = $"mysql -h {MySqlHost} -P {port} -u {MySqlAdminUser} -p'{MySqlAdminPassword.Replace("'", "'\\''")}' -e 'SELECT 1' 2>&1";
|
||||
var (exitCode, stdout, stderr) = await ssh.RunCommandAsync(host, cmd);
|
||||
|
||||
StatusMessage = exitCode == 0
|
||||
? $"MySQL connection successful via {host.Label}."
|
||||
: $"MySQL connection failed: {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr).Trim()}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"MySQL test error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
146
OTSSignsOrchestrator.Desktop/Views/CreateInstanceView.axaml
Normal file
146
OTSSignsOrchestrator.Desktop/Views/CreateInstanceView.axaml
Normal file
@@ -0,0 +1,146 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.CreateInstanceView"
|
||||
x:DataType="vm:CreateInstanceViewModel">
|
||||
|
||||
<ScrollViewer>
|
||||
<Grid ColumnDefinitions="1*,16,1*" Margin="16,12">
|
||||
|
||||
<!-- ══ LEFT COLUMN — inputs ══ -->
|
||||
<StackPanel Grid.Column="0" Spacing="8">
|
||||
<TextBlock Text="Create New Instance" FontSize="20" FontWeight="Bold" Margin="0,0,0,12" />
|
||||
|
||||
<!-- SSH Host -->
|
||||
<TextBlock Text="Deploy to SSH Host" FontSize="12" />
|
||||
<ComboBox ItemsSource="{Binding AvailableHosts}"
|
||||
SelectedItem="{Binding SelectedSshHost}"
|
||||
PlaceholderText="Select SSH Host..."
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<Separator Margin="0,8" />
|
||||
|
||||
<!-- Core fields -->
|
||||
<TextBlock Text="Customer Name" FontSize="12" />
|
||||
<TextBox Text="{Binding CustomerName}" Watermark="e.g. Acme Corp" />
|
||||
|
||||
<TextBlock Text="Abbreviation (3 letters)" FontSize="12" />
|
||||
<TextBox Text="{Binding CustomerAbbrev}"
|
||||
Watermark="e.g. acm"
|
||||
MaxLength="3" />
|
||||
|
||||
<Separator Margin="0,12" />
|
||||
|
||||
<!-- Pangolin / Newt (optional) -->
|
||||
<Expander Header="Pangolin / Newt credentials (optional)">
|
||||
<StackPanel Spacing="8" Margin="0,8,0,0">
|
||||
<TextBlock Text="Newt ID" FontSize="12" />
|
||||
<TextBox Text="{Binding NewtId}" Watermark="(from Pangolin dashboard)" />
|
||||
|
||||
<TextBlock Text="Newt Secret" FontSize="12" />
|
||||
<TextBox Text="{Binding NewtSecret}" PasswordChar="●" Watermark="(from Pangolin dashboard)" />
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
|
||||
<!-- SMB / CIFS credentials (per-instance, defaults from global settings) -->
|
||||
<Expander Header="SMB / CIFS credentials">
|
||||
<StackPanel Spacing="8" Margin="0,8,0,0">
|
||||
<TextBlock Text="CIFS Server" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsServer}" Watermark="e.g. 192.168.1.100" />
|
||||
|
||||
<TextBlock Text="Share Base Path" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsShareBasePath}" Watermark="e.g. /share/cms" />
|
||||
|
||||
<TextBlock Text="Username" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsUsername}" Watermark="SMB username" />
|
||||
|
||||
<TextBlock Text="Password" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsPassword}" PasswordChar="●" Watermark="SMB password" />
|
||||
|
||||
<TextBlock Text="Extra Options" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsExtraOptions}" Watermark="file_mode=0777,dir_mode=0777" />
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
|
||||
<Separator Margin="0,12" />
|
||||
|
||||
<!-- Deploy button + progress -->
|
||||
<Button Content="Deploy Instance"
|
||||
Command="{Binding DeployCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Padding="12,8" FontWeight="SemiBold" />
|
||||
|
||||
<!-- Progress bar -->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0"
|
||||
IsVisible="{Binding IsBusy}">
|
||||
<ProgressBar Value="{Binding ProgressPercent}"
|
||||
Maximum="100" Height="6"
|
||||
CornerRadius="3" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding ProgressStep}"
|
||||
FontSize="11" Foreground="#a6adc8"
|
||||
Margin="8,0,0,0" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="{Binding StatusMessage}" FontSize="12" Foreground="#a6adc8"
|
||||
Margin="0,4,0,0" TextWrapping="Wrap" />
|
||||
|
||||
<!-- Deploy output -->
|
||||
<TextBox Text="{Binding DeployOutput}" IsReadOnly="True"
|
||||
AcceptsReturn="True" MaxHeight="260"
|
||||
FontFamily="Cascadia Mono, Consolas, monospace" FontSize="11"
|
||||
IsVisible="{Binding DeployOutput.Length}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- ══ RIGHT COLUMN — live resource preview ══ -->
|
||||
<Border Grid.Column="2"
|
||||
Background="#1e1e2e"
|
||||
CornerRadius="8"
|
||||
Padding="16,14"
|
||||
VerticalAlignment="Top">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Resource Preview" FontSize="14" FontWeight="SemiBold"
|
||||
Foreground="#cdd6f4" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Text="Stack" FontSize="11" Foreground="#6c7086" />
|
||||
<TextBlock Text="{Binding PreviewStackName}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#89b4fa" Margin="0,0,0,6" />
|
||||
|
||||
<TextBlock Text="Services" FontSize="11" Foreground="#6c7086" />
|
||||
<TextBlock Text="{Binding PreviewServiceWeb}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" />
|
||||
<TextBlock Text="{Binding PreviewServiceCache}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" />
|
||||
<TextBlock Text="{Binding PreviewServiceChart}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" />
|
||||
<TextBlock Text="{Binding PreviewServiceNewt}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" Margin="0,0,0,6" />
|
||||
|
||||
<TextBlock Text="Network" FontSize="11" Foreground="#6c7086" />
|
||||
<TextBlock Text="{Binding PreviewNetwork}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#94e2d5" Margin="0,0,0,6" />
|
||||
|
||||
<TextBlock Text="CIFS Volumes" FontSize="11" Foreground="#6c7086" />
|
||||
<TextBlock Text="{Binding PreviewVolCustom}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" />
|
||||
<TextBlock Text="{Binding PreviewVolBackup}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" />
|
||||
<TextBlock Text="{Binding PreviewVolLibrary}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" />
|
||||
<TextBlock Text="{Binding PreviewVolUserscripts}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" />
|
||||
<TextBlock Text="{Binding PreviewVolCaCerts}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" Margin="0,0,0,6" />
|
||||
|
||||
<TextBlock Text="Docker Secret" FontSize="11" Foreground="#6c7086" />
|
||||
<TextBlock Text="{Binding PreviewSecret}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#fab387" Margin="0,0,0,6" />
|
||||
|
||||
<TextBlock Text="MySQL Database" FontSize="11" Foreground="#6c7086" />
|
||||
<TextBlock Text="{Binding PreviewMySqlDb}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#cba6f7" />
|
||||
<TextBlock Text="{Binding PreviewMySqlUser}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#cba6f7" Margin="0,0,0,6" />
|
||||
|
||||
<TextBlock Text="CMS URL" FontSize="11" Foreground="#6c7086" />
|
||||
<TextBlock Text="{Binding PreviewCmsUrl}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#89dceb" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class CreateInstanceView : UserControl
|
||||
{
|
||||
public CreateInstanceView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
83
OTSSignsOrchestrator.Desktop/Views/HostsView.axaml
Normal file
83
OTSSignsOrchestrator.Desktop/Views/HostsView.axaml
Normal file
@@ -0,0 +1,83 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.HostsView"
|
||||
x:DataType="vm:HostsViewModel">
|
||||
|
||||
<DockPanel>
|
||||
<!-- Toolbar -->
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,12">
|
||||
<Button Content="Add Host" Command="{Binding NewHostCommand}" />
|
||||
<Button Content="Edit" Command="{Binding EditSelectedHostCommand}" />
|
||||
<Button Content="Test Connection" Command="{Binding TestConnectionCommand}" />
|
||||
<Button Content="Delete" Command="{Binding DeleteHostCommand}" />
|
||||
<Button Content="Refresh" Command="{Binding LoadHostsCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Status -->
|
||||
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Margin="0,8,0,0"
|
||||
FontSize="12" Foreground="#a6adc8" />
|
||||
|
||||
<!-- Edit panel (shown when editing) -->
|
||||
<Border DockPanel.Dock="Right" Width="350" IsVisible="{Binding IsEditing}"
|
||||
Background="#1e1e2e" CornerRadius="8" Padding="16" Margin="12,0,0,0">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="SSH Host" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Text="Label" FontSize="12" />
|
||||
<TextBox Text="{Binding EditLabel}" Watermark="e.g. Production Swarm" />
|
||||
|
||||
<TextBlock Text="Host" FontSize="12" />
|
||||
<TextBox Text="{Binding EditHost}" Watermark="hostname or IP" />
|
||||
|
||||
<TextBlock Text="Port" FontSize="12" />
|
||||
<NumericUpDown Value="{Binding EditPort}" Minimum="1" Maximum="65535" />
|
||||
|
||||
<TextBlock Text="Username" FontSize="12" />
|
||||
<TextBox Text="{Binding EditUsername}" Watermark="ssh username" />
|
||||
|
||||
<CheckBox Content="Use Key Authentication" IsChecked="{Binding EditUseKeyAuth}" />
|
||||
|
||||
<TextBlock Text="Private Key Path" FontSize="12"
|
||||
IsVisible="{Binding EditUseKeyAuth}" />
|
||||
<TextBox Text="{Binding EditPrivateKeyPath}" Watermark="~/.ssh/id_rsa"
|
||||
IsVisible="{Binding EditUseKeyAuth}" />
|
||||
|
||||
<TextBlock Text="Key Passphrase (optional)" FontSize="12"
|
||||
IsVisible="{Binding EditUseKeyAuth}" />
|
||||
<TextBox Text="{Binding EditKeyPassphrase}" PasswordChar="●"
|
||||
IsVisible="{Binding EditUseKeyAuth}" />
|
||||
|
||||
<TextBlock Text="Password (if not using key)" FontSize="12"
|
||||
IsVisible="{Binding !EditUseKeyAuth}" />
|
||||
<TextBox Text="{Binding EditPassword}" PasswordChar="●"
|
||||
IsVisible="{Binding !EditUseKeyAuth}" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,12,0,0">
|
||||
<Button Content="Save" Command="{Binding SaveHostCommand}" />
|
||||
<Button Content="Cancel" Command="{Binding CancelEditCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<!-- Host list -->
|
||||
<DataGrid ItemsSource="{Binding Hosts}"
|
||||
SelectedItem="{Binding SelectedHost}"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal"
|
||||
CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Label" Binding="{Binding Label}" Width="150" />
|
||||
<DataGridTextColumn Header="Host" Binding="{Binding Host}" Width="200" />
|
||||
<DataGridTextColumn Header="Port" Binding="{Binding Port}" Width="60" />
|
||||
<DataGridTextColumn Header="User" Binding="{Binding Username}" Width="100" />
|
||||
<DataGridCheckBoxColumn Header="Key Auth" Binding="{Binding UseKeyAuth}" Width="70" />
|
||||
<DataGridTextColumn Header="Last Tested" Binding="{Binding LastTestedAt, StringFormat='{}{0:g}'}" Width="150" />
|
||||
<DataGridCheckBoxColumn Header="OK" Binding="{Binding LastTestSuccess}" Width="50" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
11
OTSSignsOrchestrator.Desktop/Views/HostsView.axaml.cs
Normal file
11
OTSSignsOrchestrator.Desktop/Views/HostsView.axaml.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class HostsView : UserControl
|
||||
{
|
||||
public HostsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
98
OTSSignsOrchestrator.Desktop/Views/InstancesView.axaml
Normal file
98
OTSSignsOrchestrator.Desktop/Views/InstancesView.axaml
Normal file
@@ -0,0 +1,98 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.InstancesView"
|
||||
x:DataType="vm:InstancesViewModel">
|
||||
|
||||
<DockPanel>
|
||||
<!-- Toolbar -->
|
||||
<StackPanel DockPanel.Dock="Top" Spacing="8" Margin="0,0,0,12">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<ComboBox ItemsSource="{Binding AvailableHosts}"
|
||||
SelectedItem="{Binding SelectedSshHost}"
|
||||
PlaceholderText="Select SSH Host..."
|
||||
Width="250">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<Button Content="List Remote Stacks" Command="{Binding RefreshRemoteStacksCommand}" />
|
||||
<Button Content="Inspect" Command="{Binding InspectInstanceCommand}" />
|
||||
<Button Content="Delete" Command="{Binding DeleteInstanceCommand}" />
|
||||
<Button Content="Refresh" Command="{Binding LoadInstancesCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox Text="{Binding FilterText}" Watermark="Filter by name..." Width="250" />
|
||||
<Button Content="Search" Command="{Binding LoadInstancesCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Status -->
|
||||
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Margin="0,8,0,0"
|
||||
FontSize="12" Foreground="#a6adc8" />
|
||||
|
||||
<!-- Services panel (shown when inspecting) -->
|
||||
<Border DockPanel.Dock="Right" Width="350"
|
||||
IsVisible="{Binding SelectedServices.Count}"
|
||||
Background="#1e1e2e" CornerRadius="8" Padding="12" Margin="12,0,0,0">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Stack Services" FontWeight="SemiBold" Margin="0,0,0,8" />
|
||||
<ItemsControl ItemsSource="{Binding SelectedServices}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#313244" CornerRadius="4" Padding="8" Margin="0,2">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding Image}" FontSize="11" Foreground="#a6adc8" />
|
||||
<TextBlock Text="{Binding Replicas, StringFormat='Replicas: {0}'}"
|
||||
FontSize="11" Foreground="#a6adc8" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Remote stacks panel -->
|
||||
<Border DockPanel.Dock="Bottom" MaxHeight="200"
|
||||
IsVisible="{Binding RemoteStacks.Count}"
|
||||
Background="#1e1e2e" CornerRadius="8" Padding="12" Margin="0,8,0,0">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Remote Stacks" FontWeight="SemiBold" />
|
||||
<ItemsControl ItemsSource="{Binding RemoteStacks}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock>
|
||||
<Run Text="{Binding Name}" FontWeight="SemiBold" />
|
||||
<Run Text="{Binding ServiceCount, StringFormat=' ({0} services)'}"
|
||||
Foreground="#a6adc8" />
|
||||
</TextBlock>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Instance list -->
|
||||
<DataGrid ItemsSource="{Binding Instances}"
|
||||
SelectedItem="{Binding SelectedInstance}"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal"
|
||||
CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Stack" Binding="{Binding StackName}" Width="120" />
|
||||
<DataGridTextColumn Header="Customer" Binding="{Binding CustomerName}" Width="120" />
|
||||
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="80" />
|
||||
<DataGridTextColumn Header="Server" Binding="{Binding CmsServerName}" Width="150" />
|
||||
<DataGridTextColumn Header="Port" Binding="{Binding HostHttpPort}" Width="60" />
|
||||
<DataGridTextColumn Header="Host" Binding="{Binding SshHost.Label}" Width="120" />
|
||||
<DataGridTextColumn Header="Created" Binding="{Binding CreatedAt, StringFormat='{}{0:g}'}" Width="140" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
11
OTSSignsOrchestrator.Desktop/Views/InstancesView.axaml.cs
Normal file
11
OTSSignsOrchestrator.Desktop/Views/InstancesView.axaml.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class InstancesView : UserControl
|
||||
{
|
||||
public InstancesView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
29
OTSSignsOrchestrator.Desktop/Views/LogsView.axaml
Normal file
29
OTSSignsOrchestrator.Desktop/Views/LogsView.axaml
Normal file
@@ -0,0 +1,29 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.LogsView"
|
||||
x:DataType="vm:LogsViewModel">
|
||||
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,12">
|
||||
<Button Content="Refresh" Command="{Binding LoadLogsCommand}" />
|
||||
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center"
|
||||
FontSize="12" Foreground="#a6adc8" Margin="8,0,0,0" />
|
||||
</StackPanel>
|
||||
|
||||
<DataGrid ItemsSource="{Binding Logs}"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal"
|
||||
CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Time" Binding="{Binding Timestamp, StringFormat='{}{0:g}'}" Width="150" />
|
||||
<DataGridTextColumn Header="Operation" Binding="{Binding Operation}" Width="100" />
|
||||
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="80" />
|
||||
<DataGridTextColumn Header="Instance" Binding="{Binding Instance.StackName}" Width="120" />
|
||||
<DataGridTextColumn Header="Message" Binding="{Binding Message}" Width="*" />
|
||||
<DataGridTextColumn Header="Duration" Binding="{Binding DurationMs, StringFormat='{}{0}ms'}" Width="80" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
11
OTSSignsOrchestrator.Desktop/Views/LogsView.axaml.cs
Normal file
11
OTSSignsOrchestrator.Desktop/Views/LogsView.axaml.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class LogsView : UserControl
|
||||
{
|
||||
public LogsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
62
OTSSignsOrchestrator.Desktop/Views/MainWindow.axaml
Normal file
62
OTSSignsOrchestrator.Desktop/Views/MainWindow.axaml
Normal file
@@ -0,0 +1,62 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
xmlns:views="using:OTSSignsOrchestrator.Desktop.Views"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Title="OTS Signs Orchestrator"
|
||||
Width="1200" Height="800"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<Window.DataTemplates>
|
||||
<DataTemplate DataType="vm:HostsViewModel">
|
||||
<views:HostsView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vm:InstancesViewModel">
|
||||
<views:InstancesView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vm:SecretsViewModel">
|
||||
<views:SecretsView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vm:LogsViewModel">
|
||||
<views:LogsView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vm:CreateInstanceViewModel">
|
||||
<views:CreateInstanceView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vm:SettingsViewModel">
|
||||
<views:SettingsView />
|
||||
</DataTemplate>
|
||||
</Window.DataTemplates>
|
||||
|
||||
<DockPanel>
|
||||
<!-- Status bar -->
|
||||
<Border DockPanel.Dock="Bottom" Background="#1e1e2e" Padding="8,4">
|
||||
<TextBlock Text="{Binding StatusMessage}" FontSize="12" Foreground="#a0a0a0" />
|
||||
</Border>
|
||||
|
||||
<!-- Left nav -->
|
||||
<Border DockPanel.Dock="Left" Width="180" Background="#181825" Padding="0,8">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="OTS Signs" FontSize="18" FontWeight="Bold" Foreground="#cdd6f4"
|
||||
Margin="16,8,16,16" />
|
||||
|
||||
<ListBox ItemsSource="{Binding NavItems}"
|
||||
SelectedItem="{Binding SelectedNav}"
|
||||
Background="Transparent"
|
||||
Margin="4,0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding}" Padding="12,8" FontSize="14" />
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Main content -->
|
||||
<Border Padding="16">
|
||||
<ContentControl Content="{Binding CurrentView}" />
|
||||
</Border>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
11
OTSSignsOrchestrator.Desktop/Views/MainWindow.axaml.cs
Normal file
11
OTSSignsOrchestrator.Desktop/Views/MainWindow.axaml.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
37
OTSSignsOrchestrator.Desktop/Views/SecretsView.axaml
Normal file
37
OTSSignsOrchestrator.Desktop/Views/SecretsView.axaml
Normal file
@@ -0,0 +1,37 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.SecretsView"
|
||||
x:DataType="vm:SecretsViewModel">
|
||||
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,12">
|
||||
<ComboBox ItemsSource="{Binding AvailableHosts}"
|
||||
SelectedItem="{Binding SelectedSshHost}"
|
||||
PlaceholderText="Select SSH Host..."
|
||||
Width="250">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<Button Content="Load Secrets" Command="{Binding LoadSecretsCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Margin="0,8,0,0"
|
||||
FontSize="12" Foreground="#a6adc8" />
|
||||
|
||||
<DataGrid ItemsSource="{Binding Secrets}"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal"
|
||||
CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="ID" Binding="{Binding Id}" Width="200" />
|
||||
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="250" />
|
||||
<DataGridTextColumn Header="Created" Binding="{Binding CreatedAt, StringFormat='{}{0:g}'}" Width="180" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
11
OTSSignsOrchestrator.Desktop/Views/SecretsView.axaml.cs
Normal file
11
OTSSignsOrchestrator.Desktop/Views/SecretsView.axaml.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class SecretsView : UserControl
|
||||
{
|
||||
public SecretsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
205
OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml
Normal file
205
OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml
Normal file
@@ -0,0 +1,205 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.SettingsView"
|
||||
x:DataType="vm:SettingsViewModel">
|
||||
|
||||
<DockPanel>
|
||||
<!-- Top toolbar -->
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,12">
|
||||
<Button Content="Save All Settings"
|
||||
Command="{Binding SaveCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
FontWeight="SemiBold" Padding="16,8" />
|
||||
<Button Content="Reload" Command="{Binding LoadCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center"
|
||||
FontSize="12" Foreground="#a6adc8" Margin="12,0,0,0" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Scrollable settings content -->
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="16" Margin="0,0,16,16" MaxWidth="800">
|
||||
|
||||
<!-- ═══ Git Repository ═══ -->
|
||||
<Border Background="#1e1e2e" CornerRadius="8" Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Git Repository" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#89b4fa" Margin="0,0,0,4" />
|
||||
<TextBlock Text="Template repository cloned for each new instance."
|
||||
FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" />
|
||||
|
||||
<TextBlock Text="Repository URL" FontSize="12" />
|
||||
<TextBox Text="{Binding GitRepoUrl}"
|
||||
Watermark="https://github.com/org/template-repo.git" />
|
||||
|
||||
<TextBlock Text="Personal Access Token (PAT)" FontSize="12" />
|
||||
<TextBox Text="{Binding GitRepoPat}" PasswordChar="●"
|
||||
Watermark="ghp_xxxx..." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ MySQL Connection ═══ -->
|
||||
<Border Background="#1e1e2e" CornerRadius="8" Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="MySQL Connection" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#a6e3a1" Margin="0,0,0,4" />
|
||||
<TextBlock Text="Admin credentials used to create databases and users for new instances."
|
||||
FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" />
|
||||
|
||||
<Grid ColumnDefinitions="3*,8,1*" RowDefinitions="Auto,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="Host" FontSize="12" />
|
||||
<TextBox Text="{Binding MySqlHost}" Watermark="cms-sql.otshosting.app" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Text="Port" FontSize="12" />
|
||||
<TextBox Text="{Binding MySqlPort}" Watermark="3306" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="Admin Username" FontSize="12" />
|
||||
<TextBox Text="{Binding MySqlAdminUser}" Watermark="root" />
|
||||
|
||||
<TextBlock Text="Admin Password" FontSize="12" />
|
||||
<TextBox Text="{Binding MySqlAdminPassword}" PasswordChar="●" />
|
||||
|
||||
<Button Content="Test MySQL Connection"
|
||||
Command="{Binding TestMySqlConnectionCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ SMTP Settings ═══ -->
|
||||
<Border Background="#1e1e2e" CornerRadius="8" Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="SMTP Settings" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#f5c2e7" Margin="0,0,0,4" />
|
||||
<TextBlock Text="Email configuration applied to all CMS instances."
|
||||
FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" />
|
||||
|
||||
<TextBlock Text="SMTP Server (host:port)" FontSize="12" />
|
||||
<TextBox Text="{Binding SmtpServer}" Watermark="smtp.azurecomm.net:587" />
|
||||
|
||||
<TextBlock Text="SMTP Username" FontSize="12" />
|
||||
<TextBox Text="{Binding SmtpUsername}" Watermark="user@domain.com" />
|
||||
|
||||
<TextBlock Text="SMTP Password" FontSize="12" />
|
||||
<TextBox Text="{Binding SmtpPassword}" PasswordChar="●" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="16">
|
||||
<CheckBox Content="Use TLS" IsChecked="{Binding SmtpUseTls}" />
|
||||
<CheckBox Content="Use STARTTLS" IsChecked="{Binding SmtpUseStartTls}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="Rewrite Domain" FontSize="12" />
|
||||
<TextBox Text="{Binding SmtpRewriteDomain}" Watermark="ots-signs.com" />
|
||||
|
||||
<TextBlock Text="SMTP Hostname" FontSize="12" />
|
||||
<TextBox Text="{Binding SmtpHostname}" Watermark="demo.ots-signs.com" />
|
||||
|
||||
<TextBlock Text="From Line Override" FontSize="12" />
|
||||
<TextBox Text="{Binding SmtpFromLineOverride}" Watermark="NO" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Pangolin / Newt ═══ -->
|
||||
<Border Background="#1e1e2e" CornerRadius="8" Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Pangolin (Newt Tunnel)" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#fab387" Margin="0,0,0,4" />
|
||||
<TextBlock Text="Global Pangolin endpoint. Newt ID and Secret are configured per-instance."
|
||||
FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" />
|
||||
|
||||
<TextBlock Text="Pangolin Endpoint URL" FontSize="12" />
|
||||
<TextBox Text="{Binding PangolinEndpoint}" Watermark="https://app.pangolin.net" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ CIFS Volumes ═══ -->
|
||||
<Border Background="#1e1e2e" CornerRadius="8" Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="CIFS Volumes" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#cba6f7" Margin="0,0,0,4" />
|
||||
<TextBlock Text="Network share settings for Docker volumes. Volumes will be mounted via CIFS."
|
||||
FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" />
|
||||
|
||||
<TextBlock Text="CIFS Server (hostname/IP)" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsServer}" Watermark="nas.local" />
|
||||
|
||||
<TextBlock Text="Share Base Path" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsShareBasePath}" Watermark="/share/cms" />
|
||||
|
||||
<TextBlock Text="Username" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsUsername}" Watermark="smbuser" />
|
||||
|
||||
<TextBlock Text="Password" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsPassword}" PasswordChar="●" />
|
||||
|
||||
<TextBlock Text="Extra Mount Options" FontSize="12" />
|
||||
<TextBox Text="{Binding CifsOptions}" Watermark="file_mode=0777,dir_mode=0777" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Instance Defaults ═══ -->
|
||||
<Border Background="#1e1e2e" CornerRadius="8" Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Instance Defaults" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#89dceb" Margin="0,0,0,4" />
|
||||
<TextBlock Text="Default Docker images, naming templates, and PHP settings for new instances. Use {abbrev} as a placeholder for the customer abbreviation."
|
||||
FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Text="Docker Images" FontSize="13" FontWeight="SemiBold" Margin="0,8,0,4" />
|
||||
|
||||
<TextBlock Text="CMS Image" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultCmsImage}"
|
||||
Watermark="ghcr.io/xibosignage/xibo-cms:release-4.2.3" />
|
||||
|
||||
<TextBlock Text="Newt Image" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultNewtImage}" Watermark="fosrl/newt" />
|
||||
|
||||
<TextBlock Text="Memcached Image" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultMemcachedImage}" Watermark="memcached:alpine" />
|
||||
|
||||
<TextBlock Text="QuickChart Image" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultQuickChartImage}" Watermark="ianw/quickchart" />
|
||||
|
||||
<TextBlock Text="Naming Templates" FontSize="13" FontWeight="SemiBold" Margin="0,12,0,4" />
|
||||
|
||||
<TextBlock Text="CMS Server Name Template" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultCmsServerNameTemplate}"
|
||||
Watermark="{}{abbrev}.ots-signs.com" />
|
||||
|
||||
<TextBlock Text="Theme Host Path Template" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultThemeHostPath}"
|
||||
Watermark="/cms/{abbrev}-cms-theme-custom" />
|
||||
|
||||
<TextBlock Text="MySQL Database Name Template" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultMySqlDbTemplate}" Watermark="{}{abbrev}_cms_db" />
|
||||
|
||||
<TextBlock Text="MySQL User Template" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultMySqlUserTemplate}" Watermark="{}{abbrev}_cms" />
|
||||
|
||||
<TextBlock Text="PHP Settings" FontSize="13" FontWeight="SemiBold" Margin="0,12,0,4" />
|
||||
|
||||
<Grid ColumnDefinitions="1*,8,1*,8,1*">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="Post Max Size" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultPhpPostMaxSize}" Watermark="10G" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Text="Upload Max Filesize" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultPhpUploadMaxFilesize}" Watermark="10G" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="4" Spacing="4">
|
||||
<TextBlock Text="Max Execution Time" FontSize="12" />
|
||||
<TextBox Text="{Binding DefaultPhpMaxExecutionTime}" Watermark="600" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
11
OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml.cs
Normal file
11
OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class SettingsView : UserControl
|
||||
{
|
||||
public SettingsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
13
OTSSignsOrchestrator.Desktop/app.manifest
Normal file
13
OTSSignsOrchestrator.Desktop/app.manifest
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="OTSSignsOrchestrator.Desktop"/>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />
|
||||
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
|
||||
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
|
||||
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
45
OTSSignsOrchestrator.Desktop/appsettings.json
Normal file
45
OTSSignsOrchestrator.Desktop/appsettings.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.EntityFrameworkCore": "Information"
|
||||
}
|
||||
},
|
||||
"FileLogging": {
|
||||
"Enabled": true,
|
||||
"Path": "logs",
|
||||
"RollingInterval": "Day",
|
||||
"RetentionDays": 7
|
||||
},
|
||||
"Git": {
|
||||
"CacheDir": ".template-cache"
|
||||
},
|
||||
"Docker": {
|
||||
"DefaultConstraints": [ "node.labels.xibo==true" ],
|
||||
"DeployTimeoutSeconds": 30,
|
||||
"ValidateBeforeDeploy": true
|
||||
},
|
||||
"Xibo": {
|
||||
"DefaultImages": {
|
||||
"Cms": "ghcr.io/xibosignage/xibo-cms:release-4.4.0",
|
||||
"Mysql": "mysql:8.4",
|
||||
"Memcached": "memcached:alpine",
|
||||
"QuickChart": "ianw/quickchart"
|
||||
},
|
||||
"TestConnectionTimeoutSeconds": 10
|
||||
},
|
||||
"Database": {
|
||||
"Provider": "Sqlite"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"Default": "Data Source=otssigns-desktop.db"
|
||||
},
|
||||
"InstanceDefaults": {
|
||||
"CmsServerNameTemplate": "{abbrev}.ots-signs.com",
|
||||
"ThemeHostPath": "/cms/ots-theme",
|
||||
"LibraryShareSubPath": "{abbrev}-cms-library",
|
||||
"MySqlDatabaseTemplate": "{abbrev}_cms_db",
|
||||
"MySqlUserTemplate": "{abbrev}_cms",
|
||||
"BaseHostHttpPort": 8080
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user