mirror of
https://github.com/langgenius/dify.git
synced 2026-07-28 23:59:34 +08:00
chore: improve archived logs hint in logs list page (#39659)
This commit is contained in:
parent
1e5e47b889
commit
b2d54cb2e9
@ -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,
|
||||
})
|
||||
|
||||
@ -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} />)
|
||||
|
||||
|
||||
@ -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),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
|
||||
54
web/app/components/app/log/cloud-sandbox-retention.ts
Normal file
54
web/app/components/app/log/cloud-sandbox-retention.ts
Normal 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'
|
||||
}
|
||||
@ -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' }),
|
||||
}))}
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
43
web/app/components/app/log/retention-upgrade-notice.tsx
Normal file
43
web/app/components/app/log/retention-upgrade-notice.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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' }),
|
||||
}))}
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -66,6 +66,7 @@
|
||||
"filter.period.yearToDate": "السنة حتى الآن",
|
||||
"filter.sortBy": "رتب حسب:",
|
||||
"monitoring.description": "يسجل الرصد حالة تشغيل التطبيق، بما في ذلك الأداء ونشاط المستخدمين والتكاليف.",
|
||||
"retention.upgradeTip.description": "قم بالترقية للاحتفاظ بجميع السجلات التي يتم إنشاؤها بعد الترقية دون حد زمني؛ لا يمكن استعادة السجلات التي انتهت مدة الاحتفاظ بها قبل الترقية.",
|
||||
"runDetail.fileListDetail": "تفاصيل",
|
||||
"runDetail.fileListLabel": "تفاصيل الملف",
|
||||
"runDetail.testWithParams": "اختبار مع المعلمات",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -66,6 +66,7 @@
|
||||
"filter.period.yearToDate": "از ابتدای سال تاکنون",
|
||||
"filter.sortBy": "مرتبسازی بر اساس:",
|
||||
"monitoring.description": "مانیتورینگ وضعیت اجرای برنامه را ثبت میکند، از جمله عملکرد، فعالیت کاربران و هزینهها.",
|
||||
"retention.upgradeTip.description": "طرح خود را ارتقا دهید تا همه لاگهای ایجادشده پس از ارتقا بدون محدودیت زمانی نگهداری شوند؛ لاگهایی که پیش از ارتقا منقضی شدهاند قابل بازیابی نیستند.",
|
||||
"runDetail.fileListDetail": "جزئیات",
|
||||
"runDetail.fileListLabel": "جزئیات فایل",
|
||||
"runDetail.testWithParams": "تست با پارامترها",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -66,6 +66,7 @@
|
||||
"filter.period.yearToDate": "वर्ष तक तिथि",
|
||||
"filter.sortBy": "इसके अनुसार क्रमबद्ध करें:",
|
||||
"monitoring.description": "मॉनिटरिंग एप्लिकेशन की रनिंग स्थिति रिकॉर्ड करती है, जिसमें प्रदर्शन, उपयोगकर्ता गतिविधि और लागतें शामिल हैं।",
|
||||
"retention.upgradeTip.description": "अपग्रेड करें और उसके बाद जनरेट किए गए सभी लॉग बिना किसी समय सीमा के सुरक्षित रखें; अपग्रेड से पहले समाप्त हो चुके लॉग पुनर्प्राप्त नहीं किए जा सकते।",
|
||||
"runDetail.fileListDetail": "विस्तार",
|
||||
"runDetail.fileListLabel": "फ़ाइल विवरण",
|
||||
"runDetail.testWithParams": "पैरामीटर्स के साथ परीक्षण",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -66,6 +66,7 @@
|
||||
"filter.period.yearToDate": "年初から今日まで",
|
||||
"filter.sortBy": "並べ替え",
|
||||
"monitoring.description": "モニタリングは、パフォーマンス、ユーザー活動、コストを含むアプリケーションの実行状況を記録します。",
|
||||
"retention.upgradeTip.description": "アップグレード後に生成されたログを無期限に保存できます。すでに期限切れとなったログは復元できません。",
|
||||
"runDetail.fileListDetail": "詳細",
|
||||
"runDetail.fileListLabel": "ファイルの詳細",
|
||||
"runDetail.testWithParams": "パラメータ付きテスト",
|
||||
|
||||
@ -66,6 +66,7 @@
|
||||
"filter.period.yearToDate": "연 초부터 오늘까지",
|
||||
"filter.sortBy": "정렬 기준:",
|
||||
"monitoring.description": "모니터링은 성능, 사용자 활동, 비용을 포함한 애플리케이션 실행 상태를 기록합니다.",
|
||||
"retention.upgradeTip.description": "업그레이드하면 업그레이드 후 생성된 모든 로그를 기간 제한 없이 보관할 수 있습니다. 업그레이드 전에 만료된 로그는 복구할 수 없습니다.",
|
||||
"runDetail.fileListDetail": "세부",
|
||||
"runDetail.fileListLabel": "파일 세부 정보",
|
||||
"runDetail.testWithParams": "매개변수로 테스트",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -66,6 +66,7 @@
|
||||
"filter.period.yearToDate": "С начала года",
|
||||
"filter.sortBy": "Сортировать по:",
|
||||
"monitoring.description": "Мониторинг записывает состояние работы приложения, включая производительность, активность пользователей и затраты.",
|
||||
"retention.upgradeTip.description": "Перейдите на более высокий тариф, чтобы бессрочно хранить все логи, созданные после обновления тарифа. Логи, срок хранения которых истёк до обновления тарифа, восстановить нельзя.",
|
||||
"runDetail.fileListDetail": "Подробность",
|
||||
"runDetail.fileListLabel": "Сведения о файле",
|
||||
"runDetail.testWithParams": "Тест с параметрами",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -66,6 +66,7 @@
|
||||
"filter.period.yearToDate": "ปีจนถึงปัจจุบัน",
|
||||
"filter.sortBy": "เมืองสีดํา:",
|
||||
"monitoring.description": "การมอนิเตอร์บันทึกสถานะการทำงานของแอปพลิเคชัน รวมถึงประสิทธิภาพ กิจกรรมผู้ใช้ และค่าใช้จ่าย",
|
||||
"retention.upgradeTip.description": "อัปเกรดเพื่อเก็บบันทึกทั้งหมดที่สร้างขึ้นหลังการอัปเกรดไว้โดยไม่จำกัดเวลา บันทึกที่หมดอายุก่อนการอัปเกรดไม่สามารถกู้คืนได้",
|
||||
"runDetail.fileListDetail": "รายละเอียด",
|
||||
"runDetail.fileListLabel": "รายละเอียดไฟล์",
|
||||
"runDetail.testWithParams": "ทดสอบด้วยพารามิเตอร์",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -66,6 +66,7 @@
|
||||
"filter.period.yearToDate": "Рік до сьогодні",
|
||||
"filter.sortBy": "Сортувати за:",
|
||||
"monitoring.description": "Моніторинг фіксує робочий стан застосунку, зокрема продуктивність, активність користувачів і витрати.",
|
||||
"retention.upgradeTip.description": "Перейдіть на вищий тариф, щоб безстроково зберігати всі логи, створені після оновлення тарифу. Логи, термін зберігання яких минув до оновлення тарифу, відновити неможливо.",
|
||||
"runDetail.fileListDetail": "Деталь",
|
||||
"runDetail.fileListLabel": "Подробиці файлу",
|
||||
"runDetail.testWithParams": "Тест з параметрами",
|
||||
|
||||
@ -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ố",
|
||||
|
||||
@ -66,6 +66,7 @@
|
||||
"filter.period.yearToDate": "本年至今",
|
||||
"filter.sortBy": "排序:",
|
||||
"monitoring.description": "监控记录应用的运行情况,包括性能、用户活动和成本。",
|
||||
"retention.upgradeTip.description": "升级即可无限期保留升级后生成的日志;此前已过期的日志无法恢复。",
|
||||
"runDetail.fileListDetail": "详情",
|
||||
"runDetail.fileListLabel": "文件详情",
|
||||
"runDetail.testWithParams": "按此参数测试",
|
||||
|
||||
@ -66,6 +66,7 @@
|
||||
"filter.period.yearToDate": "本年至今",
|
||||
"filter.sortBy": "排序:",
|
||||
"monitoring.description": "監控記錄應用的執行情況,包括效能、使用者活動和成本。",
|
||||
"retention.upgradeTip.description": "升級即可無限期保留升級後產生的日誌;此前已過期的日誌無法復原。",
|
||||
"runDetail.fileListDetail": "細節",
|
||||
"runDetail.fileListLabel": "檔詳細資訊",
|
||||
"runDetail.testWithParams": "使用參數測試",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user