initial commit
Pulsar .NET 9.0 Windows Release / build (push) Canceled after 0s
Mirror to Codeberg and Gitea / mirror (push) Canceled after 0s

This commit is contained in:
i2p
2026-08-27 10:57:58 -06:00
commit 773d05f8f1
1038 changed files with 109261 additions and 0 deletions
@@ -0,0 +1,176 @@
using Pulsar.Server.Networking;
using Pulsar.Server.Utilities;
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Media;
#nullable enable
namespace Pulsar.Server.Controls.Wpf
{
public sealed class ClientListEntry : INotifyPropertyChanged
{
private string _ip = string.Empty;
private string _nickname = string.Empty;
private string _tag = string.Empty;
private string _userAtPc = string.Empty;
private string _version = string.Empty;
private string _status = string.Empty;
private string _currentWindow = string.Empty;
private string _userStatus = string.Empty;
private string _countryWithCode = string.Empty;
private string _country = string.Empty;
private string _operatingSystem = string.Empty;
private string _accountType = string.Empty;
private bool _isFavorite;
private string _toolTip = string.Empty;
private int _imageIndex;
private ImageSource? _flagImage;
public ClientListEntry(Client client)
{
Client = client ?? throw new ArgumentNullException(nameof(client));
}
public event PropertyChangedEventHandler? PropertyChanged;
public Client Client { get; }
public string Ip
{
get => _ip;
set => SetField(ref _ip, value);
}
public string Nickname
{
get => _nickname;
set => SetField(ref _nickname, value);
}
public string Tag
{
get => _tag;
set => SetField(ref _tag, value);
}
public string UserAtPc
{
get => _userAtPc;
set => SetField(ref _userAtPc, value);
}
public string Version
{
get => _version;
set => SetField(ref _version, value);
}
public string Status
{
get => _status;
set => SetField(ref _status, value);
}
public string CurrentWindow
{
get => _currentWindow;
set => SetField(ref _currentWindow, value);
}
public string UserStatus
{
get => _userStatus;
set => SetField(ref _userStatus, value);
}
public string CountryWithCode
{
get => _countryWithCode;
set => SetField(ref _countryWithCode, value);
}
public string Country
{
get => _country;
set => SetField(ref _country, value);
}
public string OperatingSystem
{
get => _operatingSystem;
set => SetField(ref _operatingSystem, value);
}
public string AccountType
{
get => _accountType;
set => SetField(ref _accountType, value);
}
public bool IsFavorite
{
get => _isFavorite;
set => SetField(ref _isFavorite, value);
}
public string ToolTip
{
get => _toolTip;
set => SetField(ref _toolTip, value);
}
public int ImageIndex
{
get => _imageIndex;
set => SetField(ref _imageIndex, value);
}
public ImageSource? FlagImage
{
get => _flagImage;
set => SetField(ref _flagImage, value);
}
public Brush StatusBrush => string.Equals(Status, "Connected", StringComparison.OrdinalIgnoreCase)
? Brushes.LimeGreen
: Brushes.White;
public Brush VersionBrush => string.Equals(Version, ServerVersion.Current, StringComparison.OrdinalIgnoreCase)
? Brushes.Green
: Brushes.Red;
public void UpdateStatusBrush()
{
OnPropertyChanged(nameof(StatusBrush));
}
public void UpdateVersionBrush()
{
OnPropertyChanged(nameof(VersionBrush));
}
private void SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (!Equals(field, value))
{
field = value;
OnPropertyChanged(propertyName);
if (propertyName == nameof(Status))
{
UpdateStatusBrush();
}
if (propertyName == nameof(Version))
{
UpdateVersionBrush();
}
}
}
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}
@@ -0,0 +1,264 @@
<UserControl x:Class="Pulsar.Server.Controls.Wpf.ClientsListView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800" Background="Transparent">
<UserControl.Resources>
<controlsWpf:FavoriteToBrushConverter x:Key="FavoriteToBrushConverter"
xmlns:controlsWpf="clr-namespace:Pulsar.Server.Controls.Wpf" />
<SolidColorBrush x:Key="RowBackgroundBrush" Color="#1E1E1E" />
<SolidColorBrush x:Key="RowAlternateBackgroundBrush" Color="#232323" />
<SolidColorBrush x:Key="RowHoverBrush" Color="#2B2B2B" />
<SolidColorBrush x:Key="RowSelectedBrush" Color="#3A3A3A" />
<SolidColorBrush x:Key="RowSelectedInactiveBrush" Color="#333333" />
<SolidColorBrush x:Key="RowForegroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="RowSelectedForegroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="HeaderBackgroundBrush" Color="#2A2A2A" />
<SolidColorBrush x:Key="HeaderForegroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="GridBackgroundBrush" Color="#141414" />
<SolidColorBrush x:Key="ScrollBarTrackBrush" Color="#1E1E1E" />
<SolidColorBrush x:Key="ScrollBarThumbBrush" Color="#444444" />
<SolidColorBrush x:Key="ScrollBarThumbHoverBrush" Color="#5A5A5A" />
<SolidColorBrush x:Key="ScrollBarThumbPressedBrush" Color="#737373" />
<Style x:Key="ClientsRowStyle" TargetType="DataGridRow">
<Setter Property="FontSize" Value="12" />
<Setter Property="Background" Value="{DynamicResource RowBackgroundBrush}" />
<Setter Property="Foreground" Value="{DynamicResource RowForegroundBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<EventSetter Event="PreviewMouseRightButtonDown" Handler="DataGridRow_OnPreviewMouseRightButtonDown" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource RowHoverBrush}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{DynamicResource RowSelectedBrush}" />
<Setter Property="Foreground" Value="{DynamicResource RowSelectedForegroundBrush}" />
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="True" />
<Condition Property="IsKeyboardFocusWithin" Value="False" />
</MultiTrigger.Conditions>
<Setter Property="Background" Value="{DynamicResource RowSelectedInactiveBrush}" />
</MultiTrigger>
</Style.Triggers>
</Style>
<Style x:Key="StatusTextStyle" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource RowForegroundBrush}" />
<Style.Triggers>
<DataTrigger Binding="{Binding Status}" Value="Connected">
<Setter Property="Foreground" Value="#32CD32" />
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="SlimScrollBarThumbStyle" TargetType="Thumb">
<Setter Property="Background" Value="{DynamicResource ScrollBarThumbBrush}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border Background="{TemplateBinding Background}" CornerRadius="4" />
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource ScrollBarThumbHoverBrush}" />
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter Property="Background" Value="{DynamicResource ScrollBarThumbPressedBrush}" />
</Trigger>
</Style.Triggers>
</Style>
<ControlTemplate x:Key="SlimVerticalScrollBarTemplate" TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}" Width="{TemplateBinding Width}">
<Grid.RowDefinitions>
<RowDefinition Height="0" />
<RowDefinition Height="*" />
<RowDefinition Height="0" />
</Grid.RowDefinitions>
<Track x:Name="PART_Track"
Grid.Row="1"
IsDirectionReversed="true"
Orientation="Vertical"
Maximum="{TemplateBinding Maximum}"
Minimum="{TemplateBinding Minimum}"
Value="{TemplateBinding Value}"
ViewportSize="{TemplateBinding ViewportSize}">
<Track.Thumb>
<Thumb Style="{StaticResource SlimScrollBarThumbStyle}" />
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
<ControlTemplate x:Key="SlimHorizontalScrollBarTemplate" TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}" Height="{TemplateBinding Height}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="0" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="0" />
</Grid.ColumnDefinitions>
<Track x:Name="PART_Track"
Grid.Column="1"
Orientation="Horizontal"
Maximum="{TemplateBinding Maximum}"
Minimum="{TemplateBinding Minimum}"
Value="{TemplateBinding Value}"
ViewportSize="{TemplateBinding ViewportSize}">
<Track.Thumb>
<Thumb Style="{StaticResource SlimScrollBarThumbStyle}" />
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
<Style x:Key="SlimScrollBarStyle" TargetType="ScrollBar">
<Setter Property="Background" Value="{DynamicResource ScrollBarTrackBrush}" />
<Setter Property="Width" Value="10" />
<Setter Property="Template" Value="{StaticResource SlimVerticalScrollBarTemplate}" />
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Height" Value="10" />
<Setter Property="Width" Value="Auto" />
<Setter Property="Template" Value="{StaticResource SlimHorizontalScrollBarTemplate}" />
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="ClientsCellStyle" TargetType="DataGridCell">
<Setter Property="Background" Value="{Binding Background, RelativeSource={RelativeSource AncestorType=DataGridRow}}" />
<Setter Property="Foreground" Value="{DynamicResource RowForegroundBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Foreground" Value="{DynamicResource RowSelectedForegroundBrush}" />
</Trigger>
<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter Property="Foreground" Value="{DynamicResource RowSelectedForegroundBrush}" />
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="DataGridColumnHeader">
<Setter Property="Foreground" Value="{DynamicResource HeaderForegroundBrush}" />
<Setter Property="Background" Value="{DynamicResource HeaderBackgroundBrush}" />
</Style>
<Style TargetType="DataGrid">
<Setter Property="RowBackground" Value="{DynamicResource RowBackgroundBrush}" />
<Setter Property="AlternatingRowBackground" Value="{DynamicResource RowAlternateBackgroundBrush}" />
<Setter Property="Foreground" Value="{DynamicResource RowForegroundBrush}" />
</Style>
</UserControl.Resources>
<Grid>
<DataGrid x:Name="ClientsGrid"
AutoGenerateColumns="False"
HeadersVisibility="Column"
IsReadOnly="True"
SelectionMode="Extended"
SelectionUnit="FullRow"
EnableRowVirtualization="True"
EnableColumnVirtualization="True"
GridLinesVisibility="None"
ScrollViewer.CanContentScroll="True"
ItemsSource="{Binding ClientsView}"
SelectionChanged="ClientsGrid_OnSelectionChanged"
MouseDoubleClick="ClientsGrid_OnMouseDoubleClick"
BorderThickness="0"
CanUserResizeRows="False"
AlternationCount="2"
Background="{DynamicResource GridBackgroundBrush}"
RowStyle="{StaticResource ClientsRowStyle}"
CellStyle="{StaticResource ClientsCellStyle}">
<DataGrid.Resources>
<Style TargetType="ScrollBar" BasedOn="{StaticResource SlimScrollBarStyle}" />
</DataGrid.Resources>
<DataGrid.Columns>
<DataGridTemplateColumn Header="IP" Width="180">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Image Source="{Binding FlagImage}"
Width="28"
Height="20"
VerticalAlignment="Center"
Stretch="Uniform"
RenderOptions.BitmapScalingMode="Fant">
<Image.Style>
<Style TargetType="Image">
<Setter Property="Visibility" Value="Visible" />
<Style.Triggers>
<Trigger Property="Source" Value="{x:Null}">
<Setter Property="Visibility" Value="Collapsed" />
</Trigger>
</Style.Triggers>
</Style>
</Image.Style>
</Image>
<TextBlock Text="{Binding Ip}" VerticalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Margin" Value="6,0,0,0" />
<Style.Triggers>
<DataTrigger Binding="{Binding FlagImage}" Value="{x:Null}">
<Setter Property="Margin" Value="0" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Binding="{Binding Nickname}" Header="Nickname" Width="120" />
<DataGridTextColumn Binding="{Binding Tag}" Header="Tag" Width="80" />
<DataGridTextColumn Binding="{Binding UserAtPc}" Header="User@PC" Width="150" />
<DataGridTemplateColumn Header="Version" Width="100">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Version}" Foreground="{Binding VersionBrush}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Status" Width="120">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Status}" Style="{StaticResource StatusTextStyle}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Binding="{Binding CurrentWindow}" Header="Current Window" Width="200" />
<DataGridTextColumn Binding="{Binding UserStatus}" Header="User Status" Width="120" />
<DataGridTextColumn Binding="{Binding CountryWithCode}" Header="Country" Width="160" />
<DataGridTextColumn Binding="{Binding OperatingSystem}" Header="OS" Width="180" />
<DataGridTextColumn Binding="{Binding AccountType}" Header="Account Type" Width="120" />
<DataGridTemplateColumn Width="40" Header="★">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<ToggleButton HorizontalAlignment="Center"
VerticalAlignment="Center"
Content="★"
FontSize="14"
Background="Transparent"
BorderThickness="0"
Foreground="{Binding IsFavorite, Converter={StaticResource FavoriteToBrushConverter}}"
Command="{Binding DataContext.ToggleFavoriteCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"
IsChecked="{Binding IsFavorite, Mode=TwoWay}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Grid>
</UserControl>
@@ -0,0 +1,591 @@
using Pulsar.Server.Models;
using Pulsar.Server.Networking;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Controls.Primitives;
using System.Windows.Documents;
#nullable enable
namespace Pulsar.Server.Controls.Wpf
{
public partial class ClientsListView : UserControl
{
private readonly ObservableCollection<ClientListEntry> _entries = new();
private readonly Dictionary<Client, ClientListEntry> _entryLookup = new();
private readonly CollectionViewSource _collectionViewSource;
private bool _groupByCountry;
private Predicate<object>? _filter;
private bool _suppressSelectionNotifications;
private bool _isDragSelecting;
private ClientListEntry? _dragAnchorEntry;
private Point _dragStartPoint;
private SelectionAdorner? _selectionAdorner;
public ClientsListView()
{
InitializeComponent();
_collectionViewSource = new CollectionViewSource { Source = _entries };
_collectionViewSource.Filter += OnCollectionFilter;
ClientsView = _collectionViewSource.View;
ClientsView.SortDescriptions.Add(new SortDescription(nameof(ClientListEntry.Country), ListSortDirection.Ascending));
ClientsView.SortDescriptions.Add(new SortDescription(nameof(ClientListEntry.IsFavorite), ListSortDirection.Descending));
ClientsView.SortDescriptions.Add(new SortDescription(nameof(ClientListEntry.Nickname), ListSortDirection.Ascending));
ToggleFavoriteCommand = new RelayCommand<ClientListEntry>(OnToggleFavorite);
DataContext = this;
ApplyTheme(Settings.DarkMode);
ClientsGrid.PreviewMouseLeftButtonDown += ClientsGrid_OnPreviewMouseLeftButtonDown;
ClientsGrid.PreviewMouseMove += ClientsGrid_OnPreviewMouseMove;
ClientsGrid.PreviewMouseLeftButtonUp += ClientsGrid_OnPreviewMouseLeftButtonUp;
ClientsGrid.MouseLeave += ClientsGrid_OnMouseLeave;
}
public ICollectionView ClientsView { get; }
public ICommand ToggleFavoriteCommand { get; }
public event EventHandler<IReadOnlyList<ClientListEntry>>? SelectionChanged;
public event EventHandler<ClientListEntry>? ItemDoubleClicked;
public event EventHandler<ClientListEntry>? FavoriteToggled;
public IReadOnlyList<ClientListEntry> SelectedEntries => ClientsGrid.SelectedItems.Cast<ClientListEntry>().ToList();
public ClientListEntry? GetEntryByClient(Client client)
{
if (client == null)
{
return null;
}
if (Dispatcher.CheckAccess())
{
_entryLookup.TryGetValue(client, out var entry);
return entry;
}
return Dispatcher.Invoke(() =>
{
_entryLookup.TryGetValue(client, out var entry);
return entry;
});
}
public ClientListEntry AddOrUpdate(Client client, Action<ClientListEntry> updater)
{
if (client == null)
{
throw new ArgumentNullException(nameof(client));
}
if (updater == null)
{
throw new ArgumentNullException(nameof(updater));
}
if (Dispatcher.CheckAccess())
{
return AddOrUpdateInternal(client, updater);
}
return Dispatcher.Invoke(() => AddOrUpdateInternal(client, updater));
}
private ClientListEntry AddOrUpdateInternal(Client client, Action<ClientListEntry> updater)
{
if (!_entryLookup.TryGetValue(client, out var entry))
{
entry = new ClientListEntry(client);
_entries.Add(entry);
_entryLookup[client] = entry;
}
updater(entry);
return entry;
}
public void Remove(Client client)
{
if (client == null)
{
return;
}
void RemoveInternal()
{
if (_entryLookup.TryGetValue(client, out var target))
{
_entries.Remove(target);
_entryLookup.Remove(client);
}
}
if (Dispatcher.CheckAccess())
{
RemoveInternal();
}
else
{
Dispatcher.Invoke(RemoveInternal);
}
}
public void Clear()
{
void ClearInternal()
{
_entries.Clear();
_entryLookup.Clear();
}
if (Dispatcher.CheckAccess())
{
ClearInternal();
}
else
{
Dispatcher.Invoke(ClearInternal);
}
}
public void ApplyFilter(Predicate<ClientListEntry>? filter)
{
_filter = filter != null ? new Predicate<object>(o => filter((ClientListEntry)o)) : null;
Dispatcher.Invoke(() => ClientsView.Refresh());
}
public void SetGroupByCountry(bool enabled)
{
_groupByCountry = enabled;
Dispatcher.Invoke(UpdateGrouping);
}
public void SetSelectedClients(IEnumerable<Client> clients)
{
if (clients == null)
{
return;
}
var target = new HashSet<Client>(clients);
Dispatcher.Invoke(() =>
{
_suppressSelectionNotifications = true;
try
{
ClientsGrid.SelectedItems.Clear();
foreach (var entry in _entries)
{
if (target.Contains(entry.Client))
{
ClientsGrid.SelectedItems.Add(entry);
}
}
}
finally
{
_suppressSelectionNotifications = false;
}
});
}
public void RefreshSort()
{
Dispatcher.Invoke(() =>
{
using (ClientsView.DeferRefresh())
{
ClientsView.SortDescriptions.Clear();
ClientsView.SortDescriptions.Add(new SortDescription(nameof(ClientListEntry.Country), ListSortDirection.Ascending));
ClientsView.SortDescriptions.Add(new SortDescription(nameof(ClientListEntry.IsFavorite), ListSortDirection.Descending));
ClientsView.SortDescriptions.Add(new SortDescription(nameof(ClientListEntry.Nickname), ListSortDirection.Ascending));
}
});
}
public void RefreshItem(ClientListEntry entry)
{
Dispatcher.Invoke(() =>
{
entry.UpdateStatusBrush();
ClientsView.Refresh();
});
}
public void ApplyTheme(bool isDarkMode)
{
Dispatcher.Invoke(() =>
{
Resources["RowBackgroundBrush"] = CreateBrush(isDarkMode ? "#1E1E1E" : "#FFFFFF");
Resources["RowAlternateBackgroundBrush"] = CreateBrush(isDarkMode ? "#232323" : "#F7F7F7");
Resources["RowHoverBrush"] = CreateBrush(isDarkMode ? "#2E2E2E" : "#ECECEC");
Resources["RowSelectedBrush"] = CreateBrush(isDarkMode ? "#162B4C" : "#D8E6FF");
Resources["RowSelectedInactiveBrush"] = CreateBrush(isDarkMode ? "#11213C" : "#E5EFFE");
Resources["RowForegroundBrush"] = CreateBrush(isDarkMode ? "#FFFFFF" : "#1A1A1A");
Resources["RowSelectedForegroundBrush"] = CreateBrush(isDarkMode ? "#67B0FF" : "#0F3B8C");
Resources["HeaderBackgroundBrush"] = CreateBrush(isDarkMode ? "#2A2A2A" : "#FFFFFF");
Resources["HeaderForegroundBrush"] = CreateBrush(isDarkMode ? "#FFFFFF" : "#1A1A1A");
Resources["GridBackgroundBrush"] = CreateBrush(isDarkMode ? "#141414" : "#FFFFFF");
Resources["ScrollBarTrackBrush"] = CreateBrush(isDarkMode ? "#1E1E1E" : "#E5E5E5");
Resources["ScrollBarThumbBrush"] = CreateBrush(isDarkMode ? "#444444" : "#B5B5B5");
Resources["ScrollBarThumbHoverBrush"] = CreateBrush(isDarkMode ? "#5A5A5A" : "#9E9E9E");
Resources["ScrollBarThumbPressedBrush"] = CreateBrush(isDarkMode ? "#737373" : "#7C7C7C");
ClientsGrid.Background = (Brush)Resources["GridBackgroundBrush"];
ClientsGrid.RowBackground = (Brush)Resources["RowBackgroundBrush"];
ClientsGrid.AlternatingRowBackground = (Brush)Resources["RowAlternateBackgroundBrush"];
ClientsGrid.Foreground = (Brush)Resources["RowForegroundBrush"];
});
}
private void UpdateGrouping()
{
using (ClientsView.DeferRefresh())
{
ClientsView.GroupDescriptions.Clear();
if (_groupByCountry)
{
ClientsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(ClientListEntry.Country)));
}
}
}
private void OnCollectionFilter(object sender, FilterEventArgs e)
{
if (_filter == null)
{
e.Accepted = true;
return;
}
e.Accepted = _filter(e.Item);
}
private void OnToggleFavorite(ClientListEntry? entry)
{
if (entry == null)
{
return;
}
System.Diagnostics.Debug.WriteLine($"[ClientsListView] Toggling favorite for {entry.Nickname} ({entry.Client?.Value?.UserAtPc ?? "unknown"})");
System.Diagnostics.Debug.WriteLine($"[ClientsListView] Before toggle: IsFavorite={entry.IsFavorite}");
RefreshSort();
FavoriteToggled?.Invoke(this, entry);
System.Diagnostics.Debug.WriteLine($"[ClientsListView] After toggle: IsFavorite={entry.IsFavorite}");
}
private void ClientsGrid_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_suppressSelectionNotifications)
{
return;
}
var selection = SelectedEntries;
SelectionChanged?.Invoke(this, selection);
}
private void ClientsGrid_OnMouseDoubleClick(object sender, MouseButtonEventArgs e)
{
if (ClientsGrid.SelectedItem is ClientListEntry entry)
{
ItemDoubleClicked?.Invoke(this, entry);
}
}
private void ClientsGrid_OnPreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.OriginalSource is not DependencyObject source)
{
return;
}
if (FindVisualParent<DataGridColumnHeader>(source) != null || FindVisualParent<ScrollBar>(source) != null)
{
return;
}
if (FindVisualParent<ButtonBase>(source) != null)
{
return;
}
_dragStartPoint = e.GetPosition(ClientsGrid);
ClientsGrid.Focus();
if (Keyboard.Modifiers != ModifierKeys.None)
{
return;
}
var row = FindVisualParent<DataGridRow>(source);
_dragAnchorEntry = row?.Item as ClientListEntry;
BeginDragSelection();
if (_dragAnchorEntry != null)
{
SelectEntries(new[] { _dragAnchorEntry });
}
else
{
ClearSelectionInternal(false);
}
e.Handled = true;
}
private void ClientsGrid_OnPreviewMouseMove(object sender, MouseEventArgs e)
{
if (!_isDragSelecting || e.LeftButton != MouseButtonState.Pressed || Keyboard.Modifiers != ModifierKeys.None)
{
return;
}
var point = e.GetPosition(ClientsGrid);
UpdateDragSelection(point);
e.Handled = true;
}
private void ClientsGrid_OnPreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (_isDragSelecting)
{
EndDragSelection();
e.Handled = true;
}
}
private void ClientsGrid_OnMouseLeave(object sender, MouseEventArgs e)
{
if (e.LeftButton != MouseButtonState.Pressed)
{
EndDragSelection();
}
}
private void DataGridRow_OnPreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e)
{
if (sender is DataGridRow row)
{
if (!ClientsGrid.SelectedItems.Contains(row.Item))
{
ClientsGrid.SelectedItem = row.Item;
}
ClientsGrid.Focus();
}
}
private static SolidColorBrush CreateBrush(string hex)
{
var color = (Color)ColorConverter.ConvertFromString(hex)!;
var brush = new SolidColorBrush(color);
brush.Freeze();
return brush;
}
public void ClearSelection()
{
ClearSelectionInternal(true);
}
private void EndDragSelection()
{
_isDragSelecting = false;
_dragAnchorEntry = null;
if (ClientsGrid.IsMouseCaptured)
{
ClientsGrid.ReleaseMouseCapture();
}
if (_selectionAdorner != null)
{
var layer = AdornerLayer.GetAdornerLayer(ClientsGrid);
layer?.Remove(_selectionAdorner);
_selectionAdorner = null;
}
}
private DataGridRow? GetRowFromPoint(Point point)
{
var element = ClientsGrid.InputHitTest(point) as DependencyObject;
return FindVisualParent<DataGridRow>(element);
}
private static T? FindVisualParent<T>(DependencyObject? current) where T : DependencyObject
{
while (current != null)
{
if (current is T target)
{
return target;
}
current = VisualTreeHelper.GetParent(current);
}
return null;
}
private void BeginDragSelection()
{
_isDragSelecting = true;
ClientsGrid.Focus();
ClientsGrid.CaptureMouse();
var layer = AdornerLayer.GetAdornerLayer(ClientsGrid);
if (layer != null)
{
_selectionAdorner = new SelectionAdorner(ClientsGrid, _dragStartPoint);
layer.Add(_selectionAdorner);
}
}
private void UpdateDragSelection(Point currentPoint)
{
_selectionAdorner?.Update(currentPoint);
var rect = new Rect(_dragStartPoint, currentPoint);
var selected = new List<ClientListEntry>();
if (_dragAnchorEntry != null)
{
selected.Add(_dragAnchorEntry);
}
var itemCount = ClientsGrid.Items.Count;
for (var i = 0; i < itemCount; i++)
{
if (ClientsGrid.Items[i] is not ClientListEntry entry)
{
continue;
}
if (ReferenceEquals(entry, _dragAnchorEntry))
{
continue;
}
if (ClientsGrid.ItemContainerGenerator.ContainerFromIndex(i) is not DataGridRow row)
{
continue;
}
var bounds = VisualTreeHelper.GetDescendantBounds(row);
var topLeft = row.TransformToAncestor(ClientsGrid).Transform(new Point(bounds.X, bounds.Y));
var rowRect = new Rect(topLeft, bounds.Size);
if (rowRect.IntersectsWith(rect))
{
selected.Add(entry);
}
}
SelectEntries(selected);
}
private void SelectEntries(IReadOnlyList<ClientListEntry> entries)
{
_suppressSelectionNotifications = true;
try
{
ClientsGrid.SelectedItems.Clear();
foreach (var entry in entries)
{
ClientsGrid.SelectedItems.Add(entry);
}
}
finally
{
_suppressSelectionNotifications = false;
}
SelectionChanged?.Invoke(this, entries);
}
private void ClearSelectionInternal(bool raiseEvent)
{
if (ClientsGrid.SelectedItems.Count == 0)
{
if (raiseEvent)
{
SelectionChanged?.Invoke(this, Array.Empty<ClientListEntry>());
}
return;
}
_suppressSelectionNotifications = true;
try
{
ClientsGrid.SelectedItems.Clear();
}
finally
{
_suppressSelectionNotifications = false;
}
if (raiseEvent)
{
SelectionChanged?.Invoke(this, Array.Empty<ClientListEntry>());
}
}
private sealed class SelectionAdorner : Adorner
{
private static readonly Brush FillBrush;
private static readonly Pen BorderPen;
private Point _start;
private Point _end;
static SelectionAdorner()
{
FillBrush = new SolidColorBrush(Color.FromArgb(40, 51, 153, 255));
FillBrush.Freeze();
BorderPen = new Pen(new SolidColorBrush(Color.FromArgb(200, 51, 153, 255)), 1)
{
DashStyle = DashStyles.Dash
};
BorderPen.Brush.Freeze();
BorderPen.Freeze();
}
public SelectionAdorner(UIElement adornedElement, Point start)
: base(adornedElement)
{
IsHitTestVisible = false;
_start = start;
_end = start;
}
public void Update(Point current)
{
_end = current;
InvalidateVisual();
}
protected override void OnRender(DrawingContext drawingContext)
{
var rect = new Rect(_start, _end);
drawingContext.DrawRectangle(FillBrush, BorderPen, rect);
}
}
}
}
@@ -0,0 +1,23 @@
using System;
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
#nullable enable
namespace Pulsar.Server.Controls.Wpf
{
internal sealed class FavoriteToBrushConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
bool isFavorite = value is bool flag && flag;
return isFavorite ? Brushes.Gold : Brushes.Gray;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}
}
+268
View File
@@ -0,0 +1,268 @@
<UserControl x:Class="Pulsar.Server.Controls.Wpf.HeatMapView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
d:DesignWidth="900"
d:DesignHeight="620">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<SolidColorBrush x:Key="StatsBackgroundBrush" Color="#FFFFFFFF" />
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFF5F5F5" />
<SolidColorBrush x:Key="CardBorderBrush" Color="#FFE0E0E0" />
<SolidColorBrush x:Key="CardForegroundBrush" Color="#FF1F1F1F" />
<SolidColorBrush x:Key="SectionHeaderBrush" Color="#FF1F1F1F" />
<SolidColorBrush x:Key="MutedTextBrush" Color="#FF5F6368" />
<SolidColorBrush x:Key="AccentBrush" Color="#FF1976D2" />
<SolidColorBrush x:Key="PositiveAccentBrush" Color="#FF2E7D32" />
<SolidColorBrush x:Key="NegativeAccentBrush" Color="#FFC62828" />
<SolidColorBrush x:Key="ChartBackgroundBrush" Color="#FFFFFFFF" />
<SolidColorBrush x:Key="ChartBorderBrush" Color="#FFE0E0E0" />
<SolidColorBrush x:Key="ScrollBarTrackBrush" Color="#FFE5E5E5" />
<SolidColorBrush x:Key="ScrollBarThumbBrush" Color="#FFB5B5B5" />
<SolidColorBrush x:Key="ScrollBarThumbHoverBrush" Color="#FF9E9E9E" />
<SolidColorBrush x:Key="ScrollBarThumbPressedBrush" Color="#FF7C7C7C" />
<Style x:Key="RightAlignedCell" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style x:Key="SlimScrollBarThumbStyle" TargetType="Thumb">
<Setter Property="Background" Value="{DynamicResource ScrollBarThumbBrush}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border Background="{TemplateBinding Background}" CornerRadius="4" />
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource ScrollBarThumbHoverBrush}" />
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter Property="Background" Value="{DynamicResource ScrollBarThumbPressedBrush}" />
</Trigger>
</Style.Triggers>
</Style>
<ControlTemplate x:Key="SlimVerticalScrollBarTemplate" TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}" Width="{TemplateBinding Width}">
<Grid.RowDefinitions>
<RowDefinition Height="0" />
<RowDefinition Height="*" />
<RowDefinition Height="0" />
</Grid.RowDefinitions>
<Track x:Name="PART_Track"
Grid.Row="1"
IsDirectionReversed="True"
Orientation="Vertical"
Maximum="{TemplateBinding Maximum}"
Minimum="{TemplateBinding Minimum}"
Value="{TemplateBinding Value}"
ViewportSize="{TemplateBinding ViewportSize}">
<Track.Thumb>
<Thumb Style="{StaticResource SlimScrollBarThumbStyle}" />
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
<ControlTemplate x:Key="SlimHorizontalScrollBarTemplate" TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}" Height="{TemplateBinding Height}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="0" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="0" />
</Grid.ColumnDefinitions>
<Track x:Name="PART_Track"
Grid.Column="1"
Orientation="Horizontal"
Maximum="{TemplateBinding Maximum}"
Minimum="{TemplateBinding Minimum}"
Value="{TemplateBinding Value}"
ViewportSize="{TemplateBinding ViewportSize}">
<Track.Thumb>
<Thumb Style="{StaticResource SlimScrollBarThumbStyle}" />
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
<Style x:Key="SlimScrollBarStyle" TargetType="ScrollBar">
<Setter Property="Background" Value="{DynamicResource ScrollBarTrackBrush}" />
<Setter Property="Width" Value="10" />
<Setter Property="Template" Value="{StaticResource SlimVerticalScrollBarTemplate}" />
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Height" Value="10" />
<Setter Property="Width" Value="Auto" />
<Setter Property="Template" Value="{StaticResource SlimHorizontalScrollBarTemplate}" />
</Trigger>
</Style.Triggers>
</Style>
</UserControl.Resources>
<Grid x:Name="LayoutRoot" Background="{StaticResource StatsBackgroundBrush}">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Background="Transparent"
Visibility="{Binding IsContentVisible, Converter={StaticResource BoolToVisibilityConverter}}">
<ScrollViewer.Resources>
<Style TargetType="ScrollBar" BasedOn="{StaticResource SlimScrollBarStyle}" />
</ScrollViewer.Resources>
<StackPanel Margin="24">
<TextBlock Text="Global presence"
FontSize="20"
FontWeight="SemiBold"
Foreground="{StaticResource SectionHeaderBrush}" />
<ItemsControl ItemsSource="{Binding StatCards}" Margin="0,16,0,24">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="2" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Margin="8"
Padding="16"
CornerRadius="12"
Background="{StaticResource CardBackgroundBrush}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1">
<StackPanel>
<TextBlock Text="{Binding Title}"
FontSize="14"
FontWeight="SemiBold"
Foreground="{StaticResource CardForegroundBrush}" />
<TextBlock Text="{Binding Value}"
FontSize="28"
FontWeight="Bold"
Margin="0,8,0,4"
Foreground="{StaticResource AccentBrush}" />
<TextBlock Text="{Binding Subtitle}"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Opacity="0.7" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid Margin="0,0,0,24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0"
Margin="0,0,12,0"
Padding="16"
CornerRadius="12"
Background="{StaticResource CardBackgroundBrush}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1">
<StackPanel>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,0,12">
<TextBlock Text="World heat map"
FontSize="16"
FontWeight="SemiBold"
Foreground="{StaticResource SectionHeaderBrush}" />
<TextBlock Text="— Clients by country"
FontSize="13"
Margin="8,2,0,0"
Foreground="{StaticResource MutedTextBrush}"
Opacity="0.7" />
</StackPanel>
<ContentControl x:Name="MapHost"
Height="360"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch" />
<TextBlock Text="Hotter regions indicate more connected clients."
Margin="0,12,0,0"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}" />
</StackPanel>
</Border>
<Border Grid.Column="1"
Padding="16"
CornerRadius="12"
Background="{StaticResource CardBackgroundBrush}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1">
<StackPanel>
<TextBlock Text="Top countries"
FontSize="16"
FontWeight="SemiBold"
Foreground="{StaticResource SectionHeaderBrush}"
Margin="0,0,0,12" />
<ListView x:Name="TopCountriesList"
ItemsSource="{Binding TopCountries}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="0"
Background="Transparent"
Foreground="{StaticResource CardForegroundBrush}"
ScrollViewer.VerticalScrollBarVisibility="Auto">
<ListView.Resources>
<Style TargetType="ScrollBar" BasedOn="{StaticResource SlimScrollBarStyle}" />
</ListView.Resources>
<ListView.View>
<GridView AllowsColumnReorder="False">
<GridViewColumn Width="36" Header="#" DisplayMemberBinding="{Binding Rank}" />
<GridViewColumn Width="140" Header="Country" DisplayMemberBinding="{Binding Country}" />
<GridViewColumn Width="60" Header="ISO" DisplayMemberBinding="{Binding Code}" />
<GridViewColumn Width="80" Header="Clients" DisplayMemberBinding="{Binding Count}" />
<GridViewColumn Width="80" Header="Share" DisplayMemberBinding="{Binding SharePercent}" />
</GridView>
</ListView.View>
</ListView>
</StackPanel>
</Border>
</Grid>
<TextBlock Text="{Binding LastUpdated}"
Margin="0,16,0,0"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Opacity="0.7" />
</StackPanel>
</ScrollViewer>
<Border Background="#AA000000"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="Loading heat map..."
Foreground="White"
FontSize="18"
FontWeight="SemiBold"
TextAlignment="Center" />
</StackPanel>
</Border>
<Border Background="#33FF0000"
Visibility="{Binding HasError, Converter={StaticResource BoolToVisibilityConverter}}">
<Border Background="{StaticResource CardBackgroundBrush}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="24"
HorizontalAlignment="Center"
VerticalAlignment="Center"
MaxWidth="420">
<StackPanel>
<TextBlock Text="Unable to load heat map"
FontSize="18"
FontWeight="SemiBold"
Foreground="{StaticResource CardForegroundBrush}"
TextAlignment="Center" />
<TextBlock Text="{Binding ErrorMessage}"
Margin="0,12,0,0"
TextWrapping="Wrap"
Foreground="{StaticResource CardForegroundBrush}"
Opacity="0.8"
TextAlignment="Center" />
</StackPanel>
</Border>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,148 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Media;
using LiveChartsCore.Geo;
using LiveChartsCore.SkiaSharpView.WPF;
using Pulsar.Server.Statistics;
#nullable enable
namespace Pulsar.Server.Controls.Wpf
{
public partial class HeatMapView : UserControl
{
private readonly HeatMapViewModel _viewModel;
private readonly GeoMap _geoMap;
public HeatMapView()
{
InitializeComponent();
_viewModel = new HeatMapViewModel();
DataContext = _viewModel;
Dispatcher.UnhandledException += OnDispatcherUnhandledException;
_geoMap = CreateGeoMap();
MapHost.Content = _geoMap;
Bind(_geoMap, GeoMap.SeriesProperty, nameof(HeatMapViewModel.Series));
}
private void OnDispatcherUnhandledException(object? sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e)
{
if (e.Exception is NullReferenceException &&
e.Exception.StackTrace?.Contains("LiveChartsCore.SkiaSharpView.WPF.Rendering.CompositionTargetTicker.DisposeTicker", StringComparison.Ordinal) == true)
{
e.Handled = true;
}
}
public void ShowLoading()
{
Dispatcher.Invoke(_viewModel.SetLoading);
}
public void ShowError(string message)
{
Dispatcher.Invoke(() => _viewModel.SetError(message));
}
public void UpdateSnapshot(ClientGeoSnapshot snapshot)
{
if (snapshot == null)
{
return;
}
Dispatcher.Invoke(() => _viewModel.UpdateSnapshot(snapshot));
}
public void ApplyTheme(bool isDarkMode)
{
Dispatcher.Invoke(() =>
{
UpdateBrush("StatsBackgroundBrush", isDarkMode ? "#FF1A1A1A" : "#FFFFFFFF");
UpdateBrush("CardBackgroundBrush", isDarkMode ? "#FF222327" : "#FFF5F5F5");
UpdateBrush("CardBorderBrush", isDarkMode ? "#FF2E3136" : "#FFE0E0E0");
UpdateBrush("CardForegroundBrush", isDarkMode ? "#FFE8EAED" : "#FF1F1F1F");
UpdateBrush("MutedTextBrush", isDarkMode ? "#FF9AA0A6" : "#FF5F6368");
UpdateBrush("AccentBrush", isDarkMode ? "#FF64B5F6" : "#FF1976D2");
UpdateBrush("SectionHeaderBrush", isDarkMode ? "#FF64B5F6" : "#FF1976D2");
UpdateBrush("ChartBackgroundBrush", isDarkMode ? "#FF1E1F23" : "#FFFFFFFF");
UpdateBrush("ChartBorderBrush", isDarkMode ? "#FF2F3338" : "#FFE0E0E0");
UpdateBrush("ScrollBarTrackBrush", isDarkMode ? "#FF1E1E1E" : "#FFE5E5E5");
UpdateBrush("ScrollBarThumbBrush", isDarkMode ? "#FF444444" : "#FFB5B5B5");
UpdateBrush("ScrollBarThumbHoverBrush", isDarkMode ? "#FF5A5A5A" : "#FF9E9E9E");
UpdateBrush("ScrollBarThumbPressedBrush", isDarkMode ? "#FF737373" : "#FF7C7C7C");
LayoutRoot.Background = (Brush)Resources["StatsBackgroundBrush"];
ApplyMapTheme();
_viewModel.UpdateTheme(isDarkMode);
});
}
private void UpdateBrush(string resourceKey, string hex)
{
var color = (Color)ColorConverter.ConvertFromString(hex)!;
if (Resources[resourceKey] is SolidColorBrush brush)
{
if (!brush.IsFrozen)
{
brush.Color = color;
}
else
{
var mutable = brush.Clone();
mutable.Color = color;
Resources[resourceKey] = mutable;
}
}
else
{
Resources[resourceKey] = new SolidColorBrush(color);
}
}
private void ApplyMapTheme()
{
if (Resources["ChartBackgroundBrush"] is not SolidColorBrush chartBackground)
{
return;
}
if (Resources["ChartBorderBrush"] is not SolidColorBrush chartBorder)
{
return;
}
_geoMap.Background = chartBackground;
_geoMap.BorderBrush = chartBorder;
_geoMap.BorderThickness = new Thickness(1);
}
private static GeoMap CreateGeoMap()
{
return new GeoMap
{
Height = 360,
Padding = new Thickness(8)
};
}
private static Binding CreateOneWayBinding(string path)
{
return new Binding(path)
{
Mode = BindingMode.OneWay,
UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged
};
}
private static void Bind(FrameworkElement element, DependencyProperty property, string path)
{
element.SetBinding(property, CreateOneWayBinding(path));
}
}
}
@@ -0,0 +1,231 @@
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using LiveChartsCore.Geo;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Drawing.Geometries;
using Pulsar.Server.Statistics;
#nullable enable
namespace Pulsar.Server.Controls.Wpf
{
public sealed class HeatMapViewModel : INotifyPropertyChanged
{
private readonly ObservableCollection<StatCardViewModel> _statCards = new()
{
new StatCardViewModel("Total Clients"),
new StatCardViewModel("Geolocated"),
new StatCardViewModel("Unknown Location"),
new StatCardViewModel("Unique Countries")
};
private readonly ObservableCollection<CountryHeatItem> _topCountries = new();
private HeatLandSeries[] _series = Array.Empty<HeatLandSeries>();
private bool _isLoading;
private bool _hasError;
private string? _errorMessage;
private string _lastUpdated = string.Empty;
private bool _isDarkMode;
private ClientGeoSnapshot? _lastSnapshot;
public event PropertyChangedEventHandler? PropertyChanged;
public ReadOnlyObservableCollection<StatCardViewModel> StatCards { get; }
public HeatLandSeries[] Series
{
get => _series;
private set => SetField(ref _series, value);
}
public bool IsLoading
{
get => _isLoading;
private set
{
if (SetField(ref _isLoading, value))
{
OnPropertyChanged(nameof(IsContentVisible));
}
}
}
public bool HasError
{
get => _hasError;
private set
{
if (SetField(ref _hasError, value))
{
OnPropertyChanged(nameof(IsContentVisible));
}
}
}
public bool IsContentVisible => !IsLoading && !HasError;
public string? ErrorMessage
{
get => _errorMessage;
private set => SetField(ref _errorMessage, value);
}
public string LastUpdated
{
get => _lastUpdated;
private set => SetField(ref _lastUpdated, value);
}
public ReadOnlyObservableCollection<CountryHeatItem> TopCountries { get; }
public HeatMapViewModel()
{
StatCards = new ReadOnlyObservableCollection<StatCardViewModel>(_statCards);
TopCountries = new ReadOnlyObservableCollection<CountryHeatItem>(_topCountries);
}
public void SetLoading()
{
ErrorMessage = null;
HasError = false;
IsLoading = true;
}
public void SetError(string message)
{
_lastSnapshot = null;
ErrorMessage = message;
HasError = true;
IsLoading = false;
LastUpdated = string.Empty;
ClearData();
}
public void UpdateSnapshot(ClientGeoSnapshot snapshot)
{
_lastSnapshot = snapshot;
ErrorMessage = snapshot.ErrorMessage;
HasError = snapshot.HasError;
IsLoading = false;
if (snapshot.HasError)
{
LastUpdated = string.Empty;
ClearData();
return;
}
LastUpdated = $"Updated {snapshot.GeneratedAtUtc.ToLocalTime():g}";
UpdateCards(snapshot);
UpdateTopCountries(snapshot);
BuildSeries();
}
public void UpdateTheme(bool isDarkMode)
{
_isDarkMode = isDarkMode;
BuildSeries();
}
private void UpdateCards(ClientGeoSnapshot snapshot)
{
_statCards[0].Update(snapshot.TotalClients.ToString("N0"), "Records processed");
_statCards[1].Update(snapshot.MappedClients.ToString("N0"), "Known country");
_statCards[2].Update(snapshot.UnknownClients.ToString("N0"), "No location data");
_statCards[3].Update(snapshot.UniqueCountryCount.ToString("N0"), "Countries represented");
}
private void UpdateTopCountries(ClientGeoSnapshot snapshot)
{
_topCountries.Clear();
var rank = 1;
foreach (var country in snapshot.Countries.Take(15))
{
_topCountries.Add(new CountryHeatItem(rank++, country.Name, country.CountryCode3.ToUpperInvariant(), country.Count, country.Share));
}
}
private void BuildSeries()
{
if (_lastSnapshot == null || _lastSnapshot.HasError)
{
Series = Array.Empty<HeatLandSeries>();
return;
}
var lands = _lastSnapshot.Countries
.Where(c => !string.IsNullOrWhiteSpace(c.CountryCode3))
.Select(c => new HeatLand
{
Name = c.CountryCode3.ToLowerInvariant(),
Value = c.Count
})
.ToArray();
if (lands.Length == 0)
{
Series = Array.Empty<HeatLandSeries>();
return;
}
Series = new[]
{
new HeatLandSeries
{
Lands = lands
}
};
}
private void ClearData()
{
Series = Array.Empty<HeatLandSeries>();
_topCountries.Clear();
}
private bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
public sealed class CountryHeatItem
{
public CountryHeatItem(int rank, string country, string code, int count, double share)
{
Rank = rank;
Country = string.IsNullOrWhiteSpace(country) ? "Unknown" : country;
Code = string.IsNullOrWhiteSpace(code) ? "" : code;
Count = count;
Share = share;
}
public int Rank { get; }
public string Country { get; }
public string Code { get; }
public int Count { get; }
public double Share { get; }
public string SharePercent => Share > 0 ? Share.ToString("P1") : "0%";
}
}
@@ -0,0 +1,175 @@
<UserControl x:Class="Pulsar.Server.Controls.Wpf.ProcessTreeView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:wpf="clr-namespace:Pulsar.Server.Controls.Wpf"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="480">
<UserControl.Resources>
<SolidColorBrush x:Key="RowSeparatorBrush" Color="#2E2E2E" />
<SolidColorBrush x:Key="RowHoverBrush" Color="#2A2A2A" />
<SolidColorBrush x:Key="ScrollbarTrackBrush" Color="#1E1E1E" />
<SolidColorBrush x:Key="ScrollbarThumbBrush" Color="#3C3C3C" />
<SolidColorBrush x:Key="ScrollbarThumbHoverBrush" Color="#525252" />
<HierarchicalDataTemplate DataType="{x:Type wpf:ProcessTreeNode}" ItemsSource="{Binding Children}">
<Border BorderBrush="{StaticResource RowSeparatorBrush}"
BorderThickness="0,0,0,1"
Background="Transparent"
Padding="0">
<Grid Margin="0" VerticalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding Name}"
Foreground="{Binding Foreground}" />
<TextBlock Grid.Column="1"
Text="{Binding PidDisplay}"
Margin="16,0,16,0"
HorizontalAlignment="Left"
Foreground="{Binding Foreground}" />
<TextBlock Grid.Column="2"
Text="{Binding WindowTitle}"
Foreground="{Binding Foreground}" />
</Grid>
</Border>
</HierarchicalDataTemplate>
<Style TargetType="TreeViewItem">
<Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<Setter Property="Padding" Value="4" />
<Setter Property="FontSize" Value="12" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="#F5F5F5" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsRatProcess}" Value="True">
<Setter Property="Foreground" Value="#9CFF9C" />
<Setter Property="FontWeight" Value="SemiBold" />
</DataTrigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{StaticResource RowHoverBrush}" />
<Setter Property="Foreground" Value="#FFFFFF" />
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{StaticResource RowHoverBrush}" />
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="ScrollBar">
<Setter Property="Width" Value="12" />
<Setter Property="Background" Value="{StaticResource ScrollbarTrackBrush}" />
<Setter Property="Foreground" Value="{StaticResource ScrollbarThumbBrush}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}">
<Track x:Name="PART_Track"
IsDirectionReversed="true">
<Track.Thumb>
<Thumb x:Name="Thumb"
Background="{Binding RelativeSource={RelativeSource AncestorType=ScrollBar}, Path=Foreground}">
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Border Background="{TemplateBinding Background}" CornerRadius="4" />
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
</Track>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Thumb" Property="Background" Value="{StaticResource ScrollbarThumbHoverBrush}" />
</Trigger>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Width" Value="Auto" />
<Setter Property="Height" Value="12" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<Grid Background="Transparent">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border Grid.Row="0"
Background="#1F1F1F"
BorderBrush="#2E2E2E"
BorderThickness="0,0,0,1"
Padding="10,6">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0"
Orientation="Horizontal"
Cursor="Hand"
MouseLeftButtonUp="OnNameHeaderClick">
<TextBlock Text="Name"
Foreground="#F0F0F0"
FontSize="13"
FontWeight="SemiBold" />
<TextBlock Text="{Binding HeaderGlyphName}"
Foreground="#F0F0F0"
Margin="6,0,0,0" />
</StackPanel>
<StackPanel Grid.Column="1"
Orientation="Horizontal"
Margin="16,0"
Cursor="Hand"
MouseLeftButtonUp="OnPidHeaderClick">
<TextBlock Text="PID"
Foreground="#F0F0F0"
FontSize="13"
FontWeight="SemiBold" />
<TextBlock Text="{Binding HeaderGlyphPid}"
Foreground="#F0F0F0"
Margin="6,0,0,0" />
</StackPanel>
<StackPanel Grid.Column="2"
Orientation="Horizontal"
Cursor="Hand"
MouseLeftButtonUp="OnTitleHeaderClick">
<TextBlock Text="Window Title"
Foreground="#F0F0F0"
FontSize="13"
FontWeight="SemiBold" />
<TextBlock Text="{Binding HeaderGlyphTitle}"
Foreground="#F0F0F0"
Margin="6,0,0,0" />
</StackPanel>
</Grid>
</Border>
<Border Grid.Row="1" Background="Transparent">
<TreeView x:Name="Tree"
ItemsSource="{Binding RootNodes}"
Background="Transparent"
BorderThickness="0"
ScrollViewer.VerticalScrollBarVisibility="Auto"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
SelectedItemChanged="OnTreeSelected"
PreviewMouseRightButtonDown="OnTreePreviewRightMouse"
PreviewMouseWheel="OnTreePreviewMouseWheel" />
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,484 @@
using Pulsar.Common.Models;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
namespace Pulsar.Server.Controls.Wpf
{
public partial class ProcessTreeView : UserControl
{
private readonly ProcessTreeViewModel _viewModel = new ProcessTreeViewModel();
private ScrollViewer _scrollViewer;
public ProcessTreeView()
{
InitializeComponent();
DataContext = _viewModel;
Loaded += OnLoaded;
}
public event EventHandler<SortRequestedEventArgs> SortRequested;
public event EventHandler SelectedProcessChanged;
public Process SelectedProcess => (Tree.SelectedItem as ProcessTreeNode)?.Model;
public IReadOnlyList<Process> SelectedProcesses
{
get
{
var selected = SelectedProcess;
return selected != null ? new[] { selected } : Array.Empty<Process>();
}
}
// make sure these fields exist in the class
private List<ProcessTreeNode> _allNodes = new List<ProcessTreeNode>();
private int _searchIndex = -1;
// public so the form can call it (or you can call it from FindNext)
public void FlattenNodes()
{
_allNodes.Clear();
void Add(ProcessTreeNode node)
{
_allNodes.Add(node);
foreach (var child in node.Children)
Add(child);
}
foreach (var root in _viewModel.RootNodes)
Add(root);
}
private HashSet<int> _expandedNodeIds = new();
public void SaveExpandedNodes()
{
_expandedNodeIds.Clear();
foreach (var node in FlattenAllNodes())
if (node.IsExpanded)
_expandedNodeIds.Add(node.Model.Id);
}
private IEnumerable<ProcessTreeNode> FlattenAllNodes()
{
var list = new List<ProcessTreeNode>();
void Add(ProcessTreeNode n)
{
list.Add(n);
foreach (var c in n.Children) Add(c);
}
foreach (var root in _viewModel.RootNodes)
Add(root);
return list;
}
// Call this to find the next match for the given query.
// Query supports multiple words; all words must be matched (AND) across any of the fields.
public void FindNext(string query)
{
if (string.IsNullOrWhiteSpace(query)) return;
// ensure list is fresh
FlattenNodes();
if (_allNodes.Count == 0) return;
var keywords = query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
.Select(k => k.Trim())
.Where(k => k.Length > 0)
.ToArray();
if (keywords.Length == 0) return;
// start searching from next index
_searchIndex = (_searchIndex + 1) % _allNodes.Count;
for (int i = 0; i < _allNodes.Count; i++)
{
int idx = (_searchIndex + i) % _allNodes.Count;
var node = _allNodes[idx];
bool matchesAll = keywords.All(k =>
(!string.IsNullOrEmpty(node.Name) && node.Name.IndexOf(k, StringComparison.OrdinalIgnoreCase) >= 0) ||
(!string.IsNullOrEmpty(node.WindowTitle) && node.WindowTitle.IndexOf(k, StringComparison.OrdinalIgnoreCase) >= 0) ||
node.Model.Id.ToString().IndexOf(k, StringComparison.OrdinalIgnoreCase) >= 0
);
if (matchesAll)
{
// deselect previous selection(s)
foreach (var n in _allNodes) n.IsSelected = false;
// select and expand this node
node.IsSelected = true;
node.IsExpanded = true;
// expand ancestors so the node is visible
ExpandAncestorsForNode(node);
// scroll into view if possible
var tvi = GetTreeViewItem(node);
tvi?.BringIntoView();
_searchIndex = idx;
break;
}
}
}
// very small helper: expand ancestors by walking from root — simple and non-invasive
private void ExpandAncestorsForNode(ProcessTreeNode target)
{
// walk all root branches and expand while searching for the target; when found, keep ancestors expanded
bool TryExpandPath(ProcessTreeNode node)
{
if (node == target) return true;
foreach (var child in node.Children)
{
if (TryExpandPath(child))
{
node.IsExpanded = true; // keep ancestor expanded
return true;
}
}
return false;
}
foreach (var root in _viewModel.RootNodes)
{
if (TryExpandPath(root)) break;
}
}
// Highlight processes by keyword(s) in Name, WindowTitle, or PID
public void FindProcesses(string query)
{
if (string.IsNullOrWhiteSpace(query)) return;
var keywords = query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
void Highlight(ProcessTreeNode node)
{
node.IsSelected = keywords.All(k =>
(!string.IsNullOrEmpty(node.Name) && node.Name.IndexOf(k, StringComparison.OrdinalIgnoreCase) >= 0) ||
(!string.IsNullOrEmpty(node.WindowTitle) && node.WindowTitle.IndexOf(k, StringComparison.OrdinalIgnoreCase) >= 0) ||
node.Model.Id.ToString().Contains(k)
);
foreach (var child in node.Children)
Highlight(child);
}
foreach (var root in _viewModel.RootNodes)
Highlight(root);
}
// Clear all highlights / selections
public void ClearSearch()
{
void Clear(ProcessTreeNode node)
{
node.IsSelected = false;
foreach (var child in node.Children)
Clear(child);
}
foreach (var root in _viewModel.RootNodes)
Clear(root);
}
private void RestoreExpandedNodes()
{
foreach (var node in FlattenAllNodes())
node.IsExpanded = _expandedNodeIds.Contains(node.Model.Id);
}
public void UpdateProcesses(IEnumerable<Process> processes, ProcessTreeSortColumn sortColumn, bool ascending, int? ratPid)
{
// Save current selections and expanded state
var selectedIds = SelectedProcesses.Select(p => p.Id).ToArray();
SaveExpandedNodes();
_viewModel.Apply(processes, sortColumn, ascending, ratPid);
// Remove ExpandAll(), we want to restore only previous expansions
RestoreExpandedNodes();
// Restore selection
SelectProcessesById(selectedIds);
}
public void ExpandRoots()
{
foreach (var node in _viewModel.RootNodes)
node.IsExpanded = true;
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
if (_scrollViewer == null)
_scrollViewer = FindDescendant<ScrollViewer>(Tree);
}
private void OnTreeSelected(object sender, RoutedPropertyChangedEventArgs<object> e)
{
SelectedProcessChanged?.Invoke(this, EventArgs.Empty);
}
private void OnNameHeaderClick(object sender, MouseButtonEventArgs e) => SortRequested?.Invoke(this, new SortRequestedEventArgs(ProcessTreeSortColumn.Name));
private void OnPidHeaderClick(object sender, MouseButtonEventArgs e) => SortRequested?.Invoke(this, new SortRequestedEventArgs(ProcessTreeSortColumn.Pid));
private void OnTitleHeaderClick(object sender, MouseButtonEventArgs e) => SortRequested?.Invoke(this, new SortRequestedEventArgs(ProcessTreeSortColumn.WindowTitle));
private void OnTreePreviewRightMouse(object sender, MouseButtonEventArgs e)
{
if (FindAncestor<TreeViewItem>((DependencyObject)e.OriginalSource) is TreeViewItem tvi)
{
tvi.IsSelected = true;
tvi.Focus();
}
}
public void SelectProcessesById(int[] ids)
{
if (ids == null || ids.Length == 0) return;
void RestoreSelection(ProcessTreeNode node)
{
node.IsSelected = ids.Contains(node.Model.Id);
foreach (var child in node.Children)
RestoreSelection(child);
}
foreach (var root in _viewModel.RootNodes)
RestoreSelection(root);
}
// Recursively get TreeViewItem for a node
private TreeViewItem GetTreeViewItem(object item)
{
return Tree.ItemContainerGenerator.ContainerFromItem(item) as TreeViewItem
?? FindContainerInChildren(Tree.ItemContainerGenerator, item);
}
private TreeViewItem FindContainerInChildren(ItemContainerGenerator parentGenerator, object item)
{
foreach (var child in parentGenerator.Items)
{
var tvi = parentGenerator.ContainerFromItem(child) as TreeViewItem;
if (tvi != null)
{
var childTvi = tvi.ItemContainerGenerator.ContainerFromItem(item) as TreeViewItem;
if (childTvi != null) return childTvi;
var recursive = FindContainerInChildren(tvi.ItemContainerGenerator, item);
if (recursive != null) return recursive;
}
}
return null;
}
private void OnTreePreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
if (_scrollViewer == null)
_scrollViewer = FindDescendant<ScrollViewer>(Tree);
if (_scrollViewer != null && e.Delta != 0)
{
var offset = _scrollViewer.VerticalOffset - e.Delta / 3.0;
_scrollViewer.ScrollToVerticalOffset(Math.Max(0, offset));
e.Handled = true;
}
}
private static T FindAncestor<T>(DependencyObject current) where T : DependencyObject
{
while (current != null)
{
if (current is T match) return match;
current = VisualTreeHelper.GetParent(current);
}
return null;
}
private static T FindDescendant<T>(DependencyObject parent) where T : DependencyObject
{
if (parent == null) return null;
for (int i = 0, count = VisualTreeHelper.GetChildrenCount(parent); i < count; i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (child is T match) return match;
var descendant = FindDescendant<T>(child);
if (descendant != null) return descendant;
}
return null;
}
}
public enum ProcessTreeSortColumn
{
Name = 0,
Pid = 1,
WindowTitle = 2
}
public sealed class SortRequestedEventArgs : EventArgs
{
public SortRequestedEventArgs(ProcessTreeSortColumn column) => Column = column;
public ProcessTreeSortColumn Column { get; }
}
internal sealed class ProcessTreeViewModel : INotifyPropertyChanged
{
public ObservableCollection<ProcessTreeNode> RootNodes { get; } = new ObservableCollection<ProcessTreeNode>();
private ProcessTreeSortColumn _sortColumn = ProcessTreeSortColumn.Name;
private bool _sortAscending = true;
public string HeaderGlyphName => BuildGlyph(ProcessTreeSortColumn.Name);
public string HeaderGlyphPid => BuildGlyph(ProcessTreeSortColumn.Pid);
public string HeaderGlyphTitle => BuildGlyph(ProcessTreeSortColumn.WindowTitle);
public event PropertyChangedEventHandler PropertyChanged;
public void Apply(IEnumerable<Process> processes, ProcessTreeSortColumn sortColumn, bool ascending, int? ratPid)
{
_sortColumn = sortColumn;
_sortAscending = ascending;
OnPropertyChanged(nameof(HeaderGlyphName));
OnPropertyChanged(nameof(HeaderGlyphPid));
OnPropertyChanged(nameof(HeaderGlyphTitle));
RootNodes.Clear();
var items = processes?.ToArray() ?? Array.Empty<Process>();
if (items.Length == 0) return;
var processById = items.ToDictionary(p => p.Id, p => p);
var children = new Dictionary<int, List<Process>>();
var roots = new List<Process>();
foreach (var process in items)
{
if (process.ParentId.HasValue && process.ParentId.Value > 0 && process.ParentId.Value != process.Id && processById.ContainsKey(process.ParentId.Value))
{
if (!children.TryGetValue(process.ParentId.Value, out var list))
{
list = new List<Process>();
children.Add(process.ParentId.Value, list);
}
list.Add(process);
}
else
{
roots.Add(process);
}
}
var comparer = new ProcessComparer(sortColumn, ascending);
roots.Sort(comparer);
var visited = new HashSet<int>();
foreach (var root in roots) AddNodeRecursive(root, null);
foreach (var process in items)
if (!visited.Contains(process.Id)) AddNodeRecursive(process, null);
void AddNodeRecursive(Process process, ProcessTreeNode parent)
{
if (!visited.Add(process.Id)) return;
var node = new ProcessTreeNode(process, ratPid, parent == null);
if (parent == null) RootNodes.Add(node);
else parent.Children.Add(node);
if (children.TryGetValue(process.Id, out var childList))
{
childList.Sort(comparer);
foreach (var child in childList) AddNodeRecursive(child, node);
}
}
}
public void ExpandAll()
{
foreach (var node in RootNodes)
node.SetExpandedRecursive(true);
}
private string BuildGlyph(ProcessTreeSortColumn forColumn) => _sortColumn != forColumn ? string.Empty : (_sortAscending ? "▲" : "▼");
private void OnPropertyChanged([CallerMemberName] string propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
internal sealed class ProcessTreeNode : INotifyPropertyChanged
{
private bool _isSelected;
public bool IsSelected
{
get => _isSelected;
set { if (_isSelected != value) { _isSelected = value; OnPropertyChanged(); } }
}
public ProcessTreeNode(Process model, int? ratPid, bool expandByDefault)
{
Model = model;
Children = new ObservableCollection<ProcessTreeNode>();
_isExpanded = expandByDefault;
IsRatProcess = ratPid.HasValue && model.Id == ratPid.Value;
_foreground = IsRatProcess ? new SolidColorBrush(Color.FromRgb(140, 255, 140)) : (Brush)new SolidColorBrush(Color.FromRgb(230, 230, 230));
}
public Process Model { get; }
public ObservableCollection<ProcessTreeNode> Children { get; }
public bool IsRatProcess { get; }
public string Name => string.IsNullOrWhiteSpace(Model.Name) ? "(unknown)" : Model.Name;
public string PidDisplay => Model.Id.ToString();
public string WindowTitle => string.IsNullOrWhiteSpace(Model.MainWindowTitle) ? string.Empty : Model.MainWindowTitle;
private bool _isExpanded;
public bool IsExpanded
{
get => _isExpanded;
set { if (_isExpanded != value) { _isExpanded = value; OnPropertyChanged(); } }
}
private readonly Brush _foreground;
public Brush Foreground => _foreground;
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
public void SetExpandedRecursive(bool isExpanded) { IsExpanded = isExpanded; foreach (var child in Children) child.SetExpandedRecursive(isExpanded); }
}
internal sealed class ProcessComparer : IComparer<Process>
{
private readonly ProcessTreeSortColumn _column;
private readonly bool _ascending;
public ProcessComparer(ProcessTreeSortColumn column, bool ascending) { _column = column; _ascending = ascending; }
public int Compare(Process x, Process y)
{
if (ReferenceEquals(x, y)) return 0;
if (x is null) return _ascending ? -1 : 1;
if (y is null) return _ascending ? 1 : -1;
int result = _column switch
{
ProcessTreeSortColumn.Pid => x.Id.CompareTo(y.Id),
ProcessTreeSortColumn.WindowTitle => string.Compare(x.MainWindowTitle ?? string.Empty, y.MainWindowTitle ?? string.Empty, StringComparison.CurrentCultureIgnoreCase),
_ => string.Compare(x.Name ?? string.Empty, y.Name ?? string.Empty, StringComparison.CurrentCultureIgnoreCase)
};
if (result == 0) result = x.Id.CompareTo(y.Id);
return _ascending ? result : -result;
}
}
}
@@ -0,0 +1,36 @@
using System;
using System.Windows.Input;
#nullable enable
namespace Pulsar.Server.Controls.Wpf
{
internal sealed class RelayCommand<T> : ICommand
{
private readonly Action<T?> _execute;
private readonly Func<T?, bool>? _canExecute;
public RelayCommand(Action<T?> execute, Func<T?, bool>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter)
{
return _canExecute?.Invoke((T?)parameter) ?? true;
}
public void Execute(object? parameter)
{
_execute((T?)parameter);
}
public void RaiseCanExecuteChanged()
{
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}
}
+275
View File
@@ -0,0 +1,275 @@
<UserControl x:Class="Pulsar.Server.Controls.Wpf.StatsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
d:DesignWidth="900"
d:DesignHeight="620">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<SolidColorBrush x:Key="StatsBackgroundBrush" Color="#FFFFFFFF" />
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFF5F5F5" />
<SolidColorBrush x:Key="CardBorderBrush" Color="#FFE0E0E0" />
<SolidColorBrush x:Key="CardForegroundBrush" Color="#FF1F1F1F" />
<SolidColorBrush x:Key="SectionHeaderBrush" Color="#FF1F1F1F" />
<SolidColorBrush x:Key="MutedTextBrush" Color="#FF5F6368" />
<SolidColorBrush x:Key="AccentBrush" Color="#FF1976D2" />
<SolidColorBrush x:Key="PositiveAccentBrush" Color="#FF2E7D32" />
<SolidColorBrush x:Key="NegativeAccentBrush" Color="#FFC62828" />
<SolidColorBrush x:Key="ChartBackgroundBrush" Color="#FFFFFFFF" />
<SolidColorBrush x:Key="ChartBorderBrush" Color="#FFE0E0E0" />
<SolidColorBrush x:Key="ScrollBarTrackBrush" Color="#FFE5E5E5" />
<SolidColorBrush x:Key="ScrollBarThumbBrush" Color="#FFB5B5B5" />
<SolidColorBrush x:Key="ScrollBarThumbHoverBrush" Color="#FF9E9E9E" />
<SolidColorBrush x:Key="ScrollBarThumbPressedBrush" Color="#FF7C7C7C" />
<Style x:Key="RightAlignedCell" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style x:Key="SlimScrollBarThumbStyle" TargetType="Thumb">
<Setter Property="Background" Value="{DynamicResource ScrollBarThumbBrush}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border Background="{TemplateBinding Background}" CornerRadius="4" />
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource ScrollBarThumbHoverBrush}" />
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter Property="Background" Value="{DynamicResource ScrollBarThumbPressedBrush}" />
</Trigger>
</Style.Triggers>
</Style>
<ControlTemplate x:Key="SlimVerticalScrollBarTemplate" TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}" Width="{TemplateBinding Width}">
<Grid.RowDefinitions>
<RowDefinition Height="0" />
<RowDefinition Height="*" />
<RowDefinition Height="0" />
</Grid.RowDefinitions>
<Track x:Name="PART_Track"
Grid.Row="1"
IsDirectionReversed="True"
Orientation="Vertical"
Maximum="{TemplateBinding Maximum}"
Minimum="{TemplateBinding Minimum}"
Value="{TemplateBinding Value}"
ViewportSize="{TemplateBinding ViewportSize}">
<Track.Thumb>
<Thumb Style="{StaticResource SlimScrollBarThumbStyle}" />
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
<ControlTemplate x:Key="SlimHorizontalScrollBarTemplate" TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}" Height="{TemplateBinding Height}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="0" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="0" />
</Grid.ColumnDefinitions>
<Track x:Name="PART_Track"
Grid.Column="1"
Orientation="Horizontal"
Maximum="{TemplateBinding Maximum}"
Minimum="{TemplateBinding Minimum}"
Value="{TemplateBinding Value}"
ViewportSize="{TemplateBinding ViewportSize}">
<Track.Thumb>
<Thumb Style="{StaticResource SlimScrollBarThumbStyle}" />
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
<Style x:Key="SlimScrollBarStyle" TargetType="ScrollBar">
<Setter Property="Background" Value="{DynamicResource ScrollBarTrackBrush}" />
<Setter Property="Width" Value="10" />
<Setter Property="Template" Value="{StaticResource SlimVerticalScrollBarTemplate}" />
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Height" Value="10" />
<Setter Property="Width" Value="Auto" />
<Setter Property="Template" Value="{StaticResource SlimHorizontalScrollBarTemplate}" />
</Trigger>
</Style.Triggers>
</Style>
</UserControl.Resources>
<Grid x:Name="LayoutRoot" Background="{StaticResource StatsBackgroundBrush}">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Background="Transparent"
Visibility="{Binding IsContentVisible, Converter={StaticResource BoolToVisibilityConverter}}">
<ScrollViewer.Resources>
<Style TargetType="ScrollBar" BasedOn="{StaticResource SlimScrollBarStyle}" />
</ScrollViewer.Resources>
<StackPanel Margin="24">
<TextBlock Text="Overview"
FontSize="20"
FontWeight="SemiBold"
Foreground="{StaticResource SectionHeaderBrush}" />
<ItemsControl ItemsSource="{Binding StatCards}" Margin="0,16,0,24">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="2" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Margin="8"
Padding="16"
CornerRadius="12"
Background="{StaticResource CardBackgroundBrush}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1">
<StackPanel>
<TextBlock Text="{Binding Title}"
FontSize="14"
FontWeight="SemiBold"
Foreground="{StaticResource CardForegroundBrush}" />
<TextBlock x:Name="ValueText"
Text="{Binding Value}"
FontSize="28"
FontWeight="Bold"
Margin="0,8,0,4"
Foreground="{StaticResource AccentBrush}" />
<TextBlock Text="{Binding Subtitle}"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Opacity="0.7" />
</StackPanel>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding Title}" Value="Online Now">
<Setter TargetName="ValueText"
Property="Foreground"
Value="{StaticResource PositiveAccentBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding Title}" Value="Offline">
<Setter TargetName="ValueText"
Property="Foreground"
Value="{StaticResource NegativeAccentBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid Margin="0,0,0,24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0"
Margin="0,0,12,0"
Padding="16"
CornerRadius="12"
Background="{StaticResource CardBackgroundBrush}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1">
<StackPanel>
<TextBlock Text="New clients per day"
FontSize="16"
FontWeight="SemiBold"
Margin="0,0,0,12"
Foreground="{StaticResource SectionHeaderBrush}" />
<ContentControl x:Name="NewClientsChartHost"
Height="240"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch" />
</StackPanel>
</Border>
<StackPanel Grid.Column="1">
<Border Padding="16"
Margin="0,0,0,12"
CornerRadius="12"
Background="{StaticResource CardBackgroundBrush}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1">
<StackPanel>
<TextBlock Text="Clients by country"
FontSize="16"
FontWeight="SemiBold"
Margin="0,0,0,12"
Foreground="{StaticResource SectionHeaderBrush}" />
<ContentControl x:Name="CountryChartHost"
Height="160"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch" />
</StackPanel>
</Border>
<Border Padding="16"
CornerRadius="12"
Background="{StaticResource CardBackgroundBrush}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1">
<StackPanel>
<TextBlock Text="Clients by operating system"
FontSize="16"
FontWeight="SemiBold"
Margin="0,0,0,12"
Foreground="{StaticResource SectionHeaderBrush}" />
<ContentControl x:Name="OperatingSystemChartHost"
Height="160"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch" />
</StackPanel>
</Border>
</StackPanel>
</Grid>
<TextBlock Text="{Binding LastUpdated}"
Margin="0,16,0,0"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Opacity="0.7" />
</StackPanel>
</ScrollViewer>
<Border Background="#AA000000"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="Loading statistics..."
Foreground="White"
FontSize="18"
FontWeight="SemiBold"
TextAlignment="Center" />
</StackPanel>
</Border>
<Border Background="#33FF0000"
Visibility="{Binding HasError, Converter={StaticResource BoolToVisibilityConverter}}">
<Border Background="{StaticResource CardBackgroundBrush}"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="24"
HorizontalAlignment="Center"
VerticalAlignment="Center"
MaxWidth="420">
<StackPanel>
<TextBlock Text="Unable to load statistics"
FontSize="18"
FontWeight="SemiBold"
Foreground="{StaticResource CardForegroundBrush}"
TextAlignment="Center" />
<TextBlock Text="{Binding ErrorMessage}"
Margin="0,12,0,0"
TextWrapping="Wrap"
Foreground="{StaticResource CardForegroundBrush}"
Opacity="0.8"
TextAlignment="Center" />
</StackPanel>
</Border>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,179 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Media;
using LiveChartsCore.SkiaSharpView.WPF;
using Pulsar.Server.Statistics;
#nullable enable
namespace Pulsar.Server.Controls.Wpf
{
public partial class StatsView : UserControl
{
private readonly StatsViewModel _viewModel;
private readonly CartesianChart _newClientsChart;
private readonly PieChart _countryChart;
private readonly PieChart _operatingSystemChart;
public StatsView()
{
InitializeComponent();
_viewModel = new StatsViewModel();
DataContext = _viewModel;
Dispatcher.UnhandledException += OnDispatcherUnhandledException;
_newClientsChart = CreateCartesianChart();
_countryChart = CreatePieChart();
_operatingSystemChart = CreatePieChart();
NewClientsChartHost.Content = _newClientsChart;
CountryChartHost.Content = _countryChart;
OperatingSystemChartHost.Content = _operatingSystemChart;
Bind(_newClientsChart, CartesianChart.SeriesProperty, nameof(StatsViewModel.NewClientsSeries));
Bind(_newClientsChart, CartesianChart.XAxesProperty, nameof(StatsViewModel.NewClientsXAxes));
Bind(_newClientsChart, CartesianChart.YAxesProperty, nameof(StatsViewModel.NewClientsYAxes));
Bind(_countryChart, PieChart.SeriesProperty, nameof(StatsViewModel.ClientsByCountrySeries));
Bind(_operatingSystemChart, PieChart.SeriesProperty, nameof(StatsViewModel.ClientsByOperatingSystemSeries));
}
private void OnDispatcherUnhandledException(object? sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e)
{
if (e.Exception is NullReferenceException &&
e.Exception.StackTrace?.Contains("LiveChartsCore.SkiaSharpView.WPF.Rendering.CompositionTargetTicker.DisposeTicker", StringComparison.Ordinal) == true)
{
e.Handled = true;
}
}
public void ShowLoading()
{
Dispatcher.Invoke(() => _viewModel.SetLoading());
}
public void ShowError(string message)
{
Dispatcher.Invoke(() => _viewModel.SetError(message));
}
public void UpdateSnapshot(ClientStatisticsSnapshot snapshot)
{
if (snapshot == null)
{
return;
}
Dispatcher.Invoke(() => _viewModel.UpdateSnapshot(snapshot));
}
public void ApplyTheme(bool isDarkMode)
{
Dispatcher.Invoke(() =>
{
UpdateBrush("StatsBackgroundBrush", isDarkMode ? "#FF1A1A1A" : "#FFFFFFFF");
UpdateBrush("CardBackgroundBrush", isDarkMode ? "#FF222327" : "#FFF5F5F5");
UpdateBrush("CardBorderBrush", isDarkMode ? "#FF2E3136" : "#FFE0E0E0");
UpdateBrush("CardForegroundBrush", isDarkMode ? "#FFE8EAED" : "#FF1F1F1F");
UpdateBrush("MutedTextBrush", isDarkMode ? "#FF9AA0A6" : "#FF5F6368");
UpdateBrush("AccentBrush", isDarkMode ? "#FF64B5F6" : "#FF1976D2");
UpdateBrush("PositiveAccentBrush", isDarkMode ? "#FF81C784" : "#FF2E7D32");
UpdateBrush("NegativeAccentBrush", isDarkMode ? "#FFEF5350" : "#FFC62828");
UpdateBrush("SectionHeaderBrush", isDarkMode ? "#FF64B5F6" : "#FF1976D2");
UpdateBrush("ChartBackgroundBrush", isDarkMode ? "#FF1E1F23" : "#FFFFFFFF");
UpdateBrush("ChartBorderBrush", isDarkMode ? "#FF2F3338" : "#FFE0E0E0");
UpdateBrush("ScrollBarTrackBrush", isDarkMode ? "#FF1E1E1E" : "#FFE5E5E5");
UpdateBrush("ScrollBarThumbBrush", isDarkMode ? "#FF444444" : "#FFB5B5B5");
UpdateBrush("ScrollBarThumbHoverBrush", isDarkMode ? "#FF5A5A5A" : "#FF9E9E9E");
UpdateBrush("ScrollBarThumbPressedBrush", isDarkMode ? "#FF737373" : "#FF7C7C7C");
LayoutRoot.Background = (Brush)Resources["StatsBackgroundBrush"];
ApplyChartTheme();
_viewModel.UpdateTheme(isDarkMode);
});
}
private void UpdateBrush(string resourceKey, string hex)
{
var color = (Color)ColorConverter.ConvertFromString(hex)!;
if (Resources[resourceKey] is SolidColorBrush brush)
{
if (!brush.IsFrozen)
{
brush.Color = color;
}
else
{
var mutable = brush.Clone();
mutable.Color = color;
Resources[resourceKey] = mutable;
}
}
else
{
Resources[resourceKey] = new SolidColorBrush(color);
}
}
private void ApplyChartTheme()
{
if (Resources["ChartBackgroundBrush"] is not SolidColorBrush chartBackgroundBrush)
{
return;
}
if (Resources["ChartBorderBrush"] is not SolidColorBrush chartBorderBrush)
{
return;
}
_newClientsChart.Background = chartBackgroundBrush;
_countryChart.Background = chartBackgroundBrush;
_operatingSystemChart.Background = chartBackgroundBrush;
_newClientsChart.BorderBrush = chartBorderBrush;
_countryChart.BorderBrush = chartBorderBrush;
_operatingSystemChart.BorderBrush = chartBorderBrush;
var borderThickness = new Thickness(1);
_newClientsChart.BorderThickness = borderThickness;
_countryChart.BorderThickness = borderThickness;
_operatingSystemChart.BorderThickness = borderThickness;
}
private static CartesianChart CreateCartesianChart()
{
return new CartesianChart
{
Height = 240,
Padding = new Thickness(8)
};
}
private static PieChart CreatePieChart()
{
return new PieChart
{
Height = 160,
Padding = new Thickness(8)
};
}
private static Binding CreateOneWayBinding(string path)
{
return new Binding(path)
{
Mode = BindingMode.OneWay,
UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged
};
}
private static void Bind(FrameworkElement element, DependencyProperty property, string path)
{
element.SetBinding(property, CreateOneWayBinding(path));
}
}
}
@@ -0,0 +1,383 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using LiveChartsCore;
using LiveChartsCore.Drawing;
using LiveChartsCore.Measure;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
using Pulsar.Server.Statistics;
using SkiaSharp;
#nullable enable
namespace Pulsar.Server.Controls.Wpf
{
public sealed class StatsViewModel : INotifyPropertyChanged
{
private static readonly SKColor[] LightPalette =
{
SKColor.Parse("#1976D2"),
SKColor.Parse("#388E3C"),
SKColor.Parse("#F57C00"),
SKColor.Parse("#7B1FA2"),
SKColor.Parse("#C2185B"),
SKColor.Parse("#0097A7"),
SKColor.Parse("#AFB42B")
};
private static readonly SKColor[] DarkPalette =
{
SKColor.Parse("#64B5F6"),
SKColor.Parse("#81C784"),
SKColor.Parse("#FFB74D"),
SKColor.Parse("#BA68C8"),
SKColor.Parse("#F06292"),
SKColor.Parse("#4DD0E1"),
SKColor.Parse("#DCE775")
};
private readonly ObservableCollection<StatCardViewModel> _statCards = new()
{
new StatCardViewModel("Total Clients"),
new StatCardViewModel("Online Now"),
new StatCardViewModel("Offline"),
new StatCardViewModel("New (7 days)")
};
private ISeries[] _newClientsSeries = Array.Empty<ISeries>();
private Axis[] _newClientsXAxes = Array.Empty<Axis>();
private Axis[] _newClientsYAxes = Array.Empty<Axis>();
private ISeries[] _clientsByCountrySeries = Array.Empty<ISeries>();
private ISeries[] _clientsByOsSeries = Array.Empty<ISeries>();
private bool _isLoading;
private bool _hasError;
private string? _errorMessage;
private string _lastUpdated = "";
private bool _isDarkMode;
private ClientStatisticsSnapshot? _lastSnapshot;
public event PropertyChangedEventHandler? PropertyChanged;
public ReadOnlyObservableCollection<StatCardViewModel> StatCards { get; }
public ISeries[] NewClientsSeries
{
get => _newClientsSeries;
private set => SetField(ref _newClientsSeries, value);
}
public Axis[] NewClientsXAxes
{
get => _newClientsXAxes;
private set => SetField(ref _newClientsXAxes, value);
}
public Axis[] NewClientsYAxes
{
get => _newClientsYAxes;
private set => SetField(ref _newClientsYAxes, value);
}
public ISeries[] ClientsByCountrySeries
{
get => _clientsByCountrySeries;
private set => SetField(ref _clientsByCountrySeries, value);
}
public ISeries[] ClientsByOperatingSystemSeries
{
get => _clientsByOsSeries;
private set => SetField(ref _clientsByOsSeries, value);
}
public bool IsLoading
{
get => _isLoading;
private set
{
if (SetField(ref _isLoading, value))
{
OnPropertyChanged(nameof(IsContentVisible));
}
}
}
public bool HasError
{
get => _hasError;
private set
{
if (SetField(ref _hasError, value))
{
OnPropertyChanged(nameof(IsContentVisible));
}
}
}
public bool IsContentVisible => !IsLoading && !HasError;
public string? ErrorMessage
{
get => _errorMessage;
private set => SetField(ref _errorMessage, value);
}
public string LastUpdated
{
get => _lastUpdated;
private set => SetField(ref _lastUpdated, value);
}
public StatsViewModel()
{
StatCards = new ReadOnlyObservableCollection<StatCardViewModel>(_statCards);
}
public void SetLoading()
{
ErrorMessage = null;
HasError = false;
IsLoading = true;
}
public void SetError(string message)
{
_lastSnapshot = null;
ErrorMessage = message;
HasError = true;
IsLoading = false;
LastUpdated = string.Empty;
ClearSeries();
}
public void UpdateSnapshot(ClientStatisticsSnapshot snapshot)
{
_lastSnapshot = snapshot;
ErrorMessage = snapshot.ErrorMessage;
HasError = snapshot.HasError;
IsLoading = false;
if (snapshot.HasError)
{
LastUpdated = string.Empty;
ClearSeries();
return;
}
LastUpdated = $"Updated {snapshot.GeneratedAtUtc.ToLocalTime():g}";
UpdateCards(snapshot);
BuildSeries();
}
public void UpdateTheme(bool isDarkMode)
{
_isDarkMode = isDarkMode;
BuildSeries();
}
private void UpdateCards(ClientStatisticsSnapshot snapshot)
{
_statCards[0].Update(snapshot.TotalClients.ToString("N0"), "Unique clients recorded");
_statCards[1].Update(snapshot.OnlineClients.ToString("N0"), "Currently connected");
_statCards[2].Update(snapshot.OfflineClients.ToString("N0"), "Seen but offline");
_statCards[3].Update(snapshot.NewClientsLast7Days.ToString("N0"), "Joined in last 7 days");
}
private void BuildSeries()
{
if (_lastSnapshot == null || _lastSnapshot.HasError)
{
ClearSeries();
return;
}
var accent = GetAccentColor();
var axisText = GetAxisTextColor();
var separator = GetSeparatorColor();
var dailyValues = _lastSnapshot.NewClientsByDay.Select(d => d.Count).ToArray();
var labels = _lastSnapshot.NewClientsByDay.Select(d => d.Date.ToString("MMM dd")).ToArray();
NewClientsSeries = new ISeries[]
{
CreateColumnSeries(dailyValues, accent, axisText)
};
NewClientsXAxes = new[]
{
new Axis
{
Labels = labels,
LabelsPaint = new SolidColorPaint(axisText),
Name = "Day",
NamePaint = new SolidColorPaint(axisText),
TextSize = 13,
Padding = new Padding(10, 0, 10, 0),
SeparatorsPaint = new SolidColorPaint(separator) { StrokeThickness = 1 }
}
};
NewClientsYAxes = new[]
{
new Axis
{
LabelsPaint = new SolidColorPaint(axisText),
TextSize = 13,
Name = "Clients",
NamePaint = new SolidColorPaint(axisText),
MinLimit = 0,
SeparatorsPaint = new SolidColorPaint(separator) { StrokeThickness = 1 }
}
};
ClientsByCountrySeries = BuildPieSeries(_lastSnapshot.ClientsByCountry);
ClientsByOperatingSystemSeries = BuildPieSeries(_lastSnapshot.ClientsByOperatingSystem);
}
private ISeries[] BuildPieSeries(IReadOnlyCollection<CategoryCount> categories)
{
if (categories == null || categories.Count == 0)
{
return Array.Empty<ISeries>();
}
var palette = _isDarkMode ? DarkPalette : LightPalette;
var axisText = GetAxisTextColor();
var stroke = GetSeparatorColor();
var series = categories
.Select((entry, index) =>
{
var pieSeries = new PieSeries<int>
{
Values = new[] { entry.Count },
Name = entry.Label,
Fill = new SolidColorPaint(palette[index % palette.Length]),
Stroke = new SolidColorPaint(stroke) { StrokeThickness = 1.5f },
DataLabelsPaint = new SolidColorPaint(axisText),
DataLabelsSize = 12,
DataLabelsPosition = PolarLabelsPosition.Middle,
DataLabelsFormatter = point =>
{
var value = point.Model;
return value > 0
? $"{entry.Label}: {value:N0} ({entry.Share:P1})"
: entry.Label;
}
};
return pieSeries;
})
.Cast<ISeries>()
.ToArray();
return series;
}
private static ColumnSeries<int> CreateColumnSeries(int[] values, SKColor accent, SKColor axisText)
{
var series = new ColumnSeries<int>
{
Values = values,
Fill = new SolidColorPaint(accent),
Stroke = null
};
if (values.Length <= 10 && values.Any(v => v > 0))
{
series.DataLabelsPaint = new SolidColorPaint(axisText);
series.DataLabelsPosition = LiveChartsCore.Measure.DataLabelsPosition.Top;
series.DataLabelsFormatter = point => point.Model.ToString("N0");
}
return series;
}
private void ClearSeries()
{
NewClientsSeries = Array.Empty<ISeries>();
NewClientsXAxes = Array.Empty<Axis>();
NewClientsYAxes = Array.Empty<Axis>();
ClientsByCountrySeries = Array.Empty<ISeries>();
ClientsByOperatingSystemSeries = Array.Empty<ISeries>();
}
private SKColor GetAccentColor() => _isDarkMode ? SKColor.Parse("#64B5F6") : SKColor.Parse("#1E88E5");
private SKColor GetAxisTextColor() => _isDarkMode ? SKColors.White : SKColor.Parse("#1A1A1A");
private SKColor GetSeparatorColor() => _isDarkMode ? SKColor.Parse("#424242") : SKColor.Parse("#BDBDBD");
private bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
public sealed class StatCardViewModel : INotifyPropertyChanged
{
private string _value = "0";
private string _subtitle = string.Empty;
public StatCardViewModel(string title)
{
Title = title;
}
public string Title { get; }
public string Value
{
get => _value;
private set => SetField(ref _value, value);
}
public string Subtitle
{
get => _subtitle;
private set => SetField(ref _subtitle, value);
}
public event PropertyChangedEventHandler? PropertyChanged;
public void Update(string value, string subtitle)
{
Value = value;
Subtitle = subtitle;
}
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
}
}