diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 3cabdcbc36c00..8f48264e86e09 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -62,6 +62,7 @@ The skills in `.claude/skills/` are the source of truth for conventions — load - `telemetry-standards` — PostHog events, `packages/common/telemetry-constants.ts` - `dev-toolbar-review` — `packages/dev-tools`, `packages/common/posthog-client.ts`, `packages/common/feature-flags.tsx` - `safe-sql-execution` — any code that builds or executes SQL against user databases +- `react-hook-form` — writing or modifying any form code, anywhere in the monorepo - `vitest` / `vercel-composition-patterns` — generic unit-testing and React composition references ## Studio diff --git a/.claude/skills/react-hook-form/SKILL.md b/.claude/skills/react-hook-form/SKILL.md new file mode 100644 index 0000000000000..29571cb24c742 --- /dev/null +++ b/.claude/skills/react-hook-form/SKILL.md @@ -0,0 +1,278 @@ +--- +name: react-hook-form +description: Correct React Hook Form usage anywhere in the monorepo — data flow, subscriptions, + reset, dirty state, number inputs, and controlled-input rules. Load this BEFORE + writing or modifying ANY form code, adding a field to an existing form, touching + watch/useWatch/formState/getValues/setValue/reset, wiring a form into a dialog or + sheet, or building a submit/cancel footer — even when the change looks trivial. + The codebase contains widespread RHF anti-patterns; without this skill you will + copy them. For form layout and which components to use, also load + studio-ui-patterns. +--- + +# React Hook Form + +How to write forms that stay correct as they grow. The existing codebase is **not** +a safe reference: `form.watch()` off prop-drilled form objects, subscription-only +watches, unguarded `valueAsNumber`, and `?? undefined` controlled values are all +common in older code and all wrong. Follow this skill, not the neighboring file. + +**Policy — fix what you touch.** New code must follow these rules. When you modify +existing form code, upgrade the specific fields/hooks/components you're editing to +match (e.g. a component you touch that calls `form.watch` gets converted to +`useWatch`). Leave untouched code alone, but tell the user about anti-patterns you +noticed and didn't fix. Never add new violations: `react-hook-form/no-use-watch` +is ratcheted in Studio CI — any increase in the warning count fails the build. + +## Mental model: subscriptions decide who re-renders + +RHF is uncontrolled at heart. Values live in refs; nothing re-renders unless a +subscription says so. Every read API is a subscription decision: + +| API | Subscribes | Re-renders | Use for | +| ----------------------------- | ---------- | -------------------------- | ---------------------------------------------- | +| `useWatch({ control, name })` | yes | only the calling component | reactive value reads, anywhere | +| `useFormState({ control })` | yes | only the calling component | `isDirty`/`errors`/etc. outside the form owner | +| `formState` (destructured) | yes | the `useForm` owner | form state **in the owner component only** | +| `form.watch(name)` | yes | the **entire form tree** | avoid — lint-flagged, see below | +| `getValues()` | no | never | event handlers and `onSubmit` only | +| `subscribe()` | callback | none | side effects outside render | + +Two facts explain most of the bugs we've shipped: + +1. **`form.watch()` and `form.formState` hoist their subscription to the `useForm` + owner**, no matter which component calls them. A child that reads + `form.watch('x')` off a prop works today only because the whole tree re-renders + on every change — it silently goes stale the moment anyone adds `React.memo` + between owner and child, and until then it re-renders every sibling on every + keystroke. A no-arg `form.watch()` sets `watchAll` and re-renders the tree on + every field change for the life of the form. +2. **`formState` is a Proxy** — reading a property is what arms the subscription. + Destructure it (`const { isDirty } = form.formState`), never pass the object + around or read it conditionally (`a && formState.isValid` may never subscribe). + Enforced by `react-hook-form/destructuring-formstate` (error). + +### Reading values, by location + +- **In the component that owns `useForm`:** destructure `formState`; prefer + `useWatch` over `form.watch` even here (the `no-use-watch` rule flags every + `watch`, and `useWatch` scopes the re-render if the JSX is later extracted). +- **In any child component or custom hook:** accept `control` (not the whole + `form`) and use `useWatch({ control, name })` / `useFormState({ control })`. + Inside `
` (which _is_ `FormProvider`), `useFormContext()` + + `useWatch({ name })` also works and avoids prop-drilling entirely. +- **Consume the return value.** Never call a watch for its subscription side + effect and then read via `getValues()` — the watch list and the read list will + drift apart (it has already happened; fields silently lost reactivity). The + value you render must _be_ the value you subscribed to. +- **One read path per value per render.** Mixing `useWatch('x')` on one line and + `getValues('x')` a few lines later lets the two disagree within a single render. +- **Name what you watch.** `useWatch({ control })` with no `name` re-renders on + every keystroke in every field. Subscribe to the specific names you use. +- `watch(callback)` is deprecated — use `subscribe()` for render-free listeners, + and always return its cleanup from `useEffect`. + +```tsx +// ❌ common in the codebase — all three subscriptions hoist to the form owner +function Fields({ form }: { form: UseFormReturn }) { + form.watch(['storageType', 'totalSize']) // return value discarded + const { errors } = form.formState // prop-form formState + const size = form.getValues('totalSize') // non-reactive read in render + ... +} + +// ✅ child subscribes for itself and consumes what it watches +function Fields({ control }: { control: Control }) { + const [storageType, totalSize] = useWatch({ control, name: ['storageType', 'totalSize'] }) + const { errors } = useFormState({ control }) + ... +} +``` + +## The canonical form + +zod schema → `z.infer` type → `useForm` with `zodResolver` and **complete** +`defaultValues` → `` → `FormField` render-prop per field → +`FormItemLayout` → `FormControl` → primitive from `ui`. Layout/container choices +(Card vs Sheet, `layout=` variants) are covered by the `studio-ui-patterns` skill +and the demos in `apps/design-system/registry/default/example/` +(`form-patterns-pagelayout.tsx`, `form-patterns-sidepanel.tsx`) — check them +before inventing structure. + +```tsx +// Module level — static references, not recreated on every render +const FORM_ID = 'pool-config-form' + +const FormSchema = z.object({ + name: z.string().min(1, 'Name is required'), + maxConnections: z + .union([z.literal(''), z.coerce.number().gte(1, 'Must be at least 1')]) + .refine((v) => v !== '', 'Max connections is required'), +}) +type FormValues = z.infer + +const defaultValues: FormValues = { name: '', maxConnections: '' } + +// Inside the component +const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues, +}) + + + + ( + + + + + + )} + /> + + +``` + +Define the schema, `type`, static `defaultValues`, and the form's id at module +level, outside the component. Rebuilding them per render is wasted work and +unstable references — RHF reads `defaultValues` only on the first render, but +anything else comparing against these objects sees a fresh identity each time. +When they genuinely depend on runtime data, build the schema with `useMemo` and +feed server-driven defaults through the `values` option (next section) instead +of hoisting. + +Submit buttons living outside the `
` (sheet/dialog footers) use the same +module-level `FORM_ID` via `form={FORM_ID}` on the button. A module-level id is +only safe for singleton forms — if the component can mount more than once at a +time, duplicate ids make external buttons submit the first matching form, so +mint a per-instance id with `useId()` and share it between the `` and its +buttons. + +## defaultValues, server data, and reset + +- **Provide a complete `defaultValues` object — every field, no `undefined`.** + `isDirty`, `dirtyFields`, and Cancel-reset all compare against it; a missing or + `undefined` default breaks all three, and `undefined` also makes React treat the + input as uncontrolled (see below). +- **Form populated from an API? Use the `values` option, not a hand-rolled + effect.** `values` reacts to the query resolving and resets the form for you; + computing `defaultValues` from a query that may not have loaded freezes whatever + happened to be in cache at mount. Add + `resetOptions: { keepDirtyValues: true }` when a background refetch must not + clobber the user's in-progress edits. (Good examples: + `components/interfaces/Settings/Database/ConnectionLogging.tsx`, + `components/interfaces/Storage/EditBucketModal.tsx`.) +- **After a successful mutation, re-baseline the form** in `onSuccess` so the + saved state becomes the new baseline (`isDirty` returns to false, Cancel now + reverts to the saved values). Prefer what the server actually persisted: if the + form uses `values` and the mutation invalidates the query, the refetch handles + this for you; if the mutation returns the updated resource, `reset(response)`. + `reset(submittedValues)` is the fallback for APIs that store exactly what was + sent — if the server normalizes or fills values, it baselines the form to data + that was never saved. A bare `reset()` reverts to the _previous_ defaults — + wrong after a save. +- Cancel buttons call `form.reset()`. This only visually restores fields whose + values round-trip through defined, controlled values — which is why the null + rules below matter. + +## Controlled inputs: never let `value` flip to `undefined` + +React decides controlled vs uncontrolled per render from whether `value` is +defined. A field whose value can be `undefined` (or becomes `undefined` on reset) +flips modes: console warnings, and — worse — `reset()` stops clearing the visible +text because React abandoned the DOM value. `value={field.value ?? undefined}` is +a bug, not a fix. + +- Text fields: default to `''`, never `null`/`undefined`. +- **Normalize `null` from the API at the form boundary** (`growthPercent ?? ''` + when building defaults) and convert back on submit (`'' → null`). Do not paper + over a `null` default with a `placeholder` that looks like a value: the user + sees "50", the form holds `null`, and every downstream comparison + (`defaultValues.growthPercent !== watched` → `null !== 50`) reports a permanent + phantom change while Cancel silently fails to reset the field. +- Selects/radios: default to `''` or a real option value; checkboxes/switches to + `false`. + +## Number inputs + +The blessed pattern keeps `''` as the "empty" sentinel so the input stays +controlled, and lets zod coerce on validation (see `maxConnections` above): +`z.union([z.literal(''), z.coerce.number()...]).refine((v) => v !== '', '…')` +with a plain ``. + +If you instead wire `onChange` through `e.target.valueAsNumber` (or +`valueAsNumber: true`), an empty or partially-typed input produces `NaN`, which +lands in form state and propagates into every calculation, price preview, and +`value` attribute downstream. Guard it with the **same empty sentinel the +field's schema declares** — with the `''`-union schema above: +`field.onChange(Number.isNaN(e.target.valueAsNumber) ? '' : e.target.valueAsNumber)`. +Never let `NaN` into form state. + +A nullable API field (`null` = "unset", e.g. a platform default applies) +doesn't change the in-form sentinel — keep `''` inside the form and convert at +the boundaries: + +```tsx +// inbound: null → '' when building defaults/values +values: { growthPercent: data.growth_percent ?? '' }, +// schema: '' stays the in-form sentinel, zod coerces real input +growthPercent: z.union([z.literal(''), z.coerce.number().gte(10).lte(100)]), +// outbound: '' → null in onSubmit +mutate({ growth_percent: values.growthPercent === '' ? null : values.growthPercent }) +``` + +If `null` does end up in form state (some existing forms hold it), keep it out +of both the input and the coercion: render via `value={field.value ?? ''}`, and +don't pass the value through `z.coerce.number()` — `Number(null)` is `0`, so a +nullable field fed into the coercing union silently validates empty as `0`. +Either way it's one sentinel per field, used consistently across defaults, +schema, `onChange`, rendering, and the submit mapping. + +## Dirty state and change detection + +- Gate Save on `isDirty`; show Cancel only when dirty. In the owner, destructure + from `form.formState`; anywhere else, `useFormState({ control })`. +- To show _which_ fields changed (review/summary dialogs), read `dirtyFields` + from the same subscription instead of hand-comparing + `defaultValues.x !== watchedX`. RHF already does that comparison correctly; + hand-rolled versions break on the null-vs-placeholder mismatch and must be + kept in sync with the watch list by hand. +- `setValue` outside user input needs explicit flags: + `setValue('x', v, { shouldDirty: true, shouldValidate: true })` — otherwise the + change is invisible to `isDirty` and validation. + +## Disabling and gating + +If a field must not be edited (plan tier, permissions, cooldown), disable the +field itself — a notice next to an editable input gates nothing. Wire the same +condition into both the notice and the control. Permission checks come from +`useAsyncCheckPermissions`; disabled buttons that need an explanation use +`ButtonTooltip`. + +Caution: `register`/`useController` `disabled: true` removes the field's value +from submission data. For "visible but locked" fields whose value must survive +submit, use the input's own `disabled`/`readOnly` prop (as `FormField` + +primitive props do) rather than RHF-level disabling, or the form-level +`disabled` option to freeze everything during async work. + +## Submit and mutations + +`onSubmit` receives validated, typed data — trust it; don't re-read via +`getValues()`. Mutations follow Studio conventions: `onSuccess` → `toast.success` + +- `reset(values)` (or query invalidation when using `values:`), `onError` → + `toast.error`; pass the mutation's `isPending` to the button's `loading` prop. + Default validation `mode: 'onSubmit'` is right for most forms — pick another mode + deliberately, not by copying. + +## Lint rules in force (Studio) + +| Rule | Level | Meaning | +| ------------------------------------------- | ---------------- | ------------------------------------------------ | +| `react-hook-form/destructuring-formstate` | error | destructure `formState`, never hold the object | +| `react-hook-form/no-access-control` | error | don't reach into `control` internals | +| `react-hook-form/no-nested-object-setvalue` | error | `setValue('a.b', v)`, not `setValue('a', {b:v})` | +| `react-hook-form/no-use-watch` | warn (ratcheted) | use `useWatch`, not `watch` | diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 502aa46c4b91d..33ffc26b371d0 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -82,7 +82,7 @@ knowledge_base: code_guidelines: filePatterns: # Studio code conventions — React/TS, UI patterns, composition, data fetching, errors - - files: '.claude/skills/{studio-best-practices,studio-ui-patterns,vercel-composition-patterns,studio-queries,studio-error-handling}/SKILL.md' + - files: '.claude/skills/{studio-best-practices,studio-ui-patterns,vercel-composition-patterns,studio-queries,studio-error-handling,react-hook-form}/SKILL.md' applyTo: 'apps/studio/**/*.{ts,tsx}' # Studio unit / component test conventions - files: '.claude/skills/{studio-testing,studio-mock-api-tests}/SKILL.md' diff --git a/apps/studio/CLAUDE.md b/apps/studio/CLAUDE.md index 53de52db8915a..5d4790ba75152 100644 --- a/apps/studio/CLAUDE.md +++ b/apps/studio/CLAUDE.md @@ -6,17 +6,18 @@ Next.js pages router + TanStack Start (mid-migration, see below), React 19. Dev Load the skills matching the task; stack them when a task spans areas: -| Task | Additional skills | -| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | -| Query/mutation hooks, query keys (`data/**`) | `studio-queries` | -| UI: pages, forms, tables, charts, sheets, empty states | `studio-ui-patterns` | -| Displaying API errors | `studio-error-handling` | -| Tests (deciding, writing, reviewing) | `studio-testing`, then `studio-mock-api-tests` (component/MSW) or `studio-e2e-tests` (Playwright) | -| PostHog event tracking | `telemetry-standards` | -| SQL against user databases | `safe-sql-execution` | -| Logs Explorer SQL, `data/logs` | `clickhouse-logs-queries` | -| Component API design, boolean-prop refactors | `vercel-composition-patterns` | -| User-facing copy | `copywriting` | +| Task | Additional skills | +| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Query/mutation hooks, query keys (`data/**`) | `studio-queries` | +| UI: pages, forms, tables, charts, sheets, empty states | `studio-ui-patterns` | +| Form logic: react-hook-form fields, watch/formState, reset, number inputs | `react-hook-form` | +| Displaying API errors | `studio-error-handling` | +| Tests (deciding, writing, reviewing) | `studio-testing`, then `studio-mock-api-tests` (component/MSW) or `studio-e2e-tests` (Playwright) | +| PostHog event tracking | `telemetry-standards` | +| SQL against user databases | `safe-sql-execution` | +| Logs Explorer SQL, `data/logs` | `clickhouse-logs-queries` | +| Component API design, boolean-prop refactors | `vercel-composition-patterns` | +| User-facing copy | `copywriting` | ## TanStack Start migration diff --git a/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.test.tsx b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.test.tsx new file mode 100644 index 0000000000000..155dc75c4ead6 --- /dev/null +++ b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.test.tsx @@ -0,0 +1,277 @@ +import { render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { ComputeAndDiskUsageCharts } from './ComputeAndDiskUsageCharts' +import type { InfraMonitoringMultiResponse } from '@/data/analytics/infra-monitoring-query' + +const { mockGetInfraMonitoringAttributes, mockUseInfraMonitoringAttributesQuery, mockUseProject } = + vi.hoisted(() => ({ + mockGetInfraMonitoringAttributes: vi.fn(), + mockUseInfraMonitoringAttributesQuery: vi.fn(), + mockUseProject: vi.fn(), + })) + +vi.mock('common', () => ({ + useParams: () => ({ ref: 'project-ref' }), +})) + +vi.mock('ui', () => ({ + cn: (...classes: Array) => classes.filter(Boolean).join(' '), +})) + +vi.mock('ui-patterns/Chart', async () => { + const React = await vi.importActual('react') + const ChartStateContext = React.createContext({ isLoading: false, isErrored: false }) + + return { + Chart: ({ + children, + isLoading, + isErrored, + }: { + children: ReactNode + isLoading: boolean + isErrored: boolean + }) => ( + + {children} + + ), + ChartCard: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), + ChartContent: ({ + children, + isEmpty, + loadingState, + errorState, + emptyState, + }: { + children: ReactNode + isEmpty: boolean + loadingState: ReactNode + errorState: ReactNode + emptyState: ReactNode + }) => { + const { isLoading, isErrored } = React.useContext(ChartStateContext) + + if (isLoading) return loadingState + if (isErrored) return errorState + if (isEmpty) return emptyState + return children + }, + ChartEmptyState: ({ title, description }: { title: string; description?: string }) => ( +
+ {title} + {description && {description}} +
+ ), + ChartHeader: ({ children }: { children: ReactNode }) =>
{children}
, + ChartLine: ({ dataKey, dataKeys }: { dataKey: string; dataKeys: string[] }) => ( +
+ ), + ChartLoadingState: () =>
Loading chart
, + ChartMetric: ({ + label, + value, + status, + tooltip, + }: { + label: string + value: ReactNode + status?: string + tooltip?: ReactNode + }) => ( +
+ {label}: {value} +
+ ), + } +}) + +vi.mock('@/data/analytics/infra-monitoring-query', () => ({ + getInfraMonitoringAttributes: mockGetInfraMonitoringAttributes, + useInfraMonitoringAttributesQuery: mockUseInfraMonitoringAttributesQuery, +})) + +vi.mock('@/hooks/misc/useSelectedProject', () => ({ + useSelectedProjectQuery: mockUseProject, +})) + +vi.mock('@/lib/helpers', () => ({ + formatBytes: (bytes: number) => `${bytes} bytes`, +})) + +const buildUsageResponse = ({ + cpu = 40, + memory = 50, + diskIo = 60, + database = 40, + wal = 5, + system = 5, + diskSize = 100, +}: { + cpu?: number + memory?: number + diskIo?: number + database?: number + wal?: number + system?: number + diskSize?: number +} = {}): InfraMonitoringMultiResponse => ({ + series: {}, + data: [ + { + period_start: '2026-07-20T00:00:00.000Z', + values: { + max_cpu_usage: String(cpu), + ram_usage: String(memory), + disk_io_consumption: String(diskIo), + pg_database_size: String(database), + disk_fs_used_wal: String(wal), + disk_fs_used_system: String(system), + disk_fs_size: String(diskSize), + }, + }, + ], +}) + +describe('ComputeAndDiskUsageCharts', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseProject.mockReturnValue({ data: { infra_compute_size: 'micro' } }) + mockUseInfraMonitoringAttributesQuery.mockReturnValue({ + data: buildUsageResponse(), + isLoading: false, + isError: false, + }) + mockGetInfraMonitoringAttributes.mockResolvedValue(buildUsageResponse()) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('renders warning and critical summaries and all chart data', () => { + mockUseInfraMonitoringAttributesQuery.mockReturnValue({ + data: buildUsageResponse({ + cpu: 80, + memory: 91, + diskIo: 70, + database: 80, + wal: 5, + system: 5, + }), + isLoading: false, + isError: false, + }) + + const { container } = render() + + expect(screen.getByTestId('metric-Compute')).toHaveTextContent('91%') + expect(screen.getByTestId('metric-Compute')).toHaveAttribute('data-status', 'negative') + expect(screen.getByTestId('metric-CPU')).toHaveAttribute('data-status', 'warning') + expect(screen.getByTestId('metric-Memory')).toHaveAttribute('data-status', 'negative') + expect(screen.getByTestId('metric-Disk IO')).toHaveAttribute('data-status', 'default') + expect(screen.getByTestId('metric-Compute')).toHaveAttribute('data-has-tooltip', 'true') + expect(screen.getByTestId('metric-Disk')).toHaveAttribute('data-has-tooltip', 'true') + for (const label of ['CPU', 'Memory', 'Disk IO', 'Database', 'WAL', 'System']) { + expect(screen.getByTestId(`metric-${label}`)).toHaveAttribute('data-has-tooltip', 'false') + } + expect(screen.getByTestId('metric-Disk')).toHaveTextContent('90%') + expect(screen.getByTestId('metric-Disk')).toHaveAttribute('data-status', 'negative') + expect(screen.getByTestId('chart-line-maxCpuUsage')).toHaveAttribute( + 'data-keys', + 'maxCpuUsage,ramUsage,diskIoConsumption' + ) + expect(container.firstElementChild).toHaveClass( + 'grid-cols-1', + '@[680px]:grid-cols-2', + '@[680px]:items-stretch' + ) + expect(container.querySelector('#cpu')).toBeInTheDocument() + expect(container.querySelector('#ram')).toBeInTheDocument() + expect(container.querySelector('#disk_io')).toBeInTheDocument() + expect(container.querySelector('#disk')).toBeInTheDocument() + }) + + it('hides burst-only disk IO and excludes it from status on dedicated-I/O compute', () => { + mockUseProject.mockReturnValue({ data: { infra_compute_size: '4xlarge' } }) + mockUseInfraMonitoringAttributesQuery.mockReturnValue({ + data: buildUsageResponse({ cpu: 40, memory: 50, diskIo: 95 }), + isLoading: false, + isError: false, + }) + + render() + + expect(screen.getByTestId('metric-Compute')).toHaveTextContent('50%') + expect(screen.getByTestId('metric-Compute')).toHaveAttribute('data-status', 'default') + expect(screen.queryByTestId('metric-Disk IO')).not.toBeInTheDocument() + expect(screen.getByTestId('chart-line-maxCpuUsage')).toHaveAttribute( + 'data-keys', + 'maxCpuUsage,ramUsage' + ) + }) + + it('renders empty states without presenting missing disk metrics as zero', () => { + mockUseInfraMonitoringAttributesQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + }) + + render() + + expect(screen.getByText('No compute data')).toBeInTheDocument() + expect(screen.getByText('No disk data')).toBeInTheDocument() + expect(screen.getByTestId('metric-Database')).toHaveTextContent('—') + expect(screen.getByTestId('metric-WAL')).toHaveTextContent('—') + expect(screen.getByTestId('metric-System')).toHaveTextContent('—') + }) + + it.each([ + { + state: 'loading', + queryState: { data: undefined, isLoading: true, isError: false }, + expectedText: 'Loading chart', + }, + { + state: 'error', + queryState: { data: undefined, isLoading: false, isError: true }, + expectedText: 'Failed to load usage data', + }, + ])('renders both $state states', ({ queryState, expectedText }) => { + mockUseInfraMonitoringAttributesQuery.mockReturnValue(queryState) + + render() + + expect(screen.getAllByText(expectedText)).toHaveLength(2) + }) + + it('uses a fresh rolling seven-day window when the query refetches', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-20T12:00:00.000Z')) + + render() + + const [, options] = mockUseInfraMonitoringAttributesQuery.mock.calls[0] + vi.setSystemTime(new Date('2026-07-27T12:00:00.000Z')) + await options.queryFn({ signal: undefined }) + + expect(mockGetInfraMonitoringAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + projectRef: 'project-ref', + startDate: '2026-07-20T12:00:00.000Z', + endDate: '2026-07-27T12:00:00.000Z', + interval: '1d', + }), + undefined + ) + }) +}) diff --git a/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.tsx b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.tsx new file mode 100644 index 0000000000000..ec8d8b68dbc10 --- /dev/null +++ b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.tsx @@ -0,0 +1,327 @@ +import { useParams } from 'common' +import dayjs from 'dayjs' +import { Activity, BarChart2, Database } from 'lucide-react' +import { useMemo } from 'react' +import type { ChartConfig } from 'ui' +import { cn } from 'ui' +import { + Chart, + ChartCard, + ChartContent, + ChartEmptyState, + ChartHeader, + ChartLine, + ChartLoadingState, + ChartMetric, +} from 'ui-patterns/Chart' + +import { + buildUsageChartData, + formatUsagePercent, + getComputeUsageSummary, + getDiskUsageSummary, + getUsageMetricStatus, + type UsageMetricStatus, +} from './ComputeAndDiskUsageCharts.utils' +import { hasBurstableIO } from './DiskManagement.utils' +import type { InfraMonitoringAttribute } from '@/data/analytics/infra-monitoring-query' +import { + getInfraMonitoringAttributes, + useInfraMonitoringAttributesQuery, +} from '@/data/analytics/infra-monitoring-query' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { formatBytes } from '@/lib/helpers' + +const USAGE_ATTRIBUTES = [ + 'max_cpu_usage', + 'ram_usage', + 'disk_io_consumption', + 'disk_fs_used_system', + 'disk_fs_used_wal', + 'pg_database_size', + 'disk_fs_size', +] satisfies InfraMonitoringAttribute[] + +const ROLLING_WINDOW_DAYS = 7 + +const getRollingWindow = () => { + const now = dayjs() + + return { + startDate: now.subtract(ROLLING_WINDOW_DAYS, 'day').toISOString(), + endDate: now.toISOString(), + } +} + +const COMPUTE_CHART_CONFIG = { + maxCpuUsage: { + label: 'CPU', + color: 'hsl(var(--brand-default))', + }, + ramUsage: { + label: 'Memory', + color: 'hsl(var(--warning-default))', + }, + diskIoConsumption: { + label: 'Disk IO', + color: 'hsl(var(--destructive-500))', + }, +} satisfies ChartConfig + +const DISK_CHART_CONFIG = { + databaseUsagePercent: { + label: 'Database', + color: 'hsl(var(--brand-default))', + }, + walUsagePercent: { + label: 'WAL', + color: 'hsl(var(--warning-default))', + }, + systemUsagePercent: { + label: 'System', + color: 'hsl(var(--destructive-500))', + }, +} satisfies ChartConfig + +const PERCENTAGE_Y_AXIS_PROPS = { + domain: [0, 100] as [number, number], + allowDataOverflow: true, + ticks: [0, 25, 50, 75, 100], + tickFormatter: (value: number) => `${Math.round(Number(value))}%`, + width: 64, +} + +const getUsageCardClassName = (status: UsageMetricStatus) => + cn( + 'h-full flex flex-col transition-colors', + status === 'warning' && 'border-warning-400 bg-warning-200/30', + status === 'negative' && 'border-destructive-400 bg-destructive-200/30' + ) + +export const ComputeAndDiskUsageCharts = ({ className }: { className?: string }) => { + const { ref: projectRef } = useParams() + const { data: project } = useSelectedProjectQuery() + const supportsBurstableIO = hasBurstableIO(project?.infra_compute_size) + + // Anchor query key dates to mount time; fetch uses a rolling 7-day window on each refetch. + const { startDate, endDate } = useMemo(getRollingWindow, []) + + const { + data: usageData, + isLoading, + isError, + } = useInfraMonitoringAttributesQuery( + { + projectRef, + attributes: USAGE_ATTRIBUTES, + startDate, + endDate, + interval: '1d', + }, + { + queryFn: ({ signal }) => + getInfraMonitoringAttributes( + { + projectRef, + attributes: USAGE_ATTRIBUTES, + ...getRollingWindow(), + interval: '1d', + }, + signal + ), + } + ) + + const { computeChartData, diskChartData } = useMemo( + () => buildUsageChartData(usageData), + [usageData] + ) + + const { + peakCpuUsage, + peakMemoryUsage, + peakDiskIoUsage, + peakComputeUsage, + status: computeUsageStatus, + } = useMemo( + () => getComputeUsageSummary(computeChartData, supportsBurstableIO), + [computeChartData, supportsBurstableIO] + ) + + const { + latestDataPoint: latestDiskDataPoint, + usagePercent: latestDiskUsage, + status: diskUsageStatus, + } = useMemo(() => getDiskUsageSummary(diskChartData), [diskChartData]) + + const isComputeEmpty = computeChartData.length === 0 + const isDiskEmpty = diskChartData.length === 0 + + return ( +
+
+ + + + + + +
+ + + {supportsBurstableIO && ( + + )} +
+
+ } + errorState={ + } + title="Failed to load usage data" + /> + } + emptyState={ + } + title="No compute data" + description="Usage data may take a few minutes to appear." + /> + } + > +
+ +
+
+
+
+
+ +
+ + + + +
+ + + +
+
+ } + errorState={ + } + title="Failed to load usage data" + /> + } + emptyState={ + } + title="No disk data" + description="Disk metrics may take a few minutes to appear." + /> + } + > +
+ +
+
+
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.test.ts b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.test.ts new file mode 100644 index 0000000000000..ad79f432675dd --- /dev/null +++ b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, test } from 'vitest' + +import { + buildUsageChartData, + clampPercentage, + formatUsagePercent, + getComputeUsageSummary, + getDiskUsageSummary, + getPeakChartValue, + getUsageMetricStatus, + getWorstUsageMetricStatus, + toNumber, + type ComputeUsageChartDatum, + type DiskUsageChartDatum, +} from './ComputeAndDiskUsageCharts.utils' +import type { + InfraMonitoringMultiResponse, + InfraMonitoringSingleResponse, +} from '@/data/analytics/infra-monitoring-query' + +const GB = 1024 ** 3 + +const buildMultiResponse = ( + data: InfraMonitoringMultiResponse['data'] +): InfraMonitoringMultiResponse => ({ + data, + series: {}, +}) + +describe('ComputeAndDiskUsageCharts utils', () => { + describe('toNumber', () => { + test('parses numeric strings', () => { + expect(toNumber('42')).toBe(42) + expect(toNumber('3.14')).toBe(3.14) + expect(toNumber('-5')).toBe(-5) + }) + + test('passes through finite numbers', () => { + expect(toNumber(0)).toBe(0) + expect(toNumber(99)).toBe(99) + }) + + test('falls back to 0 for undefined, empty, and non-numeric values', () => { + expect(toNumber(undefined)).toBe(0) + expect(toNumber('')).toBe(0) + expect(toNumber('not-a-number')).toBe(0) + }) + + test('falls back to 0 for non-finite values', () => { + expect(toNumber(Infinity)).toBe(0) + expect(toNumber('Infinity')).toBe(0) + expect(toNumber(NaN)).toBe(0) + }) + }) + + describe('clampPercentage', () => { + test('returns the value when within range', () => { + expect(clampPercentage(50)).toBe(50) + }) + + test('clamps to the [0, 100] bounds', () => { + expect(clampPercentage(-10)).toBe(0) + expect(clampPercentage(150)).toBe(100) + }) + + test('keeps exact boundary values', () => { + expect(clampPercentage(0)).toBe(0) + expect(clampPercentage(100)).toBe(100) + }) + }) + + describe('formatUsagePercent', () => { + test('renders an em dash for undefined', () => { + expect(formatUsagePercent(undefined)).toBe('—') + }) + + test('renders a rounded whole-number percentage', () => { + expect(formatUsagePercent(0)).toBe('0%') + expect(formatUsagePercent(42.4)).toBe('42%') + expect(formatUsagePercent(42.6)).toBe('43%') + expect(formatUsagePercent(100)).toBe('100%') + }) + }) + + describe('getUsageMetricStatus', () => { + test('returns default for undefined', () => { + expect(getUsageMetricStatus(undefined)).toBe('default') + }) + + test('returns default below the warning threshold', () => { + expect(getUsageMetricStatus(0)).toBe('default') + expect(getUsageMetricStatus(74.9)).toBe('default') + }) + + test('returns warning between 75 and 90 (inclusive of 75)', () => { + expect(getUsageMetricStatus(75)).toBe('warning') + expect(getUsageMetricStatus(89.9)).toBe('warning') + }) + + test('returns negative at or above 90', () => { + expect(getUsageMetricStatus(90)).toBe('negative') + expect(getUsageMetricStatus(100)).toBe('negative') + }) + }) + + describe('getWorstUsageMetricStatus', () => { + test('returns default with no values or all-default values', () => { + expect(getWorstUsageMetricStatus()).toBe('default') + expect(getWorstUsageMetricStatus(10, 20, undefined)).toBe('default') + }) + + test('escalates to warning when any value is in the warning band', () => { + expect(getWorstUsageMetricStatus(10, 80, 50)).toBe('warning') + }) + + test('escalates to negative when any value is critical, regardless of others', () => { + expect(getWorstUsageMetricStatus(80, 95, 10)).toBe('negative') + }) + }) + + describe('getPeakChartValue', () => { + test('returns undefined for an empty array', () => { + expect(getPeakChartValue([], 'maxCpuUsage')).toBeUndefined() + }) + + test('returns undefined when no values are numbers', () => { + const data = [{ maxCpuUsage: undefined }, { maxCpuUsage: undefined }] as unknown as Array< + Record + > + expect(getPeakChartValue(data, 'maxCpuUsage')).toBeUndefined() + }) + + test('returns the maximum numeric value for the key', () => { + const data = [{ value: 10 }, { value: 55 }, { value: 30 }] + expect(getPeakChartValue(data, 'value')).toBe(55) + }) + + test('ignores non-numeric entries when computing the peak', () => { + const data = [{ value: 10 }, { value: undefined }, { value: 42 }] as unknown as Array< + Record + > + expect(getPeakChartValue(data, 'value')).toBe(42) + }) + }) + + describe('buildUsageChartData', () => { + test('returns empty series for undefined data', () => { + expect(buildUsageChartData(undefined)).toEqual({ + computeChartData: [], + diskChartData: [], + }) + }) + + test('returns empty series for the single-attribute response shape', () => { + const singleResponse = { + yAxisLimit: 0, + format: '%', + total: 0, + totalAverage: 0, + data: [{ period_start: '2024-01-01T00:00:00Z', max_cpu_usage: '50' }], + } satisfies InfraMonitoringSingleResponse + + expect(buildUsageChartData(singleResponse)).toEqual({ + computeChartData: [], + diskChartData: [], + }) + }) + + test('maps compute attributes and clamps them to [0, 100]', () => { + const result = buildUsageChartData( + buildMultiResponse([ + { + period_start: '2024-01-01T00:00:00Z', + values: { + max_cpu_usage: '120', // over 100 -> clamped + ram_usage: '-5', // under 0 -> clamped + disk_io_consumption: '33', + }, + }, + ]) + ) + + expect(result.computeChartData).toEqual([ + { + timestamp: '2024-01-01T00:00:00Z', + maxCpuUsage: 100, + ramUsage: 0, + diskIoConsumption: 33, + }, + ]) + }) + + test('computes disk usage percentages relative to the disk size', () => { + const result = buildUsageChartData( + buildMultiResponse([ + { + period_start: '2024-01-01T00:00:00Z', + values: { + pg_database_size: String(25 * GB), + disk_fs_used_wal: String(25 * GB), + disk_fs_used_system: String(10 * GB), + disk_fs_size: String(100 * GB), + }, + }, + ]) + ) + + expect(result.diskChartData).toEqual([ + { + timestamp: '2024-01-01T00:00:00Z', + databaseBytes: 25 * GB, + walBytes: 25 * GB, + systemBytes: 10 * GB, + diskSizeBytes: 100 * GB, + databaseUsagePercent: 25, + walUsagePercent: 25, + systemUsagePercent: 10, + }, + ]) + }) + + test('drops disk points without a known disk size', () => { + const result = buildUsageChartData( + buildMultiResponse([ + { + period_start: '2024-01-01T00:00:00Z', + values: { pg_database_size: String(GB), disk_fs_size: '0' }, + }, + { + period_start: '2024-01-02T00:00:00Z', + values: { pg_database_size: String(GB), disk_fs_size: String(10 * GB) }, + }, + ]) + ) + + // First point dropped (disk_fs_size = 0), compute still has both timestamps + expect(result.diskChartData).toHaveLength(1) + expect(result.diskChartData[0].timestamp).toBe('2024-01-02T00:00:00Z') + expect(result.computeChartData).toHaveLength(2) + }) + + test('treats missing compute values as 0', () => { + const result = buildUsageChartData( + buildMultiResponse([{ period_start: '2024-01-01T00:00:00Z', values: {} }]) + ) + + expect(result.computeChartData).toEqual([ + { + timestamp: '2024-01-01T00:00:00Z', + maxCpuUsage: 0, + ramUsage: 0, + diskIoConsumption: 0, + }, + ]) + }) + }) + + describe('getComputeUsageSummary', () => { + test('returns undefined peaks and default status for empty data', () => { + expect(getComputeUsageSummary([])).toEqual({ + peakCpuUsage: undefined, + peakMemoryUsage: undefined, + peakDiskIoUsage: undefined, + peakComputeUsage: undefined, + status: 'default', + }) + }) + + test('computes per-metric peaks, the overall peak, and the worst status', () => { + const data: ComputeUsageChartDatum[] = [ + { timestamp: 't1', maxCpuUsage: 40, ramUsage: 92, diskIoConsumption: 10 }, + { timestamp: 't2', maxCpuUsage: 60, ramUsage: 80, diskIoConsumption: 20 }, + ] + + expect(getComputeUsageSummary(data)).toEqual({ + peakCpuUsage: 60, + peakMemoryUsage: 92, + peakDiskIoUsage: 20, + peakComputeUsage: 92, + status: 'negative', // memory peak of 92 is critical + }) + }) + + test('excludes disk IO from the overall peak and status for dedicated-I/O instances', () => { + const data: ComputeUsageChartDatum[] = [ + { timestamp: 't1', maxCpuUsage: 40, ramUsage: 50, diskIoConsumption: 95 }, + ] + + expect(getComputeUsageSummary(data, false)).toEqual({ + peakCpuUsage: 40, + peakMemoryUsage: 50, + peakDiskIoUsage: 95, + peakComputeUsage: 50, + status: 'default', + }) + }) + }) + + describe('getDiskUsageSummary', () => { + test('returns undefined usage and default status for empty data', () => { + expect(getDiskUsageSummary([])).toEqual({ + latestDataPoint: undefined, + usedBytes: 0, + sizeBytes: 0, + usagePercent: undefined, + status: 'default', + }) + }) + + test('summarizes the latest data point and clamps the usage percentage', () => { + const data: DiskUsageChartDatum[] = [ + { + timestamp: 't1', + databaseBytes: 10 * GB, + walBytes: 0, + systemBytes: 0, + diskSizeBytes: 100 * GB, + databaseUsagePercent: 10, + walUsagePercent: 0, + systemUsagePercent: 0, + }, + { + timestamp: 't2', + databaseBytes: 80 * GB, + walBytes: 10 * GB, + systemBytes: 5 * GB, + diskSizeBytes: 100 * GB, + databaseUsagePercent: 80, + walUsagePercent: 10, + systemUsagePercent: 5, + }, + ] + + const summary = getDiskUsageSummary(data) + expect(summary.latestDataPoint?.timestamp).toBe('t2') + expect(summary.usedBytes).toBe(95 * GB) + expect(summary.sizeBytes).toBe(100 * GB) + expect(summary.usagePercent).toBe(95) + expect(summary.status).toBe('negative') + }) + + test('returns undefined usage when the latest disk size is 0', () => { + const data: DiskUsageChartDatum[] = [ + { + timestamp: 't1', + databaseBytes: 0, + walBytes: 0, + systemBytes: 0, + diskSizeBytes: 0, + databaseUsagePercent: 0, + walUsagePercent: 0, + systemUsagePercent: 0, + }, + ] + + expect(getDiskUsageSummary(data).usagePercent).toBeUndefined() + }) + }) +}) diff --git a/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.ts b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.ts new file mode 100644 index 0000000000000..5f18c7a23d7c7 --- /dev/null +++ b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.ts @@ -0,0 +1,149 @@ +import type { InfraMonitoringResponse } from '@/data/analytics/infra-monitoring-query' + +export type UsageMetricStatus = 'default' | 'warning' | 'negative' + +export type ComputeUsageChartDatum = { + timestamp: string + maxCpuUsage: number + ramUsage: number + diskIoConsumption: number +} + +export type DiskUsageChartDatum = { + timestamp: string + databaseBytes: number + walBytes: number + systemBytes: number + diskSizeBytes: number + databaseUsagePercent: number + walUsagePercent: number + systemUsagePercent: number +} + +/** Coerces an API value (string | number | undefined) into a finite number, defaulting to 0. */ +export const toNumber = (value: string | number | undefined) => { + const parsedValue = Number(value) + return Number.isFinite(parsedValue) ? parsedValue : 0 +} + +/** Constrains a percentage to the [0, 100] range so charts never overflow their axis. */ +export const clampPercentage = (value: number) => Math.min(Math.max(value, 0), 100) + +export const formatUsagePercent = (value: number | undefined) => + value === undefined ? '—' : `${value.toFixed(0)}%` + +export const getUsageMetricStatus = (value: number | undefined): UsageMetricStatus => { + if (value === undefined) return 'default' + if (value >= 90) return 'negative' + if (value >= 75) return 'warning' + return 'default' +} + +export const getWorstUsageMetricStatus = ( + ...values: Array +): UsageMetricStatus => { + const statuses = values.map(getUsageMetricStatus) + if (statuses.includes('negative')) return 'negative' + if (statuses.includes('warning')) return 'warning' + return 'default' +} + +/** Returns the highest numeric value for a given key across all data points, or undefined when empty. */ +export const getPeakChartValue = >( + data: T[], + dataKey: keyof T +): number | undefined => { + const values = data + .map((point) => point[dataKey] as unknown) + .filter((value): value is number => typeof value === 'number') + + if (values.length === 0) return undefined + + return Math.max(...values) +} + +/** + * Transforms a raw infra-monitoring response into the compute and disk chart series. + * Disk points without a known disk size are dropped so usage percentages stay meaningful. + */ +export const buildUsageChartData = ( + usageData: InfraMonitoringResponse | undefined +): { computeChartData: ComputeUsageChartDatum[]; diskChartData: DiskUsageChartDatum[] } => { + if (!usageData || !('series' in usageData)) { + return { computeChartData: [], diskChartData: [] } + } + + const computeChartData = usageData.data.map((point) => ({ + timestamp: point.period_start, + maxCpuUsage: clampPercentage(toNumber(point.values.max_cpu_usage)), + ramUsage: clampPercentage(toNumber(point.values.ram_usage)), + diskIoConsumption: clampPercentage(toNumber(point.values.disk_io_consumption)), + })) + + const diskChartData = usageData.data.flatMap((point) => { + const databaseBytes = toNumber(point.values.pg_database_size) + const walBytes = toNumber(point.values.disk_fs_used_wal) + const systemBytes = toNumber(point.values.disk_fs_used_system) + const totalBytes = toNumber(point.values.disk_fs_size) + if (totalBytes <= 0) return [] + + return { + timestamp: point.period_start, + databaseBytes, + walBytes, + systemBytes, + diskSizeBytes: totalBytes, + databaseUsagePercent: clampPercentage((databaseBytes / totalBytes) * 100), + walUsagePercent: clampPercentage((walBytes / totalBytes) * 100), + systemUsagePercent: clampPercentage((systemBytes / totalBytes) * 100), + } + }) + + return { computeChartData, diskChartData } +} + +/** Derives peak compute values and the worst-case status for metrics supported by the instance. */ +export const getComputeUsageSummary = ( + data: ComputeUsageChartDatum[], + includeDiskIo: boolean = true +) => { + const peakCpuUsage = getPeakChartValue(data, 'maxCpuUsage') + const peakMemoryUsage = getPeakChartValue(data, 'ramUsage') + const peakDiskIoUsage = getPeakChartValue(data, 'diskIoConsumption') + + const supportedPeaks = [ + peakCpuUsage, + peakMemoryUsage, + ...(includeDiskIo ? [peakDiskIoUsage] : []), + ] + const peaks = supportedPeaks.filter((value): value is number => value !== undefined) + const peakComputeUsage = peaks.length > 0 ? Math.max(...peaks) : undefined + + return { + peakCpuUsage, + peakMemoryUsage, + peakDiskIoUsage, + peakComputeUsage, + status: getWorstUsageMetricStatus(...supportedPeaks), + } +} + +/** Derives the latest used/total bytes, overall usage percentage, and status for the disk card. */ +export const getDiskUsageSummary = (data: DiskUsageChartDatum[]) => { + const latestDataPoint = data[data.length - 1] + + const usedBytes = + (latestDataPoint?.databaseBytes ?? 0) + + (latestDataPoint?.walBytes ?? 0) + + (latestDataPoint?.systemBytes ?? 0) + const sizeBytes = latestDataPoint?.diskSizeBytes ?? 0 + const usagePercent = sizeBytes > 0 ? clampPercentage((usedBytes / sizeBytes) * 100) : undefined + + return { + latestDataPoint, + usedBytes, + sizeBytes, + usagePercent, + status: getUsageMetricStatus(usagePercent), + } +} diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx index f3e54c03a108f..e6fb89e3de59c 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx @@ -16,6 +16,7 @@ import { PageSectionTitle, } from 'ui-patterns/PageSection' +import { ComputeAndDiskUsageCharts } from './ComputeAndDiskUsageCharts' import { CreateDiskStorageSchema, DiskStorageSchemaType } from './DiskManagement.schema' import { DiskManagementMessage } from './DiskManagement.types' import { @@ -61,7 +62,7 @@ import { } from '@/hooks/misc/useSelectedProject' import { GB, PROJECT_STATUS } from '@/lib/constants' -export function DiskManagementForm() { +export function DiskManagementForm({ chartsClassName }: { chartsClassName?: string } = {}) { const { ref: projectRef } = useParams() const { data: project, isPending: isProjectPending } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() @@ -390,6 +391,12 @@ export function DiskManagementForm() { + + + + + + {(isProjectResizing || isProjectRequestingDiskChanges || (isEntitlementsLoaded && !isPlanUpgradeRequired && noPermissions)) && ( diff --git a/apps/studio/components/interfaces/DiskManagement/ui/DiskSpaceBar.tsx b/apps/studio/components/interfaces/DiskManagement/ui/DiskSpaceBar.tsx index 1e7491d44e976..b5787f9f6574c 100644 --- a/apps/studio/components/interfaces/DiskManagement/ui/DiskSpaceBar.tsx +++ b/apps/studio/components/interfaces/DiskManagement/ui/DiskSpaceBar.tsx @@ -238,15 +238,6 @@ export const DiskSpaceBar = ({ form }: DiskSpaceBarProps) => { />
)} -

- Note: Disk Size refers to the total space your - project occupies on disk, including the database itself (currently{' '} - {formatBytes(diskBreakdownBytes?.dbSizeBytes, 2, 'GB')}), additional files like - the write-ahead log (currently{' '} - {formatBytes(diskBreakdownBytes?.walSizeBytes, 2, 'GB')}), and other system - resources (currently {formatBytes(diskBreakdownBytes?.systemBytes, 2, 'GB')}). - Data can take 5 minutes to refresh. -

) } diff --git a/apps/studio/components/interfaces/Observability/DatabaseConnections/Activity.tsx b/apps/studio/components/interfaces/Observability/DatabaseConnections/Activity.tsx index 6152edeebbee2..4cd5b3f822771 100644 --- a/apps/studio/components/interfaces/Observability/DatabaseConnections/Activity.tsx +++ b/apps/studio/components/interfaces/Observability/DatabaseConnections/Activity.tsx @@ -1,13 +1,12 @@ import { isEqual } from 'lodash' import { Search, X } from 'lucide-react' -import { parseAsArrayOf, parseAsInteger, parseAsString, useQueryState, useQueryStates } from 'nuqs' -import { useEffect } from 'react' +import { parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs' import { Button, Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { ReportsSelectFilter } from '../../Reports/v2/ReportsSelectFilter' -import { ActivityRow } from './ActivityRow' +import { GroupedActivityRow } from './ActivityRow' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query' import { useDatabaseActivityQuery } from '@/data/database/activity-query' @@ -22,14 +21,13 @@ interface ActivityProps { export const Activity = ({ live }: ActivityProps) => { const { data: project } = useSelectedProjectQuery() - const [selectedPid] = useQueryState('pid', parseAsInteger) - const [ { search: searchFilter, states: statesFilter, applications: applicationsFilter, roles: rolesFilter, + view: viewFilter, }, setQueryStates, ] = useQueryStates({ @@ -37,15 +35,17 @@ export const Activity = ({ live }: ActivityProps) => { states: parseAsArrayOf(parseAsString, ',').withDefault([]), applications: parseAsArrayOf(parseAsString, ',').withDefault([]), roles: parseAsArrayOf(parseAsString, ',').withDefault(DEFAULT_ROLES_FILTER), + view: parseAsString.withDefault(''), }) const hasNoFiltersApplied = searchFilter.length === 0 && statesFilter.length === 0 && applicationsFilter.length === 0 && - isEqual(rolesFilter, DEFAULT_ROLES_FILTER) + isEqual(rolesFilter, DEFAULT_ROLES_FILTER) && + viewFilter === '' - const { data, isPending, isSuccess } = useDatabaseActivityQuery( + const { data, isPending } = useDatabaseActivityQuery( { projectRef: project?.ref, connectionString: project?.connectionString, @@ -61,6 +61,9 @@ export const Activity = ({ live }: ActivityProps) => { const matchesSearch = (activity: { query: string | null }) => !searchFilter || (activity.query?.toLowerCase().includes(searchFilter.toLowerCase()) ?? false) + // Pids referenced in some other activity's blocked_by - i.e. they are blocking something + const blockingPids = new Set((data ?? []).flatMap((x) => x.blocked_by)) + const activities = data?.filter((activity) => { const matchesState = !statesFilter || @@ -69,8 +72,18 @@ export const Activity = ({ live }: ActivityProps) => { const matchesRole = rolesFilter.length === 0 || rolesFilter.includes(activity.role_name) const matchesApplication = applicationsFilter.length === 0 || applicationsFilter.includes(activity.application_name) - return matchesState && matchesRole && matchesApplication && matchesSearch(activity) + // In the blocked view, only show root blockers - activities blocking others while not + // themselves blocked. Everything they block is shown nested under them instead. + const matchesView = + viewFilter !== 'blockers' || + (activity.blocked_by.length === 0 && blockingPids.has(activity.pid)) + return ( + matchesState && matchesRole && matchesApplication && matchesView && matchesSearch(activity) + ) }) + const rootBlockers = (data ?? []).filter( + (x) => x.blocked_by.length === 0 && blockingPids.has(x.pid) + ) const stateOptions = [ 'Idle', @@ -87,6 +100,7 @@ export const Activity = ({ live }: ActivityProps) => { y.state === x.toLowerCase() && (rolesFilter.length === 0 || rolesFilter.includes(y.role_name)) && (applicationsFilter.length === 0 || applicationsFilter.includes(y.application_name)) && + (viewFilter !== 'blockers' || y.blocked_by.length > 0) && matchesSearch(y) ).length, })) @@ -103,6 +117,7 @@ export const Activity = ({ live }: ActivityProps) => { (!statesFilter || statesFilter.length === 0 || (y.state !== null && statesFilter.includes(y.state))) && + (viewFilter !== 'blockers' || y.blocked_by.length > 0) && matchesSearch(y) ).length, })) @@ -121,6 +136,7 @@ export const Activity = ({ live }: ActivityProps) => { statesFilter.length === 0 || (y.state !== null && statesFilter.includes(y.state))) && (applicationsFilter.length === 0 || applicationsFilter.includes(y.application_name)) && + (viewFilter !== 'blockers' || y.blocked_by.length > 0) && matchesSearch(y) ).length, })) @@ -139,26 +155,19 @@ export const Activity = ({ live }: ActivityProps) => { states: [], roles: DEFAULT_ROLES_FILTER, applications: [], + view: '', }) } - useEffect(() => { - if (selectedPid && isSuccess) { - document - .getElementById(selectedPid.toString()) - ?.scrollIntoView({ behavior: 'smooth', block: 'center' }) - } - }, [selectedPid, isSuccess]) - return (

Sessions

-
+
} placeholder="Search query" - className="w-64" + className="w-56" value={searchFilter} onChange={(e) => setQueryStates({ search: e.target.value })} /> @@ -188,6 +197,19 @@ export const Activity = ({ live }: ActivityProps) => { isLoading={isPending} popoverClassName="w-60" /> + 0 ? {rootBlockers.length} : null} + onClick={() => setQueryStates({ view: viewFilter === 'blockers' ? '' : 'blockers' })} + tooltip={{ + content: { + side: 'bottom', + text: 'Shows queries currently blocking others', + }, + }} + > + Root blockers + {!hasNoFiltersApplied && ( { ) : null} {activities?.map((activity) => ( - + ))} diff --git a/apps/studio/components/interfaces/Observability/DatabaseConnections/ActivityRow.tsx b/apps/studio/components/interfaces/Observability/DatabaseConnections/ActivityRow.tsx index 6a4440bf9df74..e9bc18e2201db 100644 --- a/apps/studio/components/interfaces/Observability/DatabaseConnections/ActivityRow.tsx +++ b/apps/studio/components/interfaces/Observability/DatabaseConnections/ActivityRow.tsx @@ -1,5 +1,5 @@ -import { Minus, MoreVertical, StopCircle } from 'lucide-react' -import { parseAsInteger, useQueryState } from 'nuqs' +import { ChevronRight, Minus, MoreVertical, StopCircle } from 'lucide-react' +import { parseAsInteger, parseAsString, useQueryState } from 'nuqs' import { Fragment, useState } from 'react' import { toast } from 'sonner' import { @@ -34,7 +34,12 @@ import { WARN_DURATION_ACTIVE_QUERY, WARN_DURATION_IDLE_TXN, } from './DatabaseConnections.constants' -import { getBadgeVariant, getDuration } from './DatabaseConnections.utils' +import { + getBadgeVariant, + getBlockChain, + getBlockingChain, + getDuration, +} from './DatabaseConnections.utils' import { formatDuration } from '@/components/interfaces/QueryPerformance/QueryPerformance.utils' import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip' import { InlineLinkClassName } from '@/components/ui/InlineLink' @@ -44,23 +49,60 @@ import { useQueryAbortMutation } from '@/data/sql/abort-query-mutation' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { formatSql } from '@/lib/formatSql' -const getBlockChain = (pid: number, activities: DatabaseActivity[]) => { - const chain = [pid] - const visited = new Set([pid]) - let current = activities.find((x) => x.pid === pid) +export const GroupedActivityRow = ({ activity }: { activity: DatabaseActivity }) => { + const { data: project } = useSelectedProjectQuery() + const [view] = useQueryState('view', parseAsString.withDefault('')) + + const [expanded, setExpanded] = useState(false) - while (current && current.blocked_by.length > 0) { - const nextPid = current.blocked_by[0] - if (visited.has(nextPid)) break - chain.push(nextPid) - visited.add(nextPid) - current = activities.find((x) => x.pid === nextPid) - } + const { data } = useDatabaseActivityQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) - return chain + const queriesBlockedBy = getBlockingChain(activity.pid, data ?? []) + .map((pid) => data?.find((x) => x.pid === pid)) + .filter((x) => x !== undefined) + + return ( + <> + 0 && view === 'blockers' + ? () => setExpanded((prev) => !prev) + : undefined + } + /> + + {expanded && + view === 'blockers' && + queriesBlockedBy.map((x, index) => ( + + ))} + + ) } -export const ActivityRow = ({ activity }: { activity: DatabaseActivity }) => { +export const ActivityRow = ({ + activity, + expanded, + nested, + isLast, + onExpand, +}: { + activity: DatabaseActivity + expanded?: boolean + nested?: boolean + isLast?: boolean + onExpand?: () => void +}) => { const { data: project } = useSelectedProjectQuery() const [showTerminateConfirmDialog, setShowTerminateConfirmDialog] = useState(false) const [selectedPid, setSelectedPid] = useQueryState('pid', parseAsInteger) @@ -108,20 +150,69 @@ export const ActivityRow = ({ activity }: { activity: DatabaseActivity }) => { return ( <> - + td]:py-3', nested && 'bg-alternative')} + > {selectedPid === activity.pid && ( -
+
+ )} + + {/* Absolute (not inline in the flex row) so top-0/bottom-0 ignore the cell's padding and touch the adjacent row */} + {nested && + (isLast ? ( +
+ ) : ( + <> +
+
+ + ))} + + {/* Starts right below the expand button (row's own py-3 top padding + button height), reaches bottom-0 to touch the first nested row's border */} + {!!onExpand && expanded && ( +
)} - - - {activity.state} - - {activity.state && ( - {QUERY_STATE_TOOLTIP[activity.state]} + +
+ {nested &&
} + + {!!onExpand && ( +
+ diff --git a/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.test.ts b/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.test.ts index 243b7d053e9cc..dc7994b097f9d 100644 --- a/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.test.ts +++ b/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getConnectionMetrics } from './DatabaseConnections.utils' +import { getBlockChain, getBlockingChain, getConnectionMetrics } from './DatabaseConnections.utils' import { type DatabaseActivity } from '@/data/database/activity-query' const NOW = '2024-01-15T12:00:00Z' @@ -193,6 +193,34 @@ describe('getConnectionMetrics', () => { const { queryBlockingTheMostQueries } = getConnectionMetrics(activities) expect(queryBlockingTheMostQueries?.activity.pid).toBe(1) + expect(queryBlockingTheMostQueries?.count).toBe(4) + }) + + it('counts transitively - a longer block chain outweighs several short ones', () => { + const activities = [ + activity({ pid: 1, blocked_by: [2] }), + activity({ pid: 2, blocked_by: [3] }), + activity({ pid: 3, blocked_by: [] }), + activity({ pid: 4, blocked_by: [5] }), + activity({ pid: 5, blocked_by: [] }), + ] + + const { queryBlockingTheMostQueries } = getConnectionMetrics(activities) + expect(queryBlockingTheMostQueries?.activity.pid).toBe(3) + expect(queryBlockingTheMostQueries?.count).toBe(2) + }) + + it('counts a diamond-shaped block pattern once, not per incoming path', () => { + // root blocks both p1 and p2 directly, and both p1 and p2 block w - w must only count once + const activities = [ + activity({ pid: 0, blocked_by: [] }), + activity({ pid: 1, blocked_by: [0] }), + activity({ pid: 2, blocked_by: [0] }), + activity({ pid: 3, blocked_by: [1, 2] }), + ] + + const { queryBlockingTheMostQueries } = getConnectionMetrics(activities) + expect(queryBlockingTheMostQueries?.activity.pid).toBe(0) expect(queryBlockingTheMostQueries?.count).toBe(3) }) @@ -217,3 +245,89 @@ describe('getConnectionMetrics', () => { }) }) }) + +describe('getBlockChain', () => { + it('returns just the pid when it is not blocked', () => { + const activities = [activity({ pid: 1, blocked_by: [] })] + + expect(getBlockChain(1, activities)).toEqual([1]) + }) + + it('walks blocked_by up to the root, nearest first', () => { + const activities = [ + activity({ pid: 1, blocked_by: [2] }), + activity({ pid: 2, blocked_by: [3] }), + activity({ pid: 3, blocked_by: [] }), + ] + + expect(getBlockChain(1, activities)).toEqual([1, 2, 3]) + }) + + it('only follows the first blocker when blocked by multiple pids', () => { + const activities = [ + activity({ pid: 1, blocked_by: [2, 3] }), + activity({ pid: 2, blocked_by: [] }), + activity({ pid: 3, blocked_by: [] }), + ] + + expect(getBlockChain(1, activities)).toEqual([1, 2]) + }) + + it('stops rather than looping on a cycle', () => { + const activities = [ + activity({ pid: 1, blocked_by: [2] }), + activity({ pid: 2, blocked_by: [1] }), + ] + + expect(getBlockChain(1, activities)).toEqual([1, 2]) + }) + + it('includes a blocker pid even if its own activity record is missing', () => { + const activities = [activity({ pid: 1, blocked_by: [99] })] + + expect(getBlockChain(1, activities)).toEqual([1, 99]) + }) +}) + +describe('getBlockingChain', () => { + it('returns an empty chain when nothing is blocked by the root', () => { + const activities = [activity({ pid: 1, blocked_by: [] })] + + expect(getBlockingChain(1, activities)).toEqual([]) + }) + + it('walks forward from the root, nearest waiter first', () => { + const activities = [ + activity({ pid: 1, blocked_by: [2] }), + activity({ pid: 2, blocked_by: [3] }), + activity({ pid: 3, blocked_by: [] }), + ] + + expect(getBlockingChain(3, activities)).toEqual([2, 1]) + }) + + it('does not require the root pid to have its own activity record', () => { + const activities = [activity({ pid: 2, blocked_by: [1] })] + + expect(getBlockingChain(1, activities)).toEqual([2]) + }) + + it('stops rather than looping on a cycle', () => { + const activities = [ + activity({ pid: 2, blocked_by: [1] }), + activity({ pid: 3, blocked_by: [2] }), + activity({ pid: 1, blocked_by: [3] }), // would cycle back to the root + ] + + expect(getBlockingChain(1, activities)).toEqual([2, 3]) + }) + + it('only follows one branch when the root has multiple direct waiters', () => { + const activities = [ + activity({ pid: 2, blocked_by: [1] }), + activity({ pid: 3, blocked_by: [1] }), + ] + + expect(getBlockingChain(1, activities)).toEqual([2]) + }) +}) diff --git a/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.ts b/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.ts index 5d00de6a126b9..75b0cd50d7dde 100644 --- a/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.ts +++ b/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.ts @@ -78,6 +78,28 @@ const findLongestRunning = ( return longest === null || duration > longest.duration ? { activity, duration } : longest }, null) +// Counts every activity transitively blocked by pid - not just direct waiters, but their waiters +// in turn - so a long chain outweighs several short ones. Traverses breadth-first over a single +// counted set so a waiter reachable through more than one blocker (a "diamond") is still only +// counted once. +const countTransitivelyBlocked = (pid: number, activities: DatabaseActivity[]): number => { + const counted = new Set() + const queue = [pid] + + while (queue.length > 0) { + const currentPid = queue.shift()! + const directWaiters = activities.filter((x) => x.blocked_by.includes(currentPid)) + + for (const waiter of directWaiters) { + if (counted.has(waiter.pid)) continue + counted.add(waiter.pid) + queue.push(waiter.pid) + } + } + + return counted.size +} + // Derives the Overview page's connection/activity metrics (and their warning thresholds) from the // raw pg_stat_activity rows, so the logic can be unit tested independently of the component. export const getConnectionMetrics = (activities: DatabaseActivity[]): ConnectionMetrics => { @@ -113,18 +135,14 @@ export const getConnectionMetrics = (activities: DatabaseActivity[]): Connection longestRunningQuery?.activity.state === 'idle in transaction (aborted)') && longestRunningQuery.duration >= WARN_DURATION_IDLE_TXN) - const blockingCounts = activities.reduce>((counts, activity) => { - activity.blocked_by.forEach((pid) => counts.set(pid, (counts.get(pid) ?? 0) + 1)) - return counts - }, new Map()) - - const queryBlockingTheMostQueries = [...blockingCounts].reduce<{ + const queryBlockingTheMostQueries = activities.reduce<{ activity: DatabaseActivity count: number - } | null>((mostBlocking, [pid, count]) => { + } | null>((mostBlocking, activity) => { + const count = countTransitivelyBlocked(activity.pid, activities) + if (count === 0) return mostBlocking if (mostBlocking && count <= mostBlocking.count) return mostBlocking - const activity = activities.find((x) => x.pid === pid) - return activity ? { activity, count } : mostBlocking + return { activity, count } }, null) const warnTopBlocker = (queryBlockingTheMostQueries?.count ?? 0) >= WARN_TOP_BLOCKER @@ -141,3 +159,37 @@ export const getConnectionMetrics = (activities: DatabaseActivity[]): Connection warnTopBlocker, } } + +export const getBlockChain = (pid: number, activities: DatabaseActivity[]) => { + const chain = [pid] + const visited = new Set([pid]) + let current = activities.find((x) => x.pid === pid) + + while (current && current.blocked_by.length > 0) { + const nextPid = current.blocked_by[0] + if (visited.has(nextPid)) break + chain.push(nextPid) + visited.add(nextPid) + current = activities.find((x) => x.pid === nextPid) + } + + return chain +} + +// Walks the opposite direction of getBlockChain: starting from a root blocker +// (blocked_by.length === 0), finds the chain of pids waiting on it, nearest first +export const getBlockingChain = (rootPid: number, activities: DatabaseActivity[]) => { + const chain: number[] = [] + const visited = new Set([rootPid]) + let currentPid = rootPid + + while (true) { + const next = activities.find((x) => !visited.has(x.pid) && x.blocked_by.includes(currentPid)) + if (!next) break + chain.push(next.pid) + visited.add(next.pid) + currentPid = next.pid + } + + return chain +} diff --git a/apps/studio/components/interfaces/Observability/DatabaseConnections/Overview.tsx b/apps/studio/components/interfaces/Observability/DatabaseConnections/Overview.tsx index d98ccc507c551..6c1c1c9491176 100644 --- a/apps/studio/components/interfaces/Observability/DatabaseConnections/Overview.tsx +++ b/apps/studio/components/interfaces/Observability/DatabaseConnections/Overview.tsx @@ -66,6 +66,11 @@ export const Overview = ({ live }: OverviewProps) => { } ) + const onSelectPid = (pid: number) => { + setSelectedPid(pid) + document.getElementById(pid.toString())?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + return (
@@ -188,11 +193,11 @@ export const Overview = ({ live }: OverviewProps) => { 'hover:text-foreground hover:underline', 'focus:text-foreground focus:underline' )} - onClick={() => setSelectedPid(longestBlockedQuery.activity.pid)} + onClick={() => onSelectPid(longestBlockedQuery.activity.pid)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() - setSelectedPid(longestBlockedQuery.activity.pid) + onSelectPid(longestBlockedQuery.activity.pid) } }} > @@ -238,11 +243,11 @@ export const Overview = ({ live }: OverviewProps) => { role="button" tabIndex={0} className="normal-nums cursor-pointer hover:underline focus:underline" - onClick={() => setSelectedPid(queryBlockingTheMostQueries.activity.pid)} + onClick={() => onSelectPid(queryBlockingTheMostQueries.activity.pid)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() - setSelectedPid(queryBlockingTheMostQueries.activity.pid) + onSelectPid(queryBlockingTheMostQueries.activity.pid) } }} > @@ -295,11 +300,11 @@ export const Overview = ({ live }: OverviewProps) => { role="button" tabIndex={0} className="normal-nums hover:underline focus:underline cursor-pointer" - onClick={() => setSelectedPid(longestRunningQuery.activity.pid)} + onClick={() => onSelectPid(longestRunningQuery.activity.pid)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() - setSelectedPid(longestRunningQuery.activity.pid) + onSelectPid(longestRunningQuery.activity.pid) } }} > diff --git a/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx b/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx index 49dc2e9cf9b2e..1d22c5fadf3f2 100644 --- a/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx @@ -241,7 +241,11 @@ export const WithStatements = ({ } /> - + +
+ +
+ { const { ref } = useParams() const { data: project, isPending: isProjectPending } = useSelectedProjectQuery() const { securityLints, errorLints } = useLints() - const showReports = useIsFeatureEnabled('reports:all') - const showLogs = useIsFeatureEnabled('logs:all') - - const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview() const activeRoute = router.pathname.split('/')[3] @@ -281,11 +276,7 @@ const ProjectLinks = () => { realtime: realtimeEnabled, authOverviewPage: authOverviewPageEnabled, }) - const otherRoutes = generateOtherRoutes(ref, project, { - unifiedLogs: isUnifiedLogsEnabled, - showReports, - showLogs, - }) + const otherRoutes = useGenerateOtherRoutes() const settingsRoutes = generateSettingsRoutes(ref) return ( diff --git a/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.test.tsx b/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.test.tsx index 1c0779eb87c29..2c73a67fab941 100644 --- a/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.test.tsx +++ b/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.test.tsx @@ -102,75 +102,54 @@ describe('generateProductRoutes', () => { describe('generateOtherRoutes', () => { it('always includes advisors, logs, and integrations', () => { - const routes = generateOtherRoutes(REF, activeProject, { isPlatform: true }) + const routes = generateOtherRoutes(REF, activeProject) expect(keys(routes)).toContain('advisors') expect(keys(routes)).toContain('logs') expect(keys(routes)).toContain('integrations') }) - it('includes observability on platform when reports are enabled', () => { - const routes = generateOtherRoutes(REF, activeProject, { - isPlatform: true, - showReports: true, - }) + it('includes observability when reports are enabled', () => { + const routes = generateOtherRoutes(REF, activeProject, { showReports: true }) expect(keys(routes)).toContain('observability') }) - it('excludes observability on platform when reports are disabled', () => { - const routes = generateOtherRoutes(REF, activeProject, { - isPlatform: true, - showReports: false, - }) - expect(keys(routes)).not.toContain('observability') - }) - - it('excludes observability in self-hosted mode even when reports are enabled', () => { - const routes = generateOtherRoutes(REF, activeProject, { - isPlatform: false, - showReports: true, - }) + it('excludes observability when reports are disabled', () => { + const routes = generateOtherRoutes(REF, activeProject, { showReports: false }) expect(keys(routes)).not.toContain('observability') }) - it('excludes observability in self-hosted mode when reports are disabled', () => { - const routes = generateOtherRoutes(REF, activeProject, { - isPlatform: false, - showReports: false, - }) - expect(keys(routes)).not.toContain('observability') + it('links observability to the query performance page in self-hosted mode', () => { + // NEXT_PUBLIC_IS_PLATFORM is false in .env.test, so IS_PLATFORM is false here + const routes = generateOtherRoutes(REF, activeProject, { showReports: true }) + const observabilityRoute = routes.find((r) => r.key === 'observability') + expect(observabilityRoute?.link).toBe(`/project/${REF}/query-performance`) }) it('does not include API Docs nav item', () => { - const routes = generateOtherRoutes(REF, activeProject, { isPlatform: true }) + const routes = generateOtherRoutes(REF, activeProject) expect(keys(routes)).not.toContain('api') }) it('links logs to unified logs page when unifiedLogs is enabled', () => { - const routes = generateOtherRoutes(REF, activeProject, { - isPlatform: true, - unifiedLogs: true, - }) + const routes = generateOtherRoutes(REF, activeProject, { unifiedLogs: true }) const logsRoute = routes.find((r) => r.key === 'logs') expect(logsRoute?.link).toBe(`/project/${REF}/logs`) }) it('links logs to explorer page by default', () => { - const routes = generateOtherRoutes(REF, activeProject, { isPlatform: true }) + const routes = generateOtherRoutes(REF, activeProject) const logsRoute = routes.find((r) => r.key === 'logs') expect(logsRoute?.link).toBe(`/project/${REF}/logs/explorer`) }) it('points links to building URL when project is building', () => { - const routes = generateOtherRoutes(REF, buildingProject, { - isPlatform: true, - showReports: true, - }) + const routes = generateOtherRoutes(REF, buildingProject, { showReports: true }) const observabilityRoute = routes.find((r) => r.key === 'observability') expect(observabilityRoute?.link).toBe(`/project/${REF}`) }) it('marks routes as disabled when project is not active', () => { - const routes = generateOtherRoutes(REF, inactiveProject, { isPlatform: true }) + const routes = generateOtherRoutes(REF, inactiveProject) const advisorsRoute = routes.find((r) => r.key === 'advisors') expect(advisorsRoute?.disabled).toBe(true) }) diff --git a/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx index f92018c55bda8..74c808895e904 100644 --- a/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx +++ b/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx @@ -1,10 +1,14 @@ +import { useParams } from 'common' import { Auth, Database, EdgeFunctions, Realtime, SqlEditor, Storage, TableEditor } from 'icons' import { Blocks, Lightbulb, List, Settings, Telescope } from 'lucide-react' +import { useUnifiedLogsPreview } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { ICON_SIZE, ICON_STROKE_WIDTH } from '@/components/interfaces/Sidebar' import type { Route } from '@/components/ui/ui.types' import { EditorIndexPageLink } from '@/data/prefetchers/project.$ref.editor' import type { Project } from '@/data/projects/project-detail-query' +import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { IS_PLATFORM, PROJECT_STATUS } from '@/lib/constants' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' @@ -155,10 +159,10 @@ export const generateOtherRoutes = ( ): Route[] => { const { isProjectActive, isProjectBuilding, buildingUrl } = getRouteContext(ref, project) - const isPlatform = features?.isPlatform ?? IS_PLATFORM const unifiedLogsEnabled = features?.unifiedLogs ?? false const reportsEnabled = features?.showReports ?? true const logsEnabled = features?.showLogs ?? true + return [ { key: 'advisors', @@ -168,15 +172,20 @@ export const generateOtherRoutes = ( link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/advisors/security`), shortcutId: SHORTCUT_IDS.NAV_ADVISORS, }, - // Observability is only available on the platform, not for self-hosted/CLI - ...(isPlatform && reportsEnabled + ...(reportsEnabled ? [ { key: 'observability', label: 'Observability', disabled: !isProjectActive, icon: , - link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/observability`), + link: + ref && + (isProjectBuilding + ? buildingUrl + : IS_PLATFORM + ? `/project/${ref}/observability` + : `/project/${ref}/query-performance`), shortcutId: SHORTCUT_IDS.NAV_OBSERVABILITY, }, ] @@ -206,6 +215,21 @@ export const generateOtherRoutes = ( ] } +// [Joshen] Main hook to consume as it standardizes the generation of the menu items +export const useGenerateOtherRoutes = (): Route[] => { + const { ref } = useParams() + const { data: project } = useSelectedProjectQuery() + const { isEnabled: unifiedLogsEnabled } = useUnifiedLogsPreview() + const reportsEnabled = useIsFeatureEnabled('reports:all') + const logsEnabled = useIsFeatureEnabled('logs:all') + + return generateOtherRoutes(ref, project, { + unifiedLogs: unifiedLogsEnabled, + showReports: reportsEnabled, + showLogs: logsEnabled, + }) +} + export const generateSettingsRoutes = (ref?: string): Route[] => { return [ { diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.tsx index b5ac80bea0281..4af2ab65b0fd6 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.tsx @@ -12,13 +12,12 @@ import { resolveSectionDisplay } from './MobileMenuContent.utils' import { getProductMenuComponent } from './mobileProductMenuRegistry' import { TopLevelRouteItem } from './TopLevelRouteItem' import { routeHasSubmenu, useMobileMenuNavigation } from './useMobileMenuNavigation' -import { useUnifiedLogsPreview } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { ICON_SIZE, ICON_STROKE_WIDTH } from '@/components/interfaces/Sidebar' import { - generateOtherRoutes, generateProductRoutes, generateSettingsRoutes, generateToolRoutes, + useGenerateOtherRoutes, } from '@/components/layouts/Navigation/NavigationBar/NavigationBar.utils' import type { Route } from '@/components/ui/ui.types' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' @@ -63,8 +62,6 @@ export function MobileMenuContent({ 'realtime:all', ]) const authOverviewPageEnabled = useFlag('authOverviewPage') - const showReports = useIsFeatureEnabled('reports:all') - const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview() const toolRoutes = useMemo(() => generateToolRoutes(ref, project), [ref, project]) const productRoutes = useMemo( @@ -86,14 +83,7 @@ export function MobileMenuContent({ authOverviewPageEnabled, ] ) - const otherRoutes = useMemo( - () => - generateOtherRoutes(ref, project, { - unifiedLogs: isUnifiedLogsEnabled, - showReports, - }), - [ref, project, isUnifiedLogsEnabled, showReports] - ) + const otherRoutes = useGenerateOtherRoutes() const settingsRoutes = useMemo(() => generateSettingsRoutes(ref), [ref]) const homeRoute: Route = useMemo( diff --git a/apps/studio/pages/project/[ref]/observability/connections.tsx b/apps/studio/pages/project/[ref]/observability/connections.tsx index c93c522af4795..ab3a73a5a7b69 100644 --- a/apps/studio/pages/project/[ref]/observability/connections.tsx +++ b/apps/studio/pages/project/[ref]/observability/connections.tsx @@ -78,9 +78,9 @@ export const DatabaseConnections: NextPageWithLayout = () => { return ( -
+
-

Database Connections

+

Database Connections

{live && ( diff --git a/apps/studio/pages/project/[ref]/observability/query-performance.tsx b/apps/studio/pages/project/[ref]/observability/query-performance.tsx index 00f6b34768a27..59dfdb08085b7 100644 --- a/apps/studio/pages/project/[ref]/observability/query-performance.tsx +++ b/apps/studio/pages/project/[ref]/observability/query-performance.tsx @@ -1,4 +1,4 @@ -import { useParams } from 'common' +import { IS_PLATFORM, useParams } from 'common' import { parseAsArrayOf, parseAsInteger, parseAsJson, parseAsString, useQueryStates } from 'nuqs' import { Admonition } from 'ui-patterns/Admonition' @@ -93,7 +93,7 @@ const QueryPerformanceReport: NextPageWithLayout = () => {

{REPORT_TITLE}

- + {IS_PLATFORM && }