Files
Sharepoint-Toolbox/SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs
Dev 816fb5e3b5 feat(11-03): inject IBrandingService into all 5 export ViewModels and assemble branding in ExportHtmlAsync
- Add IBrandingService field and DI constructor parameter to all 5 ViewModels
- Add optional IBrandingService? parameter to test constructors (PermissionsViewModel, StorageViewModel, UserAccessAuditViewModel)
- Assemble ReportBranding from GetMspLogoAsync + _currentProfile.ClientLogo before each WriteAsync call
- Pass branding as last parameter to WriteAsync in all ExportHtmlAsync methods
- Guard clause: branding assembly skipped (branding = null) when _brandingService is null (test constructors)
- Build: 0 warnings, 0 errors; tests: 254 passed / 0 failed / 26 skipped
2026-04-08 14:50:54 +02:00

426 lines
14 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.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 ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile;
[ObservableProperty]
private bool _perLibrary = true;
[ObservableProperty]
private bool _includeSubsites;
[ObservableProperty]
private int _folderDepth;
[ObservableProperty]
private bool _isDonutChart = true;
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(); }
}
public bool IsMaxDepth
{
get => FolderDepth >= 999;
set
{
if (value) FolderDepth = 999;
else if (FolderDepth >= 999) FolderDepth = 0;
OnPropertyChanged();
}
}
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) ─────────
/// <summary>Sum of TotalSizeBytes across root-level library nodes.</summary>
public long SummaryTotalSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalSizeBytes);
/// <summary>Sum of VersionSizeBytes across root-level library nodes.</summary>
public long SummaryVersionSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.VersionSizeBytes);
/// <summary>Sum of TotalFileCount across root-level library nodes.</summary>
public long SummaryFileCount => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalFileCount);
public bool HasResults => Results.Count > 0;
private void NotifySummaryProperties()
{
OnPropertyChanged(nameof(SummaryTotalSize));
OnPropertyChanged(nameof(SummaryVersionSize));
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)
: base(logger)
{
_storageService = storageService;
_sessionManager = sessionManager;
_csvExportService = csvExportService;
_htmlExportService = htmlExportService;
_brandingService = brandingService;
_logger = logger;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
}
/// <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 = "No tenant selected. Please connect to a tenant first.";
return;
}
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0)
{
StatusMessage = "Select at least one site from the toolbar.";
return;
}
var nonEmpty = urls;
var options = new StorageScanOptions(
PerLibrary: PerLibrary,
IncludeSubsites: IncludeSubsites,
FolderDepth: FolderDepth);
var allNodes = new List<StorageNode>();
var allTypeMetrics = new List<FileTypeMetric>();
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);
var 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);
}
// 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(() =>
{
Results = new ObservableCollection<StorageNode>(flat);
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
});
}
else
{
Results = new ObservableCollection<StorageNode>(flat);
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
}
}
protected override void OnTenantSwitched(TenantProfile profile)
{
_currentProfile = profile;
Results = new ObservableCollection<StorageNode>();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>();
OnPropertyChanged(nameof(CurrentProfile));
ExportCsvCommand.NotifyCanExecuteChanged();
ExportHtmlCommand.NotifyCanExecuteChanged();
}
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, 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, 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 void UpdateChartSeries()
{
var metrics = FileTypeMetrics.ToList();
OnPropertyChanged(nameof(HasChartData));
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,
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) : "";
}
}
};
BarXAxes = new Axis[]
{
new Axis
{
Labels = chartItems.Select(m => m.DisplayLabel).ToArray(),
LabelsRotation = -45
}
};
BarYAxes = new Axis[]
{
new Axis
{
Labeler = value => FormatBytes((long)value)
}
};
}
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 */ }
}
}