feat: Add main application views and structure
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:
Matt Batchelder
2026-02-18 10:43:27 -05:00
parent 29b8c23dbb
commit 45c94b6536
149 changed files with 6469 additions and 63498 deletions

View 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>

View 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>();
}
}

View File

@@ -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>

View 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();
}

View 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();
}
}
}

View 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.");
}
}

View 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.");
}
}

View File

@@ -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);
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View File

@@ -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;
}
}

View 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;
}
}
}

View 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();
}

View 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>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class CreateInstanceView : UserControl
{
public CreateInstanceView()
{
InitializeComponent();
}
}

View 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>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class HostsView : UserControl
{
public HostsView()
{
InitializeComponent();
}
}

View 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>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class InstancesView : UserControl
{
public InstancesView()
{
InitializeComponent();
}
}

View 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>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class LogsView : UserControl
{
public LogsView()
{
InitializeComponent();
}
}

View 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>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}

View 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>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class SecretsView : UserControl
{
public SecretsView()
{
InitializeComponent();
}
}

View 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>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class SettingsView : UserControl
{
public SettingsView()
{
InitializeComponent();
}
}

View 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>

View 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
}
}