Skip to content
Open
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
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/defguard_core/src/handlers/activity_log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ impl fmt::Display for SortKey {
Self::Location => "location",
Self::Ip => "ip",
Self::Event => "event",
Self::Module => "module",
Self::Module => "module::text",
Self::Device => "device",
})
}
Expand Down
52 changes: 52 additions & 0 deletions crates/defguard_core/tests/integration/api/activity_log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ struct ApiActivityLogEvent {
id: Id,
timestamp: NaiveDateTime,
username: String,
module: String,
ip: Option<String>,
description: Option<String>,
}
Expand Down Expand Up @@ -365,3 +366,54 @@ async fn test_activity_log_pagination_is_stable_across_pages_for_equal_timestamp
"pagination should not duplicate events across pages"
);
}

#[sqlx::test]
async fn test_activity_log_module_sort_is_alphabetical(
_: PgPoolOptions,
options: PgConnectOptions,
) {
let pool = setup_pool(options).await;
let (mut client, db) = make_client_with_db(pool.clone()).await;
let admin = get_db_user(&db, "admin").await;

client.login_user("admin", "pass123").await;

let marker = unique_marker("module-sort");
let shared_timestamp = Utc::now().naive_utc() + TimeDelta::seconds(5);

// Insert in enum declaration order (defguard=0, client=1, vpn=2, enrollment=3).
// If sorting used enum position instead of text, asc order would be:
// defguard, client, vpn, enrollment - not alphabetical.
for module in [
ActivityLogModule::Defguard,
ActivityLogModule::Client,
ActivityLogModule::Vpn,
ActivityLogModule::Enrollment,
] {
ActivityLogEvent {
id: NoId,
timestamp: truncate_timestamp_to_microseconds(shared_timestamp),
user_id: admin.id,
username: admin.username.clone(),
location: None,
ip: None,
event: EventType::UserLogout,
module,
device: "integration-test".to_owned(),
description: Some(marker.to_string()),
metadata: None,
}
.save(&db)
.await
.expect("activity log event should persist");
}

let payload = fetch_activity_log(&client, &marker, "sort_by=module&sort_order=asc").await;
let modules: Vec<String> = payload.data.into_iter().map(|event| event.module).collect();

assert_eq!(
modules,
vec!["client", "defguard", "enrollment", "vpn"],
"module sort should be alphabetical (text order), not enum declaration order",
);
}
2 changes: 1 addition & 1 deletion web/messages/en/activity.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
"activity_event_gateway_modified": "Gateway modified",
"activity_event_gateway_deleted": "Gateway deleted",
"activity_log_table_title": "Activity",
"activity_log_empty_title": "You don't have any logs.",
"activity_log_empty_title": "You don't have any events.",
"activity_log_empty_subtitle": "Activity logs will be displayed here once events occur.",
"activity_log_col_date": "Date",
"activity_log_col_user": "User",
Expand Down
71 changes: 66 additions & 5 deletions web/src/pages/ActivityLogPage/ActivityLogPage.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,78 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query';
import type { ColumnFiltersState, SortingState } from '@tanstack/react-table';
import { useMemo, useState } from 'react';
import { m } from '../../paraglide/messages';
import type {
ActivityLogEventTypeValue,
ActivityLogModuleValue,
} from '../../shared/api/activity-log-types';
import api from '../../shared/api/api';
import type { ActivityLogSortKey } from '../../shared/api/types';
import { Page } from '../../shared/components/Page/Page';
import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox';
import { ThemeSpacing } from '../../shared/defguard-ui/types';
import { isPresent } from '../../shared/defguard-ui/utils/isPresent';
import { TablePageLayout } from '../../shared/layout/TablePageLayout/TablePageLayout';
import { getLocationsQueryOptions } from '../../shared/query';
import { ActivityLogTable } from './ActivityLogTable';

const mapColumnFiltersToApiParams = (
columnFilters: ColumnFiltersState,
): Record<string, string[]> => {
const result: Record<string, string[]> = {};
for (const filter of columnFilters) {
if (Array.isArray(filter.value) && filter.value.length > 0) {
result[filter.id] = filter.value.filter(
(value): value is string => typeof value === 'string',
);
}
}
return result;
};

export const ActivityLogPage = () => {
const [search, setSearch] = useState('');
const [sortingState, setSortingState] = useState<SortingState>([
{ id: 'timestamp', desc: true },
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);

const { data: locations } = useQuery(getLocationsQueryOptions);
const locationFilterOptions = useMemo(
() =>
locations?.map((loc) => ({
id: loc.name,
label: loc.name,
searchFields: [loc.name],
})) ?? [],
[locations],
);

const filterParams = useMemo(
() => mapColumnFiltersToApiParams(columnFilters),
[columnFilters],
);

const eventFilter = filterParams.event as ActivityLogEventTypeValue[] | undefined;
const moduleFilter = filterParams.module as ActivityLogModuleValue[] | undefined;
const locationFilter = filterParams.location as string[] | undefined;

const activeSorting = sortingState[0];

const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['activity-log'],
queryKey: ['activity-log', { search, sortingState, columnFilters }],
initialPageParam: 1,
queryFn: ({ pageParam }) =>
api.getActivityLog({
page: pageParam,
search: search.length > 0 ? search : undefined,
sort_by: activeSorting?.id as ActivityLogSortKey,
sort_order: activeSorting ? (activeSorting.desc ? 'desc' : 'asc') : undefined,
event: eventFilter,
module: moduleFilter,
location: locationFilter,
}),
placeholderData: keepPreviousData,
getNextPageParam: (lastPage) => lastPage?.pagination.next_page,
getPreviousPageParam: (page) => {
if (page.pagination.current_page !== 1) {
Expand All @@ -42,13 +98,18 @@ export const ActivityLogPage = () => {
{isPresent(flatData) && isPresent(pagination) && (
<ActivityLogTable
data={flatData}
pagination={pagination}
filters={{}}
loadingNextPage={isFetchingNextPage}
onNextPage={() => {
fetchNextPage();
}}
hasNextPage={pagination.next_page !== null}
search={search}
onSearchChange={setSearch}
sortingState={sortingState}
onSortingChange={setSortingState}
columnFilters={columnFilters}
onColumnFiltersChange={setColumnFilters}
locationFilterOptions={locationFilterOptions}
/>
)}
</TablePageLayout>
Expand Down
Loading
Loading