diff --git a/.github/workflows/studio-knip.yml b/.github/workflows/studio-knip.yml new file mode 100644 index 0000000000000..4c77a22d7972a --- /dev/null +++ b/.github/workflows/studio-knip.yml @@ -0,0 +1,65 @@ +name: Studio Dead Code (knip) + +on: + push: + branches: + - master + paths: + - 'apps/studio/**' + - 'knip.jsonc' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '.github/workflows/studio-knip.yml' + # No `branches` filter: stacked PRs target a sibling branch, not master, and + # the gate should still run on them. + pull_request: + paths: + - 'apps/studio/**' + - 'knip.jsonc' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '.github/workflows/studio-knip.yml' + +# Cancel old builds on new commit for same workflow + branch/PR +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + knip: + # Uses larger hosted runner as it significantly decreases build times + runs-on: blacksmith-4vcpu-ubuntu-2404 + env: + NODE_OPTIONS: --max-old-space-size=6144 + + steps: + # No sparse-checkout: knip resolves the whole pnpm workspace graph, so it + # needs every workspace's package.json present even when scoped to studio. + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + name: Install pnpm + with: + run_install: false + + - name: Use Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + + - name: Install deps + run: pnpm install --frozen-lockfile + + # `symbols` keeps the readable table in the job log, `github-actions` adds + # the same findings as inline PR annotations. + - name: Run knip + run: pnpm knip --workspace apps/studio --no-progress --reporter symbols --reporter github-actions diff --git a/apps/studio/CLAUDE.md b/apps/studio/CLAUDE.md index 5d4790ba75152..f1f084c3192f1 100644 --- a/apps/studio/CLAUDE.md +++ b/apps/studio/CLAUDE.md @@ -75,6 +75,7 @@ Older Studio code predates some of these conventions. For new or modified code, ## Defaults that differ here - **ESLint warnings are ratcheted in CI**: the per-rule occurrence count must not increase, so a new `any`, unresolved `exhaustive-deps` warning, or default export fails the build even though it's "only a warning". Check locally with `pnpm --filter studio run lint:ratchet`. +- **Dead files and deps are gated in CI** by knip (`pnpm knip --workspace apps/studio` locally). Framework-convention files nothing imports (routes, Vercel functions, TanStack Start files) belong in `knip.jsonc` under `workspaces["apps/studio"].entry`, not `ignore` — an ignored file's imports aren't traced, so anything only it uses gets reported as dead. There's no inline `knip-ignore` comment; the only per-file opt-out is config. In a PR stack, a file whose first consumer lands in a later PR fails the gate on the earlier one — either add it in the same PR as its first use, or add it to `workspaces["apps/studio"].ignore` with a `used from #NNN` comment and remove it in that PR. - **Clipboard**: `copyToClipboard` from `'ui'`, and never `await` anything before calling it (Safari requires the write inside the user gesture; lint-enforced) — pass a Promise as the argument instead. - **`useParams()` comes from `'common'`**, not `next/navigation` — it camelCases keys and returns `string | undefined`. - **Permissions**: `useAsyncCheckPermissions` from `hooks/misc/useCheckPermissions` (returns `can: true` when self-hosted). diff --git a/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/CPUWarnings.tsx b/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/CPUWarnings.tsx deleted file mode 100644 index 55355b892599e..0000000000000 --- a/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/CPUWarnings.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { AlertTitle } from '@ui/components/shadcn/ui/alert' -import { AlertCircle } from 'lucide-react' -import Link from 'next/link' -import { Alert, AlertDescription, Button } from 'ui' - -import { DOCS_URL } from '@/lib/constants' - -interface CPUWarningsProps { - hasAccessToComputeSizes: boolean - upgradeUrl: string - severity?: 'warning' | 'critical' | null -} - -export const CPUWarnings = ({ - hasAccessToComputeSizes, - upgradeUrl, - severity, -}: CPUWarningsProps) => { - if (severity === 'warning') { - return ( - - - Your max CPU usage has exceeded 80% - - High CPU usage could result in slower queries, disruption of daily back up routines, and - in rare cases, your instance may become unresponsive. If you need more resources, consider - upgrading to a larger compute add-on. - -
- - -
-
- ) - } - - if (severity === 'critical') { - return ( - - - Your max CPU usage has reached 100% - - High CPU usage could result in slower queries, disruption of daily back up routines, and - in rare cases, your instance may become unresponsive. If you need more resources, consider - upgrading to a larger compute add-on. - -
- - -
-
- ) - } - - return null -} diff --git a/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx b/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx deleted file mode 100644 index 21a1d5428c3c9..0000000000000 --- a/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import Link from 'next/link' -import { Button } from 'ui' -import { Admonition } from 'ui-patterns/Admonition' - -// [Joshen] In the future, conditionals should be from resource exhaustion endpoint as single source of truth -interface DiskIOBandwidthWarningsProps { - hasAccessToComputeSizes: boolean - hasLatest: boolean - upgradeUrl: string - currentBillingCycleSelected: boolean - latestIoBudgetConsumption: number - highestIoBudgetConsumption: number -} - -export const DiskIOBandwidthWarnings = ({ - hasAccessToComputeSizes, - hasLatest, - currentBillingCycleSelected, - upgradeUrl, - latestIoBudgetConsumption, - highestIoBudgetConsumption, -}: DiskIOBandwidthWarningsProps) => { - if (hasLatest && latestIoBudgetConsumption >= 100) { - return ( - -

- Your workload has used up all your Disk IO Budget and is now running at the baseline - performance. If you need consistent disk performance, consider upgrading to a larger - compute add-on. -

- - - } - /> - ) - } - - if (hasLatest && latestIoBudgetConsumption >= 80) { - return ( - -

- Your workload has consumed {latestIoBudgetConsumption}% of your Disk IO Budget. If you - use up all your Disk IO Budget, your instance will reverted to baseline performance. - If you need consistent disk performance, consider upgrading to a larger compute - add-on. -

- - - } - /> - ) - } - - if (currentBillingCycleSelected && highestIoBudgetConsumption >= 100) { - return ( - -

- Your workload has used up all your Disk IO Budget and reverted to baseline performance - at least once during this billing cycle. If you need consistent disk performance, - consider upgrading to a larger compute add-on. -

- - - } - /> - ) - } - - if (currentBillingCycleSelected && highestIoBudgetConsumption >= 80) { - return ( - -

- Your workload has consumed {highestIoBudgetConsumption}% of your Disk IO budget during - this billing cycle. If you use up all your Disk IO Budget, your instance will reverted - to baseline performance. If you need consistent disk performance, consider upgrading - to a larger compute add-on. -

- - - } - /> - ) - } - - return null -} diff --git a/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/RAMWarnings.tsx b/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/RAMWarnings.tsx deleted file mode 100644 index 4fc8c9b5e3574..0000000000000 --- a/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/RAMWarnings.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { AlertTitle } from '@ui/components/shadcn/ui/alert' -import { AlertCircle } from 'lucide-react' -import Link from 'next/link' -import { Alert, AlertDescription, Button } from 'ui' - -import { DOCS_URL } from '@/lib/constants' - -interface RAMWarningsProps { - hasAccessToComputeSizes: boolean - upgradeUrl: string - severity?: 'warning' | 'critical' | null -} - -export const RAMWarnings = ({ - hasAccessToComputeSizes, - upgradeUrl, - severity, -}: RAMWarningsProps) => { - if (severity === 'warning') { - return ( - - - Your memory usage has exceeded 80% - - High memory usage could result in overall degraded performance, and in rare cases, your - instance may become unresponsive. If you need more resources, consider upgrading to a - larger compute add-on. - -
- - -
-
- ) - } - - if (severity === 'critical') { - return ( - - - Your memory usage has reached 100% - - High memory usage could result in overall degraded performance, and in rare cases, your - instance may become unresponsive. If you need more resources, consider upgrading to a - larger compute add-on. - -
- - -
-
- ) - } - - return null -} diff --git a/apps/studio/components/interfaces/DataWarehouse/FormFooterChangeBadge.tsx b/apps/studio/components/interfaces/DataWarehouse/FormFooterChangeBadge.tsx deleted file mode 100644 index f06491437be32..0000000000000 --- a/apps/studio/components/interfaces/DataWarehouse/FormFooterChangeBadge.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { AnimatePresence, motion } from 'framer-motion' - -interface FormFooterChangeBadgeProps { - formState: { - dirtyFields: Record - } -} -export const FormFooterChangeBadge = ({ formState }: FormFooterChangeBadgeProps) => { - return ( - - {Object.keys(formState.dirtyFields).length > 0 && ( - - -

- - {Object.keys(formState.dirtyFields).length === 1 - ? '1 change to review' - : `${Object.keys(formState.dirtyFields).length} changes to review`} - -

-
-
- )} -
- ) -} diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/EmptyReplicationDiagram.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/EmptyReplicationDiagram.tsx deleted file mode 100644 index 5f975647b032d..0000000000000 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/EmptyReplicationDiagram.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Background, ColorMode, ReactFlow, ReactFlowProvider } from '@xyflow/react' -import { useTheme } from 'next-themes' - -import { PrimaryDatabaseNode, ReplicationNode } from './Nodes' - -import '@xyflow/react/dist/style.css' - -import { SmoothstepEdge } from './Edges' - -export const EmptyReplicationDiagram = () => { - return ( - - - - ) -} - -const nodeTypes = { - primary: PrimaryDatabaseNode, - replication: ReplicationNode, -} - -const edgeTypes = { smoothstep: SmoothstepEdge } - -const ReplicationDiagramContent = () => { - const { resolvedTheme } = useTheme() - - const backgroundPatternColor = - resolvedTheme === 'dark' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)' - - return ( -
- - - -
- ) -} diff --git a/apps/studio/components/interfaces/Integrations/Vercel/OrganizationPicker.tsx b/apps/studio/components/interfaces/Integrations/Vercel/OrganizationPicker.tsx deleted file mode 100644 index 17b7f82623c38..0000000000000 --- a/apps/studio/components/interfaces/Integrations/Vercel/OrganizationPicker.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { ChevronDown } from 'lucide-react' -import { useMemo, useRef, useState } from 'react' -import { - Badge, - Button, - cn, - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - Popover, - PopoverContent, - PopoverTrigger, -} from 'ui' - -import { getHasInstalledObject } from '@/components/layouts/IntegrationsLayout/Integrations.utils' -import PartnerIcon from '@/components/ui/PartnerIcon' -import { useIntegrationsQuery } from '@/data/integrations/integrations-query' -import type { IntegrationName } from '@/data/integrations/integrations.types' -import { useOrganizationsQuery } from '@/data/organizations/organizations-query' -import type { Organization } from '@/types' - -export interface OrganizationPickerProps { - integrationName: IntegrationName - configurationId?: string - selectedOrg: Organization | null - onSelectedOrgChange: (organization: Organization) => void - disabled?: boolean -} - -const OrganizationPicker = ({ - integrationName, - configurationId, - selectedOrg, - onSelectedOrgChange, - disabled, -}: OrganizationPickerProps) => { - const [open, setOpen] = useState(false) - const ref = useRef(null) - - const { data: integrationData } = useIntegrationsQuery() - const { data: organizationsData, isPending: isLoadingOrganization } = useOrganizationsQuery() - - const installed = useMemo( - () => - integrationData && organizationsData - ? getHasInstalledObject({ - integrationName, - integrationData, - organizationsData, - installationId: configurationId, - }) - : {}, - [configurationId, integrationData, integrationName, organizationsData] - ) - - return ( - <> - - - - - - - - - No results found. - - {organizationsData?.map((org) => { - return ( - { - const org = organizationsData?.find( - (org) => org.slug.toLowerCase() === slug.toLowerCase() - ) - if (org) { - onSelectedOrgChange(org) - } - - setOpen(false) - }} - > - - {org.name}{' '} - {configurationId && installed[org.slug] && ( - Integration Installed - )} - - ) - })} - - - - - - - ) -} - -export default OrganizationPicker diff --git a/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx b/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx index 393734b706cf5..c22ec181f5cc9 100644 --- a/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx +++ b/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx @@ -14,7 +14,7 @@ import { ObservabilityOverviewFooter } from './ObservabilityOverviewFooter' import { ServiceHealthTable } from './ServiceHealthTable' import { useSlowQueriesCount } from './useSlowQueriesCount' import ReportHeader from '@/components/interfaces/Reports/ReportHeader' -import ReportPadding from '@/components/interfaces/Reports/ReportPadding' +import { ReportPadding } from '@/components/interfaces/Reports/ReportPadding' import { buildUnifiedLogsUrl, type UnifiedLogType, diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTableRow.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTableRow.tsx deleted file mode 100644 index c74d0ec4ec0e0..0000000000000 --- a/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTableRow.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { Loader2 } from 'lucide-react' -import { AiIconAnimation, Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' - -import type { ClassifiedQuery } from '../QueryInsightsHealth/QueryInsightsHealth.types' -import { ISSUE_DOT_COLORS, ISSUE_ICONS } from './QueryInsightsTable.constants' -import { formatDuration, getColumnName, getTableName } from './QueryInsightsTable.utils' - -interface QueryInsightsTableRowProps { - item: ClassifiedQuery - onRowClick?: () => void - onGoToLogs?: () => void - onCreateIndex?: () => void - onExplain?: () => void - onAiSuggestedFix?: () => void - isExplainLoading?: boolean -} - -export const QueryInsightsTableRow = ({ - item, - onRowClick, - onGoToLogs, - onCreateIndex, - onExplain, - onAiSuggestedFix, - isExplainLoading, -}: QueryInsightsTableRowProps) => { - const IssueIcon = item.issueType ? ISSUE_ICONS[item.issueType] : null - - return ( -
- {item.issueType && IssueIcon && ( -
- -
- )} - -
-

- {item.queryType ?? '–'} - {getTableName(item.query) && ( - <> - {' '} - in {getTableName(item.query)} - - )} - {getColumnName(item.query) && ( - <> - , {getColumnName(item.query)} - - )} -

-

- {item.hint} -

-
- -
- - -
- = 1000 && 'text-destructive-600' - )} - > - {formatDuration(item.mean_time)} - - - avg - -
-
- - Average execution time per call. High mean time means individual runs are slow — - directly felt by users. - -
- - - -
- - {item.prop_total_time.toFixed(1)}% - - - of db - -
-
- - Percentage of total database execution time. Fixing high-impact queries has the biggest - overall effect on your database. - -
- - - -
- {item.calls.toLocaleString()} - - calls - -
-
- - Number of times this query ran in the selected time window. - -
-
- -
- - - {(item.issueType === 'index' || item.issueType === 'slow') && ( - - )} - - {item.issueType === 'index' && ( - - )} - - {(item.issueType === 'error' || item.issueType === 'slow') && ( - - )} -
-
- ) -} diff --git a/apps/studio/components/interfaces/Reports/ReportPadding.tsx b/apps/studio/components/interfaces/Reports/ReportPadding.tsx index 3a483ce86c6ff..9269f98be4291 100644 --- a/apps/studio/components/interfaces/Reports/ReportPadding.tsx +++ b/apps/studio/components/interfaces/Reports/ReportPadding.tsx @@ -19,4 +19,3 @@ export const ReportPadding = ({ ) } -export default ReportPadding diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.test.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.test.tsx new file mode 100644 index 0000000000000..940c2617184b5 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.test.tsx @@ -0,0 +1,71 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { RestartServerButton } from './RestartServerButton' +import { customRender } from '@/tests/lib/custom-render' + +const { + mockUseAsyncCheckPermissions, + mockUseFlag, + mockUseIsFeatureEnabled, + mockUseSelectedProjectQuery, +} = vi.hoisted(() => ({ + mockUseAsyncCheckPermissions: vi.fn(), + mockUseFlag: vi.fn(), + mockUseIsFeatureEnabled: vi.fn(), + mockUseSelectedProjectQuery: vi.fn(), +})) + +vi.mock('common', async (importOriginal) => ({ + ...(await importOriginal()), + useFlag: mockUseFlag, +})) + +vi.mock('next/router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})) + +vi.mock('@/hooks/misc/useCheckPermissions', () => ({ + useAsyncCheckPermissions: mockUseAsyncCheckPermissions, +})) + +vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({ + useIsFeatureEnabled: mockUseIsFeatureEnabled, +})) + +vi.mock('@/hooks/misc/useSelectedProject', () => ({ + useIsAwsK8sCloudProvider: () => false, + useIsProjectActive: () => true, + useSelectedProjectQuery: mockUseSelectedProjectQuery, +})) + +describe('RestartServerButton', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseFlag.mockReturnValue(false) + mockUseAsyncCheckPermissions.mockReturnValue({ can: true }) + mockUseIsFeatureEnabled.mockReturnValue({ projectSettingsRestartProject: true }) + mockUseSelectedProjectQuery.mockReturnValue({ + data: { ref: 'default', region: 'us-east-1', status: 'ACTIVE_HEALTHY' }, + }) + }) + + it('uses separate tab stops for the primary action and restart type menu', async () => { + const user = userEvent.setup() + customRender() + + const restartProject = screen.getByRole('button', { name: 'Restart project' }) + const chooseRestartType = screen.getByRole('button', { name: 'Choose restart type' }) + + await user.tab() + expect(restartProject).toHaveFocus() + + await user.tab() + expect(chooseRestartType).toHaveFocus() + + await user.keyboard('{Enter}') + expect(await screen.findByRole('menuitem', { name: /Fast database reboot/ })).toHaveFocus() + expect(screen.getByText(/Other project services remain running/)).toBeVisible() + }) +}) diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx index 8eda0ce5e837b..db3b604491a36 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx @@ -107,6 +107,7 @@ export const RestartServerButton = () => { {projectSettingsRestartProject ? (
{