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" - /> +
+
+