Add WAL file for database and log instance deployment failures
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled

This commit is contained in:
Matt Batchelder
2026-02-19 08:27:54 -05:00
parent 4a903bfd2a
commit adf1a2e4db
41 changed files with 2789 additions and 1297 deletions

View File

@@ -0,0 +1,25 @@
using OTSSignsOrchestrator.Core.Models.Entities;
namespace OTSSignsOrchestrator.Desktop.Models;
/// <summary>
/// Represents a CMS stack discovered live from a Docker Swarm host.
/// No data is persisted locally — all values come from <c>docker stack ls</c> / inspect.
/// </summary>
public class LiveStackItem
{
/// <summary>Docker stack name, e.g. "acm-cms-stack".</summary>
public string StackName { get; set; } = string.Empty;
/// <summary>3-letter abbreviation derived from the stack name.</summary>
public string CustomerAbbrev { get; set; } = string.Empty;
/// <summary>Number of services reported by <c>docker stack ls</c>.</summary>
public int ServiceCount { get; set; }
/// <summary>The SSH host this stack was found on.</summary>
public SshHost Host { get; set; } = null!;
/// <summary>Label of the host — convenience property for data-binding.</summary>
public string HostLabel => Host?.Label ?? string.Empty;
}

View File

@@ -93,6 +93,31 @@ public class SshConnectionService : IDisposable
});
}
/// <summary>
/// Run a command on the remote host with a timeout.
/// Returns exit code -1 and an error message if the command times out.
/// </summary>
public async Task<(int ExitCode, string Stdout, string Stderr)> RunCommandAsync(SshHost host, string command, TimeSpan timeout)
{
return await Task.Run(() =>
{
var client = GetClient(host);
using var cmd = client.CreateCommand(command);
cmd.CommandTimeout = timeout;
try
{
cmd.Execute();
return (cmd.ExitStatus ?? -1, cmd.Result, cmd.Error);
}
catch (Renci.SshNet.Common.SshOperationTimeoutException)
{
_logger.LogWarning("SSH command timed out after {Timeout}s: {Command}",
timeout.TotalSeconds, command.Length > 120 ? command[..120] + "…" : command);
return (-1, string.Empty, $"Command timed out after {timeout.TotalSeconds}s");
}
});
}
/// <summary>
/// Run a command that requires stdin input (e.g., docker stack deploy --compose-file -).
/// </summary>
@@ -171,6 +196,23 @@ public class SshConnectionService : IDisposable
return new SshClient(connInfo);
}
/// <summary>
/// Opens an SSH local port-forward from 127.0.0.1:&lt;auto&gt; → <paramref name="remoteHost"/>:<paramref name="remotePort"/>
/// through the existing SSH connection for <paramref name="host"/>.
/// The caller must dispose the returned <see cref="ForwardedPortLocal"/> to close the tunnel.
/// </summary>
public ForwardedPortLocal OpenForwardedPort(SshHost host, string remoteHost, uint remotePort)
{
var client = GetClient(host);
// Port 0 lets the OS assign a free local port; SSH.NET updates BoundPort after Start().
var tunnel = new ForwardedPortLocal("127.0.0.1", 0, remoteHost, remotePort);
client.AddForwardedPort(tunnel);
tunnel.Start();
_logger.LogDebug("SSH tunnel opened: 127.0.0.1:{LocalPort} → {RemoteHost}:{RemotePort}",
tunnel.BoundPort, remoteHost, remotePort);
return tunnel;
}
public void Dispose()
{
lock (_lock)

View File

@@ -1,6 +1,7 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MySqlConnector;
using OTSSignsOrchestrator.Core.Configuration;
using OTSSignsOrchestrator.Core.Models.DTOs;
using OTSSignsOrchestrator.Core.Models.Entities;
@@ -162,66 +163,282 @@ public class SshDockerCliService : IDockerCliService
return exitCode == 0;
}
public async Task<bool> EnsureSmbFoldersAsync(
string cifsServer,
string cifsShareName,
string cifsUsername,
string cifsPassword,
public async Task<bool> EnsureNfsFoldersAsync(
string nfsServer,
string nfsExport,
IEnumerable<string> folderNames,
string? cifsShareFolder = null)
string? nfsExportFolder = null)
{
EnsureHost();
var allSucceeded = true;
var subFolder = (cifsShareFolder ?? string.Empty).Trim('/');
var exportPath = (nfsExport ?? string.Empty).Trim('/');
var subFolder = (nfsExportFolder ?? string.Empty).Trim('/');
// If a subfolder is specified, ensure it exists first
if (!string.IsNullOrEmpty(subFolder))
// Build the sub-path beneath the mount point where volume folders will be created
var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}";
// Build mkdir targets relative to the temporary mount point
var folderList = folderNames.Select(f => $"\"$MNT{subPath}/{f}\"").ToList();
var mkdirTargets = string.Join(" ", folderList);
// Single SSH command: create temp dir, mount NFS, mkdir -p all folders, unmount, cleanup
// Use addr= to pin the server IP — avoids "Server address does not match proto= option"
// errors when the hostname resolves to IPv6 but proto=tcp implies IPv4.
var script = $"""
set -e
MNT=$(mktemp -d)
sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
sudo mkdir -p {mkdirTargets}
sudo umount "$MNT"
rmdir "$MNT"
""";
_logger.LogInformation(
"Mounting NFS export {Server}:/{Export} on Docker host {Host} to create {Count} folders",
nfsServer, exportPath, _currentHost!.Label, folderList.Count);
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, script, TimeSpan.FromSeconds(30));
if (exitCode == 0)
{
var mkdirCmd = $"smbclient //{cifsServer}/{cifsShareName} -U '{cifsUsername}%{cifsPassword}' -c 'mkdir {subFolder}' 2>&1";
var (_, mkdirOut, _) = await _ssh.RunCommandAsync(_currentHost!, mkdirCmd);
var mkdirOutput = mkdirOut ?? string.Empty;
var alreadyExists = mkdirOutput.Contains("NT_STATUS_OBJECT_NAME_COLLISION", StringComparison.OrdinalIgnoreCase)
|| mkdirOutput.Contains("already exists", StringComparison.OrdinalIgnoreCase);
var success = alreadyExists || !mkdirOutput.Contains("NT_STATUS_", StringComparison.OrdinalIgnoreCase);
if (success)
_logger.LogInformation("SMB subfolder ensured: //{Server}/{Share}/{Folder}", cifsServer, cifsShareName, subFolder);
else
{
_logger.LogWarning("Failed to create SMB subfolder //{Server}/{Share}/{Folder}: {Output}",
cifsServer, cifsShareName, subFolder, mkdirOutput.Trim());
allSucceeded = false;
}
_logger.LogInformation(
"NFS export folders ensured via mount on {Host}: {Server}:/{Export}{Sub} ({Count} folders)",
_currentHost.Label, nfsServer, exportPath, subPath, folderList.Count);
}
else
{
_logger.LogWarning(
"Failed to create NFS export folders on {Host}: {Error}",
_currentHost.Label, (stderr ?? stdout ?? "unknown error").Trim());
return false;
}
// Build the target path prefix for volume folders
var pathPrefix = string.IsNullOrEmpty(subFolder) ? string.Empty : $"{subFolder}/";
return true;
}
foreach (var folder in folderNames)
public async Task<(bool Success, string? Error)> EnsureNfsFoldersWithErrorAsync(
string nfsServer,
string nfsExport,
IEnumerable<string> folderNames,
string? nfsExportFolder = null)
{
EnsureHost();
var exportPath = (nfsExport ?? string.Empty).Trim('/');
var subFolder = (nfsExportFolder ?? string.Empty).Trim('/');
var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}";
var folderList = folderNames.Select(f => $"\"$MNT{subPath}/{f}\"").ToList();
var mkdirTargets = string.Join(" ", folderList);
var script = $"""
set -e
MNT=$(mktemp -d)
sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
sudo mkdir -p {mkdirTargets}
sudo umount "$MNT"
rmdir "$MNT"
""";
_logger.LogInformation(
"Mounting NFS export {Server}:/{Export} on Docker host {Host} to create {Count} folders",
nfsServer, exportPath, _currentHost!.Label, folderList.Count);
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, script, TimeSpan.FromSeconds(30));
if (exitCode == 0)
{
var targetFolder = $"{pathPrefix}{folder}";
// Run smbclient on the remote Docker host to create the folder on the share.
// NT_STATUS_OBJECT_NAME_COLLISION means it already exists — treat as success.
var cmd = $"smbclient //{cifsServer}/{cifsShareName} -U '{cifsUsername}%{cifsPassword}' -c 'mkdir {targetFolder}' 2>&1";
var (_, stdout, _) = await _ssh.RunCommandAsync(_currentHost!, cmd);
var output = stdout ?? string.Empty;
var exists = output.Contains("NT_STATUS_OBJECT_NAME_COLLISION", StringComparison.OrdinalIgnoreCase)
|| output.Contains("already exists", StringComparison.OrdinalIgnoreCase);
var ok = exists || !output.Contains("NT_STATUS_", StringComparison.OrdinalIgnoreCase);
if (ok)
_logger.LogInformation("SMB folder ensured: //{Server}/{Share}/{Folder}", cifsServer, cifsShareName, targetFolder);
else
{
_logger.LogWarning("Failed to create SMB folder //{Server}/{Share}/{Folder}: {Output}",
cifsServer, cifsShareName, targetFolder, output.Trim());
allSucceeded = false;
}
_logger.LogInformation(
"NFS export folders ensured via mount on {Host}: {Server}:/{Export}{Sub} ({Count} folders)",
_currentHost.Label, nfsServer, exportPath, subPath, folderList.Count);
return (true, null);
}
return allSucceeded;
var error = (stderr ?? stdout ?? "unknown error").Trim();
_logger.LogWarning(
"Failed to create NFS export folders on {Host}: {Error}",
_currentHost.Label, error);
return (false, error);
}
public async Task<bool> ForceUpdateServiceAsync(string serviceName)
{
EnsureHost();
_logger.LogInformation("Force-updating service {ServiceName} on {Host}", serviceName, _currentHost!.Label);
var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, $"docker service update --force {serviceName}");
if (exitCode != 0)
_logger.LogWarning("Force-update failed for {ServiceName}: {Error}", serviceName, stderr);
return exitCode == 0;
}
public async Task<(MySqlConnection Connection, IDisposable Tunnel)> OpenMySqlConnectionAsync(
string mysqlHost, int port,
string adminUser, string adminPassword)
{
EnsureHost();
_logger.LogInformation(
"Opening tunnelled MySQL connection to {MysqlHost}:{Port} via SSH",
mysqlHost, port);
var tunnel = _ssh.OpenForwardedPort(_currentHost!, mysqlHost, (uint)port);
var localPort = (int)tunnel.BoundPort;
var csb = new MySqlConnectionStringBuilder
{
Server = "127.0.0.1",
Port = (uint)localPort,
UserID = adminUser,
Password = adminPassword,
ConnectionTimeout = 15,
SslMode = MySqlSslMode.Disabled,
};
var connection = new MySqlConnection(csb.ConnectionString);
try
{
await connection.OpenAsync();
return (connection, tunnel);
}
catch
{
await connection.DisposeAsync();
tunnel.Dispose();
throw;
}
}
public async Task<(bool Success, string Error)> AlterMySqlUserPasswordAsync(
string mysqlHost, int port,
string adminUser, string adminPassword,
string targetUser, string newPassword)
{
_logger.LogInformation(
"Altering MySQL password for user {User} on {MysqlHost}:{Port} via SSH tunnel",
targetUser, mysqlHost, port);
try
{
var (connection, tunnel) = await OpenMySqlConnectionAsync(mysqlHost, port, adminUser, adminPassword);
await using (connection)
using (tunnel)
{
var escapedUser = targetUser.Replace("'", "''");
await using var cmd = connection.CreateCommand();
cmd.CommandText = $"ALTER USER '{escapedUser}'@'%' IDENTIFIED BY @pwd";
cmd.Parameters.AddWithValue("@pwd", newPassword);
await cmd.ExecuteNonQueryAsync();
}
_logger.LogInformation("MySQL password updated for user {User} via SSH tunnel", targetUser);
return (true, string.Empty);
}
catch (MySqlException ex)
{
_logger.LogError(ex, "MySQL ALTER USER failed via SSH tunnel for user {User}", targetUser);
return (false, ex.Message);
}
}
public async Task<bool> ServiceSwapSecretAsync(string serviceName, string oldSecretName, string newSecretName, string? targetAlias = null)
{
EnsureHost();
var target = targetAlias ?? oldSecretName;
var cmd = $"docker service update --secret-rm {oldSecretName} --secret-add \"source={newSecretName},target={target}\" {serviceName}";
_logger.LogInformation(
"Swapping secret on {ServiceName}: {OldSecret} → {NewSecret} (target={Target})",
serviceName, oldSecretName, newSecretName, target);
var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, cmd);
if (exitCode != 0)
_logger.LogError("Secret swap failed for {ServiceName}: {Error}", serviceName, stderr);
return exitCode == 0;
}
public async Task<List<NodeInfo>> ListNodesAsync()
{
EnsureHost();
_logger.LogInformation("Listing swarm nodes via SSH on {Host}", _currentHost!.Label);
// Use docker node inspect on all nodes to get IP addresses (Status.Addr)
// that are not available from 'docker node ls'.
// First, get all node IDs.
var (lsExit, lsOut, lsErr) = await _ssh.RunCommandAsync(
_currentHost!, "docker node ls --format '{{.ID}}'");
if (lsExit != 0)
{
var msg = (lsErr ?? lsOut ?? "unknown error").Trim();
_logger.LogWarning("docker node ls failed on {Host} (exit {Code}): {Error}",
_currentHost.Label, lsExit, msg);
throw new InvalidOperationException(
$"Failed to list swarm nodes on {_currentHost.Label}: {msg}");
}
if (string.IsNullOrWhiteSpace(lsOut))
return new List<NodeInfo>();
var nodeIds = lsOut.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(id => id.Trim())
.Where(id => !string.IsNullOrEmpty(id))
.ToList();
if (nodeIds.Count == 0)
return new List<NodeInfo>();
// Inspect all nodes in a single call to get full details including IP address
var ids = string.Join(" ", nodeIds);
var format = "'{{.ID}}\t{{.Description.Hostname}}\t{{.Status.State}}\t{{.Spec.Availability}}\t{{.ManagerStatus.Addr}}\t{{.Status.Addr}}\t{{.Description.Engine.EngineVersion}}\t{{.Spec.Role}}'";
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(
_currentHost!, $"docker node inspect --format {format} {ids}");
if (exitCode != 0)
{
var msg = (stderr ?? stdout ?? "unknown error").Trim();
_logger.LogWarning("docker node inspect failed on {Host} (exit {Code}): {Error}",
_currentHost.Label, exitCode, msg);
throw new InvalidOperationException(
$"Failed to inspect swarm nodes on {_currentHost.Label}: {msg}");
}
if (string.IsNullOrWhiteSpace(stdout))
return new List<NodeInfo>();
return stdout
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line =>
{
var parts = line.Split('\t', 8);
// ManagerStatus.Addr includes port (e.g. "10.0.0.1:2377"); Status.Addr is just the IP.
// Prefer Status.Addr; fall back to ManagerStatus.Addr (strip port) if Status.Addr is empty/template-error.
var statusAddr = parts.Length > 5 ? parts[5].Trim() : "";
var managerAddr = parts.Length > 4 ? parts[4].Trim() : "";
var ip = statusAddr;
if (string.IsNullOrEmpty(ip) || ip.StartsWith("<") || ip.StartsWith("{"))
{
// managerAddr may be "10.0.0.1:2377"
ip = managerAddr.Contains(':') ? managerAddr[..managerAddr.LastIndexOf(':')] : managerAddr;
}
// Clean up template rendering artefacts like "<no value>"
if (ip.StartsWith("<") || ip.StartsWith("{"))
ip = "";
var role = parts.Length > 7 ? parts[7].Trim() : "";
var managerStatus = "";
if (string.Equals(role, "manager", StringComparison.OrdinalIgnoreCase))
{
// Determine if this is the leader by checking if ManagerStatus.Addr is non-empty
managerStatus = !string.IsNullOrEmpty(managerAddr) && !managerAddr.StartsWith("<") ? "Reachable" : "";
}
return new NodeInfo
{
Id = parts.Length > 0 ? parts[0].Trim() : "",
Hostname = parts.Length > 1 ? parts[1].Trim() : "",
Status = parts.Length > 2 ? parts[2].Trim() : "",
Availability = parts.Length > 3 ? parts[3].Trim() : "",
ManagerStatus = managerStatus,
IpAddress = ip,
EngineVersion = parts.Length > 6 ? parts[6].Trim() : ""
};
})
.ToList();
}
private void EnsureHost()

View File

@@ -43,7 +43,12 @@ public class SshDockerSecretsService : IDockerSecretsService
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}");
var (rmExit, _, rmErr) = await _ssh.RunCommandAsync(_currentHost!, $"docker secret rm {name}");
if (rmExit != 0)
{
_logger.LogError("Failed to remove old secret for rotation: {SecretName} | error={Error}", name, rmErr);
return (false, string.Empty);
}
}
// Create secret via stdin

View File

@@ -1,5 +1,7 @@
using System.Collections.ObjectModel;
using System.Security.Cryptography;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
@@ -35,18 +37,23 @@ public partial class CreateInstanceViewModel : ObservableObject
[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 _cifsShareName = string.Empty;
[ObservableProperty] private string _cifsShareFolder = string.Empty;
[ObservableProperty] private string _cifsUsername = string.Empty;
[ObservableProperty] private string _cifsPassword = string.Empty;
[ObservableProperty] private string _cifsExtraOptions = string.Empty;
// NFS volume settings (per-instance, defaults loaded from global settings)
[ObservableProperty] private string _nfsServer = string.Empty;
[ObservableProperty] private string _nfsExport = string.Empty;
[ObservableProperty] private string _nfsExportFolder = string.Empty;
[ObservableProperty] private string _nfsExtraOptions = string.Empty;
// SSH host selection
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
[ObservableProperty] private SshHost? _selectedSshHost;
// YML preview
[ObservableProperty] private string _previewYml = string.Empty;
[ObservableProperty] private bool _isLoadingYml;
public bool HasPreviewYml => !string.IsNullOrEmpty(PreviewYml);
partial void OnPreviewYmlChanged(string value) => OnPropertyChanged(nameof(HasPreviewYml));
// ── Derived preview properties ───────────────────────────────────────────
public string PreviewStackName => Valid ? $"{Abbrev}-cms-stack" : "—";
@@ -55,14 +62,17 @@ public partial class CreateInstanceViewModel : ObservableObject
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 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 PreviewSecretUser => Valid ? $"{Abbrev}-cms-db-user" : "—";
public string PreviewSecretHost => "global_mysql_host";
public string PreviewSecretPort => "global_mysql_port";
public string PreviewMySqlDb => Valid ? $"{Abbrev}_cms_db" : "—";
public string PreviewMySqlUser => Valid ? $"{Abbrev}_cms" : "—";
public string PreviewMySqlUser => Valid ? $"{Abbrev}_cms_user" : "—";
public string PreviewCmsUrl => Valid ? $"https://{Abbrev}.ots-signs.com" : "—";
private string Abbrev => CustomerAbbrev.Trim().ToLowerInvariant();
@@ -74,7 +84,7 @@ public partial class CreateInstanceViewModel : ObservableObject
{
_services = services;
_ = LoadHostsAsync();
_ = LoadCifsDefaultsAsync();
_ = LoadNfsDefaultsAsync();
}
partial void OnCustomerAbbrevChanged(string value) => RefreshPreview();
@@ -93,6 +103,9 @@ public partial class CreateInstanceViewModel : ObservableObject
OnPropertyChanged(nameof(PreviewVolUserscripts));
OnPropertyChanged(nameof(PreviewVolCaCerts));
OnPropertyChanged(nameof(PreviewSecret));
OnPropertyChanged(nameof(PreviewSecretUser));
OnPropertyChanged(nameof(PreviewSecretHost));
OnPropertyChanged(nameof(PreviewSecretPort));
OnPropertyChanged(nameof(PreviewMySqlDb));
OnPropertyChanged(nameof(PreviewMySqlUser));
OnPropertyChanged(nameof(PreviewCmsUrl));
@@ -106,16 +119,138 @@ public partial class CreateInstanceViewModel : ObservableObject
AvailableHosts = new ObservableCollection<SshHost>(hosts);
}
private async Task LoadCifsDefaultsAsync()
private async Task LoadNfsDefaultsAsync()
{
using var scope = _services.CreateScope();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
CifsServer = await settings.GetAsync(SettingsService.CifsServer) ?? string.Empty;
CifsShareName = await settings.GetAsync(SettingsService.CifsShareName) ?? string.Empty;
CifsShareFolder = await settings.GetAsync(SettingsService.CifsShareFolder) ?? 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");
NfsServer = await settings.GetAsync(SettingsService.NfsServer) ?? string.Empty;
NfsExport = await settings.GetAsync(SettingsService.NfsExport) ?? string.Empty;
NfsExportFolder = await settings.GetAsync(SettingsService.NfsExportFolder) ?? string.Empty;
NfsExtraOptions = await settings.GetAsync(SettingsService.NfsOptions) ?? string.Empty;
}
[RelayCommand]
private async Task LoadYmlPreviewAsync()
{
if (!Valid)
{
PreviewYml = "# Abbreviation must be exactly 3 lowercase letters (a-z) before loading the YML preview.";
return;
}
IsLoadingYml = true;
try
{
using var scope = _services.CreateScope();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
var git = scope.ServiceProvider.GetRequiredService<GitTemplateService>();
var composer = scope.ServiceProvider.GetRequiredService<ComposeRenderService>();
var repoUrl = await settings.GetAsync(SettingsService.GitRepoUrl);
var repoPat = await settings.GetAsync(SettingsService.GitRepoPat);
if (string.IsNullOrWhiteSpace(repoUrl))
{
PreviewYml = "# Git template repository URL is not configured. Set it in Settings → Git Repo URL.";
return;
}
var templateConfig = await git.FetchAsync(repoUrl, repoPat);
var abbrev = Abbrev;
var stackName = $"{abbrev}-cms-stack";
var mySqlHost = await settings.GetAsync(SettingsService.MySqlHost, "localhost");
var mySqlPort = await settings.GetAsync(SettingsService.MySqlPort, "3306");
var mySqlDbName = (await settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
var mySqlUser = (await settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev);
var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com")).Replace("{abbrev}", abbrev);
var themePath = (await settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
var smtpServer = await settings.GetAsync(SettingsService.SmtpServer, string.Empty);
var smtpUsername = await settings.GetAsync(SettingsService.SmtpUsername, string.Empty);
var smtpPassword = await settings.GetAsync(SettingsService.SmtpPassword, string.Empty);
var smtpUseTls = await settings.GetAsync(SettingsService.SmtpUseTls, "YES");
var smtpUseStartTls = await settings.GetAsync(SettingsService.SmtpUseStartTls, "YES");
var smtpRewriteDomain = await settings.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty);
var smtpHostname = await settings.GetAsync(SettingsService.SmtpHostname, string.Empty);
var smtpFromLineOverride = await settings.GetAsync(SettingsService.SmtpFromLineOverride, "NO");
var pangolinEndpoint = await settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
var cmsImage = await settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
var newtImage = await settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
var memcachedImage = await settings.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
var quickChartImage = await settings.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
var phpPostMaxSize = await settings.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
var phpUploadMaxFilesize = await settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
var phpMaxExecutionTime = await settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
// Use form values; fall back to saved global settings
var nfsServer = string.IsNullOrWhiteSpace(NfsServer) ? await settings.GetAsync(SettingsService.NfsServer) : NfsServer;
var nfsExport = string.IsNullOrWhiteSpace(NfsExport) ? await settings.GetAsync(SettingsService.NfsExport) : NfsExport;
var nfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? await settings.GetAsync(SettingsService.NfsExportFolder) : NfsExportFolder;
var nfsOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? await settings.GetAsync(SettingsService.NfsOptions, string.Empty) : NfsExtraOptions;
var ctx = new RenderContext
{
CustomerName = CustomerName.Trim(),
CustomerAbbrev = abbrev,
StackName = stackName,
CmsServerName = cmsServerName,
HostHttpPort = 80,
CmsImage = cmsImage,
MemcachedImage = memcachedImage,
QuickChartImage = quickChartImage,
NewtImage = newtImage,
ThemeHostPath = themePath,
MySqlHost = mySqlHost,
MySqlPort = mySqlPort,
MySqlDatabase = mySqlDbName,
MySqlUser = mySqlUser,
SmtpServer = smtpServer,
SmtpUsername = smtpUsername,
SmtpPassword = smtpPassword,
SmtpUseTls = smtpUseTls,
SmtpUseStartTls = smtpUseStartTls,
SmtpRewriteDomain = smtpRewriteDomain,
SmtpHostname = smtpHostname,
SmtpFromLineOverride = smtpFromLineOverride,
PhpPostMaxSize = phpPostMaxSize,
PhpUploadMaxFilesize = phpUploadMaxFilesize,
PhpMaxExecutionTime = phpMaxExecutionTime,
PangolinEndpoint = pangolinEndpoint,
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
NfsServer = nfsServer,
NfsExport = nfsExport,
NfsExportFolder = nfsExportFolder,
NfsExtraOptions = nfsOptions,
};
PreviewYml = composer.Render(templateConfig.Yaml, ctx);
}
catch (Exception ex)
{
PreviewYml = $"# Error rendering YML preview:\n# {ex.Message}";
}
finally
{
IsLoadingYml = false;
}
}
[RelayCommand]
private async Task CopyYmlAsync()
{
if (string.IsNullOrEmpty(PreviewYml)) return;
var mainWindow = (Application.Current?.ApplicationLifetime
as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (mainWindow is null) return;
var clipboard = TopLevel.GetTopLevel(mainWindow)?.Clipboard;
if (clipboard is not null)
await clipboard.SetTextAsync(PreviewYml);
}
[RelayCommand]
@@ -145,7 +280,8 @@ public partial class CreateInstanceViewModel : ObservableObject
try
{
// Wire SSH host into docker services
// Wire SSH host into docker services (singletons must know the target host before
// InstanceService uses them internally for secrets and CLI operations)
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedSshHost);
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
@@ -154,38 +290,12 @@ public partial class CreateInstanceViewModel : ObservableObject
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 direct TCP ────────
SetProgress(35, "Creating MySQL database and user...");
var (mysqlOk, mysqlMsg) = await instanceSvc.CreateMySqlDatabaseAsync(
Abbrev,
mysqlPassword);
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...");
// InstanceService.CreateInstanceAsync handles the full provisioning flow:
// 1. Clone template repo
// 2. Generate MySQL password → create Docker Swarm secret
// 3. Create MySQL database + SQL user (same password as the secret)
// 4. Render compose YAML → deploy stack
SetProgress(30, "Provisioning instance (MySQL user, secrets, stack)...");
var dto = new CreateInstanceDto
{
@@ -194,12 +304,10 @@ public partial class CreateInstanceViewModel : ObservableObject
SshHostId = SelectedSshHost.Id,
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
CifsServer = string.IsNullOrWhiteSpace(CifsServer) ? null : CifsServer.Trim(),
CifsShareName = string.IsNullOrWhiteSpace(CifsShareName) ? null : CifsShareName.Trim(),
CifsShareFolder = string.IsNullOrWhiteSpace(CifsShareFolder) ? null : CifsShareFolder.Trim(),
CifsUsername = string.IsNullOrWhiteSpace(CifsUsername) ? null : CifsUsername.Trim(),
CifsPassword = string.IsNullOrWhiteSpace(CifsPassword) ? null : CifsPassword.Trim(),
CifsExtraOptions = string.IsNullOrWhiteSpace(CifsExtraOptions) ? null : CifsExtraOptions.Trim(),
NfsServer = string.IsNullOrWhiteSpace(NfsServer) ? null : NfsServer.Trim(),
NfsExport = string.IsNullOrWhiteSpace(NfsExport) ? null : NfsExport.Trim(),
NfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? null : NfsExportFolder.Trim(),
NfsExtraOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? null : NfsExtraOptions.Trim(),
};
var result = await instanceSvc.CreateInstanceAsync(dto);
@@ -236,10 +344,5 @@ public partial class CreateInstanceViewModel : ObservableObject
DeployOutput += (DeployOutput.Length > 0 ? "\n" : "") + text;
}
private static string GenerateRandomPassword(int length)
{
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
return RandomNumberGenerator.GetString(chars, length);
}
}

View File

@@ -4,6 +4,7 @@ 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.Desktop.Services;
@@ -22,6 +23,8 @@ public partial class HostsViewModel : ObservableObject
[ObservableProperty] private bool _isEditing;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private ObservableCollection<NodeInfo> _remoteNodes = new();
[ObservableProperty] private string _nodesStatusMessage = string.Empty;
// Edit form fields
[ObservableProperty] private string _editLabel = string.Empty;
@@ -202,6 +205,36 @@ public partial class HostsViewModel : ObservableObject
}
}
[RelayCommand]
private async Task ListNodesAsync()
{
if (SelectedHost == null)
{
NodesStatusMessage = "Select a host first.";
return;
}
IsBusy = true;
NodesStatusMessage = $"Listing nodes on {SelectedHost.Label}...";
try
{
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedHost);
var nodes = await dockerCli.ListNodesAsync();
RemoteNodes = new ObservableCollection<NodeInfo>(nodes);
NodesStatusMessage = $"Found {nodes.Count} node(s) on {SelectedHost.Label}.";
}
catch (Exception ex)
{
RemoteNodes.Clear();
NodesStatusMessage = $"Error: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task TestConnectionAsync()
{

View File

@@ -6,181 +6,156 @@ using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services;
using OTSSignsOrchestrator.Desktop.Models;
using OTSSignsOrchestrator.Desktop.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for listing, viewing, and managing CMS instances.
/// All data is fetched live from Docker Swarm hosts — nothing stored locally.
/// </summary>
public partial class InstancesViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty] private ObservableCollection<CmsInstance> _instances = new();
[ObservableProperty] private CmsInstance? _selectedInstance;
[ObservableProperty] private ObservableCollection<LiveStackItem> _instances = new();
[ObservableProperty] private LiveStackItem? _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
// Available SSH hosts — loaded for display and used to scope operations
[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();
_ = RefreshAllAsync();
}
/// <summary>
/// Enumerates all SSH hosts, then calls docker stack ls on each to build the
/// live instance list. Only stacks matching *-cms-stack are shown.
/// </summary>
[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);
}
private async Task LoadInstancesAsync() => await RefreshAllAsync();
[RelayCommand]
private async Task LoadInstancesAsync()
private async Task RefreshAllAsync()
{
IsBusy = true;
StatusMessage = "Loading live instances from all hosts...";
SelectedServices = new ObservableCollection<ServiceInfo>();
try
{
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);
var query = db.CmsInstances.Include(i => i.SshHost).AsQueryable();
if (!string.IsNullOrWhiteSpace(FilterText))
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
var all = new List<LiveStackItem>();
var errors = new List<string>();
foreach (var host in hosts)
{
query = query.Where(i =>
i.CustomerName.Contains(FilterText) ||
i.StackName.Contains(FilterText));
try
{
dockerCli.SetHost(host);
var stacks = await dockerCli.ListStacksAsync();
foreach (var stack in stacks.Where(s => s.Name.EndsWith("-cms-stack")))
{
all.Add(new LiveStackItem
{
StackName = stack.Name,
CustomerAbbrev = stack.Name[..^10],
ServiceCount = stack.ServiceCount,
Host = host,
});
}
}
catch (Exception ex) { errors.Add($"{host.Label}: {ex.Message}"); }
}
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;
}
}
if (!string.IsNullOrWhiteSpace(FilterText))
all = all.Where(i =>
i.StackName.Contains(FilterText, StringComparison.OrdinalIgnoreCase) ||
i.CustomerAbbrev.Contains(FilterText, StringComparison.OrdinalIgnoreCase) ||
i.HostLabel.Contains(FilterText, StringComparison.OrdinalIgnoreCase)).ToList();
[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;
Instances = new ObservableCollection<LiveStackItem>(all);
var msg = $"Found {all.Count} instance(s) across {hosts.Count} host(s).";
if (errors.Count > 0) msg += $" | Errors: {string.Join(" | ", errors)}";
StatusMessage = msg;
}
catch (Exception ex) { StatusMessage = $"Error: {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;
StatusMessage = $"Inspecting '{SelectedInstance.StackName}'...";
try
{
var host = SelectedInstance.SshHost ?? SelectedSshHost!;
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(host);
dockerCli.SetHost(SelectedInstance.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;
}
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);
dockerCli.SetHost(SelectedInstance.Host);
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
dockerSecrets.SetHost(host);
dockerSecrets.SetHost(SelectedInstance.Host);
using var scope = _services.CreateScope();
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
var result = await instanceSvc.DeleteInstanceAsync(SelectedInstance.Id);
var result = await instanceSvc.DeleteInstanceAsync(
SelectedInstance.StackName, SelectedInstance.CustomerAbbrev);
StatusMessage = result.Success
? $"Instance '{SelectedInstance.StackName}' deleted."
: $"Delete failed: {result.ErrorMessage}";
await RefreshAllAsync();
}
catch (Exception ex) { StatusMessage = $"Error deleting: {ex.Message}"; }
finally { IsBusy = false; }
}
await LoadInstancesAsync();
}
catch (Exception ex)
[RelayCommand]
private async Task RotateMySqlPasswordAsync()
{
if (SelectedInstance == null) return;
IsBusy = true;
StatusMessage = $"Rotating MySQL password for {SelectedInstance.StackName}...";
try
{
StatusMessage = $"Error deleting: {ex.Message}";
}
finally
{
IsBusy = false;
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedInstance.Host);
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
dockerSecrets.SetHost(SelectedInstance.Host);
using var scope = _services.CreateScope();
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
var (ok, msg) = await instanceSvc.RotateMySqlPasswordAsync(SelectedInstance.StackName);
StatusMessage = ok ? $"Done: {msg}" : $"Failed: {msg}";
await RefreshAllAsync();
}
catch (Exception ex) { StatusMessage = $"Error rotating password: {ex.Message}"; }
finally { IsBusy = false; }
}
}

View File

@@ -36,7 +36,6 @@ public partial class LogsViewModel : ObservableObject
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var items = await db.OperationLogs
.Include(l => l.Instance)
.OrderByDescending(l => l.Timestamp)
.Take(MaxEntries)
.ToListAsync();

View File

@@ -2,13 +2,12 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using MySqlConnector;
using OTSSignsOrchestrator.Core.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for the Settings page — manages Git, MySQL, SMTP, Pangolin, CIFS,
/// ViewModel for the Settings page — manages Git, MySQL, SMTP, Pangolin, NFS,
/// and Instance Defaults configuration, persisted via SettingsService.
/// </summary>
public partial class SettingsViewModel : ObservableObject
@@ -41,13 +40,11 @@ public partial class SettingsViewModel : ObservableObject
// ── Pangolin ────────────────────────────────────────────────────────────
[ObservableProperty] private string _pangolinEndpoint = "https://app.pangolin.net";
// ── CIFS ────────────────────────────────────────────────────────────────
[ObservableProperty] private string _cifsServer = string.Empty;
[ObservableProperty] private string _cifsShareName = string.Empty;
[ObservableProperty] private string _cifsShareFolder = string.Empty;
[ObservableProperty] private string _cifsUsername = string.Empty;
[ObservableProperty] private string _cifsPassword = string.Empty;
[ObservableProperty] private string _cifsOptions = "file_mode=0777,dir_mode=0777";
// ── NFS ────────────────────────────────────────────────────────────────
[ObservableProperty] private string _nfsServer = string.Empty;
[ObservableProperty] private string _nfsExport = string.Empty;
[ObservableProperty] private string _nfsExportFolder = string.Empty;
[ObservableProperty] private string _nfsOptions = string.Empty;
// ── Instance Defaults ───────────────────────────────────────────────────
[ObservableProperty] private string _defaultCmsImage = "ghcr.io/xibosignage/xibo-cms:release-4.2.3";
@@ -57,7 +54,7 @@ public partial class SettingsViewModel : ObservableObject
[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 _defaultMySqlUserTemplate = "{abbrev}_cms_user";
[ObservableProperty] private string _defaultPhpPostMaxSize = "10G";
[ObservableProperty] private string _defaultPhpUploadMaxFilesize = "10G";
[ObservableProperty] private string _defaultPhpMaxExecutionTime = "600";
@@ -100,13 +97,11 @@ public partial class SettingsViewModel : ObservableObject
// Pangolin
PangolinEndpoint = await svc.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
// CIFS
CifsServer = await svc.GetAsync(SettingsService.CifsServer, string.Empty);
CifsShareName = await svc.GetAsync(SettingsService.CifsShareName, string.Empty);
CifsShareFolder = await svc.GetAsync(SettingsService.CifsShareFolder, 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");
// NFS
NfsServer = await svc.GetAsync(SettingsService.NfsServer, string.Empty);
NfsExport = await svc.GetAsync(SettingsService.NfsExport, string.Empty);
NfsExportFolder = await svc.GetAsync(SettingsService.NfsExportFolder, string.Empty);
NfsOptions = await svc.GetAsync(SettingsService.NfsOptions, string.Empty);
// Instance Defaults
DefaultCmsImage = await svc.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
@@ -116,7 +111,7 @@ public partial class SettingsViewModel : ObservableObject
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");
DefaultMySqlUserTemplate = await svc.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user");
DefaultPhpPostMaxSize = await svc.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
@@ -167,13 +162,11 @@ public partial class SettingsViewModel : ObservableObject
// Pangolin
(SettingsService.PangolinEndpoint, NullIfEmpty(PangolinEndpoint), SettingsService.CatPangolin, false),
// CIFS
(SettingsService.CifsServer, NullIfEmpty(CifsServer), SettingsService.CatCifs, false),
(SettingsService.CifsShareName, NullIfEmpty(CifsShareName), SettingsService.CatCifs, false),
(SettingsService.CifsShareFolder, NullIfEmpty(CifsShareFolder), SettingsService.CatCifs, false),
(SettingsService.CifsUsername, NullIfEmpty(CifsUsername), SettingsService.CatCifs, false),
(SettingsService.CifsPassword, NullIfEmpty(CifsPassword), SettingsService.CatCifs, true),
(SettingsService.CifsOptions, NullIfEmpty(CifsOptions), SettingsService.CatCifs, false),
// NFS
(SettingsService.NfsServer, NullIfEmpty(NfsServer), SettingsService.CatNfs, false),
(SettingsService.NfsExport, NullIfEmpty(NfsExport), SettingsService.CatNfs, false),
(SettingsService.NfsExportFolder, NullIfEmpty(NfsExportFolder), SettingsService.CatNfs, false),
(SettingsService.NfsOptions, NullIfEmpty(NfsOptions), SettingsService.CatNfs, false),
// Instance Defaults
(SettingsService.DefaultCmsImage, DefaultCmsImage, SettingsService.CatDefaults, false),
@@ -218,32 +211,21 @@ public partial class SettingsViewModel : ObservableObject
if (!int.TryParse(MySqlPort, out var port))
port = 3306;
var csb = new MySqlConnectionStringBuilder
{
Server = MySqlHost,
Port = (uint)port,
UserID = MySqlAdminUser,
Password = MySqlAdminPassword,
ConnectionTimeout = 10,
SslMode = MySqlSslMode.Preferred,
};
await using var connection = new MySqlConnection(csb.ConnectionString);
await connection.OpenAsync();
var docker = _services.GetRequiredService<IDockerCliService>();
var (connection, tunnel) = await docker.OpenMySqlConnectionAsync(
MySqlHost, port, MySqlAdminUser, MySqlAdminPassword);
await using var _ = connection;
using var __ = tunnel;
await using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT 1";
await cmd.ExecuteScalarAsync();
StatusMessage = $"MySQL connection successful ({MySqlHost}:{port}).";
}
catch (MySqlException ex)
{
StatusMessage = $"MySQL connection failed: {ex.Message}";
StatusMessage = $"MySQL connection successful ({MySqlHost}:{port} via SSH tunnel).";
}
catch (Exception ex)
{
StatusMessage = $"MySQL test error: {ex.Message}";
StatusMessage = $"MySQL connection failed: {ex.Message}";
}
finally
{

View File

@@ -48,26 +48,20 @@
</StackPanel>
</Expander>
<!-- SMB / CIFS credentials (per-instance, defaults from global settings) -->
<Expander Header="SMB / CIFS credentials">
<!-- NFS volume settings (per-instance, defaults from global settings) -->
<Expander Header="NFS volume settings">
<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="NFS Server" FontSize="12" />
<TextBox Text="{Binding NfsServer}" Watermark="e.g. 192.168.1.100" />
<TextBlock Text="Share Name" FontSize="12" />
<TextBox Text="{Binding CifsShareName}" Watermark="e.g. u548897-sub1" />
<TextBlock Text="Export Path" FontSize="12" />
<TextBox Text="{Binding NfsExport}" Watermark="e.g. /srv/nfs" />
<TextBlock Text="Share Folder (optional)" FontSize="12" />
<TextBox Text="{Binding CifsShareFolder}" Watermark="e.g. ots_cms (leave empty for share root)" />
<TextBlock Text="Export Folder (optional)" FontSize="12" />
<TextBox Text="{Binding NfsExportFolder}" Watermark="e.g. ots_cms (leave empty for export root)" />
<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" />
<TextBlock Text="Extra Mount Options" FontSize="12" />
<TextBox Text="{Binding NfsExtraOptions}" Watermark="Additional options after nfsvers=4,proto=tcp" />
</StackPanel>
</Expander>
@@ -102,46 +96,95 @@
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" />
<!-- ══ RIGHT COLUMN — tabbed preview ══ -->
<TabControl Grid.Column="2" VerticalAlignment="Top">
<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" />
<!-- Tab 1: Resource preview -->
<TabItem Header="Resource Preview">
<Border Background="#1e1e2e"
CornerRadius="8"
Padding="16,14">
<StackPanel Spacing="6">
<TextBlock Text="Resource Preview" FontSize="14" FontWeight="SemiBold"
Foreground="#cdd6f4" Margin="0,0,0,8" />
<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="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="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="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="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="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="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="NFS 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="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="Docker Secrets" FontSize="11" Foreground="#6c7086" />
<TextBlock Text="{Binding PreviewSecret}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#fab387" />
<TextBlock Text="{Binding PreviewSecretUser}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#fab387" />
<TextBlock Text="{Binding PreviewSecretHost}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#fab387" />
<TextBlock Text="{Binding PreviewSecretPort}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#fab387" 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>
<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>
</TabItem>
<!-- Tab 2: Rendered compose YML -->
<TabItem Header="Compose YML">
<Border Background="#1e1e2e"
CornerRadius="8"
Padding="16,14">
<Grid RowDefinitions="Auto,*,Auto">
<!-- Load button -->
<Button Grid.Row="0"
Content="↻ Load / Refresh YML"
Command="{Binding LoadYmlPreviewCommand}"
IsEnabled="{Binding !IsLoadingYml}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="10,6"
Margin="0,0,0,8" />
<!-- YML text box -->
<TextBox Grid.Row="1"
Text="{Binding PreviewYml}"
IsReadOnly="True"
AcceptsReturn="True"
FontFamily="Cascadia Mono, Consolas, monospace"
FontSize="11"
MinHeight="320"
Watermark="Click 'Load / Refresh YML' to preview the rendered compose file…"
TextWrapping="NoWrap" />
<!-- Copy button -->
<Button Grid.Row="2"
Content="⎘ Copy to Clipboard"
Command="{Binding CopyYmlCommand}"
IsEnabled="{Binding HasPreviewYml}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="10,6"
Margin="0,8,0,0" />
</Grid>
</Border>
</TabItem>
</TabControl>
</Grid>
</ScrollViewer>

View File

@@ -18,6 +18,37 @@
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Margin="0,8,0,0"
FontSize="12" Foreground="#a6adc8" />
<!-- Remote Nodes panel -->
<Border DockPanel.Dock="Bottom" Margin="0,12,0,0" Padding="8"
Background="#1e1e2e" CornerRadius="8"
MinHeight="120" MaxHeight="300">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8">
<TextBlock Text="Cluster Nodes" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center" />
<Button Content="List Nodes" Command="{Binding ListNodesCommand}" />
<TextBlock Text="{Binding NodesStatusMessage}" FontSize="12"
Foreground="#a6adc8" VerticalAlignment="Center" Margin="8,0,0,0" />
</StackPanel>
<DataGrid ItemsSource="{Binding RemoteNodes}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserResizeColumns="True"
HeadersVisibility="Column">
<DataGrid.Columns>
<DataGridTextColumn Header="Hostname" Binding="{Binding Hostname}" Width="160" />
<DataGridTextColumn Header="IP Address" Binding="{Binding IpAddress}" Width="130" />
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="80" />
<DataGridTextColumn Header="Availability" Binding="{Binding Availability}" Width="100" />
<DataGridTextColumn Header="Manager Status" Binding="{Binding ManagerStatus}" Width="120" />
<DataGridTextColumn Header="Engine" Binding="{Binding EngineVersion}" Width="100" />
<DataGridTextColumn Header="ID" Binding="{Binding Id}" Width="200" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Border>
<!-- 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">

View File

@@ -7,21 +7,13 @@
<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}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Refresh" Command="{Binding LoadInstancesCommand}" />
<Button Content="Inspect" Command="{Binding InspectInstanceCommand}" />
<Button Content="Delete" Command="{Binding DeleteInstanceCommand}" />
<Button Content="Refresh" Command="{Binding LoadInstancesCommand}" />
<Button Content="Rotate DB Password" Command="{Binding RotateMySqlPasswordCommand}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Generate a new MySQL password, update the Docker secret, and redeploy the stack." />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
@@ -57,25 +49,7 @@
</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>
<!-- Remote stacks panel removed: all live stacks are shown in the main list below -->
<!-- Instance list -->
<DataGrid ItemsSource="{Binding Instances}"
@@ -85,13 +59,10 @@
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" />
<DataGridTextColumn Header="Stack" Binding="{Binding StackName}" Width="150" />
<DataGridTextColumn Header="Abbrev" Binding="{Binding CustomerAbbrev}" Width="70" />
<DataGridTextColumn Header="Services" Binding="{Binding ServiceCount}" Width="70" />
<DataGridTextColumn Header="Host" Binding="{Binding HostLabel}" Width="150" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>

View File

@@ -20,7 +20,7 @@
<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="Stack" Binding="{Binding StackName}" Width="120" />
<DataGridTextColumn Header="Message" Binding="{Binding Message}" Width="*" />
<DataGridTextColumn Header="Duration" Binding="{Binding DurationMs, StringFormat='{}{0}ms'}" Width="80" />
</DataGrid.Columns>

View File

@@ -116,31 +116,25 @@
</StackPanel>
</Border>
<!-- ═══ CIFS Volumes ═══ -->
<!-- ═══ NFS Volumes ═══ -->
<Border Background="#1e1e2e" CornerRadius="8" Padding="16">
<StackPanel Spacing="8">
<TextBlock Text="CIFS Volumes" FontSize="16" FontWeight="SemiBold"
<TextBlock Text="NFS 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."
<TextBlock Text="Network share settings for Docker volumes. Volumes will be mounted via NFS."
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="NFS Server (hostname/IP)" FontSize="12" />
<TextBox Text="{Binding NfsServer}" Watermark="nas.local" />
<TextBlock Text="Share Name" FontSize="12" />
<TextBox Text="{Binding CifsShareName}" Watermark="u548897-sub1" />
<TextBlock Text="Export Path" FontSize="12" />
<TextBox Text="{Binding NfsExport}" Watermark="/srv/nfs" />
<TextBlock Text="Share Folder (optional)" FontSize="12" />
<TextBox Text="{Binding CifsShareFolder}" Watermark="ots_cms (leave empty for share root)" />
<TextBlock Text="Username" FontSize="12" />
<TextBox Text="{Binding CifsUsername}" Watermark="smbuser" />
<TextBlock Text="Password" FontSize="12" />
<TextBox Text="{Binding CifsPassword}" PasswordChar="●" />
<TextBlock Text="Export Folder (optional)" FontSize="12" />
<TextBox Text="{Binding NfsExportFolder}" Watermark="ots_cms (leave empty for export root)" />
<TextBlock Text="Extra Mount Options" FontSize="12" />
<TextBox Text="{Binding CifsOptions}" Watermark="file_mode=0777,dir_mode=0777" />
<TextBox Text="{Binding NfsOptions}" Watermark="Additional options after nfsvers=4,proto=tcp" />
</StackPanel>
</Border>
@@ -181,7 +175,7 @@
<TextBox Text="{Binding DefaultMySqlDbTemplate}" Watermark="{}{abbrev}_cms_db" />
<TextBlock Text="MySQL User Template" FontSize="12" />
<TextBox Text="{Binding DefaultMySqlUserTemplate}" Watermark="{}{abbrev}_cms" />
<TextBox Text="{Binding DefaultMySqlUserTemplate}" Watermark="{}{abbrev}_cms_user" />
<TextBlock Text="PHP Settings" FontSize="13" FontWeight="SemiBold" Margin="0,12,0,4" />

View File

@@ -39,7 +39,7 @@
"ThemeHostPath": "/cms/ots-theme",
"LibraryShareSubPath": "{abbrev}-cms-library",
"MySqlDatabaseTemplate": "{abbrev}_cms_db",
"MySqlUserTemplate": "{abbrev}_cms",
"MySqlUserTemplate": "{abbrev}_cms_user",
"BaseHostHttpPort": 8080
}
}