chore: improve archived logs hint in logs list page (#39659)

This commit is contained in:
非法操作 2026-07-28 13:15:35 +08:00 committed by GitHub
parent 1e5e47b889
commit b2d54cb2e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 833 additions and 76 deletions

View File

@ -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,
})

View File

@ -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<typeof import('@tanstack/react-query')>()
return {
...actual,
useSuspenseQuery: () => ({ data: mockRuntime.deploymentEdition }),
}
})
vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>()
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 (
<div>
<div>{currentItem?.name}</div>
<button onClick={() => onSelect(items.at(-1)!)}>{`select-${items.at(-1)?.value}`}</button>
<button onClick={onClear}>clear-chip</button>
</div>
)
},
}))
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 (
<div>
<div>{currentItem?.name}</div>
<button aria-label={`open-options-${items[0]?.value}`} onClick={() => setIsOpen(true)}>
open-chip
</button>
{isOpen && (
<ul aria-label={`options-${items[0]?.value}`}>
{items.map((item) => (
<li key={item.value}>{item.name}</li>
))}
</ul>
)}
<button onClick={() => onSelect(items.at(-1)!)}>{`select-${items.at(-1)?.value}`}</button>
<button onClick={onClear}>clear-chip</button>
</div>
)
},
}
})
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(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
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(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
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(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
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(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
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(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
fireEvent.click(screen.getAllByText('clear-chip')[0]!)
expect(mockSetQueryParams).toHaveBeenCalledWith({
...defaultQueryParams,
period: '1',
})
})
it('should update keyword when typing in search input', () => {
render(<Filter {...defaultProps} />)

View File

@ -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: <T,>(value: T) => value,
useDebounce: <T,>(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<string, string>) => void }) => (
<button
onClick={() =>
setQueryParams({
period: '9',
annotation_status: 'all',
sort_by: '-created_at',
keyword: 'hello',
})
}
>
filter-controls
</button>
),
}))
vi.mock('../cloud-sandbox-retention', async (importOriginal) => {
const actual = await importOriginal<typeof import('../cloud-sandbox-retention')>()
return {
...actual,
useCloudSandboxPlanStatus: () => mockPlanState.value,
}
})
vi.mock('../list', () => ({
default: ({ logs }: { logs: { total?: number } }) => (
@ -69,6 +79,10 @@ vi.mock('../empty-element', () => ({
default: () => <div>empty-logs</div>,
}))
vi.mock('../retention-upgrade-notice', () => ({
RetentionUpgradeNotice: () => <div>retention-upgrade-notice</div>,
}))
vi.mock('@/app/components/base/loading', () => ({
default: () => <div>loading-logs</div>,
}))
@ -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(
<Logs
appDetail={
{
id: 'app-sandbox-last-30-days',
mode: AppModeEnum.CHAT,
} as any
}
/>,
)
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(<Logs appDetail={appDetail} />)
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(<Logs appDetail={appDetail} />)
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(<Logs appDetail={appDetail} />)
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),
}),
}),
)
})
})

View File

@ -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<typeof import('@/context/provider-context')>()
return {
...actual,
useProviderContext: vi.fn(),
}
})
vi.mock('@/context/modal-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/modal-context')>()
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(<RetentionUpgradeNotice />, { wrapper })
}
beforeEach(() => {
vi.clearAllMocks()
mockProvider()
mockUseModalContext.mockReturnValue({
setShowPricingModal,
} as unknown as ReturnType<typeof useModalContext>)
})
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()
})
})

View File

@ -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 (
<div className="mb-3 flex items-start gap-2 rounded-lg border border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 px-3 py-2">
<span
aria-hidden
className="mt-0.5 i-ri-information-line size-4 shrink-0 text-util-colors-warning-warning-600"
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="relative mb-3 shrink-0 overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"
>
<div
aria-hidden="true"
className="absolute -inset-px bg-linear-to-r from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent opacity-40"
/>
<div className="min-w-0 flex-1 system-xs-regular text-util-colors-warning-warning-700">
{t(($) => $['archives.notice.description'], { ns: 'appLog' })}
<button
type="button"
className="ml-1 system-xs-semibold text-util-colors-warning-warning-700 underline underline-offset-2 hover:text-text-primary"
<div className="relative flex items-center gap-3 px-3 py-2">
<span
aria-hidden="true"
className="i-ri-information-2-fill size-5 shrink-0 text-text-accent"
/>
<p className="min-w-0 flex-1 system-sm-semibold wrap-break-word text-text-primary">
{t(($) => $['archives.notice.description'], { ns: 'appLog' })}
</p>
<Button
variant="primary"
className="shrink-0"
onClick={() =>
setShowAccountSettingModal({
payload: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
@ -48,7 +60,7 @@ export function ArchivedLogsNotice() {
}
>
{t(($) => $['archives.notice.action'], { ns: 'appLog' })}
</button>
</Button>
</div>
</div>
)

View File

@ -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<T extends { value: number; name: string }>(
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'
}

View File

@ -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> = ({
}: 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 (
<div className="mb-2 flex flex-row flex-wrap items-center gap-2">
@ -56,8 +69,13 @@ const Filter: FC<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' }),
}))}

View File

@ -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<ILogsProps> = ({ appDetail }) => {
return pageParam - 1
}, [searchParams])
const cachedState = logsStateCache.get(appDetail.id)
const cloudSandboxPlanState = useCloudSandboxPlanStatus()
const [queryParams, setQueryParams] = useState<QueryParam>(
cachedState?.queryParams ?? defaultQueryParams,
)
@ -64,7 +71,15 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
() => cachedState?.currPage ?? getPageFromParams(),
)
const [limit, setLimit] = React.useState<number>(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<ILogsProps> = ({ 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<ILogsProps> = ({ appDetail }) => {
<Filter
isChatMode={isChatMode}
appId={appDetail.id}
queryParams={queryParams}
queryParams={effectiveQueryParams}
setQueryParams={handleQueryParamsChange}
/>
<RetentionUpgradeNotice />
{total === undefined ? (
<Loading type="app" />
) : total > 0 ? (

View File

@ -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 (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="relative mb-3 shrink-0 overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"
>
<div
aria-hidden="true"
className="absolute -inset-px bg-linear-to-r from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent opacity-40"
/>
<div className="relative flex items-center gap-3 px-3 py-2">
<span
aria-hidden="true"
className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-components-button-primary-bg"
>
<span className="i-ri-file-list-3-fill size-4 text-components-button-primary-text" />
</span>
<p className="min-w-0 flex-1 system-sm-medium wrap-break-word text-text-primary">
{t(($) => $['retention.upgradeTip.description'], { ns: 'appLog' })}
</p>
<UpgradeBtn
isShort
size="custom"
className="h-8! shrink-0 rounded-lg! px-2"
loc="logs-retention"
/>
</div>
</div>
)
}

View File

@ -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<typeof import('@tanstack/react-query')>()
return {
...actual,
useSuspenseQuery: () => ({ data: mockRuntime.deploymentEdition }),
}
})
vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>()
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(
<Filter queryParams={createDefaultQueryParams()} setQueryParams={defaultSetQueryParams} />,
)
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(
<Filter queryParams={createDefaultQueryParams()} setQueryParams={defaultSetQueryParams} />,
)
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(
<Filter
queryParams={createDefaultQueryParams({ period: '3' })}
setQueryParams={setQueryParams}
/>,
)
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(
<Filter

View File

@ -15,11 +15,13 @@ import type { UseQueryResult } from '@tanstack/react-query'
* - trigger-by-display.spec.tsx
*/
import type { MockedFunction } from 'vitest'
import type { CloudSandboxPlanState } from '../../log/cloud-sandbox-retention'
import type { ILogsProps } from '../index'
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log'
import type { App, AppIconType, AppModeEnum } from '@/types/app'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import dayjs from 'dayjs'
import { APP_PAGE_LIMIT } from '@/config'
import { WorkflowRunTriggeredFrom } from '@/models/log'
import * as useLogModule from '@/service/use-log'
@ -31,10 +33,35 @@ import Logs from '../index'
// Mocks
// ============================================================================
const mockPlanState = vi.hoisted(() => ({
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<typeof import('../../log/cloud-sandbox-retention')>()
return {
...actual,
useCloudSandboxPlanStatus: () => mockPlanState.value,
}
})
vi.mock('ahooks', () => ({
useDebounce: <T,>(value: T) => value,
useDebounce: <T,>(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: () => <div>retention-upgrade-notice</div>,
}))
// 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<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0),
}),
)
renderWithQueryClient(<Logs {...defaultProps} />)
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<WorkflowLogsResponse>({
data: createMockLogsResponse([], 0),
}),
)
const rendered = renderWithQueryClient(<Logs {...defaultProps} />)
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(<Logs {...defaultProps} />)
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(<Logs {...defaultProps} />)
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()

View File

@ -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<IFilterProps> = ({ 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 (
<div className="mb-2 flex flex-row flex-wrap gap-2">
<Chip
@ -63,8 +76,13 @@ const Filter: FC<IFilterProps> = ({ 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' }),
}))}

View File

@ -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<ILogsProps> = ({ appDetail }) => {
})
const [queryParams, setQueryParams] = useState<QueryParam>({ status: 'all', period: '2' })
const [currPage, setCurrPage] = React.useState<number>(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<number>(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<ILogsProps> = ({ 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<ILogsProps> = ({ appDetail }) => {
description={t(($) => $.workflowSubtitle, { ns: 'appLog' })}
/>
<div className="flex max-h-[calc(100%-16px)] flex-1 flex-col py-4">
<Filter queryParams={queryParams} setQueryParams={setQueryParams} />
<Filter queryParams={effectiveQueryParams} setQueryParams={setQueryParams} />
<RetentionUpgradeNotice />
{showArchivedLogsNotice && <ArchivedLogsNotice />}
{/* workflow log */}
{total === undefined ? (

View File

@ -66,6 +66,7 @@
"filter.period.yearToDate": "السنة حتى الآن",
"filter.sortBy": "رتب حسب:",
"monitoring.description": "يسجل الرصد حالة تشغيل التطبيق، بما في ذلك الأداء ونشاط المستخدمين والتكاليف.",
"retention.upgradeTip.description": "قم بالترقية للاحتفاظ بجميع السجلات التي يتم إنشاؤها بعد الترقية دون حد زمني؛ لا يمكن استعادة السجلات التي انتهت مدة الاحتفاظ بها قبل الترقية.",
"runDetail.fileListDetail": "تفاصيل",
"runDetail.fileListLabel": "تفاصيل الملف",
"runDetail.testWithParams": "اختبار مع المعلمات",

View File

@ -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",

View File

@ -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 cant be recovered.",
"runDetail.fileListDetail": "Detail",
"runDetail.fileListLabel": "File Details",
"runDetail.testWithParams": "Test With Params",

View File

@ -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",

View File

@ -66,6 +66,7 @@
"filter.period.yearToDate": "از ابتدای سال تاکنون",
"filter.sortBy": "مرتب‌سازی بر اساس:",
"monitoring.description": "مانیتورینگ وضعیت اجرای برنامه را ثبت می‌کند، از جمله عملکرد، فعالیت کاربران و هزینه‌ها.",
"retention.upgradeTip.description": "طرح خود را ارتقا دهید تا همه لاگ‌های ایجادشده پس از ارتقا بدون محدودیت زمانی نگهداری شوند؛ لاگ‌هایی که پیش از ارتقا منقضی شده‌اند قابل بازیابی نیستند.",
"runDetail.fileListDetail": "جزئیات",
"runDetail.fileListLabel": "جزئیات فایل",
"runDetail.testWithParams": "تست با پارامترها",

View File

@ -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 lapplication, notamment les performances, lactivité 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",

View File

@ -66,6 +66,7 @@
"filter.period.yearToDate": "वर्ष तक तिथि",
"filter.sortBy": "इसके अनुसार क्रमबद्ध करें:",
"monitoring.description": "मॉनिटरिंग एप्लिकेशन की रनिंग स्थिति रिकॉर्ड करती है, जिसमें प्रदर्शन, उपयोगकर्ता गतिविधि और लागतें शामिल हैं।",
"retention.upgradeTip.description": "अपग्रेड करें और उसके बाद जनरेट किए गए सभी लॉग बिना किसी समय सीमा के सुरक्षित रखें; अपग्रेड से पहले समाप्त हो चुके लॉग पुनर्प्राप्त नहीं किए जा सकते।",
"runDetail.fileListDetail": "विस्तार",
"runDetail.fileListLabel": "फ़ाइल विवरण",
"runDetail.testWithParams": "पैरामीटर्स के साथ परीक्षण",

View File

@ -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",

View File

@ -66,6 +66,7 @@
"filter.period.yearToDate": "Anno corrente",
"filter.sortBy": "Ordina per:",
"monitoring.description": "Il monitoraggio registra lo stato di esecuzione dellapplicazione, 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",

View File

@ -66,6 +66,7 @@
"filter.period.yearToDate": "年初から今日まで",
"filter.sortBy": "並べ替え",
"monitoring.description": "モニタリングは、パフォーマンス、ユーザー活動、コストを含むアプリケーションの実行状況を記録します。",
"retention.upgradeTip.description": "アップグレード後に生成されたログを無期限に保存できます。すでに期限切れとなったログは復元できません。",
"runDetail.fileListDetail": "詳細",
"runDetail.fileListLabel": "ファイルの詳細",
"runDetail.testWithParams": "パラメータ付きテスト",

View File

@ -66,6 +66,7 @@
"filter.period.yearToDate": "연 초부터 오늘까지",
"filter.sortBy": "정렬 기준:",
"monitoring.description": "모니터링은 성능, 사용자 활동, 비용을 포함한 애플리케이션 실행 상태를 기록합니다.",
"retention.upgradeTip.description": "업그레이드하면 업그레이드 후 생성된 모든 로그를 기간 제한 없이 보관할 수 있습니다. 업그레이드 전에 만료된 로그는 복구할 수 없습니다.",
"runDetail.fileListDetail": "세부",
"runDetail.fileListLabel": "파일 세부 정보",
"runDetail.testWithParams": "매개변수로 테스트",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -66,6 +66,7 @@
"filter.period.yearToDate": "С начала года",
"filter.sortBy": "Сортировать по:",
"monitoring.description": "Мониторинг записывает состояние работы приложения, включая производительность, активность пользователей и затраты.",
"retention.upgradeTip.description": "Перейдите на более высокий тариф, чтобы бессрочно хранить все логи, созданные после обновления тарифа. Логи, срок хранения которых истёк до обновления тарифа, восстановить нельзя.",
"runDetail.fileListDetail": "Подробность",
"runDetail.fileListLabel": "Сведения о файле",
"runDetail.testWithParams": "Тест с параметрами",

View File

@ -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",

View File

@ -66,6 +66,7 @@
"filter.period.yearToDate": "ปีจนถึงปัจจุบัน",
"filter.sortBy": "เมืองสีดํา:",
"monitoring.description": "การมอนิเตอร์บันทึกสถานะการทำงานของแอปพลิเคชัน รวมถึงประสิทธิภาพ กิจกรรมผู้ใช้ และค่าใช้จ่าย",
"retention.upgradeTip.description": "อัปเกรดเพื่อเก็บบันทึกทั้งหมดที่สร้างขึ้นหลังการอัปเกรดไว้โดยไม่จำกัดเวลา บันทึกที่หมดอายุก่อนการอัปเกรดไม่สามารถกู้คืนได้",
"runDetail.fileListDetail": "รายละเอียด",
"runDetail.fileListLabel": "รายละเอียดไฟล์",
"runDetail.testWithParams": "ทดสอบด้วยพารามิเตอร์",

View File

@ -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",

View File

@ -66,6 +66,7 @@
"filter.period.yearToDate": "Рік до сьогодні",
"filter.sortBy": "Сортувати за:",
"monitoring.description": "Моніторинг фіксує робочий стан застосунку, зокрема продуктивність, активність користувачів і витрати.",
"retention.upgradeTip.description": "Перейдіть на вищий тариф, щоб безстроково зберігати всі логи, створені після оновлення тарифу. Логи, термін зберігання яких минув до оновлення тарифу, відновити неможливо.",
"runDetail.fileListDetail": "Деталь",
"runDetail.fileListLabel": "Подробиці файлу",
"runDetail.testWithParams": "Тест з параметрами",

View File

@ -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ố",

View File

@ -66,6 +66,7 @@
"filter.period.yearToDate": "本年至今",
"filter.sortBy": "排序:",
"monitoring.description": "监控记录应用的运行情况,包括性能、用户活动和成本。",
"retention.upgradeTip.description": "升级即可无限期保留升级后生成的日志;此前已过期的日志无法恢复。",
"runDetail.fileListDetail": "详情",
"runDetail.fileListLabel": "文件详情",
"runDetail.testWithParams": "按此参数测试",

View File

@ -66,6 +66,7 @@
"filter.period.yearToDate": "本年至今",
"filter.sortBy": "排序:",
"monitoring.description": "監控記錄應用的執行情況,包括效能、使用者活動和成本。",
"retention.upgradeTip.description": "升級即可無限期保留升級後產生的日誌;此前已過期的日誌無法復原。",
"runDetail.fileListDetail": "細節",
"runDetail.fileListLabel": "檔詳細資訊",
"runDetail.testWithParams": "使用參數測試",