From 2648a9a0a69ca1855c74ae6bce1e63ee30d421e7 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 26 Aug 2026 14:19:19 +0200 Subject: [PATCH] feat(app): add value formatting, state converters, and sparkline control --- src/Vantage.App/Controls/SparklineControl.cs | 89 +++++++++++++++++++ .../Converters/StateBrushConverters.cs | 43 +++++++++ src/Vantage.App/ValueFormatting.cs | 36 ++++++++ 3 files changed, 168 insertions(+) create mode 100644 src/Vantage.App/Controls/SparklineControl.cs create mode 100644 src/Vantage.App/Converters/StateBrushConverters.cs create mode 100644 src/Vantage.App/ValueFormatting.cs diff --git a/src/Vantage.App/Controls/SparklineControl.cs b/src/Vantage.App/Controls/SparklineControl.cs new file mode 100644 index 0000000..431aef4 --- /dev/null +++ b/src/Vantage.App/Controls/SparklineControl.cs @@ -0,0 +1,89 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace Vantage.App.Controls; + +public sealed class SparklineControl : Control +{ + public static readonly StyledProperty?> ValuesProperty = + AvaloniaProperty.Register?>(nameof(Values)); + + public static readonly StyledProperty MaximumProperty = + AvaloniaProperty.Register(nameof(Maximum), 100); + + public static readonly StyledProperty StrokeProperty = + AvaloniaProperty.Register(nameof(Stroke), Brushes.DeepSkyBlue); + + public static readonly StyledProperty FillProperty = + AvaloniaProperty.Register(nameof(Fill)); + + static SparklineControl() + { + AffectsRender(ValuesProperty, MaximumProperty, StrokeProperty, FillProperty); + } + + public IReadOnlyList? Values + { + get => GetValue(ValuesProperty); + set => SetValue(ValuesProperty, value); + } + + public double Maximum + { + get => GetValue(MaximumProperty); + set => SetValue(MaximumProperty, value); + } + + public IBrush Stroke + { + get => GetValue(StrokeProperty); + set => SetValue(StrokeProperty, value); + } + + public IBrush? Fill + { + get => GetValue(FillProperty); + set => SetValue(FillProperty, value); + } + + public override void Render(DrawingContext context) + { + var values = Values; + var width = Bounds.Width; + var height = Bounds.Height; + if (values is null || values.Count < 2 || width <= 0 || height <= 0) + return; + + var max = Maximum <= 0 ? 1 : Maximum; + var stepX = width / (values.Count - 1); + + var geometry = new StreamGeometry(); + using (var ctx = geometry.Open()) + { + var first = new Point(0, ToY(values[0], max, height)); + ctx.BeginFigure(first, isFilled: Fill is not null); + for (var i = 1; i < values.Count; i++) + ctx.LineTo(new Point(i * stepX, ToY(values[i], max, height))); + + if (Fill is not null) + { + ctx.LineTo(new Point((values.Count - 1) * stepX, height)); + ctx.LineTo(new Point(0, height)); + } + + ctx.EndFigure(Fill is not null); + } + + if (Fill is not null) + context.DrawGeometry(Fill, null, geometry); + + context.DrawGeometry(null, new Pen(Stroke, 1.5), geometry); + } + + private static double ToY(double value, double max, double height) + { + var clamped = Math.Clamp(value, 0, max); + return height - clamped / max * height; + } +} diff --git a/src/Vantage.App/Converters/StateBrushConverters.cs b/src/Vantage.App/Converters/StateBrushConverters.cs new file mode 100644 index 0000000..2c59c83 --- /dev/null +++ b/src/Vantage.App/Converters/StateBrushConverters.cs @@ -0,0 +1,43 @@ +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; +using Vantage.Core.Models; + +namespace Vantage.App.Converters; + +public sealed class HealthStatusToBrushConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => value switch + { + HealthStatus.Up => Brushes.LimeGreen, + HealthStatus.Degraded => new SolidColorBrush(Color.Parse("#FFB300")), + HealthStatus.Down => new SolidColorBrush(Color.Parse("#EF5350")), + _ => new SolidColorBrush(Color.Parse("#77808B")) + }; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); +} + +public sealed class BoolToBrushConverter : IValueConverter +{ + public IBrush TrueBrush { get; init; } = Brushes.LimeGreen; + public IBrush FalseBrush { get; init; } = Brushes.Gray; + + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => + value is true ? TrueBrush : FalseBrush; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); +} + +public static class StateBrushConverters +{ + public static readonly BoolToBrushConverter RunningBrush = new() + { + TrueBrush = new SolidColorBrush(Color.Parse("#4CAF50")), + FalseBrush = new SolidColorBrush(Color.Parse("#77808B")) + }; + + public static readonly HealthStatusToBrushConverter HealthBrush = new(); +} diff --git a/src/Vantage.App/ValueFormatting.cs b/src/Vantage.App/ValueFormatting.cs new file mode 100644 index 0000000..bb74ee0 --- /dev/null +++ b/src/Vantage.App/ValueFormatting.cs @@ -0,0 +1,36 @@ +using System.Globalization; + +namespace Vantage.App; + +public static class ValueFormatting +{ + private static readonly string[] ByteUnits = { "B", "KiB", "MiB", "GiB", "TiB", "PiB" }; + + public static string Bytes(long bytes) + { + if (bytes <= 0) + return "0 B"; + + double value = bytes; + var unit = 0; + while (value >= 1024 && unit < ByteUnits.Length - 1) + { + value /= 1024; + unit++; + } + + var format = value >= 100 || unit == 0 ? "0" : "0.0"; + return value.ToString(format, CultureInfo.InvariantCulture) + " " + ByteUnits[unit]; + } + + public static string BytesPair(long used, long total) => $"{Bytes(used)} / {Bytes(total)}"; + + public static string Uptime(TimeSpan uptime) + { + if (uptime.TotalDays >= 1) + return $"{(int)uptime.TotalDays}d {uptime.Hours}h {uptime.Minutes}m"; + if (uptime.TotalHours >= 1) + return $"{uptime.Hours}h {uptime.Minutes}m"; + return $"{uptime.Minutes}m {uptime.Seconds}s"; + } +}