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

@@ -36,7 +36,49 @@ public class StorageCsvExportService
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
{
var csv = BuildCsv(nodes);
// UTF-8 with BOM for Excel compatibility
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
}
/// <summary>
/// Builds a CSV with library details followed by a file-type breakdown section.
/// </summary>
public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
{
var sb = new StringBuilder();
// Library details
sb.AppendLine("Library,Site,Files,Total Size (MB),Version Size (MB),Last Modified");
foreach (var node in nodes)
{
sb.AppendLine(string.Join(",",
Csv(node.Name),
Csv(node.SiteTitle),
node.TotalFileCount.ToString(),
FormatMb(node.TotalSizeBytes),
FormatMb(node.VersionSizeBytes),
node.LastModified.HasValue
? Csv(node.LastModified.Value.ToString("yyyy-MM-dd"))
: string.Empty));
}
// File type breakdown
if (fileTypeMetrics.Count > 0)
{
sb.AppendLine();
sb.AppendLine("File Type,Size (MB),File Count");
foreach (var m in fileTypeMetrics)
{
string label = string.IsNullOrEmpty(m.Extension) ? "(no extension)" : m.Extension;
sb.AppendLine(string.Join(",", Csv(label), FormatMb(m.TotalSizeBytes), m.FileCount.ToString()));
}
}
return sb.ToString();
}
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct)
{
var csv = BuildCsv(nodes, fileTypeMetrics);
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
}

View File

@@ -51,6 +51,151 @@ public class StorageHtmlExportService
<h1>SharePoint Storage Metrics</h1>
""");
// Summary cards
var rootNodes0 = nodes.Where(n => n.IndentLevel == 0).ToList();
long siteTotal0 = rootNodes0.Sum(n => n.TotalSizeBytes);
long versionTotal0 = rootNodes0.Sum(n => n.VersionSizeBytes);
long fileTotal0 = rootNodes0.Sum(n => n.TotalFileCount);
sb.AppendLine($"""
<div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap">
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(siteTotal0)}</div><div style="font-size:.8rem;color:#666">Total Size</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(versionTotal0)}</div><div style="font-size:.8rem;color:#666">Version Size</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{fileTotal0:N0}</div><div style="font-size:.8rem;color:#666">Files</div></div>
</div>
""");
sb.AppendLine("""
<table>
<thead>
<tr>
<th>Library / Folder</th>
<th>Site</th>
<th class="num">Files</th>
<th class="num">Total Size</th>
<th class="num">Version Size</th>
<th>Last Modified</th>
</tr>
</thead>
<tbody>
""");
foreach (var node in nodes)
{
RenderNode(sb, node);
}
sb.AppendLine("""
</tbody>
</table>
""");
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine("</body></html>");
return sb.ToString();
}
/// <summary>
/// Builds an HTML report including a file-type breakdown chart section.
/// </summary>
public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
{
_togIdx = 0;
var sb = new StringBuilder();
sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint Storage Metrics</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
h2 { color: #333; margin-top: 24px; }
table { border-collapse: collapse; width: 100%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
th { background: #0078d4; color: #fff; padding: 8px 12px; text-align: left; font-weight: 600; }
td { padding: 6px 12px; border-bottom: 1px solid #e0e0e0; vertical-align: top; }
tr:hover { background: #f0f7ff; }
.toggle-btn { background: none; border: 1px solid #0078d4; color: #0078d4; border-radius: 3px;
cursor: pointer; font-size: 11px; padding: 1px 6px; margin-right: 6px; }
.toggle-btn:hover { background: #e5f1fb; }
.sf-tbl { width: 100%; border: none; box-shadow: none; margin: 0; }
.sf-tbl td { background: #fafcff; font-size: 12px; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.generated { font-size: 11px; color: #888; margin-top: 12px; }
.chart-section { margin: 20px 0; padding: 16px; background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
.bar-row { display: flex; align-items: center; margin: 4px 0; }
.bar-label { width: 80px; font-size: 12px; font-weight: 600; text-align: right; padding-right: 10px; }
.bar-track { flex: 1; background: #eee; border-radius: 4px; height: 22px; position: relative; }
.bar-fill { height: 100%; border-radius: 4px; background: #0078d4; min-width: 2px; }
.bar-value { font-size: 11px; color: #555; padding-left: 8px; white-space: nowrap; min-width: 140px; }
.stats { display: flex; gap: 16px; margin: 16px 0; flex-wrap: wrap; }
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
.stat-card .value { font-size: 1.8rem; font-weight: 700; color: #0078d4; }
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
</style>
<script>
function toggle(i) {
var row = document.getElementById('sf-' + i);
if (row) row.style.display = (row.style.display === 'none' || row.style.display === '') ? 'table-row' : 'none';
}
</script>
</head>
<body>
<h1>SharePoint Storage Metrics</h1>
""");
// ── Summary cards ──
var rootNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
long siteTotal = rootNodes.Sum(n => n.TotalSizeBytes);
long versionTotal = rootNodes.Sum(n => n.VersionSizeBytes);
long fileTotal = rootNodes.Sum(n => n.TotalFileCount);
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">Total Size</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(versionTotal)}</div><div class=\"label\">Version Size</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{fileTotal:N0}</div><div class=\"label\">Files</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{rootNodes.Count}</div><div class=\"label\">Libraries</div></div>");
sb.AppendLine("</div>");
// ── File type chart section ──
if (fileTypeMetrics.Count > 0)
{
var maxSize = fileTypeMetrics.Max(m => m.TotalSizeBytes);
var totalSize = fileTypeMetrics.Sum(m => m.TotalSizeBytes);
var totalFiles = fileTypeMetrics.Sum(m => m.FileCount);
sb.AppendLine("<div class=\"chart-section\">");
sb.AppendLine($"<h2>Storage by File Type ({totalFiles:N0} files, {FormatSize(totalSize)})</h2>");
var colors = new[] { "#0078d4", "#2b88d8", "#106ebe", "#005a9e", "#004578",
"#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" };
int idx = 0;
foreach (var m in fileTypeMetrics.Take(15))
{
double pct = maxSize > 0 ? m.TotalSizeBytes * 100.0 / maxSize : 0;
string color = colors[idx % colors.Length];
string label = string.IsNullOrEmpty(m.Extension) ? "(no ext)" : m.Extension;
sb.AppendLine($"""
<div class="bar-row">
<span class="bar-label">{HtmlEncode(label)}</span>
<div class="bar-track"><div class="bar-fill" style="width:{pct:F1}%;background:{color}"></div></div>
<span class="bar-value">{FormatSize(m.TotalSizeBytes)} &middot; {m.FileCount:N0} files</span>
</div>
""");
idx++;
}
sb.AppendLine("</div>");
}
// ── Storage table ──
sb.AppendLine("<h2>Library Details</h2>");
sb.AppendLine("""
<table>
<thead>
@@ -88,6 +233,12 @@ public class StorageHtmlExportService
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
}
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct)
{
var html = BuildHtml(nodes, fileTypeMetrics);
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
}
// ── Private rendering ────────────────────────────────────────────────────
private void RenderNode(StringBuilder sb, StorageNode node)

View File

@@ -26,4 +26,16 @@ public interface IStorageService
ClientContext ctx,
IProgress<OperationProgress> progress,
CancellationToken ct);
/// <summary>
/// Backfills StorageNodes that have zero TotalFileCount/TotalSizeBytes
/// by enumerating files per library via CamlQuery.
/// This works around the StorageMetrics API returning zeros when the
/// caller lacks sufficient permissions or metrics haven't been calculated.
/// </summary>
Task BackfillZeroNodesAsync(
ClientContext ctx,
IReadOnlyList<StorageNode> nodes,
IProgress<OperationProgress> progress,
CancellationToken ct);
}

View File

@@ -158,6 +158,152 @@ public class StorageService : IStorageService
.ToList();
}
public async Task BackfillZeroNodesAsync(
ClientContext ctx,
IReadOnlyList<StorageNode> nodes,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
// Find root-level library nodes that have any zero-valued nodes in their tree
var libNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
var needsBackfill = libNodes.Where(lib =>
lib.TotalFileCount == 0 || HasZeroChild(lib)).ToList();
if (needsBackfill.Count == 0) return;
// Load libraries to get RootFolder.ServerRelativeUrl for path matching
ctx.Load(ctx.Web, w => w.ServerRelativeUrl,
w => w.Lists.Include(
l => l.Title, l => l.Hidden, l => l.BaseType,
l => l.RootFolder.ServerRelativeUrl));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var libs = ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToDictionary(l => l.Title, StringComparer.OrdinalIgnoreCase);
int idx = 0;
foreach (var libNode in needsBackfill)
{
ct.ThrowIfCancellationRequested();
idx++;
if (!libs.TryGetValue(libNode.Name, out var lib)) continue;
progress.Report(new OperationProgress(idx, needsBackfill.Count,
$"Counting files: {libNode.Name} ({idx}/{needsBackfill.Count})"));
string libRootSrl = lib.RootFolder.ServerRelativeUrl.TrimEnd('/');
// Build a lookup of all folder nodes in this library's tree (by server-relative path)
var folderLookup = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
BuildFolderLookup(libNode, libRootSrl, folderLookup);
// Reset all nodes in this tree to zero before accumulating
ResetNodeCounts(libNode);
// Enumerate all files with their folder path
var query = new CamlQuery
{
ViewXml = @"<View Scope='RecursiveAll'>
<Query><Where>
<Eq><FieldRef Name='FSObjType' /><Value Type='Integer'>0</Value></Eq>
</Where></Query>
<ViewFields>
<FieldRef Name='FileDirRef' />
<FieldRef Name='File_x0020_Size' />
</ViewFields>
<RowLimit Paged='TRUE'>500</RowLimit>
</View>"
};
ListItemCollection items;
do
{
ct.ThrowIfCancellationRequested();
items = lib.GetItems(query);
ctx.Load(items, ic => ic.ListItemCollectionPosition,
ic => ic.Include(
i => i["FileDirRef"],
i => i["File_x0020_Size"]));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
foreach (var item in items)
{
long size = 0;
if (long.TryParse(item["File_x0020_Size"]?.ToString() ?? "0", out long s))
size = s;
string fileDirRef = item["FileDirRef"]?.ToString() ?? "";
// Always count toward the library root
libNode.TotalSizeBytes += size;
libNode.FileStreamSizeBytes += size;
libNode.TotalFileCount++;
// Also count toward the most specific matching subfolder
var matchedFolder = FindDeepestFolder(fileDirRef, folderLookup);
if (matchedFolder != null && matchedFolder != libNode)
{
matchedFolder.TotalSizeBytes += size;
matchedFolder.FileStreamSizeBytes += size;
matchedFolder.TotalFileCount++;
}
}
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
}
while (items.ListItemCollectionPosition != null);
}
}
private static bool HasZeroChild(StorageNode node)
{
foreach (var child in node.Children)
{
if (child.TotalFileCount == 0) return true;
if (HasZeroChild(child)) return true;
}
return false;
}
private static void ResetNodeCounts(StorageNode node)
{
node.TotalSizeBytes = 0;
node.FileStreamSizeBytes = 0;
node.TotalFileCount = 0;
foreach (var child in node.Children)
ResetNodeCounts(child);
}
private static void BuildFolderLookup(StorageNode node, string parentPath,
Dictionary<string, StorageNode> lookup)
{
string nodePath = node.IndentLevel == 0
? parentPath
: parentPath + "/" + node.Name;
lookup[nodePath] = node;
foreach (var child in node.Children)
BuildFolderLookup(child, nodePath, lookup);
}
private static StorageNode? FindDeepestFolder(string fileDirRef,
Dictionary<string, StorageNode> lookup)
{
// fileDirRef is the server-relative folder path, e.g. "/sites/hr/Shared Documents/Reports"
// Try exact match, then walk up until we find a match
string path = fileDirRef.TrimEnd('/');
while (!string.IsNullOrEmpty(path))
{
if (lookup.TryGetValue(path, out var node))
return node;
int lastSlash = path.LastIndexOf('/');
if (lastSlash <= 0) break;
path = path[..lastSlash];
}
return null;
}
// -- Private helpers -----------------------------------------------------
private static async Task<StorageNode> LoadFolderNodeAsync(

View File

@@ -114,7 +114,7 @@ public class UserAccessAuditService : IUserAccessAuditService
yield return new UserAccessEntry(
UserDisplayName: displayName,
UserLogin: login,
UserLogin: StripClaimsPrefix(login),
SiteUrl: site.Url,
SiteTitle: site.Title,
ObjectType: entry.ObjectType,
@@ -133,6 +133,16 @@ public class UserAccessAuditService : IUserAccessAuditService
/// <summary>
/// Classifies a PermissionEntry into Direct, Group, or Inherited access type.
/// </summary>
/// <summary>
/// Removes SharePoint claims prefixes like "i:0#.f|membership|" so the
/// UI displays a plain email instead of the full claim string.
/// </summary>
private static string StripClaimsPrefix(string login)
{
int pipe = login.LastIndexOf('|');
return pipe >= 0 ? login[(pipe + 1)..] : login;
}
private static AccessType ClassifyAccessType(PermissionEntry entry)
{
// Inherited: object does not have unique permissions