Skip to content

Commit 7a487f6

Browse files
committed
feat(10-02): wire matrix filters and toolbar into fleet page
- Add filteredPipelines and hasActiveFilters props to DeploymentMatrix - Use displayPipelines (filtered ?? all) for tbody rendering - Show empty filter state with "No matching pipelines" message (D-11) - Import useMatrixFilters, DeploymentMatrixToolbar, aggregateProcessStatus in fleet page - Add matrixQuery (deduplicated via React Query), availableTags, filteredDeployedPipelines - Clear filters on environment switch via prevEnvRef + router.replace (D-07) - Render toolbar only after matrixQuery.data is available (avoids empty tag list flash) - Status filter normalizes PascalCase to SCREAMING_SNAKE for aggregateProcessStatus (Pitfall 1)
1 parent eb95c34 commit 7a487f6

2 files changed

Lines changed: 110 additions & 5 deletions

File tree

src/app/(dashboard)/fleet/page.tsx

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
"use client";
22

3-
import { useState, useMemo, useCallback } from "react";
3+
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
44
import Link from "next/link";
5+
import { useRouter } from "next/navigation";
56
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
67
import { useTRPC } from "@/trpc/client";
78
import { useEnvironmentStore } from "@/stores/environment-store";
89
import { useTeamStore } from "@/stores/team-store";
10+
import { useMatrixFilters } from "@/hooks/use-matrix-filters";
11+
import { DeploymentMatrixToolbar } from "@/components/fleet/DeploymentMatrixToolbar";
12+
import { aggregateProcessStatus } from "@/lib/pipeline-status";
913

1014
import { Badge } from "@/components/ui/badge";
1115
import { Button } from "@/components/ui/button";
@@ -63,6 +67,76 @@ export default function FleetPage() {
6367
// Pick the first environment if none is selected yet
6468
const activeEnvId = selectedEnvironmentId || environments[0]?.id || "";
6569

70+
const router = useRouter();
71+
72+
// --- Matrix filter state (URL-synced) ---
73+
const {
74+
search: matrixSearch,
75+
statusFilter: matrixStatusFilter,
76+
tagFilter: matrixTagFilter,
77+
hasActiveFilters: matrixHasActiveFilters,
78+
setSearch: setMatrixSearch,
79+
setStatusFilter: setMatrixStatusFilter,
80+
setTagFilter: setMatrixTagFilter,
81+
} = useMatrixFilters();
82+
83+
// Same query as DeploymentMatrix — React Query deduplicates by key
84+
const matrixQuery = useQuery({
85+
...trpc.fleet.listWithPipelineStatus.queryOptions({ environmentId: activeEnvId }),
86+
enabled: !!activeEnvId,
87+
});
88+
89+
// Derive available tags from all deployed pipelines
90+
const availableTags = useMemo(() => {
91+
const tagSet = new Set<string>();
92+
for (const p of matrixQuery.data?.deployedPipelines ?? []) {
93+
for (const t of (p.tags as string[]) ?? []) {
94+
tagSet.add(t);
95+
}
96+
}
97+
return [...tagSet].sort();
98+
}, [matrixQuery.data?.deployedPipelines]);
99+
100+
// Compute filtered pipelines with AND logic across search, status, and tag filters
101+
const filteredDeployedPipelines = useMemo(() => {
102+
let result = matrixQuery.data?.deployedPipelines ?? [];
103+
const nodes = matrixQuery.data?.nodes ?? [];
104+
105+
if (matrixSearch) {
106+
const lc = matrixSearch.toLowerCase();
107+
result = result.filter((p) => p.name.toLowerCase().includes(lc));
108+
}
109+
110+
if (matrixStatusFilter.length > 0) {
111+
result = result.filter((p) => {
112+
const nodeStatuses = nodes.flatMap((n) =>
113+
n.pipelineStatuses.filter((s) => s.pipelineId === p.id),
114+
);
115+
const agg = aggregateProcessStatus(nodeStatuses);
116+
// statusFilter values are PascalCase ("Running"), agg is SCREAMING_SNAKE ("RUNNING")
117+
return agg !== null && matrixStatusFilter.map((s) => s.toUpperCase()).includes(agg);
118+
});
119+
}
120+
121+
if (matrixTagFilter.length > 0) {
122+
result = result.filter((p) => {
123+
const pTags = (p.tags as string[]) ?? [];
124+
return matrixTagFilter.some((t) => pTags.includes(t));
125+
});
126+
}
127+
128+
return result;
129+
}, [matrixQuery.data, matrixSearch, matrixStatusFilter, matrixTagFilter]);
130+
131+
// Clear all matrix filters when environment changes (D-07)
132+
const prevEnvRef = useRef(activeEnvId);
133+
useEffect(() => {
134+
if (prevEnvRef.current !== activeEnvId) {
135+
prevEnvRef.current = activeEnvId;
136+
router.replace("/fleet", { scroll: false });
137+
}
138+
}, [activeEnvId, router]);
139+
66140
// --- Filter state ---
67141
const [search, setSearch] = useState("");
68142
const [statusFilter, setStatusFilter] = useState<string[]>([]);
@@ -464,7 +538,22 @@ export default function FleetPage() {
464538
{activeEnvId && (
465539
<div className="space-y-4">
466540
<h3 className="text-lg font-semibold">Pipeline Deployment Matrix</h3>
467-
<DeploymentMatrix environmentId={activeEnvId} />
541+
{matrixQuery.data && (
542+
<DeploymentMatrixToolbar
543+
search={matrixSearch}
544+
onSearchChange={setMatrixSearch}
545+
statusFilter={matrixStatusFilter}
546+
onStatusFilterChange={setMatrixStatusFilter}
547+
tagFilter={matrixTagFilter}
548+
onTagFilterChange={setMatrixTagFilter}
549+
availableTags={availableTags}
550+
/>
551+
)}
552+
<DeploymentMatrix
553+
environmentId={activeEnvId}
554+
filteredPipelines={matrixQuery.data ? filteredDeployedPipelines : undefined}
555+
hasActiveFilters={matrixHasActiveFilters}
556+
/>
468557
</div>
469558
)}
470559

src/components/fleet/deployment-matrix.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,16 @@ interface DeploymentMatrixProps {
2525
range?: TimeRange;
2626
lossThreshold?: number;
2727
throughputData?: MatrixCellThroughput[];
28+
filteredPipelines?: Array<{ id: string; name: string; latestVersion: number; tags: string[] }>;
29+
hasActiveFilters?: boolean;
2830
}
2931

3032
export function DeploymentMatrix({
3133
environmentId,
3234
lossThreshold = 0.05,
3335
throughputData,
36+
filteredPipelines,
37+
hasActiveFilters = false,
3438
}: DeploymentMatrixProps) {
3539
const trpc = useTRPC();
3640
const polling = usePollingInterval(15_000);
@@ -52,15 +56,16 @@ export function DeploymentMatrix({
5256

5357
const data = matrixQuery.data;
5458

55-
if (!data || data.deployedPipelines.length === 0) {
59+
if (!data || (data.deployedPipelines.length === 0 && !hasActiveFilters)) {
5660
return (
5761
<div className="flex items-center justify-center rounded-lg border border-dashed p-8 text-center">
5862
<p className="text-sm text-muted-foreground">No pipelines deployed</p>
5963
</div>
6064
);
6165
}
6266

63-
const { nodes, deployedPipelines } = data;
67+
const { nodes } = data;
68+
const displayPipelines = filteredPipelines ?? data.deployedPipelines;
6469

6570
if (nodes.length === 0) {
6671
return null;
@@ -101,8 +106,18 @@ export function DeploymentMatrix({
101106
))}
102107
</tr>
103108
</thead>
109+
{displayPipelines.length === 0 && hasActiveFilters ? (
110+
<tbody>
111+
<tr>
112+
<td colSpan={nodes.length + 1} className="px-3 py-8 text-center">
113+
<p className="text-sm font-medium text-muted-foreground">No matching pipelines</p>
114+
<p className="text-xs text-muted-foreground mt-1">Try adjusting your search or filters.</p>
115+
</td>
116+
</tr>
117+
</tbody>
118+
) : (
104119
<tbody className="divide-y">
105-
{deployedPipelines.map((pipeline) => (
120+
{displayPipelines.map((pipeline) => (
106121
<tr key={pipeline.id} className="transition-colors hover:bg-muted/50">
107122
<td className="px-3 py-2 font-medium">
108123
<div className="flex items-center gap-2">
@@ -198,6 +213,7 @@ export function DeploymentMatrix({
198213
</tr>
199214
))}
200215
</tbody>
216+
)}
201217
</table>
202218
</div>
203219
);

0 commit comments

Comments
 (0)