679 lines
26 KiB
C#
679 lines
26 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Diagnostics;
|
|
using System.Windows;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using LiveChartsCore;
|
|
using LiveChartsCore.SkiaSharpView;
|
|
using LiveChartsCore.SkiaSharpView.Painting;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Win32;
|
|
using SharepointToolbox.Core.Models;
|
|
using SharepointToolbox.Localization;
|
|
using SharepointToolbox.Services;
|
|
using SharepointToolbox.Services.Export;
|
|
using SkiaSharp;
|
|
|
|
namespace SharepointToolbox.ViewModels.Tabs;
|
|
|
|
public partial class StorageViewModel : FeatureViewModelBase
|
|
{
|
|
private readonly IStorageService _storageService;
|
|
private readonly ISessionManager _sessionManager;
|
|
private readonly StorageCsvExportService _csvExportService;
|
|
private readonly StorageHtmlExportService _htmlExportService;
|
|
private readonly IBrandingService? _brandingService;
|
|
private readonly IOwnershipElevationService? _ownershipService;
|
|
private readonly SettingsService? _settingsService;
|
|
private readonly ThemeManager? _themeManager;
|
|
private readonly ILogger<FeatureViewModelBase> _logger;
|
|
private TenantProfile? _currentProfile;
|
|
|
|
[ObservableProperty]
|
|
private bool _perLibrary = true;
|
|
|
|
[ObservableProperty]
|
|
private bool _includeSubsites;
|
|
|
|
[ObservableProperty]
|
|
private int _folderDepth;
|
|
|
|
[ObservableProperty]
|
|
private bool _isDonutChart = true;
|
|
|
|
// ── Scan-time flags (control what is captured during the CSOM scan) ─────
|
|
[ObservableProperty] private bool _scanHiddenLibraries = true;
|
|
[ObservableProperty] private bool _scanPreservationHold = true;
|
|
[ObservableProperty] private bool _scanListAttachments = true;
|
|
[ObservableProperty] private bool _scanRecycleBin = true;
|
|
|
|
// ── Report filter flags (gate which kinds appear in DataGrid + exports) ─
|
|
[ObservableProperty] private bool _showLibraries = true;
|
|
[ObservableProperty] private bool _showHiddenLibraries = true;
|
|
[ObservableProperty] private bool _showPreservationHold = true;
|
|
[ObservableProperty] private bool _showListAttachments = true;
|
|
[ObservableProperty] private bool _showRecycleBin = true;
|
|
[ObservableProperty] private bool _showSubsites = true;
|
|
|
|
/// <summary>
|
|
/// When true, recycle bin stage 1 + stage 2 collapse into a single
|
|
/// "[Recycle Bin] Total" row whose size is the sum of both stages.
|
|
/// When false, both stages render as separate rows.
|
|
/// </summary>
|
|
[ObservableProperty] private bool _combineRecycleBinStages = true;
|
|
|
|
// SPO-reported site total (Site.Usage.Storage). Independent reference
|
|
// value the user can compare against the scanned total.
|
|
[ObservableProperty] private long _spoReportedTotalSize;
|
|
|
|
partial void OnShowLibrariesChanged(bool value) => RebuildFilteredResults();
|
|
partial void OnShowHiddenLibrariesChanged(bool value) => RebuildFilteredResults();
|
|
partial void OnShowPreservationHoldChanged(bool value) => RebuildFilteredResults();
|
|
partial void OnShowListAttachmentsChanged(bool value) => RebuildFilteredResults();
|
|
partial void OnShowRecycleBinChanged(bool value) => RebuildFilteredResults();
|
|
partial void OnShowSubsitesChanged(bool value) => RebuildFilteredResults();
|
|
partial void OnCombineRecycleBinStagesChanged(bool value) => RebuildFilteredResults();
|
|
|
|
/// <summary>0 = Single file, 1 = Split by site.</summary>
|
|
[ObservableProperty] private int _splitModeIndex;
|
|
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
|
|
[ObservableProperty] private int _htmlLayoutIndex;
|
|
|
|
private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single;
|
|
private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;
|
|
|
|
private ObservableCollection<FileTypeMetric> _fileTypeMetrics = new();
|
|
public ObservableCollection<FileTypeMetric> FileTypeMetrics
|
|
{
|
|
get => _fileTypeMetrics;
|
|
private set
|
|
{
|
|
_fileTypeMetrics = value;
|
|
OnPropertyChanged();
|
|
UpdateChartSeries();
|
|
}
|
|
}
|
|
|
|
public bool HasChartData => FileTypeMetrics.Count > 0;
|
|
|
|
private IEnumerable<ISeries> _pieChartSeries = Enumerable.Empty<ISeries>();
|
|
public IEnumerable<ISeries> PieChartSeries
|
|
{
|
|
get => _pieChartSeries;
|
|
private set { _pieChartSeries = value; OnPropertyChanged(); }
|
|
}
|
|
|
|
private IEnumerable<ISeries> _barChartSeries = Enumerable.Empty<ISeries>();
|
|
public IEnumerable<ISeries> BarChartSeries
|
|
{
|
|
get => _barChartSeries;
|
|
private set { _barChartSeries = value; OnPropertyChanged(); }
|
|
}
|
|
|
|
private Axis[] _barXAxes = Array.Empty<Axis>();
|
|
public Axis[] BarXAxes
|
|
{
|
|
get => _barXAxes;
|
|
private set { _barXAxes = value; OnPropertyChanged(); }
|
|
}
|
|
|
|
private Axis[] _barYAxes = Array.Empty<Axis>();
|
|
public Axis[] BarYAxes
|
|
{
|
|
get => _barYAxes;
|
|
private set { _barYAxes = value; OnPropertyChanged(); }
|
|
}
|
|
|
|
// Stable paint instances. SKDefaultTooltip/Legend bake the Fill paint reference
|
|
// into their geometry on first Initialize() and never re-read the chart's paint
|
|
// properties. Replacing instances on theme change has no effect — we mutate
|
|
// .Color in place so the new theme color renders on the next frame.
|
|
public SolidColorPaint LegendTextPaint { get; } = new(default(SKColor));
|
|
public SolidColorPaint LegendBackgroundPaint { get; } = new(default(SKColor));
|
|
public SolidColorPaint TooltipTextPaint { get; } = new(default(SKColor));
|
|
public SolidColorPaint TooltipBackgroundPaint { get; } = new(default(SKColor));
|
|
|
|
public bool IsMaxDepth
|
|
{
|
|
get => FolderDepth >= 999;
|
|
set
|
|
{
|
|
if (value) FolderDepth = 999;
|
|
else if (FolderDepth >= 999) FolderDepth = 0;
|
|
OnPropertyChanged();
|
|
}
|
|
}
|
|
|
|
// Raw scan output — never filtered. RebuildFilteredResults projects this
|
|
// into Results based on the Show* flags.
|
|
private List<StorageNode> _allNodes = new();
|
|
|
|
private ObservableCollection<StorageNode> _results = new();
|
|
public ObservableCollection<StorageNode> Results
|
|
{
|
|
get => _results;
|
|
private set
|
|
{
|
|
_results = value;
|
|
OnPropertyChanged();
|
|
NotifySummaryProperties();
|
|
ExportCsvCommand.NotifyCanExecuteChanged();
|
|
ExportHtmlCommand.NotifyCanExecuteChanged();
|
|
}
|
|
}
|
|
|
|
// ── Summary properties (computed from root-level library nodes) ─────────
|
|
//
|
|
// Recycle-bin contents are rolled into each library's TotalSizeBytes by the
|
|
// StorageService (matches storman.aspx). Including the synthetic root-level
|
|
// RecycleBin nodes here would double-count those bytes — filter them out.
|
|
// SummaryRecycleBinSize below still reads from _allNodes so the bin metric
|
|
// remains visible to the user.
|
|
|
|
/// <summary>Sum of TotalSizeBytes across root-level non-bin nodes.</summary>
|
|
public long SummaryTotalSize => Results
|
|
.Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
|
|
.Sum(n => n.TotalSizeBytes);
|
|
|
|
/// <summary>Sum of VersionSizeBytes across root-level non-bin nodes.</summary>
|
|
public long SummaryVersionSize => Results
|
|
.Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
|
|
.Sum(n => n.VersionSizeBytes);
|
|
|
|
/// <summary>Sum of TotalFileCount across root-level non-bin nodes.</summary>
|
|
public long SummaryFileCount => Results
|
|
.Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
|
|
.Sum(n => n.TotalFileCount);
|
|
|
|
/// <summary>
|
|
/// Aggregate recycle-bin size (stage 1 + stage 2 across all sites). Reads
|
|
/// from the raw scan so it stays visible even when the user hides the
|
|
/// recycle-bin row in the report filter.
|
|
/// </summary>
|
|
public long SummaryRecycleBinSize => _allNodes
|
|
.Where(n => n.Kind == StorageNodeKind.RecycleBin)
|
|
.Sum(n => n.TotalSizeBytes);
|
|
|
|
public bool HasResults => Results.Count > 0;
|
|
|
|
private void NotifySummaryProperties()
|
|
{
|
|
OnPropertyChanged(nameof(SummaryTotalSize));
|
|
OnPropertyChanged(nameof(SummaryVersionSize));
|
|
OnPropertyChanged(nameof(SummaryRecycleBinSize));
|
|
OnPropertyChanged(nameof(SummaryFileCount));
|
|
OnPropertyChanged(nameof(HasResults));
|
|
}
|
|
|
|
public IAsyncRelayCommand ExportCsvCommand { get; }
|
|
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
|
|
|
public TenantProfile? CurrentProfile => _currentProfile;
|
|
|
|
public StorageViewModel(
|
|
IStorageService storageService,
|
|
ISessionManager sessionManager,
|
|
StorageCsvExportService csvExportService,
|
|
StorageHtmlExportService htmlExportService,
|
|
IBrandingService brandingService,
|
|
ILogger<FeatureViewModelBase> logger,
|
|
IOwnershipElevationService? ownershipService = null,
|
|
SettingsService? settingsService = null,
|
|
ThemeManager? themeManager = null)
|
|
: base(logger)
|
|
{
|
|
_storageService = storageService;
|
|
_sessionManager = sessionManager;
|
|
_csvExportService = csvExportService;
|
|
_htmlExportService = htmlExportService;
|
|
_brandingService = brandingService;
|
|
_ownershipService = ownershipService;
|
|
_settingsService = settingsService;
|
|
_themeManager = themeManager;
|
|
_logger = logger;
|
|
|
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
|
|
|
ApplyChartThemeColors();
|
|
if (_themeManager is not null)
|
|
_themeManager.ThemeChanged += (_, _) => UpdateChartSeries();
|
|
}
|
|
|
|
/// <summary>Test constructor — omits export services.</summary>
|
|
internal StorageViewModel(
|
|
IStorageService storageService,
|
|
ISessionManager sessionManager,
|
|
ILogger<FeatureViewModelBase> logger,
|
|
IBrandingService? brandingService = null)
|
|
: base(logger)
|
|
{
|
|
_storageService = storageService;
|
|
_sessionManager = sessionManager;
|
|
_csvExportService = null!;
|
|
_htmlExportService = null!;
|
|
_brandingService = brandingService;
|
|
_logger = logger;
|
|
|
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
|
}
|
|
|
|
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
|
{
|
|
if (_currentProfile == null)
|
|
{
|
|
StatusMessage = TranslationSource.Instance["err.no_tenant_connected"];
|
|
return;
|
|
}
|
|
|
|
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
|
if (urls.Count == 0)
|
|
{
|
|
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
|
|
return;
|
|
}
|
|
|
|
var nonEmpty = urls;
|
|
|
|
var options = new StorageScanOptions(
|
|
PerLibrary: PerLibrary,
|
|
IncludeSubsites: IncludeSubsites,
|
|
FolderDepth: FolderDepth,
|
|
IncludeHiddenLibraries: ScanHiddenLibraries,
|
|
IncludePreservationHold: ScanPreservationHold,
|
|
IncludeListAttachments: ScanListAttachments,
|
|
IncludeRecycleBin: ScanRecycleBin);
|
|
|
|
var allNodes = new List<StorageNode>();
|
|
var allTypeMetrics = new List<FileTypeMetric>();
|
|
long spoReportedTotal = 0;
|
|
|
|
var autoOwnership = await IsAutoTakeOwnershipEnabled();
|
|
|
|
int i = 0;
|
|
foreach (var url in nonEmpty)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
i++;
|
|
progress.Report(new OperationProgress(i, nonEmpty.Count, $"Scanning {url}..."));
|
|
|
|
var siteProfile = new TenantProfile
|
|
{
|
|
TenantUrl = url.TrimEnd('/'),
|
|
ClientId = _currentProfile.ClientId,
|
|
Name = _currentProfile.Name
|
|
};
|
|
|
|
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
|
|
|
|
IReadOnlyList<StorageNode> nodes;
|
|
try
|
|
{
|
|
nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
|
|
}
|
|
catch (Microsoft.SharePoint.Client.ServerUnauthorizedAccessException) when (_ownershipService != null && autoOwnership)
|
|
{
|
|
_logger.LogWarning("Access denied on {Url}, auto-elevating ownership...", url);
|
|
var adminUrl = DeriveAdminUrl(_currentProfile.TenantUrl ?? url);
|
|
var adminProfile = new TenantProfile
|
|
{
|
|
TenantUrl = adminUrl,
|
|
ClientId = _currentProfile.ClientId,
|
|
Name = _currentProfile.Name
|
|
};
|
|
var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
|
|
await _ownershipService.ElevateAsync(adminCtx, url, string.Empty, ct);
|
|
|
|
ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
|
|
nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
|
|
}
|
|
|
|
// Backfill any libraries where StorageMetrics returned zeros
|
|
await _storageService.BackfillZeroNodesAsync(ctx, nodes, progress, ct);
|
|
|
|
allNodes.AddRange(nodes);
|
|
|
|
progress.Report(OperationProgress.Indeterminate($"Scanning file types: {url}..."));
|
|
var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct);
|
|
allTypeMetrics.AddRange(typeMetrics);
|
|
|
|
spoReportedTotal += await _storageService.GetSiteUsageStorageBytesAsync(ctx, progress, ct);
|
|
}
|
|
|
|
// Flatten tree for DataGrid display
|
|
var flat = new List<StorageNode>();
|
|
foreach (var node in allNodes)
|
|
FlattenNode(node, 0, flat);
|
|
|
|
// Merge file-type metrics across sites (same extension -> sum)
|
|
var mergedMetrics = allTypeMetrics
|
|
.GroupBy(m => m.Extension, StringComparer.OrdinalIgnoreCase)
|
|
.Select(g => new FileTypeMetric(g.Key, g.Sum(m => m.TotalSizeBytes), g.Sum(m => m.FileCount)))
|
|
.OrderByDescending(m => m.TotalSizeBytes)
|
|
.ToList();
|
|
|
|
if (Application.Current?.Dispatcher is { } dispatcher)
|
|
{
|
|
await dispatcher.InvokeAsync(() =>
|
|
{
|
|
_allNodes = flat;
|
|
SpoReportedTotalSize = spoReportedTotal;
|
|
RebuildFilteredResults();
|
|
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
|
|
});
|
|
}
|
|
else
|
|
{
|
|
_allNodes = flat;
|
|
SpoReportedTotalSize = spoReportedTotal;
|
|
RebuildFilteredResults();
|
|
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Project <see cref="_allNodes"/> into <see cref="Results"/> using the
|
|
/// Show* flags. Nodes whose root ancestor is excluded by the flags are
|
|
/// dropped along with their entire subtree, preserving DFS ordering.
|
|
/// </summary>
|
|
private void RebuildFilteredResults()
|
|
{
|
|
if (_allNodes.Count == 0)
|
|
{
|
|
Results = new ObservableCollection<StorageNode>();
|
|
return;
|
|
}
|
|
|
|
var filtered = new List<StorageNode>(_allNodes.Count);
|
|
bool includeCurrentSubtree = true;
|
|
|
|
foreach (var node in _allNodes)
|
|
{
|
|
if (node.IndentLevel == 0)
|
|
includeCurrentSubtree = IsKindShown(node.Kind);
|
|
if (includeCurrentSubtree)
|
|
filtered.Add(node);
|
|
}
|
|
|
|
if (CombineRecycleBinStages)
|
|
filtered = CombineRecycleBins(filtered);
|
|
|
|
Results = new ObservableCollection<StorageNode>(filtered);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replaces all root recycle-bin nodes (stage 1 + stage 2) with a single
|
|
/// aggregate row inserted at the position of the first recycle-bin node
|
|
/// encountered. Preserves SiteTitle grouping when scans cover multiple
|
|
/// sites by aggregating per SiteTitle.
|
|
/// </summary>
|
|
private static List<StorageNode> CombineRecycleBins(List<StorageNode> input)
|
|
{
|
|
var byPath = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
|
|
var result = new List<StorageNode>(input.Count);
|
|
|
|
foreach (var node in input)
|
|
{
|
|
if (node.IndentLevel == 0 && node.Kind == StorageNodeKind.RecycleBin)
|
|
{
|
|
string key = node.SiteTitle ?? string.Empty;
|
|
if (!byPath.TryGetValue(key, out var agg))
|
|
{
|
|
agg = new StorageNode
|
|
{
|
|
Name = "[Recycle Bin] Total",
|
|
SiteTitle = node.SiteTitle ?? string.Empty,
|
|
Library = "RecycleBin",
|
|
Kind = StorageNodeKind.RecycleBin,
|
|
IndentLevel = 0,
|
|
Children = new List<StorageNode>()
|
|
};
|
|
byPath[key] = agg;
|
|
result.Add(agg);
|
|
}
|
|
agg.TotalSizeBytes += node.TotalSizeBytes;
|
|
agg.FileStreamSizeBytes += node.FileStreamSizeBytes;
|
|
agg.TotalFileCount += node.TotalFileCount;
|
|
if (node.LastModified.HasValue &&
|
|
(!agg.LastModified.HasValue || node.LastModified > agg.LastModified))
|
|
agg.LastModified = node.LastModified;
|
|
}
|
|
else
|
|
{
|
|
result.Add(node);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private bool IsKindShown(StorageNodeKind kind) => kind switch
|
|
{
|
|
StorageNodeKind.Library => ShowLibraries,
|
|
StorageNodeKind.HiddenLibrary => ShowHiddenLibraries,
|
|
StorageNodeKind.PreservationHold => ShowPreservationHold,
|
|
StorageNodeKind.ListAttachments => ShowListAttachments,
|
|
StorageNodeKind.RecycleBin => ShowRecycleBin,
|
|
StorageNodeKind.Subsite => ShowSubsites,
|
|
_ => true
|
|
};
|
|
|
|
protected override void OnTenantSwitched(TenantProfile profile)
|
|
{
|
|
_currentProfile = profile;
|
|
_allNodes = new List<StorageNode>();
|
|
SpoReportedTotalSize = 0;
|
|
Results = new ObservableCollection<StorageNode>();
|
|
FileTypeMetrics = new ObservableCollection<FileTypeMetric>();
|
|
OnPropertyChanged(nameof(CurrentProfile));
|
|
ExportCsvCommand.NotifyCanExecuteChanged();
|
|
ExportHtmlCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
private async Task<bool> IsAutoTakeOwnershipEnabled()
|
|
{
|
|
if (_settingsService == null) return false;
|
|
var settings = await _settingsService.GetSettingsAsync();
|
|
return settings.AutoTakeOwnership;
|
|
}
|
|
|
|
internal static string DeriveAdminUrl(string tenantUrl)
|
|
{
|
|
var uri = new Uri(tenantUrl.TrimEnd('/'));
|
|
var host = uri.Host;
|
|
if (host.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase))
|
|
return tenantUrl;
|
|
var adminHost = host.Replace(".sharepoint.com", "-admin.sharepoint.com",
|
|
StringComparison.OrdinalIgnoreCase);
|
|
return $"{uri.Scheme}://{adminHost}";
|
|
}
|
|
|
|
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
|
|
|
|
internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
|
=> RunOperationAsync(ct, progress);
|
|
|
|
private bool CanExport() => Results.Count > 0;
|
|
|
|
private async Task ExportCsvAsync()
|
|
{
|
|
if (Results.Count == 0) return;
|
|
var dialog = new SaveFileDialog
|
|
{
|
|
Title = "Export storage metrics to CSV",
|
|
Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
|
|
DefaultExt = "csv",
|
|
FileName = "storage_metrics"
|
|
};
|
|
if (dialog.ShowDialog() != true) return;
|
|
try
|
|
{
|
|
await _csvExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CurrentSplit, CancellationToken.None);
|
|
OpenFile(dialog.FileName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Export failed: {ex.Message}";
|
|
_logger.LogError(ex, "CSV export failed.");
|
|
}
|
|
}
|
|
|
|
private async Task ExportHtmlAsync()
|
|
{
|
|
if (Results.Count == 0) return;
|
|
var dialog = new SaveFileDialog
|
|
{
|
|
Title = "Export storage metrics to HTML",
|
|
Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
|
|
DefaultExt = "html",
|
|
FileName = "storage_metrics"
|
|
};
|
|
if (dialog.ShowDialog() != true) return;
|
|
try
|
|
{
|
|
ReportBranding? branding = null;
|
|
if (_brandingService is not null)
|
|
{
|
|
var mspLogo = await _brandingService.GetMspLogoAsync();
|
|
var clientLogo = _currentProfile?.ClientLogo;
|
|
branding = new ReportBranding(mspLogo, clientLogo);
|
|
}
|
|
|
|
await _htmlExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding);
|
|
OpenFile(dialog.FileName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Export failed: {ex.Message}";
|
|
_logger.LogError(ex, "HTML export failed.");
|
|
}
|
|
}
|
|
|
|
partial void OnIsDonutChartChanged(bool value)
|
|
{
|
|
UpdateChartSeries();
|
|
}
|
|
|
|
private SKColor ChartFgColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0xE7, 0xEA, 0xF1) : new SKColor(0x1F, 0x24, 0x30);
|
|
private SKColor ChartSeparatorColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x32, 0x38, 0x49) : new SKColor(0xE3, 0xE6, 0xEC);
|
|
private SKColor ChartSurfaceColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x1E, 0x22, 0x30) : new SKColor(0xFF, 0xFF, 0xFF);
|
|
|
|
private void ApplyChartThemeColors()
|
|
{
|
|
LegendTextPaint.Color = ChartFgColor;
|
|
LegendBackgroundPaint.Color = ChartSurfaceColor;
|
|
TooltipTextPaint.Color = ChartFgColor;
|
|
TooltipBackgroundPaint.Color = ChartSurfaceColor;
|
|
}
|
|
|
|
private void UpdateChartSeries()
|
|
{
|
|
var metrics = FileTypeMetrics.ToList();
|
|
OnPropertyChanged(nameof(HasChartData));
|
|
|
|
ApplyChartThemeColors();
|
|
|
|
if (metrics.Count == 0)
|
|
{
|
|
PieChartSeries = Enumerable.Empty<ISeries>();
|
|
BarChartSeries = Enumerable.Empty<ISeries>();
|
|
BarXAxes = Array.Empty<Axis>();
|
|
BarYAxes = Array.Empty<Axis>();
|
|
return;
|
|
}
|
|
|
|
// Take top 10 by size, aggregate the rest as "Other"
|
|
var top = metrics.Take(10).ToList();
|
|
long otherSize = metrics.Skip(10).Sum(m => m.TotalSizeBytes);
|
|
int otherCount = metrics.Skip(10).Sum(m => m.FileCount);
|
|
|
|
var chartItems = new List<FileTypeMetric>(top);
|
|
if (otherSize > 0)
|
|
chartItems.Add(new FileTypeMetric("Other", otherSize, otherCount));
|
|
|
|
// Pie/Donut: one PieSeries per slice (LiveCharts2 requires this for per-slice colors).
|
|
// Tooltip only shows the hovered slice because each series has exactly one value.
|
|
double innerRadius = IsDonutChart ? 50 : 0;
|
|
var pieList = new List<ISeries>();
|
|
foreach (var m in chartItems)
|
|
{
|
|
pieList.Add(new PieSeries<double>
|
|
{
|
|
Values = new[] { (double)m.TotalSizeBytes },
|
|
Name = m.DisplayLabel,
|
|
InnerRadius = innerRadius,
|
|
HoverPushout = 8,
|
|
MaxRadialColumnWidth = 60,
|
|
DataLabelsFormatter = _ => m.DisplayLabel,
|
|
DataLabelsPaint = new SolidColorPaint(ChartFgColor),
|
|
ToolTipLabelFormatter = _ =>
|
|
$"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)",
|
|
IsVisibleAtLegend = true,
|
|
});
|
|
}
|
|
PieChartSeries = pieList;
|
|
|
|
// Bar chart series
|
|
BarChartSeries = new ISeries[]
|
|
{
|
|
new ColumnSeries<long>
|
|
{
|
|
Values = chartItems.Select(m => m.TotalSizeBytes).ToArray(),
|
|
Name = "Size",
|
|
DataLabelsFormatter = point =>
|
|
{
|
|
int idx = (int)point.Index;
|
|
return idx < chartItems.Count ? FormatBytes(chartItems[idx].TotalSizeBytes) : "";
|
|
},
|
|
DataLabelsPaint = new SolidColorPaint(ChartFgColor)
|
|
}
|
|
};
|
|
|
|
BarXAxes = new Axis[]
|
|
{
|
|
new Axis
|
|
{
|
|
Labels = chartItems.Select(m => m.DisplayLabel).ToArray(),
|
|
LabelsRotation = -45,
|
|
LabelsPaint = new SolidColorPaint(ChartFgColor),
|
|
TicksPaint = new SolidColorPaint(ChartFgColor),
|
|
SeparatorsPaint = new SolidColorPaint(ChartSeparatorColor)
|
|
}
|
|
};
|
|
|
|
BarYAxes = new Axis[]
|
|
{
|
|
new Axis
|
|
{
|
|
Labeler = value => FormatBytes((long)value),
|
|
LabelsPaint = new SolidColorPaint(ChartFgColor),
|
|
TicksPaint = new SolidColorPaint(ChartFgColor),
|
|
SeparatorsPaint = new SolidColorPaint(ChartSeparatorColor)
|
|
}
|
|
};
|
|
}
|
|
|
|
private static string FormatBytes(long bytes)
|
|
{
|
|
if (bytes < 1024) return $"{bytes} B";
|
|
if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB";
|
|
if (bytes < 1024 * 1024 * 1024) return $"{bytes / (1024.0 * 1024):F1} MB";
|
|
return $"{bytes / (1024.0 * 1024 * 1024):F2} GB";
|
|
}
|
|
|
|
private static void FlattenNode(StorageNode node, int level, List<StorageNode> result)
|
|
{
|
|
node.IndentLevel = level;
|
|
result.Add(node);
|
|
foreach (var child in node.Children)
|
|
FlattenNode(child, level + 1, result);
|
|
}
|
|
|
|
private static void OpenFile(string filePath)
|
|
{
|
|
try { Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); }
|
|
catch { /* ignore -- file may open but this is best-effort */ }
|
|
}
|
|
}
|