From 8c1a58b5cf82e9f7d2a7ad0273b1940c14507328 Mon Sep 17 00:00:00 2001 From: B-Whitt <34513926+B-Whitt@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:16:19 -0400 Subject: [PATCH 1/3] feat(eda): adopt paginated log loading with timestamp-based polling Replaces the unbounded page_size={count} pattern that fetches ALL logs in one request (causing OOM on large activations) with: - Initial load: fetch newest 5000 logs, reverse for chronological display - Polling: log_timestamp__gt for new logs every 5s while running - Scroll-back: log_timestamp__lt for older history on scroll to top Resolves: AAP-83306 Assisted by: Claude Opus 4.6 --- .../ActivationInstanceEvents.tsx | 127 +++++++++++++----- 1 file changed, 97 insertions(+), 30 deletions(-) diff --git a/frontend/eda/rulebook-activations/ActivationInstancePage/ActivationInstanceEvents.tsx b/frontend/eda/rulebook-activations/ActivationInstancePage/ActivationInstanceEvents.tsx index 8ac8bfb495..4e4ae43784 100644 --- a/frontend/eda/rulebook-activations/ActivationInstancePage/ActivationInstanceEvents.tsx +++ b/frontend/eda/rulebook-activations/ActivationInstancePage/ActivationInstanceEvents.tsx @@ -2,9 +2,8 @@ import { IFilterState, IToolbarFilter } from '@ansible/ansible-ui-framework'; import { AwxItemsResponse } from '@ansible/awx-ui/common/AwxItemsResponse'; import { useScrollControls } from '@ansible/awx-ui/views/jobs/JobOutput/useScrollControls'; import { requestGet } from '@ansible/common-ui/crud/Data'; -import { useGet } from '@ansible/common-ui/crud/useGet'; import { PageSection } from '@patternfly/react-core'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; import styled from 'styled-components'; import { useVirtualizedList } from '../../..//common/utils/useVirtualized'; @@ -14,6 +13,9 @@ import { edaAPI } from '../../common/eda-utils'; import { EdaActivationInstanceLog } from '../../interfaces/EdaActivationInstanceLog'; import { ActivationInstanceOutputRow } from './ActivationInstanceOutputRow'; +const INITIAL_PAGE_SIZE = 5000; +const POLL_INTERVAL_MS = 5000; + const ScrollContainer = styled.div` overflow: auto; background-color: var(--pf-t--global--background--color--primary--default); @@ -38,57 +40,122 @@ interface IActivationInstanceEventsProps { } export function ActivationInstanceEvents(props: IActivationInstanceEventsProps) { - const [activationInstanceLog, setActivationInstanceLog] = - useState>(); + const [logs, setLogs] = useState([]); + const [hasOlderLogs, setHasOlderLogs] = useState(false); + const latestTimestampRef = useRef(0); const params = useParams<{ instanceId: string }>(); const { toolbarFilters, filterState, isFollowModeEnabled, setIsFollowModeEnabled, isRunning } = props; - const { data: activationInstanceLogInfo } = useGet>( - edaAPI`/activation-instances/${params.instanceId ?? ''}/logs/?page_size=1` - ); + const buildFilterString = useCallback(() => { + return getFiltersQueryString(toolbarFilters, filterState); + }, [toolbarFilters, filterState]); useEffect(() => { - async function fetchData() { - const filterString = getFiltersQueryString(toolbarFilters, filterState); - const qsParts = [`page_size=${activationInstanceLogInfo?.count.toString() ?? '10'}`]; + async function initialLoad() { + const filterString = buildFilterString(); + const qsParts = [`page_size=${INITIAL_PAGE_SIZE}`, 'ordering=-id']; if (filterString) { qsParts.push(filterString); } - const activationInstanceLogOutput = await requestGet< - AwxItemsResponse - >( + const response = await requestGet>( edaAPI`/activation-instances/${params.instanceId ?? ''}/logs/`.concat( `?${qsParts.join('&')}` ) ); - setActivationInstanceLog(activationInstanceLogOutput); + const results = [...(response.results ?? [])].reverse(); + setLogs(results); + setHasOlderLogs((response.count ?? 0) > INITIAL_PAGE_SIZE); + + if (results.length > 0) { + const lastLog = results[results.length - 1]; + latestTimestampRef.current = lastLog.log_timestamp ?? 0; + } } - void fetchData(); - }, [params.instanceId, activationInstanceLogInfo?.count, toolbarFilters, filterState]); + void initialLoad(); + }, [params.instanceId, buildFilterString]); + + useEffect(() => { + if (!isRunning && !isFollowModeEnabled) return; + + const interval = setInterval(async () => { + if (latestTimestampRef.current === 0) return; + + const filterString = buildFilterString(); + const qsParts = [ + `log_timestamp__gt=${latestTimestampRef.current}`, + `page_size=${INITIAL_PAGE_SIZE}`, + ]; + if (filterString) { + qsParts.push(filterString); + } + + const response = await requestGet>( + edaAPI`/activation-instances/${params.instanceId ?? ''}/logs/`.concat( + `?${qsParts.join('&')}` + ) + ); - const estimatedMaxLines = (activationInstanceLog?.results.length ?? 0) * 10; + const newLogs = response.results ?? []; + if (newLogs.length > 0) { + setLogs((prev) => [...prev, ...newLogs]); + const lastLog = newLogs[newLogs.length - 1]; + latestTimestampRef.current = lastLog.log_timestamp ?? latestTimestampRef.current; + } + }, POLL_INTERVAL_MS); + + return () => clearInterval(interval); + }, [params.instanceId, isRunning, isFollowModeEnabled, buildFilterString]); + + const loadOlderLogs = useCallback(async () => { + if (logs.length === 0 || !hasOlderLogs) return; + + const oldestTimestamp = logs[0].log_timestamp ?? 0; + const filterString = buildFilterString(); + const qsParts = [ + `log_timestamp__lt=${oldestTimestamp}`, + `page_size=${INITIAL_PAGE_SIZE}`, + 'ordering=-id', + ]; + if (filterString) { + qsParts.push(filterString); + } + + const response = await requestGet>( + edaAPI`/activation-instances/${params.instanceId ?? ''}/logs/`.concat( + `?${qsParts.join('&')}` + ) + ); + + const olderLogs = [...(response.results ?? [])].reverse(); + if (olderLogs.length > 0) { + setLogs((prev) => [...olderLogs, ...prev]); + } + setHasOlderLogs((response.count ?? 0) > INITIAL_PAGE_SIZE); + }, [logs, hasOlderLogs, params.instanceId, buildFilterString]); + + const estimatedMaxLines = (logs.length ?? 0) * 10; const outputLineChars = String(estimatedMaxLines).length; const containerRef = useRef(null); const { handleScroll, scrollToTop, scrollToBottom, scrollPageDown, scrollPageUp } = - useScrollControls( - containerRef, - isFollowModeEnabled, - setIsFollowModeEnabled, - activationInstanceLog?.results.length ?? 0, - isRunning - ); + useScrollControls(containerRef, isFollowModeEnabled, setIsFollowModeEnabled, logs.length, isRunning); + + const onScroll = useCallback( + (el: HTMLElement) => { + handleScroll(el); + if (el.scrollTop === 0 && hasOlderLogs) { + void loadOlderLogs(); + } + }, + [handleScroll, hasOlderLogs, loadOlderLogs] + ); const { beforeRowsHeight, visibleItems, afterRowsHeight, setRowHeight } = - useVirtualizedList( - containerRef, - activationInstanceLog?.results ?? [], - handleScroll - ); + useVirtualizedList(containerRef, logs, onScroll); return (
@@ -110,7 +177,7 @@ export function ActivationInstanceEvents(props: IActivationInstanceEventsProps) {visibleItems?.map((row) => ( r.id === row.id) ?? 0} + index={logs.findIndex((r) => r.id === row.id)} row={row} setHeight={setRowHeight} /> From 73c6bdf7e99d3996a54e8bbf60fce5187ddae72e Mon Sep 17 00:00:00 2001 From: B-Whitt <34513926+B-Whitt@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:28:28 -0400 Subject: [PATCH 2/3] feat(eda): add DEBUG warning and store_debug_logs checkbox on activation form Shows a warning alert when DEBUG log level is selected, explaining the storage impact. Adds a store_debug_logs checkbox (visible only at DEBUG level) that controls whether DEBUG lines are persisted to the database. The checkbox resets to false when switching away from DEBUG. Resolves: AAP-84685 Assisted by: Claude Opus 4.6 --- .../eda/interfaces/EdaRulebookActivation.ts | 1 + .../RulebookActivationForm.tsx | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/frontend/eda/interfaces/EdaRulebookActivation.ts b/frontend/eda/interfaces/EdaRulebookActivation.ts index 4144e68078..a298519969 100644 --- a/frontend/eda/interfaces/EdaRulebookActivation.ts +++ b/frontend/eda/interfaces/EdaRulebookActivation.ts @@ -12,5 +12,6 @@ export type EdaRulebookActivation = Omit & { export type EdaRulebookActivationCreate = ActivationCreate & { enable_persistence?: boolean; rule_engine_credential_id?: number | null; + store_debug_logs?: boolean; }; export type EdaRulebookActivationStatus = StatusEnum; diff --git a/frontend/eda/rulebook-activations/RulebookActivationForm.tsx b/frontend/eda/rulebook-activations/RulebookActivationForm.tsx index ab937055a6..b53324ad39 100644 --- a/frontend/eda/rulebook-activations/RulebookActivationForm.tsx +++ b/frontend/eda/rulebook-activations/RulebookActivationForm.tsx @@ -197,6 +197,10 @@ export function RulebookActivationInputs() { name: 'enable_persistence', }) as boolean; + const logLevel = useWatch({ + name: 'log_level', + }) as string; + useEffect(() => { setValue('source_mappings', jsyaml.dump(sourceMappings)); }, [setValue, sourceMappings]); @@ -222,6 +226,12 @@ export function RulebookActivationInputs() { } }, [enablePersistence, config?.managed_cloud_install, setValue]); + useEffect(() => { + if (logLevel !== 'debug') { + setValue('store_debug_logs', false); + } + }, [logLevel, setValue]); + return ( <> @@ -282,6 +292,26 @@ export function RulebookActivationInputs() { labelHelp={logLevelHelpBlock} labelHelpTitle={t('Log level')} /> + {logLevel === 'debug' && ( + <> + + + label={t`Store debug logs in database`} + labelHelpTitle={t('Store debug logs in database')} + labelHelp={t( + 'When enabled, DEBUG-level log lines are stored in the database in addition to container stdout. When disabled (default), DEBUG lines are only available in container logs, significantly reducing database storage.' + )} + name="store_debug_logs" + /> + + )} {config?.deployment_type === 'k8s' && ( name="k8s_service_name" From e57001a32c21d059c7b2c161e97cb9687b5f377e Mon Sep 17 00:00:00 2001 From: B-Whitt <34513926+B-Whitt@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:36:47 -0400 Subject: [PATCH 3/3] feat(eda): add Clear Logs buttons to History tab and Activations list Adds "Clear logs" button on the activation History tab (per-activation) and "Clear all logs" on the Activations list page (superuser only). Both show a confirmation dialog and display a toast with the deleted count. Resolves: AAP-84684 Assisted by: Claude Opus 4.6 --- .../RulebookActivationHistory.tsx | 59 ++++++++++++++++++- .../hooks/useRulebookActivationsActions.tsx | 46 ++++++++++++++- 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/frontend/eda/rulebook-activations/RulebookActivationPage/RulebookActivationHistory.tsx b/frontend/eda/rulebook-activations/RulebookActivationPage/RulebookActivationHistory.tsx index a17df3be30..720476b57f 100644 --- a/frontend/eda/rulebook-activations/RulebookActivationPage/RulebookActivationHistory.tsx +++ b/frontend/eda/rulebook-activations/RulebookActivationPage/RulebookActivationHistory.tsx @@ -1,5 +1,14 @@ -import { PageLayout, PageTable } from '@ansible/ansible-ui-framework'; -import { CubesIcon } from '@patternfly/react-icons'; +import { + IPageAction, + PageActionSelection, + PageActionType, + PageLayout, + PageTable, + usePageAlertToaster, +} from '@ansible/ansible-ui-framework'; +import { postRequest } from '@ansible/common-ui/crud/Data'; +import { CubesIcon, TimesCircleIcon } from '@patternfly/react-icons'; +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { edaAPI } from '../../common/eda-utils'; @@ -11,6 +20,7 @@ import { useActivationHistoryFilters } from '../hooks/useActivationHistoryFilter export function RulebookActivationHistory() { const params = useParams<{ id: string }>(); const { t } = useTranslation(); + const alertToaster = usePageAlertToaster(); const toolbarFilters = useActivationHistoryFilters(); @@ -20,10 +30,55 @@ export function RulebookActivationHistory() { toolbarFilters, tableColumns, }); + + const clearLogs = useCallback(async () => { + const activationId = params?.id; + if (!activationId) return; + if ( + !confirm( + t('Are you sure you want to clear all logs for this activation? This action is irreversible.') + ) + ) { + return; + } + try { + const result = await postRequest<{ deleted: number }>( + edaAPI`/activations/${activationId}/clear-logs/`, + {} + ); + alertToaster.addAlert({ + variant: 'success', + title: t('Cleared {{count}} log records.', { count: result.deleted }), + timeout: 5000, + }); + } catch { + alertToaster.addAlert({ + variant: 'danger', + title: t('Failed to clear logs'), + timeout: 5000, + }); + } + }, [params?.id, alertToaster, t]); + + const toolbarActions = useMemo[]>( + () => [ + { + type: PageActionType.Button, + selection: PageActionSelection.None, + icon: TimesCircleIcon, + label: t('Clear logs'), + onClick: () => clearLogs(), + isDanger: true, + }, + ], + [clearLogs, t] + ); + return ( ) { const { t } = useTranslation(); @@ -52,6 +59,29 @@ export function useRulebookActivationsActions(view: IEdaView { + if (!confirm(t('Are you sure you want to clear ALL activation logs? This action is irreversible.'))) { + return; + } + try { + const result = await postRequest<{ deleted: number }>(edaAPI`/logs/purge/`, {}); + alertToaster.addAlert({ + variant: 'success', + title: t('Cleared {{count}} log records.', { count: result.deleted }), + timeout: 5000, + }); + } catch (err) { + const errorResults = parseError(err as Error); + alertToaster.addAlert({ + variant: 'danger', + title: t('Failed to clear all logs'), + children: <>{errorResults.parsedErrors.map((errorResult) => errorResult.message)}, + timeout: 5000, + }); + } + }, [alertToaster, parseError, t]); const enableRulebookActivation: (activation: EdaRulebookActivation) => Promise = useCallback( async (activation) => { @@ -168,6 +198,18 @@ export function useRulebookActivationsActions(view: IEdaView clearAllLogs(), + isDanger: true, + }, + ] + : []), { type: PageActionType.Button, selection: PageActionSelection.Multiple, @@ -186,6 +228,8 @@ export function useRulebookActivationsActions(view: IEdaView