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