+ )}
);
}
diff --git a/src/components/ui/utils/badgeStatus.tsx b/src/components/ui/utils/badgeStatus.tsx
index f64ce7e2f..2dc8b87b4 100644
--- a/src/components/ui/utils/badgeStatus.tsx
+++ b/src/components/ui/utils/badgeStatus.tsx
@@ -27,7 +27,7 @@ export function renderBadgeStatusVariant(value: BadgeStatusVariant): BadgeStatus
}
switch (value) {
case 'STOPPED':
- return 'secondary';
+ return 'destructive';
case 'TERMINATING':
case 'TERMINATED':
case 'FAILED':
@@ -39,6 +39,22 @@ export function renderBadgeStatusVariant(value: BadgeStatusVariant): BadgeStatus
}
}
+/**
+ * The instance is stopped or mid container-lifecycle transition, so its ops API is unreachable.
+ * Callers use this to suppress per-instance status polling (get_status) until it's back up.
+ */
+export function isStoppedOrTransitioning(value: string | undefined): boolean {
+ switch (value) {
+ case 'STOPPED':
+ case 'STOPPING':
+ case 'STARTING':
+ case 'RESTARTING':
+ return true;
+ default:
+ return false;
+ }
+}
+
export function isRunning(value: string | undefined): value is 'RUNNING' | 'UPDATED' {
switch (value) {
case 'RUNNING':
diff --git a/src/features/admin/apiToken/index.tsx b/src/features/admin/apiToken/index.tsx
new file mode 100644
index 000000000..abb78c5a0
--- /dev/null
+++ b/src/features/admin/apiToken/index.tsx
@@ -0,0 +1,72 @@
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { useGenerateApiTokenMutation } from '@/features/admin/apiToken/mutations/useGenerateApiToken';
+import { useCopyTextToClipboard } from '@/hooks/useCopyToClipboard';
+import { ApiTokenResult } from '@/integrations/api/api.patch';
+import { CopyIcon, KeyRoundIcon } from 'lucide-react';
+import { useState } from 'react';
+
+export function ApiTokenIndex() {
+ const { mutate, isPending, reset } = useGenerateApiTokenMutation();
+ const [token, setToken] = useState(null);
+ const copyText = useCopyTextToClipboard();
+
+ const onGenerate = () => {
+ // The global MutationCache.onError (react-query/queryClient) already surfaces
+ // failures with a toast, so no local onError.
+ mutate(undefined, {
+ onSuccess: (result) => {
+ // Hold the credential in local component state only, then drop it from
+ // the shared MutationCache so it can't be read there or outlive the page.
+ setToken(result);
+ reset();
+ },
+ });
+ };
+
+ return (
+
+
API Token
+
+ Generate a short-lived token for programmatic API access. It authenticates as you, with your permissions. Send
+ it as a bearer token:{' '}
+ Authorization: Bearer <token>
+
+
+
+
+ {token && (
+
+
+ Your API token
+
+ Copy it now — it won't be shown again. Expires {new Date(token.expiresAt).toLocaleString()}.
+
+
+
+ copyText(token.operationToken)}
+ />
+
+
+
+ )}
+
+ );
+}
diff --git a/src/features/admin/apiToken/mutations/useGenerateApiToken.test.ts b/src/features/admin/apiToken/mutations/useGenerateApiToken.test.ts
new file mode 100644
index 000000000..6278398d2
--- /dev/null
+++ b/src/features/admin/apiToken/mutations/useGenerateApiToken.test.ts
@@ -0,0 +1,53 @@
+/** @vitest-environment jsdom */
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { createElement, ReactNode } from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const { post } = vi.hoisted(() => ({ post: vi.fn() }));
+vi.mock('@/config/apiClient', () => ({ apiClient: { post } }));
+
+import { generateApiToken, useGenerateApiTokenMutation } from './useGenerateApiToken';
+
+describe('generateApiToken', () => {
+ beforeEach(() => post.mockReset());
+
+ it('POSTs to /Admin/ApiToken and returns the token result', async () => {
+ post.mockResolvedValue({ data: { operationToken: 'op-tok', expiresAt: '2026-01-01T00:00:00.000Z' } });
+
+ const result = await generateApiToken();
+
+ expect(post).toHaveBeenCalledWith('/Admin/ApiToken', {});
+ expect(result).toEqual({ operationToken: 'op-tok', expiresAt: '2026-01-01T00:00:00.000Z' });
+ });
+});
+
+describe('useGenerateApiTokenMutation cache hygiene', () => {
+ beforeEach(() => post.mockReset());
+
+ const SECRET = 'secret-bearer-token';
+ const withClient = (client: QueryClient) => ({ children }: { children: ReactNode }) =>
+ createElement(QueryClientProvider, { client }, children);
+
+ const cacheContainsToken = (client: QueryClient) =>
+ JSON.stringify(client.getMutationCache().getAll().map((m) => m.state)).includes(SECRET);
+
+ it('keeps the bearer token out of the shared MutationCache (reset() clears it; gcTime:0 GCs on unmount)', async () => {
+ post.mockResolvedValue({ data: { operationToken: SECRET, expiresAt: '2026-01-01T00:00:00.000Z' } });
+ const client = new QueryClient();
+ const { result, unmount } = renderHook(() => useGenerateApiTokenMutation(), { wrapper: withClient(client) });
+
+ // Generate, then do what the page does: copy into local state and reset().
+ await act(async () => {
+ await result.current.mutateAsync();
+ result.current.reset();
+ });
+
+ // The credential must not remain readable via getMutationCache().
+ await waitFor(() => expect(cacheContainsToken(client)).toBe(false));
+
+ // And nothing lingers after the page unmounts (gcTime:0).
+ unmount();
+ expect(client.getMutationCache().getAll()).toHaveLength(0);
+ });
+});
diff --git a/src/features/admin/apiToken/mutations/useGenerateApiToken.ts b/src/features/admin/apiToken/mutations/useGenerateApiToken.ts
new file mode 100644
index 000000000..ec369c4bb
--- /dev/null
+++ b/src/features/admin/apiToken/mutations/useGenerateApiToken.ts
@@ -0,0 +1,26 @@
+import { apiClient } from '@/config/apiClient';
+import { ApiTokenResult } from '@/integrations/api/api.patch';
+import { useMutation } from '@tanstack/react-query';
+
+/**
+ * POST /Admin/ApiToken → a short-lived Bearer token for the SSO'd fabric admin.
+ *
+ * The endpoint isn't in the generated API types yet, so the URL and response are
+ * cast (same approach as getCurrentUser / updateUserMutation).
+ */
+export async function generateApiToken(): Promise {
+ const { data } = await apiClient.post('/Admin/ApiToken' as '/Cluster/', {});
+ return data as unknown as ApiTokenResult;
+}
+
+export function useGenerateApiTokenMutation() {
+ return useMutation({
+ mutationFn: generateApiToken,
+ // The result is a bearer credential. Don't let it linger in the shared
+ // MutationCache: that's readable via queryClient.getMutationCache(), shows
+ // in the mutation inspector, and survives logout (logoutOnSuccess clears
+ // only the QueryCache). gcTime:0 discards it the moment the observer
+ // unmounts; the page also reset()s right after copying it into local state.
+ gcTime: 0,
+ });
+}
diff --git a/src/features/admin/components/AdminShell.tsx b/src/features/admin/components/AdminShell.tsx
new file mode 100644
index 000000000..181c069ba
--- /dev/null
+++ b/src/features/admin/components/AdminShell.tsx
@@ -0,0 +1,34 @@
+import { SubNavItem, SubNavRail } from '@/components/SubNavRail';
+import { isFabricAdmin, useCloudAuth } from '@/hooks/useAuth';
+import { Navigate, Outlet } from '@tanstack/react-router';
+import { KeyRoundIcon } from 'lucide-react';
+
+/**
+ * Shell for the Admin section: a responsive sub-nav rail (so future admin
+ * endpoints slot in as new items) plus the active page. Gated to fabric_admin
+ * (matching the token endpoint's SSO-session contract — see isFabricAdmin); the
+ * dashboard route guard already handles the unauthenticated redirect.
+ */
+const items: SubNavItem[] = [
+ { to: '/admin', label: 'API Token', icon: KeyRoundIcon, exact: true },
+];
+
+export function AdminShell() {
+ const { isLoading, user } = useCloudAuth();
+
+ if (isLoading) { return null; }
+ if (!isFabricAdmin(user)) { return ; }
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/admin/routes.ts b/src/features/admin/routes.ts
new file mode 100644
index 000000000..ae27fbda0
--- /dev/null
+++ b/src/features/admin/routes.ts
@@ -0,0 +1,19 @@
+import { AdminShell } from '@/features/admin/components/AdminShell';
+import { dashboardLayout } from '@/router/dashboardRoute';
+import { createRoute, lazyRouteComponent } from '@tanstack/react-router';
+
+export const adminLayoutRoute = createRoute({
+ getParentRoute: () => dashboardLayout,
+ path: 'admin',
+ component: AdminShell,
+});
+
+const apiTokenRoute = createRoute({
+ getParentRoute: () => adminLayoutRoute,
+ path: '/',
+ head: () => ({ meta: [{ title: 'API Token — Harper Fabric' }] }),
+ component: lazyRouteComponent(async () => import('@/features/admin/apiToken/index'), 'ApiTokenIndex'),
+});
+
+// Parent: adminLayoutRoute (keep in lockstep with rootRouteTree's addChildren).
+export const adminRoutes = [apiTokenRoute];
diff --git a/src/features/auth/handlers/clearAuthStateLocally.ts b/src/features/auth/handlers/clearAuthStateLocally.ts
new file mode 100644
index 000000000..de2abaf58
--- /dev/null
+++ b/src/features/auth/handlers/clearAuthStateLocally.ts
@@ -0,0 +1,23 @@
+import { authStore } from '@/features/auth/store/authStore';
+import { clearLocalStorage } from '@/lib/storage/clearLocalStorage';
+import { clearSessionStorage } from '@/lib/storage/clearSessionStorage';
+import { queryClient } from '@/react-query/queryClient';
+
+/**
+ * Full LOCAL sign-out for a session already known-dead (e.g. a 401 from CM):
+ * clears all in-memory auth connections/tokens/flags, persisted storage, and
+ * BOTH React Query caches — WITHOUT posting a logout to CM or instances (the
+ * session is gone, so a network logout would only fail).
+ *
+ * This closes the cross-user gap where, after A's session expired, A's stale
+ * in-memory entity connections / Fabric tokens / cached queries survived a
+ * same-tab re-login as B. Clears the MutationCache too (logoutOnSuccess clears
+ * only the QueryCache), so no credential lingers there either.
+ */
+export function clearAuthStateLocally(): void {
+ authStore.signOutAllLocally();
+ queryClient.getMutationCache().clear();
+ queryClient.getQueryCache().clear();
+ clearLocalStorage();
+ clearSessionStorage();
+}
diff --git a/src/features/auth/hooks/useCloudSignIn.test.tsx b/src/features/auth/hooks/useCloudSignIn.test.tsx
new file mode 100644
index 000000000..b468e085c
--- /dev/null
+++ b/src/features/auth/hooks/useCloudSignIn.test.tsx
@@ -0,0 +1,103 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { AxiosError } from 'axios';
+import { PropsWithChildren } from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { useCloudSignIn } from './useCloudSignIn';
+
+// One shared apiClient mock backs both the login POST and the resend POST.
+const { post } = vi.hoisted(() => ({ post: vi.fn() }));
+vi.mock('@/config/apiClient', () => ({ apiClient: { post } }));
+
+// The hook only reaches for the router; give it inert doubles we can assert against.
+const { navigate } = vi.hoisted(() => ({ navigate: vi.fn() }));
+vi.mock('@tanstack/react-router', () => ({
+ useNavigate: () => navigate,
+ useRouter: () => ({ invalidate: vi.fn() }),
+ useSearch: () => ({}),
+}));
+
+vi.mock('sonner', () => ({
+ toast: { info: vi.fn(), error: vi.fn().mockReturnValue({ dismiss: vi.fn() }), dismiss: vi.fn() },
+}));
+
+// onSuccess-only integrations — never exercised by these error-path tests, mocked to keep imports inert.
+vi.mock('@/integrations/datadog/datadog', () => ({ loginSuccessDatadogAction: vi.fn() }));
+vi.mock('@/integrations/reo/reo', () => ({ reoClient: { identify: vi.fn() } }));
+
+import { apiClient } from '@/config/apiClient';
+import { toast } from 'sonner';
+
+const EMAIL = 'unverified@example.com';
+
+function axiosError(status: number, data: unknown): AxiosError {
+ return { isAxiosError: true, response: { status, data } } as AxiosError;
+}
+
+function wrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return ({ children }: PropsWithChildren) => {children}
+ ;
+}
+
+afterEach(() => vi.clearAllMocks());
+
+describe('useCloudSignIn — unverified email', () => {
+ it('resends verification, redirects to /verifying, and suppresses the generic error toast', async () => {
+ post.mockImplementation((url: string) => {
+ if (url === '/Login/') {
+ return Promise.reject(axiosError(403, { error: 'User has not verified email address' }));
+ }
+ if (url === '/ResendVerificationEmail/') { return Promise.resolve({ data: { email: EMAIL } }); }
+ return Promise.reject(new Error(`unexpected ${url}`));
+ });
+
+ const { result } = renderHook(() => useCloudSignIn(), { wrapper: wrapper() });
+ act(() => result.current.submitForm({ email: EMAIL, password: 'correct-horse' }));
+
+ await waitFor(() => expect(navigate).toHaveBeenCalledWith({ to: '/verifying?email=unverified%40example.com' }));
+ expect(apiClient.post).toHaveBeenCalledWith('/ResendVerificationEmail/', { email: EMAIL });
+ // The "we sent a link" toast only fires once the resend actually succeeds.
+ await waitFor(() => expect(toast.info).toHaveBeenCalled());
+ // The dead-end "Error" toast must NOT fire for this case.
+ expect(toast.error).not.toHaveBeenCalled();
+ });
+
+ it('still redirects (without claiming a link was sent) when the resend fails', async () => {
+ post.mockImplementation((url: string) => {
+ if (url === '/Login/') {
+ return Promise.reject(axiosError(403, { error: 'User has not verified email address' }));
+ }
+ // Resend fails (e.g. rate-limited) — the user must still reach /verifying, and we must
+ // NOT show a "we sent a link" toast that never happened.
+ return Promise.reject(axiosError(429, { error: 'Too many requests' }));
+ });
+
+ const { result } = renderHook(() => useCloudSignIn(), { wrapper: wrapper() });
+ act(() => result.current.submitForm({ email: EMAIL, password: 'correct-horse' }));
+
+ await waitFor(() => expect(navigate).toHaveBeenCalledWith({ to: '/verifying?email=unverified%40example.com' }));
+ await waitFor(() => expect(apiClient.post).toHaveBeenCalledWith('/ResendVerificationEmail/', { email: EMAIL }));
+ expect(toast.info).not.toHaveBeenCalled();
+ });
+
+ it('shows the standard error toast (no redirect, no resend) for invalid credentials', async () => {
+ post.mockImplementation((url: string) =>
+ url === '/Login/'
+ ? Promise.reject(axiosError(401, { error: 'Invalid email or password' }))
+ : Promise.reject(new Error(`unexpected ${url}`))
+ );
+
+ const { result } = renderHook(() => useCloudSignIn(), { wrapper: wrapper() });
+ act(() => result.current.submitForm({ email: EMAIL, password: 'wrong' }));
+
+ await waitFor(() => expect(toast.error).toHaveBeenCalled());
+ expect(navigate).not.toHaveBeenCalled();
+ expect(apiClient.post).not.toHaveBeenCalledWith('/ResendVerificationEmail/', expect.anything());
+ });
+});
diff --git a/src/features/auth/hooks/useCloudSignIn.ts b/src/features/auth/hooks/useCloudSignIn.ts
index c42c2f42e..4c5a46891 100644
--- a/src/features/auth/hooks/useCloudSignIn.ts
+++ b/src/features/auth/hooks/useCloudSignIn.ts
@@ -1,4 +1,5 @@
import { apiClient } from '@/config/apiClient';
+import { isEmailNotVerifiedError } from '@/features/auth/isEmailNotVerifiedError';
import { currentUserQueryKey } from '@/features/auth/queries/getCurrentUser';
import { authStore, OverallAppSignIn } from '@/features/auth/store/authStore';
import { User } from '@/integrations/api/api.patch';
@@ -8,10 +9,13 @@ import { reoClient } from '@/integrations/reo/reo';
import { parseCompanyFromEmail } from '@/lib/string/parseCompanyFromEmail';
import { clearUtmParamsFromUrl } from '@/lib/urls/clearUtmParams';
import { getDefaultSignedInCloudRouteForUser } from '@/lib/urls/getDefaultSignedInCloudRouteForUser';
+import { errorHandler } from '@/react-query/queryClient';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useRouter, useSearch } from '@tanstack/react-router';
import { useCallback } from 'react';
+import { toast } from 'sonner';
import { z } from 'zod';
+import { useResendEmailVerification } from './useResendEmailVerification';
export function useCloudSignIn() {
const navigate = useNavigate();
@@ -20,6 +24,7 @@ export function useCloudSignIn() {
const { redirect } = useSearch({ strict: false });
const { mutate: submitLoginData, isPending } = useLoginMutation();
+ const { mutate: resendEmailVerification } = useResendEmailVerification();
const submitForm = useCallback((formData: z.infer) => {
submitLoginData(formData, {
@@ -40,8 +45,29 @@ export function useCloudSignIn() {
void router.invalidate();
await navigate({ to: redirect?.startsWith('/') ? redirect : defaultCloudRoute });
},
+ // The login mutation opts out of the global error toast (meta.skipGlobalErrorToast) so we
+ // can special-case the unverified-email rejection instead of dead-ending on a red toast.
+ onError: (error) => {
+ if (isEmailNotVerifiedError(error)) {
+ // This rejection only happens after central-manager accepts the password, so the
+ // credentials are valid — resending a fresh link here can't be abused for spam. Only
+ // claim "we sent a link" once the resend actually succeeds; navigate regardless so an
+ // unverified user always lands on /verifying (which offers a manual resend) rather than
+ // dead-ending on the sign-in page.
+ resendEmailVerification({ email: formData.email }, {
+ onSuccess: () =>
+ toast.info('Verify your email to finish signing in', {
+ description: `We sent a new verification link to ${formData.email}.`,
+ }),
+ });
+ void navigate({ to: '/verifying?email=' + encodeURIComponent(formData.email) });
+ return;
+ }
+ // Any other failure keeps the standard error toast.
+ errorHandler(error);
+ },
});
- }, [navigate, queryClient, redirect, router, submitLoginData]);
+ }, [navigate, queryClient, redirect, resendEmailVerification, router, submitLoginData]);
return {
isPending,
@@ -52,6 +78,9 @@ export function useCloudSignIn() {
function useLoginMutation() {
return useMutation>({
mutationFn: (loginData) => onLoginSubmit(loginData),
+ // submitForm renders the login-error UX itself (redirecting unverified users into the
+ // email-verification flow), so suppress the default global error toast for this mutation.
+ meta: { skipGlobalErrorToast: true },
});
}
diff --git a/src/features/auth/isEmailNotVerifiedError.test.ts b/src/features/auth/isEmailNotVerifiedError.test.ts
new file mode 100644
index 000000000..1de2d3516
--- /dev/null
+++ b/src/features/auth/isEmailNotVerifiedError.test.ts
@@ -0,0 +1,46 @@
+import { AxiosError } from 'axios';
+import { describe, expect, it } from 'vitest';
+import { isEmailNotVerifiedError } from './isEmailNotVerifiedError';
+
+// Shapes a rejection the way axios surfaces a central-manager error response.
+function axiosError(status: number, data: unknown): AxiosError {
+ return { isAxiosError: true, response: { status, data } } as AxiosError;
+}
+
+describe('isEmailNotVerifiedError', () => {
+ it('matches the 403 "User has not verified email address" rejection (data.error)', () => {
+ expect(isEmailNotVerifiedError(axiosError(403, { error: 'User has not verified email address' }))).toBe(true);
+ });
+
+ it('matches when the message is under data.message', () => {
+ expect(isEmailNotVerifiedError(axiosError(403, { message: 'User has not verified email address' }))).toBe(true);
+ });
+
+ it('matches when the body is a bare string', () => {
+ expect(isEmailNotVerifiedError(axiosError(403, 'User has not verified email address'))).toBe(true);
+ });
+
+ it('matches a nested structured error object', () => {
+ expect(isEmailNotVerifiedError(axiosError(403, { error: { message: 'User has not verified email address' } })))
+ .toBe(true);
+ });
+
+ it('does NOT match a 403 deactivated-account rejection (same status, different reason)', () => {
+ expect(isEmailNotVerifiedError(axiosError(403, { error: 'User account deactivated' }))).toBe(false);
+ });
+
+ it('does NOT match a 401 invalid-credentials rejection', () => {
+ expect(isEmailNotVerifiedError(axiosError(401, { error: 'Invalid email or password' }))).toBe(false);
+ });
+
+ it('does NOT match a 409 conflict', () => {
+ expect(isEmailNotVerifiedError(axiosError(409, { error: 'Multiple user@example.com records found' }))).toBe(false);
+ });
+
+ it('is safe on non-axios / empty errors', () => {
+ expect(isEmailNotVerifiedError(new Error('Something went wrong'))).toBe(false);
+ expect(isEmailNotVerifiedError(undefined)).toBe(false);
+ expect(isEmailNotVerifiedError(null)).toBe(false);
+ expect(isEmailNotVerifiedError(axiosError(403, undefined))).toBe(false);
+ });
+});
diff --git a/src/features/auth/isEmailNotVerifiedError.ts b/src/features/auth/isEmailNotVerifiedError.ts
new file mode 100644
index 000000000..bd704de46
--- /dev/null
+++ b/src/features/auth/isEmailNotVerifiedError.ts
@@ -0,0 +1,26 @@
+import { errorText } from '@/lib/errorText';
+import { isAxiosError } from 'axios';
+
+/**
+ * Detects the cloud-login rejection that means "this account exists and the password was
+ * correct, but the email address hasn't been verified yet".
+ *
+ * central-manager's `Login` resource checks verification *after* validating the password
+ * (so an attacker with a wrong password can't distinguish an unverified account from a bad
+ * one) and throws HTTP 403 with the message "User has not verified email address". A
+ * *deactivated* account also comes back as 403, so we additionally match the message — a
+ * bare status check would wrongly funnel deactivated users into the email-verification flow.
+ *
+ * Because this state is only reachable once the password has been accepted, callers can treat
+ * it as proof of valid credentials (e.g. safe to auto-resend a verification link).
+ */
+export function isEmailNotVerifiedError(error: unknown): boolean {
+ if (!isAxiosError(error) || error.response?.status !== 403) {
+ return false;
+ }
+ const data = error.response.data as string | { error?: unknown; message?: unknown } | undefined;
+ const message = typeof data === 'string'
+ ? data
+ : errorText(data?.error) ?? errorText(data?.message) ?? '';
+ return /verif/i.test(message);
+}
diff --git a/src/features/auth/store/authStore.ts b/src/features/auth/store/authStore.ts
index dfd022ac2..b863d4463 100644
--- a/src/features/auth/store/authStore.ts
+++ b/src/features/auth/store/authStore.ts
@@ -421,6 +421,26 @@ class AuthStore {
this.updateConnectionIfChanged(id, false, null);
}
+ /**
+ * Local-only full sign-out: clears every in-memory connection, Fabric token,
+ * and flag for ALL entities, plus the cloud slot — WITHOUT posting a logout to
+ * CM or any instance. For a session already known-dead (e.g. a 401 from CM),
+ * where the in-memory maps would otherwise survive until a page reload and a
+ * same-tab re-login could inherit the prior user's connections/tokens/cache.
+ * Distinct from signOutFromPotentiallyAuthenticatedInstances, which also posts
+ * per-instance logouts.
+ */
+ public signOutAllLocally(): void {
+ // Snapshot keys first — signOutLocally mutates the flags as it goes.
+ for (const id of Object.keys(this.potentiallyAuthenticated)) {
+ this.signOutLocally(id);
+ }
+ this.fabricConnectAuth.clear();
+ this.fabricConnectInFlight.clear();
+ this.operationTokenRefreshInFlight.clear();
+ this.setUserForEntity(OverallAppSignIn, null);
+ }
+
private calculateKeyFromEntity(entity: EntityTypes): AuthenticatedConnectionKey | undefined {
if (isLocalStudio || entity === OverallAppSignIn) {
return OverallAppSignIn;
diff --git a/src/features/auth/store/signOutAllLocally.test.ts b/src/features/auth/store/signOutAllLocally.test.ts
new file mode 100644
index 000000000..5225542f8
--- /dev/null
+++ b/src/features/auth/store/signOutAllLocally.test.ts
@@ -0,0 +1,35 @@
+/** @vitest-environment jsdom */
+// jsdom: authStore touches localStorage at module load and throughout.
+import { beforeEach, describe, expect, it } from 'vitest';
+import { authStore, OverallAppSignIn } from './authStore';
+
+type SetArgs = Parameters;
+
+describe('authStore.signOutAllLocally', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ sessionStorage.clear();
+ });
+
+ it('clears the cloud slot and every entity connection/flag locally, without a network logout', () => {
+ // Establish A's cloud session plus an authenticated instance with a Fabric flag.
+ authStore.setUserForIdAndKey(OverallAppSignIn, OverallAppSignIn, { id: 'usr_a' } as SetArgs[2]);
+ authStore.setUserForIdAndKey(
+ 'inst_1' as SetArgs[0],
+ 'https://inst-1' as SetArgs[1],
+ { username: 'u' } as SetArgs[2],
+ );
+ authStore.flagForFabricConnect('inst_1' as SetArgs[0], true);
+
+ expect(authStore.getConnectionById(OverallAppSignIn).user).not.toBeNull();
+ expect(authStore.getConnectionById('inst_1' as SetArgs[0]).user).not.toBeNull();
+ expect(authStore.checkForFabricConnect('inst_1' as SetArgs[0])).toBe(true);
+
+ authStore.signOutAllLocally();
+
+ // Cross-user leak guard: nothing of A's survives in memory or storage.
+ expect(authStore.getConnectionById(OverallAppSignIn).user).toBeNull();
+ expect(authStore.getConnectionById('inst_1' as SetArgs[0]).user).toBeNull();
+ expect(authStore.checkForFabricConnect('inst_1' as SetArgs[0])).toBe(false);
+ });
+});
diff --git a/src/features/cluster/ClusterHome.tsx b/src/features/cluster/ClusterHome.tsx
index 917027510..823157df6 100644
--- a/src/features/cluster/ClusterHome.tsx
+++ b/src/features/cluster/ClusterHome.tsx
@@ -6,6 +6,7 @@ import { getInstanceClient } from '@/config/getInstanceClient';
import { authStore } from '@/features/auth/store/authStore';
import { ClusterPageLayout } from '@/features/cluster/components/ClusterPageLayout';
import { getClusterInfoQueryOptions } from '@/features/cluster/queries/getClusterInfoQuery';
+import { ClusterStateMenu } from '@/features/clusters/components/ClusterStateMenu';
import { useInstanceAuth } from '@/hooks/useAuth';
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
import { useOrganizationClusterPermissions } from '@/hooks/usePermissions';
@@ -17,7 +18,18 @@ import { getOperationsUrlForCluster } from '@/lib/urls/getOperationsUrlForCluste
import { getOperationsUrlForInstance } from '@/lib/urls/getOperationsUrlForInstance';
import { useQuery } from '@tanstack/react-query';
import { Link, Navigate, useNavigate, useParams, useRouter } from '@tanstack/react-router';
-import { ArrowRight, CircleCheck, Copy, ExternalLink, KeyRound, Loader2, Rocket, Server, Zap } from 'lucide-react';
+import {
+ ArrowRight,
+ CircleCheck,
+ Copy,
+ ExternalLink,
+ KeyRound,
+ LifeBuoy,
+ Loader2,
+ Rocket,
+ Server,
+ Zap,
+} from 'lucide-react';
import { ComponentType, ReactNode, useCallback, useMemo, useState } from 'react';
import { toast } from 'sonner';
@@ -120,6 +132,14 @@ export function ClusterHome() {
.filter((instance) => instance.status && !deletedClusterStatuses.includes(instance.status))
.length;
+ // The cluster is "fully in safe mode" only when it's up and every instance reports safe mode — a
+ // stopped/transitioning instance has no safeMode flag, so a partial cluster won't qualify.
+ const clusterInstances = cluster.instances ?? [];
+ const allInSafeMode = !!cluster.status
+ && activeClusterStatuses.includes(cluster.status)
+ && clusterInstances.length > 0
+ && clusterInstances.every((instance) => instance.safeMode);
+
return (
@@ -130,6 +150,7 @@ export function ClusterHome() {
);
},
diff --git a/src/features/cluster/useInstanceMenuItems.tsx b/src/features/cluster/useInstanceMenuItems.tsx
index 9d2fea5a9..392061c47 100644
--- a/src/features/cluster/useInstanceMenuItems.tsx
+++ b/src/features/cluster/useInstanceMenuItems.tsx
@@ -1,11 +1,14 @@
import type { EntityMenuItem } from '@/components/ui/entityMenu';
+import { isStoppedOrTransitioning } from '@/components/ui/utils/badgeStatus';
import { defaultInstanceRoute } from '@/config/constants';
import { useInstanceClient, useInstanceClientIdParams } from '@/config/useInstanceClient';
import { authStore } from '@/features/auth/store/authStore';
import { signOutOfInstance } from '@/features/cluster/signOutOfInstance';
+import { SafeModeConfirmDialog } from '@/features/clusters/components/SafeModeConfirmDialog';
import { calculateInstanceFQDN } from '@/features/clusters/upsert/lib/calculateInstanceFQDN';
import { useInstanceAuth } from '@/hooks/useAuth';
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
+import { useInstanceContainerOps } from '@/hooks/useInstanceContainerOps';
import { useOrganizationClusterInstancePermissions } from '@/hooks/usePermissions';
import { Instance } from '@/integrations/api/api.patch';
import { getStatusQueryOptions, getSystemStatusById } from '@/integrations/api/instance/status/getStatus';
@@ -13,8 +16,19 @@ import { useSetStatus } from '@/integrations/api/instance/status/setStatus';
import { excludeFalsy } from '@/lib/arrays/excludeFalsy';
import { getOperationsUrlForInstance } from '@/lib/urls/getOperationsUrlForInstance';
import { useQuery } from '@tanstack/react-query';
-import { ClipboardIcon, LogInIcon, LogOutIcon, ServerIcon, ShieldCheckIcon, ShieldXIcon } from 'lucide-react';
-import { useCallback, useMemo } from 'react';
+import {
+ ClipboardIcon,
+ LifeBuoyIcon,
+ LogInIcon,
+ LogOutIcon,
+ PlayIcon,
+ RotateCwIcon,
+ ServerIcon,
+ ShieldCheckIcon,
+ ShieldXIcon,
+ SquareIcon,
+} from 'lucide-react';
+import { type ReactNode, useCallback, useMemo, useState } from 'react';
const READY_STATUSES = ['CLONE_READY', 'RUNNING', 'UPDATED', 'PENDING_UPGRADE'];
@@ -31,7 +45,7 @@ export function useInstanceMenuItems(
instance: Instance,
isSelfManaged: boolean,
enabled: boolean,
-): EntityMenuItem[] {
+): { items: EntityMenuItem[]; dialog: ReactNode } {
const { user: instanceUser } = useInstanceAuth(instance.id);
const operationsUrl = useMemo(() => getOperationsUrlForInstance(instance), [instance]);
const instanceClient = useInstanceClient({ operationsUrl });
@@ -39,11 +53,17 @@ export function useInstanceMenuItems(
const isFabricConnect = authStore.checkForFabricConnect(instance.id);
const statusParams = useInstanceClientIdParams({ operationsUrl, instanceId: instance.id, forceFabricConnect: true });
- const { data: statusResponse } = useQuery(getStatusQueryOptions(statusParams, enabled && canManage));
+ const { data: statusResponse } = useQuery(
+ getStatusQueryOptions(statusParams, enabled && canManage && !isStoppedOrTransitioning(instance.status)),
+ );
const systemStatus = getSystemStatusById(statusResponse, 'availability') || 'Unknown';
const isAvailable = systemStatus === 'Available';
const isUnavailable = systemStatus === 'Unavailable';
const { mutate: setStatus, isPending: isSettingStatus } = useSetStatus();
+ const { run: runContainerOp, isPending: isContainerOpPending } = useInstanceContainerOps(instance);
+ // Safe mode is jargon for a recovery action that drops user apps/components — explain it at the
+ // point of use, same as the cluster path. Both safe-mode entries route through one dialog.
+ const [safeModeAction, setSafeModeAction] = useState<'start' | 'restart' | null>(null);
const fqdn = instance.instanceFqdn;
const apiUrl = calculateInstanceFQDN({
@@ -63,6 +83,14 @@ export function useInstanceMenuItems(
const hasCopy = !!fqdn;
const hasRotation = canManage && isReady && (isAvailable || isUnavailable);
+ // Container lifecycle ops (stop/start/restart) — distinct from the proxied Harper "restart".
+ // Only offered from a settled RUNNING/STOPPED state; hidden mid-transition (the instances poll
+ // reveals the resting state and the actions reappear). Self-hosted clusters have no managed
+ // container lifecycle (Harper doesn't control their runtime), so the group is hidden for them.
+ const isRunning = instance.status === 'RUNNING';
+ const isStopped = instance.status === 'STOPPED';
+ const hasContainerOps = canManage && !isSelfManaged && (isRunning || isStopped);
+
const actions: EntityMenuItem[] = [
hasAuth && isDirectlyLoggedIn && {
key: 'direct-connect',
@@ -110,15 +138,85 @@ export function useInstanceMenuItems(
icon: ,
label: 'Bring back into rotation',
},
+
+ (hasAuth || hasCopy || hasRotation) && hasContainerOps && { type: 'separator' as const, key: 'container-sep' },
+ hasContainerOps && {
+ type: 'label' as const,
+ key: 'container-label',
+ className: 'text-gray-600 text-xs',
+ label: 'Container',
+ },
+ hasContainerOps && isStopped && {
+ key: 'container-start',
+ disabled: isContainerOpPending,
+ onClick: () => void runContainerOp('start', { safeMode: false }),
+ icon: ,
+ label: 'Start',
+ },
+ hasContainerOps && isStopped && {
+ key: 'container-start-safe',
+ disabled: isContainerOpPending,
+ onClick: () => setSafeModeAction('start'),
+ icon: ,
+ label: 'Start in safe mode',
+ },
+ hasContainerOps && isRunning && {
+ key: 'container-restart',
+ disabled: isContainerOpPending,
+ onClick: () => void runContainerOp('restart', { safeMode: false }),
+ icon: ,
+ label: 'Restart',
+ },
+ hasContainerOps && isRunning && {
+ key: 'container-restart-safe',
+ disabled: isContainerOpPending,
+ onClick: () => setSafeModeAction('restart'),
+ icon: ,
+ label: 'Restart in safe mode',
+ },
+ hasContainerOps && isRunning && {
+ key: 'container-stop',
+ variant: 'destructive' as const,
+ disabled: isContainerOpPending,
+ onClick: () => void runContainerOp('stop'),
+ icon: ,
+ label: 'Stop',
+ },
].filter(excludeFalsy);
+ const dialog = (
+ {
+ if (!isOpen) { setSafeModeAction(null); }
+ }}
+ action={safeModeAction ?? 'restart'}
+ targetName={instance.name ?? instance.id}
+ scope="instance"
+ isPending={isContainerOpPending}
+ onConfirm={() => {
+ const action = safeModeAction;
+ setSafeModeAction(null);
+ if (action) {
+ void runContainerOp(action, {
+ safeMode: true,
+ label: action === 'start' ? 'Starting in safe mode' : 'Restarting in safe mode',
+ });
+ }
+ }}
+ />
+ );
+
if (!actions.length) {
- return [];
+ return { items: [], dialog };
}
- return [
- { type: 'label', key: 'label', className: 'text-gray-600 text-xs', label: 'Options' },
- { type: 'separator', key: 'label-sep' },
- ...actions,
- ];
+ return {
+ items: [
+ { type: 'label', key: 'label', className: 'text-gray-600 text-xs', label: 'Options' },
+ { type: 'separator', key: 'label-sep' },
+ ...actions,
+ ],
+ dialog,
+ };
}
diff --git a/src/features/clusters/components/ClusterCard.tsx b/src/features/clusters/components/ClusterCard.tsx
index abf587562..391dac3d5 100644
--- a/src/features/clusters/components/ClusterCard.tsx
+++ b/src/features/clusters/components/ClusterCard.tsx
@@ -10,13 +10,17 @@ import { useInstanceClient } from '@/config/useInstanceClient';
import { authStore } from '@/features/auth/store/authStore';
import { getClusterInfo } from '@/features/cluster/queries/getClusterInfoQuery';
import { ClusterCardAction } from '@/features/clusters/components/ClusterCardAction';
+import { ClusterContainerOpModals } from '@/features/clusters/components/ClusterContainerOpModals';
import { ClusterProgress } from '@/features/clusters/components/ClusterProgress';
+import { SafeModeConfirmDialog } from '@/features/clusters/components/SafeModeConfirmDialog';
import { useTerminateClusterMutation } from '@/features/clusters/mutations/terminateCluster';
import { useInstanceAuth } from '@/hooks/useAuth';
+import { useClusterContainerOps } from '@/hooks/useClusterContainerOps';
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useOrganizationClusterPermissions } from '@/hooks/usePermissions';
import { Cluster } from '@/integrations/api/api.patch';
+import { ContainerStrategy } from '@/integrations/api/cluster/containerOperation';
import { clusterIsSelfManaged } from '@/integrations/api/clusterIsSelfManaged';
import { onInstanceLogoutSubmit } from '@/integrations/api/instance/auth/onInstanceLogoutSubmit';
import { excludeFalsy } from '@/lib/arrays/excludeFalsy';
@@ -32,9 +36,14 @@ import {
GitGraphIcon,
GlobeIcon,
KeyIcon,
+ LifeBuoyIcon,
+ Loader2,
+ PlayIcon,
RocketIcon,
+ RotateCwIcon,
ScaleIcon,
ServerIcon,
+ SquareIcon,
TrashIcon,
} from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
@@ -53,12 +62,33 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) {
const [signingOut, setSigningOut] = useState(false);
const [isTerminateClusterModalOpen, setIsTerminateClusterModalOpen] = useState(false);
+ const [stopConfirmOpen, setStopConfirmOpen] = useState(false);
+ const [restartDialogOpen, setRestartDialogOpen] = useState(false);
+ const [safeModeAction, setSafeModeAction] = useState<'start' | 'restart' | null>(null);
+ const { run: runClusterOp, isPending: isClusterOpPending } = useClusterContainerOps(cluster);
+
+ // Container-op availability by cluster state (see the per-instance menu for the same idea):
+ // RUNNING → Restart, Restart in safe mode, Stop STOPPED → Start, Start in safe mode
+ // PARTIAL → Start, Stop, Restart (some up, some down)
+ const isClusterRunning = cluster.status === 'RUNNING';
+ const isClusterStopped = cluster.status === 'STOPPED';
+ const isClusterPartial = cluster.status === 'PARTIAL';
+
+ // Temporary status badge on the card for container-op states. Transitional states tell the user
+ // what's happening (Stopping/Starting/Restarting) and clear on their own as the clusters list
+ // polls; STOPPED/PARTIAL are resting labels. RUNNING stays clean (no badge) on this route.
+ const isClusterTransitioning = cluster.status === 'STOPPING' || cluster.status === 'STARTING'
+ || cluster.status === 'RESTARTING';
+ const showContainerOpBadge = isClusterTransitioning || isClusterStopped || isClusterPartial;
const isActive = useMemo(
() => !!(cluster.status && activeClusterStatuses.includes(cluster.status)),
[cluster.status],
);
const isSelfManaged = clusterIsSelfManaged(cluster);
+ // Self-hosted clusters have no managed container lifecycle — Harper doesn't control their
+ // runtime — so the whole Container action group is hidden for them (matching ClusterStateMenu).
+ const showContainerActions = update && !isSelfManaged;
const isFabricConnect = authStore.checkForFabricConnect(cluster.id);
const isDirectConnect = !isFabricConnect && !!auth.user;
const isTerminated = useMemo(
@@ -137,7 +167,16 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) {
// The whole card opens the cluster home (overview) for the normal "Open" case — including
// self-hosted clusters, which get their own overview. A managed cluster with no FQDN yet opens its
// instances; resetPassword (→ Finish Setup / Pending) keeps its explicit CTA in ClusterCardAction.
- const cardHref = !isActive || !view || isTerminated
+ // Stopped/partial clusters aren't "active" but must still be reachable: a fully-stopped cluster
+ // opens its instances page (where you start them back up); a partial cluster (some instances still
+ // running) opens the cluster overview like a normal cluster.
+ const cardHref = !view || isTerminated
+ ? undefined
+ : cluster.status === 'STOPPED'
+ ? `/${cluster.organizationId}/${cluster.id}/instances`
+ : cluster.status === 'PARTIAL'
+ ? `/${cluster.organizationId}/${cluster.id}`
+ : !isActive
? undefined
: isSelfManaged
? `/${cluster.organizationId}/${cluster.id}`
@@ -197,7 +236,7 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) {
icon: ,
label: 'Instances',
},
- isActive && view && {
+ isActive && view && !!auth.user && {
key: 'deployments',
to: `${cluster.id}/config/deployments`,
disabled: signingOut,
@@ -205,6 +244,47 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) {
label: 'Deployments',
},
+ showContainerActions && (isClusterRunning || isClusterStopped || isClusterPartial)
+ && { type: 'separator' as const, key: 'container-separator' },
+ showContainerActions && (isClusterRunning || isClusterStopped || isClusterPartial)
+ && { type: 'label' as const, key: 'container-label', className: 'text-gray-600 text-xs', label: 'Container' },
+ showContainerActions && (isClusterStopped || isClusterPartial) && {
+ key: 'container-start',
+ disabled: isClusterOpPending,
+ onClick: () => void runClusterOp('start', { safeMode: false, strategy: 'parallel' }),
+ icon: ,
+ label: 'Start',
+ },
+ showContainerActions && isClusterStopped && {
+ key: 'container-start-safe',
+ disabled: isClusterOpPending,
+ onClick: () => setSafeModeAction('start'),
+ icon: ,
+ label: 'Start in safe mode',
+ },
+ showContainerActions && (isClusterRunning || isClusterPartial) && {
+ key: 'container-restart',
+ disabled: isClusterOpPending,
+ onClick: () => setRestartDialogOpen(true),
+ icon: ,
+ label: 'Restart',
+ },
+ showContainerActions && isClusterRunning && {
+ key: 'container-restart-safe',
+ disabled: isClusterOpPending,
+ onClick: () => setSafeModeAction('restart'),
+ icon: ,
+ label: 'Restart in safe mode',
+ },
+ showContainerActions && (isClusterRunning || isClusterPartial) && {
+ key: 'container-stop',
+ variant: 'destructive' as const,
+ disabled: isClusterOpPending,
+ onClick: () => setStopConfirmOpen(true),
+ icon: ,
+ label: 'Stop',
+ },
+
isActive && view && !!cluster.fqdn && { type: 'separator' as const, key: 'copy-separator' },
isActive && view && !!cluster.fqdn && {
key: 'copy-host-name',
@@ -296,6 +376,12 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) {
{isActive && view && }
+ {showContainerOpBadge && cluster.status && (
+
+ {isClusterTransitioning && }
+ {capitalizeWords(cluster.status)}
+
+ )}
{clusterHasFailed && cluster.status && (
<>
{capitalizeWords(cluster.status)}
@@ -315,6 +401,45 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) {
deletionConfirmed={handleTerminatedCluster}
deletionPending={isTerminateClusterPending}
/>
+
+ {
+ setStopConfirmOpen(false);
+ void runClusterOp('stop', { strategy: 'parallel' });
+ }}
+ restartOpen={restartDialogOpen}
+ setRestartOpen={setRestartDialogOpen}
+ onConfirmRestart={(strategy: ContainerStrategy) => {
+ setRestartDialogOpen(false);
+ void runClusterOp('restart', { safeMode: false, strategy });
+ }}
+ />
+
+ {
+ if (!isOpen) { setSafeModeAction(null); }
+ }}
+ action={safeModeAction ?? 'restart'}
+ targetName={cluster.name}
+ scope="cluster"
+ isPending={isClusterOpPending}
+ onConfirm={() => {
+ const action = safeModeAction;
+ setSafeModeAction(null);
+ if (action) {
+ void runClusterOp(action, {
+ safeMode: true,
+ strategy: 'parallel',
+ label: action === 'start' ? 'Starting in safe mode' : 'Restarting in safe mode',
+ });
+ }
+ }}
+ />
);
diff --git a/src/features/clusters/components/ClusterContainerOpModals.tsx b/src/features/clusters/components/ClusterContainerOpModals.tsx
new file mode 100644
index 000000000..5c72ce3db
--- /dev/null
+++ b/src/features/clusters/components/ClusterContainerOpModals.tsx
@@ -0,0 +1,98 @@
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { ContainerStrategy } from '@/integrations/api/cluster/containerOperation';
+
+/**
+ * Dialogs for cluster-wide container ops:
+ * - Stop confirmation (whole cluster goes offline).
+ * - Restart strategy picker (parallel vs rolling) — clicking a strategy dispatches the restart.
+ * State lives in the parent (ClusterCard), mirroring how the terminate modal is wired.
+ */
+export function ClusterContainerOpModals({
+ clusterName,
+ isPending,
+ stopOpen,
+ setStopOpen,
+ onConfirmStop,
+ restartOpen,
+ setRestartOpen,
+ onConfirmRestart,
+}: {
+ clusterName: string;
+ isPending: boolean;
+ stopOpen: boolean;
+ setStopOpen: (open: boolean) => void;
+ onConfirmStop: () => void;
+ restartOpen: boolean;
+ setRestartOpen: (open: boolean) => void;
+ onConfirmRestart: (strategy: ContainerStrategy) => void;
+}) {
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/src/features/clusters/components/ClusterStateMenu.tsx b/src/features/clusters/components/ClusterStateMenu.tsx
new file mode 100644
index 000000000..26a07d66f
--- /dev/null
+++ b/src/features/clusters/components/ClusterStateMenu.tsx
@@ -0,0 +1,173 @@
+import { ConfirmDeletionModal } from '@/components/ConfirmDeletionModal';
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdownMenu';
+import { ClusterContainerOpModals } from '@/features/clusters/components/ClusterContainerOpModals';
+import { SafeModeConfirmDialog } from '@/features/clusters/components/SafeModeConfirmDialog';
+import { useTerminateClusterMutation } from '@/features/clusters/mutations/terminateCluster';
+import { useClusterContainerOps } from '@/hooks/useClusterContainerOps';
+import { useOrganizationClusterPermissions } from '@/hooks/usePermissions';
+import { Cluster } from '@/integrations/api/api.patch';
+import { ContainerStrategy } from '@/integrations/api/cluster/containerOperation';
+import { clusterIsSelfManaged } from '@/integrations/api/clusterIsSelfManaged';
+import { useQueryClient } from '@tanstack/react-query';
+import { useRouter } from '@tanstack/react-router';
+import { ChevronDown, LifeBuoyIcon, PlayIcon, RotateCwIcon, SquareIcon, TrashIcon } from 'lucide-react';
+import { useCallback, useState } from 'react';
+import { toast } from 'sonner';
+
+/**
+ * "Cluster actions" dropdown (AWS EC2 "Instance state" style) for the cluster overview: every
+ * container lifecycle op plus Terminate in one discoverable, labeled place. Ops that don't apply to
+ * the current status are shown but disabled, so users can see the feature set exists. Self-hosted
+ * clusters have no container ops, so the control is hidden for them.
+ */
+export function ClusterStateMenu({ cluster }: { cluster: Cluster }) {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const { update, remove } = useOrganizationClusterPermissions(cluster.organizationId, cluster.id);
+ const { run: runClusterOp, isPending } = useClusterContainerOps(cluster);
+ const { mutate: terminateCluster, isPending: isTerminatePending } = useTerminateClusterMutation();
+
+ const [stopOpen, setStopOpen] = useState(false);
+ const [restartOpen, setRestartOpen] = useState(false);
+ const [terminateOpen, setTerminateOpen] = useState(false);
+ // Both safe-mode ops route through one explain-and-confirm dialog; this tracks which.
+ const [safeModeAction, setSafeModeAction] = useState<'start' | 'restart' | null>(null);
+
+ const isRunning = cluster.status === 'RUNNING';
+ const isStopped = cluster.status === 'STOPPED';
+ const isPartial = cluster.status === 'PARTIAL';
+ const opsDisabled = !update || isPending;
+
+ const onTerminate = useCallback(() => {
+ terminateCluster(cluster.id, {
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: [cluster.organizationId], refetchType: 'active' });
+ setTerminateOpen(false);
+ toast.success('Success', {
+ description: 'Cluster successfully terminated.',
+ action: { label: 'Dismiss', onClick: () => toast.dismiss() },
+ });
+ // The overview for a terminated cluster is a dead end — return to the clusters list.
+ void router.navigate({ to: `/${cluster.organizationId}` });
+ },
+ onError: () => {
+ // The global MutationCache.onError already toasts the failure (with the server's
+ // message); just close the modal here to avoid double-toasting.
+ setTerminateOpen(false);
+ },
+ });
+ }, [cluster.id, cluster.organizationId, terminateCluster, queryClient, router]);
+
+ const onConfirmSafeMode = useCallback(() => {
+ const action = safeModeAction;
+ setSafeModeAction(null);
+ if (action) {
+ void runClusterOp(action, {
+ safeMode: true,
+ strategy: 'parallel',
+ label: action === 'start' ? 'Starting in safe mode' : 'Restarting in safe mode',
+ });
+ }
+ }, [safeModeAction, runClusterOp]);
+
+ // Container ops are managed-cluster only; a user with neither permission gets no control.
+ if (clusterIsSelfManaged(cluster) || (!update && !remove)) { return null; }
+
+ return (
+ <>
+
+
+
+
+
+ Container
+ void runClusterOp('start', { safeMode: false, strategy: 'parallel' })}
+ >
+ Start
+
+ setSafeModeAction('start')}>
+ Start in safe mode
+
+ setRestartOpen(true)}
+ >
+ Restart
+
+ setSafeModeAction('restart')}>
+ Restart in safe mode
+
+ setStopOpen(true)}
+ >
+ Stop
+
+
+ setTerminateOpen(true)}>
+ Terminate
+
+
+
+
+ {
+ setStopOpen(false);
+ void runClusterOp('stop', { strategy: 'parallel' });
+ }}
+ restartOpen={restartOpen}
+ setRestartOpen={setRestartOpen}
+ onConfirmRestart={(strategy: ContainerStrategy) => {
+ setRestartOpen(false);
+ void runClusterOp('restart', { safeMode: false, strategy });
+ }}
+ />
+
+ {
+ if (!isOpen) { setSafeModeAction(null); }
+ }}
+ action={safeModeAction ?? 'restart'}
+ targetName={cluster.name}
+ scope="cluster"
+ isPending={isPending}
+ onConfirm={onConfirmSafeMode}
+ />
+
+
+ >
+ );
+}
diff --git a/src/features/clusters/components/SafeModeConfirmDialog.tsx b/src/features/clusters/components/SafeModeConfirmDialog.tsx
new file mode 100644
index 000000000..a135d757c
--- /dev/null
+++ b/src/features/clusters/components/SafeModeConfirmDialog.tsx
@@ -0,0 +1,75 @@
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { LifeBuoyIcon } from 'lucide-react';
+
+/**
+ * Explain-and-confirm dialog for the safe-mode ops. "Safe mode" is jargon and a recovery action, so
+ * rather than a hover tooltip (hover-only, easy to miss) we explain what it does at the moment of
+ * use. Shared by the start-in-safe-mode and restart-in-safe-mode entries at both cluster and
+ * instance scope — the explanation matters equally either way (see #1429 review).
+ */
+export function SafeModeConfirmDialog({
+ open,
+ setOpen,
+ action,
+ targetName,
+ scope,
+ isPending,
+ onConfirm,
+}: {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ action: 'start' | 'restart';
+ targetName: string;
+ scope: 'cluster' | 'instance';
+ isPending: boolean;
+ onConfirm: () => void;
+}) {
+ const verb = action === 'start' ? 'Start' : 'Restart';
+ const verbed = action === 'start' ? 'starts' : 'restarts';
+ return (
+
+ );
+}
diff --git a/src/features/clusters/queries/getHarperVersionsQuery.test.ts b/src/features/clusters/queries/getHarperVersionsQuery.test.ts
new file mode 100644
index 000000000..585ba0105
--- /dev/null
+++ b/src/features/clusters/queries/getHarperVersionsQuery.test.ts
@@ -0,0 +1,156 @@
+import { apiClient } from '@/config/apiClient';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ dedupeHarperVersionsByTag,
+ getHarperVersionsOptions,
+ HarperVersion,
+ HarperVersionsResponse,
+} from './getHarperVersionsQuery';
+
+vi.mock('@/config/apiClient', () => ({
+ apiClient: {
+ get: vi.fn(),
+ },
+}));
+
+const mockedGet = vi.mocked(apiClient.get);
+
+beforeEach(() => {
+ mockedGet.mockReset();
+});
+
+function tags(versions: HarperVersion[]) {
+ return versions.map(({ version, name }) => `${version} ${name}`);
+}
+
+describe('dedupeHarperVersionsByTag', () => {
+ it('keeps the most-preferred tag when a version appears more than once', () => {
+ const result = dedupeHarperVersionsByTag([
+ { name: 'next', version: '5.1.21' },
+ { name: 'stable', version: '5.1.21' },
+ ]);
+ expect(tags(result)).toEqual(['5.1.21 stable']);
+ });
+
+ it('discards a less-preferred tag that arrives after a more-preferred one', () => {
+ const result = dedupeHarperVersionsByTag([
+ { name: 'stable', version: '5.1.21' },
+ { name: 'next', version: '5.1.21' },
+ ]);
+ expect(tags(result)).toEqual(['5.1.21 stable']);
+ });
+
+ it('applies the full preference order stable > next > beta > alpha', () => {
+ const result = dedupeHarperVersionsByTag([
+ { name: 'alpha', version: '6.0.0' },
+ { name: 'beta', version: '6.0.0' },
+ { name: 'next', version: '6.0.0' },
+ ]);
+ expect(tags(result)).toEqual(['6.0.0 next']);
+ });
+
+ it('prefers any known tag over an unknown one', () => {
+ const result = dedupeHarperVersionsByTag([
+ { name: 'foobarbaz', version: '5.1.21' },
+ { name: 'alpha', version: '5.1.21' },
+ ]);
+ expect(tags(result)).toEqual(['5.1.21 alpha']);
+ });
+
+ it('keeps a single unknown tag when there is nothing better', () => {
+ const result = dedupeHarperVersionsByTag([
+ { name: 'foobarbaz', version: '5.1.21' },
+ ]);
+ expect(tags(result)).toEqual(['5.1.21 foobarbaz']);
+ });
+
+ it('keeps distinct versions untouched and preserves their order', () => {
+ const result = dedupeHarperVersionsByTag([
+ { name: 'stable', version: '5.1.21' },
+ { name: 'next', version: '5.2.0' },
+ { name: 'beta', version: '6.0.0' },
+ ]);
+ expect(tags(result)).toEqual(['5.1.21 stable', '5.2.0 next', '6.0.0 beta']);
+ });
+
+ it('keeps each version at the position of its first appearance', () => {
+ const result = dedupeHarperVersionsByTag([
+ { name: 'next', version: '5.1.21' },
+ { name: 'stable', version: '5.2.0' },
+ { name: 'stable', version: '5.1.21' },
+ ]);
+ expect(tags(result)).toEqual(['5.1.21 stable', '5.2.0 stable']);
+ });
+
+ it('handles an empty list', () => {
+ expect(dedupeHarperVersionsByTag([])).toEqual([]);
+ });
+});
+
+describe('getHarperVersionsOptions', () => {
+ it('scopes the cache key to the organization (org-first) and does not retry', () => {
+ const options = getHarperVersionsOptions('org_123');
+ // Org-first so it participates in org-scoped `invalidateQueries([organizationId])`.
+ expect(options.queryKey).toEqual(['org_123', 'HarperVersions']);
+ expect(options.staleTime).toBe(60_000);
+ expect(options.retry).toBe(false);
+ });
+
+ it('fetches the versions and dedupes overlapping tags, keeping the rest of the response', async () => {
+ mockedGet.mockResolvedValue({
+ data: {
+ name: 'Harper Versions',
+ description: 'Available Harper versions',
+ value: [
+ { name: 'next', version: '5.1.21' },
+ { name: 'stable', version: '5.1.21' },
+ { name: 'beta', version: '5.2.0' },
+ ],
+ } satisfies HarperVersionsResponse,
+ });
+
+ const options = getHarperVersionsOptions('org_1');
+ const result = await (options.queryFn as () => Promise)();
+
+ expect(mockedGet).toHaveBeenCalledWith('/HarperVersions/', { params: { organizationId: 'org_1' } });
+ expect(result).toEqual({
+ name: 'Harper Versions',
+ description: 'Available Harper versions',
+ value: [
+ { name: 'stable', version: '5.1.21' },
+ { name: 'beta', version: '5.2.0' },
+ ],
+ });
+ });
+
+ it('passes the organizationId through axios params (axios handles encoding)', async () => {
+ mockedGet.mockResolvedValue({
+ data: {
+ name: 'Harper Versions',
+ description: 'Available Harper versions',
+ value: [
+ { name: 'stable', version: '5.1.21' },
+ { name: 'deployed on prod-east', version: '5.0.8' },
+ ],
+ } satisfies HarperVersionsResponse,
+ });
+
+ const options = getHarperVersionsOptions('org/1');
+ const result = await (options.queryFn as () => Promise)();
+
+ expect(mockedGet).toHaveBeenCalledWith('/HarperVersions/', { params: { organizationId: 'org/1' } });
+ expect(tags(result.value)).toEqual(['5.1.21 stable', '5.0.8 deployed on prod-east']);
+ });
+
+ it('surfaces (rather than swallows) a malformed response with no value array', async () => {
+ // The endpoint's OpenAPI description is broken, so the `as HarperVersionsResponse` cast is not
+ // schema-backed. If it ever returns a body without `value`, the queryFn throws — React Query's
+ // QueryCache.onError surfaces it and every consumer null-checks `harperVersions?.value`, so it
+ // fails safely rather than silently rendering an empty picker.
+ mockedGet.mockResolvedValue({ data: { name: 'Harper Versions', description: '' } });
+
+ const options = getHarperVersionsOptions('org_1');
+
+ await expect((options.queryFn as () => Promise)()).rejects.toThrow();
+ });
+});
diff --git a/src/features/clusters/queries/getHarperVersionsQuery.ts b/src/features/clusters/queries/getHarperVersionsQuery.ts
index ebf7146a0..2bf6bbb26 100644
--- a/src/features/clusters/queries/getHarperVersionsQuery.ts
+++ b/src/features/clusters/queries/getHarperVersionsQuery.ts
@@ -7,21 +7,65 @@ export interface HarperVersionsResponse {
value: HarperVersion[];
}
-interface HarperVersion {
- name: 'stable' | 'next' | 'current';
+export interface HarperVersion {
+ /**
+ * Release tag for this version. `stable`, `next`, `beta`, and `alpha` come from the endpoint (which may
+ * also return tags we don't know about yet, hence the plain `string`); `current` is synthesized
+ * client-side for the version a cluster already runs.
+ */
+ name: string;
version: string;
}
-async function getHarperVersions() {
+/**
+ * Known release tags, most-preferred first. When the endpoint returns the same version under more than one
+ * tag we keep the most-preferred; any unrecognized tag ranks below all of these.
+ */
+export const HARPER_VERSION_TAG_PREFERENCE = ['stable', 'next', 'beta', 'alpha'] as const;
+
+function tagRank(name: string): number {
+ const index = (HARPER_VERSION_TAG_PREFERENCE as readonly string[]).indexOf(name);
+ return index === -1 ? HARPER_VERSION_TAG_PREFERENCE.length : index;
+}
+
+/**
+ * Collapse versions that share the same version string down to a single entry, keeping the one with the
+ * most-preferred tag (stable > next > beta > alpha > anything else). The endpoint can return, e.g.,
+ * `5.1.21` tagged both `stable` and `next`; the picker should only offer `stable`.
+ */
+export function dedupeHarperVersionsByTag(versions: HarperVersion[]): HarperVersion[] {
+ const bestByVersion = new Map();
+ for (const version of versions) {
+ const existing = bestByVersion.get(version.version);
+ if (!existing || tagRank(version.name) < tagRank(existing.name)) {
+ // Map preserves first-insertion order, so replacing the value keeps the version's original position.
+ bestByVersion.set(version.version, version);
+ }
+ }
+ return [...bestByVersion.values()];
+}
+
+async function getHarperVersions(organizationId: string) {
// TODO: OpenAPI from CM is erroring, so this new endpoint isn't described.
- const { data } = await apiClient.get(`/HarperVersions/` as any);
- return data as HarperVersionsResponse;
+ // The list is org-scoped: enterprise orgs also get the versions currently deployed on their
+ // clusters (labeled with the cluster) merged in server-side.
+ const { data } = await apiClient.get(`/HarperVersions/` as any, {
+ params: { organizationId },
+ });
+ const response = data as HarperVersionsResponse;
+ return {
+ ...response,
+ value: dedupeHarperVersionsByTag(response.value),
+ } satisfies HarperVersionsResponse;
}
-export function getHarperVersionsOptions() {
+export function getHarperVersionsOptions(organizationId: string) {
return queryOptions({
- queryKey: ['HarperVersions'],
- queryFn: getHarperVersions,
+ // Org-first, matching the sibling queries (getPlanTypesQuery, getRegionLocationsQuery, …) so
+ // org-scoped invalidations — `queryClient.invalidateQueries({ queryKey: [organizationId] })`,
+ // used after cluster ops that can change deployed versions — also refresh this list.
+ queryKey: [organizationId, 'HarperVersions'],
+ queryFn: () => getHarperVersions(organizationId),
staleTime: 60_000,
retry: false,
});
diff --git a/src/features/clusters/upsert/index.tsx b/src/features/clusters/upsert/index.tsx
index f9b6ae651..ca30713a3 100644
--- a/src/features/clusters/upsert/index.tsx
+++ b/src/features/clusters/upsert/index.tsx
@@ -73,12 +73,14 @@ export function UpsertCluster() {
}));
const { data: regionLocationsDedicated } = useQuery(getRegionLocationsOptions({ organizationId }));
- const { data: newHarperVersions } = useQuery(getHarperVersionsOptions());
+ const { data: newHarperVersions } = useQuery(getHarperVersionsOptions(organizationId));
const harperVersions = useMemo(() => {
if (cluster) {
const clusterVersions = cluster.instances?.map(i => i.version).filter(excludeFalsy);
if (newHarperVersions && clusterVersions) {
- const latestClusterVersion = clusterVersions.sort(compareVersions).pop();
+ // Copy before sort — sort mutates in place, and we reuse the full set below.
+ const latestClusterVersion = [...clusterVersions].sort(compareVersions).pop();
+ const clusterVersionSet = new Set(clusterVersions);
return {
...newHarperVersions,
value: [
@@ -87,13 +89,12 @@ export function UpsertCluster() {
version: latestClusterVersion,
} as const,
...(newHarperVersions?.value || []).filter(v => {
- // Is our version unique from the latest cluster version?
- return latestClusterVersion !== v.version
- // Do we have a cluster version?
- && (!latestClusterVersion
- // Or if we do, have we updated to a higher version already?
- // This can prevent upgrading to, say, "next" v5, and then downgrading to the "latest" v4.
- || wasAReleasedBeforeB(latestClusterVersion, v.version));
+ // Drop any version this cluster already runs: the current version is shown once as
+ // "current" above, and the backend also returns it (and any co-tenant instance's
+ // version, mid-upgrade) as a "deployed on " entry we don't want to duplicate.
+ return !clusterVersionSet.has(v.version)
+ // Only offer newer releases — no downgrades (e.g. don't drop from "next" v5 to "stable" v4).
+ && (!latestClusterVersion || wasAReleasedBeforeB(latestClusterVersion, v.version));
}),
].filter(excludeFalsy),
} satisfies HarperVersionsResponse;
diff --git a/src/features/instance/applications/components/TextEditorView/DegradedIntelliSenseBanner.test.tsx b/src/features/instance/applications/components/TextEditorView/DegradedIntelliSenseBanner.test.tsx
new file mode 100644
index 000000000..75fa35727
--- /dev/null
+++ b/src/features/instance/applications/components/TextEditorView/DegradedIntelliSenseBanner.test.tsx
@@ -0,0 +1,34 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { DegradedIntelliSenseBanner } from './DegradedIntelliSenseBanner';
+
+afterEach(cleanup);
+
+describe('DegradedIntelliSenseBanner (HarperFast/studio#1504)', () => {
+ it('announces politely rather than as an alert, so it is non-blocking', () => {
+ render();
+ expect(screen.getByRole('status').getAttribute('aria-live')).toBe('polite');
+ });
+
+ it('explains the budget degradation and its cause', () => {
+ render();
+ const text = screen.getByRole('status').textContent ?? '';
+ expect(text).toMatch(/cannot find module/i);
+ expect(text).toMatch(/references more packages/i);
+ });
+
+ it('explains the oversized-file degradation', () => {
+ render();
+ expect(screen.getByRole('status').textContent ?? '').toMatch(/plain text/i);
+ });
+
+ it('invokes onDismiss when the dismiss control is clicked', () => {
+ const onDismiss = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /dismiss notice/i }));
+ expect(onDismiss).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/features/instance/applications/components/TextEditorView/DegradedIntelliSenseBanner.tsx b/src/features/instance/applications/components/TextEditorView/DegradedIntelliSenseBanner.tsx
new file mode 100644
index 000000000..a34f53192
--- /dev/null
+++ b/src/features/instance/applications/components/TextEditorView/DegradedIntelliSenseBanner.tsx
@@ -0,0 +1,56 @@
+/**
+ * A thin, dismissible notice shown above the code editor when the editor has
+ * silently degraded its language features to stay responsive
+ * (HarperFast/studio#1504). Two modes exist, both otherwise invisible:
+ *
+ * - `budget`: the session-wide automatic type-acquisition budget is spent, so
+ * further packages report a spurious "cannot find module"
+ * (see `typeAcquisition.ts` / `ExtraLibBudget`).
+ * - `oversized`: the open file is over `MAX_WORKER_MODEL_CHARS`, so it renders
+ * as plaintext with no highlighting or IntelliSense.
+ *
+ * Non-blocking and dismissible (the caller owns the dismissed state so it can
+ * reset per file/mode); announced via `role="status"` / `aria-live="polite"`
+ * rather than a purely visual cue.
+ */
+import { InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react';
+
+export type IntelliSenseDegradation = 'budget' | 'oversized';
+
+const MESSAGES: Record = {
+ budget:
+ 'Type information is limited to keep the editor responsive — this application references more packages than the editor can load, so some imports may show “cannot find module.”',
+ oversized: 'This file is large, so it’s shown as plain text without language features.',
+};
+
+export interface DegradedIntelliSenseBannerProps {
+ variant: IntelliSenseDegradation;
+ onDismiss: () => void;
+}
+
+export function DegradedIntelliSenseBanner({
+ variant,
+ onDismiss,
+}: DegradedIntelliSenseBannerProps) {
+ const Icon = variant === 'oversized' ? InfoIcon : TriangleAlertIcon;
+ return (
+
+
+ {MESSAGES[variant]}
+
+
+ );
+}
diff --git a/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts b/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts
index a62fef921..6dd97821e 100644
--- a/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts
+++ b/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts
@@ -16,6 +16,7 @@
* blocked CDN, unknown package — is swallowed so it can never break editing.
*/
import { typescript } from '@/lib/monaco/languageServices';
+import { ExtraLibBudget } from '@/lib/monaco/workerLimits';
/** node builtins are not on npm; their types come from `@types/node` (not acquired here). */
const NODE_BUILTINS = new Set([
@@ -107,6 +108,12 @@ function createImportScannerShim(): unknown {
let runnerPromise: Promise<(source: string) => Promise> | undefined;
const acquiredPaths = new Set();
+/**
+ * Bounds the chars handed to the worker as extra libs. Session-lifetime and
+ * never reclaimed — the backstop against unbounded cross-project accumulation
+ * that would otherwise OOM the language worker (HarperFast/studio#1499).
+ */
+const extraLibBudget = new ExtraLibBudget();
function getRunner(): Promise<(source: string) => Promise> {
if (!runnerPromise) {
@@ -120,7 +127,28 @@ function getRunner(): Promise<(source: string) => Promise> {
if (acquiredPaths.has(path)) {
return;
}
+ // Mark it seen even when rejected, so a skipped lib isn't
+ // reconsidered on every subsequent acquisition pass.
acquiredPaths.add(path);
+ // Extra libs are eagerly cloned to the language worker
+ // (setEagerModelSync) and never swept; bound the total so a
+ // large or deep dependency graph — or a long multi-project
+ // session — can't OOM the worker's clone buffer (#1499).
+ const { admitted, justExhausted, oversize } = extraLibBudget.admit(code.length);
+ if (!admitted) {
+ // Leave a breadcrumb: without one, a user whose IntelliSense
+ // quietly stops resolving a package has no signal why.
+ if (justExhausted) {
+ console.warn(
+ '[harper] type acquisition: extra-lib budget exhausted; further packages will report "cannot find module" in this tab',
+ );
+ } else if (oversize) {
+ console.debug(
+ `[harper] type acquisition: skipped oversized declaration ${path} (${code.length} chars)`,
+ );
+ }
+ return;
+ }
const uri = `file://${path}`;
typescriptDefaults.addExtraLib(code, uri);
javascriptDefaults.addExtraLib(code, uri);
@@ -132,6 +160,17 @@ function getRunner(): Promise<(source: string) => Promise> {
return runnerPromise;
}
+/**
+ * Whether this session's automatic type-acquisition budget is exhausted. Once
+ * true it stays true — the budget is never reclaimed — so further packages are
+ * no longer acquired and their imports report a spurious "cannot find module"
+ * until the tab is reopened. Surfaced (rather than only `console.warn`'d) so the
+ * editor can show a user-facing degradation notice (HarperFast/studio#1504).
+ */
+export function isTypeAcquisitionBudgetSpent(): boolean {
+ return extraLibBudget.isSpent;
+}
+
/**
* Acquire npm `@types` for every package imported across the given source files.
* Best-effort and idempotent; safe to call on each project load.
@@ -140,6 +179,13 @@ export async function acquireApplicationTypes(sources: string[]): Promise
if (sources.length === 0) {
return;
}
+ // Once the aggregate budget is spent it is never reclaimed, so the
+ // acquisition engine would walk the CDN and parse declarations only for
+ // `receivedFile` to discard every one. Skip the whole pass — including its
+ // network requests — rather than pay for work that can't land.
+ if (extraLibBudget.isSpent) {
+ return;
+ }
try {
const run = await getRunner();
// One pass: the shim scans the combined source for every import at once.
diff --git a/src/features/instance/applications/components/TextEditorView/index.tsx b/src/features/instance/applications/components/TextEditorView/index.tsx
index 9d00c0496..24fac4593 100644
--- a/src/features/instance/applications/components/TextEditorView/index.tsx
+++ b/src/features/instance/applications/components/TextEditorView/index.tsx
@@ -14,6 +14,7 @@ import { MAX_WORKER_MODEL_CHARS } from '@/lib/monaco/workerLimits';
import { parseFileExtension } from '@/lib/string/parseFileExtension';
import type { EditorProps, OnMount } from '@monaco-editor/react';
import { useCallback, useEffect, useState } from 'react';
+import { DegradedIntelliSenseBanner, type IntelliSenseDegradation } from './DegradedIntelliSenseBanner';
import { configureHarperLanguageSupport } from './harper-language';
import './monaco-customizations.css';
@@ -64,7 +65,7 @@ export function TextEditorView() {
const instanceParams = useInstanceClientIdParams();
const { openedEntryContents, openedEntry, restrictPackageModification, isSavingFile, saveFile, rootEntries } =
useEditorView();
- useApplicationTypeIntelligence(openedEntry, rootEntries);
+ const { typeAcquisitionBudgetExhausted } = useApplicationTypeIntelligence(openedEntry, rootEntries);
const {
content: updatedFileContent,
setContent,
@@ -79,6 +80,11 @@ export function TextEditorView() {
const [mounted, setMounted] = useState | null>(null);
useCodeNavigation(mounted?.[0]);
+ // Which degradation notice the user has dismissed, keyed by `:`
+ // so it re-shows for a different file or a different degradation, but stays
+ // dismissed while the same file/mode is open.
+ const [dismissedBanner, setDismissedBanner] = useState(null);
+
const extension = parseFileExtension(openedEntry?.path);
const fileContent = updatedFileContent ?? openedEntryContents;
// A huge open file would feed its full text to the language worker the same
@@ -165,23 +171,52 @@ export function TextEditorView() {
const readOnly = isSavingFile || !!openedEntry.package || !canManageBrowseInstance;
+ // Surface either silent editor degradation as a dismissible notice. Oversized
+ // wins: the file is already plaintext, so the "cannot find module" wording
+ // wouldn't apply. The budget notice only fits a real (editable) script file,
+ // where a missing package's import would actually error.
+ const isScriptLanguage = language === 'javascript' || language === 'typescript';
+ const degradation: IntelliSenseDegradation | null = oversized
+ ? 'oversized'
+ : (typeAcquisitionBudgetExhausted && isScriptLanguage && !openedEntry.package)
+ ? 'budget'
+ : null;
+ const bannerKey = degradation ? `${openedEntry.path}:${degradation}` : null;
+ const showBanner = bannerKey !== null && dismissedBanner !== bannerKey;
+
return (
-
+ // `h-full` (not `min-h-full`) gives this column a *definite* height, so the
+ // editor's `height:100%` resolves; the `flex-1 min-h-0` wrapper then hands
+ // Monaco a concrete, banner-adjusted height it can lay out against.
+
);
}
diff --git a/src/features/instance/applications/hooks/useApplicationTypeIntelligence.ts b/src/features/instance/applications/hooks/useApplicationTypeIntelligence.ts
index c4f89432d..29e68b3c9 100644
--- a/src/features/instance/applications/hooks/useApplicationTypeIntelligence.ts
+++ b/src/features/instance/applications/hooks/useApplicationTypeIntelligence.ts
@@ -32,7 +32,10 @@
* worker (HarperFast/studio#1407).
*/
import { useInstanceClientIdParams } from '@/config/useInstanceClient';
-import { acquireApplicationTypes } from '@/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition';
+import {
+ acquireApplicationTypes,
+ isTypeAcquisitionBudgetSpent,
+} from '@/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition';
import { DirectoryEntry } from '@/features/instance/applications/context/directoryEntry';
import { FileEntry } from '@/features/instance/applications/context/fileEntry';
import { isDirectory } from '@/features/instance/applications/context/isDirectory';
@@ -47,7 +50,16 @@ import { typescript } from '@/lib/monaco/languageServices';
import { MAX_WORKER_MODEL_CHARS } from '@/lib/monaco/workerLimits';
import { useQueryClient } from '@tanstack/react-query';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js';
-import { useEffect, useMemo, useRef } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
+
+/** Degradation status the editor surfaces to the user (HarperFast/studio#1504). */
+export interface ApplicationTypeIntelligenceStatus {
+ /**
+ * The session-wide `@types` budget is spent, so further packages are no longer
+ * acquired and their imports report a spurious "cannot find module".
+ */
+ typeAcquisitionBudgetExhausted: boolean;
+}
/** Source files worth registering as models (everything the worker can parse). */
const LOADABLE_SOURCE = /\.(tsx?|jsx?|mjs|cjs|mts|cts|json)$/i;
@@ -173,10 +185,18 @@ function projectUriPrefix(project: string | undefined): string | undefined {
return project === undefined ? undefined : monaco.Uri.parse(`file:///${project}/`).toString();
}
-export function useApplicationTypeIntelligence(openedEntry: AnyEntry | undefined, rootEntries: AnyEntry[]): void {
+export function useApplicationTypeIntelligence(
+ openedEntry: AnyEntry | undefined,
+ rootEntries: AnyEntry[],
+): ApplicationTypeIntelligenceStatus {
const instanceParams = useInstanceClientIdParams();
const queryClient = useQueryClient();
+ // The budget is module-level and monotonic, so seed from it: a file opened
+ // after a prior project already exhausted the budget shows the notice at once,
+ // without waiting for another (short-circuited) acquisition pass.
+ const [typeAcquisitionBudgetExhausted, setTypeAcquisitionBudgetExhausted] = useState(isTypeAcquisitionBudgetSpent);
+
// Only intelligence-load the user's own applications. Installed packages can
// be large dependency trees and are read-only.
const project = openedEntry && !openedEntry.package ? openedEntry.project : undefined;
@@ -309,7 +329,12 @@ export function useApplicationTypeIntelligence(openedEntry: AnyEntry | undefined
&& file.content.length <= MAX_WORKER_MODEL_CHARS
)
.map(file => file.content);
- void acquireApplicationTypes(scriptSources);
+ await acquireApplicationTypes(scriptSources);
+ // The pass may have just sealed the budget; surface the current state
+ // so the editor can show (or keep) the degradation notice.
+ if (!cancelled) {
+ setTypeAcquisitionBudgetExhausted(isTypeAcquisitionBudgetSpent());
+ }
})();
}
@@ -327,4 +352,6 @@ export function useApplicationTypeIntelligence(openedEntry: AnyEntry | undefined
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project, context, filesKey]);
+
+ return { typeAcquisitionBudgetExhausted };
}
diff --git a/src/features/instance/config/secrets/SecretGrantsEditor.tsx b/src/features/instance/config/secrets/SecretGrantsEditor.tsx
index 48f011273..4490e8274 100644
--- a/src/features/instance/config/secrets/SecretGrantsEditor.tsx
+++ b/src/features/instance/config/secrets/SecretGrantsEditor.tsx
@@ -2,24 +2,27 @@
* Grants editor for one secret, shown inside the edit dialog. A secret is only materialized into
* the environment of components listed in its grants, so this is where a stored secret actually
* gets scoped to applications. Grant/revoke apply immediately (they are their own operations, not
- * part of the value form).
+ * part of the value form). The target application is chosen through the shared
+ * ComponentGrantCombobox, seeded with the components the cluster reports.
*/
import { Badge } from '@/components/ui/badge';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
import { useInstanceClientIdParams } from '@/config/useInstanceClient';
+import { ComponentGrantCombobox } from '@/features/instance/secrets/ComponentGrantCombobox';
import { useGrantSecret, useRevokeSecret } from '@/integrations/api/instance/secrets/secrets';
-import { PlusIcon, XIcon } from 'lucide-react';
-import { KeyboardEvent, useCallback, useState } from 'react';
+import { XIcon } from 'lucide-react';
+import { useCallback, useState } from 'react';
import { toast } from 'sonner';
export function SecretGrantsEditor({
name,
initialGrants,
+ components,
onChanged,
}: {
name: string;
initialGrants: string[];
+ /** Component names the cluster reports, offered as picker suggestions (empty → free text). */
+ components: string[];
/** Called after a successful grant/revoke so the list view can refresh its metadata. */
onChanged?: () => void;
}) {
@@ -30,22 +33,18 @@ export function SecretGrantsEditor({
// The mutation responses carry the resulting grants, so the chips track server truth.
const [grants, setGrants] = useState(initialGrants);
- const [component, setComponent] = useState('');
- const onGrantClick = useCallback(async () => {
- const target = component.trim();
- if (!target) {
- return;
- }
+ const onGrant = useCallback(async (target: string) => {
try {
const response = await grantSecret({ ...instanceParams, name, component: target });
setGrants(response.grants);
- setComponent('');
onChanged?.();
} catch (error) {
toast.error(String(error));
+ // Rethrow so the combobox keeps the typed text for a retry instead of clearing it.
+ throw error;
}
- }, [component, grantSecret, instanceParams, name, onChanged]);
+ }, [grantSecret, instanceParams, name, onChanged]);
const onRevokeClick = useCallback(async (target: string) => {
try {
@@ -57,14 +56,6 @@ export function SecretGrantsEditor({
}
}, [revokeSecret, instanceParams, name, onChanged]);
- // This editor lives inside the value form — Enter must grant, not submit a value replacement.
- const onComponentKeyDown = useCallback((event: KeyboardEvent) => {
- if (event.key === 'Enter') {
- event.preventDefault();
- void onGrantClick();
- }
- }, [onGrantClick]);
-
return (
Granted applications
@@ -91,26 +82,13 @@ export function SecretGrantsEditor({
))}
);
}
diff --git a/src/features/instance/config/secrets/grantableComponents.test.ts b/src/features/instance/config/secrets/grantableComponents.test.ts
new file mode 100644
index 000000000..77bb69ec2
--- /dev/null
+++ b/src/features/instance/config/secrets/grantableComponents.test.ts
@@ -0,0 +1,44 @@
+import { APIDirectoryEntry } from '@/integrations/api/instance/applications/getComponents';
+import { describe, expect, it } from 'vitest';
+import { grantableComponentNames } from './grantableComponents';
+
+function tree(entries: APIDirectoryEntry['entries']): APIDirectoryEntry {
+ return { name: 'root', entries };
+}
+
+describe('grantableComponentNames', () => {
+ it('returns [] for an undefined tree', () => {
+ expect(grantableComponentNames(undefined)).toEqual([]);
+ });
+
+ it('returns the top-level directory (component) names, sorted', () => {
+ const result = grantableComponentNames(
+ tree([
+ { name: 'web-app', entries: [] },
+ { name: 'auth-service', entries: [{ name: 'index.js' }] },
+ { name: 'billing', entries: [], package: '@acme/billing' },
+ ]),
+ );
+ expect(result).toEqual(['auth-service', 'billing', 'web-app']);
+ });
+
+ it('drops top-level files that are not components (no entries)', () => {
+ const result = grantableComponentNames(
+ tree([
+ { name: 'my-app', entries: [] },
+ { name: 'harperdb-config.yaml' } as APIDirectoryEntry,
+ ]),
+ );
+ expect(result).toEqual(['my-app']);
+ });
+
+ it('de-duplicates repeated component names', () => {
+ const result = grantableComponentNames(
+ tree([
+ { name: 'dup', entries: [] },
+ { name: 'dup', entries: [] },
+ ]),
+ );
+ expect(result).toEqual(['dup']);
+ });
+});
diff --git a/src/features/instance/config/secrets/grantableComponents.ts b/src/features/instance/config/secrets/grantableComponents.ts
new file mode 100644
index 000000000..d2e368ea4
--- /dev/null
+++ b/src/features/instance/config/secrets/grantableComponents.ts
@@ -0,0 +1,22 @@
+import { isDirectory } from '@/features/instance/applications/context/isDirectory';
+import { APIDirectoryEntry } from '@/integrations/api/instance/applications/getComponents';
+
+/**
+ * The component names a secret can be scoped to, derived from a `get_components` tree. A grant
+ * targets a component by name, and every deployed component (local application or installed
+ * package) is a top-level directory entry in the tree — so those directory names are exactly the
+ * grantable targets. Stray top-level files (if any) aren't components and are dropped. Sorted for
+ * a stable picker order; the tree can list the same name twice across reads, so it's de-duped.
+ */
+export function grantableComponentNames(tree: APIDirectoryEntry | undefined): string[] {
+ if (!tree?.entries) {
+ return [];
+ }
+ const names = new Set();
+ for (const entry of tree.entries) {
+ if (isDirectory(entry)) {
+ names.add(entry.name);
+ }
+ }
+ return [...names].sort((a, b) => a.localeCompare(b));
+}
diff --git a/src/features/instance/config/secrets/index.tsx b/src/features/instance/config/secrets/index.tsx
index f4d1f8fc3..fdb7f2637 100644
--- a/src/features/instance/config/secrets/index.tsx
+++ b/src/features/instance/config/secrets/index.tsx
@@ -1,8 +1,11 @@
import { useInstanceClientIdParams } from '@/config/useInstanceClient';
import { getClusterInfoQueryOptions } from '@/features/cluster/queries/getClusterInfoQuery';
+import { grantableComponentNames } from '@/features/instance/config/secrets/grantableComponents';
import { SecretGrantsEditor } from '@/features/instance/config/secrets/SecretGrantsEditor';
import { SecretRow, SecretsManager } from '@/features/instance/secrets/SecretsManager';
+import { useInstanceManagePermission } from '@/hooks/usePermissions';
import { clusterIsSelfManaged } from '@/integrations/api/clusterIsSelfManaged';
+import { getComponentsQueryOptions } from '@/integrations/api/instance/applications/getComponents';
import {
listSecretsQueryOptions,
SecretMetadata,
@@ -26,6 +29,13 @@ export function ConfigSecretsIndex() {
const instanceParams = useInstanceClientIdParams();
const { data, refetch, isFetching } = useQuery(listSecretsQueryOptions(instanceParams));
+ // The components the cluster reports feed the grants picker so scoping a secret is a pick, not a
+ // retype. Gated on manage permission (same as the overview page); it degrades to a free-text
+ // field when unavailable (older Harper, no permission), so a failure here never blocks scoping.
+ const canManage = useInstanceManagePermission();
+ const { data: componentsTree } = useQuery(getComponentsQueryOptions({ ...instanceParams, enabled: canManage }));
+ const grantableComponents = useMemo(() => grantableComponentNames(componentsTree), [componentsTree]);
+
// Fabric-managed clusters get their public key from central-manager (the custodian — it mints
// the keypair on first use, central-manager#409); self-hosted/local nodes serve their own.
const clusterQuery = useQuery(getClusterInfoQueryOptions(clusterId, false));
@@ -123,6 +133,17 @@ export function ConfigSecretsIndex() {
onSelectName={onSelectName}
nameHeader="Secret"
delivery={true}
+ docsLink={
+
+ Secrets Docs
+
+ }
+ grantableComponents={grantableComponents}
addDescription="The value is encrypted in your browser against the cluster's secrets key — plaintext never reaches the API, the operation log, or disk. It can be replaced or deleted, but never read back."
editDescription="The current value can't be shown — it's stored encrypted. Enter a new value to replace it, adjust how applications read it, or delete the secret."
valueDescription="Encrypted client-side before it leaves this page."
@@ -136,6 +157,7 @@ export function ConfigSecretsIndex() {
key={secret.name}
name={secret.name}
initialGrants={secret.grants}
+ components={grantableComponents}
onChanged={() => void refetch()}
/>
)
diff --git a/src/features/instance/databases/components/DatabaseTableView.tsx b/src/features/instance/databases/components/DatabaseTableView.tsx
index f9fa9e5d4..7e718c835 100644
--- a/src/features/instance/databases/components/DatabaseTableView.tsx
+++ b/src/features/instance/databases/components/DatabaseTableView.tsx
@@ -36,7 +36,7 @@ import { onClickStopPropagation } from '@/lib/onClickStopPropagation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useParams, useSearch } from '@tanstack/react-router';
-import { Row, VisibilityState } from '@tanstack/react-table';
+import { ColumnSizingState, Row, VisibilityState } from '@tanstack/react-table';
import {
BrushCleaningIcon,
CircleCheckBigIcon,
@@ -117,6 +117,11 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName
);
const [selectedIds, setSelectedIds] = useEffectedState(null, allParams);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
+ // The row the user clicked, straight from the list query. We keep it so the edit modal can fall
+ // back to showing it read-only when the record can't be fetched by its declared primary key --
+ // either the row has no value for that key, or it has one but nothing is stored under it (both
+ // happen when a table's primary key was changed after rows existed; see #1199).
+ const [clickedRow, setClickedRow] = useEffectedState | null>(null, allParams);
const isLastTableInDatabase = useMemo(() => {
const tableNames = databaseName ? Object.keys(instanceDatabaseMap?.[databaseName] || []).sort() : [];
@@ -288,13 +293,28 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName
const isFetching = tableDataFetching || tableConditionsDataFetching;
// One by id
- const { data: searchByIdData } = useQuery(getSearchByIdOptions({
- ...instanceParams,
- enabled: isEditModalOpen,
- databaseName: databaseName,
- tableName: tableName,
- ids: selectedIds,
- }));
+ const { data: searchByIdData, isFetching: isSearchByIdFetching, isError: isSearchByIdError } = useQuery(
+ getSearchByIdOptions({
+ ...instanceParams,
+ enabled: isEditModalOpen,
+ databaseName: databaseName,
+ tableName: tableName,
+ ids: selectedIds,
+ }),
+ );
+
+ // The clicked row can't be shown from the server in two cases, both stemming from a table whose
+ // declared primary key doesn't match how its rows are actually stored (see #1199):
+ // - missingPrimaryKey: the row has no value for the declared primary key at all.
+ // - recordUnavailable: it has a value, we looked it up, but nothing is stored under that key
+ // (Harper kept keying rows by the original attribute), so the fetch comes back empty.
+ // In both cases we show the row the list already gave us, read-only, with an explanation.
+ const missingPrimaryKey = isEditModalOpen && !!clickedRow && (primaryKey ? clickedRow[primaryKey] == null : true);
+ const fetchedRecord = searchByIdData?.data;
+ const recordUnavailable = !missingPrimaryKey
+ && !!selectedIds?.length
+ && !isSearchByIdFetching
+ && (isSearchByIdError || (Array.isArray(fetchedRecord) && fetchedRecord.length === 0));
const { mutate: updateTableRecords, isPending: isUpdateTableRecordsPending } = useUpdateTableRecords();
const { mutate: deleteTableRecords, isPending: isDeleteTableRecordsPending } = useDeleteTableRecords();
@@ -376,8 +396,12 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName
}, [deleteTableRecords, instanceParams, databaseName, tableName, refreshTable]);
const onRowClick = (rowData: Row>) => {
- setSelectedIds([rowData.original[primaryKey]]);
- setIsEditModalOpen(!isEditModalOpen);
+ const primaryKeyValue = primaryKey ? rowData.original[primaryKey] : undefined;
+ setClickedRow(rowData.original);
+ // With no usable primary key there's nothing to look up, so skip the (doomed) fetch; otherwise
+ // fetch the fresh record. Either way the modal can fall back to `clickedRow`.
+ setSelectedIds(primaryKeyValue == null ? null : [primaryKeyValue]);
+ setIsEditModalOpen(true);
};
const onColumnClick = (accessorKey: string, isAscending: boolean) => {
setSort({
@@ -407,6 +431,11 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName
...storedColumnVisibility,
}), [relationshipInfoMap, storedColumnVisibility]);
+ const [columnSizing, setColumnSizing] = useSessionStorage(
+ `ColumnSizing/${databaseName}/${tableName}` as 'ColumnSizing/{database}/{table}',
+ {} satisfies ColumnSizingState,
+ );
+
return (
<>
@@ -557,6 +586,8 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName
filtersToggled={filtersToggled}
columns={dataTableColumns}
columnVisibility={columnVisibility}
+ columnSizing={columnSizing}
+ setColumnSizing={setColumnSizing}
onRowClick={onRowClick}
onColumnClick={onColumnClick}
totalPages={totalPages}
@@ -579,8 +610,12 @@ export function DatabaseTableView({ instanceDatabaseMap, databaseName, tableName
setIsModalOpen={setIsEditModalOpen}
isModalOpen={isEditModalOpen}
primaryKey={primaryKey}
+ missingPrimaryKey={missingPrimaryKey}
+ recordUnavailable={recordUnavailable}
syntheticAttributes={syntheticAttributes}
- data={searchByIdData?.data}
+ data={missingPrimaryKey || recordUnavailable
+ ? (clickedRow ? [clickedRow] : undefined)
+ : searchByIdData?.data}
onSaveChanges={onRecordUpdate}
onDeleteRecord={onDeleteRecord}
isUpdateTableRecordsPending={isUpdateTableRecordsPending}
diff --git a/src/features/instance/databases/components/TableView.test.tsx b/src/features/instance/databases/components/TableView.test.tsx
index 8d3714043..5be3e5682 100644
--- a/src/features/instance/databases/components/TableView.test.tsx
+++ b/src/features/instance/databases/components/TableView.test.tsx
@@ -1,8 +1,9 @@
/**
* @vitest-environment jsdom
*/
-import { ColumnDef, VisibilityState } from '@tanstack/react-table';
+import { ColumnDef, ColumnSizingState, VisibilityState } from '@tanstack/react-table';
import { cleanup, render, screen } from '@testing-library/react';
+import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import { z } from 'zod';
@@ -31,12 +32,15 @@ const data: Record[] = [{ id: 'abc-123', type: 'demo' }];
function Harness({ columnVisibility }: { columnVisibility: VisibilityState }) {
const columnFiltersForm = useForm>({ defaultValues: {} });
+ const [columnSizing, setColumnSizing] = useState({});
return (
, unknown>
applyFilters={() => undefined}
columnFiltersForm={columnFiltersForm}
columns={columns}
columnVisibility={columnVisibility}
+ columnSizing={columnSizing}
+ setColumnSizing={setColumnSizing}
data={data}
pageIndex={0}
pageSize={20}
@@ -66,3 +70,13 @@ describe('TableView column visibility', () => {
expect(screen.getByText('demo')).toBeTruthy();
});
});
+
+describe('TableView column resizing', () => {
+ it('renders a resize handle for each column header', () => {
+ // Regression: the handle used to be gated on columnDef.enableResizing (never set), so it
+ // never rendered. It is now gated on getCanResize(), driven by the table-level flag.
+ const { container } = render();
+ const handles = container.querySelectorAll('svg.lucide-grip-vertical');
+ expect(handles.length).toBe(columns.length);
+ });
+});
diff --git a/src/features/instance/databases/components/TableView.tsx b/src/features/instance/databases/components/TableView.tsx
index 330884d9b..5e0e71dd3 100644
--- a/src/features/instance/databases/components/TableView.tsx
+++ b/src/features/instance/databases/components/TableView.tsx
@@ -1,14 +1,24 @@
'use client';
import { LoadingSubtle } from '@/components/LoadingSubtle';
-import { Table, TableBody, TableCell, TableHeader, TableHeadSortable, TableRow } from '@/components/ui/table';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableHeadSortable,
+ TableRow,
+} from '@/components/ui/table';
import { cn } from '@/lib/cn';
import {
Cell,
ColumnDef,
+ ColumnSizingState,
flexRender,
getCoreRowModel,
getPaginationRowModel,
+ OnChangeFn,
Row,
useReactTable,
VisibilityState,
@@ -24,6 +34,8 @@ interface BrowseDataTableProps {
columnFiltersForm: UseFormReturn>;
columns: ColumnDef[];
columnVisibility: VisibilityState;
+ columnSizing: ColumnSizingState;
+ setColumnSizing: OnChangeFn;
data?: TData[];
isFetching?: boolean;
onColumnClick?: (accessorKey: string, isDescending: boolean) => void;
@@ -48,6 +60,8 @@ export function TableView({
columnFiltersForm,
columns,
columnVisibility,
+ columnSizing,
+ setColumnSizing,
data,
isFetching,
onColumnClick,
@@ -72,63 +86,106 @@ export function TableView({
manualPagination: true,
enableColumnResizing: true,
columnResizeMode: 'onEnd',
+ onColumnSizingChange: setColumnSizing,
pageCount: totalPages,
defaultColumn: {
- minSize: 1,
+ // Wide enough that the header title + sort/resize controls never collide when shrinking.
+ minSize: 80,
},
state: {
columnVisibility,
+ columnSizing,
},
rowCount: totalRecords,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
+ // During a column resize, preview where the new right edge will land with a full-height guide line.
+ // columnResizeMode is 'onEnd', so the column width doesn't change until release -- the guide is the
+ // live feedback. Its x is the sum of column widths up to the resizing one, plus the (clamped) drag delta.
+ const columnSizingInfo = table.getState().columnSizingInfo;
+ const resizingColumnId = columnSizingInfo.isResizingColumn;
+ let resizeGuideLeft: number | null = null;
+ if (resizingColumnId) {
+ const minSize = table.options.defaultColumn?.minSize ?? 20;
+ const startSize = table.getColumn(resizingColumnId)?.getSize() ?? 0;
+ let edge = 0;
+ for (const leafColumn of table.getVisibleLeafColumns()) {
+ edge += leafColumn.getSize();
+ if (leafColumn.id === resizingColumnId) {
+ break;
+ }
+ }
+ // Clamp to match the handle's own preview: the column can't shrink below minSize.
+ resizeGuideLeft = edge + Math.max(columnSizingInfo.deltaOffset ?? 0, minSize - startSize);
+ }
+
return (
<>
-
+ ),
+}));
+vi.mock('@/hooks/useMonacoTheme', () => ({ useMonacoTheme: () => 'light' }));
+
+beforeAll(() => {
+ // Radix Dialog relies on DOM APIs jsdom doesn't implement.
+ Element.prototype.hasPointerCapture ??= () => false;
+ Element.prototype.setPointerCapture ??= () => undefined;
+ Element.prototype.releasePointerCapture ??= () => undefined;
+ Element.prototype.scrollIntoView ??= () => undefined;
+ window.matchMedia ??= ((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addEventListener() {},
+ removeEventListener() {},
+ addListener() {},
+ removeListener() {},
+ dispatchEvent() {
+ return false;
+ },
+ })) as unknown as typeof window.matchMedia;
+ if (typeof window.PointerEvent === 'undefined') {
+ window.PointerEvent = class extends MouseEvent {} as typeof PointerEvent;
+ }
+});
+
+afterEach(() => cleanup());
+
+const noop = () => {};
+const row = { name: 'Ada Lovelace', city: 'London', id: 'abc-123' };
+
+function renderModal(overrides: Record = {}) {
+ return render(
+ ,
+ );
+}
+
+describe('EditTableRowModal', () => {
+ it('warns, goes read-only, and hides write actions when the primary key is missing', () => {
+ renderModal({ missingPrimaryKey: true });
+
+ expect(screen.getByText('This row has no primary key value')).toBeTruthy();
+ // The banner names the missing primary-key attribute.
+ expect(screen.getByText('email')).toBeTruthy();
+ expect(screen.getByRole('heading').textContent).toContain('View');
+ expect(screen.getByTestId('editor').getAttribute('data-readonly')).toBe('true');
+ // No way to save or delete a row that can't be addressed.
+ expect(screen.queryByRole('button', { name: /Save Changes/i })).toBeNull();
+ expect(screen.queryByRole('button', { name: /Delete Row/i })).toBeNull();
+ });
+
+ it("warns and hides write actions when the record can't be loaded by its primary key", () => {
+ renderModal({ recordUnavailable: true });
+
+ expect(screen.getByText("This row couldn't be loaded")).toBeTruthy();
+ expect(screen.getByText('email')).toBeTruthy();
+ expect(screen.getByRole('heading').textContent).toContain('View');
+ expect(screen.getByTestId('editor').getAttribute('data-readonly')).toBe('true');
+ expect(screen.queryByRole('button', { name: /Save Changes/i })).toBeNull();
+ expect(screen.queryByRole('button', { name: /Delete Row/i })).toBeNull();
+ });
+
+ it('lets a normal row be edited and deleted', () => {
+ renderModal();
+
+ expect(screen.queryByText('This row has no primary key value')).toBeNull();
+ expect(screen.getByRole('heading').textContent).toContain('Edit');
+ expect(screen.getByTestId('editor').getAttribute('data-readonly')).toBe('false');
+ expect(screen.getByRole('button', { name: /Save Changes/i })).toBeTruthy();
+ expect(screen.getByRole('button', { name: /Delete Row/i })).toBeTruthy();
+ });
+});
diff --git a/src/features/instance/databases/modals/EditTableRowModal.tsx b/src/features/instance/databases/modals/EditTableRowModal.tsx
index ac22a93ce..8bf6198c4 100644
--- a/src/features/instance/databases/modals/EditTableRowModal.tsx
+++ b/src/features/instance/databases/modals/EditTableRowModal.tsx
@@ -1,9 +1,10 @@
import { Loading } from '@/components/Loading';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useMonacoTheme } from '@/hooks/useMonacoTheme';
import { Editor } from '@/lib/monaco/MonacoEditor';
-import { Save, Trash } from 'lucide-react';
+import { Save, Trash, TriangleAlert } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
export function EditTableRowModal({
@@ -12,6 +13,8 @@ export function EditTableRowModal({
setIsModalOpen,
isModalOpen,
primaryKey,
+ missingPrimaryKey,
+ recordUnavailable,
syntheticAttributes,
data,
onSaveChanges,
@@ -24,6 +27,13 @@ export function EditTableRowModal({
setIsModalOpen: (open: boolean) => void;
isModalOpen: boolean;
primaryKey: string;
+ /** The clicked row has no value for the declared primary key, so it can't be looked up, edited,
+ * or deleted by id (see #1199). We still show its contents read-only, with an explanation. */
+ missingPrimaryKey?: boolean;
+ /** The row has a primary-key value, but looking it up returned no record — nothing is stored
+ * under that key (the table isn't actually keyed by the declared primary key; see #1199). Shown
+ * read-only with an explanation, since it can't be edited or deleted by that key. */
+ recordUnavailable?: boolean;
/** Relationship/computed attribute names — read-only, so they are hidden from the editable JSON
* (saving a record that assigns one fails, even with null). */
syntheticAttributes?: string[];
@@ -34,6 +44,11 @@ export function EditTableRowModal({
isDeleteTableRecordsPending: boolean;
}) {
const monacoTheme = useMonacoTheme();
+ // A row that can't be addressed by its declared primary key can't be saved or deleted
+ // individually, so force the editor read-only and hide the write actions regardless of the
+ // user's permissions.
+ const unaddressable = Boolean(missingPrimaryKey) || Boolean(recordUnavailable);
+ const isReadOnly = !canEditRecords || unaddressable;
const [isValidJSON, setIsValidJSON] = useState(true);
const [madeChanges, setMadeChanges] = useState(false);
const [updatedTableRecordData, setUpdatedTableRecordData] = useState();
@@ -58,8 +73,8 @@ export function EditTableRowModal({
{
if (madeChanges) {
event.preventDefault();
@@ -68,8 +83,42 @@ export function EditTableRowModal({
: undefined}
>
- {canEditRecords ? 'Edit' : 'View'} Row
+ {isReadOnly ? 'View' : 'Edit'} Row
+ {unaddressable && (
+
+
+
+ {missingPrimaryKey ? 'This row has no primary key value' : "This row couldn't be loaded"}
+
+
+
+ {missingPrimaryKey
+ ? (primaryKey
+ ? (
+ <>
+ It has no value for the primary key{' '}
+ {primaryKey}, so it can't be looked up, edited, or deleted individually.
+ >
+ )
+ : `It has no primary key value, so it can't be looked up, edited, or deleted individually.`)
+ : (primaryKey
+ ? (
+ <>
+ Nothing is stored under its primary key{' '}
+ {primaryKey}, so it can't be edited or deleted individually.
+ >
+ )
+ : `Nothing is stored under its primary key, so it can't be edited or deleted individually.`)}
+
+
+ This usually means the table's primary key was changed after the row was created, so the value shown
+ here isn't the key the record is actually stored under. To remove it, recreate the table or restore the
+ original primary key attribute.
+
);
}
diff --git a/src/features/instance/secrets/SecretDeliveryPicker.tsx b/src/features/instance/secrets/SecretDeliveryPicker.tsx
index 69d550e57..974f483dd 100644
--- a/src/features/instance/secrets/SecretDeliveryPicker.tsx
+++ b/src/features/instance/secrets/SecretDeliveryPicker.tsx
@@ -9,6 +9,9 @@ import { ReactNode } from 'react';
import { SecretTier } from './accessExample';
import { SecretAccessExample } from './SecretAccessExample';
+/** The Harper docs hub for the secrets store — the `secrets` accessor, tiers, and change subscriptions. */
+const SECRETS_DOCS_URL = 'https://docs.harperdb.io/reference/v5/security/secrets';
+
export function SecretDeliveryPicker({
name,
tier,
@@ -61,6 +64,14 @@ export function SecretDeliveryPicker({
How to read it in your component
+
+ Learn more about secrets in the Harper docs
+
);
diff --git a/src/features/instance/secrets/SecretModals.tsx b/src/features/instance/secrets/SecretModals.tsx
index 390ea1d7e..e3bb0f93c 100644
--- a/src/features/instance/secrets/SecretModals.tsx
+++ b/src/features/instance/secrets/SecretModals.tsx
@@ -57,6 +57,7 @@ export function AddSecretModal({
setIsModalOpen,
delivery = false,
defaultTier = 'scoped',
+ grantableComponents = [],
}: {
description: ReactNode;
valueDescription?: ReactNode;
@@ -70,6 +71,8 @@ export function AddSecretModal({
delivery?: boolean;
/** Tier pre-selected when the dialog opens (defaults to the safer scoped tier). */
defaultTier?: SecretTier;
+ /** Component names the cluster reports, offered as suggestions in the grants picker. */
+ grantableComponents?: string[];
}) {
const schema = useMemo(
() =>
@@ -173,7 +176,14 @@ export function AddSecretModal({
tier={tier}
onTierChange={setTier}
disabled={isPending}
- grantsSlot={}
+ grantsSlot={
+
+ }
/>
)}
diff --git a/src/features/instance/secrets/SecretsManager.delivery.test.tsx b/src/features/instance/secrets/SecretsManager.delivery.test.tsx
index d55b00001..a1d4d5f8c 100644
--- a/src/features/instance/secrets/SecretsManager.delivery.test.tsx
+++ b/src/features/instance/secrets/SecretsManager.delivery.test.tsx
@@ -47,6 +47,23 @@ function exampleText(): string {
return document.querySelector('pre code')?.textContent ?? '';
}
+describe('SecretsManager toolbar', () => {
+ it('renders docsLink at the start of the toolbar, before Refresh/Add', () => {
+ renderManager({
+ onRefresh: vi.fn().mockResolvedValue(undefined),
+ docsLink: (
+
+ Secrets Docs
+
+ ),
+ });
+ const docs = screen.getByRole('link', { name: /secrets docs/i });
+ const refresh = screen.getByRole('button', { name: /refresh/i });
+ // docsLink precedes Refresh in DOM order — it sits at the left, matching the sshKeys/certificates pages.
+ expect(docs.compareDocumentPosition(refresh) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+});
+
describe('SecretsManager delivery tier — Add', () => {
async function openAddWithKeyValue() {
fireEvent.click(screen.getByRole('button', { name: /add/i }));
@@ -58,20 +75,27 @@ describe('SecretsManager delivery tier — Add', () => {
renderManager();
await openAddWithKeyValue();
- // Scoped is the default: the example destructures the accessor, never touches process.env.
+ // Scoped is the default: the example reads the live accessor and subscribes for rotations,
+ // never touching process.env.
expect(exampleText()).toContain("import { secrets } from 'harper';");
- expect(exampleText()).toContain('const { NEW_KEY } = secrets;');
+ expect(exampleText()).toContain('let value = secrets.NEW_KEY;');
+ expect(exampleText()).toContain("secrets.subscribe('NEW_KEY')");
expect(exampleText()).not.toContain('process.env');
+
+ // The example links out to the Harper secrets docs for deeper reading.
+ const docsLink = screen.getByRole('link', { name: /learn more about secrets/i });
+ expect(docsLink.getAttribute('href')).toBe('https://docs.harperdb.io/reference/v5/security/secrets');
});
it('submits a scoped secret with its pending grants', async () => {
const { onSet } = renderManager();
await openAddWithKeyValue();
- // Add one grant (the PendingGrantsInput "Add" button, distinct from "Add Secret").
+ // Add one grant through the component picker. No components are supplied here, so it's a
+ // free-text field; Enter commits the typed name (the chip's Remove button confirms it landed).
fireEvent.change(screen.getByPlaceholderText('application name'), { target: { value: 'my-app' } });
- fireEvent.click(screen.getByRole('button', { name: /^add$/i }));
- expect(screen.getByText('my-app')).toBeTruthy();
+ fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter' });
+ await screen.findByRole('button', { name: /remove my-app/i });
const submit = screen.getByRole('button', { name: /add secret/i });
await waitFor(() => expect(submit.hasAttribute('disabled')).toBe(false));
@@ -125,7 +149,7 @@ describe('SecretsManager delivery tier — Edit', () => {
selectedName: 'DB_PASSWORD',
renderEditExtras: () =>
grants
,
});
- await waitFor(() => expect(exampleText()).toContain('const { DB_PASSWORD } = secrets;'));
+ await waitFor(() => expect(exampleText()).toContain('let value = secrets.DB_PASSWORD;'));
// Tier matches what's stored (scoped, unchanged), so the live grant_secret editor is safe.
expect(screen.getByTestId('live-grants')).toBeTruthy();
});
@@ -143,7 +167,7 @@ describe('SecretsManager delivery tier — Edit', () => {
// Flip to scoped without saving: the secret is still processEnv server-side, so a live
// grant_secret would be rejected — show the "save first" hint instead of the editor.
fireEvent.click(screen.getAllByRole('radio')[0]);
- await waitFor(() => expect(exampleText()).toContain('const { TOKEN } = secrets;'));
+ await waitFor(() => expect(exampleText()).toContain('let value = secrets.TOKEN;'));
expect(screen.queryByTestId('live-grants')).toBeNull();
expect(screen.getByText(/save this as a scoped secret first/i)).toBeTruthy();
});
diff --git a/src/features/instance/secrets/SecretsManager.tsx b/src/features/instance/secrets/SecretsManager.tsx
index 11d464ad4..ed7020606 100644
--- a/src/features/instance/secrets/SecretsManager.tsx
+++ b/src/features/instance/secrets/SecretsManager.tsx
@@ -46,9 +46,11 @@ export function SecretsManager({
onSet,
onDelete,
renderEditExtras,
+ docsLink,
children,
delivery = false,
deliveryDefaultTier = 'scoped',
+ grantableComponents,
}: {
rows: SecretRow[];
isFetching?: boolean;
@@ -72,12 +74,16 @@ export function SecretsManager({
onDelete?: (key: string) => Promise;
/** Extra per-secret content for the edit dialog (e.g. a live grants editor). */
renderEditExtras?: (name: string) => ReactNode;
+ /** A docs link rendered at the START of the toolbar (before Refresh/Add), matching the sshKeys/certificates config pages. */
+ docsLink?: ReactNode;
/** Extra toolbar actions, rendered after Refresh/Add. */
children?: ReactNode;
/** Enable the delivery-tier chooser (process.env vs scoped) + access examples in the dialogs. */
delivery?: boolean;
/** Tier pre-selected in the Add dialog (defaults to the safer scoped tier). */
deliveryDefaultTier?: SecretTier;
+ /** Component names the cluster reports, offered as suggestions in the Add dialog's grants picker. */
+ grantableComponents?: string[];
}) {
const columns = useMemo>>(() => [
{
@@ -112,6 +118,7 @@ export function SecretsManager({
isFetching={isFetching}
onRowClick={canManage ? onRowClick : undefined}
>
+ {docsLink}
{onRefresh && (
onSet(key, value, options)}
/>
)}
diff --git a/src/features/instance/secrets/accessExample.test.ts b/src/features/instance/secrets/accessExample.test.ts
index e29ed726c..9420239a8 100644
--- a/src/features/instance/secrets/accessExample.test.ts
+++ b/src/features/instance/secrets/accessExample.test.ts
@@ -30,22 +30,27 @@ describe('buildSecretAccessExample', () => {
expect(buildSecretAccessExample('my-key', 'processEnv')).toContain("const value = process.env['my-key'];");
});
- it('scoped destructures the secrets accessor for identifier names', () => {
+ it('scoped reads the live accessor and subscribes for identifier names', () => {
const code = buildSecretAccessExample('DB_PASSWORD', 'scoped');
expect(code).toContain("import { secrets } from 'harper';");
- expect(code).toContain('const { DB_PASSWORD } = secrets;');
+ expect(code).toContain('let value = secrets.DB_PASSWORD;');
+ expect(code).toContain("for await (const next of secrets.subscribe('DB_PASSWORD'))");
+ expect(code).toContain('if (next === value) continue;'); // skip the redundant startup rebuild
+ expect(code).toContain('} catch (error) {'); // subscription errors don't become unhandled rejections
expect(code).toContain('top level'); // the module-top-level guidance
expect(code).not.toContain('process.env');
});
it('scoped uses bracket access for non-identifier names', () => {
const code = buildSecretAccessExample('my.key', 'scoped');
- expect(code).toContain("const value = secrets['my.key'];");
- expect(code).not.toContain('const { my.key }');
+ expect(code).toContain("let value = secrets['my.key'];");
+ expect(code).toContain("secrets.subscribe('my.key')");
+ expect(code).not.toContain('secrets.my.key'); // never dot-access a non-identifier name
});
it('falls back to a placeholder name when empty or whitespace', () => {
- expect(buildSecretAccessExample('', 'scoped')).toContain(`const { ${SECRET_NAME_PLACEHOLDER} } = secrets;`);
+ expect(buildSecretAccessExample('', 'scoped')).toContain(`let value = secrets.${SECRET_NAME_PLACEHOLDER};`);
+ expect(buildSecretAccessExample('', 'scoped')).toContain(`secrets.subscribe('${SECRET_NAME_PLACEHOLDER}')`);
expect(buildSecretAccessExample(' ', 'processEnv')).toContain(`process.env.${SECRET_NAME_PLACEHOLDER}`);
});
diff --git a/src/features/instance/secrets/accessExample.ts b/src/features/instance/secrets/accessExample.ts
index a278b07e1..bb77be3ac 100644
--- a/src/features/instance/secrets/accessExample.ts
+++ b/src/features/instance/secrets/accessExample.ts
@@ -6,7 +6,11 @@
* - 'processEnv' — the value is materialized into the real `process.env` at component load and
* inherited by child processes (global, `.env` semantics). Read it as `process.env.NAME`.
* - 'scoped' — never placed on `process.env`; exposed only to granted components through the
- * `secrets` accessor (`import { secrets } from 'harper'`), read at module top level.
+ * `secrets` accessor (`import { secrets } from 'harper'`), read at module top level. On this tier
+ * the accessor is live and `secrets.subscribe('NAME')` streams the current value then each
+ * rotation (harper#1787), so the example reads the value immediately, then subscribes in a
+ * background task — module load isn't blocked and the component hot-swaps a rotated secret
+ * without a restart. (The global tier stays reload-only, so its example is just the read.)
*
* The two tiers are mutually exclusive server-side (a processEnv secret is global, so scoping it
* with grants is rejected). The secret-name grammar (`[\w.-]+`) permits `.` and `-`, which aren't
@@ -46,16 +50,29 @@ export function buildSecretAccessExample(name: string, tier: SecretTier): string
].join('\n');
}
- // Scoped: the `secrets` accessor is bound to the loading component, so it must be read at module
- // top level (during load) — reading it inside a request handler throws.
- const read = identifier
- ? `const { ${key} } = secrets;`
- : `const value = secrets[${quote(key)}];`;
+ // Scoped: the `secrets` accessor is bound to the loading component, so read it at module top
+ // level (during load), not inside a request handler. The accessor is live on this tier, and
+ // `secrets.subscribe(name)` streams the current value then each rotation (harper#1787) — so read
+ // the value immediately, then subscribe in a fire-and-forget task that never blocks module load.
+ const read = identifier ? `secrets.${key}` : `secrets[${quote(key)}]`;
return [
"import { secrets } from 'harper';",
'',
- '// Read granted secrets at the top level of your component module',
- '// (the accessor is bound during load, not inside request handlers).',
- read,
+ "// Use the current value right away — read at your module's top level.",
+ `let value = ${read};`,
+ '',
+ '// Then react to rotations without blocking startup. Subscribing in a background',
+ '// task lets module load finish, so the rest of your app keeps initializing.',
+ '(async () => {',
+ ' try {',
+ ` for await (const next of secrets.subscribe(${quote(key)})) {`,
+ ' if (next === value) continue; // the first yield is the current value you already have',
+ ' value = next; // rotated — rebuild anything bound to the secret (e.g. an API client) here',
+ ' }',
+ ' } catch (error) {',
+ ' // Log and keep serving the last value; it stays valid until the next reload.',
+ ` console.error('secret subscription ended for', ${quote(key)}, error);`,
+ ' }',
+ '})();',
].join('\n');
}
diff --git a/src/features/instance/status/analytics/StatusTabs.tsx b/src/features/instance/status/analytics/StatusTabs.tsx
index 593d07e40..4a9c35800 100644
--- a/src/features/instance/status/analytics/StatusTabs.tsx
+++ b/src/features/instance/status/analytics/StatusTabs.tsx
@@ -1,25 +1,24 @@
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
-import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig.ts';
-import { ANALYTICS_QUERY_KEY_PREFIX } from '@/integrations/api/instance/status/getAnalytics.ts';
+import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig';
+import { ANALYTICS_QUERY_KEY_PREFIX } from '@/integrations/api/instance/status/getAnalytics';
import { useQueryClient } from '@tanstack/react-query';
import { useNavigate, useSearch } from '@tanstack/react-router';
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { type StatusTabId, validateStatusSearch } from '../statusSearch.ts';
-import { AnalyticsOnboardingHint } from './components/AnalyticsOnboardingHint.tsx';
-import { TimeRangePicker } from './components/TimeRangePicker.tsx';
-import { type AnalyticsContextValue, AnalyticsProvider } from './context/AnalyticsContext.tsx';
-import { getPreset, type TimePresetId } from './context/timePresets.ts';
-import { type AnalyticsCapability, useAnalyticsCapability } from './hooks/useAnalyticsCapability.ts';
-import { useResolvedTheme } from './lib/theme.ts';
-import { DatabaseTab } from './tabs/DatabaseTab.tsx';
-import { HealthTab } from './tabs/HealthTab.tsx';
-import { OverviewTab } from './tabs/OverviewTab.tsx';
-import { ReplicationTab } from './tabs/ReplicationTab.tsx';
-import { RequestsTab } from './tabs/RequestsTab.tsx';
-import { StorageTab } from './tabs/StorageTab.tsx';
-import { TrafficTab } from './tabs/TrafficTab.tsx';
+import { type StatusTabId, validateStatusSearch } from '../statusSearch';
+import { AnalyticsOnboardingHint } from './components/AnalyticsOnboardingHint';
+import { TimeRangePicker } from './components/TimeRangePicker';
+import { type AnalyticsContextValue, AnalyticsProvider } from './context/AnalyticsContext';
+import { getPreset, type TimePresetId } from './context/timePresets';
+import { type AnalyticsCapability, useAnalyticsCapability } from './hooks/useAnalyticsCapability';
+import { DatabaseTab } from './tabs/DatabaseTab';
+import { HealthTab } from './tabs/HealthTab';
+import { OverviewTab } from './tabs/OverviewTab';
+import { ReplicationTab } from './tabs/ReplicationTab';
+import { RequestsTab } from './tabs/RequestsTab';
+import { StorageTab } from './tabs/StorageTab';
+import { TrafficTab } from './tabs/TrafficTab';
interface Props {
instanceParams: InstanceClientIdConfig & InstanceTypeConfig;
@@ -155,12 +154,6 @@ function StatusTabsInner({ instanceParams, isLocalStudio, capability }: InnerPro
};
}, [refreshMs, refresh, queryClient, tick, instanceParams.entityId]);
- // Resolved app theme (user's explicit choice, not raw OS preference) —
- // kept in the context only for the consumers that still read it there
- // (StorageTab's table-size charts, ConnectionsPanel). Chart primitives
- // re-theme via `--chart-*` CSS tokens without any prop.
- const theme = useResolvedTheme();
-
const updatePreset = useCallback((id: TimePresetId) => {
void navigate({ to: '.', search: { tab, range: id, refresh: refreshMs } });
}, [navigate, tab, refreshMs]);
@@ -191,20 +184,30 @@ function StatusTabsInner({ instanceParams, isLocalStudio, capability }: InnerPro
}
}, [capability.error, tab, navigate, presetId, refreshMs]);
- const ctxValue = useMemo(() => {
+ // The window snapshot lives in its own memo keyed ONLY on the preset and
+ // the refresh tick. Nothing else may mint a new window: it is baked into
+ // every panel's query key, so an extra dep (e.g. `tab`) would re-key every
+ // query on that dep's changes and defeat useAnalyticsRecords'
+ // staleTime-Infinity cache that makes tab flips instant.
+ const { timeRange, bucketMs } = useMemo(() => {
const preset = getPreset(presetId);
const endTime = Date.now();
const startTime = endTime - preset.durationMs;
// `tick` participates in memo deps so every refresh (interval or manual)
// produces a fresh window even if the user did not change presets.
void tick;
- return {
- timeRange: { startTime, endTime },
- bucketMs: preset.bucketMs,
- theme,
- instanceParams,
- };
- }, [presetId, theme, instanceParams, tick]);
+ return { timeRange: { startTime, endTime }, bucketMs: preset.bucketMs };
+ }, [presetId, tick]);
+
+ const ctxValue = useMemo(() => ({
+ timeRange,
+ bucketMs,
+ instanceParams,
+ // Crosshair/tooltip sync is scoped to one instance's current tab —
+ // panels on a tab share an x-domain, other tabs/instances don't.
+ // Tab switches change only this syncId; the window above stays put.
+ syncId: `${instanceParams.entityId}:${tab}`,
+ }), [timeRange, bucketMs, instanceParams, tab]);
const showTimePicker = tab !== 'overview';
const picker = showTimePicker
diff --git a/src/features/instance/status/analytics/__tests__/StatusTabs.capability-gating.test.tsx b/src/features/instance/status/analytics/__tests__/StatusTabs.capability-gating.test.tsx
index cfa65e9c4..fae997316 100644
--- a/src/features/instance/status/analytics/__tests__/StatusTabs.capability-gating.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/StatusTabs.capability-gating.test.tsx
@@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// render, the error notice carries a Retry button, and 401/403 get an
// auth-flavored message instead of "analytics unavailable".
-vi.mock('../hooks/useAnalyticsRecords.ts', () => ({
+vi.mock('../hooks/useAnalyticsRecords', () => ({
useAnalyticsRecords: () => ({
data: [],
isLoading: false,
@@ -33,13 +33,13 @@ interface MockCapability {
retry: () => void;
}
let mockCapability: MockCapability;
-vi.mock('../hooks/useAnalyticsCapability.ts', () => ({
+vi.mock('../hooks/useAnalyticsCapability', () => ({
useAnalyticsCapability: () => mockCapability,
}));
// Overview's real body suspends on get_status; stub it so these tests assert
// "Overview renders" without plumbing status fixtures.
-vi.mock('../tabs/OverviewTab.tsx', () => ({
+vi.mock('../tabs/OverviewTab', () => ({
OverviewTab: () =>
overview content
,
}));
@@ -76,7 +76,7 @@ beforeEach(() => {
});
});
-import { StatusTabs } from '../StatusTabs.tsx';
+import { StatusTabs } from '../StatusTabs';
const instanceParams = {
instanceClient: { post: vi.fn(async () => ({ data: [] })) } as never,
diff --git a/src/features/instance/status/analytics/__tests__/StatusTabs.sliding-window.test.tsx b/src/features/instance/status/analytics/__tests__/StatusTabs.sliding-window.test.tsx
index e78751c13..9018125fa 100644
--- a/src/features/instance/status/analytics/__tests__/StatusTabs.sliding-window.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/StatusTabs.sliding-window.test.tsx
@@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// hook and assert the clock in StatusTabsInner advances them.
const seenWindows: { startTime: number; endTime: number }[] = [];
-vi.mock('../hooks/useAnalyticsRecords.ts', () => ({
+vi.mock('../hooks/useAnalyticsRecords', () => ({
useAnalyticsRecords: (args: { startTime: number; endTime: number }) => {
seenWindows.push({ startTime: args.startTime, endTime: args.endTime });
return {
@@ -26,7 +26,7 @@ vi.mock('../hooks/useAnalyticsRecords.ts', () => ({
},
}));
-vi.mock('../hooks/useAnalyticsCapability.ts', () => ({
+vi.mock('../hooks/useAnalyticsCapability', () => ({
useAnalyticsCapability: () => ({
supported: true,
isLoading: false,
@@ -49,8 +49,8 @@ vi.mock('@tanstack/react-router', () => ({
useNavigate: () => navigateMock,
}));
-import { DEFAULT_REFRESH_MS } from '../context/timePresets.ts';
-import { StatusTabs } from '../StatusTabs.tsx';
+import { DEFAULT_REFRESH_MS } from '../context/timePresets';
+import { StatusTabs } from '../StatusTabs';
const instanceParams = {
instanceClient: { post: vi.fn(async () => ({ data: [] })) } as never,
diff --git a/src/features/instance/status/analytics/__tests__/StatusTabs.sync-id.test.tsx b/src/features/instance/status/analytics/__tests__/StatusTabs.sync-id.test.tsx
new file mode 100644
index 000000000..eddcb2f61
--- /dev/null
+++ b/src/features/instance/status/analytics/__tests__/StatusTabs.sync-id.test.tsx
@@ -0,0 +1,164 @@
+// @vitest-environment happy-dom
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, cleanup, render } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { AnalyticsContextValue } from '../context/AnalyticsContext';
+
+// Asserts StatusTabs threads a per-instance, per-tab Recharts syncId through
+// AnalyticsContext (`${entityId}:${tab}`), so crosshair sync stays scoped to
+// one tab of one instance's Status page (#1454) — and that switching tabs
+// changes ONLY the syncId, never the analytics window (which is baked into
+// every panel's query key).
+
+// Probe the context from inside a tab body: mock the chart tabs to a
+// component that records what useAnalyticsSyncId() / useAnalyticsContext()
+// resolve to.
+const seenSyncIds: (string | undefined)[] = [];
+const seenContexts: AnalyticsContextValue[] = [];
+vi.mock('../tabs/HealthTab', async () => {
+ const { useAnalyticsContext, useAnalyticsSyncId } = await import('../context/AnalyticsContext');
+ function Probe() {
+ seenSyncIds.push(useAnalyticsSyncId());
+ seenContexts.push(useAnalyticsContext());
+ return null;
+ }
+ return { HealthTab: Probe };
+});
+vi.mock('../tabs/TrafficTab', async () => {
+ const { useAnalyticsContext, useAnalyticsSyncId } = await import('../context/AnalyticsContext');
+ function Probe() {
+ seenSyncIds.push(useAnalyticsSyncId());
+ seenContexts.push(useAnalyticsContext());
+ return null;
+ }
+ return { TrafficTab: Probe };
+});
+
+vi.mock('../hooks/useAnalyticsCapability', () => ({
+ useAnalyticsCapability: () => ({
+ supported: true,
+ isLoading: false,
+ isFetching: false,
+ isAuthError: false,
+ retry: vi.fn(),
+ }),
+}));
+
+let currentSearch: Record = {};
+const navigateMock = vi.fn(async () => {});
+vi.mock('@tanstack/react-router', () => ({
+ useSearch: () => currentSearch,
+ useNavigate: () => navigateMock,
+}));
+
+import { DEFAULT_REFRESH_MS } from '../context/timePresets';
+import { StatusTabs } from '../StatusTabs';
+
+function makeInstanceParams(entityId: string) {
+ return {
+ instanceClient: { post: vi.fn(async () => ({ data: [] })) } as never,
+ entityId: entityId as never,
+ entityType: 'instance' as const,
+ };
+}
+
+function mount(entityId: string) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: 0 } },
+ });
+ const instanceParams = makeInstanceParams(entityId);
+ const ui = () => (
+
+
+
+ );
+ const view = render(ui());
+ // Fresh element, same stable props — re-renders the tree so the mocked
+ // useSearch is re-read after mutating `currentSearch`.
+ return { ...view, rerenderSame: () => view.rerender(ui()) };
+}
+
+beforeEach(() => {
+ currentSearch = {};
+ seenSyncIds.length = 0;
+ seenContexts.length = 0;
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ configurable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+});
+
+afterEach(() => cleanup());
+
+describe('StatusTabs syncId threading', () => {
+ it('keys the syncId by instance and tab (default health tab)', () => {
+ mount('inst-A');
+ expect(seenSyncIds.length).toBeGreaterThan(0);
+ expect(seenSyncIds.at(-1)).toBe('inst-A:health');
+ });
+
+ it('changes the syncId when the tab changes', () => {
+ currentSearch = { tab: 'traffic' };
+ mount('inst-A');
+ expect(seenSyncIds.at(-1)).toBe('inst-A:traffic');
+ });
+
+ it('differs per instance so two Status pages never cross-sync', () => {
+ mount('inst-A');
+ const a = seenSyncIds.at(-1);
+ cleanup();
+ seenSyncIds.length = 0;
+ mount('inst-B');
+ const b = seenSyncIds.at(-1);
+ expect(a).toBe('inst-A:health');
+ expect(b).toBe('inst-B:health');
+ expect(a).not.toBe(b);
+ });
+});
+
+// Regression coverage for the tab-switch refetch bug: the window is baked
+// into every panel's query key, so if switching tabs minted a new
+// [start, end] snapshot, every query would re-key and defeat
+// useAnalyticsRecords' staleTime-Infinity cache that makes tab flips instant.
+describe('StatusTabs window stability across tab switches', () => {
+ it('keeps the timeRange stable when only the tab changes', () => {
+ const { rerenderSame } = mount('inst-A');
+ const healthCtx = seenContexts.at(-1)!;
+ expect(healthCtx.syncId).toBe('inst-A:health');
+
+ currentSearch = { tab: 'traffic' };
+ rerenderSame();
+ const trafficCtx = seenContexts.at(-1)!;
+ expect(trafficCtx.syncId).toBe('inst-A:traffic');
+ // Same window object, not just equal values — referential stability is
+ // what keeps downstream memos and query keys unchanged.
+ expect(trafficCtx.timeRange).toBe(healthCtx.timeRange);
+ expect(trafficCtx.bucketMs).toBe(healthCtx.bucketMs);
+ });
+
+ it('a refresh tick DOES mint a new window (same tab)', async () => {
+ vi.useFakeTimers();
+ try {
+ mount('inst-A');
+ const before = seenContexts.at(-1)!;
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(DEFAULT_REFRESH_MS + 5);
+ });
+ const after = seenContexts.at(-1)!;
+ expect(after.timeRange).not.toBe(before.timeRange);
+ expect(after.timeRange.endTime).toBeGreaterThanOrEqual(before.timeRange.endTime + DEFAULT_REFRESH_MS);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/src/features/instance/status/analytics/__tests__/StatusTabs.url-sync.test.tsx b/src/features/instance/status/analytics/__tests__/StatusTabs.url-sync.test.tsx
index fe73b3b53..edbfce7a5 100644
--- a/src/features/instance/status/analytics/__tests__/StatusTabs.url-sync.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/StatusTabs.url-sync.test.tsx
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock useAnalyticsRecords so we don't need a real Harper. Each tab still
// mounts; we're testing the URL-sync layer, not chart rendering.
-vi.mock('../hooks/useAnalyticsRecords.ts', () => ({
+vi.mock('../hooks/useAnalyticsRecords', () => ({
useAnalyticsRecords: () => ({
data: [],
isLoading: false,
@@ -20,7 +20,7 @@ vi.mock('../hooks/useAnalyticsRecords.ts', () => ({
// Capability probe must succeed so the inner StatusTabs renders (vs. the
// fallback path).
-vi.mock('../hooks/useAnalyticsCapability.ts', () => ({
+vi.mock('../hooks/useAnalyticsCapability', () => ({
useAnalyticsCapability: () => ({
supported: true,
isLoading: false,
@@ -65,7 +65,7 @@ beforeEach(() => {
});
});
-import { StatusTabs } from '../StatusTabs.tsx';
+import { StatusTabs } from '../StatusTabs';
const instanceParams = {
instanceClient: { post: vi.fn(async () => ({ data: [] })) } as never,
diff --git a/src/features/instance/status/analytics/__tests__/charts/node-legend.test.tsx b/src/features/instance/status/analytics/__tests__/charts/node-legend.test.tsx
new file mode 100644
index 000000000..a31df3750
--- /dev/null
+++ b/src/features/instance/status/analytics/__tests__/charts/node-legend.test.tsx
@@ -0,0 +1,32 @@
+/** @vitest-environment jsdom */
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+import { NodeLegend } from '../../charts/NodeLegend';
+
+describe('NodeLegend', () => {
+ afterEach(cleanup);
+
+ const nodes = ['node1.us.acme.com', 'node1.eu.acme.com', 'other.acme.com'];
+
+ it('shows collision-aware short names, not the full FQDN', () => {
+ render( true} onClickNode={() => {}} />);
+ // node1.* collide on the first segment → keep one more segment.
+ expect(screen.getByText('node1.us')).toBeTruthy();
+ expect(screen.getByText('node1.eu')).toBeTruthy();
+ // unique first segment → shortened all the way.
+ expect(screen.getByText('other')).toBeTruthy();
+ expect(screen.queryByText('node1.us.acme.com')).toBe(null);
+ });
+
+ it('keeps the full FQDN on the button title for hover', () => {
+ render( true} onClickNode={() => {}} />);
+ const btn = screen.getByText('other').closest('button');
+ expect(btn?.getAttribute('title')).toBe('other.acme.com');
+ });
+
+ it('disabled tab keeps its explanatory title instead of the FQDN', () => {
+ render( true} onClickNode={() => {}} disabled />);
+ const btn = screen.getByText('other').closest('button');
+ expect(btn?.getAttribute('title')).toBe("Per-node filter unavailable on this tab's panels");
+ });
+});
diff --git a/src/features/instance/status/analytics/__tests__/charts/table-size-chip-row.test.tsx b/src/features/instance/status/analytics/__tests__/charts/table-size-chip-row.test.tsx
index ac3e75308..cebae9fad 100644
--- a/src/features/instance/status/analytics/__tests__/charts/table-size-chip-row.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/charts/table-size-chip-row.test.tsx
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
-import { TableSizeChipRow } from '../../charts/TableSizeChipRow.tsx';
-import { OTHER_KEY } from '../../lib/tableSize.ts';
+import { TableSizeChipRow } from '../../charts/TableSizeChipRow';
+import { OTHER_KEY } from '../../lib/tableSize';
describe('TableSizeChipRow radiogroup (shared roving-tabindex primitive)', () => {
afterEach(() => cleanup());
diff --git a/src/features/instance/status/analytics/__tests__/nodeColors.test.ts b/src/features/instance/status/analytics/__tests__/nodeColors.test.ts
index 23ee45fbb..2957cb6aa 100644
--- a/src/features/instance/status/analytics/__tests__/nodeColors.test.ts
+++ b/src/features/instance/status/analytics/__tests__/nodeColors.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { getNodeColor, NODE_PALETTE } from '../lib/nodeColors.ts';
+import { getNodeColor, NODE_PALETTE } from '../lib/nodeColors';
describe('getNodeColor', () => {
it('assigns colors based on sorted node order', () => {
diff --git a/src/features/instance/status/analytics/__tests__/nodeLabels.test.ts b/src/features/instance/status/analytics/__tests__/nodeLabels.test.ts
new file mode 100644
index 000000000..984b7f0a2
--- /dev/null
+++ b/src/features/instance/status/analytics/__tests__/nodeLabels.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest';
+import { shortenNodeLabel, shortNodeLabelMap } from '../lib/nodeLabels';
+
+describe('shortenNodeLabel', () => {
+ it('keeps the first FQDN segment', () => {
+ expect(shortenNodeLabel('xb6-us-west-1.prod.ibm.harperfabric.com')).toBe('xb6-us-west-1');
+ });
+
+ it('falls back to the full string without a dot', () => {
+ expect(shortenNodeLabel('localhost')).toBe('localhost');
+ });
+});
+
+describe('shortNodeLabelMap', () => {
+ it('shortens non-colliding nodes to their first segment (same as shortenNodeLabel)', () => {
+ const map = shortNodeLabelMap(['a.acme.com', 'b.acme.com']);
+ expect(map.get('a.acme.com')).toBe('a');
+ expect(map.get('b.acme.com')).toBe('b');
+ });
+
+ it('extends colliding labels with enough segments to disambiguate', () => {
+ const map = shortNodeLabelMap(['node1.us.acme.com', 'node1.eu.acme.com', 'node2.us.acme.com']);
+ expect(map.get('node1.us.acme.com')).toBe('node1.us');
+ expect(map.get('node1.eu.acme.com')).toBe('node1.eu');
+ // Unique first segment stays short.
+ expect(map.get('node2.us.acme.com')).toBe('node2');
+ });
+
+ it('keeps deep collisions extending until they split', () => {
+ const map = shortNodeLabelMap(['n.us.east.acme.com', 'n.us.west.acme.com']);
+ expect(map.get('n.us.east.acme.com')).toBe('n.us.east');
+ expect(map.get('n.us.west.acme.com')).toBe('n.us.west');
+ });
+
+ it('falls back to the full name when a node exhausts its segments', () => {
+ const map = shortNodeLabelMap(['web', 'web.acme.com']);
+ expect(map.get('web')).toBe('web');
+ expect(map.get('web.acme.com')).toBe('web.acme');
+ });
+
+ it('dedupes repeated inputs and handles empties', () => {
+ const map = shortNodeLabelMap(['a.acme.com', 'a.acme.com']);
+ expect(map.size).toBe(1);
+ expect(shortNodeLabelMap([]).size).toBe(0);
+ });
+});
diff --git a/src/features/instance/status/analytics/__tests__/paletteDisjointness.test.ts b/src/features/instance/status/analytics/__tests__/paletteDisjointness.test.ts
index aa41b8463..57f89c39c 100644
--- a/src/features/instance/status/analytics/__tests__/paletteDisjointness.test.ts
+++ b/src/features/instance/status/analytics/__tests__/paletteDisjointness.test.ts
@@ -1,8 +1,8 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
-import { TYPE_PALETTE } from '../lib/colorAllocators/typeColors.ts';
-import { NODE_PALETTE } from '../lib/nodeColors.ts';
-import { OTHER_COLOR, TABLE_PALETTE } from '../lib/tableColors.ts';
+import { TYPE_PALETTE } from '../lib/colorAllocators/typeColors';
+import { NODE_PALETTE } from '../lib/nodeColors';
+import { OTHER_COLOR, TABLE_PALETTE } from '../lib/tableColors';
// The palettes are now CSS custom properties (`var(--chart-…)`), so the
// disjointness invariant is enforced against their definitions in
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/aggregators.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/aggregators.test.ts
index ec1a4a127..789d8d13c 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/aggregators.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/aggregators.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { type AggInput, aggregate } from '../../pipeline/aggregators.ts';
+import { type AggInput, aggregate } from '../../pipeline/aggregators';
describe('aggregate', () => {
it('sum', () => {
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/approxLabel.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/approxLabel.test.ts
index 0c322c43f..1900fdb6d 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/approxLabel.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/approxLabel.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { isApproxAggregator, labelWithApprox } from '../../pipeline/approxLabel.ts';
+import { isApproxAggregator, labelWithApprox } from '../../pipeline/approxLabel';
describe('approxLabel', () => {
it('appends " (approx)" when aggregator is count-weighted-mean', () => {
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/bytes-received-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/bytes-received-pipeline.test.ts
index e22c16fa7..7df33d2f7 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/bytes-received-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/bytes-received-pipeline.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
-import { bytesReceivedSpec } from '../../pipeline/bytes-received.tsx';
-import { runPipeline } from '../../pipeline/pipeline.ts';
-import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts';
+import { runPipeline } from '../../pipeline/pipeline';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
+
+const bytesReceivedSpec = wrapperMetrics['bytes-received'].spec;
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/bytes-sent-tolerance.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/bytes-sent-tolerance.test.ts
index abf28f8b3..3b60232f7 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/bytes-sent-tolerance.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/bytes-sent-tolerance.test.ts
@@ -1,9 +1,11 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
-import { bytesSentSpec } from '../../pipeline/bytes-sent.tsx';
-import { mqttTrafficSentDerived } from '../../pipeline/derived/mqtt-traffic-sent.tsx';
-import { runPipeline } from '../../pipeline/pipeline.ts';
+import { mqttTrafficSentDerived } from '../../pipeline/derived/mqtt-traffic';
+import { runPipeline } from '../../pipeline/pipeline';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+
+const bytesSentSpec = wrapperMetrics['bytes-sent'].spec;
describe('bytes-sent tolerance', () => {
it('total bytes/sec ≈ Σ type-rates within 0.5% (global sum)', () => {
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/cache-hit-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/cache-hit-pipeline.test.ts
index e88545407..0d5cbbe76 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/cache-hit-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/cache-hit-pipeline.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
-import { cacheHitSpec } from '../../pipeline/cache-hit.tsx';
-import { runPipeline } from '../../pipeline/pipeline.ts';
-import type { AnalyticsDataPoint } from '../../types/analytics.ts';
+import { runPipeline } from '../../pipeline/pipeline';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+import type { AnalyticsDataPoint } from '../../types/analytics';
+
+const cacheHitSpec = wrapperMetrics['cache-hit'].spec;
// Synthetic rows shaped like the real Harper response:
// { time, node, path, period, count, total, ratio }
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/cache-resolution-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/cache-resolution-pipeline.test.ts
index 19c26d867..9a3139cce 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/cache-resolution-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/cache-resolution-pipeline.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
-import { cacheResolutionSpec } from '../../pipeline/cache-resolution.tsx';
-import { runPipeline } from '../../pipeline/pipeline.ts';
-import type { AnalyticsDataPoint } from '../../types/analytics.ts';
+import { runPipeline } from '../../pipeline/pipeline';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+import type { AnalyticsDataPoint } from '../../types/analytics';
+
+const cacheResolutionSpec = wrapperMetrics['cache-resolution'].spec;
// Synthetic rows shaped like the real Harper response. The real schema
// carries a per-bucket count-weighted distribution for each path; the
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/compute-cell-size.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/compute-cell-size.test.ts
index d4c1c2b3b..23e844564 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/compute-cell-size.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/compute-cell-size.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { computeCellSize } from '../../primitives/computeCellSize.ts';
+import { computeCellSize } from '../../primitives/computeCellSize';
describe('computeCellSize', () => {
it('returns MAX when container is wide enough', () => {
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/confidence.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/confidence.test.ts
index b6db4407a..74c4ca8a8 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/confidence.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/confidence.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { classifyConfidence } from '../../pipeline/confidence.ts';
+import { classifyConfidence } from '../../pipeline/confidence';
describe('classifyConfidence (post-aggregation windowed count)', () => {
const rule = { greyBelow: 40, suppressBelow: 100 };
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/connections-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/connections-pipeline.test.ts
index a7d7df1d2..309bf1404 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/connections-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/connections-pipeline.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest';
-import { connectionsSpec } from '../../pipeline/connections.tsx';
-import { runPipeline } from '../../pipeline/pipeline.ts';
+import { runPipeline } from '../../pipeline/pipeline';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+
+const connectionsSpec = wrapperMetrics['connections'].spec;
describe('connections pipeline', () => {
// connections is a multi-source merge (mqtt-connections + ws-connections)
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/cpu-usage-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/cpu-usage-pipeline.test.ts
index 70afa4412..43ae7c1e7 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/cpu-usage-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/cpu-usage-pipeline.test.ts
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
-import { cpuUsageSpec } from '../../pipeline/cpu-usage.tsx';
-import { runPipeline } from '../../pipeline/pipeline.ts';
-import { QUANTILE_FIELDS } from '../../pipeline/quantileFields.ts';
-import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics.ts';
+import { runPipeline } from '../../pipeline/pipeline';
+import { QUANTILE_FIELDS } from '../../pipeline/quantileFields';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics';
+
+const cpuUsageSpec = wrapperMetrics['cpu-usage'].spec;
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/database-size-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/database-size-pipeline.test.ts
index d256532c1..aea5c8564 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/database-size-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/database-size-pipeline.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
-import { databaseSizeSpec } from '../../pipeline/database-size.tsx';
-import { runPipeline } from '../../pipeline/pipeline.ts';
-import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts';
+import { runPipeline } from '../../pipeline/pipeline';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
+
+const databaseSizeSpec = wrapperMetrics['database-size'].spec;
const window: TimeRange = { startTime: 0, endTime: 10_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/db-pipelines.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/db-pipelines.test.ts
index 7f6b33678..11f0d4cc9 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/db-pipelines.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/db-pipelines.test.ts
@@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest';
-import { dbMessageSpec } from '../../pipeline/db-message.tsx';
-import { dbReadSpec } from '../../pipeline/db-read.tsx';
-import { dbWriteSpec } from '../../pipeline/db-write.tsx';
-import { runPipeline } from '../../pipeline/pipeline.ts';
-import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics.ts';
+import { runPipeline } from '../../pipeline/pipeline';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics';
+
+const dbMessageSpec = wrapperMetrics['db-message'].spec;
+const dbReadSpec = wrapperMetrics['db-read'].spec;
+const dbWriteSpec = wrapperMetrics['db-write'].spec;
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/duration-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/duration-pipeline.test.ts
index e564ad6b1..ad1330f0f 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/duration-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/duration-pipeline.test.ts
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
-import { labelWithApprox } from '../../pipeline/approxLabel.ts';
-import { durationSpec } from '../../pipeline/duration.tsx';
-import { runPipeline } from '../../pipeline/pipeline.ts';
-import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts';
+import { labelWithApprox } from '../../pipeline/approxLabel';
+import { runPipeline } from '../../pipeline/pipeline';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
+
+const durationSpec = wrapperMetrics['duration'].spec;
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/fieldExpr.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/fieldExpr.test.ts
index 968f62e3b..5f654b371 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/fieldExpr.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/fieldExpr.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { evalFieldExpr } from '../../pipeline/fieldExpr.ts';
-import type { FieldExpr } from '../../types/analytics.ts';
+import { evalFieldExpr } from '../../pipeline/fieldExpr';
+import type { FieldExpr } from '../../types/analytics';
const record = {
count: 100,
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/main-thread-utilization-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/main-thread-utilization-pipeline.test.ts
index 71ee7b4c4..f4050c5e3 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/main-thread-utilization-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/main-thread-utilization-pipeline.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
-import { mainThreadUtilizationSpec } from '../../pipeline/main-thread-utilization.tsx';
-import { runPipeline } from '../../pipeline/pipeline.ts';
-import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts';
+import { mainThreadUtilizationSpec } from '../../pipeline/main-thread-utilization';
+import { runPipeline } from '../../pipeline/pipeline';
+import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/memory-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/memory-pipeline.test.ts
index 4d9caf2fb..d0f99bbe4 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/memory-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/memory-pipeline.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
-import { memorySpec } from '../../pipeline/memory.tsx';
-import { runPipeline } from '../../pipeline/pipeline.ts';
-import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts';
+import { memorySpec } from '../../pipeline/memory';
+import { runPipeline } from '../../pipeline/pipeline';
+import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/pathParser.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/pathParser.test.ts
index b32ab9810..7d18e5136 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/pathParser.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/pathParser.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { parseReplicationPath } from '../../pipeline/pathParser.ts';
+import { parseReplicationPath } from '../../pipeline/pathParser';
const NODES = [
'xb6-us-west-1.prod.ibm.harperfabric.com',
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/pipeline-per-node.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/pipeline-per-node.test.ts
index 47cf8fb7d..2c3cbd619 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/pipeline-per-node.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/pipeline-per-node.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { makeSeriesKey, runPipeline } from '../../pipeline/pipeline.ts';
-import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics.ts';
+import { makeSeriesKey, runPipeline } from '../../pipeline/pipeline';
+import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics';
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/pipeline-time-buckets.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/pipeline-time-buckets.test.ts
index 9800c8f47..b967d0614 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/pipeline-time-buckets.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/pipeline-time-buckets.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { runPipeline } from '../../pipeline/pipeline.ts';
-import type { AnalyticsDataPoint, MetricSpec } from '../../types/analytics.ts';
+import { runPipeline } from '../../pipeline/pipeline';
+import type { AnalyticsDataPoint, MetricSpec } from '../../types/analytics';
const baseSpec: MetricSpec = {
title: 'test',
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/pipeline.test.ts
index e286fb26f..2eb42c41f 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/pipeline.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { runPipeline } from '../../pipeline/pipeline.ts';
-import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics.ts';
+import { runPipeline } from '../../pipeline/pipeline';
+import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics';
const spec: MetricSpec = {
title: 'Bytes (test)',
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/rate-period-fallback.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/rate-period-fallback.test.ts
index fbe272c2e..3bf724b22 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/rate-period-fallback.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/rate-period-fallback.test.ts
@@ -3,8 +3,8 @@
// transforms. The pipeline resolves the effective period via the same
// `spec.bucket.fallbackMs ?? 60_000` convention snapToBucketTime uses.
import { describe, expect, it } from 'vitest';
-import { runPipeline } from '../../pipeline/pipeline.ts';
-import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics.ts';
+import { runPipeline } from '../../pipeline/pipeline';
+import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics';
const window: TimeRange = { startTime: 1_000_000, endTime: 1_060_000 };
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/replication-drilldown.test.tsx b/src/features/instance/status/analytics/__tests__/pipeline/replication-drilldown.test.tsx
new file mode 100644
index 000000000..e257d64e4
--- /dev/null
+++ b/src/features/instance/status/analytics/__tests__/pipeline/replication-drilldown.test.tsx
@@ -0,0 +1,182 @@
+// @vitest-environment jsdom
+// Drilldown from a replication-heatmap cell into the node-pair time series
+// (issue #1455): click / Enter on a data cell opens a dialog titled
+// `source → target` with the pair's latency line chart.
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+import { ReplicationLatencyRenderer } from '../../pipeline/replication-latency';
+import type { AnalyticsDataPoint } from '../../types/analytics';
+
+const NODES = ['node-a', 'node-b'];
+
+function mk(source: string, dest: string, time: number, p95: number): AnalyticsDataPoint {
+ return { path: `${source}.db.tbl`, node: dest, time, p95, median: p95 / 2, count: 120, period: 60 };
+}
+
+// 2×2 matrix (heatmap branch: rows ≥ 2, cols ≥ 2, cells ≤ 12) with both
+// diagonal cells absent (a node never replicates to itself here).
+const records: AnalyticsDataPoint[] = [
+ mk('node-a', 'node-b', 1_000, 10),
+ mk('node-a', 'node-b', 2_000, 12),
+ mk('node-b', 'node-a', 1_000, 20),
+ mk('node-b', 'node-a', 2_000, 22),
+];
+
+function renderIt() {
+ return render(
+ ,
+ );
+}
+
+function findCell(re: RegExp) {
+ return screen.getAllByRole('gridcell').find((c) => re.test(c.getAttribute('aria-label') ?? ''))!;
+}
+
+afterEach(() => cleanup());
+
+describe('replication heatmap cell drilldown', () => {
+ it('clicking a data cell opens a dialog titled "source → target"', () => {
+ renderIt();
+ expect(screen.queryByRole('dialog')).toBeNull();
+ fireEvent.click(findCell(/node-a.*node-b/));
+ const dialog = screen.getByRole('dialog');
+ expect(dialog).toBeTruthy();
+ expect(screen.getByRole('heading', { name: 'node-a → node-b' })).toBeTruthy();
+ });
+
+ it('the dialog reflects the pair that was clicked', () => {
+ renderIt();
+ fireEvent.click(findCell(/node-b.*node-a/));
+ expect(screen.getByRole('heading', { name: 'node-b → node-a' })).toBeTruthy();
+ expect(screen.queryByRole('heading', { name: 'node-a → node-b' })).toBeNull();
+ });
+
+ it('Enter on the focused cell opens the dialog (keyboard parity)', () => {
+ renderIt();
+ const cell = findCell(/node-a.*node-b/);
+ (cell as HTMLElement).focus();
+ fireEvent.keyDown(cell, { key: 'Enter' });
+ expect(screen.getByRole('dialog')).toBeTruthy();
+ expect(screen.getByRole('heading', { name: 'node-a → node-b' })).toBeTruthy();
+ });
+
+ it('absent cells are aria-disabled and do not open the dialog', () => {
+ renderIt();
+ const absent = findCell(/node-a.*node-a/);
+ expect(absent.getAttribute('data-confidence')).toBe('absent');
+ expect(absent.getAttribute('aria-disabled')).toBe('true');
+ fireEvent.click(absent);
+ (absent as HTMLElement).focus();
+ fireEvent.keyDown(absent, { key: 'Enter' });
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('dialog description names the currently selected quantile', () => {
+ renderIt();
+ // Switch quantile to p50 (Harper field `median`) before drilling in.
+ const p50 = screen.getAllByTestId('quantile-button').find((b) => b.getAttribute('data-value') === 'median')!;
+ fireEvent.click(p50);
+ fireEvent.click(findCell(/node-a.*node-b/));
+ expect(screen.getByText(/p50 replication latency/i)).toBeTruthy();
+ });
+
+ it('a pair whose records all lack a numeric time opens with the empty-chart state', () => {
+ // Same 2×2 shape, but every node-a→node-b record has a bogus `time` —
+ // the cell still renders (matrix ignores time) yet the pair series is
+ // empty, so the dialog must fall through to "No data in window".
+ const noTime = [
+ { ...mk('node-a', 'node-b', 0, 10), time: 'bogus' as unknown as number },
+ { ...mk('node-a', 'node-b', 0, 12), time: 'bogus' as unknown as number },
+ mk('node-b', 'node-a', 1_000, 20),
+ ];
+ render(
+ ,
+ );
+ fireEvent.click(findCell(/node-a.*node-b/));
+ const dialog = screen.getByRole('dialog');
+ expect(screen.getByRole('heading', { name: 'node-a → node-b' })).toBeTruthy();
+ expect(dialog.textContent).toMatch(/No data in window/);
+ });
+
+ it('keeps the dialog open and tracking the pair when records refresh', () => {
+ const { rerender } = renderIt();
+ fireEvent.click(findCell(/node-a.*node-b/));
+ expect(screen.getByRole('heading', { name: 'node-a → node-b' })).toBeTruthy();
+ // Background poll delivers a new window where the drilled pair has no
+ // timestamped records — the dialog stays open and shows the empty state.
+ const refreshed = [
+ { ...mk('node-a', 'node-b', 0, 11), time: 'bogus' as unknown as number },
+ mk('node-b', 'node-a', 3_000, 24),
+ ];
+ rerender(
+ ,
+ );
+ const dialog = screen.getByRole('dialog');
+ expect(screen.getByRole('heading', { name: 'node-a → node-b' })).toBeTruthy();
+ expect(dialog.textContent).toMatch(/No data in window/);
+ });
+
+ it('stays open when a refresh collapses the matrix into the line-fallback branch', () => {
+ const { rerender } = renderIt();
+ fireEvent.click(findCell(/node-a.*node-b/));
+ expect(screen.getByRole('heading', { name: 'node-a → node-b' })).toBeTruthy();
+ // New window has a single source → renderer switches to the line
+ // fallback (no heatmap). The open drilldown must not vanish.
+ rerender(
+ ,
+ );
+ expect(screen.getByRole('dialog')).toBeTruthy();
+ expect(screen.getByRole('heading', { name: 'node-a → node-b' })).toBeTruthy();
+ });
+
+ it('re-applies the confidence gate when a refresh drops the pair below greyBelow', () => {
+ const { rerender } = renderIt();
+ fireEvent.click(findCell(/node-a.*node-b/));
+ expect(screen.getByRole('heading', { name: 'node-a → node-b' })).toBeTruthy();
+ // Refresh leaves the drilled pair with 20 samples (< greyBelow 40):
+ // the heatmap suppresses that cell, so the open dialog must hide the
+ // series too instead of leaking sub-threshold data.
+ const refreshed = [
+ { ...mk('node-a', 'node-b', 1_000, 10), count: 20 },
+ mk('node-b', 'node-a', 1_000, 20),
+ mk('node-b', 'node-a', 2_000, 22),
+ ];
+ rerender(
+ ,
+ );
+ const dialog = screen.getByRole('dialog');
+ expect(dialog.textContent).toMatch(/Fewer than 40 samples/);
+ expect(dialog.textContent).toMatch(/series hidden/);
+ });
+
+ it('closing the dialog (Escape) returns to the heatmap and allows re-drill', () => {
+ renderIt();
+ fireEvent.click(findCell(/node-a.*node-b/));
+ const dialog = screen.getByRole('dialog');
+ fireEvent.keyDown(dialog, { key: 'Escape' });
+ expect(screen.queryByRole('dialog')).toBeNull();
+ // The grid is still there and another pair can be drilled.
+ fireEvent.click(findCell(/node-b.*node-a/));
+ expect(screen.getByRole('heading', { name: 'node-b → node-a' })).toBeTruthy();
+ });
+});
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/replication-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/replication-pipeline.test.ts
index 7f00f2d8b..b538a51c1 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/replication-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/replication-pipeline.test.ts
@@ -1,8 +1,8 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
-import { aggregateReplicationMatrix, bucketLineSeries } from '../../pipeline/replication-latency.tsx';
-import type { AnalyticsDataPoint } from '../../types/analytics.ts';
+import { aggregateReplicationMatrix, bucketLineSeries } from '../../pipeline/replication-latency';
+import type { AnalyticsDataPoint } from '../../types/analytics';
const multi = JSON.parse(
readFileSync(join(import.meta.dirname, '../fixtures/replication-latency/multi-source.json'), 'utf8'),
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/resource-usage-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/resource-usage-pipeline.test.ts
index bdf62dbab..66fa4232f 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/resource-usage-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/resource-usage-pipeline.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { runPipeline } from '../../pipeline/pipeline.ts';
-import { resourceUsageSpec } from '../../pipeline/resource-usage.ts';
+import { runPipeline } from '../../pipeline/pipeline';
+import { resourceUsageSpec } from '../../pipeline/resource-usage';
const records = [
{
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/response-200-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/response-200-pipeline.test.ts
index adf1dd590..39385096e 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/response-200-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/response-200-pipeline.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
-import { runPipeline } from '../../pipeline/pipeline.ts';
-import { response200Spec } from '../../pipeline/response-200.tsx';
-import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts';
+import { runPipeline } from '../../pipeline/pipeline';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
+
+const response200Spec = wrapperMetrics['response_200'].spec;
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/runTransform.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/runTransform.test.ts
index 1f8f5a9be..0eb0d47d4 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/runTransform.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/runTransform.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { runTransform } from '../../pipeline/runTransform.ts';
-import type { Transform } from '../../types/analytics.ts';
+import { runTransform } from '../../pipeline/runTransform';
+import type { Transform } from '../../types/analytics';
describe('runTransform', () => {
it('raw returns input unchanged', () => {
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/sort-by-magnitude.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/sort-by-magnitude.test.ts
index 21d8ae08c..1b8602350 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/sort-by-magnitude.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/sort-by-magnitude.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { sortByMagnitude } from '../../primitives/sortByMagnitude.ts';
+import { sortByMagnitude } from '../../primitives/sortByMagnitude';
describe('sortByMagnitude', () => {
it('orders series by sum across all points (descending)', () => {
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/spec-registry.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/spec-registry.test.ts
index 5116d0ece..ea139bbfb 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/spec-registry.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/spec-registry.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { derivedRegistry } from '../../pipeline/derived/index.ts';
-import { specRegistry } from '../../pipeline/index.ts';
+import { derivedRegistry } from '../../pipeline/derived/index';
+import { specRegistry } from '../../pipeline/index';
// Pins the full registry contents so a typo in the wrapperMetrics factory
// table (or a dropped line in the registry) can't silently lose a metric.
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/storage-volume-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/storage-volume-pipeline.test.ts
index c3d4923ab..6960805e5 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/storage-volume-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/storage-volume-pipeline.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
-import { runPipeline } from '../../pipeline/pipeline.ts';
-import { storageVolumeSpec } from '../../pipeline/storage-volume.ts';
-import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts';
+import { runPipeline } from '../../pipeline/pipeline';
+import { storageVolumeSpec } from '../../pipeline/storage-volume';
+import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
const window: TimeRange = { startTime: 0, endTime: 10_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/success-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/success-pipeline.test.ts
index dd03aaa61..36cc9f4de 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/success-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/success-pipeline.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
-import { runPipeline } from '../../pipeline/pipeline.ts';
-import { successSpec } from '../../pipeline/success.tsx';
-import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts';
+import { runPipeline } from '../../pipeline/pipeline';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
+
+const successSpec = wrapperMetrics['success'].spec;
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/tls-reused-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/tls-reused-pipeline.test.ts
index e65412b03..458ad05ac 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/tls-reused-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/tls-reused-pipeline.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
-import { runPipeline } from '../../pipeline/pipeline.ts';
-import { tlsReusedSpec } from '../../pipeline/tls-reused.ts';
-import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts';
+import { runPipeline } from '../../pipeline/pipeline';
+import { tlsReusedSpec } from '../../pipeline/tls-reused';
+import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/transaction-log-growth.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/transaction-log-growth.test.ts
index bfc2d1672..6ae07209c 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/transaction-log-growth.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/transaction-log-growth.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { recomputeTransactionLogGrowth } from '../../pipeline/derived/transaction-log-growth.tsx';
-import type { AnalyticsDataPoint } from '../../types/analytics.ts';
+import { recomputeTransactionLogGrowth } from '../../pipeline/derived/transaction-log-growth';
+import type { AnalyticsDataPoint } from '../../types/analytics';
const range = { startTime: 0, endTime: 10_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/transfer-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/transfer-pipeline.test.ts
index bbe595697..3bb909072 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/transfer-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/transfer-pipeline.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
-import { runPipeline } from '../../pipeline/pipeline.ts';
-import { transferSpec } from '../../pipeline/transfer.tsx';
-import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts';
+import { runPipeline } from '../../pipeline/pipeline';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
+
+const transferSpec = wrapperMetrics['transfer'].spec;
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/transforms.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/transforms.test.ts
index 21afcb0bd..f92689a92 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/transforms.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/transforms.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { type NamedTransformKey, namedTransforms } from '../../pipeline/transforms.ts';
+import { type NamedTransformKey, namedTransforms } from '../../pipeline/transforms';
describe('namedTransforms registry', () => {
it('exports percent-of-core that multiplies by 100', () => {
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/utilization-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/utilization-pipeline.test.ts
index 9e23032bd..76b0efc1d 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/utilization-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/utilization-pipeline.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
-import { runPipeline } from '../../pipeline/pipeline.ts';
-import { utilizationSpec } from '../../pipeline/utilization.ts';
-import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts';
+import { runPipeline } from '../../pipeline/pipeline';
+import { utilizationSpec } from '../../pipeline/utilization';
+import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/primitives/chart-tooltip.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/chart-tooltip.test.tsx
new file mode 100644
index 000000000..166c42c82
--- /dev/null
+++ b/src/features/instance/status/analytics/__tests__/primitives/chart-tooltip.test.tsx
@@ -0,0 +1,148 @@
+// @vitest-environment happy-dom
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+import { ChartTooltip } from '../../primitives/ChartTooltip';
+
+describe('ChartTooltip (shared cartesian chart tooltip)', () => {
+ afterEach(() => cleanup());
+
+ it('returns null when inactive', () => {
+ const { container } = render();
+ expect(container.firstChild).toBe(null);
+ });
+
+ it('returns null when hidden, even while Recharts reports active (sync gating)', () => {
+ const payload = [{ dataKey: 'a', name: 'A', value: 1, color: '#f00' }];
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toBe(null);
+ });
+
+ it('returns null for an empty payload', () => {
+ const { container } = render();
+ expect(container.firstChild).toBe(null);
+ });
+
+ it('formats the time header via formatTooltipTime', () => {
+ const payload = [{ dataKey: 'a', name: 'A', value: 1, color: '#f00' }];
+ const { container } = render();
+ // Nov 14 2023 in every locale's short-month rendering carries the year.
+ expect(container.textContent ?? '').toMatch(/2023/);
+ });
+
+ it('formats values with the default formatter/unit', () => {
+ const payload = [{ dataKey: 'a', name: 'A', value: 1500, color: '#f00' }];
+ render();
+ expect(screen.getByText(/1\.5 KB\/s/)).toBeTruthy();
+ });
+
+ it('resolveEntryFormat overrides the default per entry (dual-axis charts)', () => {
+ const payload = [
+ { dataKey: 'y', name: 'util', value: 0.5, color: '#f00' },
+ { dataKey: 'y', name: 'lag', value: 12, color: '#0f0' },
+ ];
+ render(
+ entry.name === 'util' ? { formatter: 'percent' } : { formatter: 'ms' }}
+ />,
+ );
+ expect(screen.getByText(/50\.0%/)).toBeTruthy();
+ expect(screen.getByText(/12\.0 ms/)).toBeTruthy();
+ });
+
+ it('renders — for a missing (null) value instead of a fake 0', () => {
+ const payload = [{ dataKey: 'a', name: 'A', value: null, color: '#f00' }];
+ render();
+ expect(screen.getByText('—')).toBeTruthy();
+ });
+
+ it('shortens full node FQDNs in series names (display only)', () => {
+ const node = 'hpr-us-east1-b-1.anvils.acme-inc.stage.harperfabric.com';
+ const payload = [
+ { dataKey: 'y', name: `cache hits — ${node}`, value: 5, color: '#f00' },
+ { dataKey: 'y', name: node, value: 7, color: '#0f0' },
+ ];
+ render(
+ ,
+ );
+ expect(screen.getByText('cache hits — hpr-us-east1-b-1')).toBeTruthy();
+ expect(screen.getByText('hpr-us-east1-b-1')).toBeTruthy();
+ expect(screen.queryByText(new RegExp('anvils'))).toBe(null);
+ });
+
+ it('disambiguates nodes whose short labels collide', () => {
+ const nodes = ['node1.us.acme.com', 'node1.eu.acme.com'];
+ const payload = nodes.map((n, i) => ({ dataKey: 'y', name: n, value: i, color: '#f00' }));
+ render(
+ ,
+ );
+ expect(screen.getByText('node1.us')).toBeTruthy();
+ expect(screen.getByText('node1.eu')).toBeTruthy();
+ });
+
+ it('does not over-shorten when one FQDN sits inside another node short label', () => {
+ // Short labels: acme.com→acme, x.acme.com.foo.net→x.acme.com.foo,
+ // x.acme.com.bar.net→x.acme.com.bar. `acme.com` is a substring of the
+ // `x.acme.com.foo` short label; a bare substring-replace would rewrite
+ // it to `x.acme.foo`. Boundary-anchored replacement leaves it intact.
+ const nodes = ['acme.com', 'x.acme.com.foo.net', 'x.acme.com.bar.net'];
+ const payload = [
+ { dataKey: 'y', name: 'acme.com', value: 1, color: '#f00' },
+ { dataKey: 'y', name: 'x.acme.com.foo.net', value: 2, color: '#0f0' },
+ ];
+ render(
+ ,
+ );
+ expect(screen.getByText('acme')).toBeTruthy();
+ expect(screen.getByText('x.acme.com.foo')).toBeTruthy();
+ expect(screen.queryByText('x.acme.foo')).toBe(null);
+ });
+
+ it('renders a Total row summing the payload when showTotal is set', () => {
+ const payload = [
+ { dataKey: 'a', name: 'A', value: 20, color: '#f00' },
+ { dataKey: 'b', name: 'B', value: 15, color: '#0f0' },
+ ];
+ render();
+ expect(screen.getByText(/Total/)).toBeTruthy();
+ expect(screen.getByText(/35/)).toBeTruthy();
+ });
+
+ it('renders no Total row without showTotal (line charts)', () => {
+ const payload = [
+ { dataKey: 'a', name: 'A', value: 20, color: '#f00' },
+ { dataKey: 'b', name: 'B', value: 15, color: '#0f0' },
+ ];
+ render();
+ expect(screen.queryByText(/Total/)).toBe(null);
+ });
+
+ it('suppresses the Total row for single-series payloads', () => {
+ const payload = [{ dataKey: 'a', name: 'A', value: 42, color: '#f00' }];
+ render();
+ expect(screen.queryByText(/Total/)).toBe(null);
+ });
+
+ it('uses raw count formatter for Total on count-si axis (preserve precision)', () => {
+ const payload = [
+ { dataKey: 'a', name: 'A', value: 12345, color: '#f00' },
+ { dataKey: 'b', name: 'B', value: 67890, color: '#0f0' },
+ ];
+ render(
+ ,
+ );
+ // Total = 80235; rendered raw, not "80k".
+ expect(screen.getByText(/80235\s*msg\/s/)).toBeTruthy();
+ });
+});
diff --git a/src/features/instance/status/analytics/__tests__/primitives/dimension-chip-row.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/dimension-chip-row.test.tsx
index 83a54eb6d..df8df3660 100644
--- a/src/features/instance/status/analytics/__tests__/primitives/dimension-chip-row.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/primitives/dimension-chip-row.test.tsx
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
-import { DimensionChipRow } from '../../primitives/DimensionChipRow.tsx';
+import { DimensionChipRow } from '../../primitives/DimensionChipRow';
describe('DimensionChipRow primitive', () => {
afterEach(() => cleanup());
diff --git a/src/features/instance/status/analytics/__tests__/primitives/dimension-combobox.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/dimension-combobox.test.tsx
index fecefdf53..5554bdd57 100644
--- a/src/features/instance/status/analytics/__tests__/primitives/dimension-combobox.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/primitives/dimension-combobox.test.tsx
@@ -2,7 +2,7 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { useState } from 'react';
import { afterEach, describe, expect, it } from 'vitest';
-import { DimensionCombobox } from '../../primitives/DimensionCombobox.tsx';
+import { DimensionCombobox } from '../../primitives/DimensionCombobox';
function Harness(props: { values: readonly string[]; initial: string; otherKey?: string }) {
const [sel, setSel] = useState(props.initial);
diff --git a/src/features/instance/status/analytics/__tests__/primitives/dimension-selector-pipe-dim.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/dimension-selector-pipe-dim.test.tsx
index f565c6c7f..5a187d5b3 100644
--- a/src/features/instance/status/analytics/__tests__/primitives/dimension-selector-pipe-dim.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/primitives/dimension-selector-pipe-dim.test.tsx
@@ -6,8 +6,8 @@
// reads the structured Series.dim/Series.node fields.
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
-import { DimensionSelectorRenderer } from '../../primitives/DimensionSelectorRenderer.tsx';
-import type { AnalyticsDataPoint, MetricSpec } from '../../types/analytics.ts';
+import { DimensionSelectorRenderer } from '../../primitives/DimensionSelectorRenderer';
+import type { AnalyticsDataPoint, MetricSpec } from '../../types/analytics';
const range = { startTime: 0, endTime: 10_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/primitives/dimension-selector-switch.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/dimension-selector-switch.test.tsx
index e1c5623c6..fd7922abf 100644
--- a/src/features/instance/status/analytics/__tests__/primitives/dimension-selector-switch.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/primitives/dimension-selector-switch.test.tsx
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
-import { DimensionSelectorRenderer } from '../../primitives/DimensionSelectorRenderer.tsx';
-import type { AnalyticsDataPoint, MetricSpec } from '../../types/analytics.ts';
+import { DimensionSelectorRenderer } from '../../primitives/DimensionSelectorRenderer';
+import type { AnalyticsDataPoint, MetricSpec } from '../../types/analytics';
const range = { startTime: 0, endTime: 10_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/primitives/fallback-renderer.test.ts b/src/features/instance/status/analytics/__tests__/primitives/fallback-renderer.test.ts
index 3fe1d88f5..2bab0c4cd 100644
--- a/src/features/instance/status/analytics/__tests__/primitives/fallback-renderer.test.ts
+++ b/src/features/instance/status/analytics/__tests__/primitives/fallback-renderer.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { buildFallbackPanels } from '../../primitives/FallbackRenderer.tsx';
-import type { AnalyticsDataPoint } from '../../types/analytics.ts';
+import { buildFallbackPanels } from '../../primitives/FallbackRenderer';
+import type { AnalyticsDataPoint } from '../../types/analytics';
describe('buildFallbackPanels', () => {
it('drops records lacking a numeric time instead of plotting them at x=0 (1970)', () => {
diff --git a/src/features/instance/status/analytics/__tests__/primitives/heatmap-matrix.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/heatmap-matrix.test.tsx
index 2ae3ada0b..749434bbe 100644
--- a/src/features/instance/status/analytics/__tests__/primitives/heatmap-matrix.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/primitives/heatmap-matrix.test.tsx
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { afterEach, describe, expect, it } from 'vitest';
-import { HeatmapMatrix } from '../../primitives/HeatmapMatrix.tsx';
-import type { HeatmapData } from '../../types/analytics.ts';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { computeHeaderHeight, HeatmapMatrix } from '../../primitives/HeatmapMatrix';
+import type { HeatmapData } from '../../types/analytics';
const data: HeatmapData = {
rows: ['src-1', 'src-2'],
@@ -26,7 +26,7 @@ const data: HeatmapData = {
describe('HeatmapMatrix primitive', () => {
afterEach(() => cleanup());
it('renders a grid with one row per data row', () => {
- render();
+ render();
const grid = screen.getByRole('grid');
expect(grid).toBeTruthy();
// 2 data rows + 1 header row
@@ -35,7 +35,7 @@ describe('HeatmapMatrix primitive', () => {
});
it('includes "(approx)" and "p95" in cell aria-labels when approx flag is set', () => {
- render();
+ render();
const cells = screen.getAllByRole('gridcell');
const okCell = cells.find((c) => /src-1.*dest-a|30/.test(c.getAttribute('aria-label') ?? ''))!;
expect(okCell).toBeTruthy();
@@ -45,39 +45,62 @@ describe('HeatmapMatrix primitive', () => {
});
it('absent cells announce "no data"', () => {
- render();
+ render();
const cell = screen.getAllByRole('gridcell').find((c) => (c.getAttribute('aria-label') ?? '').includes('no data'));
expect(cell).toBeTruthy();
});
it('suppresses cells below suppressBelow as data-confidence="suppress"', () => {
- render();
+ render();
const cells = screen.getAllByRole('gridcell');
const suppressed = cells.filter((c) => c.getAttribute('data-confidence') === 'suppress');
expect(suppressed.length >= 1).toBeTruthy();
});
it('greys cells where greyBelow ≤ count < suppressBelow', () => {
- render();
+ render();
const cells = screen.getAllByRole('gridcell');
const grey = cells.filter((c) => c.getAttribute('data-confidence') === 'grey');
expect(grey.length >= 1).toBeTruthy();
});
it('renders rowheader + columnheader cells', () => {
- render();
+ render();
expect(screen.getByRole('rowheader', { name: /src-1/i })).toBeTruthy();
expect(screen.getByRole('columnheader', { name: /dest-a/i })).toBeTruthy();
});
+ it('displays the short node name while keeping the full FQDN in a11y + title', () => {
+ const fqdnData = {
+ ...data,
+ rows: ['src-1.us-west.acme.harperfabric.com'],
+ cols: ['dest-a.us-east.acme.harperfabric.com'],
+ cells: [{
+ row: 'src-1.us-west.acme.harperfabric.com',
+ col: 'dest-a.us-east.acme.harperfabric.com',
+ value: 5,
+ count: 200,
+ }],
+ };
+ render();
+ // Displayed label text (the node's own value, not the nested
+ // ) is the first DNS segment…
+ const col = screen.getByRole('columnheader', { name: 'dest-a.us-east.acme.harperfabric.com' });
+ expect(col.querySelector('text')?.firstChild?.textContent).toBe('dest-a');
+ // …while the and aria-label preserve the full FQDN.
+ expect(col.querySelector('title')?.textContent).toBe('dest-a.us-east.acme.harperfabric.com');
+ const row = screen.getByRole('rowheader', { name: 'src-1.us-west.acme.harperfabric.com' });
+ expect(row.querySelector('text')?.firstChild?.textContent).toBe('src-1');
+ });
+
it('renders a color-scale legend', () => {
- render();
+ render();
const legend = screen.getByRole('img', { name: /color scale|p95 latency/i });
expect(legend).toBeTruthy();
});
it('ArrowRight moves focus one column right and preventDefaults', () => {
- render();
+ render();
const cells = screen.getAllByRole('gridcell');
// Find src-1 → dest-a
const first = cells.find((c) =>
@@ -94,7 +117,7 @@ describe('HeatmapMatrix primitive', () => {
});
it('ArrowRight on rightmost cell is a no-op (no wrap)', () => {
- render();
+ render();
const cells = screen.getAllByRole('gridcell');
const last = cells.find((c) =>
/src-1/.test(c.getAttribute('aria-label') ?? '') && /dest-c/.test(c.getAttribute('aria-label') ?? '')
@@ -106,7 +129,7 @@ describe('HeatmapMatrix primitive', () => {
});
it('Home/End jump to row start/end', () => {
- render();
+ render();
const cells = screen.getAllByRole('gridcell');
const mid = cells.find((c) =>
/src-1/.test(c.getAttribute('aria-label') ?? '') && /dest-b/.test(c.getAttribute('aria-label') ?? '')
@@ -120,20 +143,20 @@ describe('HeatmapMatrix primitive', () => {
});
it('omits "(approx)" from description when data.approx is false', () => {
- render();
+ render();
const desc = screen.getByTestId('heatmap-desc');
expect(desc.textContent ?? '').not.toMatch(/\(approx\)/i);
expect(desc.textContent ?? '').not.toMatch(/count-weighted-mean/i);
});
it('omits "(count-weighted-mean, approx)" from color-legend aria-label when data.approx is false', () => {
- render();
+ render();
const legend = screen.getByRole('img', { name: /p95 latency/i });
expect(legend.getAttribute('aria-label') ?? '').not.toMatch(/count-weighted-mean.*approx/i);
});
it('clamps the roving tabindex when the matrix shrinks (regression #1443)', () => {
- const { rerender } = render();
+ const { rerender } = render();
// Move the active cell to the last row/col via keyboard: End then
// ArrowDown lands on src-2 → dest-c ([1, 2]).
const cells = screen.getAllByRole('gridcell');
@@ -156,7 +179,7 @@ describe('HeatmapMatrix primitive', () => {
{ row: 'src-1', col: 'dest-b', value: 60, count: 300 },
],
};
- rerender();
+ rerender();
const shrunkCells = screen.getAllByRole('gridcell');
expect(shrunkCells.length).toBe(2);
@@ -173,7 +196,7 @@ describe('HeatmapMatrix primitive', () => {
});
it('exposes data-cell-size attribute as a number on the SVG root', () => {
- const { container } = render();
+ const { container } = render();
const svg = container.querySelector('svg[role="grid"]');
const cellSize = Number(svg?.getAttribute('data-cell-size'));
expect(Number.isFinite(cellSize) && cellSize >= 40 && cellSize <= 80).toBeTruthy();
@@ -224,6 +247,165 @@ describe('HeatmapMatrix primitive', () => {
expect(rect?.getAttribute('stroke')).toBe('var(--chart-heatmap-muted-stroke, #9ca3af)');
});
+ describe('cell activation (onCellSelect)', () => {
+ const findCell = (re: RegExp) =>
+ screen.getAllByRole('gridcell').find((c) => re.test(c.getAttribute('aria-label') ?? ''))!;
+
+ it('click on a data cell fires onCellSelect with (row, col)', () => {
+ const onCellSelect = vi.fn();
+ render();
+ fireEvent.click(findCell(/src-1.*dest-a/));
+ expect(onCellSelect).toHaveBeenCalledTimes(1);
+ expect(onCellSelect).toHaveBeenCalledWith('src-1', 'dest-a');
+ });
+
+ it('click on a grey (low-confidence) cell still fires — grey cells carry data', () => {
+ const onCellSelect = vi.fn();
+ render();
+ const grey = findCell(/src-2.*dest-a/);
+ expect(grey.getAttribute('data-confidence')).toBe('grey');
+ fireEvent.click(grey);
+ expect(onCellSelect).toHaveBeenCalledWith('src-2', 'dest-a');
+ });
+
+ it('Enter on the focused cell activates and preventDefaults', () => {
+ const onCellSelect = vi.fn();
+ render();
+ const cell = findCell(/src-1.*dest-b/);
+ (cell as HTMLElement).focus();
+ const notPrevented = fireEvent.keyDown(cell, { key: 'Enter' });
+ expect(notPrevented, 'Enter preventDefaulted').toBe(false);
+ expect(onCellSelect).toHaveBeenCalledWith('src-1', 'dest-b');
+ });
+
+ it('Space on the focused cell activates', () => {
+ const onCellSelect = vi.fn();
+ render();
+ const cell = findCell(/src-1.*dest-a/);
+ (cell as HTMLElement).focus();
+ fireEvent.keyDown(cell, { key: ' ' });
+ expect(onCellSelect).toHaveBeenCalledWith('src-1', 'dest-a');
+ });
+
+ it('suppressed and absent cells are aria-disabled and fire on neither click nor Enter', () => {
+ const onCellSelect = vi.fn();
+ render();
+ const suppressed = findCell(/src-2.*dest-b/);
+ const absent = findCell(/src-1.*dest-c/);
+ expect(suppressed.getAttribute('data-confidence')).toBe('suppress');
+ expect(absent.getAttribute('data-confidence')).toBe('absent');
+ for (const cell of [suppressed, absent]) {
+ expect(cell.getAttribute('aria-disabled')).toBe('true');
+ fireEvent.click(cell);
+ (cell as HTMLElement).focus();
+ fireEvent.keyDown(cell, { key: 'Enter' });
+ fireEvent.keyDown(cell, { key: ' ' });
+ }
+ expect(onCellSelect).not.toHaveBeenCalled();
+ });
+
+ it('data cells are not aria-disabled when a handler is present', () => {
+ render( {}} />);
+ expect(findCell(/src-1.*dest-a/).getAttribute('aria-disabled')).toBeNull();
+ });
+
+ it('without onCellSelect no cell is aria-disabled and Enter is inert', () => {
+ render();
+ const cells = screen.getAllByRole('gridcell');
+ expect(cells.every((c) => c.getAttribute('aria-disabled') === null)).toBe(true);
+ const cell = findCell(/src-1.*dest-a/);
+ (cell as HTMLElement).focus();
+ // Should not throw or preventDefault — nothing to activate.
+ const notPrevented = fireEvent.keyDown(cell, { key: 'Enter' });
+ expect(notPrevented).toBe(true);
+ });
+
+ it('grid description mentions activation only when a handler is present', () => {
+ const { unmount } = render( {}} />);
+ expect(screen.getByTestId('heatmap-desc').textContent ?? '').toMatch(/Enter or Space/);
+ unmount();
+ render();
+ expect(screen.getByTestId('heatmap-desc').textContent ?? '').not.toMatch(/Enter or Space/);
+ });
+ });
+
+ // Regression #1518: the rotated destination (column) labels used to anchor
+ // only 8px above the grid and rotate −45°, sending each label's far end DOWN
+ // into the first cell row where the cells painted over it. The fix anchors the
+ // label's END glyph just above the grid and leans it UP-and-left (+45°), so the
+ // anchor baseline is the label's lowest point and it never enters the cells;
+ // the header is sized so the far (top) end is never clipped either.
+ describe('column-label geometry (#1518)', () => {
+ const firstCellY = () => {
+ const rect = screen.getAllByRole('gridcell')[0].querySelector('rect')!;
+ return Number(rect.getAttribute('y'));
+ };
+ const colHeaderTexts = () => screen.getAllByRole('columnheader').map((g) => g.querySelector('text')!);
+
+ it('anchors every column label above the first cell row (no overlap)', () => {
+ render();
+ const cellY = firstCellY();
+ for (const t of colHeaderTexts()) {
+ // The label's near-end baseline (its lowest point once rotated +45°)
+ // sits strictly above where the cells begin.
+ expect(Number(t.getAttribute('y'))).toBeLessThan(cellY);
+ }
+ });
+
+ it('rotates the labels UP-left (+45°), not the old down-left −45°', () => {
+ render();
+ for (const t of colHeaderTexts()) {
+ const transform = t.getAttribute('transform') ?? '';
+ expect(transform).toMatch(/rotate\(\s*45\s*,/);
+ expect(transform).not.toMatch(/rotate\(\s*-45/);
+ }
+ });
+
+ it('reserves header room that matches computeHeaderHeight, and the viewBox contains it', () => {
+ const { container } = render();
+ const svg = container.querySelector('svg[role="grid"]')!;
+ const cellSize = Number(svg.getAttribute('data-cell-size'));
+ // The first cell row starts exactly at the reserved header height.
+ expect(firstCellY()).toBe(computeHeaderHeight(data.cols, cellSize));
+ // viewBox is "0 0 W H"; H must be tall enough to contain the header
+ // plus at least the first cell row — nothing clipped top or bottom.
+ const [minX, minY, , vbHeight] = (svg.getAttribute('viewBox') ?? '').split(/\s+/).map(Number);
+ expect(minX).toBe(0);
+ expect(minY).toBe(0);
+ expect(vbHeight).toBeGreaterThan(firstCellY());
+ });
+ });
+
+ describe('computeHeaderHeight', () => {
+ it('floors short-label grids at the minimum header height (72)', () => {
+ expect(computeHeaderHeight(['a', 'bb', 'ccc'], 40)).toBe(72);
+ expect(computeHeaderHeight([], 80)).toBe(72);
+ });
+
+ it('grows to contain long labels rotated 45° so the far end is not clipped', () => {
+ const longCols = ['node-alpha.us-east-1.example.internal', 'n2.example.internal'];
+ const wide = computeHeaderHeight(longCols, 80); // wide cells → 20-char truncation
+ expect(wide).toBeGreaterThan(72);
+ // Must reserve at least the diagonal's vertical extent of a 20-char label
+ // (20 · 6.6 · sin45 ≈ 93px) so the up-left far end clears y=0.
+ expect(wide).toBeGreaterThanOrEqual(93);
+ });
+
+ it('is monotonic in the longest label length', () => {
+ const short = computeHeaderHeight(['abcdefghijklmnop'], 80);
+ const longer = computeHeaderHeight(['abcdefghijklmnopqrst'], 80);
+ expect(longer).toBeGreaterThanOrEqual(short);
+ });
+
+ it('caps growth via the per-cellSize truncation (narrow cells truncate to 8 chars)', () => {
+ const label = ['a-very-long-destination-label-that-exceeds-limits'];
+ // Narrow cells truncate to 8 chars, so the header stays at the floor.
+ expect(computeHeaderHeight(label, 40)).toBe(72);
+ // Wide cells truncate to 20 chars, so the header grows past the floor.
+ expect(computeHeaderHeight(label, 80)).toBeGreaterThan(72);
+ });
+ });
+
it('takes the measure label from data.measureLabel (legend, description, and cell labels)', () => {
render();
const legend = screen.getByRole('img', { name: /p50 latency/i });
diff --git a/src/features/instance/status/analytics/__tests__/primitives/line-chart.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/line-chart.test.tsx
index a1900b5a8..0cb08d5fa 100644
--- a/src/features/instance/status/analytics/__tests__/primitives/line-chart.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/primitives/line-chart.test.tsx
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
-import { LineChart } from '../../primitives/LineChart.tsx';
-import type { SeriesData } from '../../types/analytics.ts';
+import { LineChart } from '../../primitives/LineChart';
+import type { SeriesData } from '../../types/analytics';
const singleSeries: SeriesData = {
series: [{ key: 's1', label: 'Series One', points: [{ x: 1, y: 10 }, { x: 2, y: 20 }] }],
@@ -18,18 +18,18 @@ describe('LineChart primitive', () => {
afterEach(() => cleanup());
it('renders empty-state when series is empty', () => {
- render();
+ render();
expect(screen.getByText(/no data in window/i)).toBeTruthy();
});
it('mounts a Recharts wrapper', () => {
- render();
+ render();
const container = document.querySelector('.recharts-responsive-container');
expect(container, 'ResponsiveContainer mounted').toBeTruthy();
});
it('exposes role=img with a composed aria-label so screen readers see the chart', () => {
- render();
+ render();
const img = screen.getByRole('img');
expect(img.getAttribute('aria-label') ?? '').toMatch(/Series One/);
});
@@ -41,7 +41,7 @@ describe('LineChart primitive', () => {
{ value: 0.999, label: '99.9% SLO', direction: 'below-is-bad' },
],
};
- render();
+ render();
// Recharts renders the label text somewhere in the SVG. Search the
// whole rendered DOM for a string containing label + direction marker.
await waitFor(() => {
@@ -52,7 +52,7 @@ describe('LineChart primitive', () => {
});
it('empty-state uses role=status for live-region announcement', () => {
- render();
+ render();
const status = screen.getByRole('status');
expect(status.textContent ?? '').toMatch(/no data in window/i);
});
@@ -67,7 +67,6 @@ describe('LineChart primitive', () => {
render(
{
{ key: 'b', label: 'B', points: [{ x: 0, y: 15 }, { x: 1, y: 25 }], opacity: 0.55 },
],
};
- const { container } = render();
+ const { container } = render();
// Recharts emits
// after ResponsiveContainer sizes via ResizeObserver. Wait for the dimmed
// line to assert it carries the explicit opacity.
@@ -103,7 +102,7 @@ describe('LineChart primitive', () => {
...singleSeries,
thresholds: [{ value: 15, label: 'max', direction: 'above-is-bad' }],
};
- render();
+ render();
// Recharts renders ReferenceLine inside the svg after ResponsiveContainer sizes.
await waitFor(() => {
const lines = document.querySelectorAll('.recharts-reference-line');
@@ -114,7 +113,7 @@ describe('LineChart primitive', () => {
it.skip('appends unit suffix to Y-axis tick labels', async () => {
// Use formatter='count' so the suffix is the only decoration on values.
const data: SeriesData = { series: [{ key: 'a', label: 'A', points: [{ x: 0, y: 50 }, { x: 1, y: 100 }] }] };
- const { container } = render();
+ const { container } = render();
await waitFor(() => {
const tickEls = container.querySelectorAll('.recharts-cartesian-axis-tick-value, .recharts-yAxis text');
const labels = Array.from(tickEls).map((t) => t.textContent ?? '');
diff --git a/src/features/instance/status/analytics/__tests__/primitives/small-multiples.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/small-multiples.test.tsx
index 37ed61308..18a3468a3 100644
--- a/src/features/instance/status/analytics/__tests__/primitives/small-multiples.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/primitives/small-multiples.test.tsx
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { cleanup, render, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
-import { SmallMultiples } from '../../primitives/SmallMultiples.tsx';
-import type { AxisSpec, SeriesData } from '../../types/analytics.ts';
+import { SmallMultiples } from '../../primitives/SmallMultiples';
+import type { AxisSpec, SeriesData } from '../../types/analytics';
const panels: { title: string; data: SeriesData; yAxis?: AxisSpec | { left: AxisSpec; right?: AxisSpec } }[] = [
{ title: 'CPU', data: { series: [{ key: 'cpu', label: 'cpu', points: [{ x: 1, y: 10 }] }] } },
diff --git a/src/features/instance/status/analytics/__tests__/primitives/stack-by-toggle.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/stack-by-toggle.test.tsx
index b7ce9f093..3688d3284 100644
--- a/src/features/instance/status/analytics/__tests__/primitives/stack-by-toggle.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/primitives/stack-by-toggle.test.tsx
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
-import { TrafficByTypeRenderer } from '../../primitives/TrafficByTypeRenderer.tsx';
-import type { AnalyticsDataPoint, MetricSpec } from '../../types/analytics.ts';
+import { TrafficByTypeRenderer } from '../../primitives/TrafficByTypeRenderer';
+import type { AnalyticsDataPoint, MetricSpec } from '../../types/analytics';
const range = { startTime: 0, endTime: 10_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/primitives/stacked-area.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/stacked-area.test.tsx
index 3f9d8d40e..ea17c3768 100644
--- a/src/features/instance/status/analytics/__tests__/primitives/stacked-area.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/primitives/stacked-area.test.tsx
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
-import { StackedAreaChart, StackedAreaTooltip } from '../../primitives/StackedAreaChart.tsx';
-import type { SeriesData } from '../../types/analytics.ts';
+import { StackedAreaChart } from '../../primitives/StackedAreaChart';
+import type { SeriesData } from '../../types/analytics';
const stacked: SeriesData = {
series: [
@@ -137,46 +137,3 @@ describe('StackedAreaChart Step 3.5 polish', () => {
document.documentElement.classList.remove('dark');
});
});
-
-// TODO: the it.skip() cases below are Recharts visual-rendering assertions
-// that depend on real layout (computed width/height, stroke geometry, axis
-// tick text). Both jsdom and happy-dom fall short here even with the
-// getBoundingClientRect / ResizeObserver shim in __tests__/setup.ts. Math is
-// covered by the pipeline + aggregator suites; revisit these visual checks
-// with a Playwright smoke pass once studio adopts E2E.
-describe('StackedAreaTooltip', () => {
- afterEach(() => cleanup());
-
- it('renders Total row summing payload values', () => {
- const payload = [
- { dataKey: 'a', name: 'A', value: 20, color: '#f00' },
- { dataKey: 'b', name: 'B', value: 15, color: '#0f0' },
- ];
- render();
- expect(screen.getByText(/Total/)).toBeTruthy();
- expect(screen.getByText(/35/)).toBeTruthy();
- });
-
- it('suppresses Total row for single-series payload', () => {
- const payload = [{ dataKey: 'a', name: 'A', value: 42, color: '#f00' }];
- render();
- expect(screen.queryByText(/Total/)).toBe(null);
- });
-
- it('uses raw count formatter for Total on count-si axis (preserve precision)', () => {
- const payload = [
- { dataKey: 'a', name: 'A', value: 12345, color: '#f00' },
- { dataKey: 'b', name: 'B', value: 67890, color: '#0f0' },
- ];
- render(
- ,
- );
- // Total = 80235; rendered raw not "80k".
- expect(screen.getByText(/80235\s*msg\/s/)).toBeTruthy();
- });
-
- it('returns null when inactive', () => {
- const { container } = render();
- expect(container.firstChild).toBe(null);
- });
-});
diff --git a/src/features/instance/status/analytics/__tests__/primitives/sync-id.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/sync-id.test.tsx
new file mode 100644
index 000000000..12bdce131
--- /dev/null
+++ b/src/features/instance/status/analytics/__tests__/primitives/sync-id.test.tsx
@@ -0,0 +1,160 @@
+// @vitest-environment happy-dom
+import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig';
+import { cleanup, render } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { type AnalyticsContextValue, AnalyticsProvider } from '../../context/AnalyticsContext';
+import type { SeriesData } from '../../types/analytics';
+
+// Capture the props the primitives hand to the Recharts wrapper components.
+// Rendering real Recharts gives no DOM signal for syncId (it lives in
+// Recharts' internal store), so swap the two wrappers for prop recorders.
+// All three charts route through useChartSyncProps (AnalyticsContext.tsx);
+// exercising them here covers the hook's tab/dialog/no-provider branches.
+const lineChartCalls: Record[] = [];
+const areaChartCalls: Record[] = [];
+
+vi.mock('recharts', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ LineChart: (props: Record) => {
+ lineChartCalls.push(props);
+ return null;
+ },
+ AreaChart: (props: Record) => {
+ areaChartCalls.push(props);
+ return null;
+ },
+ };
+});
+
+// Import after the mock so the primitives bind to the stubbed wrappers.
+const { LineChart } = await import('../../primitives/LineChart');
+const { StackedAreaChart } = await import('../../primitives/StackedAreaChart');
+const { TableSizeTrend } = await import('../../charts/TableSizeTrend');
+type TableSizeDerived = import('../../lib/tableSize').TableSizeDerived;
+
+const data: SeriesData = {
+ series: [{ key: 's1', label: 'Series One', points: [{ x: 1, y: 10 }, { x: 2, y: 20 }] }],
+};
+
+function makeContextValue(syncId?: string): AnalyticsContextValue {
+ return {
+ timeRange: { startTime: 0, endTime: 60_000 },
+ bucketMs: 60_000,
+ instanceParams: {
+ instanceClient: { post: async () => ({ data: [] }) } as never,
+ entityId: 'test-instance' as never,
+ entityType: 'instance',
+ } satisfies InstanceClientIdConfig & InstanceTypeConfig,
+ syncId,
+ };
+}
+
+function withProvider(children: ReactNode, syncId?: string) {
+ return {children};
+}
+
+describe('tab-scoped chart crosshair sync (syncId)', () => {
+ beforeEach(() => {
+ lineChartCalls.length = 0;
+ areaChartCalls.length = 0;
+ });
+ afterEach(() => cleanup());
+
+ it('LineChart passes the context syncId and syncMethod="value" to Recharts', () => {
+ render(withProvider(, 'test-instance:health'));
+ expect(lineChartCalls.length).toBeGreaterThan(0);
+ expect(lineChartCalls[0].syncId).toBe('test-instance:health');
+ expect(lineChartCalls[0].syncMethod).toBe('value');
+ });
+
+ it('StackedAreaChart passes the context syncId and syncMethod="value" to Recharts', () => {
+ render(withProvider(, 'test-instance:traffic'));
+ expect(areaChartCalls.length).toBeGreaterThan(0);
+ expect(areaChartCalls[0].syncId).toBe('test-instance:traffic');
+ expect(areaChartCalls[0].syncMethod).toBe('value');
+ });
+
+ // The expand dialog (the only fillParent caller) gets a separate sync
+ // scope: never the tab's id, so it can't drive the panels behind the
+ // overlay, but SmallMultiples dialogs keep intra-dialog sync.
+ it('LineChart with fillParent (expand dialog) uses the dialog scope, not the tab scope', () => {
+ render(withProvider(, 'test-instance:health'));
+ expect(lineChartCalls.length).toBeGreaterThan(0);
+ expect(lineChartCalls[0].syncId).toBe('test-instance:health:expanded');
+ expect(lineChartCalls[0].syncMethod).toBe('value');
+ });
+
+ it('StackedAreaChart with fillParent (expand dialog) uses the dialog scope, not the tab scope', () => {
+ render(withProvider(, 'test-instance:traffic'));
+ expect(areaChartCalls.length).toBeGreaterThan(0);
+ expect(areaChartCalls[0].syncId).toBe('test-instance:traffic:expanded');
+ expect(areaChartCalls[0].syncMethod).toBe('value');
+ });
+
+ it('LineChart outside any AnalyticsProvider gets no syncId', () => {
+ render();
+ expect(lineChartCalls.length).toBeGreaterThan(0);
+ expect(lineChartCalls[0].syncId).toBeUndefined();
+ expect(lineChartCalls[0].syncMethod).toBeUndefined();
+ });
+
+ it('StackedAreaChart outside any AnalyticsProvider gets no syncId', () => {
+ render();
+ expect(areaChartCalls.length).toBeGreaterThan(0);
+ expect(areaChartCalls[0].syncId).toBeUndefined();
+ expect(areaChartCalls[0].syncMethod).toBeUndefined();
+ });
+
+ it('a provider without syncId (e.g. legacy context value) does not sync charts', () => {
+ render(withProvider());
+ expect(lineChartCalls.length).toBeGreaterThan(0);
+ expect(lineChartCalls[0].syncId).toBeUndefined();
+ });
+
+ // TableSizeTrend (Storage tab) builds its chart from raw Recharts rather
+ // than the primitives, so it gets the same treatment separately.
+ const derived: TableSizeDerived = {
+ snapshot: { byNode: [], tableSet: ['db.t1'], hasOther: false, otherMembers: [] },
+ trend: () => [
+ { time: 1_000, values: { n1: 100 } },
+ { time: 2_000, values: { n1: 200 } },
+ ],
+ defaultSelection: () => 'db.t1',
+ emptyCause: null,
+ signature: 'sig',
+ };
+ const trendProps = {
+ derived,
+ viewMode: 'per-node' as const,
+ selectedTable: 'db.t1',
+ onChipSelect: () => {},
+ manualSelection: true,
+ range: { startTime: 0, endTime: 60_000 },
+ clusterNodeIds: ['n1'],
+ rankBy: 'bytes' as const,
+ onRankChange: () => {},
+ };
+
+ it('TableSizeTrend passes the context syncId and syncMethod="value" to Recharts', () => {
+ render(withProvider(, 'test-instance:storage'));
+ expect(lineChartCalls.length).toBeGreaterThan(0);
+ expect(lineChartCalls[0].syncId).toBe('test-instance:storage');
+ expect(lineChartCalls[0].syncMethod).toBe('value');
+ });
+
+ it('TableSizeTrend in the expand dialog uses the dialog scope, not the tab scope', () => {
+ render(withProvider(, 'test-instance:storage'));
+ expect(lineChartCalls.length).toBeGreaterThan(0);
+ expect(lineChartCalls[0].syncId).toBe('test-instance:storage:expanded');
+ expect(lineChartCalls[0].syncMethod).toBe('value');
+ });
+
+ it('TableSizeTrend outside any AnalyticsProvider gets no syncId', () => {
+ render();
+ expect(lineChartCalls.length).toBeGreaterThan(0);
+ expect(lineChartCalls[0].syncId).toBeUndefined();
+ });
+});
diff --git a/src/features/instance/status/analytics/__tests__/primitives/tooltip-gating-props.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/tooltip-gating-props.test.tsx
new file mode 100644
index 000000000..8af593559
--- /dev/null
+++ b/src/features/instance/status/analytics/__tests__/primitives/tooltip-gating-props.test.tsx
@@ -0,0 +1,126 @@
+// @vitest-environment happy-dom
+import { cleanup, fireEvent, render } from '@testing-library/react';
+import type { ReactElement, ReactNode } from 'react';
+import { Children, isValidElement } from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { SeriesData } from '../../types/analytics';
+
+// StackedAreaChart and TableSizeTrend render an unconditional