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 // TODO: Update / version check
VersionName = "v2.1.0 (a2d4edc0e5)"; VersionName = "v2.1.0 (a2d4edc0e5)";
IsUpToDate = true; IsUpToDate = false;
OperatingSystem = OsHelper.GetName(); 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.AddOptions<FrontendOptions>().BindConfiguration("Moonlight:Frontend");
builder.Services.AddScoped<FrontendService>(); 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) private static void UseBase(WebApplication application)

View File

@@ -1,37 +1,31 @@
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase @inherits ShadcnBlazor.Extras.Dialogs.DialogBase
@using System.Text.Json
@using LucideBlazor @using LucideBlazor
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Events
@using ShadcnBlazor.Dialogs @using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.AlertDialogs @using ShadcnBlazor.Extras.AlertDialogs
@using ShadcnBlazor.Progresses @using ShadcnBlazor.Progresses
@using ShadcnBlazor.Spinners @using ShadcnBlazor.Spinners
@inject AlertDialogService AlertService @inject AlertDialogService AlertService
@inject HttpClient HttpClient
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Updating... Updating instance...
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div class="text-base flex flex-col p-2 gap-y-0.5"> <div class="grid grid-cols-1 xl:grid-cols-2 w-full gap-5">
@for (var i = 0; i < Steps.Length; i++) <div class="text-base flex flex-col p-2 gap-y-0.5">
{ @for (var i = 0; i < Steps.Length; i++)
if (CurrentStep == i)
{ {
<div class="flex flex-row items-center gap-x-2"> if (CurrentStep == i)
<Spinner ClassName="size-4" />
<span>
@Steps[i]
</span>
</div>
}
else
{
if (i < CurrentStep)
{ {
<div class="flex flex-row items-center gap-x-2"> <div class="flex flex-row items-center gap-x-2">
<CheckIcon ClassName="text-green-500 size-4" /> <Spinner ClassName="size-4"/>
<span> <span>
@Steps[i] @Steps[i]
</span> </span>
@@ -39,13 +33,33 @@
} }
else else
{ {
<div class="text-muted-foreground flex flex-row items-center gap-x-2"> if (i < CurrentStep)
<span class="size-4"></span> {
@Steps[i] <div class="flex flex-row items-center gap-x-2">
</div> <CheckIcon ClassName="text-green-500 size-4"/>
<span>
@Steps[i]
</span>
</div>
}
else
{
<div class="text-muted-foreground flex flex-row items-center gap-x-2">
<span class="size-4"></span>
@Steps[i]
</div>
}
} }
} }
} </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> </div>
<DialogFooter> <DialogFooter>
@@ -62,12 +76,15 @@
[ [
"Preparing", "Preparing",
"Updating configuration files", "Updating configuration files",
"Starting rebuild task",
"Building docker image", "Building docker image",
"Redeploying container instance", "Redeploying container instance",
"Waiting for container instance to start up", "Waiting for container instance to start up",
"Update complete" "Update complete"
]; ];
private List<string?> LogLines = new();
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (!firstRender) if (!firstRender)
@@ -83,30 +100,100 @@
Progress = 20; Progress = 20;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
await Task.Delay(6000);
CurrentStep = 2;
Progress = 40;
await InvokeAsync(StateHasChanged);
await Task.Delay(2000); await Task.Delay(2000);
CurrentStep = 3; CurrentStep = 2;
Progress = 60; Progress = 30;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
await Task.Delay(4000); var response = await HttpClient.SendAsync(
new HttpRequestMessage(HttpMethod.Post, "api/admin/ch/rebuild"),
HttpCompletionOption.ResponseHeadersRead
);
CurrentStep = 4; await using var responseStream = await response.Content.ReadAsStreamAsync();
Progress = 80; using var streamReader = new StreamReader(responseStream);
await InvokeAsync(StateHasChanged);
await Task.Delay(4000); 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;
}
break;
}
await InvokeAsync(StateHasChanged);
}
catch (Exception e)
{
// TODO: Log
break;
}
} while (true);
CurrentStep = 5; 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; Progress = 100;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
await Task.Delay(1000); await Task.Delay(1000);
await AlertService.SuccessAsync( await AlertService.SuccessAsync(

View File

@@ -160,5 +160,6 @@
private async Task LaunchUpdateModalAsync() => await DialogService.LaunchAsync<UpdateInstanceModal>(onConfigure: model => private async Task LaunchUpdateModalAsync() => await DialogService.LaunchAsync<UpdateInstanceModal>(onConfigure: model =>
{ {
model.ShowCloseButton = false; 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 System.Text.Json.Serialization;
using Moonlight.Shared.Http.Events;
using Moonlight.Shared.Http.Requests.ApiKeys; using Moonlight.Shared.Http.Requests.ApiKeys;
using Moonlight.Shared.Http.Requests.Roles; using Moonlight.Shared.Http.Requests.Roles;
using Moonlight.Shared.Http.Requests.Themes; using Moonlight.Shared.Http.Requests.Themes;
@@ -43,6 +44,9 @@ namespace Moonlight.Shared.Http;
[JsonSerializable(typeof(UpdateThemeDto))] [JsonSerializable(typeof(UpdateThemeDto))]
[JsonSerializable(typeof(PagedData<ThemeDto>))] [JsonSerializable(typeof(PagedData<ThemeDto>))]
[JsonSerializable(typeof(ThemeDto))] [JsonSerializable(typeof(ThemeDto))]
// Events
[JsonSerializable(typeof(RebuildEvent))]
public partial class SerializationContext : JsonSerializerContext public partial class SerializationContext : JsonSerializerContext
{ {
} }

View File

@@ -36,4 +36,32 @@
# Logging # Logging
- "Logging__LogLevel__Default=Information" - "Logging__LogLevel__Default=Information"
- "Logging__LogLevel__Microsoft.AspNetCore=Warning" - "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"