diff --git a/.changeset/scheduled-task-runs-page.md b/.changeset/scheduled-task-runs-page.md new file mode 100644 index 000000000..eb5082795 --- /dev/null +++ b/.changeset/scheduled-task-runs-page.md @@ -0,0 +1,5 @@ +--- +'@openchoreo/backstage-plugin-openchoreo-observability': minor +--- + +Add Runs tab to scheduled-task component entity pages. Renders a Component → Runs (Jobs) → Retries (Pods) → Logs hierarchy over the observer's `/scheduled-tasks/runs/query` and `/scheduled-tasks/runs/{jobName}/retries/query` endpoints. Retries queries are scoped to each run's own lifetime via optional `startTime` / `endTime`, avoiding the observer's per-call event cap on high-frequency CronJobs. Requires the observer backend from openchoreo/openchoreo#3933. diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index ce254f19e..d27ad1adc 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -142,6 +142,7 @@ import { ObservabilityWirelogs, ObservabilityProjectIncidents, ObservabilityCostAnalysis, + ObservabilityRuns, useComponentHasAnyCiliumEnabledEnvironment, type RenderLogRowAction, } from '@openchoreo/backstage-plugin-openchoreo-observability'; @@ -454,6 +455,84 @@ const ServiceEntityPage = () => { ); }; +/** + * Scheduled task entity page with delete menu support. + * Adds a Runs tab that shows Job/Pod execution history. + * No API tab or Alerts tab (not relevant for CronJobs). + */ +const scheduledTaskEntityPage = ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {techdocsContent} + + + {/* External CI Platform Tabs - only shown when annotation is present */} + + + + + + + + + + + + +); + /** * Website entity page with delete menu support. * Routes are defined as static JSX children so routable extensions are discoverable. @@ -618,8 +697,13 @@ function getComponentPageVariant(entity: Entity): PageVariant { const isServiceComponent = (entity: Entity) => getComponentPageVariant(entity) === 'service'; -const isGenericComponent = (entity: Entity) => - getComponentPageVariant(entity) !== 'service'; +const isScheduledTaskComponent = (entity: Entity) => + getComponentPageVariant(entity) === 'scheduled-task'; + +const isGenericComponent = (entity: Entity) => { + const variant = getComponentPageVariant(entity); + return variant !== 'service' && variant !== 'scheduled-task'; +}; const componentPage = ( @@ -627,6 +711,10 @@ const componentPage = ( + + {scheduledTaskEntityPage} + + diff --git a/plugins/openchoreo-observability/src/api/ObservabilityApi.test.ts b/plugins/openchoreo-observability/src/api/ObservabilityApi.test.ts index 0e473b976..6e29fbd49 100644 --- a/plugins/openchoreo-observability/src/api/ObservabilityApi.test.ts +++ b/plugins/openchoreo-observability/src/api/ObservabilityApi.test.ts @@ -399,3 +399,371 @@ describe('ObservabilityClient.getRuntimeEvents', () => { ).rejects.toThrow('kaboom'); }); }); + +describe('ObservabilityClient.getRuns', () => { + beforeEach(() => { + jest.clearAllMocks(); + resolveUrls.mockResolvedValue({ observerUrl: 'http://observer' }); + }); + + it('POSTs to the runs endpoint with searchScope and options, and maps the response', async () => { + mockFetchApi.fetch.mockResolvedValueOnce( + mockOkResponse({ + runs: [ + { + jobName: 'job-1', + status: 'succeeded', + startTime: '2026-03-05T10:00:00.000Z', + completionTime: '2026-03-05T10:05:00.000Z', + eventCount: 3, + failureReason: null, + events: [{ reason: 'Created', message: 'ok' }], + }, + ], + total: 42, + tookMs: 12, + }), + ); + + const client = createClient(); + const result = await client.getRuns( + 'ns1', + 'project-a', + 'dev', + 'component-a', + { + startTime: '2026-03-05T09:00:00.000Z', + endTime: '2026-03-05T10:00:00.000Z', + limit: 25, + offset: 5, + sortOrder: 'asc', + }, + ); + + expect(mockFetchApi.fetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetchApi.fetch.mock.calls[0]; + expect(url).toBe('http://observer/api/v1/scheduled-tasks/runs/query'); + expect(options.method).toBe('POST'); + const payload = JSON.parse(options.body); + expect(payload).toEqual({ + startTime: '2026-03-05T09:00:00.000Z', + endTime: '2026-03-05T10:00:00.000Z', + limit: 25, + offset: 5, + sortOrder: 'asc', + searchScope: { + namespace: 'ns1', + project: 'project-a', + component: 'component-a', + environment: 'dev', + }, + }); + expect(result.runs).toHaveLength(1); + expect(result.runs[0]).toEqual({ + jobName: 'job-1', + status: 'succeeded', + startTime: '2026-03-05T10:00:00.000Z', + completionTime: '2026-03-05T10:05:00.000Z', + eventCount: 3, + failureReason: null, + events: [{ reason: 'Created', message: 'ok' }], + }); + expect(result.total).toBe(42); + expect(result.tookMs).toBe(12); + }); + + it('applies defaults for limit / offset / sortOrder when options are omitted', async () => { + mockFetchApi.fetch.mockResolvedValueOnce(mockOkResponse({ runs: [] })); + + const client = createClient(); + await client.getRuns('ns1', 'project-a', 'dev', 'component-a'); + + const payload = JSON.parse(mockFetchApi.fetch.mock.calls[0][1].body); + expect(payload.limit).toBe(20); + expect(payload.offset).toBe(0); + expect(payload.sortOrder).toBe('desc'); + expect(typeof payload.startTime).toBe('string'); + expect(typeof payload.endTime).toBe('string'); + }); + + it('coerces missing per-run fields to safe defaults', async () => { + mockFetchApi.fetch.mockResolvedValueOnce( + mockOkResponse({ runs: [{}], total: undefined }), + ); + + const client = createClient(); + const result = await client.getRuns( + 'ns1', + 'project-a', + 'dev', + 'component-a', + ); + + expect(result.runs[0]).toEqual({ + jobName: '', + status: 'unknown', + startTime: '', + completionTime: undefined, + eventCount: 0, + failureReason: undefined, + events: undefined, + }); + expect(result.total).toBe(0); + expect(result.tookMs).toBe(0); + }); + + it('maps the not-configured error to an observability-disabled message', async () => { + mockFetchApi.fetch.mockResolvedValueOnce({ + ok: false, + json: () => + Promise.resolve({ + error: 'Observability is not configured for component foo', + }), + }); + + const client = createClient(); + await expect( + client.getRuns('ns1', 'project-a', 'dev', 'component-a'), + ).rejects.toThrow('Observability is not enabled for this component'); + }); + + it('throws the parsed error for other failures', async () => { + mockFetchApi.fetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Server Error', + json: () => Promise.resolve({ error: 'runs boom' }), + }); + + const client = createClient(); + await expect( + client.getRuns('ns1', 'project-a', 'dev', 'component-a'), + ).rejects.toThrow('runs boom'); + }); +}); + +describe('ObservabilityClient.getRetries', () => { + beforeEach(() => { + jest.clearAllMocks(); + resolveUrls.mockResolvedValue({ observerUrl: 'http://observer' }); + }); + + it('POSTs to the retries endpoint and omits time bounds when neither option is set', async () => { + mockFetchApi.fetch.mockResolvedValueOnce( + mockOkResponse({ retries: [], total: 0, tookMs: 0 }), + ); + + const client = createClient(); + await client.getRetries('job-1', 'ns1', 'project-a', 'dev', 'component-a'); + + expect(mockFetchApi.fetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetchApi.fetch.mock.calls[0]; + expect(url).toBe( + 'http://observer/api/v1/scheduled-tasks/runs/job-1/retries/query', + ); + expect(options.method).toBe('POST'); + const payload = JSON.parse(options.body); + expect(payload).toEqual({ + searchScope: { + namespace: 'ns1', + project: 'project-a', + component: 'component-a', + environment: 'dev', + }, + }); + expect(payload.startTime).toBeUndefined(); + expect(payload.endTime).toBeUndefined(); + }); + + it('includes both time bounds when both are provided', async () => { + mockFetchApi.fetch.mockResolvedValueOnce(mockOkResponse({ retries: [] })); + + const client = createClient(); + await client.getRetries('job-1', 'ns1', 'project-a', 'dev', 'component-a', { + startTime: '2026-03-05T09:00:00.000Z', + endTime: '2026-03-05T10:00:00.000Z', + }); + + const payload = JSON.parse(mockFetchApi.fetch.mock.calls[0][1].body); + expect(payload.startTime).toBe('2026-03-05T09:00:00.000Z'); + expect(payload.endTime).toBe('2026-03-05T10:00:00.000Z'); + }); + + it('omits time bounds when only startTime is provided (both-or-none)', async () => { + mockFetchApi.fetch.mockResolvedValueOnce(mockOkResponse({ retries: [] })); + + const client = createClient(); + await client.getRetries('job-1', 'ns1', 'project-a', 'dev', 'component-a', { + startTime: '2026-03-05T09:00:00.000Z', + }); + + const payload = JSON.parse(mockFetchApi.fetch.mock.calls[0][1].body); + expect(payload.startTime).toBeUndefined(); + expect(payload.endTime).toBeUndefined(); + }); + + it('omits time bounds when only endTime is provided (both-or-none)', async () => { + mockFetchApi.fetch.mockResolvedValueOnce(mockOkResponse({ retries: [] })); + + const client = createClient(); + await client.getRetries('job-1', 'ns1', 'project-a', 'dev', 'component-a', { + endTime: '2026-03-05T10:00:00.000Z', + }); + + const payload = JSON.parse(mockFetchApi.fetch.mock.calls[0][1].body); + expect(payload.startTime).toBeUndefined(); + expect(payload.endTime).toBeUndefined(); + }); + + it('URL-encodes the jobName', async () => { + mockFetchApi.fetch.mockResolvedValueOnce(mockOkResponse({ retries: [] })); + + const client = createClient(); + await client.getRetries( + 'job/with slashes', + 'ns1', + 'project-a', + 'dev', + 'component-a', + ); + + const [url] = mockFetchApi.fetch.mock.calls[0]; + expect(url).toContain('job%2Fwith%20slashes'); + }); + + it('maps response retries with default fields', async () => { + mockFetchApi.fetch.mockResolvedValueOnce( + mockOkResponse({ + retries: [ + { + podName: 'pod-1', + status: 'Succeeded', + startTime: '2026-03-05T10:00:00.000Z', + eventCount: 2, + events: [], + }, + {}, + ], + total: 2, + tookMs: 7, + }), + ); + + const client = createClient(); + const result = await client.getRetries( + 'job-1', + 'ns1', + 'project-a', + 'dev', + 'component-a', + ); + + expect(result.retries).toEqual([ + { + podName: 'pod-1', + status: 'Succeeded', + startTime: '2026-03-05T10:00:00.000Z', + eventCount: 2, + events: [], + }, + { + podName: '', + status: 'Unknown', + startTime: '', + eventCount: 0, + events: undefined, + }, + ]); + expect(result.total).toBe(2); + expect(result.tookMs).toBe(7); + }); + + it('throws the parsed error when the response is not ok', async () => { + mockFetchApi.fetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Server Error', + json: () => Promise.resolve({ error: 'retries boom' }), + }); + + const client = createClient(); + await expect( + client.getRetries('job-1', 'ns1', 'project-a', 'dev', 'component-a'), + ).rejects.toThrow('retries boom'); + }); +}); + +describe('ObservabilityClient.getPodLogs', () => { + beforeEach(() => { + jest.clearAllMocks(); + resolveUrls.mockResolvedValue({ observerUrl: 'http://observer' }); + }); + + it('POSTs to the logs endpoint with the pod name in searchScope', async () => { + mockFetchApi.fetch.mockResolvedValueOnce( + mockOkResponse({ logs: [{ timestamp: 't1', message: 'hello' }] }), + ); + + const client = createClient(); + const result = await client.getPodLogs( + 'pod-1', + 'ns1', + 'project-a', + 'dev', + 'component-a', + { + startTime: '2026-03-05T09:00:00.000Z', + endTime: '2026-03-05T10:00:00.000Z', + limit: 100, + sortOrder: 'desc', + }, + ); + + expect(mockFetchApi.fetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetchApi.fetch.mock.calls[0]; + expect(url).toBe('http://observer/api/v1/logs/query'); + expect(options.method).toBe('POST'); + const payload = JSON.parse(options.body); + expect(payload.startTime).toBe('2026-03-05T09:00:00.000Z'); + expect(payload.endTime).toBe('2026-03-05T10:00:00.000Z'); + expect(payload.limit).toBe(100); + expect(payload.sortOrder).toBe('desc'); + expect(payload.searchScope).toEqual({ + namespace: 'ns1', + project: 'project-a', + component: 'component-a', + environment: 'dev', + podName: 'pod-1', + }); + expect(result).toEqual({ + logs: [{ timestamp: 't1', message: 'hello' }], + }); + }); + + it('applies default limit/sortOrder and default start/end when no options given', async () => { + mockFetchApi.fetch.mockResolvedValueOnce(mockOkResponse({ logs: [] })); + + const client = createClient(); + await client.getPodLogs('pod-1', 'ns1', 'project-a', 'dev', 'component-a'); + + const payload = JSON.parse(mockFetchApi.fetch.mock.calls[0][1].body); + expect(payload.limit).toBe(500); + expect(payload.sortOrder).toBe('asc'); + expect(typeof payload.startTime).toBe('string'); + expect(typeof payload.endTime).toBe('string'); + }); + + it('throws the parsed error when the response is not ok', async () => { + mockFetchApi.fetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Server Error', + json: () => Promise.resolve({ error: 'pod logs boom' }), + }); + + const client = createClient(); + await expect( + client.getPodLogs('pod-1', 'ns1', 'project-a', 'dev', 'component-a'), + ).rejects.toThrow('pod logs boom'); + }); +}); diff --git a/plugins/openchoreo-observability/src/api/ObservabilityApi.ts b/plugins/openchoreo-observability/src/api/ObservabilityApi.ts index 948555e02..e8145aaab 100644 --- a/plugins/openchoreo-observability/src/api/ObservabilityApi.ts +++ b/plugins/openchoreo-observability/src/api/ObservabilityApi.ts @@ -19,6 +19,10 @@ import { } from '../types'; import { LogsResponse } from '../components/RuntimeLogs/types'; import { EventsResponse } from '../components/RuntimeEvents/types'; +import type { + RunsQueryResponse, + RetriesQueryResponse, +} from '../components/Runs/types'; import { ObserverUrlCache } from './ObserverUrlCache'; export interface ObservabilityApi { @@ -184,6 +188,57 @@ export interface ObservabilityApi { environmentName: string, namespaceName: string, ): Promise; + + getRuns( + namespaceName: string, + projectName: string, + environmentName: string, + componentName: string, + options?: { + startTime?: string; + endTime?: string; + limit?: number; + offset?: number; + sortOrder?: 'asc' | 'desc'; + }, + ): Promise; + + getRetries( + jobName: string, + namespaceName: string, + projectName: string, + environmentName: string, + componentName: string, + options?: { + /** + * ISO timestamp for the lower bound of the retries lookup window. Pair + * with `endTime` — the backend rejects one-only (both-or-none contract). + * When both are omitted the backend falls back to a 30-day lookback, + * which can silently truncate under the observer adapter's per-call + * 1000-event cap on high-frequency CronJobs. + */ + startTime?: string; + /** + * ISO timestamp for the upper bound of the retries lookup window. See + * `startTime` for the both-or-none contract and truncation caveat. + */ + endTime?: string; + }, + ): Promise; + + getPodLogs( + podName: string, + namespaceName: string, + projectName: string, + environmentName: string, + componentName: string, + options?: { + startTime?: string; + endTime?: string; + limit?: number; + sortOrder?: 'asc' | 'desc'; + }, + ): Promise; } export const observabilityApiRef = createApiRef({ @@ -943,9 +998,6 @@ export class ObservabilityClient implements ObservabilityApi { if (error.includes('FinOps service is not configured')) { throw new Error('FinOps service is not configured'); } - if (error.includes('Observability is not configured for component')) { - throw new Error('Observability is not enabled for this component'); - } throw new Error( error || `Failed to fetch FinOps reports: ${response.statusText}`, ); @@ -1006,6 +1058,204 @@ export class ObservabilityClient implements ObservabilityApi { return data; } + async getRuns( + namespaceName: string, + projectName: string, + environmentName: string, + componentName: string, + options?: { + startTime?: string; + endTime?: string; + limit?: number; + offset?: number; + sortOrder?: 'asc' | 'desc'; + }, + ): Promise { + const { observerUrl } = await this.urlCache.resolveUrls( + namespaceName, + environmentName, + ); + + const response = await this.fetchApi.fetch( + `${observerUrl}/api/v1/scheduled-tasks/runs/query`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...DIRECT_HEADER }, + body: JSON.stringify({ + startTime: + options?.startTime ?? new Date(Date.now() - 3600000).toISOString(), + endTime: options?.endTime ?? new Date().toISOString(), + limit: options?.limit ?? 20, + offset: options?.offset ?? 0, + sortOrder: options?.sortOrder ?? 'desc', + searchScope: { + namespace: namespaceName, + project: projectName, + component: componentName, + environment: environmentName, + }, + }), + }, + ); + + if (!response.ok) { + const error = await this.parseError(response); + if (error.includes('Observability is not configured for component')) { + throw new Error('Observability is not enabled for this component'); + } + throw new Error(error || `Failed to fetch runs: ${response.statusText}`); + } + + const data = await response.json(); + return { + runs: (data.runs ?? []).map((r: any) => ({ + jobName: r.jobName ?? '', + status: r.status ?? 'unknown', + startTime: r.startTime ?? '', + completionTime: r.completionTime, + eventCount: r.eventCount ?? 0, + failureReason: r.failureReason, + events: r.events, + })), + total: data.total ?? 0, + tookMs: data.tookMs ?? 0, + }; + } + + async getRetries( + jobName: string, + namespaceName: string, + projectName: string, + environmentName: string, + componentName: string, + options?: { + /** + * ISO timestamp for the lower bound of the retries lookup window. Pair + * with `endTime` — the backend rejects one-only (both-or-none contract). + * When both are omitted the backend falls back to a 30-day lookback, + * which can silently truncate under the observer adapter's per-call + * 1000-event cap on high-frequency CronJobs. + */ + startTime?: string; + /** + * ISO timestamp for the upper bound of the retries lookup window. See + * `startTime` for the both-or-none contract and truncation caveat. + */ + endTime?: string; + }, + ): Promise { + const { observerUrl } = await this.urlCache.resolveUrls( + namespaceName, + environmentName, + ); + + const body: { + searchScope: { + namespace: string; + project: string; + component: string; + environment: string; + }; + startTime?: string; + endTime?: string; + } = { + searchScope: { + namespace: namespaceName, + project: projectName, + component: componentName, + environment: environmentName, + }, + }; + // Only include time bounds when BOTH are present. Backend rejects one-only. + if (options?.startTime && options?.endTime) { + body.startTime = options.startTime; + body.endTime = options.endTime; + } + + const response = await this.fetchApi.fetch( + `${observerUrl}/api/v1/scheduled-tasks/runs/${encodeURIComponent( + jobName, + )}/retries/query`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...DIRECT_HEADER }, + body: JSON.stringify(body), + }, + ); + + if (!response.ok) { + const error = await this.parseError(response); + throw new Error( + error || `Failed to fetch retries: ${response.statusText}`, + ); + } + + const data = await response.json(); + return { + retries: (data.retries ?? []).map((r: any) => ({ + podName: r.podName ?? '', + status: r.status ?? 'Unknown', + startTime: r.startTime ?? '', + eventCount: r.eventCount ?? 0, + events: r.events, + })), + total: data.total ?? 0, + tookMs: data.tookMs ?? 0, + }; + } + + async getPodLogs( + podName: string, + namespaceName: string, + projectName: string, + environmentName: string, + componentName: string, + options?: { + startTime?: string; + endTime?: string; + limit?: number; + sortOrder?: 'asc' | 'desc'; + }, + ): Promise { + const { observerUrl } = await this.urlCache.resolveUrls( + namespaceName, + environmentName, + ); + + const response = await this.fetchApi.fetch( + `${observerUrl}/api/v1/logs/query`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...DIRECT_HEADER }, + body: JSON.stringify({ + startTime: + options?.startTime ?? + new Date(Date.now() - 24 * 3600 * 1000).toISOString(), + endTime: options?.endTime ?? new Date().toISOString(), + limit: options?.limit ?? 500, + sortOrder: options?.sortOrder ?? 'asc', + searchScope: { + namespace: namespaceName, + project: projectName, + component: componentName, + environment: environmentName, + podName, + }, + }), + }, + ); + + if (!response.ok) { + const error = await this.parseError(response); + throw new Error( + error || `Failed to fetch pod logs: ${response.statusText}`, + ); + } + + const data = await response.json(); + return data; + } + private async parseError(response: Response): Promise { try { const error = await response.json(); diff --git a/plugins/openchoreo-observability/src/components/Runs/ObservabilityRunsPage.tsx b/plugins/openchoreo-observability/src/components/Runs/ObservabilityRunsPage.tsx new file mode 100644 index 000000000..5055c008a --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Runs/ObservabilityRunsPage.tsx @@ -0,0 +1,226 @@ +import { useEffect, useRef, useMemo, useState } from 'react'; +import { Box, Typography, Button } from '@material-ui/core'; +import { EmptyState, Progress, WarningIcon } from '@backstage/core-components'; +import { Alert } from '@material-ui/lab'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { RunsFilter } from './RunsFilter'; +import { RunsTable } from './RunsTable'; +import { RunsActions } from './RunsActions'; +import { + useRuns, + useGetNamespaceAndProjectByEntity, + useUrlFiltersForRuns, +} from '../../hooks'; +import { + useLogsPermission, + useProjectEnvironments, +} from '@openchoreo/backstage-plugin-react'; +import { useRuntimeLogsStyles } from '../RuntimeLogs/styles'; +import { EnvironmentsStatusNotice } from '../common'; +import type { Environment } from './types'; +import { RUNS_PAGE_SIZE } from './types'; + +const ObservabilityRunsContent = () => { + const classes = useRuntimeLogsStyles(); + const { entity } = useEntity(); + + const { namespace, project } = useGetNamespaceAndProjectByEntity(entity); + + const { + environments: projectEnvironments, + loading: environmentsLoading, + status: environmentsStatus, + } = useProjectEnvironments(project, namespace); + + // Map the upstream `{ name, displayName, ... }` environment shape onto the + // simpler `{ id, name, resourceName }` shape the Runs filter / URL sync use. + const environments = useMemo(() => { + return projectEnvironments.map(env => ({ + id: env.name, + name: env.displayName || env.name, + resourceName: env.name, + })); + }, [projectEnvironments]); + + const { filters, updateFilters } = useUrlFiltersForRuns({ + environments, + }); + + const selectedEnvironment = environments.find( + env => env.id === filters.environmentId, + ); + + const componentName = + entity.metadata.annotations?.[CHOREO_ANNOTATIONS.COMPONENT]; + + const [lastUpdated, setLastUpdated] = useState(new Date()); + + const { + runs, + loading: runsLoading, + error: runsError, + totalCount, + fetchRuns, + refresh, + } = useRuns(entity, namespace || '', project || '', { + environmentId: filters.environmentId, + environmentName: selectedEnvironment?.resourceName || '', + timeRange: filters.timeRange, + limit: RUNS_PAGE_SIZE, + offset: filters.page * RUNS_PAGE_SIZE, + sortOrder: filters.sortOrder, + }); + + const previousFiltersRef = useRef<{ + environmentId: string; + timeRange: string; + sortOrder: 'asc' | 'desc'; + page: number; + } | null>(null); + + useEffect(() => { + const currentFilters = { + environmentId: filters.environmentId, + timeRange: filters.timeRange, + sortOrder: filters.sortOrder, + page: filters.page, + }; + const filtersChanged = + previousFiltersRef.current === null || + JSON.stringify(previousFiltersRef.current) !== + JSON.stringify(currentFilters); + + if ( + filters.environmentId && + selectedEnvironment && + namespace && + project && + componentName && + filtersChanged + ) { + fetchRuns(true); + setLastUpdated(new Date()); + previousFiltersRef.current = currentFilters; + } + }, [ + filters.environmentId, + filters.timeRange, + filters.sortOrder, + filters.page, + fetchRuns, + selectedEnvironment, + namespace, + project, + componentName, + ]); + + useEffect(() => { + if (!runsLoading) setLastUpdated(new Date()); + }, [runsLoading]); + + const handleRefresh = () => { + refresh(); + setLastUpdated(new Date()); + }; + + const handleFiltersChange = (newFilters: Partial) => { + updateFilters(newFilters); + }; + + const renderError = (error: string) => { + const isObservabilityDisabled = error.includes( + 'Observability is not enabled', + ); + return ( + + + {isObservabilityDisabled + ? 'Observability is not enabled for this component. Please enable observability to view runs.' + : error} + + {!isObservabilityDisabled && ( + + )} + + ); + }; + + // When the pipeline has no resolvable environments (empty, forbidden, or + // unavailable) there's nothing to filter or list — show only the notice. + if (environmentsStatus !== 'ok' && !environmentsLoading) { + return ( + + + + ); + } + + return ( + + + + {runsError && renderError(runsError)} + + {filters.environmentId && selectedEnvironment && ( + <> + + + + + )} + + ); +}; + +export const ObservabilityRunsPage = () => { + const { + canViewLogs, + loading: permissionLoading, + deniedTooltip, + } = useLogsPermission(); + + if (permissionLoading) return ; + + if (!canViewLogs) { + return ( + + + {deniedTooltip} + + } + /> + ); + } + + return ; +}; diff --git a/plugins/openchoreo-observability/src/components/Runs/RetryRow.tsx b/plugins/openchoreo-observability/src/components/Runs/RetryRow.tsx new file mode 100644 index 000000000..7cbaf2281 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Runs/RetryRow.tsx @@ -0,0 +1,285 @@ +import { FC, MouseEvent, useEffect, useMemo, useState } from 'react'; +import { + TableRow, + TableCell, + Chip, + Collapse, + Box, + Typography, + CircularProgress, + IconButton, + Tooltip, +} from '@material-ui/core'; +import ExpandMore from '@material-ui/icons/ExpandMore'; +import ChevronRight from '@material-ui/icons/ChevronRight'; +import Refresh from '@material-ui/icons/Refresh'; +import type { Retry, RetryStatus } from './types'; +import { useLogEntryStyles } from '../RuntimeLogs/styles'; +import { useRunsStyles } from './styles'; +import { usePodLogs } from '../../hooks/usePodLogs'; + +interface RetryRowProps { + retry: Retry; + namespaceName: string; + projectName: string; + environmentName: string; + componentName: string; + runStartTime?: string; + runCompletionTime?: string; +} + +const formatTimestamp = (ts?: string) => { + if (!ts) return '—'; + try { + return new Date(ts).toLocaleString(); + } catch { + return ts; + } +}; + +const getStatusChipClass = ( + status: RetryStatus, + logClasses: ReturnType, + runClasses: ReturnType, +): string => { + switch (status) { + case 'Succeeded': + return runClasses.successChip; + case 'Failed': + return logClasses.errorChip; + case 'Running': + return runClasses.runningChip; + default: + return logClasses.undefinedChip; + } +}; + +export const RetryRow: FC = ({ + retry, + namespaceName, + projectName, + environmentName, + componentName, + runStartTime, + runCompletionTime, +}) => { + const logClasses = useLogEntryStyles(); + const runClasses = useRunsStyles(); + const [expanded, setExpanded] = useState(false); + const [eventsOpen, setEventsOpen] = useState(false); + const [logsOpen, setLogsOpen] = useState(true); + + const { logsStartTime, logsEndTime } = useMemo(() => { + const baseStart = retry.startTime || runStartTime; + const startMs = baseStart + ? new Date(baseStart).getTime() - 60 * 1000 + : Date.now() - 24 * 3600 * 1000; + const endMs = runCompletionTime + ? new Date(runCompletionTime).getTime() + 5 * 60 * 1000 + : Date.now(); + return { + logsStartTime: new Date(startMs).toISOString(), + logsEndTime: new Date(endMs).toISOString(), + }; + }, [retry.startTime, runStartTime, runCompletionTime]); + + const { + logs, + loading: logsLoading, + error: logsError, + fetchLogs, + } = usePodLogs({ + podName: expanded ? retry.podName : '', + namespaceName, + projectName, + environmentName, + componentName, + startTime: logsStartTime, + endTime: logsEndTime, + }); + + useEffect(() => { + if (expanded) { + fetchLogs(); + } + }, [expanded, fetchLogs]); + + const logLevelClass = (level?: string): string => { + switch ((level || '').toUpperCase()) { + case 'ERROR': + return runClasses.logLevelError; + case 'WARN': + case 'WARNING': + return runClasses.logLevelWarn; + case 'INFO': + return runClasses.logLevelInfo; + case 'DEBUG': + return runClasses.logLevelDebug; + default: + return ''; + } + }; + + return ( + <> + setExpanded(prev => !prev)} + > + + + + + {retry.podName} + + + {formatTimestamp(retry.startTime)} + + + {retry.eventCount} + + + + {expanded && ( + + + + + setLogsOpen(prev => !prev)} + > + {logsOpen ? ( + + ) : ( + + )} + + Logs ({logs.length}) + + + { + e.stopPropagation(); + if (!logsOpen) setLogsOpen(true); + fetchLogs(); + }} + > + + + + + + + {logsLoading && ( + + + + )} + + {logsError && ( + + {logsError} + + )} + + {!logsLoading && !logsError && logs.length === 0 && ( + + No logs found for this pod. + + )} + + {!logsLoading && !logsError && logs.length > 0 && ( + + {logs.map((log, idx) => ( + + + {formatTimestamp(log.timestamp)} + + + {log.level || '-'} + + + {log.log} + + + ))} + + )} + + + {retry.events && retry.events.length > 0 && ( + <> + setEventsOpen(prev => !prev)} + > + {eventsOpen ? ( + + ) : ( + + )} + + Events ({retry.events.length}) + + + + + {retry.events.map((event, idx) => ( + + + {formatTimestamp(event.timestamp)} + + + {event.reason} + + + {event.message} + + + ))} + + + + )} + + + + + )} + + ); +}; diff --git a/plugins/openchoreo-observability/src/components/Runs/RunRow.tsx b/plugins/openchoreo-observability/src/components/Runs/RunRow.tsx new file mode 100644 index 000000000..64784db8f --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Runs/RunRow.tsx @@ -0,0 +1,222 @@ +import { FC, useState, useEffect } from 'react'; +import { + TableRow, + TableCell, + Chip, + Collapse, + Box, + Typography, + Table, + TableHead, + TableBody, + CircularProgress, +} from '@material-ui/core'; +import type { Run, RunStatus } from './types'; +import { useLogEntryStyles } from '../RuntimeLogs/styles'; +import { useRunsStyles } from './styles'; +import { useRetries } from '../../hooks/useRetries'; +import { RetryRow } from './RetryRow'; + +interface RunRowProps { + run: Run; + namespaceName: string; + projectName: string; + environmentName: string; + componentName: string; +} + +const formatTimestamp = (ts?: string) => { + if (!ts) return '—'; + try { + return new Date(ts).toLocaleString(); + } catch { + return ts; + } +}; + +const formatDuration = ( + startTime?: string, + completionTime?: string, + status?: RunStatus, +): string => { + if (status !== 'succeeded' && status !== 'failed') return '—'; + if (!startTime || !completionTime) return '—'; + const startMs = new Date(startTime).getTime(); + const endMs = new Date(completionTime).getTime(); + if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs < startMs) { + return '—'; + } + const totalSec = Math.round((endMs - startMs) / 1000); + if (totalSec < 60) return `${totalSec}s`; + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + if (m < 60) return s ? `${m}m ${s}s` : `${m}m`; + const h = Math.floor(m / 60); + const remM = m % 60; + return remM ? `${h}h ${remM}m` : `${h}h`; +}; + +const getStatusChipClass = ( + status: RunStatus, + logClasses: ReturnType, + runClasses: ReturnType, +): string => { + switch (status) { + case 'succeeded': + return runClasses.successChip; + case 'failed': + return logClasses.errorChip; + case 'running': + return runClasses.runningChip; + default: + return logClasses.undefinedChip; + } +}; + +export const RunRow: FC = ({ + run, + namespaceName, + projectName, + environmentName, + componentName, +}) => { + const logClasses = useLogEntryStyles(); + const runClasses = useRunsStyles(); + const [expanded, setExpanded] = useState(false); + + const { + retries, + loading: retriesLoading, + error: retriesError, + fetchRetries, + } = useRetries({ + jobName: expanded ? run.jobName : '', + namespaceName, + projectName, + environmentName, + componentName, + // Scope retries fetch to this run's lifetime. When still running, + // `completionTime` is undefined — `useRetries` only forwards the pair + // when both are non-empty, so the backend uses its 30-day fallback. + startTime: run.startTime, + endTime: run.completionTime || undefined, + }); + + useEffect(() => { + if (expanded) { + fetchRetries(); + } + }, [expanded, fetchRetries]); + + return ( + <> + setExpanded(prev => !prev)} + > + + + {run.status.toUpperCase()} + + ({run.failureReason}) + + + ) : ( + run.status.toUpperCase() + ) + } + className={`${logClasses.logLevelChip} ${getStatusChipClass( + run.status, + logClasses, + runClasses, + )}`} + /> + + + {run.jobName} + + + {formatTimestamp(run.startTime)} + + + {formatTimestamp(run.completionTime)} + + + {formatDuration(run.startTime, run.completionTime, run.status)} + + {run.eventCount} + + + {expanded && ( + + + + + + Retries ({retries.length} pod{retries.length !== 1 ? 's' : ''} + ) + + + {retriesLoading && ( + + + + )} + + {retriesError && ( + + {retriesError} + + )} + + {!retriesLoading && !retriesError && retries.length > 0 && ( + + + + Status + Pod Name + Start Time + Events + + + + {retries.map(retry => ( + + ))} + +
+ )} + + {!retriesLoading && !retriesError && retries.length === 0 && ( + + No retry pods found for this run. + + )} +
+
+
+
+ )} + + ); +}; diff --git a/plugins/openchoreo-observability/src/components/Runs/RunsActions.test.tsx b/plugins/openchoreo-observability/src/components/Runs/RunsActions.test.tsx new file mode 100644 index 000000000..2642bd26a --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Runs/RunsActions.test.tsx @@ -0,0 +1,177 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { RunsActions } from './RunsActions'; +import { RunsFilters, RUNS_PAGE_SIZE } from './types'; + +const baseFilters: RunsFilters = { + environmentId: 'env-1', + timeRange: '24h', + sortOrder: 'desc', + page: 0, +}; + +function renderActions( + overrides: Partial> = {}, +) { + const defaultProps = { + totalCount: 100, + disabled: false, + onRefresh: jest.fn(), + filters: baseFilters, + onFiltersChange: jest.fn(), + lastUpdated: new Date('2026-06-01T10:00:00Z'), + }; + return { + ...render(), + props: { ...defaultProps, ...overrides }, + }; +} + +describe('RunsActions', () => { + describe('total count and showing-range display', () => { + it('shows total and current window when there are results', () => { + renderActions({ totalCount: 100, filters: { ...baseFilters, page: 0 } }); + expect(screen.getByText(/Total runs:\s*100/)).toBeInTheDocument(); + expect(screen.getByText(/showing 1-20/)).toBeInTheDocument(); + }); + + it('shows only total when there are zero results, no showing range', () => { + renderActions({ totalCount: 0 }); + expect(screen.getByText(/Total runs:\s*0/)).toBeInTheDocument(); + expect(screen.queryByText(/showing/)).not.toBeInTheDocument(); + }); + + it('caps endItem at totalCount on the last (partial) page', () => { + // With page size 20 and total 25, page 1 (0-indexed) should show 21-25. + renderActions({ + totalCount: 25, + filters: { ...baseFilters, page: 1 }, + }); + expect(screen.getByText(/showing 21-25/)).toBeInTheDocument(); + }); + }); + + describe('page indicator', () => { + it('displays 1 / N when on the first page', () => { + // total 45 / 20 = 3 pages + renderActions({ totalCount: 45 }); + expect(screen.getByText('Page 1 / 3')).toBeInTheDocument(); + }); + + it('always shows at least 1 total page even when totalCount is 0', () => { + renderActions({ totalCount: 0 }); + expect(screen.getByText('Page 1 / 1')).toBeInTheDocument(); + }); + }); + + describe('Prev button', () => { + it('is disabled on the first page', () => { + renderActions({ filters: { ...baseFilters, page: 0 } }); + expect(screen.getByRole('button', { name: /Prev/i })).toBeDisabled(); + }); + + it('is enabled on non-first pages', () => { + renderActions({ + totalCount: 100, + filters: { ...baseFilters, page: 1 }, + }); + expect(screen.getByRole('button', { name: /Prev/i })).toBeEnabled(); + }); + + it('dispatches page-1 to onFiltersChange when clicked', async () => { + const onFiltersChange = jest.fn(); + renderActions({ + totalCount: 100, + filters: { ...baseFilters, page: 2 }, + onFiltersChange, + }); + await userEvent.click(screen.getByRole('button', { name: /Prev/i })); + expect(onFiltersChange).toHaveBeenCalledWith({ page: 1 }); + }); + }); + + describe('Next button', () => { + it('is enabled when more pages remain', () => { + // 3 pages available, on page 0 + renderActions({ totalCount: 45, filters: { ...baseFilters, page: 0 } }); + expect(screen.getByRole('button', { name: /Next/i })).toBeEnabled(); + }); + + it('is disabled on the last page', () => { + // 3 pages, on page 2 (0-indexed) = last page + renderActions({ totalCount: 45, filters: { ...baseFilters, page: 2 } }); + expect(screen.getByRole('button', { name: /Next/i })).toBeDisabled(); + }); + + it('is disabled when totalCount is 0 (single-page indicator = only page)', () => { + renderActions({ totalCount: 0 }); + expect(screen.getByRole('button', { name: /Next/i })).toBeDisabled(); + }); + + it('dispatches page+1 to onFiltersChange when clicked', async () => { + const onFiltersChange = jest.fn(); + renderActions({ + totalCount: 100, + filters: { ...baseFilters, page: 1 }, + onFiltersChange, + }); + await userEvent.click(screen.getByRole('button', { name: /Next/i })); + expect(onFiltersChange).toHaveBeenCalledWith({ page: 2 }); + }); + }); + + describe('Refresh button', () => { + it('invokes onRefresh when clicked', async () => { + const onRefresh = jest.fn(); + renderActions({ onRefresh }); + await userEvent.click(screen.getByRole('button', { name: /Refresh/i })); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + }); + + describe('disabled prop', () => { + it('disables all three buttons regardless of pagination state', () => { + // On a middle page (Prev + Next would normally be enabled) verify the top-level + // `disabled` flag overrides both. + renderActions({ + totalCount: 100, + filters: { ...baseFilters, page: 1 }, + disabled: true, + }); + expect(screen.getByRole('button', { name: /Prev/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Next/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Refresh/i })).toBeDisabled(); + }); + }); + + describe('lastUpdated', () => { + it('renders the provided lastUpdated timestamp', () => { + renderActions({ lastUpdated: new Date('2026-06-01T10:00:00Z') }); + // formatDate uses local time zone; we just assert the "Last updated at:" prefix + // and the year appear so the test is TZ-agnostic. + expect(screen.getByText(/Last updated at:.*2026/)).toBeInTheDocument(); + }); + + it('falls back to a rendered date when lastUpdated is undefined', () => { + renderActions({ lastUpdated: undefined }); + // Match the DD/MM/YYYY, HH:MM:SS shape from formatDate. + expect( + screen.getByText( + /Last updated at:\s*\d{2}\/\d{2}\/\d{4},\s*\d{2}:\d{2}:\d{2}/, + ), + ).toBeInTheDocument(); + }); + }); + + describe('page size constant', () => { + it('uses RUNS_PAGE_SIZE for the window (guard against silent constant changes)', () => { + // If someone bumps RUNS_PAGE_SIZE, this test flags that the component was + // wired to it and the assertion needs updating alongside. + renderActions({ totalCount: RUNS_PAGE_SIZE * 2 }); + expect(screen.getByText('Page 1 / 2')).toBeInTheDocument(); + expect( + screen.getByText(new RegExp(`showing 1-${RUNS_PAGE_SIZE}`)), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Runs/RunsActions.tsx b/plugins/openchoreo-observability/src/components/Runs/RunsActions.tsx new file mode 100644 index 000000000..2b6a2381a --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Runs/RunsActions.tsx @@ -0,0 +1,107 @@ +import { Box, Typography, Button } from '@material-ui/core'; +import Refresh from '@material-ui/icons/Refresh'; +import NavigateBefore from '@material-ui/icons/NavigateBefore'; +import NavigateNext from '@material-ui/icons/NavigateNext'; +import { useLogsActionsStyles } from '../RuntimeLogs/styles'; +import type { RunsFilters } from './types'; +import { RUNS_PAGE_SIZE } from './types'; + +interface RunsActionsProps { + totalCount: number; + disabled: boolean; + onRefresh: () => void; + filters: RunsFilters; + onFiltersChange: (filters: Partial) => void; + lastUpdated?: Date; +} + +const formatDate = (date: Date): string => { + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `${day}/${month}/${year}, ${hours}:${minutes}:${seconds}`; +}; + +export const RunsActions = ({ + totalCount, + disabled, + onRefresh, + filters, + onFiltersChange, + lastUpdated, +}: RunsActionsProps) => { + const classes = useLogsActionsStyles(); + const displayDate = lastUpdated || new Date(); + + const page = filters.page; + const pageSize = RUNS_PAGE_SIZE; + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + const startItem = totalCount === 0 ? 0 : page * pageSize + 1; + const endItem = Math.min((page + 1) * pageSize, totalCount); + + const handlePrev = () => { + if (page > 0) { + onFiltersChange({ page: page - 1 }); + } + }; + + const handleNext = () => { + if (page + 1 < totalPages) { + onFiltersChange({ page: page + 1 }); + } + }; + + return ( + + + + Total runs: {totalCount} + {totalCount > 0 && ( + <> + {' '} + — showing {startItem}-{endItem} + + )} + + + Last updated at: {formatDate(displayDate)} + + + + + + Page {page + 1} / {totalPages} + + + + + + ); +}; diff --git a/plugins/openchoreo-observability/src/components/Runs/RunsFilter.tsx b/plugins/openchoreo-observability/src/components/Runs/RunsFilter.tsx new file mode 100644 index 000000000..47ee0e604 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Runs/RunsFilter.tsx @@ -0,0 +1,102 @@ +import { FC, ChangeEvent } from 'react'; +import { + FormControl, + InputLabel, + Select, + MenuItem, + Grid, +} from '@material-ui/core'; +import { Skeleton } from '@material-ui/lab'; +import type { RunsFilters, Environment } from './types'; +import { RUNS_TIME_RANGE_OPTIONS } from './types'; + +interface RunsFilterProps { + filters: RunsFilters; + onFiltersChange: (filters: Partial) => void; + environments: Environment[]; + environmentsLoading: boolean; + disabled?: boolean; +} + +export const RunsFilter: FC = ({ + filters, + onFiltersChange, + environments, + environmentsLoading, + disabled = false, +}) => { + const handleEnvironmentChange = (event: ChangeEvent<{ value: unknown }>) => { + onFiltersChange({ environmentId: event.target.value as string }); + }; + + const handleTimeRangeChange = (event: ChangeEvent<{ value: unknown }>) => { + onFiltersChange({ timeRange: event.target.value as string }); + }; + + const handleSortOrderChange = (event: ChangeEvent<{ value: unknown }>) => { + onFiltersChange({ sortOrder: event.target.value as 'asc' | 'desc' }); + }; + + return ( + + + + Environment + {environmentsLoading ? ( + + ) : ( + + )} + + + + + + Time Range + + + + + + + Sort Order + + + + + ); +}; diff --git a/plugins/openchoreo-observability/src/components/Runs/RunsTable.tsx b/plugins/openchoreo-observability/src/components/Runs/RunsTable.tsx new file mode 100644 index 000000000..4db1929e2 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Runs/RunsTable.tsx @@ -0,0 +1,124 @@ +import { FC } from 'react'; +import { + Table, + TableHead, + TableBody, + TableRow, + TableCell, + Paper, + Box, + Typography, + CircularProgress, +} from '@material-ui/core'; +import { Skeleton } from '@material-ui/lab'; +import type { Run } from './types'; +import { useLogsTableStyles } from '../RuntimeLogs/styles'; +import { RunRow } from './RunRow'; + +interface RunsTableProps { + runs: Run[]; + loading: boolean; + namespaceName: string; + projectName: string; + environmentName: string; + componentName: string; +} + +export const RunsTable: FC = ({ + runs, + loading, + namespaceName, + projectName, + environmentName, + componentName, +}) => { + const classes = useLogsTableStyles(); + + const renderLoadingSkeletons = () => + Array.from({ length: 5 }).map((_, i) => ( + + + + + + + + + + + + + + + + + + + + + )); + + const renderEmptyState = () => ( + + + + + No runs found + + + No scheduled task runs match the current filters in the selected + time range. + + + + + ); + + return ( + + + + + + + Status + + Job Name + + Start Time + + + Completion Time + + + Duration + + + Events + + + + + {loading && runs.length === 0 && renderLoadingSkeletons()} + {!loading && runs.length === 0 && renderEmptyState()} + {runs.map(run => ( + + ))} + +
+
+ {loading && runs.length > 0 && ( + + + + )} +
+ ); +}; diff --git a/plugins/openchoreo-observability/src/components/Runs/index.ts b/plugins/openchoreo-observability/src/components/Runs/index.ts new file mode 100644 index 000000000..fc0abc11e --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Runs/index.ts @@ -0,0 +1,7 @@ +export { ObservabilityRunsPage } from './ObservabilityRunsPage'; +export { RunsFilter } from './RunsFilter'; +export { RunsTable } from './RunsTable'; +export { RunsActions } from './RunsActions'; +export { RunRow } from './RunRow'; +export { RetryRow } from './RetryRow'; +export * from './types'; diff --git a/plugins/openchoreo-observability/src/components/Runs/styles.ts b/plugins/openchoreo-observability/src/components/Runs/styles.ts new file mode 100644 index 000000000..e0b094a37 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Runs/styles.ts @@ -0,0 +1,151 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useRunsStyles = makeStyles(theme => ({ + successChip: { + backgroundColor: '#c8e6c9', + color: '#2e7d32', + outline: '1px solid #4caf50', + }, + runningChip: { + backgroundColor: theme.palette.info.light, + color: theme.palette.info.dark, + outline: `1px solid ${theme.palette.info.main}`, + }, + statusChipReason: { + marginLeft: 4, + fontSize: '0.55rem', + fontStyle: 'italic', + fontWeight: 'normal', + opacity: 0.85, + }, + retriesContainer: { + padding: theme.spacing(1, 2), + }, + retriesTable: { + '& td': { + padding: '4px 8px !important', + fontSize: '0.75rem', + }, + '& th': { + padding: '4px 8px !important', + fontSize: '0.7rem', + fontWeight: 'bold', + }, + }, + eventsContainer: { + padding: theme.spacing(1), + marginTop: theme.spacing(1), + }, + eventItem: { + display: 'flex', + gap: theme.spacing(1), + padding: theme.spacing(0.5, 0), + borderBottom: `1px solid ${theme.palette.divider}`, + fontSize: '11px', + fontFamily: 'monospace', + '&:last-child': { + borderBottom: 'none', + }, + }, + eventTimestamp: { + color: theme.palette.text.secondary, + whiteSpace: 'nowrap', + minWidth: 160, + }, + eventReason: { + fontWeight: 'bold', + minWidth: 140, + }, + eventMessage: { + color: theme.palette.text.secondary, + wordBreak: 'break-word', + flex: 1, + }, + warningEvent: { + color: theme.palette.warning.dark, + }, + sectionTitle: { + fontSize: '11px', + fontWeight: 'bold', + marginBottom: theme.spacing(0.5), + marginTop: theme.spacing(1), + }, + sectionHeader: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + marginTop: theme.spacing(1), + marginBottom: theme.spacing(0.5), + cursor: 'pointer', + userSelect: 'none', + }, + sectionHeaderTitle: { + fontSize: '11px', + fontWeight: 'bold', + flex: 1, + }, + sectionToggleIcon: { + fontSize: '16px', + color: theme.palette.text.secondary, + }, + sectionRefreshButton: { + padding: 2, + }, + sectionRefreshIcon: { + fontSize: '14px', + }, + logsContainer: { + maxHeight: 220, + overflowY: 'auto', + border: `1px solid ${theme.palette.divider}`, + borderRadius: 4, + backgroundColor: theme.palette.background.default, + padding: theme.spacing(0.5, 1), + }, + logLine: { + display: 'flex', + gap: theme.spacing(1), + fontSize: '11px', + fontFamily: 'monospace', + padding: '2px 0', + borderBottom: `1px solid ${theme.palette.divider}`, + '&:last-child': { + borderBottom: 'none', + }, + }, + logTimestamp: { + color: theme.palette.text.secondary, + whiteSpace: 'nowrap', + minWidth: 160, + }, + logLevel: { + fontWeight: 'bold', + minWidth: 50, + textTransform: 'uppercase', + }, + logPod: { + color: theme.palette.text.secondary, + minWidth: 0, + maxWidth: 220, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + logMessage: { + flex: 1, + wordBreak: 'break-word', + whiteSpace: 'pre-wrap', + }, + logLevelError: { + color: theme.palette.error.main, + }, + logLevelWarn: { + color: theme.palette.warning.dark, + }, + logLevelInfo: { + color: theme.palette.info.dark, + }, + logLevelDebug: { + color: theme.palette.text.secondary, + }, +})); diff --git a/plugins/openchoreo-observability/src/components/Runs/types.ts b/plugins/openchoreo-observability/src/components/Runs/types.ts new file mode 100644 index 000000000..568a6ae93 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Runs/types.ts @@ -0,0 +1,75 @@ +/** + * Local Environment shape used by the Runs page filter / URL sync. + * The upstream `Environment` from `@openchoreo/backstage-plugin-react` + * (name / displayName / namespace / dataPlaneRef) is mapped onto this + * simpler `{ id, name, resourceName }` shape by `ObservabilityRunsPage`. + */ +export interface Environment { + id: string; + name: string; + resourceName: string; +} + +export type RunStatus = 'succeeded' | 'failed' | 'running' | 'unknown'; + +export interface RunEvent { + reason: string; + message: string; + timestamp: string; + type: 'Normal' | 'Warning'; +} + +export interface Run { + jobName: string; + status: RunStatus; + startTime: string; + completionTime?: string; + eventCount: number; + failureReason?: string; + events?: RunEvent[]; +} + +export interface RunsQueryResponse { + runs: Run[]; + total: number; + tookMs: number; +} + +export type RetryStatus = 'Succeeded' | 'Failed' | 'Running' | 'Unknown'; + +export interface RetryEvent { + reason: string; + message: string; + timestamp: string; + type: 'Normal' | 'Warning'; +} + +export interface Retry { + podName: string; + status: RetryStatus; + startTime: string; + eventCount: number; + events?: RetryEvent[]; +} + +export interface RetriesQueryResponse { + retries: Retry[]; + total: number; + tookMs: number; +} + +export interface RunsFilters { + environmentId: string; + timeRange: string; + sortOrder: 'asc' | 'desc'; + page: number; +} + +export const RUNS_TIME_RANGE_OPTIONS = [ + { value: '1h', label: 'Last 1 hour' }, + { value: '24h', label: 'Last 24 hours' }, + { value: '7d', label: 'Last 7 days' }, + { value: '14d', label: 'Last 14 days' }, +] as const; + +export const RUNS_PAGE_SIZE = 20; diff --git a/plugins/openchoreo-observability/src/hooks/index.ts b/plugins/openchoreo-observability/src/hooks/index.ts index cbc8a2f88..5fd8a1fd2 100644 --- a/plugins/openchoreo-observability/src/hooks/index.ts +++ b/plugins/openchoreo-observability/src/hooks/index.ts @@ -27,3 +27,7 @@ export { useComponentAlerts } from './useComponentAlerts'; export { useUrlFiltersForIncidents } from './useUrlFiltersForIncidents'; export { useProjectIncidents } from './useProjectIncidents'; export { useUpdateIncident } from './useUpdateIncident'; +export { useRuns } from './useRuns'; +export { useRetries } from './useRetries'; +export { usePodLogs } from './usePodLogs'; +export { useUrlFiltersForRuns } from './useUrlFiltersForRuns'; diff --git a/plugins/openchoreo-observability/src/hooks/usePodLogs.test.ts b/plugins/openchoreo-observability/src/hooks/usePodLogs.test.ts new file mode 100644 index 000000000..7a3408455 --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/usePodLogs.test.ts @@ -0,0 +1,174 @@ +import { act, renderHook } from '@testing-library/react'; +import { useApi } from '@backstage/core-plugin-api'; +import { usePodLogs } from './usePodLogs'; + +jest.mock('@backstage/core-plugin-api', () => { + const actual = jest.requireActual('@backstage/core-plugin-api'); + return { + ...actual, + useApi: jest.fn(), + }; +}); + +describe('usePodLogs', () => { + const getPodLogs = jest.fn(); + + const baseOptions = { + podName: 'pod-1', + namespaceName: 'dev', + projectName: 'project-a', + environmentName: 'development', + componentName: 'component-a', + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useApi as jest.Mock).mockReturnValue({ getPodLogs }); + }); + + it('starts with empty logs, no loading, no error', () => { + const { result } = renderHook(() => usePodLogs(baseOptions)); + + expect(result.current.logs).toEqual([]); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('does NOT call the API when podName is empty', async () => { + const { result } = renderHook(() => + usePodLogs({ ...baseOptions, podName: '' }), + ); + + await act(async () => { + await result.current.fetchLogs(); + }); + + expect(getPodLogs).not.toHaveBeenCalled(); + expect(result.current.logs).toEqual([]); + }); + + it('does NOT call the API when componentName is empty', async () => { + const { result } = renderHook(() => + usePodLogs({ ...baseOptions, componentName: '' }), + ); + + await act(async () => { + await result.current.fetchLogs(); + }); + + expect(getPodLogs).not.toHaveBeenCalled(); + }); + + it('fetchLogs populates logs on success', async () => { + getPodLogs.mockResolvedValueOnce({ + logs: [{ timestamp: 't1', body: 'hello' }], + totalCount: 1, + }); + + const { result } = renderHook(() => + usePodLogs({ + ...baseOptions, + startTime: '2026-03-05T09:00:00.000Z', + endTime: '2026-03-05T10:00:00.000Z', + }), + ); + + await act(async () => { + await result.current.fetchLogs(); + }); + + expect(getPodLogs).toHaveBeenCalledWith( + 'pod-1', + 'dev', + 'project-a', + 'development', + 'component-a', + { + startTime: '2026-03-05T09:00:00.000Z', + endTime: '2026-03-05T10:00:00.000Z', + limit: 500, + sortOrder: 'asc', + }, + ); + expect(result.current.logs).toHaveLength(1); + expect(result.current.error).toBeNull(); + expect(result.current.loading).toBe(false); + }); + + it('passes undefined time bounds when caller does not provide them', async () => { + getPodLogs.mockResolvedValueOnce({ logs: [] }); + + const { result } = renderHook(() => usePodLogs(baseOptions)); + + await act(async () => { + await result.current.fetchLogs(); + }); + + expect(getPodLogs).toHaveBeenCalledWith( + 'pod-1', + 'dev', + 'project-a', + 'development', + 'component-a', + { + startTime: undefined, + endTime: undefined, + limit: 500, + sortOrder: 'asc', + }, + ); + }); + + it('sets error state when the API rejects with an Error', async () => { + getPodLogs.mockRejectedValueOnce(new Error('boom')); + + const { result } = renderHook(() => usePodLogs(baseOptions)); + + await act(async () => { + await result.current.fetchLogs(); + }); + + expect(result.current.error).toBe('boom'); + expect(result.current.logs).toEqual([]); + expect(result.current.loading).toBe(false); + }); + + it('uses a generic error message for non-Error rejections', async () => { + getPodLogs.mockRejectedValueOnce('unknown'); + + const { result } = renderHook(() => usePodLogs(baseOptions)); + + await act(async () => { + await result.current.fetchLogs(); + }); + + expect(result.current.error).toBe('Failed to fetch logs'); + }); + + it('stale-request guard: an older in-flight call cannot overwrite the newer result', async () => { + let resolveFirst!: (v: any) => void; + const firstPromise = new Promise(resolve => { + resolveFirst = resolve; + }); + getPodLogs.mockReturnValueOnce(firstPromise); + getPodLogs.mockResolvedValueOnce({ + logs: [{ timestamp: 't2', body: 'newer' }], + }); + + const { result } = renderHook(() => usePodLogs(baseOptions)); + + await act(async () => { + result.current.fetchLogs(); + await result.current.fetchLogs(); + }); + + expect(result.current.logs).toEqual([{ timestamp: 't2', body: 'newer' }]); + + await act(async () => { + resolveFirst({ logs: [{ timestamp: 't1', body: 'older' }] }); + await Promise.resolve(); + }); + + expect(result.current.logs).toEqual([{ timestamp: 't2', body: 'newer' }]); + }); +}); diff --git a/plugins/openchoreo-observability/src/hooks/usePodLogs.ts b/plugins/openchoreo-observability/src/hooks/usePodLogs.ts new file mode 100644 index 000000000..5f263373b --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/usePodLogs.ts @@ -0,0 +1,83 @@ +import { useCallback, useRef, useState } from 'react'; +import { useApi } from '@backstage/core-plugin-api'; +import { observabilityApiRef } from '../api/ObservabilityApi'; +import type { LogEntry } from '../components/RuntimeLogs/types'; + +export interface UsePodLogsOptions { + podName: string; + namespaceName: string; + projectName: string; + environmentName: string; + componentName: string; + startTime?: string; + endTime?: string; +} + +export interface UsePodLogsResult { + logs: LogEntry[]; + loading: boolean; + error: string | null; + fetchLogs: () => Promise; +} + +export function usePodLogs(options: UsePodLogsOptions): UsePodLogsResult { + const observabilityApi = useApi(observabilityApiRef); + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const requestVersionRef = useRef(0); + + const fetchLogs = useCallback(async () => { + if ( + !options.podName || + !options.namespaceName || + !options.environmentName || + !options.componentName + ) { + setLogs([]); + return; + } + + const version = ++requestVersionRef.current; + + try { + setLoading(true); + setError(null); + + const response = await observabilityApi.getPodLogs( + options.podName, + options.namespaceName, + options.projectName, + options.environmentName, + options.componentName, + { + startTime: options.startTime, + endTime: options.endTime, + limit: 500, + sortOrder: 'asc', + }, + ); + + if (version !== requestVersionRef.current) return; + setLogs(response.logs ?? []); + } catch (err) { + if (version !== requestVersionRef.current) return; + setError(err instanceof Error ? err.message : 'Failed to fetch logs'); + } finally { + if (version === requestVersionRef.current) { + setLoading(false); + } + } + }, [ + observabilityApi, + options.podName, + options.namespaceName, + options.projectName, + options.environmentName, + options.componentName, + options.startTime, + options.endTime, + ]); + + return { logs, loading, error, fetchLogs }; +} diff --git a/plugins/openchoreo-observability/src/hooks/useRetries.test.ts b/plugins/openchoreo-observability/src/hooks/useRetries.test.ts new file mode 100644 index 000000000..92c2908b1 --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/useRetries.test.ts @@ -0,0 +1,224 @@ +import { act, renderHook } from '@testing-library/react'; +import { useApi } from '@backstage/core-plugin-api'; +import { useRetries } from './useRetries'; + +jest.mock('@backstage/core-plugin-api', () => { + const actual = jest.requireActual('@backstage/core-plugin-api'); + return { + ...actual, + useApi: jest.fn(), + }; +}); + +describe('useRetries', () => { + const getRetries = jest.fn(); + + const baseOptions = { + jobName: 'job-1', + namespaceName: 'dev', + projectName: 'project-a', + environmentName: 'development', + componentName: 'component-a', + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useApi as jest.Mock).mockReturnValue({ getRetries }); + }); + + it('starts with empty retries, no loading, no error', () => { + const { result } = renderHook(() => useRetries(baseOptions)); + + expect(result.current.retries).toEqual([]); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('does NOT call the API when jobName is empty', async () => { + const { result } = renderHook(() => + useRetries({ ...baseOptions, jobName: '' }), + ); + + await act(async () => { + await result.current.fetchRetries(); + }); + + expect(getRetries).not.toHaveBeenCalled(); + }); + + it('does NOT call the API when namespaceName is empty', async () => { + const { result } = renderHook(() => + useRetries({ ...baseOptions, namespaceName: '' }), + ); + + await act(async () => { + await result.current.fetchRetries(); + }); + + expect(getRetries).not.toHaveBeenCalled(); + }); + + it('calls the API WITH both time bounds when both are provided', async () => { + getRetries.mockResolvedValueOnce({ + retries: [ + { + podName: 'pod-1', + status: 'Succeeded', + startTime: '2026-03-05T10:00:00.000Z', + eventCount: 1, + }, + ], + total: 1, + }); + + const { result } = renderHook(() => + useRetries({ + ...baseOptions, + startTime: '2026-03-05T09:00:00.000Z', + endTime: '2026-03-05T10:00:00.000Z', + }), + ); + + await act(async () => { + await result.current.fetchRetries(); + }); + + expect(getRetries).toHaveBeenCalledWith( + 'job-1', + 'dev', + 'project-a', + 'development', + 'component-a', + { + startTime: '2026-03-05T09:00:00.000Z', + endTime: '2026-03-05T10:00:00.000Z', + }, + ); + expect(result.current.retries).toHaveLength(1); + expect(result.current.retries[0].podName).toBe('pod-1'); + }); + + it('calls the API WITHOUT time bounds when only startTime is provided', async () => { + getRetries.mockResolvedValueOnce({ retries: [], total: 0 }); + + const { result } = renderHook(() => + useRetries({ + ...baseOptions, + startTime: '2026-03-05T09:00:00.000Z', + }), + ); + + await act(async () => { + await result.current.fetchRetries(); + }); + + expect(getRetries).toHaveBeenCalledWith( + 'job-1', + 'dev', + 'project-a', + 'development', + 'component-a', + undefined, + ); + }); + + it('calls the API WITHOUT time bounds when only endTime is provided', async () => { + getRetries.mockResolvedValueOnce({ retries: [], total: 0 }); + + const { result } = renderHook(() => + useRetries({ + ...baseOptions, + endTime: '2026-03-05T10:00:00.000Z', + }), + ); + + await act(async () => { + await result.current.fetchRetries(); + }); + + expect(getRetries).toHaveBeenCalledWith( + 'job-1', + 'dev', + 'project-a', + 'development', + 'component-a', + undefined, + ); + }); + + it('calls the API WITHOUT time bounds when neither is provided', async () => { + getRetries.mockResolvedValueOnce({ retries: [], total: 0 }); + + const { result } = renderHook(() => useRetries(baseOptions)); + + await act(async () => { + await result.current.fetchRetries(); + }); + + expect(getRetries).toHaveBeenCalledWith( + 'job-1', + 'dev', + 'project-a', + 'development', + 'component-a', + undefined, + ); + }); + + it('sets error state when the API rejects with an Error', async () => { + getRetries.mockRejectedValueOnce(new Error('boom')); + + const { result } = renderHook(() => useRetries(baseOptions)); + + await act(async () => { + await result.current.fetchRetries(); + }); + + expect(result.current.error).toBe('boom'); + expect(result.current.retries).toEqual([]); + expect(result.current.loading).toBe(false); + }); + + it('uses a generic error message for non-Error rejections', async () => { + getRetries.mockRejectedValueOnce('unknown'); + + const { result } = renderHook(() => useRetries(baseOptions)); + + await act(async () => { + await result.current.fetchRetries(); + }); + + expect(result.current.error).toBe('Failed to fetch retries'); + }); + + it('stale-request guard: an older in-flight call cannot overwrite the newer result', async () => { + let resolveFirst!: (v: any) => void; + const firstPromise = new Promise(resolve => { + resolveFirst = resolve; + }); + getRetries.mockReturnValueOnce(firstPromise); + getRetries.mockResolvedValueOnce({ + retries: [{ podName: 'newer', status: 'Succeeded' }], + total: 1, + }); + + const { result } = renderHook(() => useRetries(baseOptions)); + + await act(async () => { + result.current.fetchRetries(); + await result.current.fetchRetries(); + }); + + expect(result.current.retries[0].podName).toBe('newer'); + + await act(async () => { + resolveFirst({ + retries: [{ podName: 'older', status: 'Succeeded' }], + total: 99, + }); + await Promise.resolve(); + }); + + expect(result.current.retries[0].podName).toBe('newer'); + }); +}); diff --git a/plugins/openchoreo-observability/src/hooks/useRetries.ts b/plugins/openchoreo-observability/src/hooks/useRetries.ts new file mode 100644 index 000000000..f6cf2854c --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/useRetries.ts @@ -0,0 +1,95 @@ +import { useCallback, useRef, useState } from 'react'; +import { useApi } from '@backstage/core-plugin-api'; +import { observabilityApiRef } from '../api/ObservabilityApi'; +import { Retry } from '../components/Runs/types'; + +export interface UseRetriesOptions { + jobName: string; + namespaceName: string; + projectName: string; + environmentName: string; + componentName: string; + /** + * Optional. Scope the events fetch to a specific run's lifetime. Must be + * paired with `endTime` — the backend rejects one-only (both-or-none). Set + * these when the caller knows the run window so the observer adapter does + * not silently truncate at its 1000-event-per-call cap on high-frequency + * CronJobs. When omitted, the backend falls back to a 30-day lookback. + */ + startTime?: string; + /** + * Optional. See `startTime` — both must be provided together. + */ + endTime?: string; +} + +export interface UseRetriesResult { + retries: Retry[]; + loading: boolean; + error: string | null; + fetchRetries: () => Promise; +} + +export function useRetries(options: UseRetriesOptions): UseRetriesResult { + const observabilityApi = useApi(observabilityApiRef); + const [retries, setRetries] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const requestVersionRef = useRef(0); + + const fetchRetries = useCallback(async () => { + if ( + !options.jobName || + !options.namespaceName || + !options.environmentName || + !options.componentName + ) { + return; + } + + const version = ++requestVersionRef.current; + + try { + setLoading(true); + setError(null); + + const response = await observabilityApi.getRetries( + options.jobName, + options.namespaceName, + options.projectName, + options.environmentName, + options.componentName, + options.startTime && options.endTime + ? { startTime: options.startTime, endTime: options.endTime } + : undefined, + ); + + if (version !== requestVersionRef.current) return; + + setRetries(response.retries ?? []); + } catch (err) { + if (version !== requestVersionRef.current) return; + setError(err instanceof Error ? err.message : 'Failed to fetch retries'); + } finally { + if (version === requestVersionRef.current) { + setLoading(false); + } + } + }, [ + observabilityApi, + options.jobName, + options.namespaceName, + options.projectName, + options.environmentName, + options.componentName, + options.startTime, + options.endTime, + ]); + + return { + retries, + loading, + error, + fetchRetries, + }; +} diff --git a/plugins/openchoreo-observability/src/hooks/useRuns.test.ts b/plugins/openchoreo-observability/src/hooks/useRuns.test.ts new file mode 100644 index 000000000..5f8ff71dd --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/useRuns.test.ts @@ -0,0 +1,218 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { useApi } from '@backstage/core-plugin-api'; +import { useRuns } from './useRuns'; + +jest.mock('@backstage/core-plugin-api', () => { + const actual = jest.requireActual('@backstage/core-plugin-api'); + return { + ...actual, + useApi: jest.fn(), + }; +}); + +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + calculateTimeRange: jest.fn().mockReturnValue({ + startTime: '2026-03-05T09:00:00.000Z', + endTime: '2026-03-05T10:00:00.000Z', + }), +})); + +describe('useRuns', () => { + const getRuns = jest.fn(); + + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'component-a', + annotations: { + 'openchoreo.io/namespace': 'dev', + 'openchoreo.io/component': 'component-a', + }, + }, + spec: { owner: 'group:default/team' }, + }; + + const options = { + environmentId: 'env-1', + environmentName: 'development', + timeRange: '24h', + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useApi as jest.Mock).mockReturnValue({ getRuns }); + }); + + it('starts with empty runs, no loading, no error', () => { + const { result } = renderHook(() => + useRuns(entity as any, 'dev', 'project-a', options), + ); + + expect(result.current.runs).toEqual([]); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.totalCount).toBe(0); + }); + + it('fetchRuns populates runs and totalCount on success', async () => { + getRuns.mockResolvedValueOnce({ + runs: [ + { + jobName: 'job-1', + status: 'succeeded', + startTime: '2026-03-05T10:00:00.000Z', + eventCount: 2, + }, + ], + total: 1, + tookMs: 5, + }); + + const { result } = renderHook(() => + useRuns(entity as any, 'dev', 'project-a', options), + ); + + await act(async () => { + await result.current.fetchRuns(); + }); + + expect(getRuns).toHaveBeenCalledTimes(1); + expect(getRuns).toHaveBeenCalledWith( + 'dev', + 'project-a', + 'development', + 'component-a', + expect.objectContaining({ + limit: 20, + offset: 0, + startTime: '2026-03-05T09:00:00.000Z', + endTime: '2026-03-05T10:00:00.000Z', + sortOrder: 'desc', + }), + ); + expect(result.current.runs).toHaveLength(1); + expect(result.current.runs[0].jobName).toBe('job-1'); + expect(result.current.totalCount).toBe(1); + expect(result.current.error).toBeNull(); + expect(result.current.loading).toBe(false); + }); + + it('sets error state when the API rejects with an Error', async () => { + getRuns.mockRejectedValueOnce(new Error('boom')); + + const { result } = renderHook(() => + useRuns(entity as any, 'dev', 'project-a', options), + ); + + await act(async () => { + await result.current.fetchRuns(); + }); + + expect(result.current.error).toBe('boom'); + expect(result.current.runs).toEqual([]); + expect(result.current.loading).toBe(false); + }); + + it('uses a generic error message for non-Error rejections', async () => { + getRuns.mockRejectedValueOnce('unknown'); + + const { result } = renderHook(() => + useRuns(entity as any, 'dev', 'project-a', options), + ); + + await act(async () => { + await result.current.fetchRuns(); + }); + + expect(result.current.error).toBe('Failed to fetch runs'); + }); + + it('skips the API call when required identifiers are missing', async () => { + const entityNoComponent = { + ...entity, + metadata: { name: 'x', annotations: {} }, + }; + + const { result } = renderHook(() => + useRuns(entityNoComponent as any, 'dev', 'project-a', options), + ); + + await act(async () => { + await result.current.fetchRuns(); + }); + + expect(getRuns).not.toHaveBeenCalled(); + expect(result.current.runs).toEqual([]); + expect(result.current.error).toBeNull(); + }); + + it('refresh clears runs then fetches again', async () => { + getRuns + .mockResolvedValueOnce({ + runs: [{ jobName: 'job-a', status: 'succeeded' }], + total: 1, + }) + .mockResolvedValueOnce({ + runs: [{ jobName: 'job-b', status: 'succeeded' }], + total: 1, + }); + + const { result } = renderHook(() => + useRuns(entity as any, 'dev', 'project-a', options), + ); + + await act(async () => { + await result.current.fetchRuns(); + }); + expect(result.current.runs[0].jobName).toBe('job-a'); + + await act(async () => { + result.current.refresh(); + }); + await waitFor(() => expect(getRuns).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(result.current.runs).toHaveLength(1)); + expect(result.current.runs[0].jobName).toBe('job-b'); + }); + + it('stale-request guard: an older in-flight call cannot overwrite the newer result', async () => { + // First call: never resolves within the test window. + let resolveFirst!: (v: any) => void; + const firstPromise = new Promise(resolve => { + resolveFirst = resolve; + }); + getRuns.mockReturnValueOnce(firstPromise); + // Second call: resolves immediately with the "newer" data. + getRuns.mockResolvedValueOnce({ + runs: [{ jobName: 'newer', status: 'succeeded' }], + total: 1, + }); + + const { result } = renderHook(() => + useRuns(entity as any, 'dev', 'project-a', options), + ); + + // Kick off both fetches, then resolve the first one late. + await act(async () => { + // Fire the first (unresolved) fetch — do not await, or we'd deadlock. + result.current.fetchRuns(); + // Fire the second fetch; this awaits and updates state to "newer". + await result.current.fetchRuns(); + }); + + expect(result.current.runs[0].jobName).toBe('newer'); + + // Now resolve the older, in-flight first call — the guard must ignore it. + await act(async () => { + resolveFirst({ + runs: [{ jobName: 'older', status: 'succeeded' }], + total: 99, + }); + // Yield a microtask so any pending .then handlers run. + await Promise.resolve(); + }); + + expect(result.current.runs[0].jobName).toBe('newer'); + expect(result.current.totalCount).toBe(1); + }); +}); diff --git a/plugins/openchoreo-observability/src/hooks/useRuns.ts b/plugins/openchoreo-observability/src/hooks/useRuns.ts new file mode 100644 index 000000000..f9aba0deb --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/useRuns.ts @@ -0,0 +1,117 @@ +import { useCallback, useRef, useState } from 'react'; +import { useApi } from '@backstage/core-plugin-api'; +import { Entity } from '@backstage/catalog-model'; +import { observabilityApiRef } from '../api/ObservabilityApi'; +import { calculateTimeRange } from '@openchoreo/backstage-plugin-react'; +import { Run } from '../components/Runs/types'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; + +export interface UseRunsOptions { + environmentId: string; + environmentName: string; + timeRange: string; + limit?: number; + offset?: number; + sortOrder?: 'asc' | 'desc'; +} + +export interface UseRunsResult { + runs: Run[]; + loading: boolean; + error: string | null; + totalCount: number; + fetchRuns: (reset?: boolean) => Promise; + refresh: () => void; +} + +export function useRuns( + entity: Entity, + namespace: string, + project: string, + options: UseRunsOptions, +): UseRunsResult { + const observabilityApi = useApi(observabilityApiRef); + const [runs, setRuns] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [totalCount, setTotalCount] = useState(0); + const requestVersionRef = useRef(0); + + const componentName = + entity.metadata.annotations?.[CHOREO_ANNOTATIONS.COMPONENT]; + + const fetchRuns = useCallback( + async (_reset = true) => { + if ( + !options.environmentId || + !options.environmentName || + !namespace || + !project || + !componentName + ) { + return; + } + + const version = ++requestVersionRef.current; + + try { + setLoading(true); + setError(null); + + const { startTime, endTime } = calculateTimeRange(options.timeRange); + + const response = await observabilityApi.getRuns( + namespace, + project, + options.environmentName, + componentName, + { + limit: options.limit ?? 20, + offset: options.offset ?? 0, + startTime, + endTime, + sortOrder: options.sortOrder ?? 'desc', + }, + ); + + if (version !== requestVersionRef.current) return; + + setRuns(response.runs ?? []); + setTotalCount(response.total ?? 0); + } catch (err) { + if (version !== requestVersionRef.current) return; + setError(err instanceof Error ? err.message : 'Failed to fetch runs'); + } finally { + if (version === requestVersionRef.current) { + setLoading(false); + } + } + }, + [ + observabilityApi, + options.environmentId, + options.environmentName, + options.timeRange, + options.limit, + options.offset, + options.sortOrder, + namespace, + project, + componentName, + ], + ); + + const refresh = useCallback(() => { + setRuns([]); + fetchRuns(true); + }, [fetchRuns]); + + return { + runs, + loading, + error, + totalCount, + fetchRuns, + refresh, + }; +} diff --git a/plugins/openchoreo-observability/src/hooks/useUrlFiltersForRuns.test.tsx b/plugins/openchoreo-observability/src/hooks/useUrlFiltersForRuns.test.tsx new file mode 100644 index 000000000..8f3c85425 --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/useUrlFiltersForRuns.test.tsx @@ -0,0 +1,143 @@ +import { act, renderHook } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { useUrlFiltersForRuns } from './useUrlFiltersForRuns'; + +const environments = [ + { id: 'env-1', name: 'development', resourceName: 'dev' }, + { id: 'env-2', name: 'production', resourceName: 'prod' }, +]; + +const renderFilters = (initialEntry = '/', envs = environments) => + renderHook(() => useUrlFiltersForRuns({ environments: envs }), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + }); + +describe('useUrlFiltersForRuns', () => { + describe('parsing', () => { + it('applies default filters when the URL is empty', () => { + const { result } = renderFilters('/'); + // First render sees defaults; the auto-select effect then writes env=env-1. + expect(result.current.filters).toEqual( + expect.objectContaining({ + timeRange: '24h', + sortOrder: 'desc', + page: 0, + }), + ); + }); + + it('auto-selects the first environment when none is in the URL', () => { + const { result } = renderFilters('/'); + // Effect runs synchronously under RTL's act; the environment is written back. + expect(result.current.filters.environmentId).toBe('env-1'); + }); + + it('reads env, timeRange, sort and page from the URL', () => { + const { result } = renderFilters( + '/?env=env-2&timeRange=7d&sort=asc&page=3', + ); + + expect(result.current.filters.environmentId).toBe('env-2'); + expect(result.current.filters.timeRange).toBe('7d'); + expect(result.current.filters.sortOrder).toBe('asc'); + expect(result.current.filters.page).toBe(3); + }); + + it('falls back to the default when timeRange is not in the whitelist', () => { + const { result } = renderFilters('/?env=env-1&timeRange=bogus'); + expect(result.current.filters.timeRange).toBe('24h'); + }); + + it('falls back to desc sort order when the sort param is invalid', () => { + const { result } = renderFilters('/?env=env-1&sort=weird'); + expect(result.current.filters.sortOrder).toBe('desc'); + }); + + it('clamps a negative page to 0 and treats non-numeric as 0', () => { + const negative = renderFilters('/?env=env-1&page=-5'); + expect(negative.result.current.filters.page).toBe(0); + + const nonNumeric = renderFilters('/?env=env-1&page=abc'); + expect(nonNumeric.result.current.filters.page).toBe(0); + }); + + it('rejects an env id not in the list and auto-selects the first', () => { + const { result } = renderFilters('/?env=missing'); + expect(result.current.filters.environmentId).toBe('env-1'); + }); + + it('leaves environmentId empty when the environments list is empty', () => { + const { result } = renderFilters('/', []); + expect(result.current.filters.environmentId).toBe(''); + }); + }); + + describe('updateFilters', () => { + it('writes a new environmentId and resets page', () => { + const { result } = renderFilters('/?env=env-1&page=2'); + act(() => result.current.updateFilters({ environmentId: 'env-2' })); + expect(result.current.filters.environmentId).toBe('env-2'); + expect(result.current.filters.page).toBe(0); + }); + + it('clears the environmentId when passed an empty string', () => { + // Empty environments list so the auto-select effect doesn't re-fill it. + const { result } = renderFilters('/?env=env-1', []); + act(() => result.current.updateFilters({ environmentId: '' })); + expect(result.current.filters.environmentId).toBe(''); + }); + + it('persists a non-default timeRange and clears page', () => { + const { result } = renderFilters('/?env=env-1&page=2'); + act(() => result.current.updateFilters({ timeRange: '7d' })); + expect(result.current.filters.timeRange).toBe('7d'); + expect(result.current.filters.page).toBe(0); + }); + + it('resetting timeRange to the default removes it from the URL', () => { + const { result } = renderFilters('/?env=env-1&timeRange=7d'); + act(() => result.current.updateFilters({ timeRange: '24h' })); + expect(result.current.filters.timeRange).toBe('24h'); + }); + + it('persists asc sort and treats desc as the default', () => { + const { result } = renderFilters('/?env=env-1'); + act(() => result.current.updateFilters({ sortOrder: 'asc' })); + expect(result.current.filters.sortOrder).toBe('asc'); + act(() => result.current.updateFilters({ sortOrder: 'desc' })); + expect(result.current.filters.sortOrder).toBe('desc'); + }); + + it('writes page > 0 and clears it when set back to 0', () => { + const { result } = renderFilters('/?env=env-1'); + act(() => result.current.updateFilters({ page: 4 })); + expect(result.current.filters.page).toBe(4); + act(() => result.current.updateFilters({ page: 0 })); + expect(result.current.filters.page).toBe(0); + }); + }); + + describe('resetFilters', () => { + it('resets to the first environment and clears every other param', () => { + const { result } = renderFilters( + '/?env=env-2&timeRange=7d&sort=asc&page=5', + ); + + act(() => result.current.resetFilters()); + + expect(result.current.filters.environmentId).toBe('env-1'); + expect(result.current.filters.timeRange).toBe('24h'); + expect(result.current.filters.sortOrder).toBe('desc'); + expect(result.current.filters.page).toBe(0); + }); + + it('leaves env empty when there are no environments', () => { + const { result } = renderFilters('/?env=env-1&page=5', []); + act(() => result.current.resetFilters()); + expect(result.current.filters.environmentId).toBe(''); + expect(result.current.filters.page).toBe(0); + }); + }); +}); diff --git a/plugins/openchoreo-observability/src/hooks/useUrlFiltersForRuns.ts b/plugins/openchoreo-observability/src/hooks/useUrlFiltersForRuns.ts new file mode 100644 index 000000000..c26b41f93 --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/useUrlFiltersForRuns.ts @@ -0,0 +1,115 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import type { Environment, RunsFilters } from '../components/Runs/types'; +import { RUNS_TIME_RANGE_OPTIONS } from '../components/Runs/types'; + +const DEFAULT_TIME_RANGE = '24h'; +const VALID_TIME_RANGES: readonly string[] = RUNS_TIME_RANGE_OPTIONS.map( + o => o.value, +); + +interface UseUrlFiltersForRunsOptions { + environments: Environment[]; +} + +export function useUrlFiltersForRuns({ + environments, +}: UseUrlFiltersForRunsOptions): { + filters: RunsFilters; + updateFilters: (newFilters: Partial) => void; + resetFilters: () => void; +} { + const [searchParams, setSearchParams] = useSearchParams(); + + const filters = useMemo(() => { + const envId = searchParams.get('env'); + const rawTimeRange = searchParams.get('timeRange') || DEFAULT_TIME_RANGE; + const timeRange = VALID_TIME_RANGES.includes(rawTimeRange) + ? rawTimeRange + : DEFAULT_TIME_RANGE; + const rawSortOrder = searchParams.get('sort'); + const sortOrder: 'asc' | 'desc' = + rawSortOrder === 'asc' || rawSortOrder === 'desc' ? rawSortOrder : 'desc'; + const rawPage = searchParams.get('page'); + const page = rawPage ? Math.max(0, parseInt(rawPage, 10) || 0) : 0; + + const environment = envId + ? environments.find(e => e.id === envId) + : undefined; + + return { + environmentId: environment?.id || '', + timeRange, + sortOrder, + page, + }; + }, [searchParams, environments]); + + useEffect(() => { + if (environments.length === 0) return; + const envParam = searchParams.get('env'); + const isValid = envParam && environments.some(e => e.id === envParam); + if (!isValid) { + const newParams = new URLSearchParams(searchParams); + newParams.set('env', environments[0].id); + setSearchParams(newParams, { replace: true }); + } + }, [environments, searchParams, setSearchParams]); + + const updateFilters = useCallback( + (newFilters: Partial) => { + const newParams = new URLSearchParams(searchParams); + + if (newFilters.environmentId !== undefined) { + if (newFilters.environmentId) { + newParams.set('env', newFilters.environmentId); + } else { + newParams.delete('env'); + } + // Reset page when environment changes + newParams.delete('page'); + } + + if (newFilters.timeRange !== undefined) { + if (newFilters.timeRange === DEFAULT_TIME_RANGE) { + newParams.delete('timeRange'); + } else { + newParams.set('timeRange', newFilters.timeRange); + } + // Reset page when time range changes + newParams.delete('page'); + } + + if (newFilters.sortOrder !== undefined) { + if (newFilters.sortOrder === 'desc') { + newParams.delete('sort'); + } else { + newParams.set('sort', newFilters.sortOrder); + } + // Reset page when sort changes + newParams.delete('page'); + } + + if (newFilters.page !== undefined) { + if (newFilters.page === 0) { + newParams.delete('page'); + } else { + newParams.set('page', String(newFilters.page)); + } + } + + setSearchParams(newParams, { replace: true }); + }, + [searchParams, setSearchParams], + ); + + const resetFilters = useCallback(() => { + const newParams = new URLSearchParams(); + if (environments.length > 0) { + newParams.set('env', environments[0].id); + } + setSearchParams(newParams, { replace: true }); + }, [environments, setSearchParams]); + + return { filters, updateFilters, resetFilters }; +} diff --git a/plugins/openchoreo-observability/src/index.ts b/plugins/openchoreo-observability/src/index.ts index ba370e958..4b80633ff 100644 --- a/plugins/openchoreo-observability/src/index.ts +++ b/plugins/openchoreo-observability/src/index.ts @@ -10,6 +10,7 @@ export { ObservabilityWirelogs, ObservabilityProjectIncidents, ObservabilityCostAnalysis, + ObservabilityRuns, } from './plugin'; export type { RenderLogRowAction } from './components/RuntimeLogs/LogEntry'; export { useComponentHasAnyCiliumEnabledEnvironment } from './hooks'; diff --git a/plugins/openchoreo-observability/src/plugin.ts b/plugins/openchoreo-observability/src/plugin.ts index 1dc5c901f..f6722e614 100644 --- a/plugins/openchoreo-observability/src/plugin.ts +++ b/plugins/openchoreo-observability/src/plugin.ts @@ -116,3 +116,9 @@ export const ObservabilityCostAnalysis = lazy(() => default: m.CostAnalysisPage, })), ); + +export const ObservabilityRuns = lazy(() => + import('./components/Runs/ObservabilityRunsPage').then(m => ({ + default: m.ObservabilityRunsPage, + })), +);