summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--LibSystemdCli.Models/LibSystemdCli.Models.csproj2
-rw-r--r--LibSystemdCli.Models/SystemdServiceData.cs33
-rw-r--r--LibSystemdCli/CommandExecutor.cs10
-rw-r--r--LibSystemdCli/LibSystemdCli.csproj2
-rw-r--r--LibSystemdCli/SystemdExecutor.cs14
-rw-r--r--SystemdCtl.Client/Pages/Graphs/TimelineGraph.razor70
-rw-r--r--SystemdCtl.Client/Pages/ServiceManage.razor50
-rw-r--r--SystemdCtl.Client/SystemdCtl.Client.csproj2
-rw-r--r--SystemdCtl/Controllers/UnitController.cs81
-rw-r--r--SystemdCtl/SystemdCtl.csproj4
10 files changed, 229 insertions, 39 deletions
diff --git a/LibSystemdCli.Models/LibSystemdCli.Models.csproj b/LibSystemdCli.Models/LibSystemdCli.Models.csproj

index 3a63532..17b910f 100644 --- a/LibSystemdCli.Models/LibSystemdCli.Models.csproj +++ b/LibSystemdCli.Models/LibSystemdCli.Models.csproj
@@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net8.0</TargetFramework> + <TargetFramework>net9.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> diff --git a/LibSystemdCli.Models/SystemdServiceData.cs b/LibSystemdCli.Models/SystemdServiceData.cs
index 1efcfd7..8d35c26 100644 --- a/LibSystemdCli.Models/SystemdServiceData.cs +++ b/LibSystemdCli.Models/SystemdServiceData.cs
@@ -9,14 +9,30 @@ public class SystemdServiceData { [JsonPropertyName("multiData")] public Dictionary<string, List<string>> MultiData { get; set; } = new(); - public bool IsRunning => Status is "active" or "reloading"; + public bool IsRunning => Status is "active" or "reloading" or "deactating" or "activating"; public string Status => GetSingleData("ActiveState") ?? "unknown"; + + public long MemoryCurrent { + get { + if (long.TryParse(GetSingleData("MemoryCurrent") ?? "0", out long mem)) return mem; + return 0; + } + } + + public long MemoryPeak { + get { + if (long.TryParse(GetSingleData("MemoryPeak") ?? "0", out long mem)) return mem; + return 0; + } + } + + public void AddData(string key, string value) { if (MultiData.ContainsKey(key)) { MultiData[key].Add(value); } else if (SingleData.ContainsKey(key)) { - MultiData[key] = new List<string> { SingleData[key], value }; + MultiData[key] = [SingleData[key], value]; SingleData.Remove(key); } else { @@ -43,4 +59,17 @@ public class SystemdServiceData { return null; } + + public void SetOldData(SystemdServiceData oldData) { + var now = DateTime.Now; + + var cpuDiff = CpuUsageNs - oldData.CpuUsageNs; + var timeDiff = now - oldData.Timestamp; + CpuUsagePercent = cpuDiff / timeDiff.TotalMilliseconds / 1_000_000d; + } + + public DateTime Timestamp { get; set; } = DateTime.Now; + public long CpuUsageNs => long.Parse(GetSingleData("CPUUsageNSec") ?? "0"); + + public double CpuUsagePercent { get; set; } } \ No newline at end of file diff --git a/LibSystemdCli/CommandExecutor.cs b/LibSystemdCli/CommandExecutor.cs
index 096f1c1..8a21bc5 100644 --- a/LibSystemdCli/CommandExecutor.cs +++ b/LibSystemdCli/CommandExecutor.cs
@@ -27,6 +27,16 @@ public class CommandExecutor throw new Exception($"Command {command} {args} failed with exit code {process.ExitCode} and error: {error}"); } + if (string.IsNullOrWhiteSpace(output)) + { + Console.WriteLine($"[{DateTime.Now:O}] Command {command} {args} produced no output."); + } + + if (!string.IsNullOrWhiteSpace(error)) + { + Console.WriteLine($"[{DateTime.Now:O}] Command {command} {args} produced error output: {error}"); + } + return output; } diff --git a/LibSystemdCli/LibSystemdCli.csproj b/LibSystemdCli/LibSystemdCli.csproj
index 1d1bffa..0c142de 100644 --- a/LibSystemdCli/LibSystemdCli.csproj +++ b/LibSystemdCli/LibSystemdCli.csproj
@@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net8.0</TargetFramework> + <TargetFramework>net9.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> diff --git a/LibSystemdCli/SystemdExecutor.cs b/LibSystemdCli/SystemdExecutor.cs
index ead11fd..f252891 100644 --- a/LibSystemdCli/SystemdExecutor.cs +++ b/LibSystemdCli/SystemdExecutor.cs
@@ -8,7 +8,15 @@ public class SystemdExecutor { public static async IAsyncEnumerable<SystemdUnitListItem> GetUnits() { var output = await CommandExecutor.ExecuteCommand("systemctl", "list-units --all --no-legend --no-pager --no-legend -o json-pretty"); - var data = JsonSerializer.Deserialize<List<SystemdUnitListItem>>(output); + List<SystemdUnitListItem>? data; + try { + data = JsonSerializer.Deserialize<List<SystemdUnitListItem>>(output); + } + catch (Exception ex) { + Console.WriteLine("Failed to parse systemctl output: " + ex); + Console.WriteLine("Output was: " + output); + yield break; + } foreach (var unit in data) { try { @@ -16,7 +24,9 @@ public class SystemdExecutor { // Console.WriteLine(fragmentOutput); unit.FragmentPaths = fragmentOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); } - catch { } + catch (Exception e) { + Console.WriteLine("Failed to get fragment path for unit " + unit.Unit + ": " + e); + } yield return unit; // await Task.Delay(100); diff --git a/SystemdCtl.Client/Pages/Graphs/TimelineGraph.razor b/SystemdCtl.Client/Pages/Graphs/TimelineGraph.razor new file mode 100644
index 0000000..9c53986 --- /dev/null +++ b/SystemdCtl.Client/Pages/Graphs/TimelineGraph.razor
@@ -0,0 +1,70 @@ +@using ArcaneLibs +<svg width="@Width" height="@Height"> + @if (Data.Count > 0) { + <polyline points="@string.Join(" ", _points.Select(x => $"{x.Key},{x.Value}"))" style="fill:none;stroke:black;stroke-width:3"/> + @* Y min/max labels *@ + <text> + <text x="0" y="@Height" fill="black">@(ValueFormatter.Invoke(_minValue))</text> + <text x="0" y="15" fill="black">@(ValueFormatter.Invoke(_maxValue))</text> + </text> + @* outline *@ + <rect x="0" y="0" width="@Width" height="@Height" style="fill:none;stroke:black;stroke-width:1"/> + } +</svg> + +@code { + + [Parameter] + public Dictionary<DateTime, double> Data { get; set; } + + [Parameter] + public int Width { get; set; } = 100; + + [Parameter] + public int Height { get; set; } = 100; + + [Parameter] + public double? MinValue { get; set; } + + [Parameter] + public double? MaxValue { get; set; } + + //value formatter + [Parameter] + public Func<double, string> ValueFormatter { get; set; } = x => x.ToString("X2"); + + private double _minValue => MinValue ?? (Data.Count > 0 ? Data.Values.Min() : 0); + private double _maxValue => MaxValue ?? (Data.Count > 0 ? Data.Values.Max() : 0); + + private Dictionary<double, double> _points = []; + + protected override async Task OnParametersSetAsync() { + await RebuildGraph(); + await base.OnParametersSetAsync(); + } + + private async Task RebuildGraph() { + if (Data.Count == 0) return; + _points.Clear(); + var startTime = Data.Keys.Min(x => x).Ticks; + var endTime = Data.Keys.Max(x => x).Ticks; + var minValue = _minValue; + var maxValue = _maxValue; + Console.WriteLine($"Start: {startTime}, End: {endTime}, Min: {minValue}, Max: {maxValue}"); + foreach (var item in Data) { + _points.Add(Map(item.Key.Ticks, startTime, endTime, 0, Width), + Map(item.Value, minValue, maxValue, Height, 0)); + } + } + + public static double Map( + double value, + double originalStart, + double originalEnd, + double newStart, + double newEnd) { + double num = (newEnd - newStart) / (originalEnd - originalStart); + return newStart + (value - originalStart) * num; + } + +} \ No newline at end of file diff --git a/SystemdCtl.Client/Pages/ServiceManage.razor b/SystemdCtl.Client/Pages/ServiceManage.razor
index 851f1b6..39be89e 100644 --- a/SystemdCtl.Client/Pages/ServiceManage.razor +++ b/SystemdCtl.Client/Pages/ServiceManage.razor
@@ -6,6 +6,8 @@ @using SystemdCtl.Client.Abstractions @using System.Diagnostics.Metrics @using System.Text.Json +@using ArcaneLibs +@using SystemdCtl.Client.Pages.Graphs @* @attribute [StreamRendering] *@ @rendermode InteractiveWebAssembly @inject NavigationManager NavigationManager @@ -33,6 +35,19 @@ </div> </div> +<div class="row"> + <div class="col-12"> + <h3>Statistics</h3> + <div class="card"> + <div class="card-body"> + <p>CPU usage: @ServiceInfo?.CpuUsagePercent.ToString("P2")</p> + <TimelineGraph Data="@CpuUsageHistory" Height="50" Width="500" MinValue="0" ValueFormatter="@(value => value.ToString("P2"))"/> + <p>Memory usage: @Util.SI_BytesToString(ServiceInfo?.MemoryCurrent ?? 0) (peak: @Util.SI_BytesToString(ServiceInfo?.MemoryPeak ?? 0))</p> + <TimelineGraph Data="@MemoryUsageHistory" Height="50" Width="500" ValueFormatter="@(value => Util.SI_BytesToString((long)value))"/> + </div> + </div> + </div> +</div> @* //simple log view *@ <div class="row"> @@ -72,17 +87,22 @@ private async Task Run() { if (!IsClient) return; - int history = 100; - LogLines.Clear(); GetServiceDataTask(); + GetLogs(); + } + + private JsonSerializerOptions jso = new() { + DefaultBufferSize = 1, + AllowTrailingCommas = true + }; + + private async Task GetLogs() { + int history = 100; var Http = new StreamingHttpClient() { BaseAddress = new Uri(NavigationManager.BaseUri) }; - var jso = new JsonSerializerOptions() { - DefaultBufferSize = 1, - AllowTrailingCommas = true - }; + + LogLines.Clear(); var _items = Http.GetAsyncEnumerableFromJsonAsync<SystemdJournalLogItem>($"/api/unit/{ServiceName}/logs?contextLines={history}", jso); await foreach (var item in _items) { - // LogLines.RemoveAll(x=>x.SystemdInvocationId != item.SystemdInvocationId); LogLines.Add(item); if (LogLines.Count > history) { LogLines.RemoveAt(0); @@ -95,10 +115,17 @@ private async Task GetServiceDataTask() { if (!IsClient) return; var Http = new StreamingHttpClient() { BaseAddress = new Uri(NavigationManager.BaseUri) }; - while (true) { - ServiceInfo = await Http.GetFromJsonAsync<SystemdServiceData>($"/api/unit/{ServiceName}/data"); + + var stream = Http.GetAsyncEnumerableFromJsonAsync<SystemdServiceData>($"/api/unit/{ServiceName}/dataStream", jso); + await foreach (var item in stream) { + ServiceInfo = item; + CpuUsageHistory.Add(ServiceInfo.Timestamp, ServiceInfo.CpuUsagePercent); + MemoryUsageHistory.Add(ServiceInfo.Timestamp, ServiceInfo.MemoryCurrent); + CpuUsageHistory = CpuUsageHistory.Where((x, y) => x.Key > DateTime.Now.AddMinutes(-1)) + .OrderBy(x => x.Key).ToDictionary(); + MemoryUsageHistory = MemoryUsageHistory.Where((x, y) => x.Key > DateTime.Now.AddMinutes(-1)).OrderBy(x => x.Key).ToDictionary(); + StateHasChanged(); - await Task.Delay(TimeSpan.FromSeconds(5)); } } @@ -130,4 +157,7 @@ await Http.GetAsync($"/api/unit/{ServiceName}/kill", null); } + public Dictionary<DateTime, double> CpuUsageHistory { get; set; } = []; + public Dictionary<DateTime, double> MemoryUsageHistory { get; set; } = []; + } \ No newline at end of file diff --git a/SystemdCtl.Client/SystemdCtl.Client.csproj b/SystemdCtl.Client/SystemdCtl.Client.csproj
index e00a924..677fb9a 100644 --- a/SystemdCtl.Client/SystemdCtl.Client.csproj +++ b/SystemdCtl.Client/SystemdCtl.Client.csproj
@@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly"> <PropertyGroup> - <TargetFramework>net8.0</TargetFramework> + <TargetFramework>net9.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile> diff --git a/SystemdCtl/Controllers/UnitController.cs b/SystemdCtl/Controllers/UnitController.cs
index a128584..40ef46a 100644 --- a/SystemdCtl/Controllers/UnitController.cs +++ b/SystemdCtl/Controllers/UnitController.cs
@@ -1,51 +1,92 @@ +using System.Runtime.InteropServices; +using System.Text; +using ArcaneLibs.Extensions; +using ArcaneLibs.Extensions.Streams; using LibSystemdCli; using LibSystemdCli.Models; using Microsoft.AspNetCore.Mvc; namespace SystemdCtl.Controllers; + [ApiController] [Route("/api/")] -public class UnitController : ControllerBase -{ +public class UnitController : ControllerBase { [HttpGet("listUnits")] - public async IAsyncEnumerable<SystemdUnitListItem> GetUnits() - { - await foreach (var unit in SystemdExecutor.GetUnits()) - { + public async IAsyncEnumerable<SystemdUnitListItem> GetUnits() { + await foreach (var unit in SystemdExecutor.GetUnits()) { yield return unit; - await Response.Body.FlushAsync(); + + if (Response.HasStarted) + await Response.Body.FlushAsync(); } } - + + // [HttpGet("unit/{serviceName}/logs")] + // public async IAsyncEnumerable<SystemdJournalLogItem> GetUnitLogs(string serviceName, [FromQuery] int contextLines = 100) + // { + // await foreach (var log in SystemdExecutor.GetUnitLogs(serviceName, contextLines: contextLines)) + // { + // Console.WriteLine(log.Message); + // yield return log; + // await Response.Body.FlushAsync(); + // } + // } [HttpGet("unit/{serviceName}/logs")] - public async IAsyncEnumerable<SystemdJournalLogItem> GetUnitLogs(string serviceName, [FromQuery] int contextLines = 100) - { - await foreach (var log in SystemdExecutor.GetUnitLogs(serviceName, contextLines: contextLines)) - { + public async Task GetUnitLogs(string serviceName, [FromQuery] int contextLines = 100) { + Response.ContentType = "application/json"; + await Response.StartAsync(); + await Response.Body.WriteAsync("[\n"u8.ToArray()); + await foreach (var log in SystemdExecutor.GetUnitLogs(serviceName, contextLines: contextLines)) { Console.WriteLine(log.Message); - yield return log; + var bytes = Encoding.UTF8.GetBytes($" {log.ToJson(indent: false)},\n"); + await Response.Body.WriteAsync(bytes); await Response.Body.FlushAsync(); } + + await Response.Body.WriteAsync("]\n"u8.ToArray()); + await Response.Body.FlushAsync(); + await Response.CompleteAsync(); } - + + [HttpGet("unit/{serviceName}/dataStream")] + public async Task GetUnitDataStream(string serviceName, [FromQuery] int contextLines = 100) { + Response.ContentType = "application/json"; + await Response.StartAsync(); + await Response.Body.WriteAsync("[\n"u8.ToArray()); + var oldData = await SystemdExecutor.GetUnitData(serviceName); + while (true) { + var data = await SystemdExecutor.GetUnitData(serviceName); + data.SetOldData(oldData); + var bytes = Encoding.UTF8.GetBytes($" {data.ToJson(indent: false)},\n"); + await Response.Body.WriteAsync(bytes); + await Response.Body.FlushAsync(); + oldData = data; + await Task.Delay(1000); + } + + await Response.Body.WriteAsync("]\n"u8.ToArray()); + await Response.Body.FlushAsync(); + await Response.CompleteAsync(); + } + [HttpGet("unit/{serviceName}/data")] public Task<SystemdServiceData> GetUnitData(string serviceName) => SystemdExecutor.GetUnitData(serviceName); - + [HttpGet("unit/{serviceName}/start")] public Task StartUnit(string serviceName) => CommandExecutor.ExecuteCommand("systemctl", $"start {serviceName}"); - + [HttpGet("unit/{serviceName}/stop")] public Task StopUnit(string serviceName) => CommandExecutor.ExecuteCommand("systemctl", $"stop {serviceName}"); - + [HttpGet("unit/{serviceName}/restart")] public Task RestartUnit(string serviceName) => CommandExecutor.ExecuteCommand("systemctl", $"restart {serviceName}"); - + [HttpGet("unit/{serviceName}/reload")] public Task ReloadUnit(string serviceName) => CommandExecutor.ExecuteCommand("systemctl", $"reload {serviceName}"); - + [HttpGet("unit/{serviceName}/enable")] public Task EnableUnit(string serviceName) => CommandExecutor.ExecuteCommand("systemctl", $"enable {serviceName}"); - + [HttpGet("unit/{serviceName}/disable")] public Task DisableUnit(string serviceName) => CommandExecutor.ExecuteCommand("systemctl", $"disable {serviceName}"); } \ No newline at end of file diff --git a/SystemdCtl/SystemdCtl.csproj b/SystemdCtl/SystemdCtl.csproj
index 7a4f701..4425832 100644 --- a/SystemdCtl/SystemdCtl.csproj +++ b/SystemdCtl/SystemdCtl.csproj
@@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk.Web"> <PropertyGroup> - <TargetFramework>net8.0</TargetFramework> + <TargetFramework>net9.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> </PropertyGroup> @@ -9,7 +9,7 @@ <ItemGroup> <ProjectReference Include="..\LibSystemdCli\LibSystemdCli.csproj" /> <ProjectReference Include="..\SystemdCtl.Client\SystemdCtl.Client.csproj" /> - <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.0" /> + <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="9.0.8" /> </ItemGroup> <ItemGroup>