From 2b030f0581d19e6641cb9a85fd51eea40fe08f43 Mon Sep 17 00:00:00 2001 From: Thushara Sampath Date: Sat, 25 Apr 2026 15:12:17 +0530 Subject: [PATCH 1/9] Add triggers page showing triggers and retries Signed-off-by: Thushara Sampath --- .../app/src/components/catalog/EntityPage.tsx | 92 ++++++- .../src/api/ObservabilityApi.ts | 145 ++++++++++- .../Triggers/ObservabilityTriggersPage.tsx | 226 ++++++++++++++++++ .../src/components/Triggers/RetryRow.tsx | 106 ++++++++ .../src/components/Triggers/TriggerRow.tsx | 195 +++++++++++++++ .../components/Triggers/TriggersActions.tsx | 104 ++++++++ .../components/Triggers/TriggersFilter.tsx | 102 ++++++++ .../src/components/Triggers/TriggersTable.tsx | 118 +++++++++ .../src/components/Triggers/index.ts | 7 + .../src/components/Triggers/styles.ts | 66 +++++ .../src/components/Triggers/types.ts | 66 +++++ .../src/hooks/index.ts | 3 + .../src/hooks/useRetries.ts | 80 +++++++ .../src/hooks/useTriggers.ts | 119 +++++++++ .../src/hooks/useUrlFiltersForTriggers.ts | 116 +++++++++ plugins/openchoreo-observability/src/index.ts | 1 + .../openchoreo-observability/src/plugin.ts | 6 + 17 files changed, 1547 insertions(+), 5 deletions(-) create mode 100644 plugins/openchoreo-observability/src/components/Triggers/ObservabilityTriggersPage.tsx create mode 100644 plugins/openchoreo-observability/src/components/Triggers/RetryRow.tsx create mode 100644 plugins/openchoreo-observability/src/components/Triggers/TriggerRow.tsx create mode 100644 plugins/openchoreo-observability/src/components/Triggers/TriggersActions.tsx create mode 100644 plugins/openchoreo-observability/src/components/Triggers/TriggersFilter.tsx create mode 100644 plugins/openchoreo-observability/src/components/Triggers/TriggersTable.tsx create mode 100644 plugins/openchoreo-observability/src/components/Triggers/index.ts create mode 100644 plugins/openchoreo-observability/src/components/Triggers/styles.ts create mode 100644 plugins/openchoreo-observability/src/components/Triggers/types.ts create mode 100644 plugins/openchoreo-observability/src/hooks/useRetries.ts create mode 100644 plugins/openchoreo-observability/src/hooks/useTriggers.ts create mode 100644 plugins/openchoreo-observability/src/hooks/useUrlFiltersForTriggers.ts diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index ce254f19e..e12eb47d2 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, + ObservabilityTriggers, 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 Triggers 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.ts b/plugins/openchoreo-observability/src/api/ObservabilityApi.ts index 948555e02..1f031eb8e 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 { + TriggersQueryResponse, + RetriesQueryResponse, +} from '../components/Triggers/types'; import { ObserverUrlCache } from './ObserverUrlCache'; export interface ObservabilityApi { @@ -184,6 +188,28 @@ export interface ObservabilityApi { environmentName: string, namespaceName: string, ): Promise; + + getTriggers( + 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, + ): Promise; } export const observabilityApiRef = createApiRef({ @@ -943,9 +969,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 +1029,122 @@ export class ObservabilityClient implements ObservabilityApi { return data; } + async getTriggers( + 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/triggers/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 triggers: ${response.statusText}`, + ); + } + + const data = await response.json(); + return { + triggers: (data.triggers ?? []).map((t: any) => ({ + jobName: t.jobName ?? '', + status: t.status ?? 'unknown', + startTime: t.startTime ?? '', + completionTime: t.completionTime, + eventCount: t.eventCount ?? 0, + events: t.events, + })), + total: data.total ?? 0, + tookMs: data.tookMs ?? 0, + }; + } + + async getRetries( + jobName: string, + namespaceName: string, + projectName: string, + environmentName: string, + componentName: string, + ): Promise { + const { observerUrl } = await this.urlCache.resolveUrls( + namespaceName, + environmentName, + ); + + const response = await this.fetchApi.fetch( + `${observerUrl}/api/v1/scheduled-tasks/triggers/${encodeURIComponent( + jobName, + )}/retries/query`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...DIRECT_HEADER }, + body: JSON.stringify({ + searchScope: { + namespace: namespaceName, + project: projectName, + component: componentName, + environment: environmentName, + }, + }), + }, + ); + + 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, + }; + } + private async parseError(response: Response): Promise { try { const error = await response.json(); diff --git a/plugins/openchoreo-observability/src/components/Triggers/ObservabilityTriggersPage.tsx b/plugins/openchoreo-observability/src/components/Triggers/ObservabilityTriggersPage.tsx new file mode 100644 index 000000000..3076726cb --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Triggers/ObservabilityTriggersPage.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 { TriggersFilter } from './TriggersFilter'; +import { TriggersTable } from './TriggersTable'; +import { TriggersActions } from './TriggersActions'; +import { + useTriggers, + useGetNamespaceAndProjectByEntity, + useGetEnvironmentsByNamespace, + useUrlFiltersForTriggers, +} from '../../hooks'; +import { useLogsPermission } from '@openchoreo/backstage-plugin-react'; +import { useRuntimeLogsStyles } from '../RuntimeLogs/styles'; +import type { Environment as RuntimeLogsEnvironment } from '../RuntimeLogs/types'; +import { TRIGGERS_PAGE_SIZE } from './types'; + +const ObservabilityTriggersContent = () => { + const classes = useRuntimeLogsStyles(); + const { entity } = useEntity(); + + const { namespace, project } = useGetNamespaceAndProjectByEntity(entity); + + const { + environments: observabilityEnvironments, + loading: environmentsLoading, + error: environmentsError, + } = useGetEnvironmentsByNamespace(namespace); + + const environments = useMemo(() => { + return observabilityEnvironments.map(env => ({ + id: env.name, + name: env.displayName || env.name, + resourceName: env.name, + })); + }, [observabilityEnvironments]); + + const { filters, updateFilters } = useUrlFiltersForTriggers({ + 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 { + triggers, + loading: triggersLoading, + error: triggersError, + totalCount, + fetchTriggers, + refresh, + } = useTriggers(entity, namespace || '', project || '', { + environmentId: filters.environmentId, + environmentName: selectedEnvironment?.resourceName || '', + timeRange: filters.timeRange, + limit: TRIGGERS_PAGE_SIZE, + offset: filters.page * TRIGGERS_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 + ) { + fetchTriggers(true); + setLastUpdated(new Date()); + previousFiltersRef.current = currentFilters; + } + }, [ + filters.environmentId, + filters.timeRange, + filters.sortOrder, + filters.page, + fetchTriggers, + selectedEnvironment, + namespace, + project, + componentName, + ]); + + useEffect(() => { + if (!triggersLoading) setLastUpdated(new Date()); + }, [triggersLoading]); + + 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 triggers.' + : error} + + {!isObservabilityDisabled && ( + + )} + + ); + }; + + if (environmentsError) { + return {renderError(environmentsError)}; + } + + return ( + + + + {triggersError && renderError(triggersError)} + + {!filters.environmentId && + !environmentsLoading && + environments.length === 0 && ( + + + No environments found. Make sure your component is properly + configured. + + + )} + + {filters.environmentId && selectedEnvironment && ( + <> + + + + + )} + + ); +}; + +export const ObservabilityTriggersPage = () => { + const { + canViewLogs, + loading: permissionLoading, + deniedTooltip, + } = useLogsPermission(); + + if (permissionLoading) return ; + + if (!canViewLogs) { + return ( + + + {deniedTooltip} + + } + /> + ); + } + + return ; +}; diff --git a/plugins/openchoreo-observability/src/components/Triggers/RetryRow.tsx b/plugins/openchoreo-observability/src/components/Triggers/RetryRow.tsx new file mode 100644 index 000000000..9ec11a13a --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Triggers/RetryRow.tsx @@ -0,0 +1,106 @@ +import { FC, useState } from 'react'; +import { + TableRow, + TableCell, + Chip, + Collapse, + Box, + Typography, +} from '@material-ui/core'; +import type { Retry, RetryStatus } from './types'; +import { useLogEntryStyles } from '../RuntimeLogs/styles'; +import { useTriggersStyles } from './styles'; + +interface RetryRowProps { + retry: Retry; +} + +const formatTimestamp = (ts?: string) => { + if (!ts) return '—'; + try { + return new Date(ts).toLocaleString(); + } catch { + return ts; + } +}; + +const getStatusChipClass = ( + status: RetryStatus, + logClasses: ReturnType, + triggerClasses: ReturnType, +): string => { + switch (status) { + case 'Succeeded': + return triggerClasses.successChip; + case 'Failed': + return logClasses.errorChip; + case 'Running': + return triggerClasses.runningChip; + default: + return logClasses.undefinedChip; + } +}; + +export const RetryRow: FC = ({ retry }) => { + const logClasses = useLogEntryStyles(); + const triggerClasses = useTriggersStyles(); + const [expanded, setExpanded] = useState(false); + + return ( + <> + setExpanded(prev => !prev)} + > + + + + + {retry.podName} + + + {formatTimestamp(retry.startTime)} + + + {retry.eventCount} + + + + {expanded && retry.events && retry.events.length > 0 && ( + + + + + + Events + + + {retry.events.map((event, idx) => ( + + + {formatTimestamp(event.timestamp)} + + + {event.reason} + + + {event.message} + + + ))} + + + + + + )} + + ); +}; diff --git a/plugins/openchoreo-observability/src/components/Triggers/TriggerRow.tsx b/plugins/openchoreo-observability/src/components/Triggers/TriggerRow.tsx new file mode 100644 index 000000000..2c52b2b77 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Triggers/TriggerRow.tsx @@ -0,0 +1,195 @@ +import { FC, useState, useEffect } from 'react'; +import { + TableRow, + TableCell, + Chip, + Collapse, + Box, + Typography, + Table, + TableHead, + TableBody, + CircularProgress, +} from '@material-ui/core'; +import type { Trigger, TriggerStatus } from './types'; +import { useLogEntryStyles } from '../RuntimeLogs/styles'; +import { useTriggersStyles } from './styles'; +import { useRetries } from '../../hooks/useRetries'; +import { RetryRow } from './RetryRow'; + +interface TriggerRowProps { + trigger: Trigger; + 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 getStatusChipClass = ( + status: TriggerStatus, + logClasses: ReturnType, + triggerClasses: ReturnType, +): string => { + switch (status) { + case 'succeeded': + return triggerClasses.successChip; + case 'failed': + return logClasses.errorChip; + case 'running': + return triggerClasses.runningChip; + default: + return logClasses.undefinedChip; + } +}; + +export const TriggerRow: FC = ({ + trigger, + namespaceName, + projectName, + environmentName, + componentName, +}) => { + const logClasses = useLogEntryStyles(); + const triggerClasses = useTriggersStyles(); + const [expanded, setExpanded] = useState(false); + + const { + retries, + loading: retriesLoading, + error: retriesError, + fetchRetries, + } = useRetries({ + jobName: expanded ? trigger.jobName : '', + namespaceName, + projectName, + environmentName, + componentName, + }); + + useEffect(() => { + if (expanded) { + fetchRetries(); + } + }, [expanded, fetchRetries]); + + return ( + <> + setExpanded(prev => !prev)} + > + + + + + {trigger.jobName} + + + {formatTimestamp(trigger.startTime)} + + + {formatTimestamp(trigger.completionTime)} + + + {trigger.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 trigger. + + )} + + {trigger.events && trigger.events.length > 0 && ( + <> + + Trigger Events + + + {trigger.events.map((event, idx) => ( + + + {formatTimestamp(event.timestamp)} + + + {event.reason} + + + {event.message} + + + ))} + + + )} +
+
+
+
+ )} + + ); +}; diff --git a/plugins/openchoreo-observability/src/components/Triggers/TriggersActions.tsx b/plugins/openchoreo-observability/src/components/Triggers/TriggersActions.tsx new file mode 100644 index 000000000..db27b6f17 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Triggers/TriggersActions.tsx @@ -0,0 +1,104 @@ +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 { TriggersFilters } from './types'; +import { TRIGGERS_PAGE_SIZE } from './types'; + +interface TriggersActionsProps { + totalCount: number; + disabled: boolean; + onRefresh: () => void; + filters: TriggersFilters; + 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 TriggersActions = ({ + totalCount, + disabled, + onRefresh, + filters, + onFiltersChange, + lastUpdated, +}: TriggersActionsProps) => { + const classes = useLogsActionsStyles(); + const displayDate = lastUpdated || new Date(); + + const page = filters.page; + const pageSize = TRIGGERS_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 triggers: {totalCount} + {totalCount > 0 && ( + <> — showing {startItem}-{endItem} + )} + + + Last updated at: {formatDate(displayDate)} + + + + + + Page {page + 1} / {totalPages} + + + + + + ); +}; diff --git a/plugins/openchoreo-observability/src/components/Triggers/TriggersFilter.tsx b/plugins/openchoreo-observability/src/components/Triggers/TriggersFilter.tsx new file mode 100644 index 000000000..1f7830daa --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Triggers/TriggersFilter.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 { TriggersFilters, Environment } from './types'; +import { TRIGGERS_TIME_RANGE_OPTIONS } from './types'; + +interface TriggersFilterProps { + filters: TriggersFilters; + onFiltersChange: (filters: Partial) => void; + environments: Environment[]; + environmentsLoading: boolean; + disabled?: boolean; +} + +export const TriggersFilter: 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/Triggers/TriggersTable.tsx b/plugins/openchoreo-observability/src/components/Triggers/TriggersTable.tsx new file mode 100644 index 000000000..fb815e0a0 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Triggers/TriggersTable.tsx @@ -0,0 +1,118 @@ +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 { Trigger } from './types'; +import { useLogsTableStyles } from '../RuntimeLogs/styles'; +import { TriggerRow } from './TriggerRow'; + +interface TriggersTableProps { + triggers: Trigger[]; + loading: boolean; + namespaceName: string; + projectName: string; + environmentName: string; + componentName: string; +} + +export const TriggersTable: FC = ({ + triggers, + loading, + namespaceName, + projectName, + environmentName, + componentName, +}) => { + const classes = useLogsTableStyles(); + + const renderLoadingSkeletons = () => + Array.from({ length: 5 }).map((_, i) => ( + + + + + + + + + + + + + + + + + + )); + + const renderEmptyState = () => ( + + + + + No triggers found + + + No scheduled task triggers match the current filters in the selected + time range. + + + + + ); + + return ( + + + + + + + Status + + Job Name + + Start Time + + + Completion Time + + + Events + + + + + {loading && triggers.length === 0 && renderLoadingSkeletons()} + {!loading && triggers.length === 0 && renderEmptyState()} + {triggers.map(trigger => ( + + ))} + +
+
+ {loading && triggers.length > 0 && ( + + + + )} +
+ ); +}; diff --git a/plugins/openchoreo-observability/src/components/Triggers/index.ts b/plugins/openchoreo-observability/src/components/Triggers/index.ts new file mode 100644 index 000000000..91854f42a --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Triggers/index.ts @@ -0,0 +1,7 @@ +export { ObservabilityTriggersPage } from './ObservabilityTriggersPage'; +export { TriggersFilter } from './TriggersFilter'; +export { TriggersTable } from './TriggersTable'; +export { TriggersActions } from './TriggersActions'; +export { TriggerRow } from './TriggerRow'; +export { RetryRow } from './RetryRow'; +export * from './types'; diff --git a/plugins/openchoreo-observability/src/components/Triggers/styles.ts b/plugins/openchoreo-observability/src/components/Triggers/styles.ts new file mode 100644 index 000000000..c5ba19767 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Triggers/styles.ts @@ -0,0 +1,66 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useTriggersStyles = 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}`, + }, + 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), + }, +})); diff --git a/plugins/openchoreo-observability/src/components/Triggers/types.ts b/plugins/openchoreo-observability/src/components/Triggers/types.ts new file mode 100644 index 000000000..7281ea9bc --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Triggers/types.ts @@ -0,0 +1,66 @@ +import type { Environment } from '../RuntimeLogs/types'; + +export type { Environment }; + +export type TriggerStatus = 'succeeded' | 'failed' | 'running' | 'unknown'; + +export interface TriggerEvent { + reason: string; + message: string; + timestamp: string; + type: 'Normal' | 'Warning'; +} + +export interface Trigger { + jobName: string; + status: TriggerStatus; + startTime: string; + completionTime?: string; + eventCount: number; + events?: TriggerEvent[]; +} + +export interface TriggersQueryResponse { + triggers: Trigger[]; + 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 TriggersFilters { + environmentId: string; + timeRange: string; + sortOrder: 'asc' | 'desc'; + page: number; +} + +export const TRIGGERS_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 TRIGGERS_PAGE_SIZE = 20; diff --git a/plugins/openchoreo-observability/src/hooks/index.ts b/plugins/openchoreo-observability/src/hooks/index.ts index cbc8a2f88..9361863e3 100644 --- a/plugins/openchoreo-observability/src/hooks/index.ts +++ b/plugins/openchoreo-observability/src/hooks/index.ts @@ -27,3 +27,6 @@ export { useComponentAlerts } from './useComponentAlerts'; export { useUrlFiltersForIncidents } from './useUrlFiltersForIncidents'; export { useProjectIncidents } from './useProjectIncidents'; export { useUpdateIncident } from './useUpdateIncident'; +export { useTriggers } from './useTriggers'; +export { useRetries } from './useRetries'; +export { useUrlFiltersForTriggers } from './useUrlFiltersForTriggers'; diff --git a/plugins/openchoreo-observability/src/hooks/useRetries.ts b/plugins/openchoreo-observability/src/hooks/useRetries.ts new file mode 100644 index 000000000..85e93818f --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/useRetries.ts @@ -0,0 +1,80 @@ +import { useCallback, useRef, useState } from 'react'; +import { useApi } from '@backstage/core-plugin-api'; +import { observabilityApiRef } from '../api/ObservabilityApi'; +import { Retry } from '../components/Triggers/types'; + +export interface UseRetriesOptions { + jobName: string; + namespaceName: string; + projectName: string; + environmentName: string; + componentName: 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, + ); + + 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, + ]); + + return { + retries, + loading, + error, + fetchRetries, + }; +} diff --git a/plugins/openchoreo-observability/src/hooks/useTriggers.ts b/plugins/openchoreo-observability/src/hooks/useTriggers.ts new file mode 100644 index 000000000..751ffc6a6 --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/useTriggers.ts @@ -0,0 +1,119 @@ +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 '../components/RuntimeLogs/utils'; +import { Trigger } from '../components/Triggers/types'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; + +export interface UseTriggersOptions { + environmentId: string; + environmentName: string; + timeRange: string; + limit?: number; + offset?: number; + sortOrder?: 'asc' | 'desc'; +} + +export interface UseTriggersResult { + triggers: Trigger[]; + loading: boolean; + error: string | null; + totalCount: number; + fetchTriggers: (reset?: boolean) => Promise; + refresh: () => void; +} + +export function useTriggers( + entity: Entity, + namespace: string, + project: string, + options: UseTriggersOptions, +): UseTriggersResult { + const observabilityApi = useApi(observabilityApiRef); + const [triggers, setTriggers] = 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 fetchTriggers = 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.getTriggers( + namespace, + project, + options.environmentName, + componentName, + { + limit: options.limit ?? 20, + offset: options.offset ?? 0, + startTime, + endTime, + sortOrder: options.sortOrder ?? 'desc', + }, + ); + + if (version !== requestVersionRef.current) return; + + setTriggers(response.triggers ?? []); + setTotalCount(response.total ?? 0); + } catch (err) { + if (version !== requestVersionRef.current) return; + setError( + err instanceof Error ? err.message : 'Failed to fetch triggers', + ); + } 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(() => { + setTriggers([]); + fetchTriggers(true); + }, [fetchTriggers]); + + return { + triggers, + loading, + error, + totalCount, + fetchTriggers, + refresh, + }; +} diff --git a/plugins/openchoreo-observability/src/hooks/useUrlFiltersForTriggers.ts b/plugins/openchoreo-observability/src/hooks/useUrlFiltersForTriggers.ts new file mode 100644 index 000000000..2f0208516 --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/useUrlFiltersForTriggers.ts @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import type { Environment } from '../components/RuntimeLogs/types'; +import type { TriggersFilters } from '../components/Triggers/types'; +import { TRIGGERS_TIME_RANGE_OPTIONS } from '../components/Triggers/types'; + +const DEFAULT_TIME_RANGE = '24h'; +const VALID_TIME_RANGES: readonly string[] = TRIGGERS_TIME_RANGE_OPTIONS.map( + o => o.value, +); + +interface UseUrlFiltersForTriggersOptions { + environments: Environment[]; +} + +export function useUrlFiltersForTriggers({ + environments, +}: UseUrlFiltersForTriggersOptions): { + filters: TriggersFilters; + 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..5db961e8e 100644 --- a/plugins/openchoreo-observability/src/index.ts +++ b/plugins/openchoreo-observability/src/index.ts @@ -10,6 +10,7 @@ export { ObservabilityWirelogs, ObservabilityProjectIncidents, ObservabilityCostAnalysis, + ObservabilityTriggers, } 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..64f7e867c 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 ObservabilityTriggers = lazy(() => + import('./components/Triggers/ObservabilityTriggersPage').then(m => ({ + default: m.ObservabilityTriggersPage, + })), +); From 7325cf6741a8b25e5a1d037e9c3e5431e6bb20d1 Mon Sep 17 00:00:00 2001 From: Thushara Sampath Date: Sat, 25 Apr 2026 15:47:53 +0530 Subject: [PATCH 2/9] Show retry logs in the ui Signed-off-by: Thushara Sampath --- .../src/api/ObservabilityApi.ts | 66 ++++++++ .../src/components/Triggers/RetryRow.tsx | 160 +++++++++++++++--- .../src/components/Triggers/TriggerRow.tsx | 11 +- .../src/components/Triggers/styles.ts | 54 ++++++ .../src/hooks/index.ts | 1 + .../src/hooks/usePodLogs.ts | 83 +++++++++ 6 files changed, 354 insertions(+), 21 deletions(-) create mode 100644 plugins/openchoreo-observability/src/hooks/usePodLogs.ts diff --git a/plugins/openchoreo-observability/src/api/ObservabilityApi.ts b/plugins/openchoreo-observability/src/api/ObservabilityApi.ts index 1f031eb8e..2a8e7199b 100644 --- a/plugins/openchoreo-observability/src/api/ObservabilityApi.ts +++ b/plugins/openchoreo-observability/src/api/ObservabilityApi.ts @@ -210,6 +210,20 @@ export interface ObservabilityApi { environmentName: string, componentName: 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({ @@ -1145,6 +1159,58 @@ export class ObservabilityClient implements ObservabilityApi { }; } + 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/Triggers/RetryRow.tsx b/plugins/openchoreo-observability/src/components/Triggers/RetryRow.tsx index 9ec11a13a..0b1f7154a 100644 --- a/plugins/openchoreo-observability/src/components/Triggers/RetryRow.tsx +++ b/plugins/openchoreo-observability/src/components/Triggers/RetryRow.tsx @@ -1,4 +1,4 @@ -import { FC, useState } from 'react'; +import { FC, useEffect, useMemo, useState } from 'react'; import { TableRow, TableCell, @@ -6,13 +6,21 @@ import { Collapse, Box, Typography, + CircularProgress, } from '@material-ui/core'; import type { Retry, RetryStatus } from './types'; import { useLogEntryStyles } from '../RuntimeLogs/styles'; import { useTriggersStyles } from './styles'; +import { usePodLogs } from '../../hooks/usePodLogs'; interface RetryRowProps { retry: Retry; + namespaceName: string; + projectName: string; + environmentName: string; + componentName: string; + triggerStartTime?: string; + triggerCompletionTime?: string; } const formatTimestamp = (ts?: string) => { @@ -41,11 +49,70 @@ const getStatusChipClass = ( } }; -export const RetryRow: FC = ({ retry }) => { +export const RetryRow: FC = ({ + retry, + namespaceName, + projectName, + environmentName, + componentName, + triggerStartTime, + triggerCompletionTime, +}) => { const logClasses = useLogEntryStyles(); const triggerClasses = useTriggersStyles(); const [expanded, setExpanded] = useState(false); + const { logsStartTime, logsEndTime } = useMemo(() => { + const baseStart = retry.startTime || triggerStartTime; + const startMs = baseStart + ? new Date(baseStart).getTime() - 60 * 1000 + : Date.now() - 24 * 3600 * 1000; + const endMs = triggerCompletionTime + ? new Date(triggerCompletionTime).getTime() + 5 * 60 * 1000 + : Date.now(); + return { + logsStartTime: new Date(startMs).toISOString(), + logsEndTime: new Date(endMs).toISOString(), + }; + }, [retry.startTime, triggerStartTime, triggerCompletionTime]); + + 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 triggerClasses.logLevelError; + case 'WARN': + case 'WARNING': + return triggerClasses.logLevelWarn; + case 'INFO': + return triggerClasses.logLevelInfo; + case 'DEBUG': + return triggerClasses.logLevelDebug; + default: + return ''; + } + }; + return ( <> = ({ retry }) => { - {expanded && retry.events && retry.events.length > 0 && ( + {expanded && ( + {retry.events && retry.events.length > 0 && ( + <> + + Events + + + {retry.events.map((event, idx) => ( + + + {formatTimestamp(event.timestamp)} + + + {event.reason} + + + {event.message} + + + ))} + + + )} + - Events + Logs ({logs.length}) - - {retry.events.map((event, idx) => ( - - - {formatTimestamp(event.timestamp)} - - + + + )} + + {logsError && ( + + {logsError} + + )} + + {!logsLoading && !logsError && logs.length === 0 && ( + + No logs found for this pod. + + )} + + {!logsLoading && !logsError && logs.length > 0 && ( + + {logs.map((log, idx) => ( + - {event.reason} - - - {event.message} - - - ))} - + + {formatTimestamp(log.timestamp)} + + + {log.level || '-'} + + + {log.log} + + + ))} + + )} diff --git a/plugins/openchoreo-observability/src/components/Triggers/TriggerRow.tsx b/plugins/openchoreo-observability/src/components/Triggers/TriggerRow.tsx index 2c52b2b77..d35998c53 100644 --- a/plugins/openchoreo-observability/src/components/Triggers/TriggerRow.tsx +++ b/plugins/openchoreo-observability/src/components/Triggers/TriggerRow.tsx @@ -145,7 +145,16 @@ export const TriggerRow: FC = ({ {retries.map(retry => ( - + ))} diff --git a/plugins/openchoreo-observability/src/components/Triggers/styles.ts b/plugins/openchoreo-observability/src/components/Triggers/styles.ts index c5ba19767..175ca2d14 100644 --- a/plugins/openchoreo-observability/src/components/Triggers/styles.ts +++ b/plugins/openchoreo-observability/src/components/Triggers/styles.ts @@ -63,4 +63,58 @@ export const useTriggersStyles = makeStyles(theme => ({ marginBottom: theme.spacing(0.5), marginTop: theme.spacing(1), }, + logsContainer: { + maxHeight: 320, + 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/hooks/index.ts b/plugins/openchoreo-observability/src/hooks/index.ts index 9361863e3..77be7686c 100644 --- a/plugins/openchoreo-observability/src/hooks/index.ts +++ b/plugins/openchoreo-observability/src/hooks/index.ts @@ -29,4 +29,5 @@ export { useProjectIncidents } from './useProjectIncidents'; export { useUpdateIncident } from './useUpdateIncident'; export { useTriggers } from './useTriggers'; export { useRetries } from './useRetries'; +export { usePodLogs } from './usePodLogs'; export { useUrlFiltersForTriggers } from './useUrlFiltersForTriggers'; 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 }; +} From 8de568944c1895d4877255520e0b8647c14a7a78 Mon Sep 17 00:00:00 2001 From: Thushara Sampath Date: Sun, 26 Apr 2026 15:04:34 +0530 Subject: [PATCH 3/9] Show failed reason and improve UX Signed-off-by: Thushara Sampath --- .../src/api/ObservabilityApi.ts | 1 + .../src/components/Triggers/RetryRow.tsx | 175 ++++++++++++------ .../src/components/Triggers/TriggerRow.tsx | 68 ++++--- .../src/components/Triggers/TriggersTable.tsx | 10 +- .../src/components/Triggers/styles.ts | 33 +++- .../src/components/Triggers/types.ts | 1 + 6 files changed, 198 insertions(+), 90 deletions(-) diff --git a/plugins/openchoreo-observability/src/api/ObservabilityApi.ts b/plugins/openchoreo-observability/src/api/ObservabilityApi.ts index 2a8e7199b..1f626f334 100644 --- a/plugins/openchoreo-observability/src/api/ObservabilityApi.ts +++ b/plugins/openchoreo-observability/src/api/ObservabilityApi.ts @@ -1101,6 +1101,7 @@ export class ObservabilityClient implements ObservabilityApi { startTime: t.startTime ?? '', completionTime: t.completionTime, eventCount: t.eventCount ?? 0, + failureReason: t.failureReason, events: t.events, })), total: data.total ?? 0, diff --git a/plugins/openchoreo-observability/src/components/Triggers/RetryRow.tsx b/plugins/openchoreo-observability/src/components/Triggers/RetryRow.tsx index 0b1f7154a..cf8d16367 100644 --- a/plugins/openchoreo-observability/src/components/Triggers/RetryRow.tsx +++ b/plugins/openchoreo-observability/src/components/Triggers/RetryRow.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useMemo, useState } from 'react'; +import { FC, MouseEvent, useEffect, useMemo, useState } from 'react'; import { TableRow, TableCell, @@ -7,7 +7,12 @@ import { 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 { useTriggersStyles } from './styles'; @@ -61,6 +66,8 @@ export const RetryRow: FC = ({ const logClasses = useLogEntryStyles(); const triggerClasses = useTriggersStyles(); const [expanded, setExpanded] = useState(false); + const [eventsOpen, setEventsOpen] = useState(false); + const [logsOpen, setLogsOpen] = useState(true); const { logsStartTime, logsEndTime } = useMemo(() => { const baseStart = retry.startTime || triggerStartTime; @@ -143,78 +150,124 @@ export const RetryRow: FC = ({ - {retry.events && retry.events.length > 0 && ( - <> - - Events + setLogsOpen(prev => !prev)} + > + {logsOpen ? ( + + ) : ( + + )} + + Logs ({logs.length}) + + + { + e.stopPropagation(); + if (!logsOpen) setLogsOpen(true); + fetchLogs(); + }} + > + + + + + + + {logsLoading && ( + + + + )} + + {logsError && ( + + {logsError} - - {retry.events.map((event, idx) => ( - - - {formatTimestamp(event.timestamp)} + )} + + {!logsLoading && !logsError && logs.length === 0 && ( + + No logs found for this pod. + + )} + + {!logsLoading && !logsError && logs.length > 0 && ( + + {logs.map((log, idx) => ( + + + {formatTimestamp(log.timestamp)} - {event.reason} + {log.level || '-'} - - {event.message} + + {log.log} ))} - - )} - - - Logs ({logs.length}) - + )} + - {logsLoading && ( - - - - )} - - {logsError && ( - - {logsError} - - )} - - {!logsLoading && !logsError && logs.length === 0 && ( - - No logs found for this pod. - - )} - - {!logsLoading && !logsError && logs.length > 0 && ( - - {logs.map((log, idx) => ( - 0 && ( + <> + setEventsOpen(prev => !prev)} + > + {eventsOpen ? ( + + ) : ( + + )} + - - {formatTimestamp(log.timestamp)} - - - {log.level || '-'} - - - {log.log} - + Events ({retry.events.length}) + + + + + {retry.events.map((event, idx) => ( + + + {formatTimestamp(event.timestamp)} + + + {event.reason} + + + {event.message} + + + ))} - ))} - + + )} diff --git a/plugins/openchoreo-observability/src/components/Triggers/TriggerRow.tsx b/plugins/openchoreo-observability/src/components/Triggers/TriggerRow.tsx index d35998c53..da46ae073 100644 --- a/plugins/openchoreo-observability/src/components/Triggers/TriggerRow.tsx +++ b/plugins/openchoreo-observability/src/components/Triggers/TriggerRow.tsx @@ -34,6 +34,28 @@ const formatTimestamp = (ts?: string) => { } }; +const formatDuration = ( + startTime?: string, + completionTime?: string, + status?: TriggerStatus, +): 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: TriggerStatus, logClasses: ReturnType, @@ -91,7 +113,18 @@ export const TriggerRow: FC = ({ + {trigger.status.toUpperCase()} + + ({trigger.failureReason}) + + + ) : ( + trigger.status.toUpperCase() + ) + } className={`${logClasses.logLevelChip} ${getStatusChipClass(trigger.status, logClasses, triggerClasses)}`} /> @@ -104,6 +137,13 @@ export const TriggerRow: FC = ({ {formatTimestamp(trigger.completionTime)} + + {formatDuration( + trigger.startTime, + trigger.completionTime, + trigger.status, + )} + {trigger.eventCount} @@ -111,7 +151,7 @@ export const TriggerRow: FC = ({ {expanded && ( - + @@ -170,30 +210,6 @@ export const TriggerRow: FC = ({ )} - {trigger.events && trigger.events.length > 0 && ( - <> - - Trigger Events - - - {trigger.events.map((event, idx) => ( - - - {formatTimestamp(event.timestamp)} - - - {event.reason} - - - {event.message} - - - ))} - - - )} diff --git a/plugins/openchoreo-observability/src/components/Triggers/TriggersTable.tsx b/plugins/openchoreo-observability/src/components/Triggers/TriggersTable.tsx index fb815e0a0..7e6a77347 100644 --- a/plugins/openchoreo-observability/src/components/Triggers/TriggersTable.tsx +++ b/plugins/openchoreo-observability/src/components/Triggers/TriggersTable.tsx @@ -52,12 +52,15 @@ export const TriggersTable: FC = ({ + + + )); const renderEmptyState = () => ( - + No triggers found @@ -77,7 +80,7 @@ export const TriggersTable: FC = ({ - + Status Job Name @@ -87,6 +90,9 @@ export const TriggersTable: FC = ({ Completion Time + + Duration + Events diff --git a/plugins/openchoreo-observability/src/components/Triggers/styles.ts b/plugins/openchoreo-observability/src/components/Triggers/styles.ts index 175ca2d14..21fb8154d 100644 --- a/plugins/openchoreo-observability/src/components/Triggers/styles.ts +++ b/plugins/openchoreo-observability/src/components/Triggers/styles.ts @@ -11,6 +11,13 @@ export const useTriggersStyles = makeStyles(theme => ({ 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), }, @@ -63,8 +70,32 @@ export const useTriggersStyles = makeStyles(theme => ({ 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: 320, + maxHeight: 220, overflowY: 'auto', border: `1px solid ${theme.palette.divider}`, borderRadius: 4, diff --git a/plugins/openchoreo-observability/src/components/Triggers/types.ts b/plugins/openchoreo-observability/src/components/Triggers/types.ts index 7281ea9bc..2f510f059 100644 --- a/plugins/openchoreo-observability/src/components/Triggers/types.ts +++ b/plugins/openchoreo-observability/src/components/Triggers/types.ts @@ -17,6 +17,7 @@ export interface Trigger { startTime: string; completionTime?: string; eventCount: number; + failureReason?: string; events?: TriggerEvent[]; } From 1a283bfc527a16d0b3825e827f01e228fff37986 Mon Sep 17 00:00:00 2001 From: Thushara Sampath Date: Fri, 8 May 2026 11:52:56 +0530 Subject: [PATCH 4/9] Rename "Triggers" to "Runs" Signed-off-by: Thushara Sampath --- .../app/src/components/catalog/EntityPage.tsx | 8 +- .../src/api/ObservabilityApi.ts | 34 ++++----- .../ObservabilityRunsPage.tsx} | 60 +++++++-------- .../{Triggers => Runs}/RetryRow.tsx | 74 +++++++++---------- .../TriggerRow.tsx => Runs/RunRow.tsx} | 62 ++++++++-------- .../RunsActions.tsx} | 18 ++--- .../RunsFilter.tsx} | 26 +++---- .../TriggersTable.tsx => Runs/RunsTable.tsx} | 30 ++++---- .../src/components/Runs/index.ts | 7 ++ .../components/{Triggers => Runs}/styles.ts | 2 +- .../components/{Triggers => Runs}/types.ts | 20 ++--- .../src/components/Triggers/index.ts | 7 -- .../src/hooks/index.ts | 4 +- .../src/hooks/useRetries.ts | 2 +- .../src/hooks/{useTriggers.ts => useRuns.ts} | 36 ++++----- ...ForTriggers.ts => useUrlFiltersForRuns.ts} | 20 ++--- plugins/openchoreo-observability/src/index.ts | 2 +- .../openchoreo-observability/src/plugin.ts | 6 +- 18 files changed, 209 insertions(+), 209 deletions(-) rename plugins/openchoreo-observability/src/components/{Triggers/ObservabilityTriggersPage.tsx => Runs/ObservabilityRunsPage.tsx} (82%) rename plugins/openchoreo-observability/src/components/{Triggers => Runs}/RetryRow.tsx (76%) rename plugins/openchoreo-observability/src/components/{Triggers/TriggerRow.tsx => Runs/RunRow.tsx} (78%) rename plugins/openchoreo-observability/src/components/{Triggers/TriggersActions.tsx => Runs/RunsActions.tsx} (88%) rename plugins/openchoreo-observability/src/components/{Triggers/TriggersFilter.tsx => Runs/RunsFilter.tsx} (77%) rename plugins/openchoreo-observability/src/components/{Triggers/TriggersTable.tsx => Runs/RunsTable.tsx} (82%) create mode 100644 plugins/openchoreo-observability/src/components/Runs/index.ts rename plugins/openchoreo-observability/src/components/{Triggers => Runs}/styles.ts (98%) rename plugins/openchoreo-observability/src/components/{Triggers => Runs}/types.ts (73%) delete mode 100644 plugins/openchoreo-observability/src/components/Triggers/index.ts rename plugins/openchoreo-observability/src/hooks/{useTriggers.ts => useRuns.ts} (80%) rename plugins/openchoreo-observability/src/hooks/{useUrlFiltersForTriggers.ts => useUrlFiltersForRuns.ts} (85%) diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index e12eb47d2..d27ad1adc 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -142,7 +142,7 @@ import { ObservabilityWirelogs, ObservabilityProjectIncidents, ObservabilityCostAnalysis, - ObservabilityTriggers, + ObservabilityRuns, useComponentHasAnyCiliumEnabledEnvironment, type RenderLogRowAction, } from '@openchoreo/backstage-plugin-openchoreo-observability'; @@ -457,7 +457,7 @@ const ServiceEntityPage = () => { /** * Scheduled task entity page with delete menu support. - * Adds a Triggers tab that shows Job/Pod execution history. + * Adds a Runs tab that shows Job/Pod execution history. * No API tab or Alerts tab (not relevant for CronJobs). */ const scheduledTaskEntityPage = ( @@ -480,9 +480,9 @@ const scheduledTaskEntityPage = ( - + - + diff --git a/plugins/openchoreo-observability/src/api/ObservabilityApi.ts b/plugins/openchoreo-observability/src/api/ObservabilityApi.ts index 1f626f334..bf352a6ce 100644 --- a/plugins/openchoreo-observability/src/api/ObservabilityApi.ts +++ b/plugins/openchoreo-observability/src/api/ObservabilityApi.ts @@ -20,9 +20,9 @@ import { import { LogsResponse } from '../components/RuntimeLogs/types'; import { EventsResponse } from '../components/RuntimeEvents/types'; import type { - TriggersQueryResponse, + RunsQueryResponse, RetriesQueryResponse, -} from '../components/Triggers/types'; +} from '../components/Runs/types'; import { ObserverUrlCache } from './ObserverUrlCache'; export interface ObservabilityApi { @@ -189,7 +189,7 @@ export interface ObservabilityApi { namespaceName: string, ): Promise; - getTriggers( + getRuns( namespaceName: string, projectName: string, environmentName: string, @@ -201,7 +201,7 @@ export interface ObservabilityApi { offset?: number; sortOrder?: 'asc' | 'desc'; }, - ): Promise; + ): Promise; getRetries( jobName: string, @@ -1043,7 +1043,7 @@ export class ObservabilityClient implements ObservabilityApi { return data; } - async getTriggers( + async getRuns( namespaceName: string, projectName: string, environmentName: string, @@ -1055,14 +1055,14 @@ export class ObservabilityClient implements ObservabilityApi { offset?: number; sortOrder?: 'asc' | 'desc'; }, - ): Promise { + ): Promise { const { observerUrl } = await this.urlCache.resolveUrls( namespaceName, environmentName, ); const response = await this.fetchApi.fetch( - `${observerUrl}/api/v1/scheduled-tasks/triggers/query`, + `${observerUrl}/api/v1/scheduled-tasks/runs/query`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...DIRECT_HEADER }, @@ -1089,20 +1089,20 @@ export class ObservabilityClient implements ObservabilityApi { throw new Error('Observability is not enabled for this component'); } throw new Error( - error || `Failed to fetch triggers: ${response.statusText}`, + error || `Failed to fetch runs: ${response.statusText}`, ); } const data = await response.json(); return { - triggers: (data.triggers ?? []).map((t: any) => ({ - jobName: t.jobName ?? '', - status: t.status ?? 'unknown', - startTime: t.startTime ?? '', - completionTime: t.completionTime, - eventCount: t.eventCount ?? 0, - failureReason: t.failureReason, - events: t.events, + 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, @@ -1122,7 +1122,7 @@ export class ObservabilityClient implements ObservabilityApi { ); const response = await this.fetchApi.fetch( - `${observerUrl}/api/v1/scheduled-tasks/triggers/${encodeURIComponent( + `${observerUrl}/api/v1/scheduled-tasks/runs/${encodeURIComponent( jobName, )}/retries/query`, { diff --git a/plugins/openchoreo-observability/src/components/Triggers/ObservabilityTriggersPage.tsx b/plugins/openchoreo-observability/src/components/Runs/ObservabilityRunsPage.tsx similarity index 82% rename from plugins/openchoreo-observability/src/components/Triggers/ObservabilityTriggersPage.tsx rename to plugins/openchoreo-observability/src/components/Runs/ObservabilityRunsPage.tsx index 3076726cb..f16aa82c1 100644 --- a/plugins/openchoreo-observability/src/components/Triggers/ObservabilityTriggersPage.tsx +++ b/plugins/openchoreo-observability/src/components/Runs/ObservabilityRunsPage.tsx @@ -4,21 +4,21 @@ 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 { TriggersFilter } from './TriggersFilter'; -import { TriggersTable } from './TriggersTable'; -import { TriggersActions } from './TriggersActions'; +import { RunsFilter } from './RunsFilter'; +import { RunsTable } from './RunsTable'; +import { RunsActions } from './RunsActions'; import { - useTriggers, + useRuns, useGetNamespaceAndProjectByEntity, useGetEnvironmentsByNamespace, - useUrlFiltersForTriggers, + useUrlFiltersForRuns, } from '../../hooks'; import { useLogsPermission } from '@openchoreo/backstage-plugin-react'; import { useRuntimeLogsStyles } from '../RuntimeLogs/styles'; import type { Environment as RuntimeLogsEnvironment } from '../RuntimeLogs/types'; -import { TRIGGERS_PAGE_SIZE } from './types'; +import { RUNS_PAGE_SIZE } from './types'; -const ObservabilityTriggersContent = () => { +const ObservabilityRunsContent = () => { const classes = useRuntimeLogsStyles(); const { entity } = useEntity(); @@ -38,7 +38,7 @@ const ObservabilityTriggersContent = () => { })); }, [observabilityEnvironments]); - const { filters, updateFilters } = useUrlFiltersForTriggers({ + const { filters, updateFilters } = useUrlFiltersForRuns({ environments, }); @@ -52,18 +52,18 @@ const ObservabilityTriggersContent = () => { const [lastUpdated, setLastUpdated] = useState(new Date()); const { - triggers, - loading: triggersLoading, - error: triggersError, + runs, + loading: runsLoading, + error: runsError, totalCount, - fetchTriggers, + fetchRuns, refresh, - } = useTriggers(entity, namespace || '', project || '', { + } = useRuns(entity, namespace || '', project || '', { environmentId: filters.environmentId, environmentName: selectedEnvironment?.resourceName || '', timeRange: filters.timeRange, - limit: TRIGGERS_PAGE_SIZE, - offset: filters.page * TRIGGERS_PAGE_SIZE, + limit: RUNS_PAGE_SIZE, + offset: filters.page * RUNS_PAGE_SIZE, sortOrder: filters.sortOrder, }); @@ -94,7 +94,7 @@ const ObservabilityTriggersContent = () => { componentName && filtersChanged ) { - fetchTriggers(true); + fetchRuns(true); setLastUpdated(new Date()); previousFiltersRef.current = currentFilters; } @@ -103,7 +103,7 @@ const ObservabilityTriggersContent = () => { filters.timeRange, filters.sortOrder, filters.page, - fetchTriggers, + fetchRuns, selectedEnvironment, namespace, project, @@ -111,8 +111,8 @@ const ObservabilityTriggersContent = () => { ]); useEffect(() => { - if (!triggersLoading) setLastUpdated(new Date()); - }, [triggersLoading]); + if (!runsLoading) setLastUpdated(new Date()); + }, [runsLoading]); const handleRefresh = () => { refresh(); @@ -134,7 +134,7 @@ const ObservabilityTriggersContent = () => { > {isObservabilityDisabled - ? 'Observability is not enabled for this component. Please enable observability to view triggers.' + ? 'Observability is not enabled for this component. Please enable observability to view runs.' : error} {!isObservabilityDisabled && ( @@ -152,15 +152,15 @@ const ObservabilityTriggersContent = () => { return ( - - {triggersError && renderError(triggersError)} + {runsError && renderError(runsError)} {!filters.environmentId && !environmentsLoading && @@ -175,18 +175,18 @@ const ObservabilityTriggersContent = () => { {filters.environmentId && selectedEnvironment && ( <> - - { ); }; -export const ObservabilityTriggersPage = () => { +export const ObservabilityRunsPage = () => { const { canViewLogs, loading: permissionLoading, @@ -222,5 +222,5 @@ export const ObservabilityTriggersPage = () => { ); } - return ; + return ; }; diff --git a/plugins/openchoreo-observability/src/components/Triggers/RetryRow.tsx b/plugins/openchoreo-observability/src/components/Runs/RetryRow.tsx similarity index 76% rename from plugins/openchoreo-observability/src/components/Triggers/RetryRow.tsx rename to plugins/openchoreo-observability/src/components/Runs/RetryRow.tsx index cf8d16367..e286217a6 100644 --- a/plugins/openchoreo-observability/src/components/Triggers/RetryRow.tsx +++ b/plugins/openchoreo-observability/src/components/Runs/RetryRow.tsx @@ -15,7 +15,7 @@ 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 { useTriggersStyles } from './styles'; +import { useRunsStyles } from './styles'; import { usePodLogs } from '../../hooks/usePodLogs'; interface RetryRowProps { @@ -24,8 +24,8 @@ interface RetryRowProps { projectName: string; environmentName: string; componentName: string; - triggerStartTime?: string; - triggerCompletionTime?: string; + runStartTime?: string; + runCompletionTime?: string; } const formatTimestamp = (ts?: string) => { @@ -40,15 +40,15 @@ const formatTimestamp = (ts?: string) => { const getStatusChipClass = ( status: RetryStatus, logClasses: ReturnType, - triggerClasses: ReturnType, + runClasses: ReturnType, ): string => { switch (status) { case 'Succeeded': - return triggerClasses.successChip; + return runClasses.successChip; case 'Failed': return logClasses.errorChip; case 'Running': - return triggerClasses.runningChip; + return runClasses.runningChip; default: return logClasses.undefinedChip; } @@ -60,28 +60,28 @@ export const RetryRow: FC = ({ projectName, environmentName, componentName, - triggerStartTime, - triggerCompletionTime, + runStartTime, + runCompletionTime, }) => { const logClasses = useLogEntryStyles(); - const triggerClasses = useTriggersStyles(); + 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 || triggerStartTime; + const baseStart = retry.startTime || runStartTime; const startMs = baseStart ? new Date(baseStart).getTime() - 60 * 1000 : Date.now() - 24 * 3600 * 1000; - const endMs = triggerCompletionTime - ? new Date(triggerCompletionTime).getTime() + 5 * 60 * 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, triggerStartTime, triggerCompletionTime]); + }, [retry.startTime, runStartTime, runCompletionTime]); const { logs, @@ -107,14 +107,14 @@ export const RetryRow: FC = ({ const logLevelClass = (level?: string): string => { switch ((level || '').toUpperCase()) { case 'ERROR': - return triggerClasses.logLevelError; + return runClasses.logLevelError; case 'WARN': case 'WARNING': - return triggerClasses.logLevelWarn; + return runClasses.logLevelWarn; case 'INFO': - return triggerClasses.logLevelInfo; + return runClasses.logLevelInfo; case 'DEBUG': - return triggerClasses.logLevelDebug; + return runClasses.logLevelDebug; default: return ''; } @@ -131,7 +131,7 @@ export const RetryRow: FC = ({ @@ -151,23 +151,23 @@ export const RetryRow: FC = ({ setLogsOpen(prev => !prev)} > {logsOpen ? ( - + ) : ( )} - + Logs ({logs.length}) { e.stopPropagation(); @@ -175,7 +175,7 @@ export const RetryRow: FC = ({ fetchLogs(); }} > - + @@ -204,21 +204,21 @@ export const RetryRow: FC = ({ )} {!logsLoading && !logsError && logs.length > 0 && ( - + {logs.map((log, idx) => ( - + {formatTimestamp(log.timestamp)} {log.level || '-'} - + {log.log} @@ -230,20 +230,20 @@ export const RetryRow: FC = ({ {retry.events && retry.events.length > 0 && ( <> setEventsOpen(prev => !prev)} > {eventsOpen ? ( ) : ( )} Events ({retry.events.length}) @@ -251,16 +251,16 @@ export const RetryRow: FC = ({ {retry.events.map((event, idx) => ( - - + + {formatTimestamp(event.timestamp)} {event.reason} - + {event.message} diff --git a/plugins/openchoreo-observability/src/components/Triggers/TriggerRow.tsx b/plugins/openchoreo-observability/src/components/Runs/RunRow.tsx similarity index 78% rename from plugins/openchoreo-observability/src/components/Triggers/TriggerRow.tsx rename to plugins/openchoreo-observability/src/components/Runs/RunRow.tsx index da46ae073..c4bf94f05 100644 --- a/plugins/openchoreo-observability/src/components/Triggers/TriggerRow.tsx +++ b/plugins/openchoreo-observability/src/components/Runs/RunRow.tsx @@ -11,14 +11,14 @@ import { TableBody, CircularProgress, } from '@material-ui/core'; -import type { Trigger, TriggerStatus } from './types'; +import type { Run, RunStatus } from './types'; import { useLogEntryStyles } from '../RuntimeLogs/styles'; -import { useTriggersStyles } from './styles'; +import { useRunsStyles } from './styles'; import { useRetries } from '../../hooks/useRetries'; import { RetryRow } from './RetryRow'; -interface TriggerRowProps { - trigger: Trigger; +interface RunRowProps { + run: Run; namespaceName: string; projectName: string; environmentName: string; @@ -37,7 +37,7 @@ const formatTimestamp = (ts?: string) => { const formatDuration = ( startTime?: string, completionTime?: string, - status?: TriggerStatus, + status?: RunStatus, ): string => { if (status !== 'succeeded' && status !== 'failed') return '—'; if (!startTime || !completionTime) return '—'; @@ -57,31 +57,31 @@ const formatDuration = ( }; const getStatusChipClass = ( - status: TriggerStatus, + status: RunStatus, logClasses: ReturnType, - triggerClasses: ReturnType, + runClasses: ReturnType, ): string => { switch (status) { case 'succeeded': - return triggerClasses.successChip; + return runClasses.successChip; case 'failed': return logClasses.errorChip; case 'running': - return triggerClasses.runningChip; + return runClasses.runningChip; default: return logClasses.undefinedChip; } }; -export const TriggerRow: FC = ({ - trigger, +export const RunRow: FC = ({ + run, namespaceName, projectName, environmentName, componentName, }) => { const logClasses = useLogEntryStyles(); - const triggerClasses = useTriggersStyles(); + const runClasses = useRunsStyles(); const [expanded, setExpanded] = useState(false); const { @@ -90,7 +90,7 @@ export const TriggerRow: FC = ({ error: retriesError, fetchRetries, } = useRetries({ - jobName: expanded ? trigger.jobName : '', + jobName: expanded ? run.jobName : '', namespaceName, projectName, environmentName, @@ -114,38 +114,38 @@ export const TriggerRow: FC = ({ - {trigger.status.toUpperCase()} - - ({trigger.failureReason}) + {run.status.toUpperCase()} + + ({run.failureReason}) ) : ( - trigger.status.toUpperCase() + run.status.toUpperCase() ) } - className={`${logClasses.logLevelChip} ${getStatusChipClass(trigger.status, logClasses, triggerClasses)}`} + className={`${logClasses.logLevelChip} ${getStatusChipClass(run.status, logClasses, runClasses)}`} /> - {trigger.jobName} + {run.jobName} - {formatTimestamp(trigger.startTime)} + {formatTimestamp(run.startTime)} - {formatTimestamp(trigger.completionTime)} + {formatTimestamp(run.completionTime)} {formatDuration( - trigger.startTime, - trigger.completionTime, - trigger.status, + run.startTime, + run.completionTime, + run.status, )} - {trigger.eventCount} + {run.eventCount} @@ -154,7 +154,7 @@ export const TriggerRow: FC = ({ - + Retries ({retries.length} pod{retries.length !== 1 ? 's' : ''}) @@ -173,7 +173,7 @@ export const TriggerRow: FC = ({ {!retriesLoading && !retriesError && retries.length > 0 && (
@@ -192,8 +192,8 @@ export const TriggerRow: FC = ({ projectName={projectName} environmentName={environmentName} componentName={componentName} - triggerStartTime={trigger.startTime} - triggerCompletionTime={trigger.completionTime} + runStartTime={run.startTime} + runCompletionTime={run.completionTime} /> ))} @@ -206,7 +206,7 @@ export const TriggerRow: FC = ({ color="textSecondary" style={{ padding: 8 }} > - No retry pods found for this trigger. + No retry pods found for this run. )} diff --git a/plugins/openchoreo-observability/src/components/Triggers/TriggersActions.tsx b/plugins/openchoreo-observability/src/components/Runs/RunsActions.tsx similarity index 88% rename from plugins/openchoreo-observability/src/components/Triggers/TriggersActions.tsx rename to plugins/openchoreo-observability/src/components/Runs/RunsActions.tsx index db27b6f17..3cb8d29a2 100644 --- a/plugins/openchoreo-observability/src/components/Triggers/TriggersActions.tsx +++ b/plugins/openchoreo-observability/src/components/Runs/RunsActions.tsx @@ -3,15 +3,15 @@ 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 { TriggersFilters } from './types'; -import { TRIGGERS_PAGE_SIZE } from './types'; +import type { RunsFilters } from './types'; +import { RUNS_PAGE_SIZE } from './types'; -interface TriggersActionsProps { +interface RunsActionsProps { totalCount: number; disabled: boolean; onRefresh: () => void; - filters: TriggersFilters; - onFiltersChange: (filters: Partial) => void; + filters: RunsFilters; + onFiltersChange: (filters: Partial) => void; lastUpdated?: Date; } @@ -25,19 +25,19 @@ const formatDate = (date: Date): string => { return `${day}/${month}/${year}, ${hours}:${minutes}:${seconds}`; }; -export const TriggersActions = ({ +export const RunsActions = ({ totalCount, disabled, onRefresh, filters, onFiltersChange, lastUpdated, -}: TriggersActionsProps) => { +}: RunsActionsProps) => { const classes = useLogsActionsStyles(); const displayDate = lastUpdated || new Date(); const page = filters.page; - const pageSize = TRIGGERS_PAGE_SIZE; + 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); @@ -58,7 +58,7 @@ export const TriggersActions = ({ - Total triggers: {totalCount} + Total runs: {totalCount} {totalCount > 0 && ( <> — showing {startItem}-{endItem} )} diff --git a/plugins/openchoreo-observability/src/components/Triggers/TriggersFilter.tsx b/plugins/openchoreo-observability/src/components/Runs/RunsFilter.tsx similarity index 77% rename from plugins/openchoreo-observability/src/components/Triggers/TriggersFilter.tsx rename to plugins/openchoreo-observability/src/components/Runs/RunsFilter.tsx index 1f7830daa..47ee0e604 100644 --- a/plugins/openchoreo-observability/src/components/Triggers/TriggersFilter.tsx +++ b/plugins/openchoreo-observability/src/components/Runs/RunsFilter.tsx @@ -7,18 +7,18 @@ import { Grid, } from '@material-ui/core'; import { Skeleton } from '@material-ui/lab'; -import type { TriggersFilters, Environment } from './types'; -import { TRIGGERS_TIME_RANGE_OPTIONS } from './types'; +import type { RunsFilters, Environment } from './types'; +import { RUNS_TIME_RANGE_OPTIONS } from './types'; -interface TriggersFilterProps { - filters: TriggersFilters; - onFiltersChange: (filters: Partial) => void; +interface RunsFilterProps { + filters: RunsFilters; + onFiltersChange: (filters: Partial) => void; environments: Environment[]; environmentsLoading: boolean; disabled?: boolean; } -export const TriggersFilter: FC = ({ +export const RunsFilter: FC = ({ filters, onFiltersChange, environments, @@ -45,14 +45,14 @@ export const TriggersFilter: FC = ({ disabled={disabled || environmentsLoading} variant="outlined" > - Environment + Environment {environmentsLoading ? ( ) : ( - {TRIGGERS_TIME_RANGE_OPTIONS.map(opt => ( + {RUNS_TIME_RANGE_OPTIONS.map(opt => ( {opt.label} @@ -85,11 +85,11 @@ export const TriggersFilter: FC = ({ - Sort Order + Sort Order
- {loading && triggers.length > 0 && ( + {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/Triggers/styles.ts b/plugins/openchoreo-observability/src/components/Runs/styles.ts similarity index 98% rename from plugins/openchoreo-observability/src/components/Triggers/styles.ts rename to plugins/openchoreo-observability/src/components/Runs/styles.ts index 21fb8154d..e0b094a37 100644 --- a/plugins/openchoreo-observability/src/components/Triggers/styles.ts +++ b/plugins/openchoreo-observability/src/components/Runs/styles.ts @@ -1,6 +1,6 @@ import { makeStyles } from '@material-ui/core/styles'; -export const useTriggersStyles = makeStyles(theme => ({ +export const useRunsStyles = makeStyles(theme => ({ successChip: { backgroundColor: '#c8e6c9', color: '#2e7d32', diff --git a/plugins/openchoreo-observability/src/components/Triggers/types.ts b/plugins/openchoreo-observability/src/components/Runs/types.ts similarity index 73% rename from plugins/openchoreo-observability/src/components/Triggers/types.ts rename to plugins/openchoreo-observability/src/components/Runs/types.ts index 2f510f059..9ad37c40b 100644 --- a/plugins/openchoreo-observability/src/components/Triggers/types.ts +++ b/plugins/openchoreo-observability/src/components/Runs/types.ts @@ -2,27 +2,27 @@ import type { Environment } from '../RuntimeLogs/types'; export type { Environment }; -export type TriggerStatus = 'succeeded' | 'failed' | 'running' | 'unknown'; +export type RunStatus = 'succeeded' | 'failed' | 'running' | 'unknown'; -export interface TriggerEvent { +export interface RunEvent { reason: string; message: string; timestamp: string; type: 'Normal' | 'Warning'; } -export interface Trigger { +export interface Run { jobName: string; - status: TriggerStatus; + status: RunStatus; startTime: string; completionTime?: string; eventCount: number; failureReason?: string; - events?: TriggerEvent[]; + events?: RunEvent[]; } -export interface TriggersQueryResponse { - triggers: Trigger[]; +export interface RunsQueryResponse { + runs: Run[]; total: number; tookMs: number; } @@ -50,18 +50,18 @@ export interface RetriesQueryResponse { tookMs: number; } -export interface TriggersFilters { +export interface RunsFilters { environmentId: string; timeRange: string; sortOrder: 'asc' | 'desc'; page: number; } -export const TRIGGERS_TIME_RANGE_OPTIONS = [ +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 TRIGGERS_PAGE_SIZE = 20; +export const RUNS_PAGE_SIZE = 20; diff --git a/plugins/openchoreo-observability/src/components/Triggers/index.ts b/plugins/openchoreo-observability/src/components/Triggers/index.ts deleted file mode 100644 index 91854f42a..000000000 --- a/plugins/openchoreo-observability/src/components/Triggers/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { ObservabilityTriggersPage } from './ObservabilityTriggersPage'; -export { TriggersFilter } from './TriggersFilter'; -export { TriggersTable } from './TriggersTable'; -export { TriggersActions } from './TriggersActions'; -export { TriggerRow } from './TriggerRow'; -export { RetryRow } from './RetryRow'; -export * from './types'; diff --git a/plugins/openchoreo-observability/src/hooks/index.ts b/plugins/openchoreo-observability/src/hooks/index.ts index 77be7686c..5fd8a1fd2 100644 --- a/plugins/openchoreo-observability/src/hooks/index.ts +++ b/plugins/openchoreo-observability/src/hooks/index.ts @@ -27,7 +27,7 @@ export { useComponentAlerts } from './useComponentAlerts'; export { useUrlFiltersForIncidents } from './useUrlFiltersForIncidents'; export { useProjectIncidents } from './useProjectIncidents'; export { useUpdateIncident } from './useUpdateIncident'; -export { useTriggers } from './useTriggers'; +export { useRuns } from './useRuns'; export { useRetries } from './useRetries'; export { usePodLogs } from './usePodLogs'; -export { useUrlFiltersForTriggers } from './useUrlFiltersForTriggers'; +export { useUrlFiltersForRuns } from './useUrlFiltersForRuns'; diff --git a/plugins/openchoreo-observability/src/hooks/useRetries.ts b/plugins/openchoreo-observability/src/hooks/useRetries.ts index 85e93818f..16d187c61 100644 --- a/plugins/openchoreo-observability/src/hooks/useRetries.ts +++ b/plugins/openchoreo-observability/src/hooks/useRetries.ts @@ -1,7 +1,7 @@ import { useCallback, useRef, useState } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { observabilityApiRef } from '../api/ObservabilityApi'; -import { Retry } from '../components/Triggers/types'; +import { Retry } from '../components/Runs/types'; export interface UseRetriesOptions { jobName: string; diff --git a/plugins/openchoreo-observability/src/hooks/useTriggers.ts b/plugins/openchoreo-observability/src/hooks/useRuns.ts similarity index 80% rename from plugins/openchoreo-observability/src/hooks/useTriggers.ts rename to plugins/openchoreo-observability/src/hooks/useRuns.ts index 751ffc6a6..22fc55c31 100644 --- a/plugins/openchoreo-observability/src/hooks/useTriggers.ts +++ b/plugins/openchoreo-observability/src/hooks/useRuns.ts @@ -3,10 +3,10 @@ import { useApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { observabilityApiRef } from '../api/ObservabilityApi'; import { calculateTimeRange } from '../components/RuntimeLogs/utils'; -import { Trigger } from '../components/Triggers/types'; +import { Run } from '../components/Runs/types'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; -export interface UseTriggersOptions { +export interface UseRunsOptions { environmentId: string; environmentName: string; timeRange: string; @@ -15,23 +15,23 @@ export interface UseTriggersOptions { sortOrder?: 'asc' | 'desc'; } -export interface UseTriggersResult { - triggers: Trigger[]; +export interface UseRunsResult { + runs: Run[]; loading: boolean; error: string | null; totalCount: number; - fetchTriggers: (reset?: boolean) => Promise; + fetchRuns: (reset?: boolean) => Promise; refresh: () => void; } -export function useTriggers( +export function useRuns( entity: Entity, namespace: string, project: string, - options: UseTriggersOptions, -): UseTriggersResult { + options: UseRunsOptions, +): UseRunsResult { const observabilityApi = useApi(observabilityApiRef); - const [triggers, setTriggers] = useState([]); + const [runs, setRuns] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [totalCount, setTotalCount] = useState(0); @@ -40,7 +40,7 @@ export function useTriggers( const componentName = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.COMPONENT]; - const fetchTriggers = useCallback( + const fetchRuns = useCallback( async (_reset = true) => { if ( !options.environmentId || @@ -60,7 +60,7 @@ export function useTriggers( const { startTime, endTime } = calculateTimeRange(options.timeRange); - const response = await observabilityApi.getTriggers( + const response = await observabilityApi.getRuns( namespace, project, options.environmentName, @@ -76,12 +76,12 @@ export function useTriggers( if (version !== requestVersionRef.current) return; - setTriggers(response.triggers ?? []); + setRuns(response.runs ?? []); setTotalCount(response.total ?? 0); } catch (err) { if (version !== requestVersionRef.current) return; setError( - err instanceof Error ? err.message : 'Failed to fetch triggers', + err instanceof Error ? err.message : 'Failed to fetch runs', ); } finally { if (version === requestVersionRef.current) { @@ -104,16 +104,16 @@ export function useTriggers( ); const refresh = useCallback(() => { - setTriggers([]); - fetchTriggers(true); - }, [fetchTriggers]); + setRuns([]); + fetchRuns(true); + }, [fetchRuns]); return { - triggers, + runs, loading, error, totalCount, - fetchTriggers, + fetchRuns, refresh, }; } diff --git a/plugins/openchoreo-observability/src/hooks/useUrlFiltersForTriggers.ts b/plugins/openchoreo-observability/src/hooks/useUrlFiltersForRuns.ts similarity index 85% rename from plugins/openchoreo-observability/src/hooks/useUrlFiltersForTriggers.ts rename to plugins/openchoreo-observability/src/hooks/useUrlFiltersForRuns.ts index 2f0208516..020ab7dff 100644 --- a/plugins/openchoreo-observability/src/hooks/useUrlFiltersForTriggers.ts +++ b/plugins/openchoreo-observability/src/hooks/useUrlFiltersForRuns.ts @@ -1,28 +1,28 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; import type { Environment } from '../components/RuntimeLogs/types'; -import type { TriggersFilters } from '../components/Triggers/types'; -import { TRIGGERS_TIME_RANGE_OPTIONS } from '../components/Triggers/types'; +import type { 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[] = TRIGGERS_TIME_RANGE_OPTIONS.map( +const VALID_TIME_RANGES: readonly string[] = RUNS_TIME_RANGE_OPTIONS.map( o => o.value, ); -interface UseUrlFiltersForTriggersOptions { +interface UseUrlFiltersForRunsOptions { environments: Environment[]; } -export function useUrlFiltersForTriggers({ +export function useUrlFiltersForRuns({ environments, -}: UseUrlFiltersForTriggersOptions): { - filters: TriggersFilters; - updateFilters: (newFilters: Partial) => void; +}: UseUrlFiltersForRunsOptions): { + filters: RunsFilters; + updateFilters: (newFilters: Partial) => void; resetFilters: () => void; } { const [searchParams, setSearchParams] = useSearchParams(); - const filters = useMemo(() => { + const filters = useMemo(() => { const envId = searchParams.get('env'); const rawTimeRange = searchParams.get('timeRange') || DEFAULT_TIME_RANGE; const timeRange = VALID_TIME_RANGES.includes(rawTimeRange) @@ -58,7 +58,7 @@ export function useUrlFiltersForTriggers({ }, [environments, searchParams, setSearchParams]); const updateFilters = useCallback( - (newFilters: Partial) => { + (newFilters: Partial) => { const newParams = new URLSearchParams(searchParams); if (newFilters.environmentId !== undefined) { diff --git a/plugins/openchoreo-observability/src/index.ts b/plugins/openchoreo-observability/src/index.ts index 5db961e8e..4b80633ff 100644 --- a/plugins/openchoreo-observability/src/index.ts +++ b/plugins/openchoreo-observability/src/index.ts @@ -10,7 +10,7 @@ export { ObservabilityWirelogs, ObservabilityProjectIncidents, ObservabilityCostAnalysis, - ObservabilityTriggers, + 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 64f7e867c..f6722e614 100644 --- a/plugins/openchoreo-observability/src/plugin.ts +++ b/plugins/openchoreo-observability/src/plugin.ts @@ -117,8 +117,8 @@ export const ObservabilityCostAnalysis = lazy(() => })), ); -export const ObservabilityTriggers = lazy(() => - import('./components/Triggers/ObservabilityTriggersPage').then(m => ({ - default: m.ObservabilityTriggersPage, +export const ObservabilityRuns = lazy(() => + import('./components/Runs/ObservabilityRunsPage').then(m => ({ + default: m.ObservabilityRunsPage, })), ); From 49dc372d2dd56c85892a898883b6bb420e37565c Mon Sep 17 00:00:00 2001 From: Thushara Sampath Date: Thu, 16 Jul 2026 22:46:05 +0530 Subject: [PATCH 5/9] feat(observability): scope retries query to run lifetime Pass the run's `startTime` and `completionTime` from `RunRow` through `useRetries` into `ObservabilityApi.getRetries`, and forward them as `startTime`/`endTime` on the backend retries query so the events fetch is scoped to a single run's window instead of the backend's 30-day default lookback. Motivation: the observer's kube-events-collector adapter caps a single retries call at 1000 events. On high-frequency CronJobs, a 30-day default window silently truncates data. Scoping to the run's own lifetime keeps the retries view accurate without paginating. Contract (from openchoreo/openchoreo#3933): - Both `startTime` and `endTime` provided -> that window is used. - Exactly one provided -> backend responds 400 (both-or-none). - Both omitted -> backend defaults to a 30-day lookback (unchanged behavior for callers that do not pass the pair). `useRetries` and `getRetries` only forward the pair when both values are truthy, so a still-running run (no `completionTime` yet) transparently falls back to the backend default rather than triggering a 400. Signed-off-by: Thushara Sampath --- .../src/api/ObservabilityApi.ts | 62 ++++++++++++++++--- .../src/components/Runs/RunRow.tsx | 5 ++ .../src/hooks/useRetries.ts | 17 +++++ 3 files changed, 76 insertions(+), 8 deletions(-) diff --git a/plugins/openchoreo-observability/src/api/ObservabilityApi.ts b/plugins/openchoreo-observability/src/api/ObservabilityApi.ts index bf352a6ce..c0c962c62 100644 --- a/plugins/openchoreo-observability/src/api/ObservabilityApi.ts +++ b/plugins/openchoreo-observability/src/api/ObservabilityApi.ts @@ -209,6 +209,21 @@ export interface ObservabilityApi { 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( @@ -1115,12 +1130,50 @@ export class ObservabilityClient implements ObservabilityApi { 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, @@ -1128,14 +1181,7 @@ export class ObservabilityClient implements ObservabilityApi { { method: 'POST', headers: { 'Content-Type': 'application/json', ...DIRECT_HEADER }, - body: JSON.stringify({ - searchScope: { - namespace: namespaceName, - project: projectName, - component: componentName, - environment: environmentName, - }, - }), + body: JSON.stringify(body), }, ); diff --git a/plugins/openchoreo-observability/src/components/Runs/RunRow.tsx b/plugins/openchoreo-observability/src/components/Runs/RunRow.tsx index c4bf94f05..6f6f329f4 100644 --- a/plugins/openchoreo-observability/src/components/Runs/RunRow.tsx +++ b/plugins/openchoreo-observability/src/components/Runs/RunRow.tsx @@ -95,6 +95,11 @@ export const RunRow: FC = ({ 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(() => { diff --git a/plugins/openchoreo-observability/src/hooks/useRetries.ts b/plugins/openchoreo-observability/src/hooks/useRetries.ts index 16d187c61..b795f34e0 100644 --- a/plugins/openchoreo-observability/src/hooks/useRetries.ts +++ b/plugins/openchoreo-observability/src/hooks/useRetries.ts @@ -9,6 +9,18 @@ export interface UseRetriesOptions { 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 { @@ -47,6 +59,9 @@ export function useRetries(options: UseRetriesOptions): UseRetriesResult { options.projectName, options.environmentName, options.componentName, + options.startTime && options.endTime + ? { startTime: options.startTime, endTime: options.endTime } + : undefined, ); if (version !== requestVersionRef.current) return; @@ -69,6 +84,8 @@ export function useRetries(options: UseRetriesOptions): UseRetriesResult { options.projectName, options.environmentName, options.componentName, + options.startTime, + options.endTime, ]); return { From c0e4da27e15dfffe274a55b3d28186b9dd8a7bb2 Mon Sep 17 00:00:00 2001 From: Thushara Sampath Date: Thu, 16 Jul 2026 23:33:21 +0530 Subject: [PATCH 6/9] fix(observability): migrate Runs page imports to latest upstream APIs Catch up the Runs page (originally landed as "Triggers") with upstream refactors that moved / renamed helpers the page had been importing from locations that no longer exist. The branch's task-obs commits pre-dated these upstream moves, so `tsc` failed on stale imports. No behavioral change beyond adopting the new empty/forbidden/unavailable environment status pattern already used by Metrics, Alerts, etc. - ObservabilityRunsPage.tsx: swap `useGetEnvironmentsByNamespace` (gone) for `useProjectEnvironments` from `@openchoreo/backstage-plugin-react`, and render `EnvironmentsStatusNotice` for the non-`ok` statuses (`empty-pipeline` / `forbidden` / `unavailable`) instead of the old generic "No environments found" alert. - Runs/types.ts: inline the local `Environment` shape (id/name/resourceName) the Runs filter and URL sync depend on. Previously re-exported from `RuntimeLogs/types`, which no longer defines it; the upstream `Environment` has a different shape (name/displayName/namespace/dataPlaneRef) and is mapped onto this local one by `ObservabilityRunsPage`. - hooks/useRuns.ts: import `calculateTimeRange` from `@openchoreo/backstage-plugin-react` (its new home); the old `components/RuntimeLogs/utils` module was removed upstream. - hooks/useUrlFiltersForRuns.ts: point the `Environment` type import at the local `Runs/types` shape (`id`-based) it actually uses, not the removed `RuntimeLogs/types` re-export. Signed-off-by: Thushara Sampath --- .../components/Runs/ObservabilityRunsPage.tsx | 44 +++++++++---------- .../src/components/Runs/types.ts | 14 ++++-- .../src/hooks/useRuns.ts | 2 +- .../src/hooks/useUrlFiltersForRuns.ts | 3 +- 4 files changed, 35 insertions(+), 28 deletions(-) diff --git a/plugins/openchoreo-observability/src/components/Runs/ObservabilityRunsPage.tsx b/plugins/openchoreo-observability/src/components/Runs/ObservabilityRunsPage.tsx index f16aa82c1..5055c008a 100644 --- a/plugins/openchoreo-observability/src/components/Runs/ObservabilityRunsPage.tsx +++ b/plugins/openchoreo-observability/src/components/Runs/ObservabilityRunsPage.tsx @@ -10,12 +10,15 @@ import { RunsActions } from './RunsActions'; import { useRuns, useGetNamespaceAndProjectByEntity, - useGetEnvironmentsByNamespace, useUrlFiltersForRuns, } from '../../hooks'; -import { useLogsPermission } from '@openchoreo/backstage-plugin-react'; +import { + useLogsPermission, + useProjectEnvironments, +} from '@openchoreo/backstage-plugin-react'; import { useRuntimeLogsStyles } from '../RuntimeLogs/styles'; -import type { Environment as RuntimeLogsEnvironment } from '../RuntimeLogs/types'; +import { EnvironmentsStatusNotice } from '../common'; +import type { Environment } from './types'; import { RUNS_PAGE_SIZE } from './types'; const ObservabilityRunsContent = () => { @@ -25,18 +28,20 @@ const ObservabilityRunsContent = () => { const { namespace, project } = useGetNamespaceAndProjectByEntity(entity); const { - environments: observabilityEnvironments, + environments: projectEnvironments, loading: environmentsLoading, - error: environmentsError, - } = useGetEnvironmentsByNamespace(namespace); + status: environmentsStatus, + } = useProjectEnvironments(project, namespace); - const environments = useMemo(() => { - return observabilityEnvironments.map(env => ({ + // 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, })); - }, [observabilityEnvironments]); + }, [projectEnvironments]); const { filters, updateFilters } = useUrlFiltersForRuns({ environments, @@ -146,8 +151,14 @@ const ObservabilityRunsContent = () => { ); }; - if (environmentsError) { - return {renderError(environmentsError)}; + // 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 ( @@ -162,17 +173,6 @@ const ObservabilityRunsContent = () => { {runsError && renderError(runsError)} - {!filters.environmentId && - !environmentsLoading && - environments.length === 0 && ( - - - No environments found. Make sure your component is properly - configured. - - - )} - {filters.environmentId && selectedEnvironment && ( <> Date: Fri, 17 Jul 2026 09:19:19 +0530 Subject: [PATCH 7/9] chore(observability): satisfy CI (prettier + changeset) - Ran prettier over the 6 files the Lint job flagged (ObservabilityApi.ts, RetryRow.tsx, RunRow.tsx, RunsActions.tsx, useRetries.ts, useRuns.ts). No behavioural change. - Added .changeset/scheduled-task-runs-page.md so the Check-for-changeset job passes. Marks the openchoreo-observability plugin for a minor bump and references the backend PR openchoreo/openchoreo#3933. Signed-off-by: Thushara Sampath --- .changeset/scheduled-task-runs-page.md | 5 +++ .../src/api/ObservabilityApi.ts | 4 +-- .../src/components/Runs/RetryRow.tsx | 32 +++++++++++-------- .../src/components/Runs/RunRow.tsx | 29 ++++++++--------- .../src/components/Runs/RunsActions.tsx | 5 ++- .../src/hooks/useRetries.ts | 4 +-- .../src/hooks/useRuns.ts | 4 +-- 7 files changed, 44 insertions(+), 39 deletions(-) create mode 100644 .changeset/scheduled-task-runs-page.md 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/plugins/openchoreo-observability/src/api/ObservabilityApi.ts b/plugins/openchoreo-observability/src/api/ObservabilityApi.ts index c0c962c62..e8145aaab 100644 --- a/plugins/openchoreo-observability/src/api/ObservabilityApi.ts +++ b/plugins/openchoreo-observability/src/api/ObservabilityApi.ts @@ -1103,9 +1103,7 @@ export class ObservabilityClient implements ObservabilityApi { 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}`, - ); + throw new Error(error || `Failed to fetch runs: ${response.statusText}`); } const data = await response.json(); diff --git a/plugins/openchoreo-observability/src/components/Runs/RetryRow.tsx b/plugins/openchoreo-observability/src/components/Runs/RetryRow.tsx index e286217a6..7cbaf2281 100644 --- a/plugins/openchoreo-observability/src/components/Runs/RetryRow.tsx +++ b/plugins/openchoreo-observability/src/components/Runs/RetryRow.tsx @@ -124,14 +124,20 @@ export const RetryRow: FC = ({ <> setExpanded(prev => !prev)} > @@ -157,9 +163,7 @@ export const RetryRow: FC = ({ {logsOpen ? ( ) : ( - + )} Logs ({logs.length}) @@ -214,7 +218,9 @@ export const RetryRow: FC = ({ {formatTimestamp(log.timestamp)} {log.level || '-'} @@ -234,17 +240,13 @@ export const RetryRow: FC = ({ onClick={() => setEventsOpen(prev => !prev)} > {eventsOpen ? ( - + ) : ( )} - + Events ({retry.events.length}) @@ -256,7 +258,11 @@ export const RetryRow: FC = ({ {formatTimestamp(event.timestamp)} {event.reason} diff --git a/plugins/openchoreo-observability/src/components/Runs/RunRow.tsx b/plugins/openchoreo-observability/src/components/Runs/RunRow.tsx index 6f6f329f4..64784db8f 100644 --- a/plugins/openchoreo-observability/src/components/Runs/RunRow.tsx +++ b/plugins/openchoreo-observability/src/components/Runs/RunRow.tsx @@ -112,7 +112,9 @@ export const RunRow: FC = ({ <> setExpanded(prev => !prev)} > @@ -130,7 +132,11 @@ export const RunRow: FC = ({ run.status.toUpperCase() ) } - className={`${logClasses.logLevelChip} ${getStatusChipClass(run.status, logClasses, runClasses)}`} + className={`${logClasses.logLevelChip} ${getStatusChipClass( + run.status, + logClasses, + runClasses, + )}`} /> @@ -143,15 +149,9 @@ export const RunRow: FC = ({ {formatTimestamp(run.completionTime)} - {formatDuration( - run.startTime, - run.completionTime, - run.status, - )} - - - {run.eventCount} + {formatDuration(run.startTime, run.completionTime, run.status)} + {run.eventCount} {expanded && ( @@ -160,7 +160,8 @@ export const RunRow: FC = ({ - Retries ({retries.length} pod{retries.length !== 1 ? 's' : ''}) + Retries ({retries.length} pod{retries.length !== 1 ? 's' : ''} + ) {retriesLoading && ( @@ -176,10 +177,7 @@ export const RunRow: FC = ({ )} {!retriesLoading && !retriesError && retries.length > 0 && ( - +
Status @@ -214,7 +212,6 @@ export const RunRow: FC = ({ No retry pods found for this run. )} - diff --git a/plugins/openchoreo-observability/src/components/Runs/RunsActions.tsx b/plugins/openchoreo-observability/src/components/Runs/RunsActions.tsx index 3cb8d29a2..2b6a2381a 100644 --- a/plugins/openchoreo-observability/src/components/Runs/RunsActions.tsx +++ b/plugins/openchoreo-observability/src/components/Runs/RunsActions.tsx @@ -60,7 +60,10 @@ export const RunsActions = ({ Total runs: {totalCount} {totalCount > 0 && ( - <> — showing {startItem}-{endItem} + <> + {' '} + — showing {startItem}-{endItem} + )} diff --git a/plugins/openchoreo-observability/src/hooks/useRetries.ts b/plugins/openchoreo-observability/src/hooks/useRetries.ts index b795f34e0..f6cf2854c 100644 --- a/plugins/openchoreo-observability/src/hooks/useRetries.ts +++ b/plugins/openchoreo-observability/src/hooks/useRetries.ts @@ -69,9 +69,7 @@ export function useRetries(options: UseRetriesOptions): UseRetriesResult { setRetries(response.retries ?? []); } catch (err) { if (version !== requestVersionRef.current) return; - setError( - err instanceof Error ? err.message : 'Failed to fetch retries', - ); + setError(err instanceof Error ? err.message : 'Failed to fetch retries'); } finally { if (version === requestVersionRef.current) { setLoading(false); diff --git a/plugins/openchoreo-observability/src/hooks/useRuns.ts b/plugins/openchoreo-observability/src/hooks/useRuns.ts index 5658c61e5..f9aba0deb 100644 --- a/plugins/openchoreo-observability/src/hooks/useRuns.ts +++ b/plugins/openchoreo-observability/src/hooks/useRuns.ts @@ -80,9 +80,7 @@ export function useRuns( setTotalCount(response.total ?? 0); } catch (err) { if (version !== requestVersionRef.current) return; - setError( - err instanceof Error ? err.message : 'Failed to fetch runs', - ); + setError(err instanceof Error ? err.message : 'Failed to fetch runs'); } finally { if (version === requestVersionRef.current) { setLoading(false); From 3f0acb31e1b67301d226ed13eb1d7874bd2d1a9a Mon Sep 17 00:00:00 2001 From: Thushara Sampath Date: Fri, 17 Jul 2026 10:27:30 +0530 Subject: [PATCH 8/9] test(observability): add coverage for Runs page API + hooks Covers the Runs-page API surface + supporting hooks that were pushing patch coverage under threshold on the retries-time-window PR: - src/api/ObservabilityApi.test.ts: add getRuns, getRetries, getPodLogs suites. getRetries includes explicit both/one-only/neither cases for the client-side both-or-none time-bounds guard, plus jobName encoding and response-shape defaults. - src/hooks/useRuns.test.ts: initial state, fetch success, error paths, refresh flow, and the stale-request guard on requestVersionRef. - src/hooks/useRetries.test.ts: skips call when jobName/namespace empty, bounds forwarding under both/one/neither, error paths, stale guard. - src/hooks/usePodLogs.test.ts: skips call when podName/component empty, option forwarding, error paths, stale guard. - src/hooks/useUrlFiltersForRuns.test.tsx: default filters, URL parsing, auto-select-first-env effect, updateFilters, resetFilters. Signed-off-by: Thushara Sampath --- .../src/api/ObservabilityApi.test.ts | 403 ++++++++++++++++++ .../src/hooks/usePodLogs.test.ts | 174 ++++++++ .../src/hooks/useRetries.test.ts | 224 ++++++++++ .../src/hooks/useRuns.test.ts | 218 ++++++++++ .../src/hooks/useUrlFiltersForRuns.test.tsx | 143 +++++++ 5 files changed, 1162 insertions(+) create mode 100644 plugins/openchoreo-observability/src/hooks/usePodLogs.test.ts create mode 100644 plugins/openchoreo-observability/src/hooks/useRetries.test.ts create mode 100644 plugins/openchoreo-observability/src/hooks/useRuns.test.ts create mode 100644 plugins/openchoreo-observability/src/hooks/useUrlFiltersForRuns.test.tsx diff --git a/plugins/openchoreo-observability/src/api/ObservabilityApi.test.ts b/plugins/openchoreo-observability/src/api/ObservabilityApi.test.ts index 0e473b976..32274d687 100644 --- a/plugins/openchoreo-observability/src/api/ObservabilityApi.test.ts +++ b/plugins/openchoreo-observability/src/api/ObservabilityApi.test.ts @@ -399,3 +399,406 @@ 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/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/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/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/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); + }); + }); +}); From e25c64a50f2cca2d1f53fedf323a7a0f095b0850 Mon Sep 17 00:00:00 2001 From: Thushara Sampath Date: Fri, 17 Jul 2026 13:38:52 +0530 Subject: [PATCH 9/9] test(observability): cover RunsActions pagination + fix Prettier on API tests - Prettier'd plugins/openchoreo-observability/src/api/ObservabilityApi.test.ts (Lint CI flagged it after the previous test-coverage push). - New RunsActions.test.tsx covers the pagination + refresh + disabled behaviour of the Runs page actions bar. Focused on real behaviour that breaks silently: Prev disabled at page 0, Next disabled on last page, Next disabled when totalCount is 0, endItem capped on the partial last page, "showing X-Y" hidden when totalCount is 0, disabled prop overrides pagination state. Together these bring patch coverage above the 52% threshold codecov flagged. Signed-off-by: Thushara Sampath --- .../src/api/ObservabilityApi.test.ts | 61 ++---- .../src/components/Runs/RunsActions.test.tsx | 177 ++++++++++++++++++ 2 files changed, 190 insertions(+), 48 deletions(-) create mode 100644 plugins/openchoreo-observability/src/components/Runs/RunsActions.test.tsx diff --git a/plugins/openchoreo-observability/src/api/ObservabilityApi.test.ts b/plugins/openchoreo-observability/src/api/ObservabilityApi.test.ts index 32274d687..6e29fbd49 100644 --- a/plugins/openchoreo-observability/src/api/ObservabilityApi.test.ts +++ b/plugins/openchoreo-observability/src/api/ObservabilityApi.test.ts @@ -554,13 +554,7 @@ describe('ObservabilityClient.getRetries', () => { ); const client = createClient(); - await client.getRetries( - 'job-1', - 'ns1', - 'project-a', - 'dev', - 'component-a', - ); + await client.getRetries('job-1', 'ns1', 'project-a', 'dev', 'component-a'); expect(mockFetchApi.fetch).toHaveBeenCalledTimes(1); const [url, options] = mockFetchApi.fetch.mock.calls[0]; @@ -585,17 +579,10 @@ describe('ObservabilityClient.getRetries', () => { 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', - }, - ); + 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'); @@ -606,14 +593,9 @@ describe('ObservabilityClient.getRetries', () => { 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' }, - ); + 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(); @@ -624,14 +606,9 @@ describe('ObservabilityClient.getRetries', () => { 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' }, - ); + 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(); @@ -767,13 +744,7 @@ describe('ObservabilityClient.getPodLogs', () => { mockFetchApi.fetch.mockResolvedValueOnce(mockOkResponse({ logs: [] })); const client = createClient(); - await client.getPodLogs( - 'pod-1', - 'ns1', - 'project-a', - 'dev', - 'component-a', - ); + 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); @@ -792,13 +763,7 @@ describe('ObservabilityClient.getPodLogs', () => { const client = createClient(); await expect( - client.getPodLogs( - 'pod-1', - 'ns1', - 'project-a', - 'dev', - 'component-a', - ), + client.getPodLogs('pod-1', 'ns1', 'project-a', 'dev', 'component-a'), ).rejects.toThrow('pod logs boom'); }); }); 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(); + }); + }); +});