@page "/scheduled-reports" @attribute [Microsoft.AspNetCore.Authorization.Authorize] @inject IUserContextAccessor UserContext @inject SharepointToolbox.Web.Infrastructure.Persistence.ScheduledReportRepository ScheduleRepo @inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo @inject SharepointToolbox.Web.Services.Reports.IScheduledReportRunner Runner @inject SharepointToolbox.Web.Services.Reports.ScheduledRunCoordinator Coordinator @inject TranslationSource T @rendermode InteractiveServer @using SharepointToolbox.Web.Core.Models @using SharepointToolbox.Web.Services.Export

Scheduled Reports

Automatic report generation per client. Generated files appear under Reports and are downloadable there.

@if (UserContext.Role != UserRole.Admin) {
Only administrators can manage scheduled reports.
return; } @if (!string.IsNullOrEmpty(_pageMsg)) {
@_pageMsg
} @if (_appOnlyProfiles.Count == 0) {
No client has app-only access enabled. Open a client under Client Profiles, enable scheduled reports, and upload its certificate first.
}
@if (Coordinator.IsPaused) { Scheduler paused } else { }
@if (_schedules.Count == 0 && !_showForm) {
No schedules defined.
} @foreach (var s in _schedules) {
@(string.IsNullOrEmpty(s.Name) ? "(unnamed)" : s.Name) @if (!s.Enabled) { Disabled }
@ClientName(s.ProfileId) · @s.Type · @s.Format · @RecurrenceSummary(s.Recurrence)
@(s.AllSites ? "All sites" : $"{s.SiteUrls.Count} site(s)") · Next: @(s.NextRunUtc?.ToString("yyyy-MM-dd HH:mm 'UTC'") ?? "—") · Last: @(s.LastRunUtc?.ToString("yyyy-MM-dd HH:mm 'UTC'") ?? "never")
@if (Coordinator.IsRunning(s.Id)) { }
} @if (_showForm) {
@(_editing is null ? "New schedule" : "Edit schedule")
@if (!string.IsNullOrEmpty(_formError)) {
@_formError
}
@if (_form.Type == ReportType.VersionCleanup) {
Destructive action. Version Cleanup permanently deletes old file versions across the selected sites every time it runs — unattended, with no confirmation. The report is only a summary of what was deleted. Output is HTML (no CSV).
}
@* ── Site scope ── *@
@if (!_form.AllSites) { }
@* ── Recurrence ── *@
@if (_form.Recurrence.Frequency == ReportFrequency.Weekly) {
} else if (_form.Recurrence.Frequency == ReportFrequency.Monthly) {
}
@* ── Type-specific options ── *@
@switch (_form.Type) { case ReportType.Permissions:
break; case ReportType.Storage:
break; case ReportType.Duplicates:
break; case ReportType.Search:
break; case ReportType.UserAccess:
break; case ReportType.VersionCleanup:
break; }
@* ── Email delivery ── *@
@if (_form.Email.Enabled) {
Sent through the client's app-only certificate (requires the Mail.Send application permission — re-run onboarding if the app was registered before this was added). The report file is attached.
Placeholders: {ReportName} {ClientName} {ReportType} {FileName} {DateUtc}
}
} @code { private List _schedules = new(); private List _appOnlyProfiles = new(); private bool _showForm; private ScheduledReport? _editing; private ScheduledReport _form = new(); private string _formError = string.Empty; private string _pageMsg = string.Empty; // Textarea/CSV scratch buffers mapped to/from the option lists on save. private string _siteUrlsText = string.Empty; private string _extensionsText = string.Empty; private string _targetUsersText = string.Empty; private string _libraryTitlesText = string.Empty; private string _emailToText = string.Empty; private string _emailCcText = string.Empty; protected override async Task OnInitializedAsync() { if (UserContext.Role != UserRole.Admin) return; await Reload(); } private async Task Reload() { _schedules = (await ScheduleRepo.LoadAsync()).OrderBy(s => s.Name).ToList(); _appOnlyProfiles = (await ProfileRepo.LoadAsync()).Where(p => p.AppOnlyEnabled).OrderBy(p => p.Name).ToList(); } private string ClientName(string profileId) => _appOnlyProfiles.FirstOrDefault(p => p.Id == profileId)?.Name ?? "(client removed)"; private static string RecurrenceSummary(RecurrenceRule r) => r.Frequency switch { ReportFrequency.Daily => $"Daily at {r.TimeOfDayUtc} UTC", ReportFrequency.Weekly => $"Weekly {r.DayOfWeek} at {r.TimeOfDayUtc} UTC", ReportFrequency.Monthly => $"Monthly day {r.DayOfMonth} at {r.TimeOfDayUtc} UTC", _ => r.TimeOfDayUtc }; private void AddNew() { _editing = null; _form = new ScheduledReport { ProfileId = _appOnlyProfiles.FirstOrDefault()?.Id ?? string.Empty, CreatedBy = UserContext.Email }; _siteUrlsText = _extensionsText = _targetUsersText = _libraryTitlesText = string.Empty; _emailToText = _emailCcText = string.Empty; _formError = string.Empty; _showForm = true; } private void Edit(ScheduledReport s) { _editing = s; // Deep-ish copy so cancel discards edits. _form = new ScheduledReport { Id = s.Id, ProfileId = s.ProfileId, Name = s.Name, Type = s.Type, AllSites = s.AllSites, SiteUrls = new List(s.SiteUrls), MergeMode = s.MergeMode, Format = s.Format, Enabled = s.Enabled, CreatedBy = s.CreatedBy, CreatedUtc = s.CreatedUtc, LastRunUtc = s.LastRunUtc, NextRunUtc = s.NextRunUtc, Recurrence = new RecurrenceRule { Frequency = s.Recurrence.Frequency, TimeOfDayUtc = s.Recurrence.TimeOfDayUtc, DayOfWeek = s.Recurrence.DayOfWeek, DayOfMonth = s.Recurrence.DayOfMonth }, Options = Clone(s.Options), Email = new ReportEmailSettings { Enabled = s.Email.Enabled, From = s.Email.From, To = new List(s.Email.To), Cc = new List(s.Email.Cc), Subject = s.Email.Subject, Body = s.Email.Body } }; _siteUrlsText = string.Join("\n", s.SiteUrls); _extensionsText = string.Join(", ", s.Options.Extensions); _targetUsersText = string.Join("\n", s.Options.TargetUserLogins); _libraryTitlesText = string.Join(", ", s.Options.LibraryTitles); _emailToText = string.Join("\n", s.Email.To); _emailCcText = string.Join("\n", s.Email.Cc); _formError = string.Empty; _showForm = true; } private static ScheduledReportOptions Clone(ScheduledReportOptions o) => new() { IncludeInherited = o.IncludeInherited, ScanFolders = o.ScanFolders, FolderDepth = o.FolderDepth, IncludeSubsites = o.IncludeSubsites, PerLibrary = o.PerLibrary, IncludeHiddenLibraries = o.IncludeHiddenLibraries, IncludePreservationHold = o.IncludePreservationHold, IncludeListAttachments = o.IncludeListAttachments, IncludeRecycleBin = o.IncludeRecycleBin, DuplicateMode = o.DuplicateMode, MatchSize = o.MatchSize, MatchCreated = o.MatchCreated, MatchModified = o.MatchModified, MatchSubfolderCount = o.MatchSubfolderCount, MatchFileCount = o.MatchFileCount, Library = o.Library, LibraryTitles = new List(o.LibraryTitles), KeepLast = o.KeepLast, KeepFirst = o.KeepFirst, Extensions = new List(o.Extensions), Regex = o.Regex, MaxResults = o.MaxResults, TargetUserLogins = new List(o.TargetUserLogins) }; private async Task SaveAsync() { _formError = string.Empty; if (string.IsNullOrWhiteSpace(_form.Name)) { _formError = "Name is required."; return; } if (string.IsNullOrWhiteSpace(_form.ProfileId)) { _formError = "Select a client."; return; } // VersionCleanup has no CSV exporter. if (_form.Type == ReportType.VersionCleanup) _form.Format = ReportFormat.Html; // Map scratch buffers back to option lists. _form.SiteUrls = _siteUrlsText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); _form.Options.Extensions = _extensionsText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); _form.Options.TargetUserLogins = _targetUsersText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); _form.Options.LibraryTitles = _libraryTitlesText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); if (!_form.AllSites && _form.SiteUrls.Count == 0) { _formError = "Add at least one site URL, or choose All sites."; return; } if (_form.Type == ReportType.UserAccess && _form.Options.TargetUserLogins.Count == 0) { _formError = "User Access reports need at least one target user."; return; } // Map email scratch buffers back to lists and validate when delivery is on. _form.Email.To = _emailToText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); _form.Email.Cc = _emailCcText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); if (_form.Email.Enabled) { if (string.IsNullOrWhiteSpace(_form.Email.From)) { _formError = "Email delivery needs a sender mailbox (From)."; return; } if (_form.Email.To.Count == 0 && _form.Email.Cc.Count == 0) { _formError = "Email delivery needs at least one To or Cc recipient."; return; } } // Arm the next run from now so the scheduler picks it up on the right cadence. _form.NextRunUtc = _form.Recurrence.ComputeNextRunUtc(DateTime.UtcNow); await ScheduleRepo.UpsertAsync(_form); _showForm = false; _editing = null; await Reload(); } private async Task ToggleEnabledAsync(ScheduledReport s) { s.Enabled = !s.Enabled; if (s.Enabled && s.NextRunUtc is null) s.NextRunUtc = s.Recurrence.ComputeNextRunUtc(DateTime.UtcNow); await ScheduleRepo.UpsertAsync(s); await Reload(); } private async Task DeleteAsync(ScheduledReport s) { await ScheduleRepo.DeleteAsync(s.Id); await Reload(); } private async Task RunNowAsync(ScheduledReport s) { // Register through the coordinator so this manual run is stoppable and can't // overlap a scheduler-triggered run of the same schedule. var token = Coordinator.TryBegin(s.Id, CancellationToken.None); if (token is null) { _pageMsg = $"'{s.Name}' is already running."; return; } _pageMsg = string.Empty; await InvokeAsync(StateHasChanged); try { var report = await Runner.RunAsync(s, token.Value); _pageMsg = report.Status == ReportRunStatus.Success ? $"'{s.Name}' generated {report.FileName}. See Reports." : $"'{s.Name}' failed: {report.Error}"; } catch (OperationCanceledException) { _pageMsg = $"'{s.Name}' was stopped."; } catch (Exception ex) { _pageMsg = $"'{s.Name}' failed: {ex.Message}"; } finally { Coordinator.Complete(s.Id); await Reload(); } } private void Stop(ScheduledReport s) => _pageMsg = Coordinator.Cancel(s.Id) ? $"Stop signal sent to '{s.Name}'. It ends after the current site finishes." : $"'{s.Name}' is not running."; private void StopAll() { var n = Coordinator.CancelAll(); _pageMsg = n == 0 ? "No runs in progress." : $"Stop signal sent to {n} running report(s)."; } private void PauseScheduler() { Coordinator.Pause(); _pageMsg = "Scheduler paused — no schedules will fire until resumed. Runs in progress keep going (Stop them individually)."; } private void ResumeScheduler() { Coordinator.Resume(); _pageMsg = "Scheduler resumed."; } }