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
Binary file modified .verify/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
<!-- SM0012: contract interface size warning — keep as warning, don't promote to error -->
<!-- SM0039: interceptor DbContext dependency — keep as warning, runtime fix handles it -->
<!-- NU5104: NetEscapades.EnumGenerators is a prerelease source generator (PrivateAssets=all, not shipped) -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);SM0012;SM0039;NU5104</WarningsNotAsErrors>
<!-- NU1903: high-severity advisory in the TRANSITIVE native lib SQLitePCLRaw.lib.e_sqlite3
(pulled by Microsoft.Data.Sqlite/EFCore.Sqlite 10.0.3 — not a direct reference, so it
can't be bumped here). Kept visible as a warning; remove once a patched transitive ships. -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);SM0012;SM0039;NU5104;NU1903</WarningsNotAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<RunAnalyzers>true</RunAnalyzers>
<!-- Reference assemblies: downstream projects skip recompile when only method bodies change -->
Expand Down
20 changes: 19 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<!-- Pin transitive dependencies to the central versions below. Needed so the
"Security overrides" at the bottom of this file actually replace the vulnerable
versions pulled in transitively by EF Core / Aspire (they are not direct refs).
Requires every centrally-managed version to be >= what the graph needs
transitively (e.g. Npgsql was bumped 9.0.4 -> 10.0.3 to match EF Core PG 10). -->
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<!-- Aspire -->
Expand All @@ -22,7 +28,7 @@
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
<PackageVersion Include="Npgsql" Version="9.0.4" />
<PackageVersion Include="Npgsql" Version="10.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.3" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
Expand Down Expand Up @@ -97,5 +103,17 @@
Include="Microsoft.SemanticKernel.Connectors.Postgres"
Version="1.51.0-preview"
/>
<!-- Security overrides: patched versions of transitive packages flagged by the CI
vulnerable-packages audit gate. Not referenced directly anywhere; transitive
pinning (top of file) is what makes these apply. -->
<!-- GHSA-hv8m-jj95-wg3x (High): MessagePack before 2.5.301, pulled via Aspire/Wolverine. -->
<PackageVersion Include="MessagePack" Version="2.5.302" />
<!-- GHSA-2m69-gcr7-jv3q (High): the SQLite native lib bundled by SQLitePCLRaw 2.1.11
(via Microsoft.EntityFrameworkCore.Sqlite). The patched line is 3.x; the family is
version-split there (core/bundle/provider = 3.0.3, native lib = SQLite-versioned 3.50.3). -->
<PackageVersion Include="SQLitePCLRaw.core" Version="3.0.3" />
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
<PackageVersion Include="SQLitePCLRaw.provider.e_sqlite3" Version="3.0.3" />
<PackageVersion Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ public class AuditQueryRequest
public string? SearchText { get; set; }
public int? Page { get; set; }
public int? PageSize { get; set; }

/// <summary>
/// Opt-in keyset cursor. When set (and the default Timestamp-descending ordering
/// is used), the page is fetched via <c>WHERE Timestamp &lt; Before</c> instead of
/// OFFSET, skipping the per-request <c>COUNT(*)</c> and the O(offset) row-skip that
/// make deep pages slow. Pass the <c>Timestamp</c> of the last item from the
/// previous page to fetch the next one.
/// </summary>
public DateTimeOffset? Before { get; set; }
public string? SortBy { get; set; }
public bool? SortDescending { get; set; }

Expand Down
61 changes: 56 additions & 5 deletions modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,60 @@ public sealed partial class AuditLogService(
ILogger<AuditLogService> logger
) : IAuditLogContracts
{
private static readonly string[] CustomSortColumns =
[
"UserId",
"Module",
"Path",
"StatusCode",
"DurationMs",
];

public async Task<PagedResult<AuditEntry>> QueryAsync(AuditQueryRequest request)
{
var query = BuildQuery(request);

var totalCount = await query.CountAsync();

var sortBy = request.EffectiveSortBy;
var sortDesc = request.EffectiveSortDescending;
var page = request.EffectivePage;
var pageSize = request.EffectivePageSize;
var provider = DatabaseProviderDetector.Detect(
dbOptions.Value.DefaultConnection,
dbOptions.Value.Provider
);
var isDefaultSort = sortDesc && !CustomSortColumns.Contains(sortBy);

// Keyset (cursor) pagination: when the caller supplies a cursor and uses the
// default Timestamp-descending ordering, page via WHERE Timestamp < cursor
// instead of OFFSET. This avoids both the per-request COUNT(*) and the O(offset)
// row-skip that make deep pages slow, using the IX_AuditEntries_Timestamp index.
// The ordering (Timestamp DESC, Id DESC) matches the offset default below, so a
// cursor page is a true continuation of the offset first page. SQLite cannot
// ORDER BY DateTimeOffset, so keyset only applies on managed providers (SQLite
// requests fall through to Id-ordered offset paging). TotalCount is -1 (not
// computed) in cursor mode. Note: rows sharing the exact boundary Timestamp can
// be skipped (strict <); with microsecond-resolution UtcNow this is rare.
if (request.Before.HasValue && isDefaultSort && provider != DatabaseProvider.Sqlite)
{
var cursor = request.Before.Value;
var keysetItems = await query
.Where(e => e.Timestamp < cursor)
.OrderByDescending(e => e.Timestamp)
.ThenByDescending(e => e.Id)
.Take(pageSize)
.AsNoTracking()
.ToListAsync();

return new PagedResult<AuditEntry>
{
Items = keysetItems,
TotalCount = -1,
Page = page,
PageSize = pageSize,
};
}

var totalCount = await query.CountAsync();

// Apply sorting
query = sortBy switch
Expand All @@ -40,9 +84,16 @@ public async Task<PagedResult<AuditEntry>> QueryAsync(AuditQueryRequest request)
"DurationMs" => sortDesc
? query.OrderByDescending(e => e.DurationMs)
: query.OrderBy(e => e.DurationMs),
// SQLite does not support DateTimeOffset in ORDER BY, so sort by Id
// (auto-increment, correlates with insertion order) as a fallback.
_ => sortDesc ? query.OrderByDescending(e => e.Id) : query.OrderBy(e => e.Id),
// Default (Timestamp) sort. SQLite cannot ORDER BY DateTimeOffset, so it
// falls back to Id (auto-increment, insertion order). Managed providers order
// by Timestamp with Id as a tiebreaker — this is the ordering keyset cursor
// pagination continues, so the offset first page and cursor pages agree.
_ when provider == DatabaseProvider.Sqlite => sortDesc
? query.OrderByDescending(e => e.Id)
: query.OrderBy(e => e.Id),
_ => sortDesc
? query.OrderByDescending(e => e.Timestamp).ThenByDescending(e => e.Id)
: query.OrderBy(e => e.Timestamp).ThenBy(e => e.Id),
};

var items = await query
Expand Down
4 changes: 3 additions & 1 deletion modules/AuditLogs/src/SimpleModule.AuditLogs/Locales/en.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
"Browse.Title": "Audit Logs",
"Browse.TotalEntries": "{count} total entries",
"Browse.Newest": "Newest",
"Browse.NextPage": "Next page",
"Browse.PrevPage": "Previous page",
"Browse.ExportCsv": "Export CSV",
"Browse.ExportJson": "Export JSON",
"Browse.QuickRange": "Quick range:",
Expand Down Expand Up @@ -30,7 +33,6 @@
"Browse.ColPath": "Path",
"Browse.ColStatus": "Status",
"Browse.ColDuration": "Duration",
"Browse.Showing": "Showing {start}\u2013{end} of {total} entries",
"Dashboard.Title": "Audit Dashboard",
"Dashboard.Description": "System activity overview and metrics",
"Dashboard.FilterFrom": "From",
Expand Down
4 changes: 3 additions & 1 deletion modules/AuditLogs/src/SimpleModule.AuditLogs/Locales/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ export const AuditLogsKeys = {
FilterSourceAll: 'Browse.FilterSourceAll',
FilterSourcePlaceholder: 'Browse.FilterSourcePlaceholder',
FilterTo: 'Browse.FilterTo',
Newest: 'Browse.Newest',
NextPage: 'Browse.NextPage',
PrevPage: 'Browse.PrevPage',
QuickRange: 'Browse.QuickRange',
Showing: 'Browse.Showing',
Title: 'Browse.Title',
TotalEntries: 'Browse.TotalEntries',
},
Expand Down
122 changes: 87 additions & 35 deletions modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Browse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,25 @@ interface Props {
filters: AuditQueryRequest;
}

function buildFilterParams(f: Partial<AuditQueryRequest>, page?: number): Record<string, string> {
// Cursor trail persisted across Inertia (server-rendered) navigations within the
// same browser session, so "Previous" can return to an already-visited cursor.
const CURSOR_STACK_KEY = 'auditlogs-browse-cursors';
function readCursorStack(): string[] {
if (typeof window === 'undefined') return [];
try {
const raw = window.sessionStorage.getItem(CURSOR_STACK_KEY);
return raw ? (JSON.parse(raw) as string[]) : [];
} catch {
return [];
}
}
function writeCursorStack(stack: string[]): void {
if (typeof window !== 'undefined') {
window.sessionStorage.setItem(CURSOR_STACK_KEY, JSON.stringify(stack));
}
}

function buildFilterParams(f: Partial<AuditQueryRequest>, before?: string): Record<string, string> {
const params: Record<string, string> = {};
if (f.from) params.from = String(f.from);
if (f.to) params.to = String(f.to);
Expand All @@ -36,7 +54,8 @@ function buildFilterParams(f: Partial<AuditQueryRequest>, page?: number): Record
if (f.module) params.module = f.module;
if (f.searchText) params.searchText = f.searchText;

if (page && page > 1) params.page = String(page);
// Keyset cursor: fetch the page of entries older than `before`.
if (before) params.before = before;
return params;
}

Expand All @@ -49,8 +68,14 @@ export default function Browse({ result, filters }: Props) {
const [module, setModule] = useState(filters.module ?? '');
const [searchText, setSearchText] = useState(filters.searchText ?? '');

const totalPages = Math.max(1, Math.ceil(result.totalCount / result.pageSize));
const currentPage = result.page;
const before = filters.before ? String(filters.before) : undefined;
const isFirstPage = !before;
// Total is only computed for the first page (offset); keyset pages report -1.
const knownTotal = result.totalCount >= 0;
const items = result.items;
// A full page implies more (older) rows likely exist.
const canNext = items.length >= result.pageSize;
const nextCursor = items.length > 0 ? String(items[items.length - 1].timestamp) : undefined;

function currentFilters() {
return {
Expand All @@ -63,36 +88,56 @@ export default function Browse({ result, filters }: Props) {
};
}

// Any change of filter set resets the cursor trail and returns to the newest page.
function navigateNewest(f: Partial<AuditQueryRequest>) {
writeCursorStack([]);
router.get('/audit-logs/browse', buildFilterParams(f));
}

function applyFilters(e?: FormEvent) {
e?.preventDefault();
router.get('/audit-logs/browse', buildFilterParams(currentFilters()));
navigateNewest(currentFilters());
}

function clearFilters() {
writeCursorStack([]);
router.get('/audit-logs/browse');
}

function applyDatePreset(hours: number) {
const now = new Date();
const past = new Date(now.getTime() - hours * 60 * 60 * 1000);
const toLocal = now.toISOString().slice(0, 16);
const fromLocal = past.toISOString().slice(0, 16);
router.get(
'/audit-logs/browse',
buildFilterParams({
...currentFilters(),
from: fromLocal,
to: toLocal,
}),
);
navigateNewest({
...currentFilters(),
from: past.toISOString().slice(0, 16),
to: now.toISOString().slice(0, 16),
});
}

function goToPage(page: number) {
router.get('/audit-logs/browse', buildFilterParams(currentFilters(), page), {
preserveState: true,
function goNext() {
if (!nextCursor) return;
const stack = readCursorStack();
stack.push(before ?? ''); // remember current cursor ('' marks the first page)
writeCursorStack(stack);
router.get('/audit-logs/browse', buildFilterParams(currentFilters(), nextCursor), {
preserveScroll: true,
});
}

function goPrev() {
const stack = readCursorStack();
const prev = stack.pop();
writeCursorStack(stack);
const target = prev && prev.length > 0 ? prev : undefined;
router.get('/audit-logs/browse', buildFilterParams(currentFilters(), target), {
preserveScroll: true,
});
}

function goNewest() {
navigateNewest(currentFilters());
}

function exportLogs(format: string) {
const query = new URLSearchParams({
...buildFilterParams(currentFilters()),
Expand All @@ -105,17 +150,18 @@ export default function Browse({ result, filters }: Props) {
from || to || source !== '__all__' || action !== '__all__' || module || searchText,
);

const startItem = (currentPage - 1) * result.pageSize + 1;
const endItem = Math.min(currentPage * result.pageSize, result.totalCount);

return (
<TooltipProvider>
<PageShell
className="space-y-4 sm:space-y-6"
title={t(AuditLogsKeys.Browse.Title)}
description={t(AuditLogsKeys.Browse.TotalEntries, {
count: result.totalCount.toLocaleString(),
})}
description={
knownTotal
? t(AuditLogsKeys.Browse.TotalEntries, {
count: result.totalCount.toLocaleString(),
})
: undefined
}
actions={
<div className="flex flex-col gap-2 sm:flex-row">
<Button variant="secondary" onClick={() => exportLogs('csv')}>
Expand Down Expand Up @@ -146,7 +192,7 @@ export default function Browse({ result, filters }: Props) {
onApplyDatePreset={applyDatePreset}
/>

{result.items.length === 0 ? (
{items.length === 0 ? (
<Card>
<CardContent>
<EmptyState
Expand Down Expand Up @@ -186,25 +232,31 @@ export default function Browse({ result, filters }: Props) {
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0">
<BrowseResultsTable items={result.items} />
<BrowseResultsTable items={items} />
</div>
</CardContent>
</Card>
)}

{result.totalCount > 0 && (
{items.length > 0 && (
<div className="flex flex-col items-center gap-2 sm:flex-row sm:justify-between">
<span className="text-sm text-text-muted">
{t(AuditLogsKeys.Browse.Showing, {
start: String(startItem),
end: String(endItem),
total: result.totalCount.toLocaleString(),
})}
{knownTotal
? t(AuditLogsKeys.Browse.TotalEntries, {
count: result.totalCount.toLocaleString(),
})
: null}
</span>
<BrowsePagination
currentPage={currentPage}
totalPages={totalPages}
onGoToPage={goToPage}
canPrev={!isFirstPage}
canNext={canNext}
showNewest={!isFirstPage}
newestLabel={t(AuditLogsKeys.Browse.Newest)}
prevLabel={t(AuditLogsKeys.Browse.PrevPage)}
nextLabel={t(AuditLogsKeys.Browse.NextPage)}
onPrev={goPrev}
onNext={goNext}
onNewest={goNewest}
/>
</div>
)}
Expand Down
Loading
Loading