Implemented version fetching from source control git server. Added self version detection and update checks #8

Merged
ChiaraBm merged 1 commits from feat/UpdateCheck into v2.1 2026-02-01 13:50:29 +00:00
11 changed files with 274 additions and 20 deletions
Showing only changes of commit c8fe11bd2b - Show all commits

View File

@@ -0,0 +1,7 @@
namespace Moonlight.Api.Configuration;
public class VersionOptions
{
public bool OfflineMode { get; set; }
public string? CurrentOverride { get; set; }
}

View File

@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Moonlight.Api.Mappers;
using Moonlight.Api.Services;
using Moonlight.Shared;
using Moonlight.Shared.Http.Responses.Admin;
namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController]
[Route("api/admin/versions")]
[Authorize(Policy = Permissions.System.Versions)]
public class VersionsController : Controller
{
private readonly VersionService VersionService;
public VersionsController(VersionService versionService)
{
VersionService = versionService;
}
[HttpGet]
public async Task<ActionResult<VersionDto[]>> GetAsync()
{
var versions = await VersionService.GetVersionsAsync();
return VersionMapper.ToDtos(versions).ToArray();
}
[HttpGet("instance")]
public async Task<ActionResult<VersionDto>> GetInstanceAsync()
{
var version = await VersionService.GetInstanceVersionAsync();
return VersionMapper.ToDto(version);
}
[HttpGet("latest")]
public async Task<ActionResult<VersionDto>> GetLatestAsync()
{
var version = await VersionService.GetLatestVersionAsync();
if(version == null)
return Problem("Unable to retrieve latest version", statusCode: 404);
return VersionMapper.ToDto(version);
}
}

View File

@@ -0,0 +1,15 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Models;
using Moonlight.Shared.Http.Responses.Admin;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;
[Mapper]
[SuppressMessage("Mapper", "RMG020:Source member is not mapped to any target member")]
[SuppressMessage("Mapper", "RMG012:Source member was not found for target member")]
public static partial class VersionMapper
{
public static partial IEnumerable<VersionDto> ToDtos(IEnumerable<MoonlightVersion> versions);
public static partial VersionDto ToDto(MoonlightVersion version);
}

View File

@@ -0,0 +1,12 @@
namespace Moonlight.Api.Models;
// Notes:
// Identifier - This needs to be the branch to clone to build this version if
// you want to use the container helper
public record MoonlightVersion(
string Identifier,
bool IsPreRelease,
bool IsDevelopment,
DateTimeOffset CreatedAt
);

View File

@@ -1,16 +1,26 @@
using System.Diagnostics; using System.Diagnostics;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Moonlight.Api.Helpers; using Moonlight.Api.Helpers;
namespace Moonlight.Api.Services; namespace Moonlight.Api.Services;
public class ApplicationService : IHostedLifecycleService public class ApplicationService : IHostedService
{ {
private readonly VersionService VersionService;
private readonly ILogger<ApplicationService> Logger;
public DateTimeOffset StartedAt { get; private set; } public DateTimeOffset StartedAt { get; private set; }
public string VersionName { get; private set; } = "N/A"; public string VersionName { get; private set; } = "N/A";
public bool IsUpToDate { get; set; } = true; public bool IsUpToDate { get; set; } = true;
public string OperatingSystem { get; private set; } = "N/A"; public string OperatingSystem { get; private set; } = "N/A";
public ApplicationService(VersionService versionService, ILogger<ApplicationService> logger)
{
VersionService = versionService;
Logger = logger;
}
public Task<long> GetMemoryUsageAsync() public Task<long> GetMemoryUsageAsync()
{ {
using var currentProcess = Process.GetCurrentProcess(); using var currentProcess = Process.GetCurrentProcess();
@@ -40,25 +50,36 @@ public class ApplicationService : IHostedLifecycleService
return Math.Round(cpuUsagePercent, 2); return Math.Round(cpuUsagePercent, 2);
} }
public async Task StartedAsync(CancellationToken cancellationToken) public async Task StartAsync(CancellationToken cancellationToken)
{ {
StartedAt = DateTimeOffset.UtcNow; StartedAt = DateTimeOffset.UtcNow;
// TODO: Update / version check
VersionName = "v2.1.0 (a2d4edc0e5)";
IsUpToDate = true;
OperatingSystem = OsHelper.GetName(); OperatingSystem = OsHelper.GetName();
try
{
var currentVersion = await VersionService.GetInstanceVersionAsync();
var latestVersion = await VersionService.GetLatestVersionAsync();
VersionName = currentVersion.Identifier;
IsUpToDate = latestVersion == null || currentVersion.Identifier == latestVersion.Identifier;
Logger.LogInformation("Running Moonlight Panel {version} on {operatingSystem}", VersionName, OperatingSystem);
if (!IsUpToDate)
Logger.LogWarning("Your instance is not up-to-date");
if (currentVersion.IsDevelopment)
Logger.LogWarning("Your instance is running a development version");
if (currentVersion.IsPreRelease)
Logger.LogWarning("Your instance is running a pre-release version");
}
catch (Exception e)
{
Logger.LogError(e, "An unhandled exception occurred while fetching version details");
}
} }
#region Unused
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
#endregion
} }

View File

@@ -0,0 +1,140 @@
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration;
using Moonlight.Api.Models;
namespace Moonlight.Api.Services;
public partial class VersionService
{
private readonly IOptions<VersionOptions> Options;
private readonly IHttpClientFactory HttpClientFactory;
private const string VersionPath = "/app/version";
private const string GiteaServer = "https://git.battlestati.one";
private const string GiteaRepository = "Moonlight-Panel/Moonlight";
[GeneratedRegex(@"^v(?!1(\.|$))\d+\.[A-Za-z0-9]+(\.[A-Za-z0-9]+)*$")]
private static partial Regex RegexFilter();
public VersionService(
IOptions<VersionOptions> options,
IHttpClientFactory httpClientFactory
)
{
Options = options;
HttpClientFactory = httpClientFactory;
}
public async Task<MoonlightVersion[]> GetVersionsAsync()
{
if (Options.Value.OfflineMode)
return [];
var versions = new List<MoonlightVersion>();
var httpClient = HttpClientFactory.CreateClient();
// Tags
const string tagsPath = $"{GiteaServer}/api/v1/repos/{GiteaRepository}/tags";
await using var tagsJsonStream = await httpClient.GetStreamAsync(tagsPath);
var tagsJson = await JsonNode.ParseAsync(tagsJsonStream);
if (tagsJson != null)
{
foreach (var node in tagsJson.AsArray())
{
if (node == null)
continue;
var name = node["name"]?.GetValue<string>() ?? "N/A";
var createdAt = node["createdAt"]?.GetValue<DateTimeOffset>() ?? DateTimeOffset.MinValue;
if(!RegexFilter().IsMatch(name))
continue;
versions.Add(new MoonlightVersion(
name,
name.EndsWith('b'),
false,
createdAt
));
}
}
// Branches
const string branchesPath = $"{GiteaServer}/api/v1/repos/{GiteaRepository}/branches";
await using var branchesJsonStream = await httpClient.GetStreamAsync(branchesPath);
var branchesJson = await JsonNode.ParseAsync(branchesJsonStream);
if (branchesJson != null)
{
foreach (var node in branchesJson.AsArray())
{
if (node == null)
continue;
var name = node["name"]?.GetValue<string>() ?? "N/A";
var commit = node["commit"];
if (commit == null)
continue;
var createdAt = commit["timestamp"]?.GetValue<DateTimeOffset>() ?? DateTimeOffset.MinValue;
if(!RegexFilter().IsMatch(name))
continue;
versions.Add(new MoonlightVersion(
name,
name.EndsWith('b'),
true,
createdAt
));
}
}
return versions.ToArray();
}
public async Task<MoonlightVersion> GetInstanceVersionAsync()
{
var knownVersions = await GetVersionsAsync();
string versionIdentifier;
if (!string.IsNullOrWhiteSpace(Options.Value.CurrentOverride))
versionIdentifier = Options.Value.CurrentOverride;
else
{
if (File.Exists(VersionPath))
versionIdentifier = await File.ReadAllTextAsync(VersionPath);
else
versionIdentifier = "unknown";
}
var version = knownVersions.FirstOrDefault(x => x.Identifier == versionIdentifier);
if (version != null)
return version;
return new MoonlightVersion(
versionIdentifier,
false,
false,
DateTimeOffset.UtcNow
);
}
public async Task<MoonlightVersion?> GetLatestVersionAsync()
{
var versions = await GetVersionsAsync();
return versions
.Where(x => x is { IsDevelopment: false, IsPreRelease: false })
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefault();
}
}

View File

@@ -9,7 +9,7 @@ using Moonlight.Api.Helpers;
using Moonlight.Api.Implementations; using Moonlight.Api.Implementations;
using Moonlight.Api.Interfaces; using Moonlight.Api.Interfaces;
using Moonlight.Api.Services; using Moonlight.Api.Services;
using SessionOptions = Microsoft.AspNetCore.Builder.SessionOptions; using SessionOptions = Moonlight.Api.Configuration.SessionOptions;
namespace Moonlight.Api.Startup; namespace Moonlight.Api.Startup;
@@ -38,6 +38,11 @@ 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.AddHttpClient();
builder.Services.AddOptions<VersionOptions>().BindConfiguration("Moonlight:Version");
builder.Services.AddSingleton<VersionService>();
} }
private static void UseBase(WebApplication application) private static void UseBase(WebApplication application)

View File

@@ -27,6 +27,7 @@ public sealed class PermissionProvider : IPermissionProvider
new PermissionCategory("System", typeof(CogIcon), [ new PermissionCategory("System", typeof(CogIcon), [
new Permission(Permissions.System.Info, "Info", "View system info"), new Permission(Permissions.System.Info, "Info", "View system info"),
new Permission(Permissions.System.Diagnose, "Diagnose", "Run diagnostics"), new Permission(Permissions.System.Diagnose, "Diagnose", "Run diagnostics"),
new Permission(Permissions.System.Versions, "Versions", "Look at the available versions"),
]), ]),
new PermissionCategory("API Keys", typeof(KeyIcon), [ new PermissionCategory("API Keys", typeof(KeyIcon), [
new Permission(Permissions.ApiKeys.Create, "Create", "Create new API keys"), new Permission(Permissions.ApiKeys.Create, "Create", "Create new API keys"),

View File

@@ -0,0 +1,3 @@
namespace Moonlight.Shared.Http.Responses.Admin;
public record VersionDto(string Identifier, bool IsPreRelease, bool IsDevelopment, DateTimeOffset CreatedAt);

View File

@@ -43,6 +43,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))]
//Misc
[JsonSerializable(typeof(VersionDto))]
public partial class SerializationContext : JsonSerializerContext public partial class SerializationContext : JsonSerializerContext
{ {
} }

View File

@@ -53,5 +53,6 @@ public static class Permissions
public const string Info = $"{Prefix}{Section}.{nameof(Info)}"; public const string Info = $"{Prefix}{Section}.{nameof(Info)}";
public const string Diagnose = $"{Prefix}{Section}.{nameof(Diagnose)}"; public const string Diagnose = $"{Prefix}{Section}.{nameof(Diagnose)}";
public const string Versions = $"{Prefix}{Section}.{nameof(Versions)}";
} }
} }