Added container rebuild flow with real-time logs and updated UI, backend implementation, config options, and container helper API integration.

This commit is contained in:
2026-01-23 16:38:42 +01:00
parent 76a8a72e83
commit e2f344ab4e
11 changed files with 300 additions and 37 deletions

View File

@@ -0,0 +1,7 @@
namespace Moonlight.Api.Configuration;
public class ContainerHelperOptions
{
public bool IsEnabled { get; set; }
public string Url { get; set; } = "http://helper:8080";
}

View File

@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moonlight.Api.Services;
namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController]
[Route("api/admin/ch")]
public class ChController : Controller
{
private readonly ContainerHelperService ContainerHelperService;
public ChController(ContainerHelperService containerHelperService)
{
ContainerHelperService = containerHelperService;
}
[HttpPost("rebuild")]
public Task<IResult> RebuildAsync()
{
var result = ContainerHelperService.RebuildAsync();
return Task.FromResult<IResult>(
TypedResults.ServerSentEvents(result)
);
}
}

View File

@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Moonlight.Api.Http.Controllers;
[ApiController]
[Route("api/ping")]
public class PingController : Controller
{
[HttpGet]
[AllowAnonymous]
public IActionResult Get() => Ok("Pong");
}

View File

@@ -47,7 +47,7 @@ public class ApplicationService : IHostedLifecycleService
// TODO: Update / version check
VersionName = "v2.1.0 (a2d4edc0e5)";
IsUpToDate = true;
IsUpToDate = false;
OperatingSystem = OsHelper.GetName();
}

View File

@@ -0,0 +1,67 @@
using System.Text.Json;
using Moonlight.Shared.Http;
using Moonlight.Shared.Http.Events;
namespace Moonlight.Api.Services;
public class ContainerHelperService
{
private readonly IHttpClientFactory HttpClientFactory;
public ContainerHelperService(IHttpClientFactory httpClientFactory)
{
HttpClientFactory = httpClientFactory;
}
public async IAsyncEnumerable<RebuildEvent> RebuildAsync()
{
var options = new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = true
};
options.TypeInfoResolverChain.Add(SerializationContext.Default);
var client = HttpClientFactory.CreateClient("ContainerHelper");
var response = await client.GetAsync("api/rebuild", HttpCompletionOption.ResponseHeadersRead);
if (!response.IsSuccessStatusCode)
{
var responseText = await response.Content.ReadAsStringAsync();
yield return new RebuildEvent()
{
Type = RebuildEventType.Failed,
Data = responseText
};
yield break;
}
await using var responseStream = await response.Content.ReadAsStreamAsync();
using var streamReader = new StreamReader(responseStream);
do
{
var line = await streamReader.ReadLineAsync();
if(line == null)
break;
if(string.IsNullOrWhiteSpace(line))
continue;
var data = line.Trim("data: ");
var deserializedData = JsonSerializer.Deserialize<RebuildEvent>(data, options);
yield return deserializedData;
} while (true);
yield return new RebuildEvent()
{
Type = RebuildEventType.Succeeded,
Data = string.Empty
};
}
}

View File

@@ -38,6 +38,15 @@ public partial class Startup
builder.Services.AddOptions<FrontendOptions>().BindConfiguration("Moonlight:Frontend");
builder.Services.AddScoped<FrontendService>();
builder.Services.AddOptions<ContainerHelperOptions>().BindConfiguration("Moonlight:ContainerHelper");
builder.Services.AddSingleton<ContainerHelperService>();
builder.Services.AddHttpClient("ContainerHelper", (provider, client) =>
{
var options = provider.GetRequiredService<IOptions<ContainerHelperOptions>>();
client.BaseAddress = new Uri(options.Value.IsEnabled ? options.Value.Url : "http://you-should-fail.invalid");
});
}
private static void UseBase(WebApplication application)

View File

@@ -1,19 +1,24 @@
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
@using System.Text.Json
@using LucideBlazor
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Events
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.AlertDialogs
@using ShadcnBlazor.Progresses
@using ShadcnBlazor.Spinners
@inject AlertDialogService AlertService
@inject HttpClient HttpClient
<DialogHeader>
<DialogTitle>
Updating...
Updating instance...
</DialogTitle>
</DialogHeader>
<div class="grid grid-cols-1 xl:grid-cols-2 w-full gap-5">
<div class="text-base flex flex-col p-2 gap-y-0.5">
@for (var i = 0; i < Steps.Length; i++)
{
@@ -47,6 +52,15 @@
}
}
</div>
<div class="bg-black text-white rounded-lg font-mono h-96 flex flex-col-reverse overflow-auto p-3 scrollbar-thin">
@for (var i = LogLines.Count - 1; i >= 0; i--)
{
<div>
@LogLines[i]
</div>
}
</div>
</div>
<DialogFooter>
<Progress Value="@Progress"></Progress>
@@ -62,12 +76,15 @@
[
"Preparing",
"Updating configuration files",
"Starting rebuild task",
"Building docker image",
"Redeploying container instance",
"Waiting for container instance to start up",
"Update complete"
];
private List<string?> LogLines = new();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
@@ -83,27 +100,97 @@
Progress = 20;
await InvokeAsync(StateHasChanged);
await Task.Delay(6000);
CurrentStep = 2;
Progress = 40;
await InvokeAsync(StateHasChanged);
await Task.Delay(2000);
CurrentStep = 3;
Progress = 60;
CurrentStep = 2;
Progress = 30;
await InvokeAsync(StateHasChanged);
await Task.Delay(4000);
var response = await HttpClient.SendAsync(
new HttpRequestMessage(HttpMethod.Post, "api/admin/ch/rebuild"),
HttpCompletionOption.ResponseHeadersRead
);
await using var responseStream = await response.Content.ReadAsStreamAsync();
using var streamReader = new StreamReader(responseStream);
do
{
try
{
var line = await streamReader.ReadLineAsync();
if (line == null)
break;
if (string.IsNullOrWhiteSpace(line))
continue;
var data = line.Trim("data: ");
var deserializedData = JsonSerializer.Deserialize<RebuildEvent>(data, Constants.SerializerOptions);
switch (deserializedData.Type)
{
case RebuildEventType.Log:
LogLines.Add(deserializedData.Data);
break;
case RebuildEventType.Step:
switch (deserializedData.Data)
{
case "BuildImage":
CurrentStep = 3;
Progress = 40;
await InvokeAsync(StateHasChanged);
break;
case "ServiceDown":
CurrentStep = 4;
Progress = 60;
await InvokeAsync(StateHasChanged);
break;
case "ServiceUp":
CurrentStep = 4;
Progress = 80;
await InvokeAsync(StateHasChanged);
break;
}
await Task.Delay(4000);
break;
}
await InvokeAsync(StateHasChanged);
}
catch (Exception e)
{
// TODO: Log
break;
}
} while (true);
CurrentStep = 5;
Progress = 90;
await InvokeAsync(StateHasChanged);
// Ping instance until its reachable again
while (true)
{
try
{
await HttpClient.GetStringAsync("api/ping");
break;
}
catch (Exception)
{
// Ignored
}
await Task.Delay(3000);
}
CurrentStep = 6;
Progress = 100;
await InvokeAsync(StateHasChanged);

View File

@@ -160,5 +160,6 @@
private async Task LaunchUpdateModalAsync() => await DialogService.LaunchAsync<UpdateInstanceModal>(onConfigure: model =>
{
model.ShowCloseButton = false;
model.ClassName = "sm:max-w-4xl!";
});
}

View File

@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
namespace Moonlight.Shared.Http.Events;
public struct RebuildEvent
{
[JsonPropertyName("type")]
public RebuildEventType Type { get; set; }
[JsonPropertyName("data")]
public string Data { get; set; }
}
public enum RebuildEventType
{
Log = 0,
Failed = 1,
Succeeded = 2,
Step = 3
}

View File

@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
using Moonlight.Shared.Http.Events;
using Moonlight.Shared.Http.Requests.ApiKeys;
using Moonlight.Shared.Http.Requests.Roles;
using Moonlight.Shared.Http.Requests.Themes;
@@ -43,6 +44,9 @@ namespace Moonlight.Shared.Http;
[JsonSerializable(typeof(UpdateThemeDto))]
[JsonSerializable(typeof(PagedData<ThemeDto>))]
[JsonSerializable(typeof(ThemeDto))]
// Events
[JsonSerializable(typeof(RebuildEvent))]
public partial class SerializationContext : JsonSerializerContext
{
}

View File

@@ -37,3 +37,31 @@
# Logging
- "Logging__LogLevel__Default=Information"
- "Logging__LogLevel__Microsoft.AspNetCore=Warning"
- "Logging__LogLevel__System.Net.Http.HttpClient=Warning"
- "Moonlight__ContainerHelper__IsEnabled=true"
- "Moonlight__ContainerHelper__Url=http://app:8080"
app:
image: git.battlestati.one/moonlight-panel/container_helper
group_add:
- "989"
environment:
# Logging
- "Logging__LogLevel__Default=Information"
- "Logging__LogLevel__Microsoft.AspNetCore=Warning"
# Compose
- "ContainerHelper__Compose__Directory=${PWD}"
- "ContainerHelper__Compose__Binary=docker-compose"
- "ContainerHelper__Service__Name=api"
# HTTP Proxy
- "HTTP_PROXY=${HTTP_PROXY}"
- "HTTPS_PROXY=${HTTPS_PROXY}"
volumes:
- "${PWD}:${PWD}"
- "/var/run/docker.sock:/var/run/docker.sock"