Skip to content

Commit 32a466f

Browse files
committed
fix(dashboard): scope the logs and metrics tabs to the selected app
The provider keeps a query's previous result while its key changes, so while the next application's GET is pending the detail page still holds the previous instance. The Logs and Metrics tabs took the application name from that instance, so their proxy root did not change and the previous application's lines and series stayed under the new heading until the GET completed. Take the name from the route instead, which is the identity the user selected, and cover the pending GET at the route level with the real page, router and production query defaults. Assisted-by: LLM Signed-off-by: ZverGuy <[email protected]>
1 parent 89194b4 commit 32a466f

4 files changed

Lines changed: 125 additions & 10 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest"
2+
import { act, render, screen, waitFor } from "@testing-library/react"
3+
import { createMemoryRouter, RouterProvider } from "react-router"
4+
import { K8sProvider } from "@cozystack/k8s-client"
5+
import { TenantContext, type TenantContextValue } from "../../lib/tenant-context.tsx"
6+
import type { ChartData } from "../../lib/observability/metrics.ts"
7+
8+
// Keep the consoles' and the chart's browser-only libraries out of jsdom; the
9+
// Logs and Metrics tabs, the page and the providers stay real.
10+
vi.mock("./VncTab.tsx", () => ({ VncTab: () => null }))
11+
vi.mock("./SerialTab.tsx", () => ({ SerialTab: () => null }))
12+
vi.mock("../../components/observability/TimeSeriesChart.tsx", () => ({
13+
TimeSeriesChart: ({ data }: { data: ChartData }) => (
14+
<div data-testid="chart">{data.series.map((s) => s.label).join(",")}</div>
15+
),
16+
}))
17+
18+
const { ApplicationDetailPage } = await import("./ApplicationDetailPage.tsx")
19+
20+
const NS = "tenant-test"
21+
const APPS = `/apis/apps.cozystack.io/v1alpha1/namespaces/${NS}/postgreses`
22+
23+
const tenant: TenantContextValue = {
24+
tenants: [],
25+
selectedTenant: "test",
26+
selectTenant: () => {},
27+
tenantNamespace: NS,
28+
isLoading: false,
29+
error: null,
30+
}
31+
32+
const definitions = {
33+
items: [{ metadata: { name: "postgres" }, spec: { application: { plural: "postgreses", kind: "Postgres" } } }],
34+
}
35+
36+
const instance = (name: string) => ({
37+
apiVersion: "apps.cozystack.io/v1alpha1",
38+
kind: "Postgres",
39+
metadata: { name, namespace: NS },
40+
})
41+
42+
const logLine = (name: string) =>
43+
`${JSON.stringify({ _time: "2026-11-04T10:00:01Z", _msg: `${name} is ready`, _stream_id: "s", kubernetes_pod_name: `${name}-0`, kubernetes_container_name: "postgres" })}\n`
44+
45+
const matrix = (name: string) =>
46+
Response.json({ status: "success", data: { resultType: "matrix", result: [{ metric: { pod: `${name}-0` }, values: [[1, "1"]] }] } })
47+
48+
// app-b's own GET never answers, so the page keeps the placeholder of app-a
49+
// that the production query defaults hand it; its proxy reads do answer.
50+
function handle(url: URL): Response | Promise<Response> {
51+
const path = url.pathname
52+
if (path.endsWith("/applicationdefinitions")) return Response.json(definitions)
53+
if (path === `${APPS}/app-a`) return Response.json(instance("app-a"))
54+
if (path === `${APPS}/app-b`) return new Promise(() => {})
55+
const app = path.match(/\/postgreses\/(app-[ab])\//)?.[1]
56+
if (app && path.endsWith("/select/logsql/query")) return new Response(logLine(app))
57+
if (app && path.endsWith("/stream_field_values")) return Response.json({ values: [] })
58+
if (app && path.includes("/metrics/api/v1/query_range")) return matrix(app)
59+
return Response.json({ items: [] })
60+
}
61+
62+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => handle(new URL(String(input), "http://x")))
63+
64+
function renderAt(path: string) {
65+
const router = createMemoryRouter(
66+
[{ path: "/console/:plural/:name/*", element: <ApplicationDetailPage /> }],
67+
{ initialEntries: [path] },
68+
)
69+
// No queryClient: the provider's production defaults keep a query's previous
70+
// result while its key changes, which is what exposes the stale instance.
71+
render(
72+
<K8sProvider>
73+
<TenantContext.Provider value={tenant}>
74+
<RouterProvider router={router} />
75+
</TenantContext.Provider>
76+
</K8sProvider>,
77+
)
78+
return { go: (to: string) => act(() => router.navigate(to)) }
79+
}
80+
81+
const requested = (prefix: string) =>
82+
fetchMock.mock.calls.some(([input]) => new URL(String(input), "http://x").pathname.startsWith(prefix))
83+
84+
beforeEach(() => {
85+
vi.stubGlobal("fetch", fetchMock)
86+
fetchMock.mockClear()
87+
})
88+
89+
describe("ApplicationDetailPage observability while the next application loads", () => {
90+
it("shows the selected application's logs, not the previous one's", async () => {
91+
const { go } = renderAt("/console/postgreses/app-a/logs")
92+
expect(await screen.findByText("app-a is ready")).toBeInTheDocument()
93+
94+
await go("/console/postgreses/app-b/logs")
95+
96+
expect(screen.getByRole("heading", { name: "app-b" })).toBeInTheDocument()
97+
expect(screen.queryByText("app-a is ready")).toBeNull()
98+
await waitFor(() => expect(requested(`${APPS}/app-b/log/`)).toBe(true))
99+
expect(await screen.findByText("app-b is ready")).toBeInTheDocument()
100+
})
101+
102+
it("shows the selected application's metrics, not the previous one's", async () => {
103+
const { go } = renderAt("/console/postgreses/app-a/metrics")
104+
await waitFor(() => expect(screen.getAllByTestId("chart")[0]).toHaveTextContent("app-a-0"))
105+
106+
await go("/console/postgreses/app-b/metrics")
107+
108+
expect(screen.getByRole("heading", { name: "app-b" })).toBeInTheDocument()
109+
expect(screen.queryAllByText("app-a-0", { exact: false })).toEqual([])
110+
await waitFor(() => expect(requested(`${APPS}/app-b/metrics/`)).toBe(true))
111+
await waitFor(() => expect(screen.getAllByTestId("chart")[0]).toHaveTextContent("app-b-0"))
112+
})
113+
})

‎packages/system/dashboard/images/console/apps/console/src/routes/detail/ApplicationDetailPage.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,11 +273,11 @@ export function ApplicationDetailPage() {
273273
path="metrics"
274274
element={
275275
<Suspense fallback={<Spinner />}>
276-
<MetricsTab ad={ad} instance={instance} />
276+
<MetricsTab ad={ad} name={name} />
277277
</Suspense>
278278
}
279279
/>
280-
<Route path="logs" element={<LogsTab ad={ad} instance={instance} />} />
280+
<Route path="logs" element={<LogsTab ad={ad} name={name} />} />
281281
</Routes>
282282
</div>
283283
</div>
Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
import type { ApplicationDefinition, ApplicationInstance } from "@cozystack/types"
1+
import type { ApplicationDefinition } from "@cozystack/types"
22
import { useTenantContext } from "../../lib/tenant-context.tsx"
33
import { LogsView } from "../../components/observability/LogsView.tsx"
44

55
interface LogsTabProps {
66
ad: ApplicationDefinition
7-
instance: ApplicationInstance
7+
// The route's name: while the next application loads, the page still holds the previous instance.
8+
name: string
89
}
910

10-
export function LogsTab({ ad, instance }: LogsTabProps) {
11+
export function LogsTab({ ad, name }: LogsTabProps) {
1112
const { tenantNamespace } = useTenantContext()
1213
const plural = ad.spec?.application.plural
1314
if (!tenantNamespace || !plural) return null
14-
return <LogsView scope={{ namespace: tenantNamespace, plural, name: instance.metadata.name }} />
15+
return <LogsView scope={{ namespace: tenantNamespace, plural, name }} />
1516
}
Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
import type { ApplicationDefinition, ApplicationInstance } from "@cozystack/types"
1+
import type { ApplicationDefinition } from "@cozystack/types"
22
import { useTenantContext } from "../../lib/tenant-context.tsx"
33
import { MetricsView } from "../../components/observability/MetricsView.tsx"
44

55
interface MetricsTabProps {
66
ad: ApplicationDefinition
7-
instance: ApplicationInstance
7+
// The route's name: while the next application loads, the page still holds the previous instance.
8+
name: string
89
}
910

10-
export function MetricsTab({ ad, instance }: MetricsTabProps) {
11+
export function MetricsTab({ ad, name }: MetricsTabProps) {
1112
const { tenantNamespace } = useTenantContext()
1213
const plural = ad.spec?.application.plural
1314
if (!tenantNamespace || !plural) return null
14-
return <MetricsView scope={{ namespace: tenantNamespace, plural, name: instance.metadata.name }} />
15+
return <MetricsView scope={{ namespace: tenantNamespace, plural, name }} />
1516
}

0 commit comments

Comments
 (0)