feat: add client-side Level/Event filters to Log Entries

The backend rejects `level`/`event` as x:Log/query filter conditions
(unsupportedFilter), even though both are already returned per row.
Since a real fix requires backend changes outside this repo, the two
filters are injected into the schema client-side (clientOnly flag) and
applied entirely in the browser: excluded from the JMAP filter sent to
the server, and used to narrow an eagerly-fetched, locally-paginated
result set instead.

Also makes large enum filters (Event has 634 values) render as a
searchable Combobox instead of a plain Select, generically for any
list with more than 15 enum options.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Steven RYDELL
2026-07-30 16:31:36 +02:00
co-authored by Claude Sonnet 5
parent da2c99990f
commit 21b183327c
5 changed files with 159 additions and 17 deletions
+110 -10
View File
@@ -25,6 +25,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import { Combobox } from '@/components/ui/combobox';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { formatSize as fmtSize, formatDuration as fmtDuration } from '@/lib/durationFormat';
@@ -60,7 +61,14 @@ import { useAuthStore } from '@/stores/authStore';
import { useAccountStore } from '@/stores/accountStore';
import { useCacheStore } from '@/stores/cacheStore';
import { resolveObject, resolveSchema, resolveList, getDisplayProperty } from '@/lib/schemaResolver';
import { jmapGetBatched, jmapQueryAll, jmapQueryAndGet, jmapQueryAllAndGet, jmapSet, getAccountId } from '@/services/jmap/client';
import {
jmapGetBatched,
jmapQueryAll,
jmapQueryAndGet,
jmapQueryAllAndGet,
jmapSet,
getAccountId,
} from '@/services/jmap/client';
import type { Schema, Field, MassAction, ItemAction, Filter as FilterDef } from '@/types/schema';
import type { JmapSetResponse, JmapSetError } from '@/types/jmap';
@@ -68,6 +76,13 @@ import type { ResolvedSchema } from '@/lib/schemaResolver';
const PAGE_SIZE = 25;
const MAX_REPORTED_ERRORS = 3;
// Combobox threshold: plain <Select> is fine for a handful of options, but
// unusable (no search) once an enum has dozens of entries.
const ENUM_COMBOBOX_THRESHOLD = 15;
function isClientOnlyFilter(f: FilterDef): boolean {
return f.type === 'enum' && f.clientOnly === true;
}
function parseSetResponse(raw: [string, Record<string, unknown>, string][]): JmapSetResponse | null {
const entry = raw.find(([name]) => name.endsWith('/set'));
@@ -424,6 +439,24 @@ export function DynamicList({ viewName }: DynamicListProps) {
const [sort, setSort] = useState<SortState | null>(readUrlSort);
// Filters marked `clientOnly` (currently Level/Event on the Logs list) are
// not supported by the server's query engine, so they narrow an
// already-fetched result set in the browser instead of being sent as a
// JMAP filter. That switches pagination to a client-held array.
const clientFilterDefs = useMemo(
() => (resolved?.list?.filters ?? []).filter(isClientOnlyFilter),
[resolved?.list?.filters],
);
const activeClientFilters = useMemo(
() =>
clientFilterDefs
.map((f) => ({ field: f.field, value: appliedFilters[f.field] ?? '' }))
.filter((f) => f.value !== ''),
[clientFilterDefs, appliedFilters],
);
const [clientAllItems, setClientAllItems] = useState<Record<string, unknown>[] | null>(null);
const [clientPage, setClientPage] = useState(0);
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [activeWebApp, setActiveWebApp] = useState<Record<string, unknown> | null>(null);
@@ -461,6 +494,8 @@ export function DynamicList({ viewName }: DynamicListProps) {
setSelectedIds(new Set());
setSelectAllMode(false);
setError(null);
setClientAllItems(null);
setClientPage(0);
const initialFilters = readUrlFilters();
setFilterValues(initialFilters);
@@ -470,13 +505,20 @@ export function DynamicList({ viewName }: DynamicListProps) {
});
const buildFilter = useCallback((): Record<string, unknown> => {
const clientFields = new Set(clientFilterDefs.map((f) => f.field));
const serverAppliedFilters: Record<string, string> = {};
for (const [key, val] of Object.entries(appliedFilters)) {
const baseKey = key.endsWith('Op') ? key.slice(0, -2) : key;
if (clientFields.has(baseKey)) continue;
serverAppliedFilters[key] = val;
}
return buildJmapFilter({
appliedFilters,
appliedFilters: serverAppliedFilters,
filters: resolved?.list?.filters,
filtersStatic: resolved?.list?.filtersStatic,
isXPrefixed: objectName?.startsWith('x:') ?? false,
});
}, [appliedFilters, resolved?.list, objectName]);
}, [appliedFilters, resolved?.list, objectName, clientFilterDefs]);
const buildSort = useCallback((): Record<string, unknown>[] | undefined => {
if (!sort) return undefined;
@@ -500,6 +542,28 @@ export function DynamicList({ viewName }: DynamicListProps) {
const filter = buildFilter();
const sortArr = buildSort();
if (activeClientFilters.length > 0) {
// No server-side pagination possible once a client-only filter is
// active: fetch every server-matching row up front, narrow it in
// the browser, then paginate the in-memory result locally.
const { list: fullList } = await jmapQueryAllAndGet(
obj.objectName,
accountId,
{ filter: Object.keys(filter).length > 0 ? filter : undefined, sort: sortArr },
properties,
);
const matched = fullList.filter((item) =>
activeClientFilters.every((f) => String(item[f.field] ?? '') === f.value),
);
setClientAllItems(matched);
setClientPage(0);
setTotal(matched.length);
setItems(matched.slice(0, PAGE_SIZE));
setSelectedIds(new Set());
return;
}
setClientAllItems(null);
const queryOptions: Record<string, unknown> = {
filter: Object.keys(filter).length > 0 ? filter : undefined,
sort: sortArr,
@@ -546,7 +610,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
setLoading(false);
}
},
[resolved, schema, buildFilter, buildSort],
[resolved, schema, buildFilter, buildSort, isWebApplications, activeClientFilters],
);
useEffect(() => {
@@ -637,6 +701,13 @@ export function DynamicList({ viewName }: DynamicListProps) {
}, [filterValues, sort]);
const handleNextPage = useCallback(() => {
if (clientAllItems !== null) {
const nextPage = clientPage + 1;
setClientPage(nextPage);
setItems(clientAllItems.slice(nextPage * PAGE_SIZE, nextPage * PAGE_SIZE + PAGE_SIZE));
return;
}
if (items.length === 0) return;
const lastItem = items[items.length - 1];
const lastId = lastItem?.id as string;
@@ -648,9 +719,16 @@ export function DynamicList({ viewName }: DynamicListProps) {
}
setCurrentAnchor(lastId);
fetchData(lastId, 1);
}, [items, fetchData]);
}, [items, fetchData, clientAllItems, clientPage]);
const handlePrevPage = useCallback(() => {
if (clientAllItems !== null) {
const prevPage = Math.max(0, clientPage - 1);
setClientPage(prevPage);
setItems(clientAllItems.slice(prevPage * PAGE_SIZE, prevPage * PAGE_SIZE + PAGE_SIZE));
return;
}
if (anchorStack.length === 0) {
return;
}
@@ -666,7 +744,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
setCurrentAnchor(prevFirstId);
fetchData(prevFirstId, 0);
}
}, [anchorStack, fetchData]);
}, [anchorStack, fetchData, clientAllItems, clientPage]);
const toggleSelectAll = useCallback(() => {
if (selectedIds.size === items.length) {
@@ -730,12 +808,18 @@ export function DynamicList({ viewName }: DynamicListProps) {
let targetIds: string[];
if (selectAllMode) {
if (clientAllItems !== null) {
// A client-only filter is active: the full server-matching set
// would include rows it excludes, so use the already-narrowed list.
targetIds = clientAllItems.map((item) => item.id as string);
} else {
const filter = buildFilter();
const sortArr = buildSort();
targetIds = await jmapQueryAll(obj.objectName, accountId, {
filter: Object.keys(filter).length > 0 ? filter : undefined,
sort: sortArr,
});
}
} else {
targetIds = Array.from(selectedIds);
}
@@ -790,7 +874,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
setLoading(false);
}
},
[resolved, selectedIds, selectAllMode, buildFilter, buildSort, fetchData, currentAnchor, t],
[resolved, selectedIds, selectAllMode, buildFilter, buildSort, fetchData, currentAnchor, t, clientAllItems],
);
const executeItemAction = useCallback(
@@ -916,11 +1000,11 @@ export function DynamicList({ viewName }: DynamicListProps) {
const hasMassActions = effectiveMassActions.length > 0;
const hasItemActions = (list.itemActions?.length ?? 0) > 0;
const pageStart = anchorStack.length * PAGE_SIZE;
const pageStart = clientAllItems !== null ? clientPage * PAGE_SIZE : anchorStack.length * PAGE_SIZE;
const rangeStart = pageStart + 1;
const rangeEnd = pageStart + items.length;
const hasNextPage = total !== null && rangeEnd < total;
const hasPrevPage = anchorStack.length > 0;
const hasNextPage = clientAllItems !== null ? rangeEnd < clientAllItems.length : total !== null && rangeEnd < total;
const hasPrevPage = clientAllItems !== null ? clientPage > 0 : anchorStack.length > 0;
function renderFilter(filterDef: FilterDef): React.ReactNode {
const value = filterValues[filterDef.field] ?? '';
@@ -945,6 +1029,22 @@ export function DynamicList({ viewName }: DynamicListProps) {
case 'enum': {
const enumVariants = schema!.enums[filterDef.enumName] ?? [];
if (enumVariants.length > ENUM_COMBOBOX_THRESHOLD) {
return wrapper(
<Combobox
options={enumVariants.map((v) => ({ value: v.name, label: v.label }))}
value={value}
onValueChange={(v) => handleFilterChange(filterDef.field, v)}
placeholder={filterDef.label}
searchPlaceholder={t('list.comboboxSearchPlaceholder', 'Search...')}
emptyText={t('list.comboboxEmptyText', 'No matches.')}
nullable
nullLabel={t('filters.all', 'All')}
/>,
);
}
return wrapper(
<Select value={value || '__all__'} onValueChange={(v) => handleFilterSelectChange(filterDef.field, v)}>
<SelectTrigger>
+2
View File
@@ -289,6 +289,8 @@
"bulkSuccessUpdate_one": "{{count}} item updated successfully.",
"bulkSuccessUpdate_other": "{{count}} items updated successfully.",
"clearSelection": "Clear selection",
"comboboxEmptyText": "No matches.",
"comboboxSearchPlaceholder": "Search...",
"confirmDescription": "Are you sure you want to proceed with: {{action}}?",
"confirmTitle": "Confirm Action",
"create": "Create {{name}}",
+35
View File
@@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import type { Schema } from '@/types/schema';
/**
* The Stalwart JMAP backend rejects `level`/`event` as filter conditions on
* `x:Log/query` (`unsupportedFilter`), even though both properties are
* already returned per row. Until the backend adds real support, these two
* filters are appended client-side and applied entirely in the browser
* (see the `clientOnly` flag consumed by DynamicList) instead of being sent
* to the server.
*/
export function withClientLogFilters(schema: Schema): Schema {
const logList = schema.lists['x:Log'];
if (!logList || !schema.enums['TracingLevel'] || !schema.enums['EventType']) return schema;
return {
...schema,
lists: {
...schema.lists,
'x:Log': {
...logList,
filters: [
...(logList.filters ?? []),
{ type: 'enum', field: 'level', enumName: 'TracingLevel', label: 'Level', clientOnly: true },
{ type: 'enum', field: 'event', enumName: 'EventType', label: 'Event', clientOnly: true },
],
},
},
};
}
+2 -1
View File
@@ -12,6 +12,7 @@ import { useSchemaStore } from '@/stores/schemaStore';
import { useAccountStore } from '@/stores/accountStore';
import { useUIStore } from '@/stores/uiStore';
import { fetchSession, fetchSchema, fetchAccountInfo } from '@/services/jmap/client';
import { withClientLogFilters } from '@/lib/logFilters';
import { setLocale } from '@/i18n';
import { TopBar } from '@/components/layout/TopBar';
import { Sidebar } from '@/components/layout/Sidebar';
@@ -147,7 +148,7 @@ export default function AdminPanel() {
if (cancelled) return;
setSchema(schemaData);
setSchema(withClientLogFilters(schemaData));
setAccountInfo(accountData.permissions, accountData.edition, accountData.locale);
setLocale(accountData.locale);
+4
View File
@@ -303,6 +303,10 @@ export interface FilterEnum {
field: string;
enumName: string;
label: string;
/** Applied client-side after fetch instead of sent to the server as a JMAP
* filter condition. Used for properties the backend query engine doesn't
* (yet) support filtering on, even though it returns them per row. */
clientOnly?: boolean;
}
export interface FilterInteger {