6d9c79ad5a
Feature work: - Certificate (app-only) auth per profile: cert store, context/Graph client factories, automated app-registration provisioning (delegated + application permissions, admin consent), and a SessionManager seam that resolves the auth model per profile. - Scheduled reports: repositories, hosted service/runner/coordinator, report pages, and email delivery (app-only Mail.Send). - Tenant-wide user-access audit when no site is selected. Audit fixes: - Site enumeration: app-only discovery used Graph getAllSites (needs Graph Sites.Read.All the cert app lacks) and silently returned empty. Switched to the admin-host CSOM TenantSiteEnumerator, matching the scheduler; both auth models now share one enumeration path. - Group expansion: the scan records a SharePoint group as a single principal, so user-centric audits found nothing for group-granted access. Resolve group membership (shared by audit + scheduler) and attribute it to the target user. - M365 group claims: the resolver only recognized AAD security groups (c:0t.c|). Group-connected/Teams sites grant via the M365 group claim (c:0o.c|…|<guid>[_o]); now expanded too, resolving owners for the "_o" claim. - Provision Directory.Read.All as an application permission so M365/AAD group expansion works under the cert identity. Also: ignore data/appcerts/ (encrypted certificate key material). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
500 lines
24 KiB
Plaintext
500 lines
24 KiB
Plaintext
@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
|
|
|
|
<h1 class="page-title">Scheduled Reports</h1>
|
|
<p class="page-subtitle">Automatic report generation per client. Generated files appear under Reports and are downloadable there.</p>
|
|
|
|
@if (UserContext.Role != UserRole.Admin)
|
|
{
|
|
<div class="alert alert-info">Only administrators can manage scheduled reports.</div>
|
|
return;
|
|
}
|
|
|
|
@if (!string.IsNullOrEmpty(_pageMsg)) { <div class="alert alert-info" style="margin-bottom:12px">@_pageMsg</div> }
|
|
|
|
@if (_appOnlyProfiles.Count == 0)
|
|
{
|
|
<div class="alert alert-error">
|
|
No client has app-only access enabled. Open a client under <a href="/profiles">Client Profiles</a>,
|
|
enable scheduled reports, and upload its certificate first.
|
|
</div>
|
|
}
|
|
|
|
<div class="flex-row" style="margin-bottom:16px">
|
|
<button class="btn btn-primary" @onclick="AddNew" disabled="@(_appOnlyProfiles.Count == 0)">New schedule</button>
|
|
<div class="spacer"></div>
|
|
@if (Coordinator.IsPaused)
|
|
{
|
|
<span class="chip chip-blue" style="align-self:center">Scheduler paused</span>
|
|
<button class="btn btn-secondary btn-sm" @onclick="ResumeScheduler">Resume scheduler</button>
|
|
}
|
|
else
|
|
{
|
|
<button class="btn btn-secondary btn-sm" @onclick="PauseScheduler">Pause scheduler</button>
|
|
}
|
|
<button class="btn btn-danger btn-sm" @onclick="StopAll">Stop all running</button>
|
|
</div>
|
|
|
|
@if (_schedules.Count == 0 && !_showForm)
|
|
{
|
|
<div class="alert alert-info">No schedules defined.</div>
|
|
}
|
|
|
|
@foreach (var s in _schedules)
|
|
{
|
|
<div class="card">
|
|
<div class="flex-row">
|
|
<div>
|
|
<div style="font-weight:600;font-size:15px">
|
|
@(string.IsNullOrEmpty(s.Name) ? "(unnamed)" : s.Name)
|
|
@if (!s.Enabled) { <span class="chip chip-blue" style="margin-left:6px">Disabled</span> }
|
|
</div>
|
|
<div class="text-muted">@ClientName(s.ProfileId) · @s.Type · @s.Format · @RecurrenceSummary(s.Recurrence)</div>
|
|
<div class="text-muted">
|
|
@(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")
|
|
</div>
|
|
</div>
|
|
<div class="spacer"></div>
|
|
<button class="btn btn-secondary btn-sm" @onclick="() => RunNowAsync(s)" disabled="@Coordinator.IsRunning(s.Id)">
|
|
@(Coordinator.IsRunning(s.Id) ? "Running…" : "Run now")
|
|
</button>
|
|
@if (Coordinator.IsRunning(s.Id))
|
|
{
|
|
<button class="btn btn-danger btn-sm" @onclick="() => Stop(s)">Stop</button>
|
|
}
|
|
<button class="btn btn-secondary btn-sm" @onclick="() => ToggleEnabledAsync(s)">@(s.Enabled ? "Disable" : "Enable")</button>
|
|
<button class="btn btn-secondary btn-sm" @onclick="() => Edit(s)">Edit</button>
|
|
<button class="btn btn-danger btn-sm" @onclick="() => DeleteAsync(s)">Delete</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (_showForm)
|
|
{
|
|
<div class="card" style="border-color:var(--accent)">
|
|
<div class="card-title">@(_editing is null ? "New schedule" : "Edit schedule")</div>
|
|
@if (!string.IsNullOrEmpty(_formError)) { <div class="alert alert-error">@_formError</div> }
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Name</label>
|
|
<input class="form-input" @bind="_form.Name" placeholder="e.g. Weekly permissions audit" />
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group" style="flex:1">
|
|
<label class="form-label">Client</label>
|
|
<select class="form-input" @bind="_form.ProfileId">
|
|
@foreach (var p in _appOnlyProfiles)
|
|
{
|
|
<option value="@p.Id">@p.Name</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="form-group" style="flex:1">
|
|
<label class="form-label">Report type</label>
|
|
<select class="form-input" @bind="_form.Type">
|
|
@foreach (var t in Enum.GetValues<ReportType>())
|
|
{
|
|
<option value="@t">@t</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
@if (_form.Type == ReportType.VersionCleanup)
|
|
{
|
|
<div class="alert alert-error">
|
|
<strong>Destructive action.</strong> Version Cleanup permanently <strong>deletes</strong> 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).
|
|
</div>
|
|
}
|
|
|
|
<div class="form-row">
|
|
<div class="form-group" style="flex:1">
|
|
<label class="form-label">Format</label>
|
|
<select class="form-input" @bind="_form.Format" disabled="@(_form.Type == ReportType.VersionCleanup)">
|
|
@foreach (var f in Enum.GetValues<ReportFormat>())
|
|
{
|
|
<option value="@f">@f</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="form-group" style="flex:1">
|
|
<label class="form-label">Multi-site bundling</label>
|
|
<select class="form-input" @bind="_form.MergeMode">
|
|
@foreach (var m in Enum.GetValues<ReportMergeMode>())
|
|
{
|
|
<option value="@m">@m</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
@* ── Site scope ── *@
|
|
<div class="form-group">
|
|
<label><input type="checkbox" @bind="_form.AllSites" /> All sites in the tenant (auto-discovered)</label>
|
|
@if (!_form.AllSites)
|
|
{
|
|
<label class="form-label mt-8">Site URLs (one per line)</label>
|
|
<textarea class="form-textarea" rows="3" @bind="_siteUrlsText"
|
|
placeholder="https://contoso.sharepoint.com/sites/Marketing"></textarea>
|
|
}
|
|
</div>
|
|
|
|
@* ── Recurrence ── *@
|
|
<div class="form-row">
|
|
<div class="form-group" style="flex:1">
|
|
<label class="form-label">Frequency</label>
|
|
<select class="form-input" @bind="_form.Recurrence.Frequency">
|
|
@foreach (var f in Enum.GetValues<ReportFrequency>())
|
|
{
|
|
<option value="@f">@f</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="form-group" style="flex:1">
|
|
<label class="form-label">Time (UTC, HH:mm)</label>
|
|
<input class="form-input" @bind="_form.Recurrence.TimeOfDayUtc" placeholder="06:00" />
|
|
</div>
|
|
@if (_form.Recurrence.Frequency == ReportFrequency.Weekly)
|
|
{
|
|
<div class="form-group" style="flex:1">
|
|
<label class="form-label">Day of week</label>
|
|
<select class="form-input" @bind="_form.Recurrence.DayOfWeek">
|
|
@foreach (var d in Enum.GetValues<DayOfWeek>())
|
|
{
|
|
<option value="@d">@d</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
}
|
|
else if (_form.Recurrence.Frequency == ReportFrequency.Monthly)
|
|
{
|
|
<div class="form-group" style="flex:1">
|
|
<label class="form-label">Day of month</label>
|
|
<input class="form-input" type="number" min="1" max="31" @bind="_form.Recurrence.DayOfMonth" />
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
@* ── Type-specific options ── *@
|
|
<div class="form-group" style="border-top:1px solid var(--border);padding-top:12px">
|
|
<label class="form-label" style="font-weight:600">Options</label>
|
|
@switch (_form.Type)
|
|
{
|
|
case ReportType.Permissions:
|
|
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
|
|
<label><input type="checkbox" @bind="_form.Options.IncludeInherited" /> Include inherited</label>
|
|
<label><input type="checkbox" @bind="_form.Options.ScanFolders" /> Scan folders</label>
|
|
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
|
|
<label>Folder depth <input class="form-input" type="number" min="0" max="999" style="width:80px" @bind="_form.Options.FolderDepth" /></label>
|
|
</div>
|
|
break;
|
|
|
|
case ReportType.Storage:
|
|
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
|
|
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
|
|
<label><input type="checkbox" @bind="_form.Options.IncludeHiddenLibraries" /> Include hidden libraries</label>
|
|
<label><input type="checkbox" @bind="_form.Options.IncludeRecycleBin" /> Include recycle bin</label>
|
|
<label>Folder depth <input class="form-input" type="number" min="0" max="20" style="width:80px" @bind="_form.Options.FolderDepth" /></label>
|
|
</div>
|
|
break;
|
|
|
|
case ReportType.Duplicates:
|
|
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
|
|
<label>Mode
|
|
<select class="form-input" style="width:120px" @bind="_form.Options.DuplicateMode">
|
|
<option value="Files">Files</option>
|
|
<option value="Folders">Folders</option>
|
|
</select>
|
|
</label>
|
|
<label><input type="checkbox" @bind="_form.Options.MatchSize" /> Match size</label>
|
|
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
|
|
<label>Library <input class="form-input" style="width:160px" @bind="_form.Options.Library" placeholder="(all)" /></label>
|
|
</div>
|
|
break;
|
|
|
|
case ReportType.Search:
|
|
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
|
|
<label>Extensions <input class="form-input" style="width:180px" @bind="_extensionsText" placeholder="pdf, docx" /></label>
|
|
<label>Regex <input class="form-input" style="width:180px" @bind="_form.Options.Regex" placeholder="(optional)" /></label>
|
|
<label>Max results <input class="form-input" type="number" min="1" style="width:100px" @bind="_form.Options.MaxResults" /></label>
|
|
<label>Library <input class="form-input" style="width:160px" @bind="_form.Options.Library" placeholder="(all)" /></label>
|
|
</div>
|
|
break;
|
|
|
|
case ReportType.UserAccess:
|
|
<label class="form-label">Target users (login/email, one per line)</label>
|
|
<textarea class="form-textarea" rows="2" @bind="_targetUsersText" placeholder="alice@contoso.com bob@contoso.com"></textarea>
|
|
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center;margin-top:8px">
|
|
<label><input type="checkbox" @bind="_form.Options.IncludeInherited" /> Include inherited</label>
|
|
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
|
|
</div>
|
|
break;
|
|
|
|
case ReportType.VersionCleanup:
|
|
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
|
|
<label>Libraries (comma separated, blank = all) <input class="form-input" style="width:220px" @bind="_libraryTitlesText" /></label>
|
|
<label>Keep last <input class="form-input" type="number" min="0" style="width:80px" @bind="_form.Options.KeepLast" /></label>
|
|
<label><input type="checkbox" @bind="_form.Options.KeepFirst" /> Keep first version</label>
|
|
</div>
|
|
break;
|
|
}
|
|
</div>
|
|
|
|
@* ── Email delivery ── *@
|
|
<div class="form-group" style="border-top:1px solid var(--border);padding-top:12px">
|
|
<label style="font-weight:600"><input type="checkbox" @bind="_form.Email.Enabled" /> Email the report via Graph</label>
|
|
@if (_form.Email.Enabled)
|
|
{
|
|
<div class="alert alert-info" style="margin-top:8px">
|
|
Sent through the client's app-only certificate (requires the <strong>Mail.Send</strong> application
|
|
permission — re-run onboarding if the app was registered before this was added). The report file is attached.
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">From (sender mailbox)</label>
|
|
<input class="form-input" @bind="_form.Email.From" placeholder="reports@contoso.com" />
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group" style="flex:1">
|
|
<label class="form-label">To (one per line)</label>
|
|
<textarea class="form-textarea" rows="2" @bind="_emailToText" placeholder="alice@contoso.com"></textarea>
|
|
</div>
|
|
<div class="form-group" style="flex:1">
|
|
<label class="form-label">Cc (one per line)</label>
|
|
<textarea class="form-textarea" rows="2" @bind="_emailCcText" placeholder="(optional)"></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Subject</label>
|
|
<input class="form-input" @bind="_form.Email.Subject" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Body (HTML)</label>
|
|
<textarea class="form-textarea" rows="5" @bind="_form.Email.Body"></textarea>
|
|
<div class="text-muted" style="margin-top:4px">
|
|
Placeholders: {ReportName} {ClientName} {ReportType} {FileName} {DateUtc}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label><input type="checkbox" @bind="_form.Enabled" /> Enabled</label>
|
|
</div>
|
|
|
|
<div class="flex-row mt-8">
|
|
<button class="btn btn-primary" @onclick="SaveAsync">Save</button>
|
|
<button class="btn btn-secondary" @onclick="() => _showForm = false">Cancel</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@code {
|
|
private List<ScheduledReport> _schedules = new();
|
|
private List<TenantProfile> _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<string>(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<string>(s.Email.To), Cc = new List<string>(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<string>(o.LibraryTitles), KeepLast = o.KeepLast, KeepFirst = o.KeepFirst,
|
|
Extensions = new List<string>(o.Extensions), Regex = o.Regex, MaxResults = o.MaxResults,
|
|
TargetUserLogins = new List<string>(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.";
|
|
}
|
|
}
|