diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/annotations/page.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/annotations/page.tsx index fb22b843176..3252ab6cd18 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/annotations/page.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/annotations/page.tsx @@ -1,13 +1,21 @@ -import * as React from 'react' +import { Suspense } from 'react' import Main from '@/app/components/app/log-annotation' import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type' +import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton' -export type IProps = { - params: Promise<{ appId: string }> +export default async function Page({ params }: { params: Promise<{ appId: string }> }) { + const { appId } = await params + return ( + + + + + + } + > +
+ + ) } - -const Logs = async () => { - return
-} - -export default Logs diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/logs/page.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/logs/page.tsx index c5536f03a11..aebcac0c2eb 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/logs/page.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/logs/page.tsx @@ -1,9 +1,21 @@ -import * as React from 'react' +import { Suspense } from 'react' import Main from '@/app/components/app/log-annotation' import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type' +import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton' -const Logs = async () => { - return
+export default async function Page({ params }: { params: Promise<{ appId: string }> }) { + const { appId } = await params + return ( + + + + + + } + > +
+ + ) } - -export default Logs diff --git a/web/app/components/app/log-annotation/__tests__/index.spec.tsx b/web/app/components/app/log-annotation/__tests__/index.spec.tsx index 09200aab8f4..939c69e9086 100644 --- a/web/app/components/app/log-annotation/__tests__/index.spec.tsx +++ b/web/app/components/app/log-annotation/__tests__/index.spec.tsx @@ -1,7 +1,10 @@ import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' -import { render, screen } from '@testing-library/react' -import { useStore as useAppStore } from '@/app/components/app/store' +import { screen } from '@testing-library/react' +import { Suspense } from 'react' +import ErrorBoundary from '@/app/components/base/error-boundary' import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type' +import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app' import { AppModeEnum } from '@/types/app' import LogAnnotation from '../index' @@ -33,31 +36,49 @@ const createMockApp = (overrides: Partial = {}): AppDetailWit ...overrides, }) +let appDetail: AppDetailWithSite | undefined +const render = (ui: Parameters[0]) => { + const queryClient = createConsoleQueryClient() + if (appDetail) + queryClient.setQueryData( + consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: appDetail.id } } }), + appDetail, + ) + return renderWithConsoleQuery(Loading logs}>{ui}, { + queryClient, + }) +} + +vi.mock('@/service/base', async (importOriginal) => ({ + ...(await importOriginal()), + request: vi.fn(() => new Promise(() => {})), +})) + describe('LogAnnotation', () => { beforeEach(() => { vi.clearAllMocks() - useAppStore.setState({ appDetail: createMockApp() }) + appDetail = createMockApp() }) // Rendering behavior describe('Rendering', () => { it('should render loading state when app detail is missing', () => { // Arrange - useAppStore.setState({ appDetail: undefined }) + appDetail = undefined // Act - render() + render() // Assert - expect(screen.getByRole('progressbar')).toBeInTheDocument() + expect(screen.getByText('Loading logs')).toBeInTheDocument() }) it('should render log content without the old page tabs', () => { // Arrange - useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) }) + appDetail = createMockApp({ mode: AppModeEnum.CHAT }) // Act - render() + render() // Assert expect(screen.getByRole('region', { name: 'App log' })).toBeInTheDocument() @@ -67,10 +88,10 @@ describe('LogAnnotation', () => { it('should render completion logs without the old page tabs', () => { // Arrange - useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.COMPLETION }) }) + appDetail = createMockApp({ mode: AppModeEnum.COMPLETION }) // Act - render() + render() // Assert expect(screen.getByRole('region', { name: 'App log' })).toBeInTheDocument() @@ -80,10 +101,10 @@ describe('LogAnnotation', () => { it('should hide tabs and render workflow log in workflow mode', () => { // Arrange - useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.WORKFLOW }) }) + appDetail = createMockApp({ mode: AppModeEnum.WORKFLOW }) // Act - render() + render() // Assert expect(screen.queryByText('appLog.title')).not.toBeInTheDocument() @@ -95,10 +116,10 @@ describe('LogAnnotation', () => { describe('Props', () => { it('should render log content when page type is log', () => { // Arrange - useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) }) + appDetail = createMockApp({ mode: AppModeEnum.CHAT }) // Act - render() + render() // Assert expect(screen.getByRole('region', { name: 'App log' })).toBeInTheDocument() @@ -107,10 +128,10 @@ describe('LogAnnotation', () => { it('should render annotation content when page type is annotation', () => { // Arrange - useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) }) + appDetail = createMockApp({ mode: AppModeEnum.CHAT }) // Act - render() + render() // Assert expect(screen.getByRole('region', { name: 'Annotation log' })).toBeInTheDocument() @@ -118,3 +139,45 @@ describe('LogAnnotation', () => { }) }) }) + +it('shows the destination log mode and app identity after navigation', () => { + appDetail = createMockApp({ mode: 'chat' }) + const { queryClient, rerender } = render( + , + ) + queryClient.setQueryData( + consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: 'workflow-app' } } }), + createMockApp({ id: 'workflow-app', mode: 'workflow' }), + ) + expect(screen.getByRole('region', { name: 'App log' })).toHaveTextContent('app-123') + rerender() + expect(screen.getByRole('region', { name: 'Workflow log' })).toHaveTextContent('workflow-app') + expect(screen.queryByRole('region', { name: 'App log' })).not.toBeInTheDocument() +}) + +it('surfaces an initial detail error instead of staying in the loading state', async () => { + const queryClient = createConsoleQueryClient() + const queryKey = consoleQuery.apps.byAppId.get.queryKey({ + input: { params: { app_id: 'unavailable-app' } }, + }) + await queryClient + .query({ queryKey, queryFn: () => Promise.reject(new Error('Detail unavailable')) }) + .catch(() => {}) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + renderWithConsoleQuery( + + Loading logs}> + + + , + { queryClient }, + ) + expect( + await screen.findByRole('button', { name: 'common.errorBoundary.tryAgain' }), + ).toBeInTheDocument() + expect(screen.queryByText('Loading logs')).not.toBeInTheDocument() + } finally { + consoleError.mockRestore() + } +}) diff --git a/web/app/components/app/log-annotation/index.tsx b/web/app/components/app/log-annotation/index.tsx index 37c0115fe76..325d8f36b6e 100644 --- a/web/app/components/app/log-annotation/index.tsx +++ b/web/app/components/app/log-annotation/index.tsx @@ -1,28 +1,25 @@ 'use client' import type { FC } from 'react' +import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import Annotation from '@/app/components/app/annotation' import Log from '@/app/components/app/log' -import { useStore as useAppStore } from '@/app/components/app/store' import WorkflowLog from '@/app/components/app/workflow-log' import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type' -import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' +import { consoleQuery } from '@/service/console' import { AppModeEnum } from '@/types/app' type Props = Readonly<{ + appId: string pageType: PageType }> -const LogAnnotation: FC = ({ pageType }) => { - const appDetail = useAppStore((state) => state.appDetail) - - if (!appDetail) { - return ( -
- -
- ) - } +const LogAnnotation: FC = ({ appId, pageType }) => { + const { data: appDetail } = useSuspenseQuery( + consoleQuery.apps.byAppId.get.queryOptions({ + input: { params: { app_id: appId } }, + }), + ) return (
diff --git a/web/app/components/app/workflow-log/__tests__/detail.spec.tsx b/web/app/components/app/workflow-log/__tests__/detail.spec.tsx index e3b024689e8..d275c885b53 100644 --- a/web/app/components/app/workflow-log/__tests__/detail.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/detail.spec.tsx @@ -1,4 +1,3 @@ -import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' /** * DetailPanel Component Tests * @@ -10,8 +9,6 @@ import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.g */ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useStore as useAppStore } from '@/app/components/app/store' -import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app' import DetailPanel from '../detail' // ============================================================================ @@ -54,29 +51,13 @@ vi.mock('ahooks', () => ({ }, })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - -const createMockApp = (overrides: Partial = {}): AppDetailWithSite => - createAppDetailFixture({ - id: 'test-app-id', - name: 'Test App', - mode: 'workflow', - site: createAppSiteFixture({ access_token: 'token', app_base_url: 'https://example.com' }), - ...overrides, - }) - -// ============================================================================ -// Tests -// ============================================================================ - describe('DetailPanel', () => { const defaultOnClose = vi.fn() + let appId = 'test-app-id' beforeEach(() => { vi.clearAllMocks() - useAppStore.setState({ appDetail: createMockApp() }) + appId = 'test-app-id' }) // -------------------------------------------------------------------------- @@ -84,21 +65,21 @@ describe('DetailPanel', () => { // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render workflow title', () => { - render() + render() expect(screen.getByText('appLog.runDetail.workflowTitle')).toBeInTheDocument() }) it('should render close button', () => { - render() + render() expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() }) it('should render Run component with correct URLs', () => { - useAppStore.setState({ appDetail: createMockApp({ id: 'app-456' }) }) + appId = 'app-456' - render() + render() expect(screen.getByTestId('workflow-run')).toBeInTheDocument() expect(screen.getByTestId('run-detail-url')).toHaveTextContent( @@ -110,7 +91,7 @@ describe('DetailPanel', () => { }) it('should render WorkflowContextProvider wrapper', () => { - render() + render() expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument() }) @@ -121,7 +102,7 @@ describe('DetailPanel', () => { // -------------------------------------------------------------------------- describe('Props', () => { it('should not render replay button when canReplay is false (default)', () => { - render() + render() expect( screen.queryByRole('button', { name: 'appLog.runDetail.testWithParams' }), @@ -129,7 +110,9 @@ describe('DetailPanel', () => { }) it('should render replay button when canReplay is true', () => { - render() + render( + , + ) expect( screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' }), @@ -137,7 +120,7 @@ describe('DetailPanel', () => { }) it('should use empty URL when runID is empty', () => { - render() + render() expect(screen.getByTestId('run-detail-url')).toHaveTextContent('') expect(screen.getByTestId('tracing-list-url')).toHaveTextContent('') @@ -152,7 +135,7 @@ describe('DetailPanel', () => { const user = userEvent.setup() const onClose = vi.fn() - render() + render() const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) @@ -163,9 +146,16 @@ describe('DetailPanel', () => { it('should navigate to workflow page with replayRunId when replay button is clicked', async () => { const user = userEvent.setup() - useAppStore.setState({ appDetail: createMockApp({ id: 'app-replay-test' }) }) + appId = 'app-replay-test' - render() + render( + , + ) const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' }) await user.click(replayButton) @@ -174,18 +164,6 @@ describe('DetailPanel', () => { '/app/app-replay-test/workflow?replayRunId=run-to-replay', ) }) - - it('should not navigate when replay clicked but appDetail is missing', async () => { - const user = userEvent.setup() - useAppStore.setState({ appDetail: undefined }) - - render() - - const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' }) - await user.click(replayButton) - - expect(mockRouterPush).not.toHaveBeenCalled() - }) }) // -------------------------------------------------------------------------- @@ -193,9 +171,9 @@ describe('DetailPanel', () => { // -------------------------------------------------------------------------- describe('URL Generation', () => { it('should generate correct run detail URL', () => { - useAppStore.setState({ appDetail: createMockApp({ id: 'my-app' }) }) + appId = 'my-app' - render() + render() expect(screen.getByTestId('run-detail-url')).toHaveTextContent( '/apps/my-app/workflow-runs/my-run', @@ -203,9 +181,9 @@ describe('DetailPanel', () => { }) it('should generate correct tracing list URL', () => { - useAppStore.setState({ appDetail: createMockApp({ id: 'my-app' }) }) + appId = 'my-app' - render() + render() expect(screen.getByTestId('tracing-list-url')).toHaveTextContent( '/apps/my-app/workflow-runs/my-run/node-executions', @@ -213,9 +191,9 @@ describe('DetailPanel', () => { }) it('should handle special characters in runID', () => { - useAppStore.setState({ appDetail: createMockApp({ id: 'app-id' }) }) + appId = 'app-id' - render() + render() expect(screen.getByTestId('run-detail-url')).toHaveTextContent( '/apps/app-id/workflow-runs/run-with-special-123', @@ -223,36 +201,12 @@ describe('DetailPanel', () => { }) }) - // -------------------------------------------------------------------------- - // Store Integration Tests - // -------------------------------------------------------------------------- - describe('Store Integration', () => { - it('should read appDetail from store', () => { - useAppStore.setState({ appDetail: createMockApp({ id: 'store-app-id' }) }) - - render() - - expect(screen.getByTestId('run-detail-url')).toHaveTextContent( - '/apps/store-app-id/workflow-runs/run-123', - ) - }) - - it('should handle undefined appDetail from store gracefully', () => { - useAppStore.setState({ appDetail: undefined }) - - render() - - // Run component should still render but with undefined in URL - expect(screen.getByTestId('workflow-run')).toBeInTheDocument() - }) - }) - // -------------------------------------------------------------------------- // Edge Cases (REQUIRED) // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle empty runID', () => { - render() + render() expect(screen.getByTestId('run-detail-url')).toHaveTextContent('') expect(screen.getByTestId('tracing-list-url')).toHaveTextContent('') @@ -260,9 +214,9 @@ describe('DetailPanel', () => { it('should handle very long runID', () => { const longRunId = 'a'.repeat(100) - useAppStore.setState({ appDetail: createMockApp({ id: 'app-id' }) }) + appId = 'app-id' - render() + render() expect(screen.getByTestId('run-detail-url')).toHaveTextContent( `/apps/app-id/workflow-runs/${longRunId}`, @@ -270,14 +224,18 @@ describe('DetailPanel', () => { }) it('should render replay button with correct aria-label', () => { - render() + render( + , + ) const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' }) expect(replayButton).toHaveAttribute('aria-label', 'appLog.runDetail.testWithParams') }) it('should maintain proper component structure', () => { - const { container } = render() + const { container } = render( + , + ) // Check for main container with flex layout const mainContainer = container.querySelector('.flex.grow.flex-col') @@ -294,7 +252,9 @@ describe('DetailPanel', () => { // -------------------------------------------------------------------------- describe('Tooltip', () => { it('should have tooltip on replay button', () => { - render() + render( + , + ) // The replay button should be wrapped in TooltipPlus const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' }) diff --git a/web/app/components/app/workflow-log/__tests__/list.spec.tsx b/web/app/components/app/workflow-log/__tests__/list.spec.tsx index 224b088f90a..3a672ae8e9e 100644 --- a/web/app/components/app/workflow-log/__tests__/list.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/list.spec.tsx @@ -13,7 +13,6 @@ import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } fr import type { AppModeEnum } from '@/types/app' import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useStore as useAppStore } from '@/app/components/app/store' import { APP_PAGE_LIMIT } from '@/config' import { WorkflowRunTriggeredFrom } from '@/models/log' import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app' @@ -150,7 +149,6 @@ describe('WorkflowAppLogList', () => { beforeEach(() => { vi.clearAllMocks() - useAppStore.setState({ appDetail: createMockApp() }) }) // -------------------------------------------------------------------------- @@ -447,7 +445,7 @@ describe('WorkflowAppLogList', () => { describe('Drawer', () => { it('should open drawer when clicking on a log row', async () => { const user = userEvent.setup() - useAppStore.setState({ appDetail: createMockApp({ id: 'app-123' }) }) + const logs = createMockLogsResponse([ createMockWorkflowLog({ id: 'log-1', @@ -473,7 +471,7 @@ describe('WorkflowAppLogList', () => { const user = userEvent.setup() const appDetail = createMockApp() const onRefresh = vi.fn() - useAppStore.setState({ appDetail }) + const logs = createMockLogsResponse([ createMockWorkflowLog({ id: 'log-1', created_at: 100 }), createMockWorkflowLog({ id: 'log-2', created_at: 200 }), @@ -508,7 +506,7 @@ describe('WorkflowAppLogList', () => { it('should close drawer and call onRefresh when closing', async () => { const user = userEvent.setup() const onRefresh = vi.fn() - useAppStore.setState({ appDetail: createMockApp() }) + const logs = createMockLogsResponse([createMockWorkflowLog()]) render() @@ -588,7 +586,7 @@ describe('WorkflowAppLogList', () => { describe('Replay Functionality', () => { it('should allow replay when triggered from app-run', async () => { const user = userEvent.setup() - useAppStore.setState({ appDetail: createMockApp({ id: 'app-replay' }) }) + const logs = createMockLogsResponse([ createMockWorkflowLog({ workflow_run: createMockWorkflowRun({ @@ -612,13 +610,13 @@ describe('WorkflowAppLogList', () => { await user.click(replayButton) expect(mockRouterPush).toHaveBeenCalledWith( - '/app/app-replay/workflow?replayRunId=run-to-replay', + '/app/test-app-id/workflow?replayRunId=run-to-replay', ) }) it('should allow replay when triggered from debugging', async () => { const user = userEvent.setup() - useAppStore.setState({ appDetail: createMockApp({ id: 'app-debug' }) }) + const logs = createMockLogsResponse([ createMockWorkflowLog({ workflow_run: createMockWorkflowRun({ @@ -644,7 +642,7 @@ describe('WorkflowAppLogList', () => { it('should not show replay for webhook triggers', async () => { const user = userEvent.setup() - useAppStore.setState({ appDetail: createMockApp({ id: 'app-webhook' }) }) + const logs = createMockLogsResponse([ createMockWorkflowLog({ workflow_run: createMockWorkflowRun({ diff --git a/web/app/components/app/workflow-log/detail.tsx b/web/app/components/app/workflow-log/detail.tsx index 8bbe0cfd880..2d773e13260 100644 --- a/web/app/components/app/workflow-log/detail.tsx +++ b/web/app/components/app/workflow-log/detail.tsx @@ -3,25 +3,23 @@ import type { FC } from 'react' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiCloseLine, RiPlayLargeLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' -import { useStore } from '@/app/components/app/store' import { WorkflowContextProvider } from '@/app/components/workflow/context' import Run from '@/app/components/workflow/run' import { useRouter } from '@/next/navigation' type ILogDetail = { + appId: string runID: string onClose: () => void canReplay?: boolean } -const DetailPanel: FC = ({ runID, onClose, canReplay = false }) => { +const DetailPanel: FC = ({ appId, runID, onClose, canReplay = false }) => { const { t } = useTranslation(['appLog', 'common']) - const appDetail = useStore((state) => state.appDetail) const router = useRouter() const handleReplay = () => { - if (!appDetail?.id) return - router.push(`/app/${appDetail.id}/workflow?replayRunId=${runID}`) + router.push(`/app/${appId}/workflow?replayRunId=${runID}`) } return ( @@ -60,10 +58,8 @@ const DetailPanel: FC = ({ runID, onClose, canReplay = false }) => {
diff --git a/web/app/components/app/workflow-log/list.tsx b/web/app/components/app/workflow-log/list.tsx index c3558dbb266..3dc585d15eb 100644 --- a/web/app/components/app/workflow-log/list.tsx +++ b/web/app/components/app/workflow-log/list.tsx @@ -290,6 +290,7 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => {