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/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/Controllers/UnitController.cs b/SystemdCtl/Controllers/UnitController.cs
index a128584..da1c534 100644
--- a/SystemdCtl/Controllers/UnitController.cs
+++ b/SystemdCtl/Controllers/UnitController.cs
@@ -1,3 +1,7 @@
+using System.Runtime.InteropServices;
+using System.Text;
+using ArcaneLibs.Extensions;
+using ArcaneLibs.Extensions.Streams;
using LibSystemdCli;
using LibSystemdCli.Models;
using Microsoft.AspNetCore.Mvc;
@@ -17,15 +21,54 @@ public class UnitController : ControllerBase
}
}
+ // [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)
+ 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")]
|