820 lines
30 KiB
C#
820 lines
30 KiB
C#
// -----------------------------------------------------------------------------
|
|
// CommandHandlers.Federation.cs
|
|
// Sprint: SPRINT_8200_0014_0002 (Delta Bundle Export), SPRINT_8200_0014_0003 (Bundle Import)
|
|
// Description: Command handlers for federation bundle export and import operations.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Spectre.Console;
|
|
using System.Net.Http.Headers;
|
|
using System.Text.Json;
|
|
|
|
namespace StellaOps.Cli.Commands;
|
|
|
|
internal static partial class CommandHandlers
|
|
{
|
|
internal static async Task<int> HandleFederationBundleExportAsync(
|
|
IServiceProvider services,
|
|
string? sinceCursor,
|
|
string? output,
|
|
bool sign,
|
|
int compressLevel,
|
|
int maxItems,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("[blue]Exporting federation bundle...[/]");
|
|
AnsiConsole.MarkupLine($" Since Cursor: [bold]{Markup.Escape(sinceCursor ?? "(none - full export)")}[/]");
|
|
AnsiConsole.MarkupLine($" Sign: {sign}");
|
|
AnsiConsole.MarkupLine($" Compression Level: {compressLevel}");
|
|
AnsiConsole.MarkupLine($" Max Items: {maxItems}");
|
|
}
|
|
|
|
try
|
|
{
|
|
var httpClientFactory = services.GetService<IHttpClientFactory>();
|
|
if (httpClientFactory == null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error: HTTP client factory not available.[/]");
|
|
return 1;
|
|
}
|
|
|
|
var client = httpClientFactory.CreateClient("Concelier");
|
|
|
|
// Build query string
|
|
var queryParams = new List<string>();
|
|
if (!string.IsNullOrEmpty(sinceCursor))
|
|
queryParams.Add($"since_cursor={Uri.EscapeDataString(sinceCursor)}");
|
|
queryParams.Add($"sign={sign.ToString().ToLowerInvariant()}");
|
|
queryParams.Add($"max_items={maxItems}");
|
|
queryParams.Add($"compress_level={compressLevel}");
|
|
|
|
var url = $"/api/v1/federation/export?{string.Join("&", queryParams)}";
|
|
|
|
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var error = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
AnsiConsole.MarkupLine($"[red]Error: {response.StatusCode}[/]");
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]{Markup.Escape(error)}[/]");
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
// Extract metadata from headers
|
|
var bundleHash = response.Headers.TryGetValues("X-Bundle-Hash", out var hashValues)
|
|
? hashValues.FirstOrDefault()
|
|
: null;
|
|
var exportCursor = response.Headers.TryGetValues("X-Export-Cursor", out var cursorValues)
|
|
? cursorValues.FirstOrDefault()
|
|
: null;
|
|
var itemsCount = response.Headers.TryGetValues("X-Items-Count", out var countValues)
|
|
? countValues.FirstOrDefault()
|
|
: null;
|
|
|
|
// Determine output stream
|
|
Stream outputStream;
|
|
bool disposeStream;
|
|
|
|
if (string.IsNullOrEmpty(output))
|
|
{
|
|
outputStream = Console.OpenStandardOutput();
|
|
disposeStream = false;
|
|
}
|
|
else
|
|
{
|
|
outputStream = File.Create(output);
|
|
disposeStream = true;
|
|
}
|
|
|
|
try
|
|
{
|
|
await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
|
await contentStream.CopyToAsync(outputStream, cancellationToken);
|
|
}
|
|
finally
|
|
{
|
|
if (disposeStream)
|
|
{
|
|
await outputStream.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
// Output metadata
|
|
if (!string.IsNullOrEmpty(output))
|
|
{
|
|
if (json)
|
|
{
|
|
var metadata = new
|
|
{
|
|
bundle_hash = bundleHash,
|
|
export_cursor = exportCursor,
|
|
since_cursor = sinceCursor,
|
|
items_count = int.TryParse(itemsCount, out var count) ? count : 0,
|
|
output_path = output
|
|
};
|
|
AnsiConsole.WriteLine(JsonSerializer.Serialize(metadata, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true
|
|
}));
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[green]Bundle exported successfully.[/]");
|
|
AnsiConsole.MarkupLine($" Output: [bold]{Markup.Escape(output)}[/]");
|
|
if (bundleHash != null)
|
|
AnsiConsole.MarkupLine($" Hash: [dim]{bundleHash}[/]");
|
|
if (exportCursor != null)
|
|
AnsiConsole.MarkupLine($" New Cursor: [bold]{exportCursor}[/]");
|
|
if (itemsCount != null)
|
|
AnsiConsole.MarkupLine($" Items: {itemsCount}");
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Connection error: {Markup.Escape(ex.Message)}[/]");
|
|
return 1;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.WriteException(ex);
|
|
}
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleFederationBundlePreviewAsync(
|
|
IServiceProvider services,
|
|
string? sinceCursor,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("[blue]Previewing federation export...[/]");
|
|
AnsiConsole.MarkupLine($" Since Cursor: [bold]{Markup.Escape(sinceCursor ?? "(none - full export)")}[/]");
|
|
}
|
|
|
|
try
|
|
{
|
|
var httpClientFactory = services.GetService<IHttpClientFactory>();
|
|
if (httpClientFactory == null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error: HTTP client factory not available.[/]");
|
|
return 1;
|
|
}
|
|
|
|
var client = httpClientFactory.CreateClient("Concelier");
|
|
|
|
var url = "/api/v1/federation/export/preview";
|
|
if (!string.IsNullOrEmpty(sinceCursor))
|
|
{
|
|
url += $"?since_cursor={Uri.EscapeDataString(sinceCursor)}";
|
|
}
|
|
|
|
using var response = await client.GetAsync(url, cancellationToken);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var error = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
AnsiConsole.MarkupLine($"[red]Error: {response.StatusCode}[/]");
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[grey]{Markup.Escape(error)}[/]");
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(content);
|
|
}
|
|
else
|
|
{
|
|
var preview = JsonSerializer.Deserialize<PreviewResponse>(content, new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
});
|
|
|
|
if (preview != null)
|
|
{
|
|
AnsiConsole.MarkupLine("[green]Export Preview[/]");
|
|
AnsiConsole.MarkupLine($" Since Cursor: [dim]{sinceCursor ?? "(full export)"}[/]");
|
|
AnsiConsole.MarkupLine($" Estimated Canonicals: [bold]{preview.EstimatedCanonicals:N0}[/]");
|
|
AnsiConsole.MarkupLine($" Estimated Edges: [bold]{preview.EstimatedEdges:N0}[/]");
|
|
AnsiConsole.MarkupLine($" Estimated Deletions: [bold]{preview.EstimatedDeletions:N0}[/]");
|
|
AnsiConsole.MarkupLine($" Estimated Size: [bold]{preview.EstimatedSizeMb:F2} MB[/]");
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.WriteLine(content);
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Connection error: {Markup.Escape(ex.Message)}[/]");
|
|
return 1;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.WriteException(ex);
|
|
}
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
private sealed class PreviewResponse
|
|
{
|
|
public string? SinceCursor { get; set; }
|
|
public int EstimatedCanonicals { get; set; }
|
|
public int EstimatedEdges { get; set; }
|
|
public int EstimatedDeletions { get; set; }
|
|
public long EstimatedSizeBytes { get; set; }
|
|
public double EstimatedSizeMb { get; set; }
|
|
}
|
|
|
|
internal static async Task<int> HandleFederationBundleImportAsync(
|
|
IServiceProvider services,
|
|
string inputPath,
|
|
bool dryRun,
|
|
bool skipSignature,
|
|
string? onConflict,
|
|
bool force,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("[blue]Importing federation bundle...[/]");
|
|
AnsiConsole.MarkupLine($" File: [bold]{Markup.Escape(inputPath)}[/]");
|
|
AnsiConsole.MarkupLine($" Dry Run: {dryRun}");
|
|
AnsiConsole.MarkupLine($" Skip Signature: {skipSignature}");
|
|
AnsiConsole.MarkupLine($" On Conflict: {onConflict ?? "PreferRemote"}");
|
|
AnsiConsole.MarkupLine($" Force: {force}");
|
|
}
|
|
|
|
if (!File.Exists(inputPath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: File not found: {Markup.Escape(inputPath)}[/]");
|
|
return 1;
|
|
}
|
|
|
|
try
|
|
{
|
|
var httpClientFactory = services.GetService<IHttpClientFactory>();
|
|
if (httpClientFactory == null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error: HTTP client factory not available.[/]");
|
|
return 1;
|
|
}
|
|
|
|
var client = httpClientFactory.CreateClient("Concelier");
|
|
|
|
// Build query string
|
|
var queryParams = new List<string>();
|
|
if (dryRun)
|
|
queryParams.Add("dry_run=true");
|
|
if (skipSignature)
|
|
queryParams.Add("skip_signature=true");
|
|
if (!string.IsNullOrEmpty(onConflict))
|
|
queryParams.Add($"on_conflict={Uri.EscapeDataString(onConflict)}");
|
|
if (force)
|
|
queryParams.Add("force=true");
|
|
|
|
var url = "/api/v1/federation/import";
|
|
if (queryParams.Count > 0)
|
|
url += $"?{string.Join("&", queryParams)}";
|
|
|
|
await using var fileStream = File.OpenRead(inputPath);
|
|
using var content = new StreamContent(fileStream);
|
|
content.Headers.ContentType = new MediaTypeHeaderValue("application/zstd");
|
|
|
|
using var response = await client.PostAsync(url, content, cancellationToken);
|
|
|
|
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(responseContent);
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Import failed: {response.StatusCode}[/]");
|
|
try
|
|
{
|
|
var errorResponse = JsonSerializer.Deserialize<ImportErrorResponse>(responseContent, new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
});
|
|
if (errorResponse?.FailureReason != null)
|
|
{
|
|
AnsiConsole.MarkupLine($" Reason: [yellow]{Markup.Escape(errorResponse.FailureReason)}[/]");
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
if (verbose)
|
|
AnsiConsole.MarkupLine($"[grey]{Markup.Escape(responseContent)}[/]");
|
|
}
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(responseContent);
|
|
}
|
|
else
|
|
{
|
|
var result = JsonSerializer.Deserialize<ImportSuccessResponse>(responseContent, new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
});
|
|
|
|
if (result != null)
|
|
{
|
|
var status = dryRun ? "[yellow]DRY RUN[/]" : "[green]SUCCESS[/]";
|
|
AnsiConsole.MarkupLine($"{status} Bundle import completed.");
|
|
AnsiConsole.MarkupLine($" Bundle Hash: [dim]{result.BundleHash}[/]");
|
|
AnsiConsole.MarkupLine($" Cursor: [bold]{result.ImportedCursor}[/]");
|
|
if (result.Counts != null)
|
|
{
|
|
AnsiConsole.MarkupLine($" Created: [green]{result.Counts.CanonicalCreated:N0}[/]");
|
|
AnsiConsole.MarkupLine($" Updated: [blue]{result.Counts.CanonicalUpdated:N0}[/]");
|
|
AnsiConsole.MarkupLine($" Skipped: [dim]{result.Counts.CanonicalSkipped:N0}[/]");
|
|
AnsiConsole.MarkupLine($" Edges: [blue]{result.Counts.EdgesAdded:N0}[/]");
|
|
AnsiConsole.MarkupLine($" Deletions: [yellow]{result.Counts.DeletionsProcessed:N0}[/]");
|
|
}
|
|
if (result.Conflicts?.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine($" Conflicts: [yellow]{result.Conflicts.Count}[/]");
|
|
}
|
|
AnsiConsole.MarkupLine($" Duration: {result.DurationMs:F0}ms");
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Connection error: {Markup.Escape(ex.Message)}[/]");
|
|
return 1;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.WriteException(ex);
|
|
}
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleFederationBundleValidateAsync(
|
|
IServiceProvider services,
|
|
string inputPath,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("[blue]Validating federation bundle...[/]");
|
|
AnsiConsole.MarkupLine($" File: [bold]{Markup.Escape(inputPath)}[/]");
|
|
}
|
|
|
|
if (!File.Exists(inputPath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: File not found: {Markup.Escape(inputPath)}[/]");
|
|
return 1;
|
|
}
|
|
|
|
try
|
|
{
|
|
var httpClientFactory = services.GetService<IHttpClientFactory>();
|
|
if (httpClientFactory == null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error: HTTP client factory not available.[/]");
|
|
return 1;
|
|
}
|
|
|
|
var client = httpClientFactory.CreateClient("Concelier");
|
|
|
|
await using var fileStream = File.OpenRead(inputPath);
|
|
using var content = new StreamContent(fileStream);
|
|
content.Headers.ContentType = new MediaTypeHeaderValue("application/zstd");
|
|
|
|
using var response = await client.PostAsync("/api/v1/federation/import/validate", content, cancellationToken);
|
|
|
|
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(responseContent);
|
|
}
|
|
else
|
|
{
|
|
var result = JsonSerializer.Deserialize<ValidateResponse>(responseContent, new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
});
|
|
|
|
if (result != null)
|
|
{
|
|
var status = result.IsValid ? "[green]VALID[/]" : "[red]INVALID[/]";
|
|
AnsiConsole.MarkupLine($"{status} Bundle validation result");
|
|
AnsiConsole.MarkupLine($" Hash Valid: {(result.HashValid ? "[green]Yes[/]" : "[red]No[/]")}");
|
|
AnsiConsole.MarkupLine($" Signature Valid: {(result.SignatureValid ? "[green]Yes[/]" : "[yellow]No/Skipped[/]")}");
|
|
AnsiConsole.MarkupLine($" Cursor Valid: {(result.CursorValid ? "[green]Yes[/]" : "[yellow]No[/]")}");
|
|
|
|
if (result.Errors?.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Errors:[/]");
|
|
foreach (var error in result.Errors)
|
|
{
|
|
AnsiConsole.MarkupLine($" - {Markup.Escape(error)}");
|
|
}
|
|
}
|
|
|
|
if (result.Warnings?.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]Warnings:[/]");
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
AnsiConsole.MarkupLine($" - {Markup.Escape(warning)}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return response.IsSuccessStatusCode ? 0 : 1;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Connection error: {Markup.Escape(ex.Message)}[/]");
|
|
return 1;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.WriteException(ex);
|
|
}
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
private sealed class ImportErrorResponse
|
|
{
|
|
public bool Success { get; set; }
|
|
public string? BundleHash { get; set; }
|
|
public string? FailureReason { get; set; }
|
|
public double DurationMs { get; set; }
|
|
}
|
|
|
|
private sealed class ImportSuccessResponse
|
|
{
|
|
public bool Success { get; set; }
|
|
public string? BundleHash { get; set; }
|
|
public string? ImportedCursor { get; set; }
|
|
public ImportCountsResponse? Counts { get; set; }
|
|
public List<object>? Conflicts { get; set; }
|
|
public double DurationMs { get; set; }
|
|
public bool DryRun { get; set; }
|
|
}
|
|
|
|
private sealed class ImportCountsResponse
|
|
{
|
|
public int CanonicalCreated { get; set; }
|
|
public int CanonicalUpdated { get; set; }
|
|
public int CanonicalSkipped { get; set; }
|
|
public int EdgesAdded { get; set; }
|
|
public int DeletionsProcessed { get; set; }
|
|
public int Total { get; set; }
|
|
}
|
|
|
|
private sealed class ValidateResponse
|
|
{
|
|
public bool IsValid { get; set; }
|
|
public List<string>? Errors { get; set; }
|
|
public List<string>? Warnings { get; set; }
|
|
public bool HashValid { get; set; }
|
|
public bool SignatureValid { get; set; }
|
|
public bool CursorValid { get; set; }
|
|
}
|
|
|
|
internal static async Task<int> HandleFederationSitesListAsync(
|
|
IServiceProvider services,
|
|
bool enabledOnly,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine("[blue]Listing federation sites...[/]");
|
|
}
|
|
|
|
try
|
|
{
|
|
var httpClientFactory = services.GetService<IHttpClientFactory>();
|
|
if (httpClientFactory == null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error: HTTP client factory not available.[/]");
|
|
return 1;
|
|
}
|
|
|
|
var client = httpClientFactory.CreateClient("Concelier");
|
|
|
|
var url = "/api/v1/federation/sites";
|
|
if (enabledOnly)
|
|
url += "?enabled_only=true";
|
|
|
|
using var response = await client.GetAsync(url, cancellationToken);
|
|
|
|
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {response.StatusCode}[/]");
|
|
if (verbose)
|
|
AnsiConsole.MarkupLine($"[grey]{Markup.Escape(responseContent)}[/]");
|
|
return 1;
|
|
}
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(responseContent);
|
|
}
|
|
else
|
|
{
|
|
var result = JsonSerializer.Deserialize<SitesListResponse>(responseContent, new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
});
|
|
|
|
if (result?.Sites != null && result.Sites.Count > 0)
|
|
{
|
|
var table = new Table();
|
|
table.AddColumn("Site ID");
|
|
table.AddColumn("Display Name");
|
|
table.AddColumn("Enabled");
|
|
table.AddColumn("Last Sync");
|
|
table.AddColumn("Imports");
|
|
|
|
foreach (var site in result.Sites)
|
|
{
|
|
var enabledMark = site.Enabled ? "[green]Yes[/]" : "[red]No[/]";
|
|
var lastSync = site.LastSyncAt?.ToString("g") ?? "-";
|
|
table.AddRow(
|
|
site.SiteId ?? "-",
|
|
site.DisplayName ?? "-",
|
|
enabledMark,
|
|
lastSync,
|
|
site.TotalImports.ToString());
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.MarkupLine($"\n[dim]{result.Count} site(s)[/]");
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[dim]No sites found.[/]");
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Connection error: {Markup.Escape(ex.Message)}[/]");
|
|
return 1;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
|
|
if (verbose)
|
|
AnsiConsole.WriteException(ex);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleFederationSitesShowAsync(
|
|
IServiceProvider services,
|
|
string siteId,
|
|
bool json,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[blue]Fetching site details for: {Markup.Escape(siteId)}[/]");
|
|
}
|
|
|
|
try
|
|
{
|
|
var httpClientFactory = services.GetService<IHttpClientFactory>();
|
|
if (httpClientFactory == null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error: HTTP client factory not available.[/]");
|
|
return 1;
|
|
}
|
|
|
|
var client = httpClientFactory.CreateClient("Concelier");
|
|
|
|
using var response = await client.GetAsync($"/api/v1/federation/sites/{Uri.EscapeDataString(siteId)}", cancellationToken);
|
|
|
|
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Site '{Markup.Escape(siteId)}' not found.[/]");
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {response.StatusCode}[/]");
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
if (json)
|
|
{
|
|
AnsiConsole.WriteLine(responseContent);
|
|
}
|
|
else
|
|
{
|
|
var site = JsonSerializer.Deserialize<SiteDetailsResponse>(responseContent, new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
});
|
|
|
|
if (site != null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[bold]Site: {Markup.Escape(site.SiteId ?? "")}[/]");
|
|
AnsiConsole.MarkupLine($" Display Name: {site.DisplayName ?? "(none)"}");
|
|
AnsiConsole.MarkupLine($" Enabled: {(site.Enabled ? "[green]Yes[/]" : "[red]No[/]")}");
|
|
AnsiConsole.MarkupLine($" Last Sync: {site.LastSyncAt?.ToString("g") ?? "(never)"}");
|
|
AnsiConsole.MarkupLine($" Last Cursor: [dim]{site.LastCursor ?? "(none)"}[/]");
|
|
AnsiConsole.MarkupLine($" Total Imports: {site.TotalImports}");
|
|
|
|
if (site.RecentHistory?.Count > 0)
|
|
{
|
|
AnsiConsole.MarkupLine("\n[bold]Recent Sync History:[/]");
|
|
var table = new Table();
|
|
table.AddColumn("Imported At");
|
|
table.AddColumn("Items");
|
|
table.AddColumn("Bundle Hash");
|
|
|
|
foreach (var entry in site.RecentHistory)
|
|
{
|
|
table.AddRow(
|
|
entry.ImportedAt.ToString("g"),
|
|
entry.ItemCount.ToString(),
|
|
entry.BundleHash?.Length > 16 ? entry.BundleHash[..16] + "..." : entry.BundleHash ?? "-"
|
|
);
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Connection error: {Markup.Escape(ex.Message)}[/]");
|
|
return 1;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
|
|
if (verbose)
|
|
AnsiConsole.WriteException(ex);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
internal static async Task<int> HandleFederationSitesSetEnabledAsync(
|
|
IServiceProvider services,
|
|
string siteId,
|
|
bool enabled,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var action = enabled ? "Enabling" : "Disabling";
|
|
if (verbose)
|
|
{
|
|
AnsiConsole.MarkupLine($"[blue]{action} site: {Markup.Escape(siteId)}[/]");
|
|
}
|
|
|
|
try
|
|
{
|
|
var httpClientFactory = services.GetService<IHttpClientFactory>();
|
|
if (httpClientFactory == null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error: HTTP client factory not available.[/]");
|
|
return 1;
|
|
}
|
|
|
|
var client = httpClientFactory.CreateClient("Concelier");
|
|
|
|
var payload = new { enabled };
|
|
var content = new StringContent(
|
|
JsonSerializer.Serialize(payload),
|
|
System.Text.Encoding.UTF8,
|
|
"application/json");
|
|
|
|
using var response = await client.PutAsync(
|
|
$"/api/v1/federation/sites/{Uri.EscapeDataString(siteId)}/policy",
|
|
content,
|
|
cancellationToken);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
AnsiConsole.MarkupLine($"[red]Error: {response.StatusCode}[/]");
|
|
if (verbose)
|
|
AnsiConsole.MarkupLine($"[grey]{Markup.Escape(errorContent)}[/]");
|
|
return 1;
|
|
}
|
|
|
|
var result = enabled ? "[green]enabled[/]" : "[yellow]disabled[/]";
|
|
AnsiConsole.MarkupLine($"Site '{Markup.Escape(siteId)}' {result}.");
|
|
|
|
return 0;
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Connection error: {Markup.Escape(ex.Message)}[/]");
|
|
return 1;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
|
|
if (verbose)
|
|
AnsiConsole.WriteException(ex);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
private sealed class SitesListResponse
|
|
{
|
|
public List<SiteInfo>? Sites { get; set; }
|
|
public int Count { get; set; }
|
|
}
|
|
|
|
private class SiteInfo
|
|
{
|
|
public string? SiteId { get; set; }
|
|
public string? DisplayName { get; set; }
|
|
public bool Enabled { get; set; }
|
|
public DateTimeOffset? LastSyncAt { get; set; }
|
|
public string? LastCursor { get; set; }
|
|
public int TotalImports { get; set; }
|
|
}
|
|
|
|
private sealed class SiteDetailsResponse : SiteInfo
|
|
{
|
|
public List<SyncHistoryEntry>? RecentHistory { get; set; }
|
|
}
|
|
|
|
private sealed class SyncHistoryEntry
|
|
{
|
|
public string? Cursor { get; set; }
|
|
public string? BundleHash { get; set; }
|
|
public int ItemCount { get; set; }
|
|
public DateTimeOffset ExportedAt { get; set; }
|
|
public DateTimeOffset ImportedAt { get; set; }
|
|
}
|
|
}
|