mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +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">
|
||||
|
||||
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
|
||||
"path": "guides/CLI.md",
|
||||
"section": "guides",
|
||||
"title": "Containr CLI",
|
||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create <name> <url>\n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nbanners list|creatLine truncated
|
||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create <name> <url>\n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nactivity
Line truncated
|
||||
},
|
||||
{
|
||||
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
emptyFilterState,
|
||||
FilterEngine,
|
||||
type FilterConfig,
|
||||
} from '@/lib/dynamic-filter';
|
||||
|
||||
interface Row {
|
||||
status: string;
|
||||
name: string;
|
||||
labels: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const ROWS: Row[] = [
|
||||
{ status: 'success', name: 'web-api', labels: ['prod'], createdAt: new Date().toISOString() },
|
||||
{ status: 'failed', name: 'worker', labels: [], createdAt: '2020-01-01T00:00:00Z' },
|
||||
{ status: 'running', name: 'web-admin', labels: ['prod', 'canary'], createdAt: '2020-06-01T00:00:00Z' },
|
||||
];
|
||||
|
||||
const SCHEMA: FilterConfig<Row>[] = [
|
||||
{ key: 'q', label: 'Search', type: 'search', searchFields: ['name'] },
|
||||
{ key: 'status', label: 'Status', type: 'select' },
|
||||
{ key: 'labels', label: 'Labels', type: 'multiselect' },
|
||||
{ key: 'createdAt', label: 'Time', type: 'date-range' },
|
||||
];
|
||||
|
||||
const f = (over: Partial<Record<string, unknown>>) => ({
|
||||
...emptyFilterState(SCHEMA),
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('FilterEngine', () => {
|
||||
it('passes everything on an empty filter state', () => {
|
||||
expect(new FilterEngine(ROWS, SCHEMA).apply(f({}))).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('searches across configured fields case-insensitively', () => {
|
||||
const engine = new FilterEngine(ROWS, SCHEMA);
|
||||
expect(engine.apply(f({ q: 'WEB' })).map((r) => r.name)).toEqual(['web-api', 'web-admin']);
|
||||
expect(engine.apply(f({ q: 'worker' }))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('select matches exactly and "all" is a no-op', () => {
|
||||
const engine = new FilterEngine(ROWS, SCHEMA);
|
||||
expect(engine.apply(f({ status: 'failed' }))).toHaveLength(1);
|
||||
expect(engine.apply(f({ status: 'all' }))).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('multiselect intersects array values', () => {
|
||||
const engine = new FilterEngine(ROWS, SCHEMA);
|
||||
expect(engine.apply(f({ labels: ['canary'] })).map((r) => r.name)).toEqual(['web-admin']);
|
||||
});
|
||||
|
||||
it('date-range bounds by preset', () => {
|
||||
const engine = new FilterEngine(ROWS, SCHEMA);
|
||||
expect(engine.apply(f({ createdAt: 'today' })).map((r) => r.name)).toEqual(['web-api']);
|
||||
expect(engine.apply(f({ createdAt: 'all' }))).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('combines filters conjunctively', () => {
|
||||
const engine = new FilterEngine(ROWS, SCHEMA);
|
||||
expect(engine.apply(f({ q: 'web', status: 'running' })).map((r) => r.name)).toEqual(['web-admin']);
|
||||
});
|
||||
|
||||
it('derives options with counts from data', () => {
|
||||
const opts = new FilterEngine(ROWS, SCHEMA).options().find((c) => c.key === 'status');
|
||||
expect(opts?.options?.find((o) => o.value === 'success')?.count).toBe(1);
|
||||
expect(opts?.options?.map((o) => o.value).sort()).toEqual(['failed', 'running', 'success']);
|
||||
});
|
||||
|
||||
it('counts active filters and lists them with raw + display values', () => {
|
||||
const engine = new FilterEngine(ROWS, SCHEMA);
|
||||
const state = f({ status: 'failed', q: 'w' });
|
||||
expect(engine.activeCount(state)).toBe(2);
|
||||
const active = engine.active(state);
|
||||
expect(active.find((a) => a.key === 'status')).toMatchObject({ value: 'failed', displayValue: 'Failed' });
|
||||
expect(active.find((a) => a.key === 'q')).toMatchObject({ displayValue: 'w' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
// Dynamic filter framework — typed filter configs → client-side filtering
|
||||
// with optional URL-state sync. Port of dflow's filter.utils pattern,
|
||||
// adapted to Containr (no external date lib, searchParams-driven).
|
||||
//
|
||||
// A page declares a FilterConfig<T>[] schema, passes fetched data +
|
||||
// schema to useDynamicFilter(), and renders <FilterBar/>. Filter state
|
||||
// lives in the URL when `urlSync` is on, so filtered views are
|
||||
// shareable links.
|
||||
|
||||
export type FilterType =
|
||||
| 'search'
|
||||
| 'select'
|
||||
| 'multiselect'
|
||||
| 'boolean'
|
||||
| 'date-range'
|
||||
| 'status';
|
||||
|
||||
export interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface FilterConfig<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
type: FilterType;
|
||||
options?: FilterOption[];
|
||||
/** fields searched by type 'search' (defaults to [key]) */
|
||||
searchFields?: (keyof T & string)[];
|
||||
/** custom value accessor (defaults to item[key]) */
|
||||
accessor?: (item: T) => unknown;
|
||||
}
|
||||
|
||||
export type FilterState = Record<string, unknown>;
|
||||
|
||||
const EMPTY: Record<FilterType, unknown> = {
|
||||
search: '',
|
||||
select: 'all',
|
||||
multiselect: [],
|
||||
boolean: [],
|
||||
'date-range': 'all',
|
||||
status: [],
|
||||
};
|
||||
|
||||
export function emptyValueFor(type: FilterType): unknown {
|
||||
return structuredClone(EMPTY[type]);
|
||||
}
|
||||
|
||||
export function emptyFilterState<T>(schema: FilterConfig<T>[]): FilterState {
|
||||
const state: FilterState = {};
|
||||
for (const c of schema) state[c.key] = structuredClone(EMPTY[c.type]);
|
||||
return state;
|
||||
}
|
||||
|
||||
function accessorOf<T>(config: FilterConfig<T>): (item: T) => unknown {
|
||||
if (config.accessor) return config.accessor;
|
||||
return (item) => (item as Record<string, unknown>)[config.key];
|
||||
}
|
||||
|
||||
export class FilterEngine<T> {
|
||||
private data: T[];
|
||||
private schema: FilterConfig<T>[];
|
||||
|
||||
constructor(data: T[], schema: FilterConfig<T>[]) {
|
||||
this.data = data;
|
||||
this.schema = schema;
|
||||
}
|
||||
|
||||
/** Fill in option lists (with counts) for configs that didn't declare them. */
|
||||
options(): FilterConfig<T>[] {
|
||||
return this.schema.map((config) => {
|
||||
if (config.options?.length) return config;
|
||||
const counts = new Map<string, number>();
|
||||
for (const item of this.data) {
|
||||
const v = accessorOf(config)(item);
|
||||
for (const each of Array.isArray(v) ? v : [v]) {
|
||||
if (each != null && each !== '') {
|
||||
const key = String(each);
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
const options = [...counts.entries()]
|
||||
.map(([value, count]) => ({ value, label: formatLabel(value), count }))
|
||||
.sort((a, b) => b.count! - a.count!);
|
||||
return { ...config, options };
|
||||
});
|
||||
}
|
||||
|
||||
apply(filters: FilterState): T[] {
|
||||
return this.data.filter((item) =>
|
||||
this.schema.every((config) => {
|
||||
const value = filters[config.key];
|
||||
if (value == null || value === '' || value === 'all') return true;
|
||||
if (Array.isArray(value) && value.length === 0) return true;
|
||||
return this.evaluate(item, config, value);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private evaluate(item: T, config: FilterConfig<T>, value: unknown): boolean {
|
||||
const itemValue = accessorOf(config)(item);
|
||||
switch (config.type) {
|
||||
case 'search': {
|
||||
const term = String(value).toLowerCase();
|
||||
const fields = config.searchFields ?? [config.key as keyof T & string];
|
||||
return fields.some((f) =>
|
||||
String((item as Record<string, unknown>)[f] ?? '').toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
case 'select':
|
||||
return String(itemValue) === String(value);
|
||||
case 'multiselect':
|
||||
case 'status': {
|
||||
const values = value as string[];
|
||||
if (Array.isArray(itemValue)) return itemValue.some((v) => values.includes(String(v)));
|
||||
return values.includes(String(itemValue));
|
||||
}
|
||||
case 'boolean': {
|
||||
const values = value as string[];
|
||||
return values.includes(String(Boolean(itemValue)));
|
||||
}
|
||||
case 'date-range':
|
||||
return dateInRange(itemValue, String(value));
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
activeCount(filters: FilterState): number {
|
||||
let n = 0;
|
||||
for (const c of this.schema) {
|
||||
const v = filters[c.key];
|
||||
if (v == null || v === '' || v === 'all') continue;
|
||||
n += Array.isArray(v) ? v.length : 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
active(filters: FilterState): { key: string; label: string; value: string; displayValue: string }[] {
|
||||
const out: { key: string; label: string; value: string; displayValue: string }[] = [];
|
||||
const opts = this.options();
|
||||
for (const c of this.schema) {
|
||||
const v = filters[c.key];
|
||||
if (v == null || v === '' || v === 'all') continue;
|
||||
const optFor = (raw: unknown) => {
|
||||
if (c.type === 'search') return String(raw);
|
||||
if (c.type === 'date-range') {
|
||||
return DATE_RANGE_OPTIONS.find((o) => o.value === raw)?.label ?? String(raw);
|
||||
}
|
||||
return (
|
||||
opts.find((o) => o.key === c.key)?.options?.find((o) => o.value === raw)?.label ??
|
||||
formatLabel(String(raw))
|
||||
);
|
||||
};
|
||||
if (Array.isArray(v)) {
|
||||
for (const each of v) {
|
||||
out.push({ key: c.key, label: c.label, value: String(each), displayValue: optFor(each) });
|
||||
}
|
||||
} else {
|
||||
out.push({ key: c.key, label: c.label, value: String(v), displayValue: optFor(v) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
function formatLabel(value: string): string {
|
||||
if (value === 'true') return 'Yes';
|
||||
if (value === 'false') return 'No';
|
||||
return value.charAt(0).toUpperCase() + value.slice(1).replace(/[_-]/g, ' ');
|
||||
}
|
||||
|
||||
function dateInRange(itemValue: unknown, range: string): boolean {
|
||||
if (!itemValue || range === 'all') return true;
|
||||
const date = new Date(String(itemValue));
|
||||
if (Number.isNaN(date.getTime())) return false;
|
||||
const now = new Date();
|
||||
let boundary: Date;
|
||||
switch (range) {
|
||||
case 'today':
|
||||
boundary = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
break;
|
||||
case 'week': {
|
||||
const day = (now.getDay() + 6) % 7; // monday start
|
||||
boundary = new Date(now.getFullYear(), now.getMonth(), now.getDate() - day);
|
||||
break;
|
||||
}
|
||||
case 'month':
|
||||
boundary = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
break;
|
||||
case '30d':
|
||||
boundary = new Date(now.getTime() - 30 * 86400000);
|
||||
break;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
return date >= boundary;
|
||||
}
|
||||
|
||||
export const DATE_RANGE_OPTIONS: FilterOption[] = [
|
||||
{ value: 'all', label: 'All time' },
|
||||
{ value: 'today', label: 'Today' },
|
||||
{ value: 'week', label: 'This week' },
|
||||
{ value: 'month', label: 'This month' },
|
||||
{ value: '30d', label: 'Last 30 days' },
|
||||
];
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
emptyFilterState,
|
||||
emptyValueFor,
|
||||
FilterEngine,
|
||||
type FilterConfig,
|
||||
type FilterState,
|
||||
} from '@/lib/dynamic-filter';
|
||||
|
||||
// Filter state lives in `?f.<key>=...` search params — filtered views are
|
||||
// shareable links. Arrays join with commas.
|
||||
|
||||
function stateFromParams<T>(schema: FilterConfig<T>[], params: URLSearchParams): FilterState {
|
||||
const state = emptyFilterState(schema);
|
||||
for (const c of schema) {
|
||||
const raw = params.get(`f.${c.key}`);
|
||||
if (raw == null) continue;
|
||||
switch (c.type) {
|
||||
case 'multiselect':
|
||||
case 'boolean':
|
||||
case 'status':
|
||||
state[c.key] = raw.split(',').filter(Boolean);
|
||||
break;
|
||||
default:
|
||||
state[c.key] = raw;
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function stateToParams<T>(schema: FilterConfig<T>[], state: FilterState): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
const empty = emptyFilterState(schema);
|
||||
for (const c of schema) {
|
||||
const v = state[c.key];
|
||||
if (v == null || v === '' || v === 'all') continue;
|
||||
if (Array.isArray(v) && v.length === 0) continue;
|
||||
if (JSON.stringify(v) === JSON.stringify(empty[c.key])) continue;
|
||||
params.set(`f.${c.key}`, Array.isArray(v) ? v.join(',') : String(v));
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/** URL-backed filter state — usable before any data is fetched. */
|
||||
export function useFilterState<T>(schema: FilterConfig<T>[]) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const filters = useMemo(() => stateFromParams(schema, searchParams), [schema, searchParams]);
|
||||
|
||||
const setFilters = useCallback(
|
||||
(updater: (prev: FilterState) => FilterState) => {
|
||||
const next = updater(filters);
|
||||
setSearchParams((prev) => {
|
||||
const merged = new URLSearchParams(prev);
|
||||
for (const key of [...merged.keys()]) {
|
||||
if (key.startsWith('f.')) merged.delete(key);
|
||||
}
|
||||
stateToParams(schema, next).forEach((v, k) => merged.set(k, v));
|
||||
return merged;
|
||||
}, { replace: true });
|
||||
},
|
||||
[filters, schema, setSearchParams],
|
||||
);
|
||||
|
||||
const updateFilter = useCallback(
|
||||
(key: string, value: unknown) => setFilters((prev) => ({ ...prev, [key]: value })),
|
||||
[setFilters],
|
||||
);
|
||||
const clearAll = useCallback(
|
||||
() => setFilters(() => emptyFilterState(schema)),
|
||||
[setFilters, schema],
|
||||
);
|
||||
const removeFilter = useCallback(
|
||||
(key: string, rawValue?: string) =>
|
||||
setFilters((prev) => {
|
||||
const v = prev[key];
|
||||
if (Array.isArray(v) && rawValue !== undefined) {
|
||||
return { ...prev, [key]: v.filter((x) => String(x) !== rawValue) };
|
||||
}
|
||||
const config = schema.find((c) => c.key === key);
|
||||
return config ? { ...prev, [key]: emptyValueFor(config.type) } : prev;
|
||||
}),
|
||||
[setFilters, schema],
|
||||
);
|
||||
|
||||
return { filters, updateFilter, removeFilter, clearAll };
|
||||
}
|
||||
|
||||
/** Full client-side filter: URL state + engine over `data`. */
|
||||
export function useDynamicFilter<T>({ data, schema }: { data: T[]; schema: FilterConfig<T>[] }) {
|
||||
const state = useFilterState(schema);
|
||||
const engine = useMemo(() => new FilterEngine(data, schema), [data, schema]);
|
||||
|
||||
return {
|
||||
filteredData: useMemo(() => engine.apply(state.filters), [engine, state.filters]),
|
||||
filters: state.filters,
|
||||
schema: useMemo(() => engine.options(), [engine]),
|
||||
activeCount: useMemo(() => engine.activeCount(state.filters), [engine, state.filters]),
|
||||
active: useMemo(() => engine.active(state.filters), [engine, state.filters]),
|
||||
updateFilter: state.updateFilter,
|
||||
removeFilter: state.removeFilter,
|
||||
clearAll: state.clearAll,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Search, X } from 'lucide-react';
|
||||
import {
|
||||
DATE_RANGE_OPTIONS,
|
||||
type FilterConfig,
|
||||
type FilterState,
|
||||
} from '@/lib/dynamic-filter';
|
||||
|
||||
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';
|
||||
|
||||
// Dynamic filter bar — renders a FilterConfig schema as compact controls.
|
||||
// State is owned by useDynamicFilter (URL-synced); this is a pure view.
|
||||
|
||||
export function FilterBar<T>({
|
||||
schema,
|
||||
filters,
|
||||
activeCount,
|
||||
active,
|
||||
updateFilter,
|
||||
removeFilter,
|
||||
clearAll,
|
||||
}: {
|
||||
schema: FilterConfig<T>[];
|
||||
filters: FilterState;
|
||||
activeCount: number;
|
||||
active: { key: string; label: string; value: string; displayValue: string }[];
|
||||
updateFilter: (key: string, value: unknown) => void;
|
||||
removeFilter: (key: string, rawValue?: string) => void;
|
||||
clearAll: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
{schema.map((config) => {
|
||||
const value = filters[config.key];
|
||||
switch (config.type) {
|
||||
case 'search':
|
||||
return (
|
||||
<div key={config.key} className="relative">
|
||||
<Search
|
||||
size={13}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)]"
|
||||
/>
|
||||
<input
|
||||
value={String(value ?? '')}
|
||||
onChange={(e) => updateFilter(config.key, e.target.value)}
|
||||
placeholder={`Search ${config.label.toLowerCase()}…`}
|
||||
className={`${inputClass} w-56 pl-8`}
|
||||
aria-label={config.label}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'select':
|
||||
case 'date-range': {
|
||||
const options =
|
||||
config.type === 'date-range'
|
||||
? DATE_RANGE_OPTIONS
|
||||
: [{ value: 'all', label: `All ${config.label.toLowerCase()}` }, ...(config.options ?? [])];
|
||||
return (
|
||||
<select
|
||||
key={config.key}
|
||||
value={String(value ?? 'all')}
|
||||
onChange={(e) => updateFilter(config.key, e.target.value)}
|
||||
className={`${inputClass} w-40`}
|
||||
aria-label={config.label}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.count != null ? `${o.label} (${o.count})` : o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
case 'multiselect':
|
||||
case 'boolean':
|
||||
case 'status': {
|
||||
const selected = Array.isArray(value) ? (value as string[]) : [];
|
||||
const options =
|
||||
config.type === 'boolean'
|
||||
? [
|
||||
{ value: 'true', label: 'Yes' },
|
||||
{ value: 'false', label: 'No' },
|
||||
]
|
||||
: (config.options ?? []);
|
||||
return (
|
||||
<div key={config.key} className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||
{config.label}:
|
||||
</span>
|
||||
{options.map((o) => {
|
||||
const on = selected.includes(o.value);
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
onClick={() =>
|
||||
updateFilter(
|
||||
config.key,
|
||||
on ? selected.filter((s) => s !== o.value) : [...selected, o.value],
|
||||
)
|
||||
}
|
||||
className={`h-7 px-2.5 rounded-[var(--radius-sm)] text-xs font-medium border transition-colors ${
|
||||
on
|
||||
? 'bg-[var(--accent-primary-soft)] border-[var(--accent-primary)]/40 text-[var(--accent-primary)]'
|
||||
: 'border-[var(--border-subtle)] text-[var(--text-secondary)] hover:border-[var(--border-default)]'
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
{o.count != null && <span className="ml-1 opacity-60">{o.count}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
{activeCount > 0 && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
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 ({activeCount})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{active.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-3">
|
||||
{active.map((a, i) => (
|
||||
<button
|
||||
key={`${a.key}-${a.value}-${i}`}
|
||||
onClick={() => removeFilter(a.key, a.value)}
|
||||
className="group inline-flex items-center gap-1.5 h-6 px-2 rounded-[var(--radius-sm)] bg-[var(--accent-primary-soft)] text-[11px] font-medium text-[var(--accent-primary)]"
|
||||
>
|
||||
<span className="opacity-70">{a.label}</span>
|
||||
{a.displayValue}
|
||||
<X size={11} className="opacity-50 group-hover:opacity-100" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,3 +11,4 @@ export * from './StatusBadge';
|
||||
export { useToast } from '../hooks/use-toast';
|
||||
export type { ToastType, ToastContextValue } from '../hooks/use-toast';
|
||||
export * from './BrandWordmark';
|
||||
export * from './FilterBar';
|
||||
Reference in new issue
Block a user