diff --git a/web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx b/web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx index e255b3b0954..9f7214d4ae4 100644 --- a/web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx +++ b/web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx @@ -1,4 +1,5 @@ -import { fireEvent, screen } from '@testing-library/react' +import { screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { createMockProviderContextValue } from '@/__mocks__/provider-context' import { defaultPlan } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' @@ -67,11 +68,16 @@ describe('ArchivedLogsNotice', () => { ) }) - it('should show notice for paid workspace managers', () => { + it('should show an accessible notice for paid workspace managers', async () => { + const user = userEvent.setup() renderNotice() - expect(screen.getByText('appLog.archives.notice.description')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'appLog.archives.notice.action' })) + const notice = screen.getByRole('status') + expect(notice).toHaveAttribute('aria-live', 'polite') + expect(notice).toHaveAttribute('aria-atomic', 'true') + expect(within(notice).getByText('appLog.archives.notice.description')).toBeInTheDocument() + + await user.click(within(notice).getByRole('button', { name: 'appLog.archives.notice.action' })) expect(setShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES, }) diff --git a/web/app/components/app/log/__tests__/filter.spec.tsx b/web/app/components/app/log/__tests__/filter.spec.tsx index ee580d2cc0a..45e9be675f7 100644 --- a/web/app/components/app/log/__tests__/filter.spec.tsx +++ b/web/app/components/app/log/__tests__/filter.spec.tsx @@ -1,9 +1,37 @@ import type { QueryParam } from '../index' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, within } from '@testing-library/react' import Filter, { TIME_PERIOD_MAPPING } from '../filter' let mockAnnotationsCountLoading = false let mockAnnotationsCountData: { count: number } | null = { count: 10 } +const mockRuntime = vi.hoisted(() => ({ + deploymentEdition: 'CLOUD', + enableBilling: true, + isFetchedPlan: true, + isFetchedPlanInfo: true, + planType: 'professional', +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSuspenseQuery: () => ({ data: mockRuntime.deploymentEdition }), + } +}) + +vi.mock('@/context/provider-context', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useProviderContext: () => ({ + enableBilling: mockRuntime.enableBilling, + isFetchedPlan: mockRuntime.isFetchedPlan, + isFetchedPlanInfo: mockRuntime.isFetchedPlanInfo, + plan: { type: mockRuntime.planType }, + }), + } +}) vi.mock('@/service/use-log', () => ({ useAnnotationsCount: () => ({ @@ -12,28 +40,43 @@ vi.mock('@/service/use-log', () => ({ }), })) -vi.mock('@/app/components/base/chip', () => ({ - default: ({ - items, - value, - onSelect, - onClear, - }: { - items: Array<{ value: string; name: string }> - value?: string - onSelect: (item: { value: string; name: string }) => void - onClear: () => void - }) => { - const currentItem = items.find((item) => item.value === value) ?? items[0] - return ( -
-
{currentItem?.name}
- - -
- ) - }, -})) +vi.mock('@/app/components/base/chip', async () => { + const { useState } = await import('react') + + return { + default: function MockChip({ + items, + value, + onSelect, + onClear, + }: { + items: Array<{ value: string; name: string }> + value?: string + onSelect: (item: { value: string; name: string }) => void + onClear: () => void + }) { + const [isOpen, setIsOpen] = useState(false) + const currentItem = items.find((item) => item.value === value) ?? items[0] + return ( +
+
{currentItem?.name}
+ + {isOpen && ( +
    + {items.map((item) => ( +
  • {item.name}
  • + ))} +
+ )} + + +
+ ) + }, + } +}) vi.mock('@/app/components/base/sort', () => ({ default: ({ onSelect }: { onSelect: (value: string) => void }) => ( @@ -59,6 +102,11 @@ describe('Filter', () => { vi.clearAllMocks() mockAnnotationsCountLoading = false mockAnnotationsCountData = { count: 10 } + mockRuntime.deploymentEdition = 'CLOUD' + mockRuntime.enableBilling = true + mockRuntime.isFetchedPlan = true + mockRuntime.isFetchedPlanInfo = true + mockRuntime.planType = 'professional' }) describe('Rendering', () => { @@ -124,6 +172,77 @@ describe('Filter', () => { }) describe('User Interactions', () => { + it('should only show supported periods for Cloud sandbox workspaces', () => { + mockRuntime.deploymentEdition = 'CLOUD' + mockRuntime.planType = 'sandbox' + + render() + + fireEvent.click(screen.getByRole('button', { name: 'open-options-1' })) + + const periodOptions = within(screen.getByRole('list', { name: 'options-1' })) + expect(periodOptions.getAllByRole('listitem').map((item) => item.textContent)).toEqual([ + expect.stringMatching(/(?:^|\.)filter\.period\.today(?=$|:)/), + expect.stringMatching(/(?:^|\.)filter\.period\.last7days(?=$|:)/), + expect.stringMatching(/(?:^|\.)filter\.period\.last30days(?=$|:)/), + ]) + }) + + it('should only show supported periods while the Cloud plan is pending', () => { + mockRuntime.isFetchedPlan = false + mockRuntime.isFetchedPlanInfo = false + + render() + + fireEvent.click(screen.getByRole('button', { name: 'open-options-1' })) + + const periodOptions = within(screen.getByRole('list', { name: 'options-1' })) + expect(periodOptions.getAllByRole('listitem').map((item) => item.textContent)).toEqual([ + expect.stringMatching(/(?:^|\.)filter\.period\.today(?=$|:)/), + expect.stringMatching(/(?:^|\.)filter\.period\.last7days(?=$|:)/), + expect.stringMatching(/(?:^|\.)filter\.period\.last30days(?=$|:)/), + ]) + }) + + it('should keep all periods when Cloud billing is known to be disabled', () => { + mockRuntime.enableBilling = false + mockRuntime.isFetchedPlan = false + mockRuntime.isFetchedPlanInfo = true + + render() + + fireEvent.click(screen.getByRole('button', { name: 'open-options-1' })) + + const periodOptions = within(screen.getByRole('list', { name: 'options-1' })) + expect(periodOptions.getAllByRole('listitem')).toHaveLength(9) + }) + + it('should keep all periods for sandbox workspaces outside Cloud', () => { + mockRuntime.deploymentEdition = 'COMMUNITY' + mockRuntime.planType = 'sandbox' + + render() + + fireEvent.click(screen.getByRole('button', { name: 'open-options-1' })) + + const periodOptions = within(screen.getByRole('list', { name: 'options-1' })) + expect(periodOptions.getAllByRole('listitem')).toHaveLength(9) + }) + + it('should reset the Cloud sandbox period to today when cleared', () => { + mockRuntime.deploymentEdition = 'CLOUD' + mockRuntime.planType = 'sandbox' + + render() + + fireEvent.click(screen.getAllByText('clear-chip')[0]!) + + expect(mockSetQueryParams).toHaveBeenCalledWith({ + ...defaultQueryParams, + period: '1', + }) + }) + it('should update keyword when typing in search input', () => { render() diff --git a/web/app/components/app/log/__tests__/index.spec.tsx b/web/app/components/app/log/__tests__/index.spec.tsx index 13614d652de..65330f27358 100644 --- a/web/app/components/app/log/__tests__/index.spec.tsx +++ b/web/app/components/app/log/__tests__/index.spec.tsx @@ -1,5 +1,8 @@ /* oxlint-disable typescript/no-explicit-any */ +import type { CloudSandboxPlanState } from '../cloud-sandbox-retention' import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import dayjs from 'dayjs' import { APP_PAGE_LIMIT } from '@/config' import { AppModeEnum } from '@/types/app' import Logs from '../index' @@ -11,11 +14,27 @@ vi.mock('@/context/i18n', () => ({ const mockReplace = vi.fn() const mockUseChatConversations = vi.fn() const mockUseCompletionConversations = vi.fn() +const mockPlanState = vi.hoisted(() => ({ + value: 'unrestricted' as CloudSandboxPlanState, +})) +const mockDebouncedPeriod = vi.hoisted(() => ({ + value: null as string | null, +})) let mockSearchParams = new URLSearchParams() vi.mock('ahooks', async () => { return { - useDebounce: (value: T) => value, + useDebounce: (value: T) => { + if ( + mockDebouncedPeriod.value === null || + typeof value !== 'object' || + value === null || + !('period' in value) + ) + return value + + return { ...value, period: mockDebouncedPeriod.value } + }, } }) @@ -33,28 +52,19 @@ vi.mock('@/next/navigation', () => ({ vi.mock('@/service/use-log', () => ({ useChatConversations: (...args: unknown[]) => mockUseChatConversations(...args), useCompletionConversations: (...args: unknown[]) => mockUseCompletionConversations(...args), + useAnnotationsCount: () => ({ + data: { count: 0 }, + isLoading: false, + }), })) -vi.mock('../filter', () => ({ - TIME_PERIOD_MAPPING: { - 2: { value: 7 }, - 9: { value: 0 }, - }, - default: ({ setQueryParams }: { setQueryParams: (next: Record) => void }) => ( - - ), -})) +vi.mock('../cloud-sandbox-retention', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCloudSandboxPlanStatus: () => mockPlanState.value, + } +}) vi.mock('../list', () => ({ default: ({ logs }: { logs: { total?: number } }) => ( @@ -69,6 +79,10 @@ vi.mock('../empty-element', () => ({ default: () =>
empty-logs
, })) +vi.mock('../retention-upgrade-notice', () => ({ + RetentionUpgradeNotice: () =>
retention-upgrade-notice
, +})) + vi.mock('@/app/components/base/loading', () => ({ default: () =>
loading-logs
, })) @@ -85,6 +99,8 @@ describe('Logs', () => { beforeEach(() => { vi.clearAllMocks() mockSearchParams = new URLSearchParams() + mockPlanState.value = 'unrestricted' + mockDebouncedPeriod.value = null mockUseChatConversations.mockReturnValue({ data: undefined, refetch: vi.fn(), @@ -117,6 +133,7 @@ describe('Logs', () => { expect( screen.getByRole('link', { name: /(?:^|\.)operation\.learnMore(?=$|:)/ }), ).toHaveAttribute('href', 'https://docs.example.com/use-dify/monitor/logs') + expect(screen.getByText('retention-upgrade-notice')).toBeInTheDocument() expect(screen.getByText('loading-logs')).toBeInTheDocument() }) @@ -166,4 +183,101 @@ describe('Logs', () => { expect(mockReplace).toHaveBeenCalledWith('/apps/app-1/logs?page=2', { scroll: false }) }) + + it('should query the last 30 days when a Sandbox user selects the longest period', async () => { + const user = userEvent.setup() + mockPlanState.value = 'sandbox' + mockUseChatConversations.mockReturnValue({ + data: { total: 0 }, + refetch: vi.fn(), + }) + + render( + , + ) + + await user.click(screen.getByRole('combobox', { name: /appLog\.filter\.period\.last7days/ })) + await user.click(await screen.findByText(/appLog\.filter\.period\.last30days/)) + + expect( + screen.getByRole('combobox', { name: /appLog\.filter\.period\.last30days/ }), + ).toBeInTheDocument() + expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + start: dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm'), + end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'), + }), + }), + ) + }) + + it('should use a valid period for the real Chip and request when a cached period settles to Sandbox', async () => { + const user = userEvent.setup() + const appDetail = { + id: 'app-period-transition', + mode: AppModeEnum.CHAT, + } as any + mockUseChatConversations.mockReturnValue({ + data: { total: 0 }, + refetch: vi.fn(), + }) + + const unrestrictedRender = render() + + await user.click(screen.getByRole('combobox', { name: /appLog\.filter\.period\.last7days/ })) + await user.click(await screen.findByText(/appLog\.filter\.period\.allTime/)) + expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ + params: expect.not.objectContaining({ + start: expect.anything(), + end: expect.anything(), + }), + }), + ) + unrestrictedRender.unmount() + + mockPlanState.value = 'pending' + mockDebouncedPeriod.value = '9' + const pendingRender = render() + + expect( + screen.getByRole('combobox', { name: /appLog\.filter\.period\.today/ }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: /common\.operation\.clear appLog\.filter\.period\.today/, + }), + ).toBeInTheDocument() + expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'), + end: expect.any(String), + }), + }), + ) + + mockPlanState.value = 'sandbox' + pendingRender.rerender() + + expect( + screen.getByRole('combobox', { name: /appLog\.filter\.period\.today/ }), + ).toBeInTheDocument() + expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'), + end: expect.any(String), + }), + }), + ) + }) }) diff --git a/web/app/components/app/log/__tests__/retention-upgrade-notice.spec.tsx b/web/app/components/app/log/__tests__/retention-upgrade-notice.spec.tsx new file mode 100644 index 00000000000..13e72de8a16 --- /dev/null +++ b/web/app/components/app/log/__tests__/retention-upgrade-notice.spec.tsx @@ -0,0 +1,117 @@ +import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen' +import { screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { createMockProviderContextValue } from '@/__mocks__/provider-context' +import { defaultPlan } from '@/app/components/billing/config' +import { Plan } from '@/app/components/billing/type' +import { useModalContext } from '@/context/modal-context' +import { useProviderContext } from '@/context/provider-context' +import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { render } from '@/test/console/render' +import { RetentionUpgradeNotice } from '../retention-upgrade-notice' + +vi.mock('@/context/provider-context', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useProviderContext: vi.fn(), + } +}) + +vi.mock('@/context/modal-context', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useModalContext: vi.fn(), + } +}) + +const mockUseProviderContext = vi.mocked(useProviderContext) +const mockUseModalContext = vi.mocked(useModalContext) + +describe('RetentionUpgradeNotice', () => { + const setShowPricingModal = vi.fn() + + function mockProvider({ + enableBilling = true, + isFetchedPlan = true, + isFetchedPlanInfo = true, + planType = Plan.sandbox, + }: { + enableBilling?: boolean + isFetchedPlan?: boolean + isFetchedPlanInfo?: boolean + planType?: Plan + } = {}) { + mockUseProviderContext.mockReturnValue( + createMockProviderContextValue({ + enableBilling, + isFetchedPlan, + isFetchedPlanInfo, + plan: { + ...defaultPlan, + type: planType, + }, + }), + ) + } + + function renderNotice(deploymentEdition: DeploymentEdition = 'CLOUD') { + const { wrapper } = createConsoleQueryWrapper({ + systemFeatures: { deployment_edition: deploymentEdition }, + }) + return render(, { wrapper }) + } + + beforeEach(() => { + vi.clearAllMocks() + mockProvider() + mockUseModalContext.mockReturnValue({ + setShowPricingModal, + } as unknown as ReturnType) + }) + + it('should show accessible upgrade guidance for Cloud sandbox workspaces', async () => { + const user = userEvent.setup() + renderNotice() + + const notice = screen.getByRole('status') + expect(notice).toHaveAttribute('aria-live', 'polite') + expect(notice).toHaveAttribute('aria-atomic', 'true') + expect(within(notice).getByText('appLog.retention.upgradeTip.description')).toBeInTheDocument() + + await user.click( + within(notice).getByRole('button', { name: 'billing.upgradeBtn.encourageShort' }), + ) + expect(setShowPricingModal).toHaveBeenCalledOnce() + }) + + it.each([ + { + name: 'paid Cloud workspaces', + provider: { planType: Plan.professional }, + deploymentEdition: 'CLOUD', + }, + { + name: 'self-hosted sandbox workspaces', + provider: { planType: Plan.sandbox }, + deploymentEdition: 'COMMUNITY', + }, + { + name: 'workspaces without billing', + provider: { enableBilling: false }, + deploymentEdition: 'CLOUD', + }, + { + name: 'workspaces before plan loading completes', + provider: { isFetchedPlan: false, isFetchedPlanInfo: false }, + deploymentEdition: 'CLOUD', + }, + ] as const)('should not show guidance for $name', ({ provider, deploymentEdition }) => { + mockProvider(provider) + + renderNotice(deploymentEdition) + + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/log/archived-logs-notice.tsx b/web/app/components/app/log/archived-logs-notice.tsx index fb372854397..ef3f59f1c72 100644 --- a/web/app/components/app/log/archived-logs-notice.tsx +++ b/web/app/components/app/log/archived-logs-notice.tsx @@ -1,5 +1,6 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' import { useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' @@ -31,16 +32,27 @@ export function ArchivedLogsNotice() { return null return ( -
- + ) diff --git a/web/app/components/app/log/cloud-sandbox-retention.ts b/web/app/components/app/log/cloud-sandbox-retention.ts new file mode 100644 index 00000000000..c7f7db14fc1 --- /dev/null +++ b/web/app/components/app/log/cloud-sandbox-retention.ts @@ -0,0 +1,54 @@ +'use client' + +import { useSuspenseQuery } from '@tanstack/react-query' +import { Plan } from '@/app/components/billing/type' +import { useProviderContext } from '@/context/provider-context' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' + +export const CLOUD_SANDBOX_TIME_PERIOD_KEYS = new Set(['1', '2', '3']) +export const CLOUD_SANDBOX_CLEARED_TIME_PERIOD = '1' + +const CLOUD_SANDBOX_LONGEST_TIME_PERIOD = '3' +const CLOUD_SANDBOX_LONGEST_TIME_PERIOD_OPTION = { + value: 30, + name: 'last30days', +} as const + +export type CloudSandboxPlanState = 'pending' | 'sandbox' | 'unrestricted' + +export function isLogTimePeriodRestricted(planState: CloudSandboxPlanState) { + return planState !== 'unrestricted' +} + +export function resolveLogTimePeriod(period: string, planState: CloudSandboxPlanState) { + if (!isLogTimePeriodRestricted(planState) || CLOUD_SANDBOX_TIME_PERIOD_KEYS.has(period)) + return period + + return CLOUD_SANDBOX_CLEARED_TIME_PERIOD +} + +export function resolveLogTimePeriodOption( + period: string, + option: T, + planState: CloudSandboxPlanState, +) { + if (isLogTimePeriodRestricted(planState) && period === CLOUD_SANDBOX_LONGEST_TIME_PERIOD) + return CLOUD_SANDBOX_LONGEST_TIME_PERIOD_OPTION + + return option +} + +export function useCloudSandboxPlanStatus(): CloudSandboxPlanState { + const { data: deploymentEdition } = useSuspenseQuery({ + ...systemFeaturesQueryOptions(), + select: ({ deployment_edition }) => deployment_edition, + }) + const { enableBilling, isFetchedPlan, isFetchedPlanInfo, plan } = useProviderContext() + + if (deploymentEdition !== 'CLOUD') return 'unrestricted' + if (!isFetchedPlanInfo) return 'pending' + if (!enableBilling) return 'unrestricted' + if (!isFetchedPlan) return 'pending' + + return plan.type === Plan.sandbox ? 'sandbox' : 'unrestricted' +} diff --git a/web/app/components/app/log/filter.tsx b/web/app/components/app/log/filter.tsx index 27c22ed13bf..b8e6781d51c 100644 --- a/web/app/components/app/log/filter.tsx +++ b/web/app/components/app/log/filter.tsx @@ -11,6 +11,13 @@ import Chip from '@/app/components/base/chip' import Input from '@/app/components/base/input' import Sort from '@/app/components/base/sort' import { useAnnotationsCount } from '@/service/use-log' +import { + CLOUD_SANDBOX_CLEARED_TIME_PERIOD, + CLOUD_SANDBOX_TIME_PERIOD_KEYS, + isLogTimePeriodRestricted, + resolveLogTimePeriodOption, + useCloudSandboxPlanStatus, +} from './cloud-sandbox-retention' dayjs.extend(quarterOfYear) @@ -45,6 +52,12 @@ const Filter: FC = ({ }: IFilterProps) => { const { data, isLoading } = useAnnotationsCount(appId) const { t } = useTranslation() + const planState = useCloudSandboxPlanStatus() + const isTimePeriodRestricted = isLogTimePeriodRestricted(planState) + const timePeriodEntries = Object.entries(TIME_PERIOD_MAPPING) + .filter(([key]) => !isTimePeriodRestricted || CLOUD_SANDBOX_TIME_PERIOD_KEYS.has(key)) + .map(([key, option]) => [key, resolveLogTimePeriodOption(key, option, planState)] as const) + if (isLoading || !data) return null return (
@@ -56,8 +69,13 @@ const Filter: FC = ({ onSelect={(item) => { setQueryParams({ ...queryParams, period: item.value }) }} - onClear={() => setQueryParams({ ...queryParams, period: '9' })} - items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ + onClear={() => + setQueryParams({ + ...queryParams, + period: isTimePeriodRestricted ? CLOUD_SANDBOX_CLEARED_TIME_PERIOD : '9', + }) + } + items={timePeriodEntries.map(([k, v]) => ({ value: k, name: t(($) => $[`filter.period.${v.name}`], { ns: 'appLog' }), }))} diff --git a/web/app/components/app/log/index.tsx b/web/app/components/app/log/index.tsx index 28b56078678..0c91182754d 100644 --- a/web/app/components/app/log/index.tsx +++ b/web/app/components/app/log/index.tsx @@ -15,9 +15,15 @@ import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { useChatConversations, useCompletionConversations } from '@/service/use-log' import { AppModeEnum } from '@/types/app' import PageTitle from '../log-annotation/page-title' +import { + resolveLogTimePeriod, + resolveLogTimePeriodOption, + useCloudSandboxPlanStatus, +} from './cloud-sandbox-retention' import EmptyElement from './empty-element' import Filter, { TIME_PERIOD_MAPPING } from './filter' import List from './list' +import { RetentionUpgradeNotice } from './retention-upgrade-notice' type ILogsProps = { appDetail: App @@ -57,6 +63,7 @@ const Logs: FC = ({ appDetail }) => { return pageParam - 1 }, [searchParams]) const cachedState = logsStateCache.get(appDetail.id) + const cloudSandboxPlanState = useCloudSandboxPlanStatus() const [queryParams, setQueryParams] = useState( cachedState?.queryParams ?? defaultQueryParams, ) @@ -64,7 +71,15 @@ const Logs: FC = ({ appDetail }) => { () => cachedState?.currPage ?? getPageFromParams(), ) const [limit, setLimit] = React.useState(cachedState?.limit ?? APP_PAGE_LIMIT) + const effectivePeriod = resolveLogTimePeriod(queryParams.period, cloudSandboxPlanState) + const effectiveQueryParams = { ...queryParams, period: effectivePeriod } const debouncedQueryParams = useDebounce(queryParams, { wait: 500 }) + const requestQueryParams = { ...debouncedQueryParams, period: effectivePeriod } + const requestTimePeriod = resolveLogTimePeriodOption( + requestQueryParams.period, + TIME_PERIOD_MAPPING[requestQueryParams.period]!, + cloudSandboxPlanState, + ) useEffect(() => { const pageFromParams = getPageFromParams() @@ -85,17 +100,17 @@ const Logs: FC = ({ appDetail }) => { const query = { page: currPage + 1, limit, - ...(debouncedQueryParams.period !== '9' + ...(requestQueryParams.period !== '9' ? { start: dayjs() - .subtract(TIME_PERIOD_MAPPING[debouncedQueryParams.period]!.value, 'day') + .subtract(requestTimePeriod.value, 'day') .startOf('day') .format('YYYY-MM-DD HH:mm'), end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'), } : {}), - ...(isChatMode ? { sort_by: debouncedQueryParams.sort_by } : {}), - ...omit(debouncedQueryParams, ['period']), + ...(isChatMode ? { sort_by: requestQueryParams.sort_by } : {}), + ...omit(requestQueryParams, ['period']), } // When the details are obtained, proceed to the next request @@ -143,9 +158,10 @@ const Logs: FC = ({ appDetail }) => { + {total === undefined ? ( ) : total > 0 ? ( diff --git a/web/app/components/app/log/retention-upgrade-notice.tsx b/web/app/components/app/log/retention-upgrade-notice.tsx new file mode 100644 index 00000000000..c738465c0f4 --- /dev/null +++ b/web/app/components/app/log/retention-upgrade-notice.tsx @@ -0,0 +1,43 @@ +'use client' + +import { useTranslation } from 'react-i18next' +import UpgradeBtn from '@/app/components/billing/upgrade-btn' +import { useCloudSandboxPlanStatus } from './cloud-sandbox-retention' + +export function RetentionUpgradeNotice() { + const { t } = useTranslation() + const planState = useCloudSandboxPlanStatus() + + if (planState !== 'sandbox') return null + + return ( +
+ + ) +} diff --git a/web/app/components/app/workflow-log/__tests__/filter.spec.tsx b/web/app/components/app/workflow-log/__tests__/filter.spec.tsx index 547354903f2..476c5dbce98 100644 --- a/web/app/components/app/workflow-log/__tests__/filter.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/filter.spec.tsx @@ -17,6 +17,35 @@ import Filter, { TIME_PERIOD_MAPPING } from '../filter' // Mocks // ============================================================================ +const mockRuntime = vi.hoisted(() => ({ + deploymentEdition: 'CLOUD', + enableBilling: true, + isFetchedPlan: true, + isFetchedPlanInfo: true, + planType: 'professional', +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSuspenseQuery: () => ({ data: mockRuntime.deploymentEdition }), + } +}) + +vi.mock('@/context/provider-context', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useProviderContext: () => ({ + enableBilling: mockRuntime.enableBilling, + isFetchedPlan: mockRuntime.isFetchedPlan, + isFetchedPlanInfo: mockRuntime.isFetchedPlanInfo, + plan: { type: mockRuntime.planType }, + }), + } +}) + const mockTrackEvent = vi.fn() vi.mock('@/app/components/base/amplitude/utils', () => ({ trackEvent: (...args: unknown[]) => mockTrackEvent(...args), @@ -41,6 +70,11 @@ describe('Filter', () => { beforeEach(() => { vi.clearAllMocks() + mockRuntime.deploymentEdition = 'CLOUD' + mockRuntime.enableBilling = true + mockRuntime.isFetchedPlan = true + mockRuntime.isFetchedPlanInfo = true + mockRuntime.planType = 'professional' }) // -------------------------------------------------------------------------- @@ -176,6 +210,69 @@ describe('Filter', () => { // Time Period Filter Tests // -------------------------------------------------------------------------- describe('Time Period Filter', () => { + it('should only show supported periods for Cloud sandbox workspaces', async () => { + const user = userEvent.setup() + mockRuntime.deploymentEdition = 'CLOUD' + mockRuntime.planType = 'sandbox' + + render( + , + ) + + await user.click(screen.getByRole('combobox', { name: 'appLog.filter.period.last7days' })) + + const listbox = await screen.findByRole('listbox') + expect( + within(listbox) + .getAllByRole('option') + .map((option) => option.textContent), + ).toEqual([ + 'appLog.filter.period.today', + 'appLog.filter.period.last7days', + 'appLog.filter.period.last30days', + ]) + }) + + it('should keep all periods for sandbox workspaces outside Cloud', async () => { + const user = userEvent.setup() + mockRuntime.deploymentEdition = 'COMMUNITY' + mockRuntime.planType = 'sandbox' + + render( + , + ) + + await user.click(screen.getByRole('combobox', { name: 'appLog.filter.period.last7days' })) + + const listbox = await screen.findByRole('listbox') + expect(within(listbox).getAllByRole('option')).toHaveLength(9) + }) + + it('should reset the Cloud sandbox period to today when cleared', async () => { + const user = userEvent.setup() + const setQueryParams = vi.fn() + mockRuntime.deploymentEdition = 'CLOUD' + mockRuntime.planType = 'sandbox' + + render( + , + ) + + await user.click( + screen.getByRole('button', { + name: /common\.operation\.clear appLog\.filter\.period\.last30days/, + }), + ) + + expect(setQueryParams).toHaveBeenCalledWith({ + status: 'all', + period: '1', + }) + }) + it('should display current period value', () => { render( ({ + value: 'unrestricted' as CloudSandboxPlanState, +})) +const mockDebouncedPeriod = vi.hoisted(() => ({ + value: null as string | null, +})) + vi.mock('@/service/use-log') +vi.mock('../../log/cloud-sandbox-retention', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCloudSandboxPlanStatus: () => mockPlanState.value, + } +}) + vi.mock('ahooks', () => ({ - useDebounce: (value: T) => value, + useDebounce: (value: T) => { + if ( + mockDebouncedPeriod.value === null || + typeof value !== 'object' || + value === null || + !('period' in value) + ) + return value + + return { ...value, period: mockDebouncedPeriod.value } + }, useDebounceFn: (fn: (value: string) => void) => ({ run: fn }), useBoolean: (initial: boolean) => { const setters = { @@ -58,6 +85,10 @@ vi.mock('@/next/link', () => ({ ), })) +vi.mock('../../log/retention-upgrade-notice', () => ({ + RetentionUpgradeNotice: () =>
retention-upgrade-notice
, +})) + // Mock the Run component to avoid complex dependencies vi.mock('@/app/components/workflow/run', () => ({ default: ({ runDetailUrl, tracingListUrl }: { runDetailUrl: string; tracingListUrl: string }) => ( @@ -237,6 +268,8 @@ describe('Logs Container', () => { beforeEach(() => { vi.clearAllMocks() + mockPlanState.value = 'unrestricted' + mockDebouncedPeriod.value = null }) // -------------------------------------------------------------------------- @@ -272,6 +305,7 @@ describe('Logs Container', () => { // Assert expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument() + expect(screen.getByText('retention-upgrade-notice')).toBeInTheDocument() }) }) @@ -444,6 +478,76 @@ describe('Logs Container', () => { }) }) + it('should query the last 30 days when a Sandbox user selects the longest period', async () => { + const user = userEvent.setup() + mockPlanState.value = 'sandbox' + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([], 0), + }), + ) + + renderWithQueryClient() + + await user.click(screen.getByText('appLog.filter.period.last7days')) + await user.click(await screen.findByText('appLog.filter.period.last30days')) + + expect( + screen.getByRole('combobox', { name: 'appLog.filter.period.last30days' }), + ).toBeInTheDocument() + const params = getMockCallParams()?.params + expect( + dayjs(String(params?.created_at__before)).diff(String(params?.created_at__after), 'day'), + ).toBe(30) + }) + + it('should use a valid period for the real Chip and request when plan state settles to Sandbox', async () => { + const user = userEvent.setup() + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([], 0), + }), + ) + const rendered = renderWithQueryClient() + + await user.click(screen.getByText('appLog.filter.period.last7days')) + await user.click(await screen.findByText('appLog.filter.period.allTime')) + expect(getMockCallParams()?.params).not.toHaveProperty('created_at__after') + expect(getMockCallParams()?.params).not.toHaveProperty('created_at__before') + + mockPlanState.value = 'pending' + mockDebouncedPeriod.value = '9' + rendered.rerender() + + expect( + screen.getByRole('combobox', { name: 'appLog.filter.period.today' }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: /common\.operation\.clear appLog\.filter\.period\.today/, + }), + ).toBeInTheDocument() + expect(getMockCallParams()?.params).toEqual( + expect.objectContaining({ + created_at__after: expect.any(String), + created_at__before: expect.any(String), + }), + ) + + mockPlanState.value = 'sandbox' + rendered.rerender() + + expect( + screen.getByRole('combobox', { name: 'appLog.filter.period.today' }), + ).toBeInTheDocument() + expect(getMockCallParams()?.params).toEqual( + expect.objectContaining({ + created_at__after: expect.any(String), + created_at__before: expect.any(String), + }), + ) + }) + it('should update query when typing keyword', async () => { // Arrange const user = userEvent.setup() diff --git a/web/app/components/app/workflow-log/filter.tsx b/web/app/components/app/workflow-log/filter.tsx index 1c1b555377e..2e09ce9c48a 100644 --- a/web/app/components/app/workflow-log/filter.tsx +++ b/web/app/components/app/workflow-log/filter.tsx @@ -10,6 +10,13 @@ import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude/utils' import Chip from '@/app/components/base/chip' import Input from '@/app/components/base/input' +import { + CLOUD_SANDBOX_CLEARED_TIME_PERIOD, + CLOUD_SANDBOX_TIME_PERIOD_KEYS, + isLogTimePeriodRestricted, + resolveLogTimePeriodOption, + useCloudSandboxPlanStatus, +} from '../log/cloud-sandbox-retention' dayjs.extend(quarterOfYear) @@ -36,6 +43,12 @@ type IFilterProps = { const Filter: FC = ({ queryParams, setQueryParams }: IFilterProps) => { const { t } = useTranslation() + const planState = useCloudSandboxPlanStatus() + const isTimePeriodRestricted = isLogTimePeriodRestricted(planState) + const timePeriodEntries = Object.entries(TIME_PERIOD_MAPPING) + .filter(([key]) => !isTimePeriodRestricted || CLOUD_SANDBOX_TIME_PERIOD_KEYS.has(key)) + .map(([key, option]) => [key, resolveLogTimePeriodOption(key, option, planState)] as const) + return (
= ({ queryParams, setQueryParams }: IFilterProps) onSelect={(item) => { setQueryParams({ ...queryParams, period: item.value }) }} - onClear={() => setQueryParams({ ...queryParams, period: '9' })} - items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ + onClear={() => + setQueryParams({ + ...queryParams, + period: isTimePeriodRestricted ? CLOUD_SANDBOX_CLEARED_TIME_PERIOD : '9', + }) + } + items={timePeriodEntries.map(([k, v]) => ({ value: k, name: t(($) => $[`filter.period.${v.name}`], { ns: 'appLog' }), }))} diff --git a/web/app/components/app/workflow-log/index.tsx b/web/app/components/app/workflow-log/index.tsx index 24eb42eed45..7ec0bfb46dc 100644 --- a/web/app/components/app/workflow-log/index.tsx +++ b/web/app/components/app/workflow-log/index.tsx @@ -19,6 +19,12 @@ import { useWorkflowLogs } from '@/service/use-log' import PageTitle from '../log-annotation/page-title' import { ArchivedLogsNotice } from '../log/archived-logs-notice' import { shouldShowArchivedLogsNotice } from '../log/archived-logs-notice-utils' +import { + resolveLogTimePeriod, + resolveLogTimePeriodOption, + useCloudSandboxPlanStatus, +} from '../log/cloud-sandbox-retention' +import { RetentionUpgradeNotice } from '../log/retention-upgrade-notice' import Filter, { TIME_PERIOD_MAPPING } from './filter' import List from './list' @@ -43,26 +49,35 @@ const Logs: FC = ({ appDetail }) => { }) const [queryParams, setQueryParams] = useState({ status: 'all', period: '2' }) const [currPage, setCurrPage] = React.useState(0) + const cloudSandboxPlanState = useCloudSandboxPlanStatus() + const effectivePeriod = resolveLogTimePeriod(queryParams.period, cloudSandboxPlanState) + const effectiveQueryParams = { ...queryParams, period: effectivePeriod } const debouncedQueryParams = useDebounce(queryParams, { wait: 500 }) + const requestQueryParams = { ...debouncedQueryParams, period: effectivePeriod } + const requestTimePeriod = resolveLogTimePeriodOption( + requestQueryParams.period, + TIME_PERIOD_MAPPING[requestQueryParams.period]!, + cloudSandboxPlanState, + ) const [limit, setLimit] = React.useState(APP_PAGE_LIMIT) const query = { page: currPage + 1, detail: true, limit, - ...(debouncedQueryParams.status !== 'all' ? { status: debouncedQueryParams.status } : {}), - ...(debouncedQueryParams.keyword ? { keyword: debouncedQueryParams.keyword } : {}), - ...(debouncedQueryParams.period !== '9' + ...(requestQueryParams.status !== 'all' ? { status: requestQueryParams.status } : {}), + ...(requestQueryParams.keyword ? { keyword: requestQueryParams.keyword } : {}), + ...(requestQueryParams.period !== '9' ? { created_at__after: dayjs() - .subtract(TIME_PERIOD_MAPPING[debouncedQueryParams.period]!.value, 'day') + .subtract(requestTimePeriod.value, 'day') .startOf('day') .tz(timezone) .format('YYYY-MM-DDTHH:mm:ssZ'), created_at__before: dayjs().endOf('day').tz(timezone).format('YYYY-MM-DDTHH:mm:ssZ'), } : {}), - ...omit(debouncedQueryParams, ['period', 'status']), + ...omit(requestQueryParams, ['period', 'status']), } const { data: workflowLogs, refetch: mutate } = useWorkflowLogs({ @@ -72,7 +87,7 @@ const Logs: FC = ({ appDetail }) => { const total = workflowLogs?.total const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1 const showArchivedLogsNotice = shouldShowArchivedLogsNotice( - queryParams.period, + effectiveQueryParams.period, TIME_PERIOD_MAPPING, ) @@ -83,7 +98,8 @@ const Logs: FC = ({ appDetail }) => { description={t(($) => $.workflowSubtitle, { ns: 'appLog' })} />
- + + {showArchivedLogsNotice && } {/* workflow log */} {total === undefined ? ( diff --git a/web/i18n/ar-TN/app-log.json b/web/i18n/ar-TN/app-log.json index 4eb315694bf..d8cf13af28e 100644 --- a/web/i18n/ar-TN/app-log.json +++ b/web/i18n/ar-TN/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "السنة حتى الآن", "filter.sortBy": "رتب حسب:", "monitoring.description": "يسجل الرصد حالة تشغيل التطبيق، بما في ذلك الأداء ونشاط المستخدمين والتكاليف.", + "retention.upgradeTip.description": "قم بالترقية للاحتفاظ بجميع السجلات التي يتم إنشاؤها بعد الترقية دون حد زمني؛ لا يمكن استعادة السجلات التي انتهت مدة الاحتفاظ بها قبل الترقية.", "runDetail.fileListDetail": "تفاصيل", "runDetail.fileListLabel": "تفاصيل الملف", "runDetail.testWithParams": "اختبار مع المعلمات", diff --git a/web/i18n/de-DE/app-log.json b/web/i18n/de-DE/app-log.json index d85a9d89ccd..8c2e5adbdc2 100644 --- a/web/i18n/de-DE/app-log.json +++ b/web/i18n/de-DE/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Jahr bis heute", "filter.sortBy": "Sortieren nach:", "monitoring.description": "Das Monitoring zeichnet den Betriebsstatus der Anwendung auf, einschließlich Leistung, Nutzeraktivität und Kosten.", + "retention.upgradeTip.description": "Führen Sie ein Upgrade Ihres Plans durch, um alle danach erstellten Protokolle unbegrenzt aufzubewahren. Protokolle, deren Aufbewahrungsfrist vor dem Upgrade abgelaufen ist, können nicht wiederhergestellt werden.", "runDetail.fileListDetail": "Detail", "runDetail.fileListLabel": "Details zur Datei", "runDetail.testWithParams": "Test mit Parametern", diff --git a/web/i18n/en-US/app-log.json b/web/i18n/en-US/app-log.json index 9bc35200e3c..13a246805af 100644 --- a/web/i18n/en-US/app-log.json +++ b/web/i18n/en-US/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "No archived logs", "archives.error.description": "Refresh the page or try again later.", "archives.error.title": "Could not load archived logs", - "archives.notice.action": "View archived logs", + "archives.notice.action": "Open archived logs", "archives.notice.description": "Some logs in this time range may have been archived.", "archives.summary.latest": "Latest archive", "archives.summary.months": "Archived months", @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Year to date", "filter.sortBy": "Sort by:", "monitoring.description": "Monitoring records the running status of the application, including performance, user activity, and costs.", + "retention.upgradeTip.description": "Upgrade to retain all logs generated after upgrading with no time limit; previously expired logs can’t be recovered.", "runDetail.fileListDetail": "Detail", "runDetail.fileListLabel": "File Details", "runDetail.testWithParams": "Test With Params", diff --git a/web/i18n/es-ES/app-log.json b/web/i18n/es-ES/app-log.json index 195382d5f8d..1a6abe0e6b4 100644 --- a/web/i18n/es-ES/app-log.json +++ b/web/i18n/es-ES/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Año hasta la fecha", "filter.sortBy": "Ordenar por:", "monitoring.description": "La monitorización registra el estado de ejecución de la aplicación, incluyendo rendimiento, actividad de los usuarios y costes.", + "retention.upgradeTip.description": "Mejora tu plan para conservar indefinidamente todos los registros que se generen después de la mejora. Los registros que hayan caducado antes de la mejora no se pueden recuperar.", "runDetail.fileListDetail": "Detalle", "runDetail.fileListLabel": "Detalles del archivo", "runDetail.testWithParams": "Prueba con parámetros", diff --git a/web/i18n/fa-IR/app-log.json b/web/i18n/fa-IR/app-log.json index fb93f738729..1e7a8db1b22 100644 --- a/web/i18n/fa-IR/app-log.json +++ b/web/i18n/fa-IR/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "از ابتدای سال تاکنون", "filter.sortBy": "مرتب‌سازی بر اساس:", "monitoring.description": "مانیتورینگ وضعیت اجرای برنامه را ثبت می‌کند، از جمله عملکرد، فعالیت کاربران و هزینه‌ها.", + "retention.upgradeTip.description": "طرح خود را ارتقا دهید تا همه لاگ‌های ایجادشده پس از ارتقا بدون محدودیت زمانی نگهداری شوند؛ لاگ‌هایی که پیش از ارتقا منقضی شده‌اند قابل بازیابی نیستند.", "runDetail.fileListDetail": "جزئیات", "runDetail.fileListLabel": "جزئیات فایل", "runDetail.testWithParams": "تست با پارامترها", diff --git a/web/i18n/fr-FR/app-log.json b/web/i18n/fr-FR/app-log.json index 5ef8cb5ff3e..51bc30fd933 100644 --- a/web/i18n/fr-FR/app-log.json +++ b/web/i18n/fr-FR/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Année à ce jour", "filter.sortBy": "Trier par :", "monitoring.description": "Le monitoring enregistre l’état de fonctionnement de l’application, notamment les performances, l’activité des utilisateurs et les coûts.", + "retention.upgradeTip.description": "Passez à une offre supérieure pour conserver sans limite de durée tous les journaux générés après la mise à niveau. Les journaux dont la durée de conservation a expiré avant la mise à niveau ne peuvent pas être récupérés.", "runDetail.fileListDetail": "Détail", "runDetail.fileListLabel": "Détails du fichier", "runDetail.testWithParams": "Test avec paramètres", diff --git a/web/i18n/hi-IN/app-log.json b/web/i18n/hi-IN/app-log.json index a78bc82a474..47adae71c94 100644 --- a/web/i18n/hi-IN/app-log.json +++ b/web/i18n/hi-IN/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "वर्ष तक तिथि", "filter.sortBy": "इसके अनुसार क्रमबद्ध करें:", "monitoring.description": "मॉनिटरिंग एप्लिकेशन की रनिंग स्थिति रिकॉर्ड करती है, जिसमें प्रदर्शन, उपयोगकर्ता गतिविधि और लागतें शामिल हैं।", + "retention.upgradeTip.description": "अपग्रेड करें और उसके बाद जनरेट किए गए सभी लॉग बिना किसी समय सीमा के सुरक्षित रखें; अपग्रेड से पहले समाप्त हो चुके लॉग पुनर्प्राप्त नहीं किए जा सकते।", "runDetail.fileListDetail": "विस्तार", "runDetail.fileListLabel": "फ़ाइल विवरण", "runDetail.testWithParams": "पैरामीटर्स के साथ परीक्षण", diff --git a/web/i18n/id-ID/app-log.json b/web/i18n/id-ID/app-log.json index 6e784ddc5e1..c0aff59edca 100644 --- a/web/i18n/id-ID/app-log.json +++ b/web/i18n/id-ID/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Tahun hingga saat ini", "filter.sortBy": "Urutkan berdasarkan", "monitoring.description": "Monitoring mencatat status berjalan aplikasi, termasuk performa, aktivitas pengguna, dan biaya.", + "retention.upgradeTip.description": "Tingkatkan paket untuk menyimpan semua log yang dibuat setelah upgrade tanpa batas waktu; log yang kedaluwarsa sebelum upgrade tidak dapat dipulihkan.", "runDetail.fileListDetail": "Detail", "runDetail.fileListLabel": "Rincian File", "runDetail.testWithParams": "Uji Dengan Param", diff --git a/web/i18n/it-IT/app-log.json b/web/i18n/it-IT/app-log.json index f735e2efc6a..d2e7b497a05 100644 --- a/web/i18n/it-IT/app-log.json +++ b/web/i18n/it-IT/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Anno corrente", "filter.sortBy": "Ordina per:", "monitoring.description": "Il monitoraggio registra lo stato di esecuzione dell’applicazione, inclusi prestazioni, attività degli utenti e costi.", + "retention.upgradeTip.description": "Passa a un piano superiore per conservare senza limiti di tempo tutti i log generati dopo l'upgrade. I log scaduti prima dell'upgrade non possono essere recuperati.", "runDetail.fileListDetail": "Dettaglio", "runDetail.fileListLabel": "Dettagli del file", "runDetail.testWithParams": "Test con parametri", diff --git a/web/i18n/ja-JP/app-log.json b/web/i18n/ja-JP/app-log.json index f692843ef4b..25b49eeea13 100644 --- a/web/i18n/ja-JP/app-log.json +++ b/web/i18n/ja-JP/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "年初から今日まで", "filter.sortBy": "並べ替え", "monitoring.description": "モニタリングは、パフォーマンス、ユーザー活動、コストを含むアプリケーションの実行状況を記録します。", + "retention.upgradeTip.description": "アップグレード後に生成されたログを無期限に保存できます。すでに期限切れとなったログは復元できません。", "runDetail.fileListDetail": "詳細", "runDetail.fileListLabel": "ファイルの詳細", "runDetail.testWithParams": "パラメータ付きテスト", diff --git a/web/i18n/ko-KR/app-log.json b/web/i18n/ko-KR/app-log.json index 49c01088249..9a3f8241fcb 100644 --- a/web/i18n/ko-KR/app-log.json +++ b/web/i18n/ko-KR/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "연 초부터 오늘까지", "filter.sortBy": "정렬 기준:", "monitoring.description": "모니터링은 성능, 사용자 활동, 비용을 포함한 애플리케이션 실행 상태를 기록합니다.", + "retention.upgradeTip.description": "업그레이드하면 업그레이드 후 생성된 모든 로그를 기간 제한 없이 보관할 수 있습니다. 업그레이드 전에 만료된 로그는 복구할 수 없습니다.", "runDetail.fileListDetail": "세부", "runDetail.fileListLabel": "파일 세부 정보", "runDetail.testWithParams": "매개변수로 테스트", diff --git a/web/i18n/nl-NL/app-log.json b/web/i18n/nl-NL/app-log.json index 79fd680f33b..a63f93b7491 100644 --- a/web/i18n/nl-NL/app-log.json +++ b/web/i18n/nl-NL/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Year to date", "filter.sortBy": "Sort by:", "monitoring.description": "Monitoring registreert de actieve status van de applicatie, inclusief prestaties, gebruikersactiviteit en kosten.", + "retention.upgradeTip.description": "Upgrade uw abonnement om alle logs die daarna worden gegenereerd onbeperkt te bewaren. Logs waarvan de bewaartermijn vóór de upgrade was verstreken, kunnen niet worden hersteld.", "runDetail.fileListDetail": "Detail", "runDetail.fileListLabel": "File Details", "runDetail.testWithParams": "Test With Params", diff --git a/web/i18n/pl-PL/app-log.json b/web/i18n/pl-PL/app-log.json index 53f77bd828f..8ec7c60945c 100644 --- a/web/i18n/pl-PL/app-log.json +++ b/web/i18n/pl-PL/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Od początku roku", "filter.sortBy": "Sortuj według:", "monitoring.description": "Monitoring rejestruje stan działania aplikacji, w tym wydajność, aktywność użytkowników i koszty.", + "retention.upgradeTip.description": "Przejdź na wyższy plan, aby bezterminowo przechowywać wszystkie logi wygenerowane po zmianie planu. Logów, których okres przechowywania upłynął przed zmianą planu, nie można odzyskać.", "runDetail.fileListDetail": "Detal", "runDetail.fileListLabel": "Szczegóły pliku", "runDetail.testWithParams": "Test z parametrami", diff --git a/web/i18n/pt-BR/app-log.json b/web/i18n/pt-BR/app-log.json index 0d980632c24..41875ffa780 100644 --- a/web/i18n/pt-BR/app-log.json +++ b/web/i18n/pt-BR/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Ano até hoje", "filter.sortBy": "Ordenar por:", "monitoring.description": "O monitoramento registra o status de execução do aplicativo, incluindo desempenho, atividade dos usuários e custos.", + "retention.upgradeTip.description": "Faça upgrade do seu plano para armazenar por tempo indeterminado todos os logs gerados após o upgrade. Logs cujo período de retenção expirou antes do upgrade não podem ser recuperados.", "runDetail.fileListDetail": "Detalhe", "runDetail.fileListLabel": "Detalhes do arquivo", "runDetail.testWithParams": "Teste com parâmetros", diff --git a/web/i18n/ro-RO/app-log.json b/web/i18n/ro-RO/app-log.json index a656cbb2598..300d3ac5692 100644 --- a/web/i18n/ro-RO/app-log.json +++ b/web/i18n/ro-RO/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Anul curent", "filter.sortBy": "Sortează după:", "monitoring.description": "Monitorizarea înregistrează starea de funcționare a aplicației, inclusiv performanța, activitatea utilizatorilor și costurile.", + "retention.upgradeTip.description": "Treci la un plan superior pentru a păstra pe termen nelimitat toate jurnalele generate după upgrade. Jurnalele al căror termen de păstrare a expirat înainte de upgrade nu pot fi recuperate.", "runDetail.fileListDetail": "Amănunt", "runDetail.fileListLabel": "Detalii fișier", "runDetail.testWithParams": "Test cu parametri", diff --git a/web/i18n/ru-RU/app-log.json b/web/i18n/ru-RU/app-log.json index 1437e895a99..65a13026eed 100644 --- a/web/i18n/ru-RU/app-log.json +++ b/web/i18n/ru-RU/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "С начала года", "filter.sortBy": "Сортировать по:", "monitoring.description": "Мониторинг записывает состояние работы приложения, включая производительность, активность пользователей и затраты.", + "retention.upgradeTip.description": "Перейдите на более высокий тариф, чтобы бессрочно хранить все логи, созданные после обновления тарифа. Логи, срок хранения которых истёк до обновления тарифа, восстановить нельзя.", "runDetail.fileListDetail": "Подробность", "runDetail.fileListLabel": "Сведения о файле", "runDetail.testWithParams": "Тест с параметрами", diff --git a/web/i18n/sl-SI/app-log.json b/web/i18n/sl-SI/app-log.json index f48767f2e7a..655954dd58f 100644 --- a/web/i18n/sl-SI/app-log.json +++ b/web/i18n/sl-SI/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Leto do danes", "filter.sortBy": "Razvrsti po:", "monitoring.description": "Spremljanje beleži stanje delovanja aplikacije, vključno z zmogljivostjo, dejavnostjo uporabnikov in stroški.", + "retention.upgradeTip.description": "Nadgradite paket, da se bodo vsi dnevniki, ustvarjeni po nadgradnji, hranili brez časovne omejitve. Dnevnikov, ki jim je pred nadgradnjo potekel rok hrambe, ni mogoče obnoviti.", "runDetail.fileListDetail": "Podrobnosti", "runDetail.fileListLabel": "Podrobnosti o datoteki", "runDetail.testWithParams": "Preizkus s parametri", diff --git a/web/i18n/th-TH/app-log.json b/web/i18n/th-TH/app-log.json index 8972325ebcc..129c21717f6 100644 --- a/web/i18n/th-TH/app-log.json +++ b/web/i18n/th-TH/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "ปีจนถึงปัจจุบัน", "filter.sortBy": "เมืองสีดํา:", "monitoring.description": "การมอนิเตอร์บันทึกสถานะการทำงานของแอปพลิเคชัน รวมถึงประสิทธิภาพ กิจกรรมผู้ใช้ และค่าใช้จ่าย", + "retention.upgradeTip.description": "อัปเกรดเพื่อเก็บบันทึกทั้งหมดที่สร้างขึ้นหลังการอัปเกรดไว้โดยไม่จำกัดเวลา บันทึกที่หมดอายุก่อนการอัปเกรดไม่สามารถกู้คืนได้", "runDetail.fileListDetail": "รายละเอียด", "runDetail.fileListLabel": "รายละเอียดไฟล์", "runDetail.testWithParams": "ทดสอบด้วยพารามิเตอร์", diff --git a/web/i18n/tr-TR/app-log.json b/web/i18n/tr-TR/app-log.json index 630801a894a..522fb0b41fa 100644 --- a/web/i18n/tr-TR/app-log.json +++ b/web/i18n/tr-TR/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Yıl Başlangıcından İtibaren", "filter.sortBy": "Sıralama ölçütü:", "monitoring.description": "İzleme, performans, kullanıcı etkinliği ve maliyetler dahil olmak üzere uygulamanın çalışma durumunu kaydeder.", + "retention.upgradeTip.description": "Yükseltme sonrasında oluşturulan tüm günlükleri süresiz olarak saklamak için planınızı yükseltin. Yükseltmeden önce süresi dolan günlükler kurtarılamaz.", "runDetail.fileListDetail": "Ayrıntı", "runDetail.fileListLabel": "Dosya Detayları", "runDetail.testWithParams": "Parametrelerle Test", diff --git a/web/i18n/uk-UA/app-log.json b/web/i18n/uk-UA/app-log.json index d6fb56e1df6..3f93908c563 100644 --- a/web/i18n/uk-UA/app-log.json +++ b/web/i18n/uk-UA/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Рік до сьогодні", "filter.sortBy": "Сортувати за:", "monitoring.description": "Моніторинг фіксує робочий стан застосунку, зокрема продуктивність, активність користувачів і витрати.", + "retention.upgradeTip.description": "Перейдіть на вищий тариф, щоб безстроково зберігати всі логи, створені після оновлення тарифу. Логи, термін зберігання яких минув до оновлення тарифу, відновити неможливо.", "runDetail.fileListDetail": "Деталь", "runDetail.fileListLabel": "Подробиці файлу", "runDetail.testWithParams": "Тест з параметрами", diff --git a/web/i18n/vi-VN/app-log.json b/web/i18n/vi-VN/app-log.json index 18ed93a95a2..7cc86088e69 100644 --- a/web/i18n/vi-VN/app-log.json +++ b/web/i18n/vi-VN/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Năm hiện tại", "filter.sortBy": "Sắp xếp theo:", "monitoring.description": "Giám sát ghi lại trạng thái hoạt động của ứng dụng, bao gồm hiệu suất, hoạt động người dùng và chi phí.", + "retention.upgradeTip.description": "Nâng cấp để lưu giữ vô thời hạn tất cả log được tạo sau khi nâng cấp; không thể khôi phục các log đã hết hạn trước khi nâng cấp.", "runDetail.fileListDetail": "Chi tiết", "runDetail.fileListLabel": "Chi tiết tệp", "runDetail.testWithParams": "Kiểm tra với các tham số", diff --git a/web/i18n/zh-Hans/app-log.json b/web/i18n/zh-Hans/app-log.json index 0e5005f60d1..38c57b9497e 100644 --- a/web/i18n/zh-Hans/app-log.json +++ b/web/i18n/zh-Hans/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "本年至今", "filter.sortBy": "排序:", "monitoring.description": "监控记录应用的运行情况,包括性能、用户活动和成本。", + "retention.upgradeTip.description": "升级即可无限期保留升级后生成的日志;此前已过期的日志无法恢复。", "runDetail.fileListDetail": "详情", "runDetail.fileListLabel": "文件详情", "runDetail.testWithParams": "按此参数测试", diff --git a/web/i18n/zh-Hant/app-log.json b/web/i18n/zh-Hant/app-log.json index b53201f82d7..59e1633f5f4 100644 --- a/web/i18n/zh-Hant/app-log.json +++ b/web/i18n/zh-Hant/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "本年至今", "filter.sortBy": "排序:", "monitoring.description": "監控記錄應用的執行情況,包括效能、使用者活動和成本。", + "retention.upgradeTip.description": "升級即可無限期保留升級後產生的日誌;此前已過期的日誌無法復原。", "runDetail.fileListDetail": "細節", "runDetail.fileListLabel": "檔詳細資訊", "runDetail.testWithParams": "使用參數測試",