mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat(filters): dynamic filter framework — schema-driven filters with URL state (#48)
* feat(filters): dynamic filter framework — typed schema, URL state, FilterBar Port of dflow's filter.utils pattern, adapted to Containr: - lib/dynamic-filter.ts: FilterConfig schema (search/select/multiselect/ boolean/date-range/status), FilterEngine with client-side apply, derived options with counts, active-filter tracking, removal. - lib/use-dynamic-filter.ts: filter state lives in ?f.<key> search params — filtered views are shareable links. useFilterState exposes URL state before data loads (server-backed filters); useDynamicFilter adds the engine over fetched data. - shared/components/FilterBar.tsx: renders a schema as compact controls + active-filter chips, matching design tokens. - Applied: audit logs (server-backed — state in URL, params to API), builds (server-backed + client date-range), activity (severity to the server query, category/search on the fetched page). - 8 unit tests for the engine. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(filters): erasableSyntaxOnly compat + BuildStatus cast - Constructor parameter properties are banned under erasableSyntaxOnly — declare fields explicitly. - listBuilds status param needs the BuildStatus type, not raw string. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
8cfe2ca826
commit
d4a4cde1fd
10 files changed
+639
-150
No files matched your search
@@ -2,8 +2,10 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { listActivity, type ActivityEntry } from '@/lib/api-client';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { type FilterConfig } from '@/lib/dynamic-filter';
|
||||
import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter';
|
||||
import { formatRelative } from '@/lib/time';
|
||||
import { DemoRestricted } from '@/shared/components';
|
||||
import { DemoRestricted, FilterBar } from '@/shared/components';
|
||||
import {
|
||||
Activity, AlertCircle, CheckCircle2, Database, GitBranch, Info, Key,
|
||||
Loader2, Package, Projector, Rocket, Shield, User, Users, Wrench,
|
||||
@@ -22,23 +24,37 @@ const SEVERITY_STYLE: Record<string, { icon: typeof Info; cls: string; dot: stri
|
||||
info: { icon: Info, cls: 'text-[var(--accent-primary)]', dot: 'bg-[var(--accent-primary)]' },
|
||||
};
|
||||
|
||||
const SEVERITIES = ['', 'error', 'warning', 'success', 'info'] as const;
|
||||
const SCHEMA: FilterConfig<ActivityEntry>[] = [
|
||||
{ key: 'q', label: 'Search', type: 'search', searchFields: ['label', 'action', 'user_email'] },
|
||||
{
|
||||
key: 'severity', label: 'Severity', type: 'select',
|
||||
options: [
|
||||
{ value: 'error', label: 'Error' },
|
||||
{ value: 'warning', label: 'Warning' },
|
||||
{ value: 'success', label: 'Success' },
|
||||
{ value: 'info', label: 'Info' },
|
||||
],
|
||||
},
|
||||
{ key: 'category', label: 'Category', type: 'select' },
|
||||
];
|
||||
|
||||
export function ActivityPage() {
|
||||
const isDemoMode = useDemoMode();
|
||||
const [severity, setSeverity] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const { filters } = useFilterState(SCHEMA);
|
||||
const severityParam = filters.severity === 'all' ? '' : String(filters.severity ?? '');
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['activity-feed', severity, page],
|
||||
queryFn: () => listActivity({ severity: severity || undefined, page, limit: 50 }),
|
||||
queryKey: ['activity-feed', severityParam, page],
|
||||
queryFn: () => listActivity({ severity: severityParam || undefined, page, limit: 50 }),
|
||||
enabled: !isDemoMode,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
const filter = useDynamicFilter<ActivityEntry>({ data: query.data?.activity ?? [], schema: SCHEMA });
|
||||
|
||||
if (isDemoMode) return <DemoRestricted feature="Activity" />;
|
||||
|
||||
const entries = query.data?.activity ?? [];
|
||||
const entries = filter.filteredData;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
@@ -52,21 +68,11 @@ export function ActivityPage() {
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-6 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{SEVERITIES.map((s) => (
|
||||
<button
|
||||
key={s || 'all'}
|
||||
onClick={() => { setSeverity(s); setPage(1); }}
|
||||
className={`v-mono text-[10px] uppercase px-3 py-1.5 rounded-md border transition-colors ${
|
||||
severity === s
|
||||
? 'border-[var(--accent-primary)] text-[var(--accent-primary)] bg-[var(--accent-primary)]/10'
|
||||
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
{s || 'all'}
|
||||
</button>
|
||||
))}
|
||||
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)]" />}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
<FilterBar {...filter} />
|
||||
</div>
|
||||
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)] mt-3" />}
|
||||
</div>
|
||||
|
||||
<div className="panel p-0 divide-y divide-[var(--border-subtle)]">
|
||||
|
||||
@@ -2,8 +2,10 @@ import { useDeferredValue, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { listAuditLogs } from '@/lib/api-client';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { type FilterConfig } from '@/lib/dynamic-filter';
|
||||
import { useDynamicFilter } from '@/lib/use-dynamic-filter';
|
||||
import { formatDate, formatRelative } from '@/lib/time';
|
||||
import { DemoRestricted } from '@/shared/components';
|
||||
import { DemoRestricted, FilterBar } from '@/shared/components';
|
||||
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
@@ -15,20 +17,34 @@ const rangeOptions = [
|
||||
{ value: '30d', label: 'Last 30 days', ms: 30 * 24 * 60 * 60 * 1000 },
|
||||
] as const;
|
||||
|
||||
const inputClass =
|
||||
'h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all';
|
||||
// Server-backed filters — the engine runs over empty data; values map to
|
||||
// API params. URL state makes filtered views shareable.
|
||||
const AUDIT_SCHEMA: FilterConfig<unknown>[] = [
|
||||
{ key: 'resource', label: 'Resource', type: 'search' },
|
||||
{ key: 'action', label: 'Action', type: 'search' },
|
||||
{ key: 'actor', label: 'Actor', type: 'search' },
|
||||
{
|
||||
key: 'range', label: 'Time', type: 'select',
|
||||
options: rangeOptions.filter((o) => o.value !== '').map((o) => ({ value: o.value, label: o.label })),
|
||||
},
|
||||
];
|
||||
|
||||
export function AuditLogsPage() {
|
||||
const isDemoMode = useDemoMode();
|
||||
const [resource, setResource] = useState('');
|
||||
const [action, setAction] = useState('');
|
||||
const [actor, setActor] = useState('');
|
||||
const [range, setRange] = useState('');
|
||||
const filter = useDynamicFilter<unknown>({ data: [], schema: AUDIT_SCHEMA });
|
||||
const [page, setPage] = useState(1);
|
||||
// Back to page 1 whenever the filter set changes.
|
||||
const filtersJson = JSON.stringify(filter.filters);
|
||||
const [prevFiltersJson, setPrevFiltersJson] = useState(filtersJson);
|
||||
if (prevFiltersJson !== filtersJson) {
|
||||
setPrevFiltersJson(filtersJson);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
const deferredResource = useDeferredValue(resource);
|
||||
const deferredAction = useDeferredValue(action);
|
||||
const deferredActor = useDeferredValue(actor);
|
||||
const deferredResource = useDeferredValue(String(filter.filters.resource ?? ''));
|
||||
const deferredAction = useDeferredValue(String(filter.filters.action ?? ''));
|
||||
const deferredActor = useDeferredValue(String(filter.filters.actor ?? ''));
|
||||
const range = filter.filters.range === 'all' ? '' : String(filter.filters.range ?? '');
|
||||
|
||||
const filters = {
|
||||
resource: deferredResource.trim() || undefined,
|
||||
@@ -54,15 +70,7 @@ export function AuditLogsPage() {
|
||||
|
||||
const logs = logsQuery.data ?? [];
|
||||
const hasNext = logs.length === PAGE_SIZE;
|
||||
const hasFilters = Boolean(resource || action || actor || range);
|
||||
|
||||
const resetFilters = () => {
|
||||
setResource('');
|
||||
setAction('');
|
||||
setActor('');
|
||||
setRange('');
|
||||
setPage(1);
|
||||
};
|
||||
const hasFilters = filter.activeCount > 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
@@ -79,63 +87,7 @@ export function AuditLogsPage() {
|
||||
|
||||
<div className="w-full px-8 py-6">
|
||||
<section className="panel p-6">
|
||||
<div className="flex flex-wrap items-end gap-3 mb-5">
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
Resource
|
||||
</label>
|
||||
<input
|
||||
value={resource}
|
||||
onChange={(e) => { setResource(e.target.value); setPage(1); }}
|
||||
placeholder="service, project…"
|
||||
className={`${inputClass} w-44`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
Action
|
||||
</label>
|
||||
<input
|
||||
value={action}
|
||||
onChange={(e) => { setAction(e.target.value); setPage(1); }}
|
||||
placeholder="create, deploy…"
|
||||
className={`${inputClass} w-44`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
Actor
|
||||
</label>
|
||||
<input
|
||||
value={actor}
|
||||
onChange={(e) => { setActor(e.target.value); setPage(1); }}
|
||||
placeholder="user email"
|
||||
className={`${inputClass} w-52`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
Time
|
||||
</label>
|
||||
<select
|
||||
value={range}
|
||||
onChange={(e) => { setRange(e.target.value); setPage(1); }}
|
||||
className={`${inputClass} w-40`}
|
||||
>
|
||||
{rangeOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<FilterBar {...filter} />
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
} from '@/lib/api-client';
|
||||
import { useBuildUpdates } from '@/lib/use-build-updates';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { type FilterConfig } from '@/lib/dynamic-filter';
|
||||
import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter';
|
||||
import { FilterBar } from '@/shared/components';
|
||||
import { demoBuilds } from '@/lib/demo-data';
|
||||
import { formatRelative } from '@/lib/time';
|
||||
import {
|
||||
@@ -34,6 +37,19 @@ const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
];
|
||||
|
||||
const BUILD_SCHEMA: FilterConfig<BuildEntity>[] = [
|
||||
{
|
||||
key: 'status', label: 'Status', type: 'select',
|
||||
options: statusOptions.filter((o) => o.value !== '').map((o) => ({ value: o.value, label: o.label })),
|
||||
},
|
||||
{ key: 'project', label: 'Project ID', type: 'search', searchFields: ['projectId'] },
|
||||
{ key: 'service', label: 'Service ID', type: 'search', searchFields: ['serviceId'] },
|
||||
{
|
||||
key: 'created', label: 'Time', type: 'date-range',
|
||||
accessor: (b) => b.startedAt ?? b.completedAt,
|
||||
},
|
||||
];
|
||||
|
||||
function StatusBadge({ status }: { status: BuildStatus }) {
|
||||
const config = {
|
||||
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
|
||||
@@ -72,9 +88,10 @@ export function BuildsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const isDemoMode = useDemoMode();
|
||||
|
||||
const [projectFilter, setProjectFilter] = useState('');
|
||||
const [serviceFilter, setServiceFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'' | BuildStatus>('');
|
||||
const { filters } = useFilterState(BUILD_SCHEMA);
|
||||
const projectFilter = String(filters.project ?? '');
|
||||
const serviceFilter = String(filters.service ?? '');
|
||||
const statusFilter = filters.status === 'all' ? '' : String(filters.status ?? '');
|
||||
const [limit, setLimit] = useState(50);
|
||||
const [selectedBuild, setSelectedBuild] = useState<BuildEntity | null>(null);
|
||||
|
||||
@@ -85,20 +102,22 @@ export function BuildsPage() {
|
||||
listBuilds({
|
||||
projectId: projectFilter || undefined,
|
||||
serviceId: serviceFilter || undefined,
|
||||
status: statusFilter || undefined,
|
||||
status: (statusFilter || undefined) as BuildStatus | undefined,
|
||||
page: 1,
|
||||
limit,
|
||||
}),
|
||||
});
|
||||
|
||||
const builds = useMemo(
|
||||
const rawBuilds = useMemo(
|
||||
() => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []),
|
||||
[isDemoMode, buildsQuery.data?.builds],
|
||||
);
|
||||
const filter = useDynamicFilter<BuildEntity>({ data: rawBuilds, schema: BUILD_SCHEMA });
|
||||
const builds = filter.filteredData;
|
||||
|
||||
const subscribedBuildIds = useMemo(
|
||||
() => (isDemoMode ? [] : builds.map((build) => build.id)),
|
||||
[isDemoMode, builds],
|
||||
() => (isDemoMode ? [] : rawBuilds.map((build) => build.id)),
|
||||
[isDemoMode, rawBuilds],
|
||||
);
|
||||
const liveStatus = useBuildUpdates(subscribedBuildIds, ({ channel }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['builds-page'] });
|
||||
@@ -217,46 +236,13 @@ export function BuildsPage() {
|
||||
{/* Filters */}
|
||||
<div className="w-full px-8">
|
||||
<div className="panel p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={16} className="text-[var(--text-tertiary)]" />
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Filters</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as '' | BuildStatus)}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
|
||||
>
|
||||
{statusOptions.map((option) => (
|
||||
<option key={option.label} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Project ID
|
||||
</label>
|
||||
<input
|
||||
value={projectFilter}
|
||||
onChange={(e) => setProjectFilter(e.target.value)}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
|
||||
placeholder="project-123"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Service ID
|
||||
</label>
|
||||
<input
|
||||
value={serviceFilter}
|
||||
onChange={(e) => setServiceFilter(e.target.value)}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
|
||||
placeholder="service-abc"
|
||||
/>
|
||||
<div className="flex flex-wrap items-end gap-3 mt-3">
|
||||
<div className="flex-1">
|
||||
<FilterBar {...filter} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
|
||||
Reference in new issue
Block a user