initial commit
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user