diff --git a/application/ui/src/features/inference/aside/data-collection.component.tsx b/application/ui/src/features/inference/aside/data-collection.component.tsx index aaac9bc2c3..daded02af3 100644 --- a/application/ui/src/features/inference/aside/data-collection.component.tsx +++ b/application/ui/src/features/inference/aside/data-collection.component.tsx @@ -6,10 +6,13 @@ import { useEffect, useState } from 'react'; import { Divider, Flex, Heading, Slider, Switch, Text } from '@geti/ui'; import { useIsPipelineConfigured } from 'hooks/use-is-pipeline-configured.hook'; import { $api } from 'src/api/client'; -import { components } from 'src/api/openapi-spec'; import { useProjectIdentifier } from 'src/hooks/use-project-identifier.hook'; -type FixedRateDataCollectionPolicy = components['schemas']['FixedRateDataCollectionPolicy']; +const DEFAULTS = { + RATE: 12, + CONFIDENCE_THRESHOLD: 0.5, + MIN_SAMPLING_INTERVAL: 2.5, +} as const; export const DataCollection = () => { const projectId = useProjectIdentifier(); @@ -26,32 +29,46 @@ export const DataCollection = () => { }, }); - const isAutoCapturingEnabled = pipelineQuery.data?.data_collection_policies[0]?.enabled ?? false; - const defaultRate = 12; - const serverRate = - (pipelineQuery.data?.data_collection_policies[0] as FixedRateDataCollectionPolicy)?.rate ?? defaultRate; + const policies = pipelineQuery.data?.data_collection_policies ?? []; + const ratePolicy = policies.find((policy) => policy.type === 'fixed_rate'); + const confidencePolicy = policies.find((policy) => policy.type === 'confidence_threshold'); - // TODO: add confidence_threshold slider + const serverRate = ratePolicy?.rate ?? DEFAULTS.RATE; + const serverConfidenceThreshold = confidencePolicy?.confidence_threshold ?? DEFAULTS.CONFIDENCE_THRESHOLD; const [localRate, setLocalRate] = useState(serverRate); + const [localConfidenceThreshold, setLocalConfidenceThreshold] = useState(serverConfidenceThreshold); + + useEffect(() => setLocalRate(serverRate), [serverRate]); + useEffect(() => setLocalConfidenceThreshold(serverConfidenceThreshold), [serverConfidenceThreshold]); + + const updatePolicies = (updates: { + rateEnabled?: boolean; + rate?: number; + confidenceEnabled?: boolean; + confidenceThreshold?: number; + }) => { + const newPolicies = [ + { + type: 'fixed_rate' as const, + rate: updates.rate ?? serverRate, + enabled: updates.rateEnabled ?? ratePolicy?.enabled ?? false, + }, + { + type: 'confidence_threshold' as const, + confidence_threshold: updates.confidenceThreshold ?? serverConfidenceThreshold, + min_sampling_interval: confidencePolicy?.min_sampling_interval ?? DEFAULTS.MIN_SAMPLING_INTERVAL, + enabled: updates.confidenceEnabled ?? confidencePolicy?.enabled ?? false, + }, + ]; - useEffect(() => { - setLocalRate(serverRate); - }, [serverRate]); - - const toggleAutoCapturing = (isEnabled: boolean) => { patchPipelineMutation.mutate({ params: { path: { project_id: projectId } }, - body: { data_collection_policies: [{ rate: defaultRate, enabled: isEnabled }] }, + body: { data_collection_policies: newPolicies }, }); }; - const updateRate = (value: number) => { - patchPipelineMutation.mutate({ - params: { path: { project_id: projectId } }, - body: { data_collection_policies: [{ rate: value, enabled: isAutoCapturingEnabled }] }, - }); - }; + const isDisabled = patchPipelineMutation.isPending || !canEditPipeline; return ( { Data collection + + Capture rate + + + Capture frames while the stream is running + updatePolicies({ rateEnabled: enabled })} + isDisabled={isDisabled} marginBottom={'size-200'} > Toggle auto capturing - Capture frames while the stream is running + updatePolicies({ rate })} + label='Rate' + isDisabled={isDisabled || !ratePolicy?.enabled} + /> - - Capture rate + + Confidence threshold + Capture frames when confidence is below threshold + + updatePolicies({ confidenceEnabled: enabled })} + isDisabled={isDisabled} + > + Confidence threshold + + updatePolicies({ confidenceThreshold })} marginY={'size-200'} - label='Rate' - isDisabled={patchPipelineMutation.isPending || !canEditPipeline} + label='Threshold' + isDisabled={isDisabled || !confidencePolicy?.enabled} /> diff --git a/application/ui/src/features/inference/aside/graphs.component.tsx b/application/ui/src/features/inference/aside/graphs.component.tsx index e7c9e3ff13..d5a669f920 100644 --- a/application/ui/src/features/inference/aside/graphs.component.tsx +++ b/application/ui/src/features/inference/aside/graphs.component.tsx @@ -3,64 +3,71 @@ import { useEffect, useRef, useState } from 'react'; -import { Flex, Grid, Heading, View } from '@geti/ui'; +import { Content, Flex, Grid, Heading, IllustratedMessage, View } from '@geti/ui'; +import { useProjectIdentifier } from 'hooks/use-project-identifier.hook'; import { CartesianGrid, Label, Line, LineChart, ReferenceLine, XAxis, YAxis } from 'recharts'; -const generateData = (n: number) => { - const result = new Array(n); - result[0] = 100; - - for (let idx = 1; idx < n; idx++) { - const dx = 1 + Math.random() * 0.3 - 0.15; - result[idx] = Math.max(0, Math.min(100, result[idx - 1] * dx)); - } - - return result; -}; - -const useData = () => { - const [data, setData] = useState( - generateData(60).map((value, idx) => { - return { name: `${idx}`, value }; - }) +import { $api } from '../../../api/client'; + +interface DataPoint { + name: string; + value: number; +} + +const POLLING_INTERVAL = 5000; +const MAX_DATA_POINTS = 60; // Keep last 60 data points + +const useMetricsData = (projectId: string) => { + const [latencyData, setLatencyData] = useState([]); + const [throughputData, setThroughputData] = useState([]); + const counterRef = useRef(0); + + const { data: metrics } = $api.useQuery( + 'get', + '/api/projects/{project_id}/pipeline/metrics', + { + params: { path: { project_id: projectId } }, + }, + { + refetchInterval: (query) => (query.state.status === 'success' ? POLLING_INTERVAL : false), + retry: false, + } ); - const timeout = useRef>(undefined); - useEffect(() => { - timeout.current = setTimeout( - () => { - const newData = data.slice(1); - const previous = newData.at(-1); - - if (!previous) { - return; - } - - const dx = 1 + Math.random() * 0.3 - 0.15; - newData.push({ - name: `${Number(previous.name) + 1}`, - value: Math.max(0, Math.min(100, previous.value * dx)), - }); - - setData(newData); - }, - Math.random() * 100 + 250 - ); - - return () => { - if (timeout.current) { - clearTimeout(timeout.current); - } - }; - }); - - return data; + if (!metrics) return; + + const dataPointName = `${counterRef.current++}`; + + setLatencyData((prev) => { + const newData = [ + ...prev, + { + name: dataPointName, + value: metrics.inference.latency.avg_ms ?? 0, + }, + ]; + + // Keep only last MAX_DATA_POINTS + return newData.slice(-MAX_DATA_POINTS); + }); + + setThroughputData((prev) => { + const newData = [ + ...prev, + { + name: dataPointName, + value: metrics.inference.throughput.avg_requests_per_second ?? 0, + }, + ]; + return newData.slice(-MAX_DATA_POINTS); + }); + }, [metrics]); + + return { latencyData, throughputData, metrics }; }; -const Graph = ({ label }: { label: string }) => { - const data = useData(); - +const Graph = ({ label, data }: { label: string; data: DataPoint[] }) => { return ( @@ -70,16 +77,8 @@ const Graph = ({ label }: { label: string }) => { dataKey='name' tickLine={false} tickMargin={8} - // tickFormatter={(value) => { - // const date = new Date(value); - // return date.toLocaleDateString('en-US', { - // month: 'short', - // day: 'numeric', - // }); - // }} /> { }} > - {/* */} - + {data.length > 0 && ( + + )} { }; export const Graphs = () => { + const projectId = useProjectIdentifier(); + const { latencyData, throughputData, metrics } = useMetricsData(projectId); + + const hasData = latencyData.length > 0 || throughputData.length > 0; + return ( { Model statistics - {/* TODO: Extract these into a shared component */} - - - Throughput - - - - - - Latency per frame - - - - - - Model confidence - - - - - - Resource utilization - - - + {!hasData && !metrics ? ( + + No statistics available + + Model statistics will appear here once the pipeline starts running and starts processing + data. + + + ) : ( + <> + + + Throughput + + + + + + Latency + + + + + )} ); diff --git a/application/ui/src/features/models/page-header.component.tsx b/application/ui/src/features/models/page-header.component.tsx index 80e37887d9..bf3ccbc24e 100644 --- a/application/ui/src/features/models/page-header.component.tsx +++ b/application/ui/src/features/models/page-header.component.tsx @@ -1,7 +1,7 @@ // Copyright (C) 2025 Intel Corporation // SPDX-License-Identifier: Apache-2.0 -import { Flex, Grid, Item, Picker, SearchField, Text } from '@geti/ui'; +import { Flex, Grid, Item, Picker, Text } from '@geti/ui'; export const ModelsHeader = () => { return ( @@ -18,7 +18,6 @@ export const ModelsHeader = () => { Sort: Architecture - ); }; diff --git a/application/ui/src/providers.tsx b/application/ui/src/providers.tsx index 6b50984b8d..329755368a 100644 --- a/application/ui/src/providers.tsx +++ b/application/ui/src/providers.tsx @@ -29,7 +29,10 @@ export const queryClient = new QueryClient({ queries: { gcTime: 30 * 60 * 1000, staleTime: 5 * 60 * 1000, - retry: 0, + retry: false, + }, + mutations: { + retry: false, }, }, mutationCache: new MutationCache({ diff --git a/application/ui/tests/inference.spec.ts b/application/ui/tests/inference.spec.ts index f28c8f5080..547942b807 100644 --- a/application/ui/tests/inference.spec.ts +++ b/application/ui/tests/inference.spec.ts @@ -84,12 +84,11 @@ test.describe('Inference', () => { // Open both tabs just to make sure everything works await page.getByRole('button', { name: 'Toggle Model statistics tab' }).click(); - await expect(page.getByText('Model statistics')).toBeVisible(); + await expect(page.getByText('Model statistics', { exact: true })).toBeVisible(); await page.getByRole('button', { name: 'Toggle Data collection policy' }).click(); await expect(page.getByRole('heading', { name: 'Data collection' })).toBeVisible(); - // Update values await expect(page.getByRole('switch', { name: 'Toggle auto capturing' })).not.toBeChecked(); network.use( @@ -105,6 +104,12 @@ test.describe('Inference', () => { enabled: true, rate: 12, }, + { + type: 'confidence_threshold', + enabled: false, + confidence_threshold: 0.5, + min_sampling_interval: 2.5, + }, ], }) ); @@ -116,29 +121,86 @@ test.describe('Inference', () => { network.use( http.get('/api/projects/{project_id}/pipeline', ({ response }) => { - return response(200).json({ - project_id: 'id-1', - status: 'idle', - source: null, - sink: null, - model: null, - data_collection_policies: [ - { - type: 'fixed_rate', - enabled: true, - rate: 20, - }, - ], - }); + return response(200).json( + getMockedPipeline({ + data_collection_policies: [ + { + type: 'fixed_rate', + enabled: true, + rate: 20, + }, + { + type: 'confidence_threshold', + enabled: false, + confidence_threshold: 0.5, + min_sampling_interval: 2.5, + }, + ], + }) + ); }) ); - const sliderInput = page.locator('input[type="range"]'); + const rateSlider = page.getByRole('slider', { name: 'Rate' }); + await expect(rateSlider).toBeVisible(); + await expect(rateSlider).toBeEnabled(); + await rateSlider.fill('20'); + await expect(rateSlider).toHaveValue('20'); + + await expect(page.getByRole('switch', { name: 'Confidence threshold' })).not.toBeChecked(); - await expect(sliderInput).toBeVisible(); - await sliderInput.fill('20'); + network.use( + http.get('/api/projects/{project_id}/pipeline', ({ response }) => { + return response(200).json( + getMockedPipeline({ + data_collection_policies: [ + { + type: 'fixed_rate', + enabled: true, + rate: 20, + }, + { + type: 'confidence_threshold', + enabled: true, + confidence_threshold: 0.5, + min_sampling_interval: 2.5, + }, + ], + }) + ); + }) + ); + + await page.getByRole('switch', { name: 'Confidence threshold' }).click(); + await expect(page.getByRole('switch', { name: 'Confidence threshold' })).toBeChecked(); + + network.use( + http.get('/api/projects/{project_id}/pipeline', ({ response }) => { + return response(200).json( + getMockedPipeline({ + data_collection_policies: [ + { + type: 'fixed_rate', + enabled: true, + rate: 20, + }, + { + type: 'confidence_threshold', + enabled: true, + confidence_threshold: 0.7, + min_sampling_interval: 2.5, + }, + ], + }) + ); + }) + ); - await expect(sliderInput).toHaveValue('20'); + const confidenceSlider = page.getByRole('slider', { name: 'Threshold' }); + await expect(confidenceSlider).toBeVisible(); + await expect(confidenceSlider).toBeEnabled(); + await confidenceSlider.fill('0.7'); + await expect(confidenceSlider).toHaveValue('0.7'); }); test('updates input and output source', async ({ page, network }) => {