chore: archive v1.1 Enhanced Reports milestone
Some checks failed
Release SharePoint Toolbox v2 / release (push) Failing after 14s

v1.1 shipped with 4 phases (25 plans), 10/10 requirements complete:
- Global site selection (toolbar picker, all tabs consume)
- User access audit (Graph people-picker, direct/group/inherited)
- Simplified permissions (plain-language labels, risk levels, detail toggle)
- Storage visualization (LiveCharts2 pie/donut + bar charts)

Post-phase polish: centralized site selection (removed per-tab pickers),
claims prefix stripping, StorageMetrics backfill, chart tooltip fix,
summary stats in app + HTML exports.

205 tests passing, 10,484 LOC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dev
2026-04-08 10:21:02 +02:00
parent fa793c5489
commit fd442f3b4c
35 changed files with 1062 additions and 760 deletions

View File

@@ -23,10 +23,6 @@ public partial class StorageViewModel : FeatureViewModelBase
private readonly StorageHtmlExportService _htmlExportService;
private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile;
private bool _hasLocalSiteOverride;
[ObservableProperty]
private string _siteUrl = string.Empty;
[ObservableProperty]
private bool _perLibrary = true;
@@ -101,11 +97,33 @@ public partial class StorageViewModel : FeatureViewModelBase
{
_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; }
@@ -146,26 +164,6 @@ public partial class StorageViewModel : FeatureViewModelBase
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
}
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
{
if (_hasLocalSiteOverride) return;
SiteUrl = sites.Count > 0 ? sites[0].Url : string.Empty;
}
partial void OnSiteUrlChanged(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
_hasLocalSiteOverride = false;
if (GlobalSites.Count > 0)
SiteUrl = GlobalSites[0].Url;
}
else if (GlobalSites.Count == 0 || value != GlobalSites[0].Url)
{
_hasLocalSiteOverride = true;
}
}
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (_currentProfile == null)
@@ -173,70 +171,83 @@ public partial class StorageViewModel : FeatureViewModelBase
StatusMessage = "No tenant selected. Please connect to a tenant first.";
return;
}
if (string.IsNullOrWhiteSpace(SiteUrl))
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0)
{
StatusMessage = "Please enter a site URL.";
StatusMessage = "Select at least one site from the toolbar.";
return;
}
// Build a site-specific profile: same ClientId and Name, but TenantUrl points to the
// site URL the user entered (may differ from the tenant root).
var siteProfile = new TenantProfile
{
TenantUrl = SiteUrl.TrimEnd('/'),
ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name
};
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
var nonEmpty = urls;
var options = new StorageScanOptions(
PerLibrary: PerLibrary,
IncludeSubsites: IncludeSubsites,
FolderDepth: FolderDepth);
var nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
var allNodes = new List<StorageNode>();
var allTypeMetrics = new List<FileTypeMetric>();
// Flatten tree to one level for DataGrid display (assign IndentLevel during flatten)
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 nodes)
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);
}
// Collect file-type metrics for chart visualization
progress.Report(OperationProgress.Indeterminate("Scanning file types for chart..."));
var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct);
if (Application.Current?.Dispatcher is { } chartDispatcher)
{
await chartDispatcher.InvokeAsync(() =>
{
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(typeMetrics);
});
}
else
{
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(typeMetrics);
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
}
}
protected override void OnTenantSwitched(TenantProfile profile)
{
_currentProfile = profile;
_hasLocalSiteOverride = false;
Results = new ObservableCollection<StorageNode>();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>();
SiteUrl = string.Empty;
OnPropertyChanged(nameof(CurrentProfile));
ExportCsvCommand.NotifyCanExecuteChanged();
ExportHtmlCommand.NotifyCanExecuteChanged();
@@ -262,7 +273,7 @@ public partial class StorageViewModel : FeatureViewModelBase
if (dialog.ShowDialog() != true) return;
try
{
await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
await _csvExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None);
OpenFile(dialog.FileName);
}
catch (Exception ex)
@@ -285,7 +296,7 @@ public partial class StorageViewModel : FeatureViewModelBase
if (dialog.ShowDialog() != true) return;
try
{
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
await _htmlExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None);
OpenFile(dialog.FileName);
}
catch (Exception ex)
@@ -323,18 +334,26 @@ public partial class StorageViewModel : FeatureViewModelBase
if (otherSize > 0)
chartItems.Add(new FileTypeMetric("Other", otherSize, otherCount));
// Pie/Donut series
// 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;
PieChartSeries = chartItems.Select(m => new PieSeries<long>
var pieList = new List<ISeries>();
foreach (var m in chartItems)
{
Values = new[] { m.TotalSizeBytes },
Name = m.DisplayLabel,
InnerRadius = innerRadius,
DataLabelsPosition = LiveChartsCore.Measure.PolarLabelsPosition.Outer,
DataLabelsFormatter = point => m.DisplayLabel,
ToolTipLabelFormatter = point =>
$"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)"
}).ToList();
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[]
@@ -388,6 +407,6 @@ public partial class StorageViewModel : FeatureViewModelBase
private static void OpenFile(string filePath)
{
try { Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); }
catch { /* ignore file may open but this is best-effort */ }
catch { /* ignore -- file may open but this is best-effort */ }
}
}