Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/docs-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,15 @@ jobs:
filters: |
docs:
- 'docs/**'
docs_changelog:
- 'docs/CHANGELOG.md'
source_or_config:
- 'src/**'
- 'tests/**'
- 'infra/**'
- 'scripts/**'
- '.github/workflows/**'
- 'azure.yaml'
- 'docker-compose.yml'
- 'README.md'
- 'StorageLens.sln'
Expand All @@ -37,6 +44,7 @@ jobs:
shell: bash
run: |
echo "docs changed: ${{ steps.changes.outputs.docs }}"
echo "docs changelog changed: ${{ steps.changes.outputs.docs_changelog }}"
echo "source/config changed: ${{ steps.changes.outputs.source_or_config }}"

if [[ "${{ steps.changes.outputs.source_or_config }}" == "true" && "${{ steps.changes.outputs.docs }}" != "true" ]]; then
Expand All @@ -45,4 +53,10 @@ jobs:
exit 1
fi

if [[ "${{ steps.changes.outputs.source_or_config }}" == "true" && "${{ steps.changes.outputs.docs_changelog }}" != "true" ]]; then
echo "Documentation update required: source/config changed, but docs/CHANGELOG.md was not updated."
echo "Please add a concise entry to docs/CHANGELOG.md describing user-visible, API, operational, or workflow-impacting changes."
exit 1
fi

echo "Documentation guard passed."
18 changes: 18 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ The format is inspired by Keep a Changelog, and this project follows Semantic Ve

## [Unreleased]

### Changed
- **Landing page is now the default home route** (`/`) and the dashboard remains available at `/Index`.
- **App navigation updated**: a new **Home** entry is shown above **Dashboard** in both desktop sidebar and mobile offcanvas nav, linking users back to the landing page.
- **Dashboard chart UX refresh**:
- **Storage Growth Trend** upgraded with pseudo-3D rendering, animated load, on-point value labels, and click-to-open detail modal interactions.
- **File Type Distribution** upgraded with pseudo-3D doughnut rendering, animated load, percentage labels, and click-to-open detail modal interactions.
- **Largest Locations** upgraded with pseudo-3D bars, animated load, TB value labels, and click-to-open detail modal interactions.
- **Reports page chart UX refresh**:
- **Most Common File Types** bar chart upgraded with pseudo-3D rendering, animated load, numeric labels, and click-to-open detail modal interactions.
- **Duplicate-Heavy Locations** panel redesigned from a plain list to a ranked visual impact view with rank badges, share percentages, and proportional impact bars.

### Added
- **Chart details modal patterns** on Dashboard and Reports pages for chart click-drill interactions.
- **Interaction hints** on chart headers (e.g., "Click bars for details", "Click points for details") to make interactivity discoverable.
- **Alerts page** (`/Alerts`) with adjustable min/max threshold sliders, test recipient support, and automatic threshold-check email dispatch when usage reaches the max threshold.
- **SMTP alert service** (`SmtpAlertEmailService`) with cooldown protection to avoid repeated email sends on page refresh.
- **`AlertsModelTests`** unit test validating that threshold crossing triggers an email dispatch call.

### Fixed
- **Service-unavailable error handling** — all API client methods (`LocationsApiClient`, `ScanJobsApiClient`, `FileInventoryApiClient`, `DuplicatesApiClient`, `AnalyticsApiClient`, `OrchestratorApiClient`) now catch `HttpRequestException` and log a warning instead of propagating the exception to the error page. Pages that fail to load backend data now display a clear yellow alert banner rather than crashing.
- **`DuplicatesService` build error** — `GroupBy` result was projected to `List<FileRecordDto>` via `.Select(g => g.OrderByDescending(...).ToList())`, causing the group key to be lost. The select now projects to a named tuple `(Key, Files)` so `HashValue` and the final `duplicateHashValues` collection are correctly populated.
Expand Down
4 changes: 2 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,5 @@ Documentation should be updated in the same pull request as source-code changes.
Recommended governance:
- Treat documentation as a release artifact
- Require impacted docs updates in PR reviews
- Require `docs/CHANGELOG.md` updates for user-visible behavior, API, or operational changes
- Use CI docs guard (`.github/workflows/docs-guard.yml`) to fail PRs when source/config changes are not reflected in `docs/`
- Require `docs/CHANGELOG.md` updates for user-visible behavior, API, operational, or workflow-impacting changes
- Use CI docs guard (`.github/workflows/docs-guard.yml`) to fail PRs when source/config/workflow/test changes are not reflected in `docs/`
4 changes: 4 additions & 0 deletions docs/developer/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,9 @@

Any change to services, contracts, workflows, or deployment behavior must update relevant files under `docs/` before merge.

Guard enforcement rules:
- If source/config/workflow/test files change, at least one file under `docs/` must also change.
- If source/config/workflow/test files change, `docs/CHANGELOG.md` must be updated in the same PR.

CI enforces this rule through:
- `.github/workflows/docs-guard.yml`
11 changes: 11 additions & 0 deletions docs/user-guide/dashboard-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@
- Largest storage locations
- Recent job status and progression

## Interactive Charts

- Storage Growth Trend: pseudo-3D line visualization with animated render and clickable data points.
- File Type Distribution: pseudo-3D doughnut visualization with percentage labels and clickable slices.
- Largest Storage Locations: pseudo-3D bar visualization with TB value labels and clickable bars.
- Clicking chart elements opens a detail modal with context (value, percentage/share, and actionable interpretation).

## Navigation Note

- Use the **Home** option in the left navigation (above **Dashboard**) to return to the landing page.

## Interpretation Tips

- Compare duplicate trends over time, not single scans only
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ private static async Task EnsureTablesCreatedForCurrentContextAsync(
{
try
{
var hasAnyTables = await DatabaseHasAnyTablesAsync(dbContext, cancellationToken);
if (hasAnyTables == true)
{
return;
}

var creator = dbContext.Database.GetService<IDatabaseCreator>();
var createTablesMethod = creator.GetType().GetMethod("CreateTablesAsync", [typeof(CancellationToken)]);

Expand All @@ -75,6 +81,31 @@ private static async Task EnsureTablesCreatedForCurrentContextAsync(
}
}

private static async Task<bool?> DatabaseHasAnyTablesAsync(DbContext dbContext, CancellationToken cancellationToken)
{
try
{
var creator = dbContext.Database.GetService<IDatabaseCreator>();
var hasTablesMethod = creator.GetType().GetMethod("HasTablesAsync", [typeof(CancellationToken)]);
if (hasTablesMethod is null)
{
return null;
}

var hasTablesTask = hasTablesMethod.Invoke(creator, [cancellationToken]) as Task<bool>;
if (hasTablesTask is null)
{
return null;
}

return await hasTablesTask;
}
catch
{
return null;
}
}

private static bool IsRetryableStartupError(Exception ex)
{
var currentException = ex;
Expand Down
137 changes: 137 additions & 0 deletions src/StorageLens.Web/Pages/Alerts.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
@page
@model StorageLens.Web.Pages.AlertsModel

<section class="mb-3 reveal-item">
<div class="d-flex flex-wrap justify-content-between align-items-end gap-3">
<div>
<h1 class="dashboard-title mb-1">Alerts</h1>
<p class="text-secondary mb-0">Configure threshold boundaries and trigger automatic email when usage reaches the maximum threshold.</p>
</div>
<span class="badge bg-primary bg-opacity-15 text-primary px-3 py-2">
<i class="bi bi-envelope-check me-1" aria-hidden="true"></i>
Test recipient: dddevacct16783@gmail.com
</span>
</div>
</section>

@if (!Model.IsFeatureAvailable)
{
<div class="alert alert-warning shadow-sm reveal-item" role="alert">
<div class="fw-semibold mb-1"><i class="bi bi-lock-fill me-2" aria-hidden="true"></i>Notification Alerts are not included in your current plan.</div>
Upgrade your subscription tier in <a asp-page="/Pricing" class="alert-link">Pricing</a> to enable threshold-based alert automation.
</div>
}
else
{
@if (!string.IsNullOrWhiteSpace(Model.SuccessMessage))
{
<div class="alert alert-success reveal-item" role="alert">@Model.SuccessMessage</div>
}

@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
{
<div class="alert alert-warning reveal-item" role="alert">@Model.ErrorMessage</div>
}

<section class="row g-3 reveal-item">
<div class="col-lg-8">
<div class="card-premium p-4 h-100">
<h5 class="mb-2">Threshold Range</h5>
<p class="text-secondary small mb-4">Set min and max boundaries from 1% to 100%. Emails are sent automatically when current usage reaches the max threshold.</p>

<form method="post" asp-page-handler="Save">
<div class="mb-3">
<label asp-for="RecipientEmail" class="form-label">Alert Recipient</label>
<input asp-for="RecipientEmail" class="form-control" type="email" />
<span asp-validation-for="RecipientEmail" class="text-danger small"></span>
</div>

<div class="threshold-grid mb-2">
<div>
<div class="d-flex justify-content-between">
<label asp-for="MinThresholdPercent" class="form-label">Minimum Threshold</label>
<span class="fw-semibold" id="minThresholdValue">@Model.MinThresholdPercent%</span>
</div>
<input asp-for="MinThresholdPercent" class="form-range" type="range" min="1" max="99" id="minThresholdRange" />
<span asp-validation-for="MinThresholdPercent" class="text-danger small"></span>
</div>
<div>
<div class="d-flex justify-content-between">
<label asp-for="MaxThresholdPercent" class="form-label">Maximum Threshold</label>
<span class="fw-semibold" id="maxThresholdValue">@Model.MaxThresholdPercent%</span>
</div>
<input asp-for="MaxThresholdPercent" class="form-range" type="range" min="2" max="100" id="maxThresholdRange" />
<span asp-validation-for="MaxThresholdPercent" class="text-danger small"></span>
</div>
</div>

<div class="small text-secondary mb-3">Tip: Keep a healthy buffer between min and max (for example, 70% and 90%) to avoid noisy alerts.</div>

<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-save me-1" aria-hidden="true"></i>Save Alert Thresholds
</button>
</form>
</div>
</div>

<div class="col-lg-4">
<div class="card-premium p-4 h-100 alerts-status-card">
<h5 class="mb-3">Live Threshold Status</h5>

<div class="mb-3">
<div class="text-secondary small">Current usage</div>
<div class="display-6 fw-bold">@Model.CurrentUsagePercent%</div>
</div>

<div class="progress alerts-progress mb-3" role="progressbar" aria-label="Usage threshold" aria-valuemin="0" aria-valuemax="100" aria-valuenow="@Model.CurrentUsagePercent">
<div class="progress-bar" style="width:@Model.CurrentUsagePercent%"></div>
</div>

<div class="small mb-2"><span class="text-secondary">Min:</span> <span class="fw-semibold">@Model.MinThresholdPercent%</span></div>
<div class="small mb-3"><span class="text-secondary">Max:</span> <span class="fw-semibold">@Model.MaxThresholdPercent%</span></div>

@if (!string.IsNullOrWhiteSpace(Model.AutoAlertMessage))
{
<div class="alert alert-light border small mb-0" role="alert">@Model.AutoAlertMessage</div>
}
</div>
</div>
</section>
}

@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
(function () {
var minRange = document.getElementById('minThresholdRange');
var maxRange = document.getElementById('maxThresholdRange');
var minLabel = document.getElementById('minThresholdValue');
var maxLabel = document.getElementById('maxThresholdValue');
if (!minRange || !maxRange || !minLabel || !maxLabel) {
return;
}

function syncLabels() {
var min = parseInt(minRange.value || '1', 10);
var max = parseInt(maxRange.value || '100', 10);

if (min >= max) {
if (document.activeElement === minRange) {
max = Math.min(100, min + 1);
maxRange.value = String(max);
} else {
min = Math.max(1, max - 1);
minRange.value = String(min);
}
}

minLabel.textContent = min + '%';
maxLabel.textContent = max + '%';
}

minRange.addEventListener('input', syncLabels);
maxRange.addEventListener('input', syncLabels);
syncLabels();
})();
</script>
}
Loading
Loading