Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/claude-progress.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
# Claude Progress Log
# Newest entries first. Agents: append your entry at the top after the header.

---
## 2026-06-08 | Session: useClusterService resource coupling fix (SRVOCF-841 item 1)
Worked on: Merge separate knativeServices/deployments arrays into correlated Record, fix naming bug, improve tests
Completed:
- Changed useClusterService return type from { knativeServices[], deployments[] } to { functions: Record<string, ClusterFunctionInfo> }
- Moved revision-based correlation logic (latestReadyRevisionName matching) from FunctionsListPage into the hook's useMemo
- Renamed misleading `deployment` field to `knativeService` on FunctionTableItem (the field held a Knative Service, not a Deployment)
- Fixed FunctionTable.test.tsx fixture from Deployment object to actual Knative Service object
- Simplified FunctionsListPage merge memo from 11-line find/match to 3-line record lookup
- Added 4 new hook tests: error propagation, co-presence pairing, ksvc without deployment, revision-based matching
- Added ownerReferences to deployment fixtures (documents Knative ownership chain)
- Removed "picks latest revision" test from FunctionsListPage (logic moved to hook, tested there)
- Updated FunctionCreatePage test mock to match new return shape
- Fixed react-hooks/exhaustive-deps lint warnings by moving conditionals inside useMemo
- 14 suites, 129 tests passing, zero lint errors
Left off: Changes ready for commit on branch cleanup-cluster-svc.
Blockers: None

---
## 2026-05-21 | Session: Page co-location restructure (continued)
Worked on: Follow-up fixes from review, test cleanup, docs
Expand Down
11 changes: 11 additions & 0 deletions docs/features.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,5 +180,16 @@
"All tests pass, no broken imports"
],
"passes": true
},
{
"category": "technical",
"description": "useClusterService: fix resource coupling and improve tests. Knative Services own their Deployments via OwnerReferences, so they are always co-present. Clarify this invariant in tests and consider simplifying to a single merged return type.",
Comment thread
matejvasek marked this conversation as resolved.
"steps": [
"Audit useClusterService return type for redundant Knative Service / Deployment separation",
"Simplify to a single merged return type if warranted",
"Update tests to reflect the co-presence invariant",
"All tests pass, no broken imports, build clean"
],
"passes": true
}
]
112 changes: 90 additions & 22 deletions src/common/services/cluster/useClusterService.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,28 +32,35 @@ const mockDeployment = {
'function.knative.dev/name': 'my-func',
'serving.knative.dev/revision': 'my-func-00001',
},
ownerReferences: [
{
apiVersion: 'serving.knative.dev/v1',
kind: 'Revision',
name: 'my-func-00001',
uid: 'rev-uid-001',
controller: true,
blockOwnerDeletion: true,
},
],
},
spec: { replicas: 1 },
status: { readyReplicas: 1 },
};

function TestConsumer({ functionNames = [] }: { functionNames?: string[] }) {
const { knativeServices, deployments, loaded, error } = useClusterService(functionNames);
const { functions, loaded, error } = useClusterService(functionNames);
const entries = Object.entries(functions);
return (
<>
<span data-testid="loaded">{String(loaded)}</span>
<span data-testid="error">{String(error)}</span>
<span data-testid="ksvc-count">{knativeServices.length}</span>
<span data-testid="dep-count">{deployments.length}</span>
{knativeServices.map((s) => (
<span key={s.metadata?.name} data-testid="ksvc">
{s.metadata?.name}
</span>
))}
{deployments.map((d) => (
<span key={d.metadata?.name} data-testid="deployment">
{d.metadata?.name}
</span>
<span data-testid="fn-count">{entries.length}</span>
{entries.map(([name, info]) => (
<div key={name} data-testid="fn-entry">
<span data-testid="fn-name">{name}</span>
<span data-testid="fn-ksvc">{info.knativeService.metadata?.name}</span>
<span data-testid="fn-dep">{info.deployment?.metadata?.name ?? 'none'}</span>
</div>
))}
</>
);
Expand All @@ -71,8 +78,7 @@ describe('useClusterService', () => {

expect(mockUseK8sWatchResource).toHaveBeenCalledWith(null);
expect(screen.getByTestId('loaded')).toHaveTextContent('true');
expect(screen.getByTestId('ksvc-count')).toHaveTextContent('0');
expect(screen.getByTestId('dep-count')).toHaveTextContent('0');
expect(screen.getByTestId('fn-count')).toHaveTextContent('0');
});

it('watches Knative Services with In selector for given function names', () => {
Expand All @@ -91,13 +97,13 @@ describe('useClusterService', () => {
],
},
});
expect(screen.getByTestId('ksvc-count')).toHaveTextContent('1');
expect(screen.getByTestId('ksvc')).toHaveTextContent('my-func');
expect(screen.getByTestId('fn-count')).toHaveTextContent('1');
expect(screen.getByTestId('fn-ksvc')).toHaveTextContent('my-func');
});

it('watches Deployments with In selector for given function names', () => {
mockUseK8sWatchResource
.mockReturnValueOnce([[], true, null])
.mockReturnValueOnce([[mockKsvc], true, null])
.mockReturnValueOnce([[mockDeployment], true, null]);

render(<TestConsumer functionNames={['my-func']} />);
Expand All @@ -111,19 +117,81 @@ describe('useClusterService', () => {
],
},
});
expect(screen.getByTestId('dep-count')).toHaveTextContent('1');
expect(screen.getByTestId('deployment')).toHaveTextContent('my-func-00001-deployment');
expect(screen.getByTestId('fn-dep')).toHaveTextContent('my-func-00001-deployment');
});

it('returns empty arrays when not loaded', () => {
it('returns empty record when not loaded', () => {
mockUseK8sWatchResource
.mockReturnValueOnce([[], false, null])
.mockReturnValueOnce([[], false, null]);

render(<TestConsumer functionNames={['my-func']} />);

expect(screen.getByTestId('loaded')).toHaveTextContent('false');
expect(screen.getByTestId('ksvc-count')).toHaveTextContent('0');
expect(screen.getByTestId('dep-count')).toHaveTextContent('0');
expect(screen.getByTestId('fn-count')).toHaveTextContent('0');
});

it('propagates deployment watch error', () => {
mockUseK8sWatchResource
.mockReturnValueOnce([[], true, null])
.mockReturnValueOnce([[], true, new Error('forbidden')]);

render(<TestConsumer functionNames={['my-func']} />);

expect(screen.getByTestId('error')).toHaveTextContent('Error: forbidden');
});

it('pairs ksvc and deployment together by revision', () => {
mockUseK8sWatchResource
.mockReturnValueOnce([[mockKsvc], true, null])
.mockReturnValueOnce([[mockDeployment], true, null]);

render(<TestConsumer functionNames={['my-func']} />);

expect(screen.getByTestId('fn-count')).toHaveTextContent('1');
expect(screen.getByTestId('fn-ksvc')).toHaveTextContent('my-func');
expect(screen.getByTestId('fn-dep')).toHaveTextContent('my-func-00001-deployment');
});

it('returns undefined deployment when ksvc has no matching deployment', () => {
mockUseK8sWatchResource
.mockReturnValueOnce([[mockKsvc], true, null])
.mockReturnValueOnce([[], true, null]);

render(<TestConsumer functionNames={['my-func']} />);

expect(screen.getByTestId('fn-count')).toHaveTextContent('1');
expect(screen.getByTestId('fn-dep')).toHaveTextContent('none');
});

it('picks deployment matching the latest ready revision', () => {
const ksvcRev2 = {
...mockKsvc,
status: {
...mockKsvc.status,
latestReadyRevisionName: 'my-func-00002',
},
};
const oldDep = mockDeployment;
const newDep = {
...mockDeployment,
metadata: {
...mockDeployment.metadata,
name: 'my-func-00002-deployment',
labels: {
'function.knative.dev/name': 'my-func',
'serving.knative.dev/revision': 'my-func-00002',
},
},
status: { readyReplicas: 2 },
};

mockUseK8sWatchResource
.mockReturnValueOnce([[ksvcRev2], true, null])
.mockReturnValueOnce([[oldDep, newDep], true, null]);

render(<TestConsumer functionNames={['my-func']} />);

expect(screen.getByTestId('fn-dep')).toHaveTextContent('my-func-00002-deployment');
});
});
38 changes: 33 additions & 5 deletions src/common/services/cluster/useClusterService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@ import { OcpClusterService } from './OcpClusterService';
const instance = new OcpClusterService();

const FUNCTION_NAME_LABEL = 'function.knative.dev/name';
const REVISION_LABEL = 'serving.knative.dev/revision';

export interface ClusterFunctionInfo {
knativeService: K8sResourceKind;
deployment: K8sResourceKind | undefined;
}

interface ClusterService {
knativeServices: K8sResourceKind[];
deployments: K8sResourceKind[];
functions: Record<string, ClusterFunctionInfo>;
loaded: boolean;
error: unknown;
generateKubeconfig: (namespace: string) => Promise<string>;
Expand Down Expand Up @@ -50,10 +55,33 @@ export function useClusterService(functionNames: string[] = []): ClusterService
const [knSvcs, knLoaded, knError] = useK8sWatchResource<K8sResourceKind[]>(knSvcConfig);
const [deps, depLoaded, depError] = useK8sWatchResource<K8sResourceKind[]>(depConfig);

const loaded = knLoaded && depLoaded;

const functions = useMemo(() => {
if (!loaded) return {};

const knativeServices = knSvcs ?? [];
const deployments = deps ?? [];
const record: Record<string, ClusterFunctionInfo> = {};

for (const ksvc of knativeServices) {
const name = ksvc.metadata?.labels?.[FUNCTION_NAME_LABEL];
if (!name) continue;

const latestRevision = ksvc.status?.latestReadyRevisionName;
const deployment = latestRevision
? deployments.find((d) => d.metadata?.labels?.[REVISION_LABEL] === latestRevision)
: deployments.find((d) => d.metadata?.labels?.[FUNCTION_NAME_LABEL] === name);

record[name] = { knativeService: ksvc, deployment };
}

return record;
}, [loaded, knSvcs, deps]);

return {
knativeServices: knLoaded ? (knSvcs ?? []) : [],
deployments: depLoaded ? (deps ?? []) : [],
loaded: knLoaded && depLoaded,
functions,
loaded,
error: knError || depError,
generateKubeconfig: instance.generateKubeconfig.bind(instance),
};
Expand Down
3 changes: 1 addition & 2 deletions src/pages/function-create/FunctionCreatePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ vi.mock('../../common/services/source-control/useSourceControlService', () => ({

vi.mock('../../common/services/cluster/useClusterService', () => ({
useClusterService: () => ({
knativeServices: [],
deployments: [],
functions: {},
loaded: true,
error: undefined,
generateKubeconfig: mockGenerateKubeconfig,
Expand Down
Loading