feat: Implement container logs functionality in InstancesViewModel

- Added properties for managing container logs, including log entries, service filters, and auto-refresh options.
- Introduced commands for refreshing logs, toggling auto-refresh, and closing the logs panel.
- Implemented log fetching logic with error handling and status messages.
- Integrated log display in the InstancesView with a dedicated logs panel.

feat: Enhance navigation to Instances page with auto-selection

- Added method to navigate to the Instances page and auto-select an instance based on abbreviation.

feat: Update SettingsViewModel to load and save Bitwarden configuration

- Integrated Bitwarden configuration loading from IOptions and saving to appsettings.json.
- Added properties for Bitwarden instance project ID and connection status.
- Updated UI to reflect Bitwarden settings and connection status.

feat: Add advanced options for instance creation

- Introduced a new expander in CreateInstanceView for advanced options, including purging stale volumes.

feat: Improve InstanceDetailsWindow with pending setup banner

- Added a banner to indicate pending setup for Xibo OAuth credentials, with editable fields for client ID and secret.

fix: Update appsettings.json to include Bitwarden configuration structure

- Added Bitwarden section to appsettings.json for storing configuration values.

chore: Update Docker Compose template with health checks

- Added health check configuration for web service in template.yml to ensure service availability.

refactor: Drop AppSettings table from database

- Removed AppSettings table and related migration files as part of database cleanup.

feat: Create ServiceLogEntry DTO for log management

- Added ServiceLogEntry class to represent individual log entries from Docker services.
This commit is contained in:
Matt Batchelder
2026-02-25 17:39:17 -05:00
parent a1c987ff21
commit 90eb649940
35 changed files with 1807 additions and 621 deletions

View File

@@ -6,6 +6,7 @@ using OTSSignsOrchestrator.Core.Configuration;
using OTSSignsOrchestrator.Core.Models.DTOs;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services;
using ServiceLogEntry = OTSSignsOrchestrator.Core.Models.DTOs.ServiceLogEntry;
namespace OTSSignsOrchestrator.Desktop.Services;
@@ -441,6 +442,121 @@ public class SshDockerCliService : IDockerCliService
.ToList();
}
public async Task<List<ServiceLogEntry>> GetServiceLogsAsync(string stackName, string? serviceName = null, int tailLines = 200)
{
EnsureHost();
// Determine which services to fetch logs for
List<string> serviceNames;
if (!string.IsNullOrEmpty(serviceName))
{
serviceNames = new List<string> { serviceName };
}
else
{
var services = await InspectStackServicesAsync(stackName);
serviceNames = services.Select(s => s.Name).ToList();
}
var allEntries = new List<ServiceLogEntry>();
foreach (var svcName in serviceNames)
{
try
{
var cmd = $"docker service logs --timestamps --no-trunc --tail {tailLines} {svcName} 2>&1";
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(_currentHost!, cmd, TimeSpan.FromSeconds(15));
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
{
_logger.LogDebug("No logs returned for service {Service} (exit={ExitCode})", svcName, exitCode);
continue;
}
// Parse each line. Docker service logs format with --timestamps:
// <timestamp> <service>.<replica>.<taskid>@<node> | <message>
// or sometimes just:
// <timestamp> <service>.<replica>.<taskid> <message>
foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var entry = ParseLogLine(line, svcName, stackName);
if (entry != null)
allEntries.Add(entry);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch logs for service {Service}", svcName);
}
}
return allEntries.OrderBy(e => e.Timestamp).ToList();
}
/// <summary>
/// Parses a single line from <c>docker service logs --timestamps</c> output.
/// </summary>
private static ServiceLogEntry? ParseLogLine(string line, string serviceName, string stackName)
{
if (string.IsNullOrWhiteSpace(line))
return null;
// Format: "2026-02-25T14:30:45.123456789Z service.replica.taskid@node | message"
// The timestamp is always the first space-delimited token when --timestamps is used.
var firstSpace = line.IndexOf(' ');
if (firstSpace <= 0)
return new ServiceLogEntry
{
Timestamp = DateTimeOffset.UtcNow,
Source = serviceName,
ServiceName = StripStackPrefix(serviceName, stackName),
Message = line
};
var timestampStr = line[..firstSpace];
var rest = line[(firstSpace + 1)..].TrimStart();
// Try to parse the timestamp
if (!DateTimeOffset.TryParse(timestampStr, out var timestamp))
{
// If timestamp parsing fails, treat the whole line as the message
return new ServiceLogEntry
{
Timestamp = DateTimeOffset.UtcNow,
Source = serviceName,
ServiceName = StripStackPrefix(serviceName, stackName),
Message = line
};
}
// Split source and message on the pipe separator
var source = serviceName;
var message = rest;
var pipeIndex = rest.IndexOf('|');
if (pipeIndex >= 0)
{
source = rest[..pipeIndex].Trim();
message = rest[(pipeIndex + 1)..].TrimStart();
}
return new ServiceLogEntry
{
Timestamp = timestamp,
Source = source,
ServiceName = StripStackPrefix(serviceName, stackName),
Message = message
};
}
/// <summary>
/// Strips the stack name prefix from a fully-qualified service name.
/// e.g. "acm-cms-stack_acm-web" → "acm-web"
/// </summary>
private static string StripStackPrefix(string serviceName, string stackName)
{
var prefix = stackName + "_";
return serviceName.StartsWith(prefix) ? serviceName[prefix.Length..] : serviceName;
}
private void EnsureHost()
{
if (_currentHost == null)