refactor(overview): read app detail from the route query (#43258)

This commit is contained in:
yyh authored and GitHub committed 2026-09-30 04:35:54 +00:00
1 parent ea38e484b8
commit 28697d9587
7 files changed
+213 -182

No files matched your search

@@ -1,18 +1,9 @@
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
import { screen } from '@testing-library/react'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { AppACLPermission } from '@/utils/permission'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import ChartView from '../chart-view'
const testState = vi.hoisted(() => ({
appDetail: {
id: 'app-1',
mode: 'chat',
maintainer: 'maintainer-1',
permission_keys: [] as string[],
},
currentUserId: 'user-1',
workspacePermissionKeys: [] as string[],
chartRenderSpy: vi.fn(),
conversationPeriodSpy: vi.fn(),
}))
@@ -24,13 +15,6 @@ vi.mock('@/context/workspace-state', async () => {
}))
})
vi.mock('@/app/components/app/store', () => ({
useStore: <T,>(selector: (state: { appDetail: typeof testState.appDetail }) => T): T =>
selector({
appDetail: testState.appDetail,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => path,
}))
@@ -103,40 +87,33 @@ vi.mock('@/context/permission-state', async () => {
}))
})
describe('ChartView monitor permission', () => {
beforeEach(() => {
vi.clearAllMocks()
testState.appDetail = {
id: 'app-1',
mode: 'chat',
maintainer: 'maintainer-1',
permission_keys: [],
}
testState.currentUserId = 'user-1'
testState.workspacePermissionKeys = []
describe('ChartView', () => {
beforeEach(() => vi.clearAllMocks())
it('renders chat charts and the page-provided header action', () => {
renderWithConsoleQuery(
<ChartView
appId="app-1"
appMode="chat"
headerRight={<button type="button">header action</button>}
/>,
)
expect(screen.getByRole('heading', { name: 'common.appMenus.overview' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'header action' })).toBeInTheDocument()
expect(screen.getByText('conversations chart')).toBeInTheDocument()
expect(screen.getByText('avg session interactions chart')).toBeInTheDocument()
})
// Monitoring charts are part of the app monitor permission surface.
describe('Permissions', () => {
it('should not render monitoring charts when app monitor permission is missing', () => {
render(<ChartView appId="app-1" headerRight={<button type="button">header action</button>} />)
it('renders completion charts from the explicit app mode', () => {
renderWithConsoleQuery(<ChartView appId="app-1" appMode="completion" headerRight={null} />)
expect(screen.getByText('avg response time chart')).toBeInTheDocument()
expect(screen.queryByText('avg session interactions chart')).not.toBeInTheDocument()
})
expect(
screen.queryByRole('heading', { name: 'common.appMenus.overview' }),
).not.toBeInTheDocument()
expect(screen.queryByText('header action')).not.toBeInTheDocument()
expect(testState.chartRenderSpy).not.toHaveBeenCalled()
})
it('should render monitoring charts when app monitor permission is granted', () => {
testState.appDetail.permission_keys = [AppACLPermission.Monitor]
render(<ChartView appId="app-1" headerRight={<button type="button">header action</button>} />)
expect(screen.getByRole('heading', { name: 'common.appMenus.overview' })).toBeInTheDocument()
expect(screen.getByText('header action')).toBeInTheDocument()
expect(screen.getByText('conversations chart')).toBeInTheDocument()
expect(testState.chartRenderSpy).toHaveBeenCalledWith('conversations')
})
it('renders workflow charts from the explicit app mode', () => {
renderWithConsoleQuery(<ChartView appId="app-2" appMode="workflow" headerRight={null} />)
expect(screen.getByText('workflow messages chart')).toBeInTheDocument()
expect(screen.queryByText('conversations chart')).not.toBeInTheDocument()
})
})
@@ -1,10 +1,18 @@
import type { ReactNode } from 'react'
import { screen } from '@testing-library/react'
import { renderWithAccountProfile as render } from '@/test/console/account-profile'
import { QueryErrorResetBoundary } from '@tanstack/react-query'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Suspense } from 'react'
import CommonLayoutError from '@/app/(commonLayout)/error'
import ErrorBoundary from '@/app/components/base/error-boundary'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { AppACLPermission } from '@/utils/permission'
import OverviewView from '../view'
const testState = vi.hoisted(() => ({
request: vi.fn<() => Promise<Response>>(),
appDetail: {
id: 'app-1',
mode: 'chat',
@@ -15,6 +23,11 @@ const testState = vi.hoisted(() => ({
workspacePermissionKeys: [] as string[],
}))
vi.mock('@/service/base', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/base')>()),
request: testState.request,
}))
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => ({
@@ -22,13 +35,6 @@ vi.mock('@/context/workspace-state', async () => {
}))
})
vi.mock('@/app/components/app/store', () => ({
useStore: <T,>(selector: (state: { appDetail: typeof testState.appDetail }) => T): T =>
selector({
appDetail: testState.appDetail,
}),
}))
vi.mock('@/app/components/app/overview/apikey-info-panel', () => ({
default: () => <div>api key info panel</div>,
}))
@@ -54,6 +60,16 @@ vi.mock('@/context/permission-state', async () => {
}))
})
const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) => {
const queryClient = createConsoleQueryClient()
const detail = createAppDetailFixture({ ...testState.appDetail, mode: 'chat' })
queryClient.setQueryData(
consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: detail.id } } }),
detail,
)
return renderWithConsoleQuery(ui, { queryClient })
}
describe('OverviewView monitor permission', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -111,3 +127,110 @@ describe('OverviewView monitor permission', () => {
})
})
})
describe('Overview app identity', () => {
it('uses the destination permissions when navigating between cached apps', async () => {
testState.appDetail.permission_keys = [AppACLPermission.Monitor, AppACLPermission.TracingConfig]
const { queryClient, rerender } = render(<OverviewView appId="app-1" />)
queryClient.setQueryData(
consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: 'app-2' } } }),
createAppDetailFixture({ id: 'app-2', permission_keys: [] }),
)
expect(screen.getByRole('button', { name: 'tracing' })).toBeInTheDocument()
rerender(<OverviewView appId="app-2" />)
expect(screen.queryByText('api key info panel')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'tracing' })).not.toBeInTheDocument()
act(() => {
queryClient.setQueryData(
consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: 'app-2' } } }),
createAppDetailFixture({ id: 'app-2', permission_keys: [AppACLPermission.Monitor] }),
)
})
await screen.findByText(/chart view app-2/)
act(() => {
queryClient.setQueryData(
consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: 'app-2' } } }),
createAppDetailFixture({ id: 'app-2', permission_keys: [] }),
)
})
await waitFor(() => expect(screen.queryByText('api key info panel')).not.toBeInTheDocument())
})
})
it('shows the page fallback until required app details resolve', async () => {
let resolveRequest: (response: Response) => void = () => {}
testState.request.mockReturnValue(
new Promise<Response>((resolve) => {
resolveRequest = resolve
}),
)
renderWithConsoleQuery(
<Suspense fallback={<div>Loading overview</div>}>
<OverviewView appId="loading-app" />
</Suspense>,
)
expect(screen.getByText('Loading overview')).toBeInTheDocument()
expect(screen.queryByText('api key info panel')).not.toBeInTheDocument()
await act(async () => {
resolveRequest(
Response.json(
createAppDetailFixture({
id: 'loading-app',
permission_keys: [AppACLPermission.Monitor],
}),
),
)
})
expect(await screen.findByText(/chart view loading-app/)).toBeInTheDocument()
expect(screen.queryByText('Loading overview')).not.toBeInTheDocument()
})
it('recovers an initial detail failure through the existing route error action', async () => {
const user = userEvent.setup()
testState.request.mockRejectedValue(new Error('Detail unavailable'))
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
renderWithConsoleQuery(
<QueryErrorResetBoundary>
<ErrorBoundary
fallback={(error, retry) => <CommonLayoutError error={error} retry={retry} />}
>
<Suspense fallback={<div>Loading overview</div>}>
<OverviewView appId="unavailable-app" />
</Suspense>
</ErrorBoundary>
</QueryErrorResetBoundary>,
)
const retry = await screen.findByRole('button', { name: 'common.errorBoundary.tryAgain' })
expect(screen.queryByText('api key info panel')).not.toBeInTheDocument()
testState.request.mockResolvedValue(
Response.json(
createAppDetailFixture({
id: 'unavailable-app',
permission_keys: [AppACLPermission.Monitor],
}),
),
)
await user.click(retry)
expect(await screen.findByText(/chart view unavailable-app/)).toBeInTheDocument()
} finally {
consoleError.mockRestore()
}
})
it('keeps loaded content visible when a background refresh fails', async () => {
testState.appDetail.permission_keys = [AppACLPermission.Monitor]
testState.request.mockRejectedValue(new Error('Refresh unavailable'))
const { queryClient } = render(<OverviewView appId="app-1" />)
await act(async () => {
await queryClient.invalidateQueries({
queryKey: consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: 'app-1' } } }),
})
})
expect(screen.getByText(/chart view app-1/)).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'common.errorBoundary.tryAgain' }),
).not.toBeInTheDocument()
})
@@ -1,11 +1,11 @@
'use client'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { useSuspenseQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import quarterOfYear from 'dayjs/plugin/quarterOfYear'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -24,12 +24,8 @@ import {
WorkflowDailyTerminalsChart,
WorkflowMessagesChart,
} from '@/app/components/app/overview/app-chart'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useDocLink } from '@/context/i18n'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { getAppACLCapabilities } from '@/utils/permission'
import LongTimeRangePicker from './long-time-range-picker'
import TimeRangePicker from './time-range-picker'
@@ -49,10 +45,11 @@ const queryDateFormat = 'YYYY-MM-DD HH:mm'
type IChartViewProps = {
appId: string
appMode: AppDetailWithSite['mode']
headerRight: React.ReactNode
}
export default function ChartView({ appId, headerRight }: IChartViewProps) {
export default function ChartView({ appId, appMode, headerRight }: IChartViewProps) {
const { data: deploymentEdition } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: ({ deployment_edition }) => deployment_edition,
@@ -61,6 +58,7 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) {
return (
<ChartViewContent
appId={appId}
appMode={appMode}
headerRight={headerRight}
deploymentEdition={deploymentEdition}
/>
@@ -69,6 +67,7 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) {
function ChartViewContent({
appId,
appMode,
headerRight,
deploymentEdition,
}: IChartViewProps & { deploymentEdition: DeploymentEdition }) {
@@ -76,23 +75,8 @@ function ChartViewContent({
const isCloudEdition = deploymentEdition === 'CLOUD'
const isNonCloudEdition = deploymentEdition === 'COMMUNITY' || deploymentEdition === 'ENTERPRISE'
const docLink = useDocLink()
const appDetail = useAppStore((state) => state.appDetail)
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile.id,
})
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canMonitor = React.useMemo(
() =>
getAppACLCapabilities(appDetail?.permission_keys, {
currentUserId,
resourceMaintainer: appDetail?.maintainer,
workspacePermissionKeys,
}).canMonitor,
[appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys],
)
const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow'
const isWorkflow = appDetail?.mode === 'workflow'
const isChatApp = appMode !== 'completion' && appMode !== 'workflow'
const isWorkflow = appMode === 'workflow'
const [period, setPeriod] = useState<PeriodParams>(() =>
isCloudEdition
? {
@@ -111,8 +95,6 @@ function ChartViewContent({
},
)
if (!appDetail || !canMonitor) return null
return (
<div className="flex h-full min-h-0 flex-col">
<div className="h-26.5 shrink-0">
@@ -1,4 +1,6 @@
import { Suspense } from 'react'
import * as React from 'react'
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
import OverviewView from './view'
export type IDevelopProps = {
@@ -10,7 +12,19 @@ const Overview = async (props: IDevelopProps) => {
const { appId } = params
return <OverviewView appId={appId} />
return (
<Suspense
fallback={
<SkeletonContainer className="gap-4 p-6" aria-busy="true">
<SkeletonRectangle className="h-6 w-48" />
<SkeletonRectangle className="h-10 w-full" />
<SkeletonRectangle className="h-96 w-full" />
</SkeletonContainer>
}
>
<OverviewView appId={appId} />
</Suspense>
)
}
export default Overview
@@ -1,40 +1,15 @@
import type { ComponentProps, ReactNode } from 'react'
import { screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import { fetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps'
import { renderWithAccountProfile as render } from '@/test/console/account-profile'
import { AppACLPermission } from '@/utils/permission'
import Panel from '../panel'
const testState = vi.hoisted(() => ({
appPermissionKeys: [] as string[],
workspacePermissionKeys: [] as string[],
configButtonProps: [] as Array<{
readOnly: boolean
hasConfigured: boolean
}>,
}))
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => ({
currentWorkspace: { id: 'workspace-1' },
}))
})
vi.mock('@/next/navigation', () => ({
usePathname: () => '/app/app-1/overview',
}))
vi.mock('@/app/components/app/store', () => ({
useStore: vi.fn((selector: (state: { appDetail: { permission_keys: string[] } }) => unknown) =>
selector({
appDetail: {
permission_keys: testState.appPermissionKeys,
},
}),
),
}))
vi.mock('@/service/apps', () => ({
fetchTracingStatus: vi.fn(),
fetchTracingConfig: vi.fn(),
@@ -79,24 +54,13 @@ const mockedFetchTracingStatus = vi.mocked(fetchTracingStatus)
const mockedFetchTracingConfig = vi.mocked(fetchTracingConfig)
const mockedUpdateTracingStatus = vi.mocked(updateTracingStatus)
const renderPanel = async () => {
render(<Panel />)
const renderPanel = async (readOnly = true) => {
render(<Panel appId="app-1" readOnly={readOnly} />)
await screen.findAllByTestId('config-button')
}
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => ({
workspacePermissionKeys: [],
}))
})
describe('Tracing overview panel permissions', () => {
describe('Tracing overview panel', () => {
beforeEach(() => {
testState.appPermissionKeys = []
testState.workspacePermissionKeys = []
testState.configButtonProps = []
mockedFetchTracingStatus.mockResolvedValue({
enabled: false,
@@ -116,7 +80,7 @@ describe('Tracing overview panel permissions', () => {
vi.clearAllMocks()
})
it('marks tracing config as read-only without app monitor or workspace tracking permissions', async () => {
it('marks tracing config as read-only when requested by its owner', async () => {
await renderPanel()
await waitFor(() => {
@@ -127,23 +91,8 @@ describe('Tracing overview panel permissions', () => {
})
})
it('marks tracing config as read-only with app monitor permission only', async () => {
testState.appPermissionKeys = [AppACLPermission.Monitor]
await renderPanel()
await waitFor(() => {
expect(testState.configButtonProps[0]).toMatchObject({
readOnly: true,
hasConfigured: false,
})
})
})
it('allows tracing config when app ACL includes tracing config permission', async () => {
testState.appPermissionKeys = [AppACLPermission.TracingConfig]
await renderPanel()
it('allows tracing config when the owner grants write access', async () => {
await renderPanel(false)
await waitFor(() => {
expect(testState.configButtonProps[0]).toMatchObject({
@@ -17,53 +17,30 @@ import type { TracingStatus } from '@/models/app'
import { cn } from '@langgenius/dify-ui/cn'
import { Separator } from '@langgenius/dify-ui/separator'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
import { toast } from '@/app/notifications'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { usePathname } from '@/next/navigation'
import {
fetchTracingConfig as doFetchTracingConfig,
fetchTracingStatus,
updateTracingStatus,
} from '@/service/apps'
import { getAppACLCapabilities } from '@/utils/permission'
import ConfigButton from './config-button'
import TracingIcon from './tracing-icon'
import { TracingProvider } from './type'
const I18N_PREFIX = 'tracing'
const Panel: FC = () => {
const { t } = useTranslation(['app', 'common'])
const pathname = usePathname()
const matched = /\/app\/([^/]+)/.exec(pathname)
const appId = matched?.length && matched[1] ? matched[1] : ''
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile.id,
})
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const appDetail = useAppStore((s) => s.appDetail)
const appACLCapabilities = React.useMemo(
() =>
getAppACLCapabilities(appDetail?.permission_keys, {
currentUserId,
resourceMaintainer: appDetail?.maintainer,
workspacePermissionKeys,
}),
[appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys],
)
const canConfigTracing = appACLCapabilities.canConfigureTracing
const readOnly = !canConfigTracing
type PanelProps = {
appId: string
readOnly: boolean
}
const Panel: FC<PanelProps> = ({ appId, readOnly }) => {
const { t } = useTranslation(['app', 'common'])
const [isLoaded, { setTrue: setLoaded }] = useBoolean(false)
const [tracingStatus, setTracingStatus] = useState<TracingStatus | null>(null)
@@ -1,12 +1,12 @@
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useSuspenseQueries } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import ApikeyInfoPanel from '@/app/components/app/overview/apikey-info-panel'
import { useStore as useAppStore } from '@/app/components/app/store'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { consoleQuery } from '@/service/console'
import { getAppACLCapabilities } from '@/utils/permission'
import ChartView from './chart-view'
import TracingPanel from './tracing/panel'
@@ -16,23 +16,27 @@ type OverviewViewProps = {
}
const OverviewView = ({ appId }: OverviewViewProps) => {
const appDetail = useAppStore((state) => state.appDetail)
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile.id,
const [{ data: appDetail }, { data: userProfile }] = useSuspenseQueries({
queries: [
consoleQuery.apps.byAppId.get.queryOptions({
input: { params: { app_id: appId } },
}),
userProfileQueryOptions(),
],
})
const currentUserId = userProfile.profile.id
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const appACLCapabilities = React.useMemo(
() =>
getAppACLCapabilities(appDetail?.permission_keys, {
getAppACLCapabilities(appDetail.permission_keys, {
currentUserId,
resourceMaintainer: appDetail?.maintainer,
resourceMaintainer: appDetail.maintainer,
workspacePermissionKeys,
}),
[appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys],
[appDetail.maintainer, appDetail.permission_keys, currentUserId, workspacePermissionKeys],
)
if (!appDetail || !appACLCapabilities.canMonitor) return null
if (!appACLCapabilities.canMonitor) return null
return (
<div className="flex h-full min-h-0 flex-col">
@@ -40,7 +44,12 @@ const OverviewView = ({ appId }: OverviewViewProps) => {
<div className="min-h-0 flex-1">
<ChartView
appId={appId}
headerRight={appACLCapabilities.canConfigureTracing ? <TracingPanel /> : null}
appMode={appDetail.mode}
headerRight={
appACLCapabilities.canConfigureTracing ? (
<TracingPanel appId={appId} readOnly={false} />
) : null
}
/>
</div>
</div>