From bb9e3d81687492278255babf3369970fe4e6979c Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 29 Jul 2026 16:07:57 +0800 Subject: [PATCH 1/5] Joshen/fe 3967 support top for postgres in self hosted and local (#48386) ## Context Allow self-host / local dashboard to access the "Observability" pages Currently only Query Performance will be accessible - eventually once Database Connections is publicly ready, it'll also be accessible here too image ## Summary by CodeRabbit * **New Features** * Navigation, sidebar, and mobile menus now consistently display **Observability** and **Logs** based on enabled features, including the unified logs preview. * **Observability** destinations now adapt to platform and settings so users land on the correct Query Performance view. * **Bug Fixes** * Improved Query Performance loading layout for a smoother loading experience. * Database selection is now shown only on supported platform environments. * **Tests** * Updated navigation/menu tests to match the new routing behavior. --- .../WithStatements/WithStatements.tsx | 6 ++- apps/studio/components/interfaces/Sidebar.tsx | 13 +---- .../NavigationBar.utils.test.tsx | 51 ++++++------------- .../NavigationBar/NavigationBar.utils.tsx | 32 ++++++++++-- .../MobileMenuContent/MobileMenuContent.tsx | 14 +---- .../[ref]/observability/query-performance.tsx | 4 +- 6 files changed, 54 insertions(+), 66 deletions(-) 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/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 && }
Date: Wed, 29 Jul 2026 16:52:03 +0800 Subject: [PATCH 2/5] chore(claude): add react-hook-form skill (#48431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Claude skill encoding correct React Hook Form usage, so AI-written form code follows best practices instead of copying the anti-patterns common in older Studio code (prop-form `form.watch()`/`formState` subscriptions, subscription-only watches, unguarded `valueAsNumber`, `?? undefined` controlled values, defaults computed from unloaded queries). **Added:** - `.claude/skills/react-hook-form/SKILL.md` — subscription model (`useWatch`/`useFormState` with `control`), canonical zod + `FormField` composition (layout deferred to `studio-ui-patterns`), `values:` option for async data, null normalization for controlled inputs, number-input handling, dirty-state and gating rules, plus a fix-what-you-touch policy aligned with the `no-use-watch` lint ratchet **Changed:** - `.claude/CLAUDE.md` and `apps/studio/CLAUDE.md` — register the skill in the skill lists/table - `.coderabbit.yaml` — add the skill to the existing Studio code-guidelines entry so CodeRabbit applies it when reviewing Studio code Benchmarked on three real form tasks (adding a live-updating field to `ThroughputField`, a new sheet form with async + nullable data, a review-changes step in `EditBucketModal`), each run with and without the skill: 13/13 assertions with the skill vs 8/13 baseline. The baseline shipped a genuine bug in one task — a `null` server default flowed into a `''` its own schema rejected, making Save unreachable — which the skill run avoided. ## To test - Ask Claude Code to add a field to any Studio form and check it loads the skill (it's in the studio CLAUDE.md skill table) and uses `useWatch({ control, name })` rather than `form.watch` - Skim `SKILL.md` for anything that contradicts current form conventions — `apps/design-system` demos remain the layout source of truth ## Summary by CodeRabbit * **Documentation** * Added a new monorepo “react-hook-form” skill guide with recommended patterns for safe form subscriptions, wiring, default values, reset/submission flows, and common anti-patterns. * Updated Studio skills/load guidance to expand and reorder the skills matrix, including form logic and copywriting guidance. * Updated required skill coverage so `react-hook-form` is included for any form-related work. * **Chores** * Expanded automated review enforcement so Studio form code is checked against the new “react-hook-form” skill guidance. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .claude/CLAUDE.md | 1 + .claude/skills/react-hook-form/SKILL.md | 260 ++++++++++++++++++++++++ .coderabbit.yaml | 2 +- apps/studio/CLAUDE.md | 23 ++- 4 files changed, 274 insertions(+), 12 deletions(-) create mode 100644 .claude/skills/react-hook-form/SKILL.md 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..ab1c39b05fef8 --- /dev/null +++ b/.claude/skills/react-hook-form/SKILL.md @@ -0,0 +1,260 @@ +--- +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 +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 form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { name: '', maxConnections: '' }, +}) + + + + ( + + + + + + )} + /> + + +``` + +Submit buttons living outside the `
` (sheet/dialog footers) use a stable +`formId` and `form={formId}` on the button. + +## 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 From ded5bc525bc448db666bed8c3f8163a34ea4d635 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 29 Jul 2026 16:55:28 +0800 Subject: [PATCH 3/5] Joshen/fe 4000 activity table to show queries which are blockers (#48383) ## Context One for Database Connections - allow a user to view the root blocking queries Adds an additional filter button here that toggles the view image When toggled, will render a list of the _root_ blocking queries - these are queries that are at the end of the blocking chain (or otherwise the problematic ones causing other queries to be blocked) image Within this view - you can expand the row to view the blocking chain image ## Other changes involved - Realised that "Top blocker" overview metric card logic is incorrect - Was previously naively checking the length of the `blocked_by` array, but it should be consider the nested chain length instead, so this PR fixes that image - Clicking the PID if highlighted on a metric card will not scroll to the PID if it's already selected. This PR fixes that ## Summary by CodeRabbit * **New Features** * Added a **Root blockers** view to highlight sessions that block others, with expandable blocking chains revealing related waiting activity. * **Bug Fixes** * Updated blocking metrics to use **transitive** blocker counts and improved cycle protection and behavior when activity records are missing. * The blockers view now consistently affects state/application/role quantities, and **reset filters** clears the view. * **Refactor / UI** * Improved the sessions table with grouped/nested rows, clearer waiting indicators, and more consistent expand/collapse behavior. * **Tests** * Expanded coverage for blocking/waiting chain traversal and branching scenarios. --- .../DatabaseConnections/Activity.tsx | 60 +++++--- .../DatabaseConnections/ActivityRow.tsx | 141 ++++++++++++++---- .../DatabaseConnections.utils.test.ts | 116 +++++++++++++- .../DatabaseConnections.utils.ts | 70 +++++++-- .../DatabaseConnections/Overview.tsx | 17 ++- .../[ref]/observability/connections.tsx | 4 +- 6 files changed, 346 insertions(+), 62 deletions(-) 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/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 && ( From 0009b4bcdfb856738ffd8c1f9e6c402cf2f7f287 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Wed, 29 Jul 2026 17:17:15 +0800 Subject: [PATCH 4/5] chore(claude): hoist static form references in RHF skill example (#48434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quick follow-up to #48431 addressing Ivan's post-merge feedback: the canonical form example now defines `FORM_ID`, the zod schema, and static `defaultValues` at module level so they're stable references rather than being recreated on every render, with a note to use `useMemo` (runtime-dependent schemas) or the `values:` option (server-driven defaults) when hoisting isn't possible. ## To test - Skim the diff — docs-only change to `.claude/skills/react-hook-form/SKILL.md` ## Summary by CodeRabbit * **Documentation** * Updated the React Hook Form guidance with a canonical example using stable, module-level form configuration. * Clarified that schemas, inferred types, default values, and form identifiers should be defined outside the component. * Documented how submit buttons outside the form should reference the shared form identifier (and cautioned to use per-instance IDs when the component may mount multiple times). --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .claude/skills/react-hook-form/SKILL.md | 26 +++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/.claude/skills/react-hook-form/SKILL.md b/.claude/skills/react-hook-form/SKILL.md index ab1c39b05fef8..29571cb24c742 100644 --- a/.claude/skills/react-hook-form/SKILL.md +++ b/.claude/skills/react-hook-form/SKILL.md @@ -100,6 +100,9 @@ and the demos in `apps/design-system/registry/default/example/` 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 @@ -108,13 +111,16 @@ const FormSchema = z.object({ }) type FormValues = z.infer +const defaultValues: FormValues = { name: '', maxConnections: '' } + +// Inside the component const form = useForm({ resolver: zodResolver(FormSchema), - defaultValues: { name: '', maxConnections: '' }, + defaultValues, }) - + ({ ``` -Submit buttons living outside the `
` (sheet/dialog footers) use a stable -`formId` and `form={formId}` on the button. +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 From b455d871e549dff092cfecda10369bf8eecfa6e3 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Wed, 29 Jul 2026 19:57:10 +1000 Subject: [PATCH 5/5] Add compute and disk usage charts (#48369) ## Summary This is the second step towards merging compute and disk with infrastructure. There are some usage charts on the current infrastructure page that are useful to have in the context of compute and disk settings. This branch adds two charts which give a general sense of usage and whether an upgrade needs to happen. Other data points in infrastructure can be found within observability and organisation usage. - Adds rolling seven-day Compute and Disk charts to the existing Compute and Disk page. - Shows CPU, memory, optional burstable disk IO, and disk usage split into database, WAL, and system data. - Covers loading, error, empty, warning, and critical states, retaining the 75% warning and 90% critical thresholds. - Uses a dedicated PageSection and keeps the charts in two columns from 680px. - Uses the concise primary labels Compute and Disk, removes the database report link, and removes tooltip icons from secondary metrics. - Adds transformation, summary, and component tests covering dedicated IO behavior, legacy anchors, responsive layout, rolling refetch, and tooltip behavior. ## Stack 1. #48368 2. #48369 (this PR) 3. #48370 ## How to test 1. Check out `chore/infra-compute-2-charts` and start Studio with `pnpm dev:studio`. 2. Open `/project//settings/compute-and-disk` on a project with recent metrics. 3. Confirm the charts are in their own page section with standard spacing below the page header. 4. Confirm the Compute chart shows CPU and memory, plus disk IO when applicable, and the Disk chart splits usage into database, WAL, and system data. 5. Confirm the primary labels are Compute and Disk, secondary metrics do not show tooltip icons, and there is no Database Observability/report link. 6. Resize across 680px. The charts should remain in two columns at and above the breakpoint and stack into two rows below it. 7. Exercise loading, error, empty, warning, and critical responses with the metrics mocks or response overrides. Confirm warning styling begins at 75%, critical styling begins at 90%, and an error or empty response does not break the configuration form. ## Summary by CodeRabbit * **New Features** * Added compute and disk usage charts to the disk management interface, including metric cards for CPU, memory, disk I/O, database, WAL, and system. * Added usage status indicators, peak calculations, tooltips, and a detailed disk breakdown with placeholders when data is missing. * Added special handling for dedicated-I/O instances to hide burst-only disk I/O. * **Style** * Simplified the disk space display by removing supplemental explanatory text. * **Tests** * Added comprehensive test coverage for chart rendering, loading/error/empty states, status/peak calculations, and rolling 7-day data window behavior. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .../ComputeAndDiskUsageCharts.test.tsx | 277 ++++++++++++++ .../ComputeAndDiskUsageCharts.tsx | 327 ++++++++++++++++ .../ComputeAndDiskUsageCharts.utils.test.ts | 359 ++++++++++++++++++ .../ComputeAndDiskUsageCharts.utils.ts | 149 ++++++++ .../DiskManagement/DiskManagementForm.tsx | 9 +- .../DiskManagement/ui/DiskSpaceBar.tsx | 9 - 6 files changed, 1120 insertions(+), 10 deletions(-) create mode 100644 apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.test.tsx create mode 100644 apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.tsx create mode 100644 apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.test.ts create mode 100644 apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.ts 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. -

) }