From d4a4cde1fd1202e489b286a6c002ee5838a6c4fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:12:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(filters):=20dynamic=20filter=20framework?= =?UTF-8?q?=20=E2=80=94=20schema-driven=20filters=20with=20URL=20state=20(?= =?UTF-8?q?#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. 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> --- ROADMAP2.md | 21 +- .../features/activity/pages/ActivityPage.tsx | 48 ++-- .../features/audit/pages/AuditLogsPage.tsx | 104 +++------ .../src/features/builds/pages/BuildsPage.tsx | 74 +++---- app/frontend/src/generated/docs-snapshot.ts | 2 +- app/frontend/src/lib/dynamic-filter.test.ts | 80 +++++++ app/frontend/src/lib/dynamic-filter.ts | 208 ++++++++++++++++++ app/frontend/src/lib/use-dynamic-filter.ts | 104 +++++++++ .../src/shared/components/FilterBar.tsx | 147 +++++++++++++ app/frontend/src/shared/components/index.ts | 1 + 10 files changed, 639 insertions(+), 150 deletions(-) create mode 100644 app/frontend/src/lib/dynamic-filter.test.ts create mode 100644 app/frontend/src/lib/dynamic-filter.ts create mode 100644 app/frontend/src/lib/use-dynamic-filter.ts create mode 100644 app/frontend/src/shared/components/FilterBar.tsx diff --git a/ROADMAP2.md b/ROADMAP2.md index 0aca1b1..3f46297 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -91,10 +91,10 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable | White-label (logo/favicon/title/OG/theme colors) | ❌ | `app_settings.branding` — **Containr remains the default brand** | | Activity feed (icon/severity/category/metadata, 90-day TTL job) | ✅ `/activity` + project view | — | | Admin metrics dashboard (users/servers/queued/failed counts) | 🟡 `/admin` exists | Extend | -| **Impersonate user** | ❌ | Admin support tool | +| **Impersonate user** | ✅ admin token + `impersonated_by` claim (browser swap pending) | Done (CLI/API/MCP) | | In-app docs (content-collections markdown) | ✅ `/docs` | — | -| Dynamic client-side filter framework (535-line `filter.utils`) | 🟡 audit page filters only | Reuse pattern for tables | -| Ops "bubble" (queue monitor, terminal, sync) | ❌ | Operations page | +| Dynamic client-side filter framework (535-line `filter.utils`) | ✅ `dynamic-filter.ts` + URL state; audit/builds/activity | Done | +| Ops "bubble" (queue monitor, terminal, sync) | ✅ `/operations` + interactive terminal | Done | #### Agent surface | dflow capability | Containr status | Action | @@ -103,8 +103,8 @@ Legend: ✅ done · 🟡 partial/different · ❌ missing · ➖ not applicable | CLI | ❌ | none in dflow — **Containr CLI is a genuine leap past it** | | MCP | ❌ | none — same leap | | Railway migrator script | ❌ | `containr migrate railway` | -| GitHub App **manifest flow** (instance self-provisions the App) | 🟡 env-configured App | `POST /git/github-app/manifest` flow | -| fork-sync webhook deploy trigger | ❌ | ~20 lines on receiver | +| GitHub App **manifest flow** (instance self-provisions the App) | ✅ manifest + convert + App webhook | Done | +| fork-sync webhook deploy trigger | ✅ sync/repository_dispatch/workflow_run | Done | | Azure DevOps provider | ❌ | Optional (dflow does token-clone only) | --- @@ -339,9 +339,14 @@ and wired refs, one click or one CLI call. Also fixed a latent bug where the webhook's deployment insert omitted the NOT NULL `version` column — push-triggered deploys silently failed for every service. -- [ ] **Dynamic filter framework** — port dflow's `filter.utils` - pattern (typed filter configs → URL state → client filtering) for - audit logs, deployments, notifications tables. +- [x] **Dynamic filter framework** — ported dflow's `filter.utils` + pattern as `lib/dynamic-filter.ts` + `use-dynamic-filter.ts` + (`FilterConfig` schema → `FilterEngine` → `FilterBar`; state lives in + `?f.` search params so filtered views are shareable links). + `useFilterState` exposes URL state pre-fetch for server-backed + filters. Applied: audit logs (server-backed), builds (server + client + date-range), activity (severity server + client category/search). + Notifications table has no list surface yet — apply when one lands. - [ ] **Notification producers round-out** — backups, agent offline, upgrade available, security findings. - [x] **Sleep-on-idle / wake-on-traffic** (serverless-lite) — diff --git a/app/frontend/src/features/activity/pages/ActivityPage.tsx b/app/frontend/src/features/activity/pages/ActivityPage.tsx index 3076911..0606065 100644 --- a/app/frontend/src/features/activity/pages/ActivityPage.tsx +++ b/app/frontend/src/features/activity/pages/ActivityPage.tsx @@ -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[] = [ + { 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({ data: query.data?.activity ?? [], schema: SCHEMA }); if (isDemoMode) return ; - const entries = query.data?.activity ?? []; + const entries = filter.filteredData; return (
@@ -52,21 +68,11 @@ export function ActivityPage() {
-
- {SEVERITIES.map((s) => ( - - ))} - {query.isFetching && } +
+
+ +
+ {query.isFetching && }
diff --git a/app/frontend/src/features/audit/pages/AuditLogsPage.tsx b/app/frontend/src/features/audit/pages/AuditLogsPage.tsx index c3e3e62..e6c5781 100644 --- a/app/frontend/src/features/audit/pages/AuditLogsPage.tsx +++ b/app/frontend/src/features/audit/pages/AuditLogsPage.tsx @@ -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[] = [ + { 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({ 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 (
@@ -79,63 +87,7 @@ export function AuditLogsPage() {
-
-
- - { setResource(e.target.value); setPage(1); }} - placeholder="service, project…" - className={`${inputClass} w-44`} - /> -
-
- - { setAction(e.target.value); setPage(1); }} - placeholder="create, deploy…" - className={`${inputClass} w-44`} - /> -
-
- - { setActor(e.target.value); setPage(1); }} - placeholder="user email" - className={`${inputClass} w-52`} - /> -
-
- - -
- {hasFilters && ( - - )} -
+
diff --git a/app/frontend/src/features/builds/pages/BuildsPage.tsx b/app/frontend/src/features/builds/pages/BuildsPage.tsx index 6875bd0..8893721 100644 --- a/app/frontend/src/features/builds/pages/BuildsPage.tsx +++ b/app/frontend/src/features/builds/pages/BuildsPage.tsx @@ -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[] = [ + { + 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(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({ 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 */}
-
+
Filters
-
-
- - -
-
- - 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" - /> -
-
- - 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" - /> +
+
+