diff --git a/.claude/skills/e2e-studio-tests/SKILL.md b/.claude/skills/e2e-studio-tests/SKILL.md new file mode 100644 index 0000000000000..6f9797f720d75 --- /dev/null +++ b/.claude/skills/e2e-studio-tests/SKILL.md @@ -0,0 +1,233 @@ +--- +name: e2e-studio-tests +description: Run e2e tests in the Studio app. Use when asked to run e2e tests, run studio tests, playwright tests, or test the feature. +--- + +# E2E Studio Tests + +Run Playwright end-to-end tests for the Studio application. + +## Running Tests + +Tests must be run from the `e2e/studio` directory: + +```bash +cd e2e/studio && pnpm run e2e +``` + +### Run specific file + +```bash +cd e2e/studio && pnpm run e2e -- features/cron-jobs.spec.ts +``` + +### Run with grep filter + +```bash +cd e2e/studio && pnpm run e2e -- --grep "test name pattern" +``` + +### UI mode for debugging + +```bash +cd e2e/studio && pnpm run e2e -- --ui +``` + +## Environment Setup + +- Tests auto-start Supabase local containers via web server config +- Self-hosted mode (`IS_PLATFORM=false`) runs tests in parallel (3 workers) +- No manual setup needed for self-hosted tests + +## Test File Structure + +- Tests are in `e2e/studio/features/*.spec.ts` +- Use custom test utility: `import { test } from '../utils/test.js'` +- Test fixtures provide `page`, `ref`, and other helpers + +## Common Patterns + +Wait for elements with generous timeouts: + +```typescript +await expect(locator).toBeVisible({ timeout: 30000 }) +``` + +Add messages to expects for debugging: + +```typescript +await expect(locator).toBeVisible({ timeout: 30000 }, 'Element should be visible after page load') +``` + +Use serial mode for tests sharing database state: + +```typescript +test.describe.configure({ mode: 'serial' }) +``` + +## Writing Robust Selectors + +### Selector priority (best to worst) + +1. **`getByRole` with accessible name** - Most robust, tests accessibility + ```typescript + page.getByRole('button', { name: 'Save' }) + page.getByRole('button', { name: 'Configure API privileges' }) + ``` + +2. **`getByTestId`** - Stable, explicit test hooks + ```typescript + page.getByTestId('table-editor-side-panel') + ``` + +3. **`getByText` with exact match** - Good for unique text + ```typescript + page.getByText('Data API Access', { exact: true }) + ``` + +4. **`locator` with CSS** - Use sparingly, more fragile + ```typescript + page.locator('[data-state="open"]') + ``` + +### Patterns to avoid + +- **XPath selectors** - Fragile to DOM changes + ```typescript + // BAD + locator('xpath=ancestor::div[contains(@class, "space-y")]') + ``` + +- **Parent traversal with `locator('..')`** - Breaks when structure changes + ```typescript + // BAD + element.locator('..').getByRole('button') + ``` + +- **Broad `filter({ hasText })` on generic elements** - May match multiple elements + ```typescript + // BAD - popover may have more than one combobox + // Could consider scoping down the container or filtering the combobox more specifically + popover.getByRole('combobox') + ``` + +### Add accessible labels to components + +When a component lacks a good accessible name, add one in the source code: + +```tsx +// In the React component + +``` + +Then use it in tests: +```typescript +page.getByRole('button', { name: 'Configure API privileges' }) +``` + +### Narrowing search scope + +Scope selectors to specific containers to avoid matching wrong elements: + +```typescript +// Good - scoped to side panel +const sidePanel = page.getByTestId('table-editor-side-panel') +const toggle = sidePanel.getByRole('switch') + +// Good - find unique element, then scope from there +const popover = page.locator('[data-radix-popper-content-wrapper]') +const roleSection = popover.getByText('Anonymous (anon)', { exact: true }) +``` + +## Avoiding `waitForTimeout` + +Never use `waitForTimeout` - always wait for something specific: + +```typescript +// BAD +await page.waitForTimeout(1000) + +// GOOD - wait for UI element +await expect(page.getByText('Success')).toBeVisible() + +// GOOD - wait for API response +const apiPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create') +await saveButton.click() +await apiPromise + +// GOOD - wait for toast indicating operation complete +await expect(page.getByText('Table created successfully')).toBeVisible({ timeout: 15000 }) +``` + +## Avoiding `force: true` on clicks + +Instead of forcing clicks on hidden elements, make them visible first: + +```typescript +// BAD +await menuButton.click({ force: true }) + +// GOOD - hover to reveal, then click +await tableRow.hover() +await expect(menuButton).toBeVisible() +await menuButton.click() +``` + +## Debugging + +### View trace + +```bash +cd e2e/studio && pnpm exec playwright show-trace +``` + +### View HTML report + +```bash +cd e2e/studio && pnpm exec playwright show-report +``` + +### Error context + +Error context files are saved in the `test-results/` directory. + +### Playwright MCP tools + +Use Playwright MCP tools to inspect UI when debugging locally. + +## CI vs Local Development + +The key difference is **cold start vs warm state**: + +### CI (cold start) + +Tests run from a blank database slate. Each test run resets the database and starts fresh containers. Extensions like pg_cron are NOT enabled by default. + +### Local dev with `pnpm dev:studio-local` + +When debugging with a running dev server, the database may already have state from previous runs (extensions enabled, test data present). + +## Handling Cold Start Bugs + +Tests that work locally but fail in CI often have assumptions about existing state. + +### Common issues + +1. Extension not enabled (must enable in test setup) +2. Race conditions when parallel tests try to modify shared state (use `test.describe.configure({ mode: 'serial' })`) +3. Locators matching wrong elements because the page structure differs when state isn't set up + +### Reproducing CI behavior locally + +The test framework automatically resets the database when running `pnpm run e2e`. This matches CI behavior. + +If using `pnpm dev:studio-local` for Playwright MCP debugging, remember the state differs from CI. + +## Debugging Workflow for CI Failures + +1. First, run the test locally with `pnpm run e2e -- features/.spec.ts` (cold start) +2. Check error context in `test-results/` directory +3. If you need to inspect UI state, start `pnpm dev:studio-local` and use Playwright MCP tools +4. Remember: what you see in the dev server may have state that doesn't exist in CI diff --git a/.gitignore b/.gitignore index 5d4494d1cbd03..4ba0158ba061d 100644 --- a/.gitignore +++ b/.gitignore @@ -120,6 +120,8 @@ next-env.d.ts .claude/* !.claude/settings.json !.claude/scripts/ +!.claude/skills/ +.claude/skills/me-* CLAUDE.md #include template .env file for docker-compose diff --git a/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx b/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx index 5aafee8260e8f..1d4bcacf00d16 100644 --- a/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx @@ -1,8 +1,4 @@ import type { PostgresExtension } from '@supabase/postgres-meta' -import { Database, ExternalLinkIcon, Plus } from 'lucide-react' -import { useEffect, useState } from 'react' -import { toast } from 'sonner' - import { DocsButton } from 'components/ui/DocsButton' import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation' import { useSchemasQuery } from 'data/database/schemas-query' @@ -10,6 +6,9 @@ import { executeSql } from 'data/sql/execute-sql-query' import { useIsOrioleDb, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useProtectedSchemas } from 'hooks/useProtectedSchemas' import { DOCS_URL } from 'lib/constants' +import { Database, ExternalLinkIcon, Plus } from 'lucide-react' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, @@ -108,6 +107,8 @@ const EnableExtensionModal = ({ visible, extension, onCancel }: EnableExtensionM return undefined } + const isLoading = fetchingSchemaInfo || isSchemasLoading + const validate = (values: any) => { const errors: any = {} if (values.schema === 'custom' && !values.name) errors.name = 'Required field' @@ -170,7 +171,7 @@ const EnableExtensionModal = ({ visible, extension, onCancel }: EnableExtensionM )} - {fetchingSchemaInfo || isSchemasLoading ? ( + {isLoading ? (
@@ -269,7 +270,7 @@ const EnableExtensionModal = ({ visible, extension, onCancel }: EnableExtensionM - diff --git a/apps/studio/components/interfaces/Home/ProjectUsage.tsx b/apps/studio/components/interfaces/Home/ProjectUsage.tsx index 29d1645696d75..3422b71595799 100644 --- a/apps/studio/components/interfaces/Home/ProjectUsage.tsx +++ b/apps/studio/components/interfaces/Home/ProjectUsage.tsx @@ -1,6 +1,7 @@ import { useParams } from 'common' import BarChart from 'components/ui/Charts/BarChart' -import { InlineLink } from 'components/ui/InlineLink' +import { ChartIntervalDropdown } from 'components/ui/Logs/ChartIntervalDropdown' +import { CHART_INTERVALS } from 'components/ui/Logs/logs.utils' import Panel from 'components/ui/Panel' import { ProjectLogStatsVariables, @@ -14,55 +15,13 @@ import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Auth, Database, Realtime, Storage } from 'icons' import sumBy from 'lodash/sumBy' -import { ChevronDown } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useState } from 'react' -import type { ChartIntervals } from 'types' -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, - Loading, - Tooltip, - TooltipContent, - TooltipTrigger, -} from 'ui' +import { Loading } from 'ui' type ChartIntervalKey = ProjectLogStatsVariables['interval'] -const LOG_RETENTION = { free: 1, pro: 7, team: 28, enterprise: 90, platform: 1 } - -const CHART_INTERVALS: ChartIntervals[] = [ - { - key: '1hr', - label: 'Last 60 minutes', - startValue: 1, - startUnit: 'hour', - format: 'MMM D, h:mma', - availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'], - }, - { - key: '1day', - label: 'Last 24 hours', - startValue: 24, - startUnit: 'hour', - format: 'MMM D, ha', - availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'], - }, - { - key: '7day', - label: 'Last 7 days', - startValue: 7, - startUnit: 'day', - format: 'MMM D', - availableIn: ['pro', 'team', 'enterprise'], - }, -] - const ProjectUsage = () => { const router = useRouter() const { ref: projectRef } = useParams() @@ -132,64 +91,15 @@ const ProjectUsage = () => { return (
- - - - - - - setInterval(interval as ProjectLogStatsVariables['interval']) - } - > - {CHART_INTERVALS.map((i) => { - const disabled = !i.availableIn?.includes(plan?.id || 'free') - - if (disabled) { - const retentionDuration = LOG_RETENTION[plan?.id ?? 'free'] - return ( - - - - {i.label} - - - -

- {plan?.name} plan only includes up to {retentionDuration} day - {retentionDuration > 1 ? 's' : ''} of log retention -

-

- - Upgrade your plan - {' '} - to increase log retention and view statistics for the{' '} - {i.label.toLowerCase()} -

-
-
- ) - } else { - return ( - - {i.label} - - ) - } - })} -
-
-
+ setInterval(interval as ProjectLogStatsVariables['interval'])} + planId={plan?.id} + planName={plan?.name} + organizationSlug={organization?.slug} + dropdownAlign="start" + tooltipSide="right" + /> Statistics for {selectedInterval.label.toLowerCase()} diff --git a/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx b/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx index 6600e507d9d40..219108b4905f9 100644 --- a/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx +++ b/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx @@ -1,33 +1,16 @@ import { useParams } from 'common' import NoDataPlaceholder from 'components/ui/Charts/NoDataPlaceholder' -import { InlineLink } from 'components/ui/InlineLink' +import { ChartIntervalDropdown } from 'components/ui/Logs/ChartIntervalDropdown' +import { CHART_INTERVALS } from 'components/ui/Logs/logs.utils' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import dayjs from 'dayjs' import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' -import { ChevronDown } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useMemo, useState } from 'react' -import type { ChartIntervals } from 'types' -import { - Button, - Card, - CardContent, - CardHeader, - CardTitle, - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, - Loading, - Tooltip, - TooltipContent, - TooltipTrigger, - cn, -} from 'ui' +import { Card, CardContent, CardHeader, CardTitle, Loading } from 'ui' import { Row } from 'ui-patterns' import { LogsBarChart } from 'ui-patterns/LogsBarChart' @@ -41,35 +24,6 @@ import { sumWarnings, } from './ProjectUsage.metrics' -const LOG_RETENTION = { free: 1, pro: 7, team: 28, enterprise: 90, platform: 1 } - -const CHART_INTERVALS: ChartIntervals[] = [ - { - key: '1hr', - label: 'Last 60 minutes', - startValue: 1, - startUnit: 'hour', - format: 'MMM D, h:mma', - availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'], - }, - { - key: '1day', - label: 'Last 24 hours', - startValue: 24, - startUnit: 'hour', - format: 'MMM D, ha', - availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'], - }, - { - key: '7day', - label: 'Last 7 days', - startValue: 7, - startUnit: 'day', - format: 'MMM D, ha', - availableIn: ['pro', 'team', 'enterprise'], - }, -] - type ChartIntervalKey = '1hr' | '1day' | '7day' type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime' @@ -242,62 +196,15 @@ export const ProjectUsageSection = () => { Success Rate
- - - - - - setInterval(interval as ChartIntervalKey)} - > - {CHART_INTERVALS.map((i) => { - const disabled = !i.availableIn?.includes(plan?.id || 'free') - - if (disabled) { - const retentionDuration = LOG_RETENTION[plan?.id ?? 'free'] - return ( - - - - {i.label} - - - -

- {plan?.name} plan only includes up to {retentionDuration} day - {retentionDuration > 1 ? 's' : ''} of log retention -

-

- - Upgrade your plan - {' '} - to increase log retention and view statistics for the{' '} - {i.label.toLowerCase()} -

-
-
- ) - } else { - return ( - - {i.label} - - ) - } - })} -
-
-
+ setInterval(interval as ChartIntervalKey)} + planId={plan?.id} + planName={plan?.name} + organizationSlug={organization?.slug} + dropdownAlign="end" + tooltipSide="left" + />
{enabledServices.map((s) => ( diff --git a/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.tsx b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.tsx new file mode 100644 index 0000000000000..96f4aa2ba160b --- /dev/null +++ b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.tsx @@ -0,0 +1,259 @@ +import { useParams } from 'common' +import { useInfraMonitoringAttributesQuery } from 'data/analytics/infra-monitoring-query' +import { useMaxConnectionsQuery } from 'data/database/max-connections-query' +import dayjs from 'dayjs' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import Link from 'next/link' +import { useMemo } from 'react' +import { cn } from 'ui' +import { + MetricCard, + MetricCardContent, + MetricCardHeader, + MetricCardLabel, + MetricCardValue, +} from 'ui-patterns/MetricCard' + +import { + parseConnectionsData, + parseInfrastructureMetrics, +} from './DatabaseInfrastructureSection.utils' + +type DatabaseInfrastructureSectionProps = { + interval: '1hr' | '1day' | '7day' + refreshKey: number + dbErrorRate: number + isLoading: boolean + slowQueriesCount?: number + slowQueriesLoading?: boolean +} + +export const DatabaseInfrastructureSection = ({ + interval, + refreshKey, + dbErrorRate, + isLoading: dbLoading, + slowQueriesCount = 0, + slowQueriesLoading = false, +}: DatabaseInfrastructureSectionProps) => { + const { ref: projectRef } = useParams() + const { data: project } = useSelectedProjectQuery() + + // refreshKey forces date recalculation when user clicks refresh button + // eslint-disable-next-line react-hooks/exhaustive-deps + const { startDate, endDate, infraInterval } = useMemo(() => { + const now = dayjs() + const end = now.toISOString() + let start: string + let infraInterval: '1h' | '1d' + + switch (interval) { + case '1hr': + start = now.subtract(1, 'hour').toISOString() + infraInterval = '1h' + break + case '1day': + start = now.subtract(1, 'day').toISOString() + infraInterval = '1h' + break + case '7day': + start = now.subtract(7, 'day').toISOString() + infraInterval = '1d' + break + default: + start = now.subtract(1, 'hour').toISOString() + infraInterval = '1h' + } + + return { startDate: start, endDate: end, infraInterval } + }, [interval, refreshKey]) + + const { + data: infraData, + isLoading: infraLoading, + error: infraError, + } = useInfraMonitoringAttributesQuery({ + projectRef, + attributes: [ + 'avg_cpu_usage', + 'ram_usage', + 'disk_io_consumption', + 'pg_stat_database_num_backends', + ], + startDate, + endDate, + interval: infraInterval, + }) + + const { data: maxConnectionsData } = useMaxConnectionsQuery({ + projectRef, + connectionString: project?.connectionString, + }) + + const metrics = useMemo(() => parseInfrastructureMetrics(infraData), [infraData]) + + const connections = useMemo( + () => parseConnectionsData(infraData, maxConnectionsData), + [infraData, maxConnectionsData] + ) + + const errorMessage = + infraError && typeof infraError === 'object' && 'message' in infraError + ? String(infraError.message) + : 'Error loading data' + + // Generate database report URL with time range parameters + const getDatabaseReportUrl = () => { + const now = dayjs() + let its: string + let helperText: string + + switch (interval) { + case '1hr': + its = now.subtract(1, 'hour').toISOString() + helperText = 'Last 60 minutes' + break + case '1day': + its = now.subtract(24, 'hour').toISOString() + helperText = 'Last 24 hours' + break + case '7day': + its = now.subtract(7, 'day').toISOString() + helperText = 'Last 7 days' + break + default: + its = now.subtract(24, 'hour').toISOString() + helperText = 'Last 24 hours' + } + + const ite = now.toISOString() + const params = new URLSearchParams({ + its, + ite, + isHelper: 'true', + helperText, + }) + + return `/project/${projectRef}/observability/database?${params.toString()}` + } + + const databaseReportUrl = getDatabaseReportUrl() + + return ( +
+

Database

+ {/* First row: Metrics */} +
+ + + + + Error Rate + + + + {dbErrorRate.toFixed(2)}% + + + + + ', value: 1000 }))}`} + className="block group" + > + + ', value: 1000 }))}`} + linkTooltip="Go to query performance" + > + + Slow Queries + + + + {slowQueriesCount} + + + + + + + + + Connections + + + + {infraError ? ( +
{errorMessage}
+ ) : connections.max > 0 ? ( + + {connections.current}/{connections.max} + + ) : ( + -- + )} +
+
+ + + + + + + Disk + + + + {infraError ? ( +
{errorMessage}
+ ) : metrics ? ( + {metrics.disk.current.toFixed(0)}% + ) : ( + -- + )} +
+
+ + + + + + + Memory + + + + {infraError ? ( +
{errorMessage}
+ ) : metrics ? ( + {metrics.ram.current.toFixed(0)}% + ) : ( + -- + )} +
+
+ + + + + + + CPU + + + + {infraError ? ( +
{errorMessage}
+ ) : metrics ? ( + {metrics.cpu.current.toFixed(0)}% + ) : ( + -- + )} +
+
+ +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.test.ts b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.test.ts new file mode 100644 index 0000000000000..09388cc150f8c --- /dev/null +++ b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.test.ts @@ -0,0 +1,256 @@ +import type { + InfraMonitoringMultiResponse, + InfraMonitoringSingleResponse, +} from 'data/analytics/infra-monitoring-query' +import { describe, expect, it } from 'vitest' + +import { + parseConnectionsData, + parseInfrastructureMetrics, + parseNumericValue, +} from './DatabaseInfrastructureSection.utils' + +describe('parseNumericValue', () => { + it('returns number value as-is', () => { + expect(parseNumericValue(42)).toBe(42) + expect(parseNumericValue(0)).toBe(0) + expect(parseNumericValue(3.14)).toBe(3.14) + }) + + it('parses valid string numbers', () => { + expect(parseNumericValue('42')).toBe(42) + expect(parseNumericValue('3.14')).toBe(3.14) + expect(parseNumericValue('0')).toBe(0) + }) + + it('returns 0 for invalid string values', () => { + expect(parseNumericValue('invalid')).toBe(0) + expect(parseNumericValue('')).toBe(0) + expect(parseNumericValue('NaN')).toBe(0) + }) + + it('returns 0 for undefined', () => { + expect(parseNumericValue(undefined)).toBe(0) + }) +}) + +describe('parseInfrastructureMetrics', () => { + it('returns null for undefined data', () => { + expect(parseInfrastructureMetrics(undefined)).toBe(null) + }) + + it('parses valid metrics from response', () => { + const mockResponse: InfraMonitoringMultiResponse = { + data: [], + series: { + avg_cpu_usage: { format: 'percent', total: 150, totalAverage: 50, yAxisLimit: 100 }, + ram_usage: { format: 'bytes', total: 180, totalAverage: 60, yAxisLimit: 100 }, + disk_io_consumption: { + format: 'percent', + total: 210, + totalAverage: 70, + yAxisLimit: 100, + }, + }, + } + + const result = parseInfrastructureMetrics(mockResponse) + + expect(result).toEqual({ + cpu: { current: 50, max: 100 }, + ram: { current: 60, max: 100 }, + disk: { current: 70, max: 100 }, + }) + }) + + it('handles string values in metrics', () => { + const mockResponse: InfraMonitoringMultiResponse = { + data: [], + series: { + avg_cpu_usage: { format: 'percent', total: 150, totalAverage: '50.5', yAxisLimit: 100 }, + ram_usage: { format: 'bytes', total: 180, totalAverage: '60.8', yAxisLimit: 100 }, + disk_io_consumption: { + format: 'percent', + total: 210, + totalAverage: '70.2', + yAxisLimit: 100, + }, + }, + } + + const result = parseInfrastructureMetrics(mockResponse) + + expect(result).toEqual({ + cpu: { current: 50.5, max: 100 }, + ram: { current: 60.8, max: 100 }, + disk: { current: 70.2, max: 100 }, + }) + }) + + it('returns 0 for missing metrics', () => { + const mockResponse: InfraMonitoringMultiResponse = { + data: [], + series: {}, + } + + const result = parseInfrastructureMetrics(mockResponse) + + expect(result).toEqual({ + cpu: { current: 0, max: 100 }, + ram: { current: 0, max: 100 }, + disk: { current: 0, max: 100 }, + }) + }) + + it('handles partial metrics data', () => { + const mockResponse: InfraMonitoringMultiResponse = { + data: [], + series: { + avg_cpu_usage: { format: 'percent', total: 150, totalAverage: 50, yAxisLimit: 100 }, + }, + } + + const result = parseInfrastructureMetrics(mockResponse) + + expect(result).toEqual({ + cpu: { current: 50, max: 100 }, + ram: { current: 0, max: 100 }, + disk: { current: 0, max: 100 }, + }) + }) + + it('handles single-response format (legacy)', () => { + const mockResponse: InfraMonitoringSingleResponse = { + data: [], + format: 'percent', + total: 150, + totalAverage: 50, + yAxisLimit: 100, + } + + const result = parseInfrastructureMetrics(mockResponse) + + expect(result).toEqual({ + cpu: { current: 0, max: 100 }, + ram: { current: 0, max: 100 }, + disk: { current: 0, max: 100 }, + }) + }) +}) + +describe('parseConnectionsData', () => { + it('returns zeros when data is undefined', () => { + expect(parseConnectionsData(undefined, undefined)).toEqual({ current: 0, max: 0 }) + expect(parseConnectionsData(undefined, { maxConnections: 100 })).toEqual({ + current: 0, + max: 0, + }) + }) + + it('parses connections data correctly', () => { + const mockInfraData: InfraMonitoringMultiResponse = { + data: [], + series: { + pg_stat_database_num_backends: { + format: 'number', + total: 150, + totalAverage: 25.5, + yAxisLimit: 100, + }, + }, + } + + const mockMaxData = { maxConnections: 100 } + + const result = parseConnectionsData(mockInfraData, mockMaxData) + + expect(result).toEqual({ current: 26, max: 100 }) // 25.5 rounded to 26 + }) + + it('handles string values for connections', () => { + const mockInfraData: InfraMonitoringMultiResponse = { + data: [], + series: { + pg_stat_database_num_backends: { + format: 'number', + total: 150, + totalAverage: '30.7', + yAxisLimit: 100, + }, + }, + } + + const mockMaxData = { maxConnections: 100 } + + const result = parseConnectionsData(mockInfraData, mockMaxData) + + expect(result).toEqual({ current: 31, max: 100 }) // 30.7 rounded to 31 + }) + + it('returns current connections even when maxConnectionsData is undefined', () => { + const mockInfraData: InfraMonitoringMultiResponse = { + data: [], + series: { + pg_stat_database_num_backends: { + format: 'number', + total: 150, + totalAverage: 25, + yAxisLimit: 100, + }, + }, + } + + const result = parseConnectionsData(mockInfraData, undefined) + + expect(result).toEqual({ current: 25, max: 0 }) + }) + + it('returns 0 max when maxConnections is missing from data object', () => { + const mockInfraData: InfraMonitoringMultiResponse = { + data: [], + series: { + pg_stat_database_num_backends: { + format: 'number', + total: 150, + totalAverage: 25, + yAxisLimit: 100, + }, + }, + } + + const mockMaxData = {} + + const result = parseConnectionsData(mockInfraData, mockMaxData) + + expect(result).toEqual({ current: 25, max: 0 }) + }) + + it('returns 0 current when connections metric is missing', () => { + const mockInfraData: InfraMonitoringMultiResponse = { + data: [], + series: {}, + } + + const mockMaxData = { maxConnections: 100 } + + const result = parseConnectionsData(mockInfraData, mockMaxData) + + expect(result).toEqual({ current: 0, max: 100 }) + }) + + it('handles single-response format (legacy)', () => { + const mockInfraData: InfraMonitoringSingleResponse = { + data: [], + format: 'number', + total: 150, + totalAverage: 25, + yAxisLimit: 100, + } + + const mockMaxData = { maxConnections: 100 } + + const result = parseConnectionsData(mockInfraData, mockMaxData) + + expect(result).toEqual({ current: 0, max: 100 }) + }) +}) diff --git a/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.ts b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.ts new file mode 100644 index 0000000000000..09d272a258412 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.ts @@ -0,0 +1,82 @@ +import type { + InfraMonitoringMultiResponse, + InfraMonitoringResponse, +} from 'data/analytics/infra-monitoring-query' + +type NumericValue = string | number | undefined + +/** + * Parses a numeric value that can be a number, string, or undefined. + * Returns 0 for invalid or missing values. + */ +export function parseNumericValue(val: NumericValue): number { + if (typeof val === 'number') return val + if (typeof val === 'string') return parseFloat(val) || 0 + return 0 +} + +type MetricData = { + current: number + max: number +} + +type InfrastructureMetrics = { + cpu: MetricData + ram: MetricData + disk: MetricData +} + +export function parseInfrastructureMetrics( + infraData: InfraMonitoringResponse | undefined +): InfrastructureMetrics | null { + if (!infraData) { + return null + } + + const series = 'series' in infraData ? infraData.series : {} + + const cpuValue = parseNumericValue(series.avg_cpu_usage?.totalAverage) + const ramValue = parseNumericValue(series.ram_usage?.totalAverage) + const diskValue = parseNumericValue(series.disk_io_consumption?.totalAverage) + + return { + cpu: { + current: cpuValue, + max: 100, + }, + ram: { + current: ramValue, + max: 100, + }, + disk: { + current: diskValue, + max: 100, + }, + } +} + +type ConnectionsData = { + current: number + max: number +} + +type MaxConnectionsData = { + maxConnections?: number +} + +export function parseConnectionsData( + infraData: InfraMonitoringResponse | undefined, + maxConnectionsData: MaxConnectionsData | undefined +): ConnectionsData { + if (!infraData) { + return { current: 0, max: 0 } + } + + const series = 'series' in infraData ? infraData.series : {} + + const currentVal = series.pg_stat_database_num_backends?.totalAverage + const current = Math.round(parseNumericValue(currentVal)) + const max = maxConnectionsData?.maxConnections || 0 + + return { current, max } +} diff --git a/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx b/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx new file mode 100644 index 0000000000000..e4737bb84dc10 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx @@ -0,0 +1,209 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useFlag, useParams } from 'common' +import ReportHeader from 'components/interfaces/Reports/ReportHeader' +import ReportPadding from 'components/interfaces/Reports/ReportPadding' +import { ChartIntervalDropdown } from 'components/ui/Logs/ChartIntervalDropdown' +import { CHART_INTERVALS } from 'components/ui/Logs/logs.utils' +import dayjs from 'dayjs' +import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { RefreshCw } from 'lucide-react' +import { useRouter } from 'next/router' +import { useCallback, useMemo, useState } from 'react' +import { Badge, Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' + +import { DatabaseInfrastructureSection } from './DatabaseInfrastructureSection' +import { ObservabilityOverviewFooter } from './ObservabilityOverviewFooter' +import { useObservabilityOverviewData } from './ObservabilityOverview.utils' +import { ServiceHealthTable } from './ServiceHealthTable' +import { useSlowQueriesCount } from './useSlowQueriesCount' + +type ChartIntervalKey = '1hr' | '1day' | '7day' + +export const ObservabilityOverview = () => { + const router = useRouter() + const { ref: projectRef } = useParams() + const { data: organization } = useSelectedOrganizationQuery() + const { plan } = useCurrentOrgPlan() + const queryClient = useQueryClient() + + const authReportEnabled = useFlag('authreportv2') + const edgeFnReportEnabled = useFlag('edgefunctionreport') + const realtimeReportEnabled = useFlag('realtimeReport') + const storageReportEnabled = useFlag('storagereport') + const postgrestReportEnabled = useFlag('postgrestreport') + const { projectStorageAll: storageSupported } = useIsFeatureEnabled(['project_storage:all']) + + const DEFAULT_INTERVAL: ChartIntervalKey = '1day' + const [interval, setInterval] = useState(DEFAULT_INTERVAL) + const [refreshKey, setRefreshKey] = useState(0) + + const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1] + + const { datetimeFormat } = useMemo(() => { + const format = selectedInterval.format || 'MMM D, ha' + return { datetimeFormat: format } + }, [selectedInterval]) + + const overviewData = useObservabilityOverviewData(projectRef!, interval, refreshKey) + + const { slowQueriesCount, isLoading: slowQueriesLoading } = useSlowQueriesCount( + projectRef, + refreshKey + ) + + const handleRefresh = useCallback(() => { + setRefreshKey((prev) => prev + 1) + queryClient.invalidateQueries({ queryKey: ['project-metrics'] }) + queryClient.invalidateQueries({ queryKey: ['postgrest-overview-metrics'] }) + queryClient.invalidateQueries({ queryKey: ['infra-monitoring'] }) + queryClient.invalidateQueries({ queryKey: ['max-connections'] }) + }, [queryClient]) + + const serviceBase = useMemo( + () => [ + { + key: 'db' as const, + name: 'Database', + reportUrl: `/project/${projectRef}/observability/database`, + logsUrl: `/project/${projectRef}/logs/postgres-logs`, + enabled: true, + hasReport: true, + }, + { + key: 'auth' as const, + name: 'Auth', + reportUrl: `/project/${projectRef}/observability/auth`, + logsUrl: `/project/${projectRef}/logs/auth-logs`, + enabled: true, + hasReport: authReportEnabled, + }, + { + key: 'functions' as const, + name: 'Edge Functions', + reportUrl: `/project/${projectRef}/observability/edge-functions`, + logsUrl: `/project/${projectRef}/logs/edge-functions-logs`, + enabled: true, + hasReport: edgeFnReportEnabled, + }, + { + key: 'realtime' as const, + name: 'Realtime', + reportUrl: `/project/${projectRef}/observability/realtime`, + logsUrl: `/project/${projectRef}/logs/realtime-logs`, + enabled: true, + hasReport: realtimeReportEnabled, + }, + { + key: 'storage' as const, + name: 'Storage', + reportUrl: `/project/${projectRef}/observability/storage`, + logsUrl: `/project/${projectRef}/logs/storage-logs`, + enabled: storageSupported, + hasReport: storageReportEnabled, + }, + { + key: 'postgrest' as const, + name: 'Data API', + reportUrl: `/project/${projectRef}/observability/postgrest`, + logsUrl: `/project/${projectRef}/logs/postgrest-logs`, + enabled: true, + hasReport: postgrestReportEnabled, + }, + ], + [ + projectRef, + authReportEnabled, + edgeFnReportEnabled, + realtimeReportEnabled, + storageReportEnabled, + storageSupported, + postgrestReportEnabled, + ] + ) + + const enabledServices = serviceBase.filter((s) => s.enabled) + + const dbServiceData = overviewData.services.db + + // Creates a 1-hour time window for the clicked bar for log filtering + const handleBarClick = useCallback( + (serviceKey: string, logsUrl: string) => (datum: any) => { + if (!datum?.timestamp) return + + const datumTimestamp = dayjs(datum.timestamp) + // Round down to the start of the hour + const start = datumTimestamp.startOf('hour').toISOString() + // Add 1 hour to get the end of the hour + const end = datumTimestamp.startOf('hour').add(1, 'hour').toISOString() + + const queryParams = new URLSearchParams({ + its: start, + ite: end, + }) + + router.push(`${logsUrl}?${queryParams.toString()}`) + }, + [router] + ) + + return ( + +
+
+ + + + Beta + + +

This page is subject to change

+
+
+
+
+ + setInterval(interval as ChartIntervalKey)} + planId={plan?.id} + planName={plan?.name} + organizationSlug={organization?.slug} + dropdownAlign="end" + tooltipSide="left" + /> +
+
+ +
+ + + ({ + key: service.key, + name: service.name, + description: '', + reportUrl: service.hasReport ? service.reportUrl : undefined, + logsUrl: service.logsUrl, + }))} + serviceData={overviewData.services} + onBarClick={handleBarClick} + interval={interval} + datetimeFormat={datetimeFormat} + /> +
+ + +
+ ) +} diff --git a/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.test.ts b/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.test.ts new file mode 100644 index 0000000000000..c6b45882e8429 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'vitest' + +import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' +import { + calculateErrorRate, + calculateSuccessRate, + getHealthStatus, +} from './ObservabilityOverview.utils' + +describe('calculateErrorRate', () => { + it('returns 0 when total is 0', () => { + const data: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 0, warning_count: 0, error_count: 0 }, + ] + + expect(calculateErrorRate(data)).toBe(0) + }) + + it('calculates correct error rate', () => { + const data: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 80, warning_count: 10, error_count: 10 }, + ] + + expect(calculateErrorRate(data)).toBe(10) + }) + + it('calculates error rate across multiple data points', () => { + const data: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 50, warning_count: 25, error_count: 25 }, + { timestamp: '2024-01-15T12:01:00Z', ok_count: 50, warning_count: 25, error_count: 25 }, + ] + + expect(calculateErrorRate(data)).toBe(25) + }) + + it('handles high error rates', () => { + const data: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 10, warning_count: 10, error_count: 80 }, + ] + + expect(calculateErrorRate(data)).toBe(80) + }) + + it('handles empty array', () => { + expect(calculateErrorRate([])).toBe(0) + }) +}) + +describe('calculateSuccessRate', () => { + it('returns 0 when total is 0', () => { + const data: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 0, warning_count: 0, error_count: 0 }, + ] + + expect(calculateSuccessRate(data)).toBe(0) + }) + + it('calculates correct success rate', () => { + const data: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 80, warning_count: 10, error_count: 10 }, + ] + + expect(calculateSuccessRate(data)).toBe(80) + }) + + it('returns 100% for all successful requests', () => { + const data: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 100, warning_count: 0, error_count: 0 }, + ] + + expect(calculateSuccessRate(data)).toBe(100) + }) + + it('calculates success rate across multiple data points', () => { + const data: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 70, warning_count: 15, error_count: 15 }, + { timestamp: '2024-01-15T12:01:00Z', ok_count: 70, warning_count: 15, error_count: 15 }, + ] + + expect(calculateSuccessRate(data)).toBe(70) + }) + + it('handles empty array', () => { + expect(calculateSuccessRate([])).toBe(0) + }) +}) + +describe('getHealthStatus', () => { + describe('unknown status', () => { + it('returns unknown when total is 0', () => { + const result = getHealthStatus(0, 0) + + expect(result).toEqual({ + status: 'unknown', + color: 'muted', + }) + }) + + it('returns unknown when total is below 100', () => { + const result = getHealthStatus(5, 50) + + expect(result).toEqual({ + status: 'unknown', + color: 'muted', + }) + }) + + it('returns unknown when total is exactly 99', () => { + const result = getHealthStatus(10, 99) + + expect(result).toEqual({ + status: 'unknown', + color: 'muted', + }) + }) + + it('returns unknown even with high error rate when total is low', () => { + const result = getHealthStatus(50, 20) + + expect(result).toEqual({ + status: 'unknown', + color: 'muted', + }) + }) + }) + + describe('error status', () => { + it('returns error when error rate is exactly 1%', () => { + const result = getHealthStatus(1, 100) + + expect(result).toEqual({ + status: 'error', + color: 'destructive', + }) + }) + + it('returns error when error rate is above 1%', () => { + const result = getHealthStatus(5, 100) + + expect(result).toEqual({ + status: 'error', + color: 'destructive', + }) + }) + + it('returns error when error rate is 10%', () => { + const result = getHealthStatus(10, 200) + + expect(result).toEqual({ + status: 'error', + color: 'destructive', + }) + }) + + it('returns error for very high error rates', () => { + const result = getHealthStatus(90, 1000) + + expect(result).toEqual({ + status: 'error', + color: 'destructive', + }) + }) + }) + + describe('healthy status', () => { + it('returns healthy when error rate is 0% with sufficient data', () => { + const result = getHealthStatus(0, 100) + + expect(result).toEqual({ + status: 'healthy', + color: 'brand', + }) + }) + + it('returns healthy when error rate is below 1%', () => { + const result = getHealthStatus(0.5, 100) + + expect(result).toEqual({ + status: 'healthy', + color: 'brand', + }) + }) + + it('returns healthy when error rate is just below 1%', () => { + const result = getHealthStatus(0.99, 100) + + expect(result).toEqual({ + status: 'healthy', + color: 'brand', + }) + }) + + it('returns healthy with 0% error rate and large total', () => { + const result = getHealthStatus(0, 10000) + + expect(result).toEqual({ + status: 'healthy', + color: 'brand', + }) + }) + }) + + describe('edge cases', () => { + it('handles fractional error rates correctly', () => { + const result = getHealthStatus(0.99, 1000) + + expect(result).toEqual({ + status: 'healthy', + color: 'brand', + }) + }) + + it('returns unknown for very small total values', () => { + const result = getHealthStatus(10, 1) + + expect(result).toEqual({ + status: 'unknown', + color: 'muted', + }) + }) + + it('handles very large total values', () => { + const result = getHealthStatus(0.5, 1000000) + + expect(result).toEqual({ + status: 'healthy', + color: 'brand', + }) + }) + + it('returns healthy when total is exactly 100 and error rate is 0', () => { + const result = getHealthStatus(0, 100) + + expect(result).toEqual({ + status: 'healthy', + color: 'brand', + }) + }) + + it('returns error when total is exactly 100 and error rate is 1%', () => { + const result = getHealthStatus(1, 100) + + expect(result).toEqual({ + status: 'error', + color: 'destructive', + }) + }) + }) +}) diff --git a/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.ts b/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.ts new file mode 100644 index 0000000000000..fe929555f5c13 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.ts @@ -0,0 +1,83 @@ +import { + computeSuccessAndNonSuccessRates, + sumErrors, + sumTotal, + sumWarnings, +} from '../HomeNew/ProjectUsage.metrics' +import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' +import { useServiceHealthMetrics } from './useServiceHealthMetrics' + +export type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime' | 'postgrest' + +export type HealthStatus = 'healthy' | 'error' | 'unknown' + +export type ServiceHealthData = { + total: number + errorRate: number + successRate: number + errorCount: number + warningCount: number + okCount: number + eventChartData: LogsBarChartDatum[] + isLoading: boolean + error: unknown | null + refresh: () => void +} + +export type OverviewData = { + services: Record + aggregated: { + totalRequests: number + totalErrors: number + totalWarnings: number + overallErrorRate: number + overallSuccessRate: number + } + isLoading: boolean +} + +export const calculateErrorRate = (data: LogsBarChartDatum[]): number => { + const total = sumTotal(data) + const errors = sumErrors(data) + return total > 0 ? (errors / total) * 100 : 0 +} + +export const calculateSuccessRate = (data: LogsBarChartDatum[]): number => { + const total = sumTotal(data) + const warnings = sumWarnings(data) + const errors = sumErrors(data) + const { successRate } = computeSuccessAndNonSuccessRates(total, warnings, errors) + return successRate +} + +/** + * Get health status and color based on error rate + * - Unknown: total_requests < 100 (insufficient data) + * - Healthy: error_rate < 1% + * - Unhealthy: error_rate ≥ 1% + */ +export const getHealthStatus = ( + errorRate: number, + total: number +): { status: HealthStatus; color: string } => { + if (total < 100) { + return { status: 'unknown', color: 'muted' } + } + if (errorRate >= 1) { + return { status: 'error', color: 'destructive' } + } + return { status: 'healthy', color: 'brand' } +} + +/** + * Hook to fetch and transform observability overview data for all services + * Uses the same reliable query logic as the logs pages + */ +export const useObservabilityOverviewData = ( + projectRef: string, + interval: '1hr' | '1day' | '7day', + refreshKey: number +): OverviewData => { + // The new hook handles all services using logs page logic + return useServiceHealthMetrics(projectRef, interval, refreshKey) +} diff --git a/apps/studio/components/interfaces/Observability/ObservabilityOverviewFooter.tsx b/apps/studio/components/interfaces/Observability/ObservabilityOverviewFooter.tsx new file mode 100644 index 0000000000000..af9586e514442 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/ObservabilityOverviewFooter.tsx @@ -0,0 +1,19 @@ +import Link from 'next/link' + +export const ObservabilityOverviewFooter = () => { + return ( +
+

+ + View our troubleshooting guides + {' '} + for solutions to common Supabase issues.{' '} +

+
+ ) +} diff --git a/apps/studio/components/interfaces/Observability/ServiceHealthCard.tsx b/apps/studio/components/interfaces/Observability/ServiceHealthCard.tsx new file mode 100644 index 0000000000000..40a2cd5378a97 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/ServiceHealthCard.tsx @@ -0,0 +1,110 @@ +import NoDataPlaceholder from 'components/ui/Charts/NoDataPlaceholder' +import Link from 'next/link' +import { Button, Card, CardContent, CardFooter, CardHeader, CardTitle, Loading, cn } from 'ui' +import { LogsBarChart } from 'ui-patterns/LogsBarChart' + +import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' +import { type ServiceKey, getHealthStatus } from './ObservabilityOverview.utils' + +const colorClassMap: Record = { + muted: 'bg-muted', + destructive: 'bg-destructive', + warning: 'bg-warning', + brand: 'bg-brand', +} + +export type ServiceHealthCardProps = { + serviceName: string + serviceKey: ServiceKey + total: number + errorRate: number + errorCount: number + warningCount: number + chartData: LogsBarChartDatum[] + reportUrl?: string // undefined if feature flag disabled or no report available + logsUrl: string + isLoading: boolean + onBarClick: (datum: any) => void + datetimeFormat: string +} + +export const ServiceHealthCard = ({ + serviceName, + serviceKey, + total, + errorRate, + errorCount, + warningCount, + chartData, + reportUrl, + logsUrl, + isLoading, + onBarClick, + datetimeFormat, +}: ServiceHealthCardProps) => { + const { status, color } = getHealthStatus(errorRate, total) + + return ( + + +
+ +
+ {serviceName} + +
+
+ {total.toLocaleString()} + requests +
+
+ {errorRate.toFixed(1)}% + error rate +
+
+
+
+
+
+
+ Warn +
+ {warningCount.toLocaleString()} +
+
+
+
+ Err +
+ {errorCount.toLocaleString()} +
+
+ + + + + + } + /> + + + + + {reportUrl && ( + + )} + + + + ) +} diff --git a/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx b/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx new file mode 100644 index 0000000000000..e372be858c840 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx @@ -0,0 +1,220 @@ +import { HelpCircle } from 'lucide-react' +import { ChevronRight } from 'lucide-react' +import Link from 'next/link' +import { Card, CardContent } from 'ui' +import { Badge, Loading, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { LogsBarChart } from 'ui-patterns/LogsBarChart' + +import { ButtonTooltip } from '../../ui/ButtonTooltip' +import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' +import { type ServiceKey, getHealthStatus } from './ObservabilityOverview.utils' + +type ServiceConfig = { + key: ServiceKey + name: string + description: string + reportUrl?: string + logsUrl: string +} + +type ServiceData = { + total: number + errorRate: number + errorCount: number + warningCount: number + eventChartData: LogsBarChartDatum[] + isLoading: boolean +} + +type IntervalKey = '1hr' | '1day' | '7day' + +export type ServiceHealthTableProps = { + services: ServiceConfig[] + serviceData: Record + onBarClick: (serviceKey: string, logsUrl: string) => (datum: LogsBarChartDatum) => void + interval: IntervalKey + datetimeFormat: string +} + +const SERVICE_DESCRIPTIONS: Record = { + db: 'PostgreSQL database health and performance', + auth: 'Authentication and user management', + functions: 'Serverless Edge Functions execution', + storage: 'Object storage for files and assets', + realtime: 'WebSocket connections and broadcasts', + postgrest: 'Auto-generated REST API for your database', +} + +const getStatusLabel = (status: 'healthy' | 'error' | 'unknown'): string => { + switch (status) { + case 'healthy': + return 'Healthy' + case 'error': + return 'Unhealthy' + case 'unknown': + return 'Unknown' + } +} + +const getStatusTooltip = (status: 'healthy' | 'error' | 'unknown'): string => { + switch (status) { + case 'healthy': + return 'Error rate is below 1%' + case 'error': + return 'Error rate is 1% or higher' + case 'unknown': + return 'Insufficient data (fewer than 100 requests)' + } +} + +const getStatusVariant = ( + status: 'healthy' | 'error' | 'unknown' +): 'success' | 'warning' | 'destructive' | 'default' => { + switch (status) { + case 'healthy': + return 'success' + case 'error': + return 'destructive' + case 'unknown': + return 'default' + } +} + +type ServiceRowProps = { + service: ServiceConfig + data: ServiceData + onBarClick: (datum: LogsBarChartDatum) => void + datetimeFormat: string +} + +const ServiceRow = ({ service, data, onBarClick, datetimeFormat }: ServiceRowProps) => { + const { status } = getHealthStatus(data.errorRate, data.total) + const statusLabel = getStatusLabel(status) + const statusVariant = getStatusVariant(status) + const statusTooltip = getStatusTooltip(status) + + const errorRate = data.total > 0 ? data.errorRate : 0 + const warningRate = data.total > 0 ? (data.warningCount / data.total) * 100 : 0 + + const reportUrl = service.reportUrl || service.logsUrl + + return ( + +
+
+ {service.name} + + + + + +

{SERVICE_DESCRIPTIONS[service.key as ServiceKey] || service.description}

+
+
+
+
+ + + {statusLabel} + + +

{statusTooltip}

+
+
+ + + +
+
+ +
e.preventDefault()}> + + {data.isLoading ? ( +
+ ) : ( + + No data +
+ } + /> + )} +
+
+ + {data.total > 0 && ( +
+ {errorRate > 0 && ( + +
+ {errorRate.toFixed(2)}% errors + + )} + {warningRate > 0 && ( + +
+ {warningRate.toFixed(2)}% warnings + + )} + {errorRate === 0 && warningRate === 0 && ( + +
+ 0% errors + + )} +
+ )} + + ) +} + +export const ServiceHealthTable = ({ + services, + serviceData, + onBarClick, + datetimeFormat, +}: ServiceHealthTableProps) => { + return ( +
+

Service Health

+ + + {services.map((service) => { + const data = serviceData[service.key] + if (!data) return null + + return ( + + ) + })} + + +
+ ) +} diff --git a/apps/studio/components/interfaces/Observability/usePostgrestOverviewMetrics.ts b/apps/studio/components/interfaces/Observability/usePostgrestOverviewMetrics.ts new file mode 100644 index 0000000000000..4180fb3b3d5a4 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/usePostgrestOverviewMetrics.ts @@ -0,0 +1,101 @@ +import { useQuery } from '@tanstack/react-query' +import { get } from 'data/fetchers' +import { generateRegexpWhere } from '../Reports/Reports.constants' +import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' + +type PostgrestMetricsVariables = { + projectRef: string + startDate: string + endDate: string + interval: '1hr' | '1day' | '7day' +} + +const getIntervalTrunc = (interval: '1hr' | '1day' | '7day') => { + switch (interval) { + case '1hr': + return 'minute' // 1-minute buckets for 1 hour + case '1day': + return 'hour' // 1-hour buckets for 1 day + case '7day': + return 'day' // 1-day buckets for 7 days + default: + return 'hour' + } +} + +const POSTGREST_METRICS_SQL = (interval: '1hr' | '1day' | '7day') => { + const truncInterval = getIntervalTrunc(interval) + + return ` + -- postgrest-overview-metrics + select + cast(timestamp_trunc(t.timestamp, ${truncInterval}) as datetime) as timestamp, + countif(response.status_code < 300) as ok_count, + countif(response.status_code >= 300 and response.status_code < 400) as warning_count, + countif(response.status_code >= 400) as error_count + FROM edge_logs t + cross join unnest(metadata) as m + cross join unnest(m.response) as response + cross join unnest(m.request) as request + WHERE + request.path like '/rest/%' + GROUP BY + timestamp + ORDER BY + timestamp ASC + ` +} + +type MetricsRow = { + timestamp: string + ok_count: number + warning_count: number + error_count: number +} + +async function fetchPostgrestMetrics( + { projectRef, startDate, endDate, interval }: PostgrestMetricsVariables, + signal?: AbortSignal +) { + const sql = POSTGREST_METRICS_SQL(interval) + + const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { + params: { + path: { ref: projectRef }, + query: { + sql, + iso_timestamp_start: startDate, + iso_timestamp_end: endDate, + }, + }, + signal, + }) + + if (error || data?.error) { + throw error || data?.error + } + + return (data?.result || []) as MetricsRow[] +} + +export const usePostgrestOverviewMetrics = ( + { projectRef, startDate, endDate, interval }: PostgrestMetricsVariables, + options?: { enabled?: boolean } +) => { + return useQuery({ + queryKey: ['postgrest-overview-metrics', projectRef, startDate, endDate, interval], + queryFn: ({ signal }) => + fetchPostgrestMetrics({ projectRef, startDate, endDate, interval }, signal), + enabled: (options?.enabled ?? true) && Boolean(projectRef), + staleTime: 1000 * 60, + }) +} + +export const transformPostgrestMetrics = (rows: MetricsRow[]): LogsBarChartDatum[] => { + return rows.map((row) => ({ + timestamp: row.timestamp, + ok_count: row.ok_count, + warning_count: row.warning_count, + error_count: row.error_count, + })) +} diff --git a/apps/studio/components/interfaces/Observability/useSlowQueriesCount.ts b/apps/studio/components/interfaces/Observability/useSlowQueriesCount.ts new file mode 100644 index 0000000000000..9789a61b190aa --- /dev/null +++ b/apps/studio/components/interfaces/Observability/useSlowQueriesCount.ts @@ -0,0 +1,36 @@ +import useDbQuery from 'hooks/analytics/useDbQuery' +import { useMemo } from 'react' + +export const useSlowQueriesCount = (projectRef?: string, refreshKey: number = 0) => { + // SQL to count queries with total execution time > 1000ms (1 second) + // refreshKey is used in useMemo to force recomputation when refresh is triggered + const sql = useMemo( + () => ` + -- observability-slow-queries-count + set search_path to public, extensions; + + SELECT + count(*)::int as slow_queries_count + FROM pg_stat_statements + WHERE total_exec_time + total_plan_time > 1000; + `, + [refreshKey] + ) + + const { data, isLoading, error } = useDbQuery({ + sql, + }) + + const slowQueriesCount = useMemo(() => { + if (!data || !Array.isArray(data) || data.length === 0) { + return 0 + } + return data[0]?.slow_queries_count ?? 0 + }, [data]) + + return { + slowQueriesCount, + isLoading, + error, + } +} diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/InitializeForeignSchemaDialog.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/InitializeForeignSchemaDialog.tsx index bb79a037b7986..03f2f808e532b 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/InitializeForeignSchemaDialog.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/InitializeForeignSchemaDialog.tsx @@ -1,10 +1,4 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { parseAsBoolean, useQueryState } from 'nuqs' -import { useState } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' -import { toast } from 'sonner' -import z from 'zod' - import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'common' import { formatWrapperTables } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils' @@ -16,6 +10,10 @@ import { useFDWUpdateMutation } from 'data/fdw/fdw-update-mutation' import { fdwKeys } from 'data/fdw/keys' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DOCS_URL } from 'lib/constants' +import { parseAsBoolean, useQueryState } from 'nuqs' +import { useState } from 'react' +import { SubmitHandler, useForm } from 'react-hook-form' +import { toast } from 'sonner' import { Button, Dialog, @@ -26,13 +24,17 @@ import { DialogSectionSeparator, DialogTitle, DialogTrigger, - Form_Shadcn_, FormField_Shadcn_, + Form_Shadcn_, Input_Shadcn_, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import z from 'zod' + import { getDecryptedParameters } from '../../Storage.utils' +import { useS3VectorsWrapperExtension } from '../useS3VectorsWrapper' import { useS3VectorsWrapperInstance } from '../useS3VectorsWrapperInstance' +import { isGreaterThanOrEqual } from '@/lib/semver' // Create foreign tables for vector bucket export const InitializeForeignSchemaDialog = () => { @@ -45,6 +47,10 @@ export const InitializeForeignSchemaDialog = () => { const [isCreating, setIsCreating] = useState(false) const { data: wrapperInstance, meta: wrapperMeta } = useS3VectorsWrapperInstance({ bucketId }) + const { extension: wrappersExtension } = useS3VectorsWrapperExtension() + const updatedImportForeignSchemaSyntax = !!wrappersExtension?.installed_version + ? isGreaterThanOrEqual(wrappersExtension.installed_version, '0.5.7') + : false const FormSchema = z.object({ schema: z @@ -107,9 +113,9 @@ export const InitializeForeignSchemaDialog = () => { projectRef, connectionString: project?.connectionString, serverName: wrapperInstance.server_name, - sourceSchema: values.schema, + sourceSchema: updatedImportForeignSchemaSyntax ? bucketId : values.schema, targetSchema: values.schema, - schemaOptions: [`bucket_name '${bucketId}'`], + schemaOptions: updatedImportForeignSchemaSyntax ? undefined : [`bucket_name '${bucketId}'`], }) toast.success( diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx index 9ececaf5dff6c..1b57d52ebc143 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx @@ -1,8 +1,3 @@ -import { ChevronDown, ListPlus } from 'lucide-react' -import Link from 'next/link' -import { parseAsBoolean, useQueryState } from 'nuqs' -import { useState } from 'react' - import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { DocsButton } from 'components/ui/DocsButton' @@ -11,26 +6,33 @@ import { VectorBucketIndex } from 'data/storage/vector-buckets-indexes-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { SqlEditor } from 'icons' import { DOCS_URL } from 'lib/constants' +import { ChevronDown, ListPlus } from 'lucide-react' +import Link from 'next/link' +import { parseAsBoolean, useQueryState } from 'nuqs' +import { useState } from 'react' import { Button, - cn, CodeBlock, - Command_Shadcn_, CommandGroup_Shadcn_, CommandItem_Shadcn_, CommandList_Shadcn_, - Popover_Shadcn_, + Command_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, + Popover_Shadcn_, Sheet, SheetContent, SheetHeader, SheetSection, SheetTitle, SheetTrigger, + cn, } from 'ui' import { Admonition } from 'ui-patterns' + +import { useS3VectorsWrapperExtension } from '../useS3VectorsWrapper' import { useS3VectorsWrapperInstance } from '../useS3VectorsWrapperInstance' +import { isGreaterThanOrEqual } from '@/lib/semver' interface VectorBucketTableExamplesSheetProps { index: VectorBucketIndex @@ -147,6 +149,11 @@ function VectorBucketIndexExamples({ const { data: wrapperInstance } = useS3VectorsWrapperInstance({ bucketId }) const foreignTable = wrapperInstance?.tables?.find((x) => x.name === indexName) + const { extension: wrappersExtension } = useS3VectorsWrapperExtension() + const updatedImportForeignSchemaSyntax = !!wrappersExtension?.installed_version + ? isGreaterThanOrEqual(wrappersExtension.installed_version, '0.5.7') + : false + const dimensionLabel = `Data should match ${dimension} dimension${dimension > 1 ? 's' : ''}` const startValue = 0.1 const dimensionComment = generateDimensionComment(dimension) @@ -159,12 +166,12 @@ insert into values ( 'doc-1', - '[${dimensionExample}]'::embd${sqlComment}, + '[${dimensionExample}]'${updatedImportForeignSchemaSyntax ? '::s3vec' : '::embd'}${sqlComment}, '{${metadataKeys.map((key) => `"${key}": "${key} value"`).join(', ')}}'::jsonb ), ( 'doc-2', - '[${dimensionExample}]'::embd${sqlComment}, + '[${dimensionExample}]'${updatedImportForeignSchemaSyntax ? '::s3vec' : '::embd'}${sqlComment}, '{${metadataKeys.map((key) => `"${key}": "${key} value"`).join(', ')}}'::jsonb );` diff --git a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx index 3ce1b8b2565e9..b0e186cbb4e0c 100644 --- a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx +++ b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx @@ -1,32 +1,34 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Plus } from 'lucide-react' -import Link from 'next/link' -import { useRouter } from 'next/router' -import { Fragment, useMemo, useState } from 'react' -import { toast } from 'sonner' - import { useFlag, useParams } from 'common' -import { IS_PLATFORM } from 'lib/constants' import { CreateReportModal } from 'components/interfaces/Reports/CreateReportModal' import { UpdateCustomReportModal } from 'components/interfaces/Reports/UpdateModal' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useContentDeleteMutation } from 'data/content/content-delete-mutation' -import { Content, useContentQuery } from 'data/content/content-query' +import { Content, ContentBase, useContentQuery } from 'data/content/content-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { IS_PLATFORM } from 'lib/constants' import { useProfile } from 'lib/profile' +import { Plus } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { parseAsBoolean, useQueryState } from 'nuqs' +import { Fragment, useMemo, useState } from 'react' +import { toast } from 'sonner' +import type { Dashboards } from 'types' import { Menu, cn } from 'ui' import { InnerSideBarEmptyPanel } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + import { ObservabilityMenuItem } from './ObservabilityMenuItem' -import { useQueryState, parseAsBoolean } from 'nuqs' const ObservabilityMenu = () => { const router = useRouter() const { profile } = useProfile() const { ref, id } = useParams() - const pageKey = (id || router.pathname.split('/')[4]) as string + const pageKey = (id || router.pathname.split('/')[4] || 'observability') as string + const showOverview = useFlag('observabilityOverview') const authEnabled = useFlag('authreportv2') const edgeFnEnabled = useFlag('edgefunctionreport') const realtimeEnabled = useFlag('realtimeReport') @@ -89,10 +91,17 @@ const ObservabilityMenu = () => { deleteReport({ projectRef: ref, ids: [selectedReportToDelete.id] }) } + function isReportContent(c: Content): c is ContentBase & { + type: 'report' + content: Dashboards.Content + } { + return c.type === 'report' + } + function getReportMenuItems() { if (!content) return [] - const reports = content?.content.filter((c) => c.type === 'report') + const reports = content?.content.filter(isReportContent) const sortedReports = reports?.sort((a, b) => { if (a.name < b.name) { @@ -121,15 +130,15 @@ const ObservabilityMenu = () => { const menuItems = [ { - title: 'Performance Reports', - key: 'performance-reports', + title: 'GENERAL', + key: 'general-section', items: [ - ...(IS_PLATFORM + ...(showOverview ? [ { - name: 'API Gateway', - key: 'api-overview', - url: `/project/${ref}/observability/api-overview${preservedQueryParams}`, + name: 'Overview', + key: 'observability', + url: `/project/${ref}/observability${preservedQueryParams}`, }, ] : []), @@ -138,54 +147,54 @@ const ObservabilityMenu = () => { key: 'query-performance', url: `/project/${ref}/observability/query-performance${preservedQueryParams}`, }, - ...(postgrestReportEnabled + ...(IS_PLATFORM ? [ { - name: 'Data API', - key: 'postgrest', - url: `/project/${ref}/observability/postgrest${preservedQueryParams}`, + name: 'API Gateway', + key: 'api-overview', + url: `/project/${ref}/observability/api-overview${preservedQueryParams}`, }, ] : []), ], }, { - title: 'Product Reports', - key: 'product-reports', + title: 'PRODUCT', + key: 'product-section', items: [ - ...(authEnabled + ...(IS_PLATFORM ? [ { - name: 'Auth', - key: 'auth', - url: `/project/${ref}/observability/auth${preservedQueryParams}`, + name: 'Database', + key: 'database', + url: `/project/${ref}/observability/database${preservedQueryParams}`, }, ] : []), - ...(IS_PLATFORM + ...(postgrestReportEnabled ? [ { - name: 'Database', - key: 'database', - url: `/project/${ref}/observability/database${preservedQueryParams}`, + name: 'Data API', + key: 'postgrest', + url: `/project/${ref}/observability/postgrest${preservedQueryParams}`, }, ] : []), - ...(edgeFnEnabled + ...(authEnabled ? [ { - name: 'Edge Functions', - key: 'edge-functions', - url: `/project/${ref}/observability/edge-functions${preservedQueryParams}`, + name: 'Auth', + key: 'auth', + url: `/project/${ref}/observability/auth${preservedQueryParams}`, }, ] : []), - ...(realtimeEnabled + ...(edgeFnEnabled ? [ { - name: 'Realtime', - key: 'realtime', - url: `/project/${ref}/observability/realtime${preservedQueryParams}`, + name: 'Edge Functions', + key: 'edge-functions', + url: `/project/${ref}/observability/edge-functions${preservedQueryParams}`, }, ] : []), @@ -198,6 +207,15 @@ const ObservabilityMenu = () => { }, ] : []), + ...(realtimeEnabled + ? [ + { + name: 'Realtime', + key: 'realtime', + url: `/project/${ref}/observability/realtime${preservedQueryParams}`, + }, + ] + : []), ], }, ] @@ -212,83 +230,6 @@ const ObservabilityMenu = () => {
) : (
- {IS_PLATFORM && ( -
- - Custom Reports - {reportMenuItems.length > 0 && ( - } - disabled={!canCreateCustomReport} - className="flex items-center justify-center h-6 w-6 absolute top-0 -right-1" - onClick={() => { - setShowNewReportModal(true) - }} - tooltip={{ - content: { - side: 'bottom', - text: !canCreateCustomReport - ? 'You need additional permissions to create custom reports' - : undefined, - }, - }} - /> - )} - - } - /> - {reportMenuItems.length === 0 ? ( -
- } - disabled={!canCreateCustomReport} - onClick={() => { - setShowNewReportModal(true) - }} - tooltip={{ - content: { - side: 'bottom', - text: !canCreateCustomReport - ? 'You need additional permissions to create custom reports' - : undefined, - }, - }} - > - New custom report - - } - /> -
- ) : ( - <> - {reportMenuItems.map((item) => ( - { - setSelectedReportToUpdate(item.report) - }} - onSelectDelete={() => { - setSelectedReportToDelete(item.report) - setDeleteModalOpen(true) - }} - /> - ))} - - )} -
- )} - {menuItems.map((item, idx) => (
@@ -323,6 +264,86 @@ const ObservabilityMenu = () => { ))} + {IS_PLATFORM && ( + +
+
+ + Custom Reports + {reportMenuItems.length > 0 && ( + } + disabled={!canCreateCustomReport} + className="flex items-center justify-center h-6 w-6 absolute top-0 -right-1" + onClick={() => { + setShowNewReportModal(true) + }} + tooltip={{ + content: { + side: 'bottom', + text: !canCreateCustomReport + ? 'You need additional permissions to create custom reports' + : undefined, + }, + }} + /> + )} + + } + /> + {reportMenuItems.length === 0 ? ( +
+ } + disabled={!canCreateCustomReport} + onClick={() => { + setShowNewReportModal(true) + }} + tooltip={{ + content: { + side: 'bottom', + text: !canCreateCustomReport + ? 'You need additional permissions to create custom reports' + : undefined, + }, + }} + > + New custom report + + } + /> +
+ ) : ( + <> + {reportMenuItems.map((item) => ( + { + setSelectedReportToUpdate(item.report) + }} + onSelectDelete={() => { + setSelectedReportToDelete(item.report) + setDeleteModalOpen(true) + }} + /> + ))} + + )} +
+ + )} + setSelectedReportToUpdate(undefined)} selectedReport={selectedReportToUpdate} diff --git a/apps/studio/components/ui/Logs/ChartIntervalDropdown.tsx b/apps/studio/components/ui/Logs/ChartIntervalDropdown.tsx new file mode 100644 index 0000000000000..15728fc0f28c5 --- /dev/null +++ b/apps/studio/components/ui/Logs/ChartIntervalDropdown.tsx @@ -0,0 +1,98 @@ +import { InlineLink } from 'components/ui/InlineLink' +import { ChevronDown } from 'lucide-react' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, +} from 'ui' + +import { CHART_INTERVALS, LOG_RETENTION } from './logs.utils' + +type PlanId = keyof typeof LOG_RETENTION + +interface ChartIntervalDropdownProps { + value: string + onChange: (value: string) => void + planId?: string + planName?: string + organizationSlug?: string + dropdownAlign?: 'start' | 'center' | 'end' + tooltipSide?: 'left' | 'right' | 'top' | 'bottom' +} + +export const ChartIntervalDropdown = ({ + value, + onChange, + planId = 'free', + planName, + organizationSlug, + dropdownAlign = 'start', + tooltipSide = 'right', +}: ChartIntervalDropdownProps) => { + const selectedInterval = CHART_INTERVALS.find((i) => i.key === value) || CHART_INTERVALS[1] + const normalizedPlanId = (planId && planId in LOG_RETENTION ? planId : 'free') as PlanId + + return ( + + + + + + + {CHART_INTERVALS.map((i) => { + const disabled = !i.availableIn?.includes(normalizedPlanId) + + if (disabled) { + const retentionDuration = LOG_RETENTION[normalizedPlanId] + return ( + + + + {i.label} + + + +

+ {planName} plan only includes up to {retentionDuration} day + {retentionDuration > 1 ? 's' : ''} of log retention +

+

+ {organizationSlug ? ( + <> + + Upgrade your plan + {' '} + to increase log retention and view statistics for the{' '} + {i.label.toLowerCase()} + + ) : ( + `Upgrade your plan to increase log retention and view statistics for the ${i.label.toLowerCase()}` + )} +

+
+
+ ) + } else { + return ( + + {i.label} + + ) + } + })} +
+
+
+ ) +} diff --git a/apps/studio/components/ui/Logs/logs.utils.ts b/apps/studio/components/ui/Logs/logs.utils.ts new file mode 100644 index 0000000000000..b6eed6c53a4d1 --- /dev/null +++ b/apps/studio/components/ui/Logs/logs.utils.ts @@ -0,0 +1,36 @@ +import type { ChartIntervals } from 'types' + +export const LOG_RETENTION = { + free: 1, + pro: 7, + team: 28, + enterprise: 90, + platform: 1, +} + +export const CHART_INTERVALS: ChartIntervals[] = [ + { + key: '1hr', + label: 'Last 60 minutes', + startValue: 1, + startUnit: 'hour', + format: 'MMM D, h:mma', + availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'], + }, + { + key: '1day', + label: 'Last 24 hours', + startValue: 24, + startUnit: 'hour', + format: 'MMM D, ha', + availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'], + }, + { + key: '7day', + label: 'Last 7 days', + startValue: 7, + startUnit: 'day', + format: 'MMM D', + availableIn: ['pro', 'team', 'enterprise'], + }, +] diff --git a/apps/studio/data/analytics/infra-monitoring-query.ts b/apps/studio/data/analytics/infra-monitoring-query.ts index 65f7faad28235..322bc4bf3ac86 100644 --- a/apps/studio/data/analytics/infra-monitoring-query.ts +++ b/apps/studio/data/analytics/infra-monitoring-query.ts @@ -1,8 +1,8 @@ import { useQuery } from '@tanstack/react-query' - import { paths } from 'api-types' import { get, handleError } from 'data/fetchers' import { UseCustomQueryOptions } from 'types' + import type { AnalyticsInterval } from './constants' import { analyticsKeys } from './keys' @@ -14,7 +14,7 @@ export type InfraMonitoringSeriesMetadata = { yAxisLimit: number format: string total: number - totalAverage: number + totalAverage: number | string } // TODO(raulb): Remove InfraMonitoringSingleResponse once API always returns multi-attribute format. diff --git a/apps/studio/data/fdw/fdw-import-foreign-schema-mutation.ts b/apps/studio/data/fdw/fdw-import-foreign-schema-mutation.ts index 95801c4345224..fd3f3262fe9a8 100644 --- a/apps/studio/data/fdw/fdw-import-foreign-schema-mutation.ts +++ b/apps/studio/data/fdw/fdw-import-foreign-schema-mutation.ts @@ -1,12 +1,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' - import { entityTypeKeys } from 'data/entity-types/keys' import { foreignTableKeys } from 'data/foreign-tables/keys' import { executeSql } from 'data/sql/execute-sql-query' import { wrapWithTransaction } from 'data/sql/utils/transaction' import { vaultSecretsKeys } from 'data/vault/keys' +import { toast } from 'sonner' import type { ResponseError, UseCustomMutationOptions } from 'types' + import { fdwKeys } from './keys' export type FDWImportForeignSchemaVariables = { diff --git a/apps/studio/evals/assistant.eval.ts b/apps/studio/evals/assistant.eval.ts index 650dbe2e525f7..9f069d54f5112 100644 --- a/apps/studio/evals/assistant.eval.ts +++ b/apps/studio/evals/assistant.eval.ts @@ -13,6 +13,7 @@ import { sqlIdentifierQuotingScorer, sqlSyntaxScorer, toolUsageScorer, + urlValidityScorer, } from './scorer' import { ToolSet, TypedToolCall, TypedToolResult } from 'ai' @@ -83,6 +84,7 @@ Eval('Assistant', { completenessScorer, docsFaithfulnessScorer, correctnessScorer, + urlValidityScorer, ], }) diff --git a/apps/studio/evals/dataset.ts b/apps/studio/evals/dataset.ts index a73e8638ba560..10652dd6e9ba7 100644 --- a/apps/studio/evals/dataset.ts +++ b/apps/studio/evals/dataset.ts @@ -106,4 +106,25 @@ export const dataset: AssistantEvalCase[] = [ description: 'Invokes `execute_sql` from default "Generate sample data" prompt', }, }, + { + input: { prompt: 'Where can I go to create a support ticket?' }, + expected: { + correctAnswer: 'https://supabase.com/dashboard/support/new', + }, + metadata: { + category: ['general_help'], + description: 'Verifies AI provides valid support ticket URL', + }, + }, + { + input: { prompt: 'What is my OAuth callback URL for setting up GitHub authentication?' }, + expected: { + requiredTools: ['search_docs'], + }, + metadata: { + category: ['general_help'], + description: + 'Verifies template URLs like https://.supabase.co/auth/v1/callback are excluded from URL validity scoring', + }, + }, ] diff --git a/apps/studio/evals/scorer.ts b/apps/studio/evals/scorer.ts index 6a9a0e1ac4825..fae806851dceb 100644 --- a/apps/studio/evals/scorer.ts +++ b/apps/studio/evals/scorer.ts @@ -2,10 +2,10 @@ import { FinishReason } from 'ai' import { LLMClassifierFromTemplate } from 'autoevals' import { EvalCase, EvalScorer } from 'braintrust' import { stripIndent } from 'common-tags' -import { parse } from 'libpg-query' -import { MOCK_TABLES_DATA } from 'lib/ai/tools/mock-tools' +import { extractUrls } from 'lib/helpers' import { extractIdentifiers } from 'lib/sql-identifier-quoting' import { isQuotedInSql, needsQuoting } from 'lib/sql-identifier-quoting' +import { parse } from 'libpg-query' const LLM_AS_A_JUDGE_MODEL = 'gpt-5.2-2025-12-11' @@ -323,3 +323,41 @@ export const sqlIdentifierQuotingScorer: EvalScorer = a metadata: errors.length > 0 ? { errors } : undefined, } } + +export const urlValidityScorer: EvalScorer = async ({ output }) => { + const responseText = extractTextOnly(output.steps) + const urls = extractUrls(responseText, { excludeCodeBlocks: true, excludeTemplates: true }) + + // Skip if no URLs found + if (urls.length === 0) { + return null + } + + const errors: string[] = [] + let validUrls = 0 + + for (const url of urls) { + try { + const response = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(5000) }) + if (response.ok) { + validUrls++ + } else { + errors.push(`${url} returned ${response.status}`) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + errors.push(`${url} failed: ${errorMessage}`) + } + } + + const metadata = { + urls, + errors: errors.length > 0 ? errors : undefined, + } + + return { + name: 'URL Validity', + score: validUrls / urls.length, + metadata, + } +} diff --git a/apps/studio/lib/ai/prompts.ts b/apps/studio/lib/ai/prompts.ts index 9c719bca9ec23..d212c09bb9190 100644 --- a/apps/studio/lib/ai/prompts.ts +++ b/apps/studio/lib/ai/prompts.ts @@ -611,6 +611,7 @@ export const CHAT_PROMPT = ` - When invoking a tool, call it directly without pausing. - Provide succinct outputs unless the complexity of the user request requires additional explanation. - Be confident in your responses and tool calling +- When referencing template URLs with placeholders, use angle bracket syntax (e.g., \`https://.supabase.co\`) ## Chat Naming - At the start of each conversation, if the chat is unnamed, call \`rename_chat\` with a succinct 2–4 word descriptive name (e.g., "User Authentication Setup", "Sales Data Analysis", "Product Table Creation"). @@ -636,6 +637,9 @@ export const CHAT_PROMPT = ` - To check organization usage, use the organization's usage page. Link directly to https://supabase.com/dashboard/org/_/usage. - Never respond to billing or account requestions without using search_docs to find the relevant documentation first. - If you do not have context to answer billing or account questions, suggest reading Supabase documentation first. +## Support +- Prefer solving issues yourself before directing users to create support tickets +- If needed, direct users to create support tickets via https://supabase.com/dashboard/support/new # Data Recovery When asked about restoring/recovering deleted data: 1. Search docs for how deletion works for that data type (e.g., "delete storage objects", "delete database rows") to understand if recovery is possible diff --git a/apps/studio/lib/helpers.test.ts b/apps/studio/lib/helpers.test.ts index 7699fdd23108b..4ce0dfe4f6227 100644 --- a/apps/studio/lib/helpers.test.ts +++ b/apps/studio/lib/helpers.test.ts @@ -1,9 +1,11 @@ +import { copyToClipboard } from 'ui' import { v4 as _uuidV4 } from 'uuid' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { detectBrowser, detectOS, + extractUrls, formatBytes, formatCurrency, getDatabaseMajorVersion, @@ -20,6 +22,7 @@ import { removeCommentsFromSql, removeJSONTrailingComma, snakeToCamel, + stripMarkdownCodeBlocks, tablesToSQL, timeout, tryParseInt, @@ -27,8 +30,6 @@ import { uuidv4, } from './helpers' -import { copyToClipboard } from 'ui' - vi.mock('uuid', () => ({ v4: vi.fn(() => 'mocked-uuid'), })) @@ -311,6 +312,197 @@ describe('isValidHttpUrl', () => { }) }) +describe('extractUrls', () => { + it('should extract basic http URLs', () => { + const result = extractUrls('Visit http://example.com for more info') + expect(result).toEqual(['http://example.com']) + }) + + it('should extract basic https URLs', () => { + const result = extractUrls('Check out https://supabase.com') + expect(result).toEqual(['https://supabase.com']) + }) + + it('should extract URLs with ports', () => { + const result = extractUrls('Connect to http://localhost:3000') + expect(result).toEqual(['http://localhost:3000']) + }) + + it('should extract URLs with paths', () => { + const result = extractUrls('Go to https://example.com/path/to/page') + expect(result).toEqual(['https://example.com/path/to/page']) + }) + + it('should extract URLs with query parameters', () => { + const result = extractUrls('Visit https://example.com/search?q=test&page=1') + expect(result).toEqual(['https://example.com/search?q=test&page=1']) + }) + + it('should extract URLs with fragments', () => { + const result = extractUrls('See https://example.com/page#section') + expect(result).toEqual(['https://example.com/page#section']) + }) + + it('should extract URLs with complex paths, query params, and fragments', () => { + const result = extractUrls('Check https://example.com/api/v1/users?id=123&name=test#details') + expect(result).toEqual(['https://example.com/api/v1/users?id=123&name=test#details']) + }) + + it('should extract multiple URLs from text', () => { + const result = extractUrls('Visit http://example.com and https://supabase.com for more info') + expect(result).toEqual(['http://example.com', 'https://supabase.com']) + }) + + it('should remove trailing punctuation from URLs', () => { + const result = extractUrls('Visit https://example.com.') + expect(result).toEqual(['https://example.com']) + }) + + it('should remove multiple trailing punctuation marks', () => { + const result = extractUrls('Check https://example.com!!!') + expect(result).toEqual(['https://example.com']) + }) + + it('should remove trailing punctuation including parentheses', () => { + const result = extractUrls('See (https://example.com)') + expect(result).toEqual(['https://example.com']) + }) + + it('should handle URLs with trailing commas and periods', () => { + const result = extractUrls('Visit https://example.com, and https://supabase.com.') + expect(result).toEqual(['https://example.com', 'https://supabase.com']) + }) + + it('should handle URLs with subpath and markdown bolding', () => { + const result = extractUrls('Check out **https://example.com/subpath** for details') + expect(result).toEqual(['https://example.com/subpath']) + }) + + it('should return empty array when no URLs are found', () => { + const result = extractUrls('This is just plain text with no URLs') + expect(result).toEqual([]) + }) + + it('should return empty array for empty string', () => { + const result = extractUrls('') + expect(result).toEqual([]) + }) + + it('should handle URLs in parentheses', () => { + const result = extractUrls('Check out (https://example.com) for details') + expect(result).toEqual(['https://example.com']) + }) + + it('should be case insensitive for protocol', () => { + const result = extractUrls('Visit HTTP://EXAMPLE.COM and HTTPS://SUPABASE.COM') + expect(result).toEqual(['HTTP://EXAMPLE.COM', 'HTTPS://SUPABASE.COM']) + }) + + it('should handle URLs with special characters in path', () => { + const result = extractUrls('Visit https://example.com/path_with_underscores/file-name.txt') + expect(result).toEqual(['https://example.com/path_with_underscores/file-name.txt']) + }) + + it('should handle URLs with encoded characters', () => { + const result = extractUrls('Visit https://example.com/search?q=hello%20world') + expect(result).toEqual(['https://example.com/search?q=hello%20world']) + }) + + it('should handle URLs with subdomains', () => { + const result = extractUrls('Visit https://www.example.com and https://api.example.com') + expect(result).toEqual(['https://www.example.com', 'https://api.example.com']) + }) + + describe('with excludeCodeBlocks option', () => { + it('should exclude URLs in fenced code blocks', () => { + const text = 'Visit https://real.com\n```\nhttps://code.com\n```' + expect(extractUrls(text, { excludeCodeBlocks: true })).toEqual(['https://real.com']) + }) + + it('should exclude URLs in fenced code blocks with language specifier', () => { + const text = 'Visit https://real.com\n```sql\nSELECT * FROM https://code.com\n```' + expect(extractUrls(text, { excludeCodeBlocks: true })).toEqual(['https://real.com']) + }) + + it('should exclude URLs in inline code', () => { + const text = 'Use `https://code.com` for the endpoint, or visit https://real.com' + expect(extractUrls(text, { excludeCodeBlocks: true })).toEqual(['https://real.com']) + }) + + it('should handle multiple code blocks', () => { + const text = + 'https://first.com\n```\nhttps://code1.com\n```\nhttps://second.com\n```\nhttps://code2.com\n```' + expect(extractUrls(text, { excludeCodeBlocks: true })).toEqual([ + 'https://first.com', + 'https://second.com', + ]) + }) + + it('should not exclude code blocks by default', () => { + const text = 'Visit https://real.com\n```\nhttps://code.com\n```' + expect(extractUrls(text)).toEqual(['https://real.com', 'https://code.com']) + }) + }) + + describe('with excludeTemplates option', () => { + it('should not extract URLs with angle brackets in subdomain', () => { + // Angle brackets in subdomain prevent the URL from being extracted at all + const text = 'Visit https://real.com or https://.supabase.co' + expect(extractUrls(text, { excludeTemplates: true })).toEqual(['https://real.com']) + }) + + it('should exclude URLs truncated at angle brackets in path', () => { + // The regex stops at angle brackets - exclude the whole truncated URL + const text = 'Visit https://real.com or https://example.com/api//data' + expect(extractUrls(text, { excludeTemplates: true })).toEqual(['https://real.com']) + }) + + it('should keep URLs without angle brackets', () => { + const text = 'Visit https://example.com/path_with_underscores' + expect(extractUrls(text, { excludeTemplates: true })).toEqual([ + 'https://example.com/path_with_underscores', + ]) + }) + }) + + describe('with both options', () => { + it('should exclude both code blocks and template URLs', () => { + const text = + 'Visit https://real.com\n```\nhttps://code.com\n```\nOr https://.supabase.co' + expect(extractUrls(text, { excludeCodeBlocks: true, excludeTemplates: true })).toEqual([ + 'https://real.com', + ]) + }) + }) +}) + +describe('stripMarkdownCodeBlocks', () => { + it('should remove fenced code blocks', () => { + const text = 'Before\n```\ncode here\n```\nAfter' + expect(stripMarkdownCodeBlocks(text)).toBe('Before\n\nAfter') + }) + + it('should remove fenced code blocks with language specifier', () => { + const text = 'Before\n```typescript\nconst x = 1;\n```\nAfter' + expect(stripMarkdownCodeBlocks(text)).toBe('Before\n\nAfter') + }) + + it('should remove inline code', () => { + const text = 'Use `inline code` here' + expect(stripMarkdownCodeBlocks(text)).toBe('Use here') + }) + + it('should handle multiple code blocks', () => { + const text = '```js\ncode1\n```\ntext\n```ts\ncode2\n```' + expect(stripMarkdownCodeBlocks(text)).toBe('\ntext\n') + }) + + it('should preserve text without code blocks', () => { + const text = 'Just regular text here' + expect(stripMarkdownCodeBlocks(text)).toBe('Just regular text here') + }) +}) + describe('removeCommentsFromSql', () => { it('should remove comments from SQL', () => { const result = removeCommentsFromSql(`-- This is a comment diff --git a/apps/studio/lib/helpers.ts b/apps/studio/lib/helpers.ts index 58ffb5c303d29..23d7437f36d16 100644 --- a/apps/studio/lib/helpers.ts +++ b/apps/studio/lib/helpers.ts @@ -262,6 +262,62 @@ export const isValidHttpUrl = (value: string) => { return url.protocol === 'http:' || url.protocol === 'https:' } +/** + * Remove markdown code blocks (fenced and inline) from text + */ +export const stripMarkdownCodeBlocks = (text: string): string => { + // Remove fenced code blocks (```...```) + const withoutFenced = text.replace(/```[\s\S]*?```/g, '') + // Remove inline code (`...`) + return withoutFenced.replace(/`[^`]+`/g, '') +} + +interface ExtractUrlsOptions { + excludeCodeBlocks?: boolean + excludeTemplates?: boolean +} + +/** + * Extract URLs from text using regex for URL detection + * Matches URLs with protocols (http/https) and common domain patterns + * @param text - The text to extract URLs from + * @param options - Optional filtering options + * @returns Array of extracted URLs with trailing punctuation removed + */ +export const extractUrls = (text: string, options?: ExtractUrlsOptions): string[] => { + const { excludeCodeBlocks = false, excludeTemplates = false } = options ?? {} + + let processedText = text + if (excludeCodeBlocks) { + processedText = stripMarkdownCodeBlocks(processedText) + } + + // Regex matches URLs with protocols (http/https) + // Handles: domains, ports, paths, query params, and fragments + // Pattern: https?://domain(:port)?(/path)?(?query)?(#fragment)? + const urlRegex = /https?:\/\/(?:[-\w.])+(?::\d+)?(?:\/(?:[\w\/_.~!*'();:@&=+$,?#[\]%-])*)?/gi + + const urls: string[] = [] + let match + + while ((match = urlRegex.exec(processedText)) !== null) { + // Remove trailing punctuation that might have been captured (common in text) + const url = match[0].replace(/[.,;:!?)*]+$/, '') + + if (excludeTemplates) { + // Skip URLs that were truncated at an angle bracket (template URL) + const endPos = match.index + match[0].length + if (processedText[endPos] === '<') { + continue + } + } + + urls.push(url) + } + + return urls +} + /** * Helper function to remove comments from SQL. * Disclaimer: Doesn't work as intended for nested comments. diff --git a/apps/studio/pages/project/[ref]/observability/index.tsx b/apps/studio/pages/project/[ref]/observability/index.tsx index b911059e054ef..087c2791fb12a 100644 --- a/apps/studio/pages/project/[ref]/observability/index.tsx +++ b/apps/studio/pages/project/[ref]/observability/index.tsx @@ -2,8 +2,9 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' -import { useParams } from 'common' +import { useParams, useFlag } from 'common' import { CreateReportModal } from 'components/interfaces/Reports/CreateReportModal' +import { ObservabilityOverview } from 'components/interfaces/Observability/ObservabilityOverview' import DefaultLayout from 'components/layouts/DefaultLayout' import ObservabilityLayout from 'components/layouts/ObservabilityLayout/ObservabilityLayout' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' @@ -19,6 +20,7 @@ export const UserReportPage: NextPageWithLayout = () => { const { ref } = useParams() const { profile } = useProfile() + const showOverview = useFlag('observabilityOverview') const [showCreateReportModal, setShowCreateReportModal] = useQueryState( 'newReport', parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) @@ -35,13 +37,14 @@ export const UserReportPage: NextPageWithLayout = () => { useEffect(() => { if (!isSuccess) return + if (showOverview) return // Don't redirect if overview is enabled const reports = data.content .filter((x) => x.type === 'report') .sort((a, b) => a.name.localeCompare(b.name)) if (reports.length >= 1) router.push(`/project/${ref}/observability/${reports[0].id}`) if (reports.length === 0) router.push(`/project/${ref}/observability/api-overview`) - }, [isSuccess, data, router, ref]) + }, [isSuccess, data, router, ref, showOverview]) const { can: canCreateReport } = useAsyncCheckPermissions( PermissionAction.CREATE, @@ -52,6 +55,11 @@ export const UserReportPage: NextPageWithLayout = () => { } ) + // Show overview page if feature flag is enabled + if (showOverview) { + return + } + return (
{isLoading ? ( diff --git a/apps/studio/pages/project/[ref]/storage/vectors/buckets/[bucketId].tsx b/apps/studio/pages/project/[ref]/storage/vectors/buckets/[bucketId].tsx index 08c21d744c71d..4c5f2200cfb91 100644 --- a/apps/studio/pages/project/[ref]/storage/vectors/buckets/[bucketId].tsx +++ b/apps/studio/pages/project/[ref]/storage/vectors/buckets/[bucketId].tsx @@ -1,27 +1,24 @@ -import { useRouter } from 'next/router' -import { useEffect } from 'react' -import { toast } from 'sonner' - import { useParams } from 'common' import { BUCKET_TYPES } from 'components/interfaces/Storage/Storage.constants' -import { useSelectedVectorBucket } from 'components/interfaces/Storage/VectorBuckets/useSelectedVectorBuckets' import { VectorBucketDetails } from 'components/interfaces/Storage/VectorBuckets/VectorBucketDetails' +import { useSelectedVectorBucket } from 'components/interfaces/Storage/VectorBuckets/useSelectedVectorBuckets' import { DefaultLayout } from 'components/layouts/DefaultLayout' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' import { DocsButton } from 'components/ui/DocsButton' import { VectorBucket as VectorBucketIcon } from 'icons' -import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' +import { useRouter } from 'next/router' +import { useEffect } from 'react' +import { toast } from 'sonner' import type { NextPageWithLayout } from 'types' const VectorsBucketPage: NextPageWithLayout = () => { - const config = BUCKET_TYPES['vectors'] const router = useRouter() const { ref, bucketId } = useParams() - const { projectRef } = useStorageExplorerStateSnapshot() - const { data: bucket, isSuccess } = useSelectedVectorBucket() + const config = BUCKET_TYPES['vectors'] + useEffect(() => { if (isSuccess && !bucket) { toast.info(`Bucket "${bucketId}" does not exist in your project`) @@ -39,7 +36,7 @@ const VectorsBucketPage: NextPageWithLayout = () => {
} breadcrumbs={[ - { label: 'Vectors', href: `/project/${projectRef}/storage/vectors` }, + { label: 'Vectors', href: `/project/${ref}/storage/vectors` }, { label: 'Buckets', }, diff --git a/apps/ui-library/config/docs.ts b/apps/ui-library/config/docs.ts index 98a90cb62f1b1..680fc13deffb5 100644 --- a/apps/ui-library/config/docs.ts +++ b/apps/ui-library/config/docs.ts @@ -76,14 +76,14 @@ export const componentPages: SidebarNavGroup = { }, { title: 'Dropzone', - supportedFrameworks: ['nextjs', 'react-router', 'tanstack', 'react'], + supportedFrameworks: ['nextjs', 'react-router', 'tanstack', 'react', 'vue', 'nuxtjs'], href: '/docs/nextjs/dropzone', items: [], commandItemLabel: 'Dropzone (File Upload)', }, { title: 'Realtime Cursor', - supportedFrameworks: ['nextjs', 'react-router', 'tanstack', 'react'], + supportedFrameworks: ['nextjs', 'react-router', 'tanstack', 'react', 'vue', 'nuxtjs'], href: '/docs/nextjs/realtime-cursor', items: [], commandItemLabel: 'Realtime Cursor', diff --git a/apps/ui-library/content/docs/nuxtjs/dropzone.mdx b/apps/ui-library/content/docs/nuxtjs/dropzone.mdx new file mode 100644 index 0000000000000..d960c6f7f13e7 --- /dev/null +++ b/apps/ui-library/content/docs/nuxtjs/dropzone.mdx @@ -0,0 +1,81 @@ +--- +title: Dropzone (File Upload) +description: Displays a control for easier uploading of files directly to Supabase Storage +--- + + + +## Installation + + + +## Folder structure + +This block assumes that you have already installed a Supabase client for Nuxt from the previous step. + + + +## Introduction + +Uploading files should be easy—this component handles the tricky parts for you. + +The File Upload component makes it easy to add file uploads to your app, with built-in support for drag-and-drop, file type restrictions, image previews, and configurable limits on file size and number of files. All the essentials, ready to go. + +**Features** + +- Drag-and-drop support +- Multiple file uploads +- File size and count limits +- Image previews for supported file types +- MIME type restrictions +- Invalid file handling +- Success and error states with clear feedback + +## Usage + +- Simply add this `` component to your page and it will handle the rest. + +```html + + + +``` + +## Props + +| Prop | Type | Default | Description | +| ------------------ | ---------- | ---------- | ---------------------------------------------------- | +| `bucketName` | `string` | `null` | The name of the Supabase Storage bucket to upload to | +| `path` | `string` | `null` | The path or subfolder to upload the file to | +| `allowedMimeTypes` | `string[]` | `[]` | The MIME types to allow for upload | +| `maxFiles` | `number` | `1` | Maximum number of files to upload | +| `maxFileSize` | `number` | `Infinity` | Maximum file size in bytes | + +## Further reading + +- [Creating buckets](https://supabase.com/docs/guides/storage/buckets/creating-buckets) +- [Access control](https://supabase.com/docs/guides/storage/security/access-control) +- [Standard uploads](https://supabase.com/docs/guides/storage/uploads/standard-uploads) diff --git a/apps/ui-library/content/docs/nuxtjs/realtime-cursor.mdx b/apps/ui-library/content/docs/nuxtjs/realtime-cursor.mdx new file mode 100644 index 0000000000000..e89d6da110020 --- /dev/null +++ b/apps/ui-library/content/docs/nuxtjs/realtime-cursor.mdx @@ -0,0 +1,66 @@ +--- +title: Realtime Cursor +description: Real-time cursor sharing for collaborative applications +--- + +
+ + +
+ +## Installation + + + +## Folder structure + +This block assumes that you have already installed a Supabase client for Nuxt from the previous step. + + + +## Introduction + +The Realtime Cursors component lets users share their cursor position with others in the same room—perfect for real-time collaboration. It handles all the setup and boilerplate for you, so you can add it to your app with minimal effort. + +**Features** + +- Broadcast cursor position to other users in the same room +- Customizable cursor appearance +- Presence detection (automatically joins/leaves users) +- Low-latency updates using Supabase Realtime +- Room-based isolation for scoped collaboration + +## Usage + +The Realtime Cursor component is designed to be used in a room. It can be used to build real-time collaborative applications. Add the `` component to your page and it will render realtime cursors from other users in the room. + +```html + + + +``` + +## Props + +| Prop | Type | Description | +| ---------- | -------- | ---------------------------------------------------------- | +| `roomName` | `string` | Unique identifier for the shared room or session. | +| `username` | `string` | Name of the current user; used to track and label cursors. | + +## Further reading + +- [Realtime Broadcast](https://supabase.com/docs/guides/realtime/broadcast) +- [Realtime authorization](https://supabase.com/docs/guides/realtime/authorization) + +### Smoother cursors + +While our Realtime Cursor component aims to keep things simple and lightweight, you may want to add smoother cursor animations for a more polished experience. Libraries like [perfect-cursors](https://github.com/steveruizok/perfect-cursors) can be integrated to add sophisticated interpolation between cursor positions. This is especially useful when dealing with network latency, as it creates fluid cursor movements even when position updates are received at longer intervals (e.g., every 50-80ms). The library handles the complex math of creating natural-looking cursor paths while maintaining performance. diff --git a/apps/ui-library/content/docs/vue/dropzone.mdx b/apps/ui-library/content/docs/vue/dropzone.mdx new file mode 100644 index 0000000000000..f3eb934391b26 --- /dev/null +++ b/apps/ui-library/content/docs/vue/dropzone.mdx @@ -0,0 +1,81 @@ +--- +title: Dropzone (File Upload) +description: Displays a control for easier uploading of files directly to Supabase Storage +--- + + + +## Installation + + + +## Folder structure + +This block assumes that you have already installed a Supabase client for Vue from the previous step. + + + +## Introduction + +Uploading files should be easy—this component handles the tricky parts for you. + +The File Upload component makes it easy to add file uploads to your app, with built-in support for drag-and-drop, file type restrictions, image previews, and configurable limits on file size and number of files. All the essentials, ready to go. + +**Features** + +- Drag-and-drop support +- Multiple file uploads +- File size and count limits +- Image previews for supported file types +- MIME type restrictions +- Invalid file handling +- Success and error states with clear feedback + +## Usage + +- Simply add this `` component to your page and it will handle the rest. + +```html + + + +``` + +## Props + +| Prop | Type | Default | Description | +| ------------------ | ---------- | ---------- | ---------------------------------------------------- | +| `bucketName` | `string` | `null` | The name of the Supabase Storage bucket to upload to | +| `path` | `string` | `null` | The path or subfolder to upload the file to | +| `allowedMimeTypes` | `string[]` | `[]` | The MIME types to allow for upload | +| `maxFiles` | `number` | `1` | Maximum number of files to upload | +| `maxFileSize` | `number` | `Infinity` | Maximum file size in bytes | + +## Further reading + +- [Creating buckets](https://supabase.com/docs/guides/storage/buckets/creating-buckets) +- [Access control](https://supabase.com/docs/guides/storage/security/access-control) +- [Standard uploads](https://supabase.com/docs/guides/storage/uploads/standard-uploads) diff --git a/apps/ui-library/content/docs/vue/realtime-cursor.mdx b/apps/ui-library/content/docs/vue/realtime-cursor.mdx new file mode 100644 index 0000000000000..281bfdd433741 --- /dev/null +++ b/apps/ui-library/content/docs/vue/realtime-cursor.mdx @@ -0,0 +1,66 @@ +--- +title: Realtime Cursor +description: Real-time cursor sharing for collaborative applications +--- + +
+ + +
+ +## Installation + + + +## Folder structure + +This block assumes that you have already installed a Supabase client for Vue from the previous step. + + + +## Introduction + +The Realtime Cursors component lets users share their cursor position with others in the same room—perfect for real-time collaboration. It handles all the setup and boilerplate for you, so you can add it to your app with minimal effort. + +**Features** + +- Broadcast cursor position to other users in the same room +- Customizable cursor appearance +- Presence detection (automatically joins/leaves users) +- Low-latency updates using Supabase Realtime +- Room-based isolation for scoped collaboration + +## Usage + +The Realtime Cursor component is designed to be used in a room. It can be used to build real-time collaborative applications. Add the `` component to your page and it will render realtime cursors from other users in the room. + +```html + + + +``` + +## Props + +| Prop | Type | Description | +| ---------- | -------- | ---------------------------------------------------------- | +| `roomName` | `string` | Unique identifier for the shared room or session. | +| `username` | `string` | Name of the current user; used to track and label cursors. | + +## Further reading + +- [Realtime Broadcast](https://supabase.com/docs/guides/realtime/broadcast) +- [Realtime authorization](https://supabase.com/docs/guides/realtime/authorization) + +### Smoother cursors + +While our Realtime Cursor component aims to keep things simple and lightweight, you may want to add smoother cursor animations for a more polished experience. Libraries like [perfect-cursors](https://github.com/steveruizok/perfect-cursors) can be integrated to add sophisticated interpolation between cursor positions. This is especially useful when dealing with network latency, as it creates fluid cursor movements even when position updates are received at longer intervals (e.g., every 50-80ms). The library handles the complex math of creating natural-looking cursor paths while maintaining performance. diff --git a/apps/ui-library/public/llms.txt b/apps/ui-library/public/llms.txt index 179a12011d2be..02e7abd8fadb7 100644 --- a/apps/ui-library/public/llms.txt +++ b/apps/ui-library/public/llms.txt @@ -1,5 +1,5 @@ # Supabase UI Library -Last updated: 2025-11-20T11:14:14.146Z +Last updated: 2026-01-22T19:37:07.586Z ## Overview Library of components for your project. The components integrate with Supabase and are shadcn compatible. @@ -33,8 +33,12 @@ Library of components for your project. The components integrate with Supabase a - Social authentication block for Next.js - [Supabase Client Libraries](https://supabase.com/ui/docs/nuxtjs/client) - Supabase client for Nuxt.js +- [Dropzone (File Upload)](https://supabase.com/ui/docs/nuxtjs/dropzone) + - Displays a control for easier uploading of files directly to Supabase Storage - [Password-based Authentication](https://supabase.com/ui/docs/nuxtjs/password-based-auth) - Password-based authentication block for Nuxt.js +- [Realtime Cursor](https://supabase.com/ui/docs/nuxtjs/realtime-cursor) + - Real-time cursor sharing for collaborative applications - [Social Authentication](https://supabase.com/ui/docs/nuxtjs/social-auth) - Social authentication block for Nuxt.js - [Platform Kit](https://supabase.com/ui/docs/platform/platform-kit) @@ -89,7 +93,11 @@ Library of components for your project. The components integrate with Supabase a - Social authentication block for Tanstack Start - [Supabase Client Libraries](https://supabase.com/ui/docs/vue/client) - Supabase client for Vue Single Page Applications +- [Dropzone (File Upload)](https://supabase.com/ui/docs/vue/dropzone) + - Displays a control for easier uploading of files directly to Supabase Storage - [Password-based Authentication](https://supabase.com/ui/docs/vue/password-based-auth) - Password-based authentication block for Vue Single Page Applications +- [Realtime Cursor](https://supabase.com/ui/docs/vue/realtime-cursor) + - Real-time cursor sharing for collaborative applications - [Social Authentication](https://supabase.com/ui/docs/vue/social-auth) - Social authentication block for Vue Single Page Applications diff --git a/apps/ui-library/public/r/ai-editor-rules.json b/apps/ui-library/public/r/ai-editor-rules.json index 7d59f8be2528f..375824e29c8d2 100644 --- a/apps/ui-library/public/r/ai-editor-rules.json +++ b/apps/ui-library/public/r/ai-editor-rules.json @@ -33,7 +33,7 @@ }, { "path": "registry/default/ai-editor-rules/writing-supabase-edge-functions.mdc", - "content": "---\ndescription: Coding rules for Supabase Edge Functions\nalwaysApply: false\n---\n\n# Writing Supabase Edge Functions\n\nYou're an expert in writing TypeScript and Deno JavaScript runtime. Generate **high-quality Supabase Edge Functions** that adhere to the following best practices:\n\n## Guidelines\n\n1. Try to use Web APIs and Deno’s core APIs instead of external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws)\n2. If you are reusing utility methods between Edge Functions, add them to `supabase/functions/_shared` and import using a relative path. Do NOT have cross dependencies between Edge Functions.\n3. Do NOT use bare specifiers when importing dependecnies. If you need to use an external dependency, make sure it's prefixed with either `npm:` or `jsr:`. For example, `@supabase/supabase-js` should be written as `npm:@supabase/supabase-js`.\n4. For external imports, always define a version. For example, `npm:@express` should be written as `npm:express@4.18.2`.\n5. For external dependencies, importing via `npm:` and `jsr:` is preferred. Minimize the use of imports from @`deno.land/x` , `esm.sh` and @`unpkg.com` . If you have a package from one of those CDNs, you can replace the CDN hostname with `npm:` specifier.\n6. You can also use Node built-in APIs. You will need to import them using `node:` specifier. For example, to import Node process: `import process from \"node:process\". Use Node APIs when you find gaps in Deno APIs.\n7. Do NOT use `import { serve } from \"https://deno.land/std@0.168.0/http/server.ts\"`. Instead use the built-in `Deno.serve`.\n8. Following environment variables (ie. secrets) are pre-populated in both local and hosted Supabase environments. Users don't need to manually set them:\n - SUPABASE_URL\n - SUPABASE_PUBLISHABLE_OR_ANON_KEY\n - SUPABASE_SERVICE_ROLE_KEY\n - SUPABASE_DB_URL\n9. To set other environment variables (ie. secrets) users can put them in a env file and run the `supabase secrets set --env-file path/to/env-file`\n10. A single Edge Function can handle multiple routes. It is recommended to use a library like Express or Hono to handle the routes as it's easier for developer to understand and maintain. Each route must be prefixed with `/function-name` so they are routed correctly.\n11. File write operations are ONLY permitted on `/tmp` directory. You can use either Deno or Node File APIs.\n12. Use `EdgeRuntime.waitUntil(promise)` static method to run long-running tasks in the background without blocking response to a request. Do NOT assume it is available in the request / execution context.\n\n## Example Templates\n\n### Simple Hello World Function\n\n```tsx\ninterface reqPayload {\n name: string\n}\n\nconsole.info('server started')\n\nDeno.serve(async (req: Request) => {\n const { name }: reqPayload = await req.json()\n const data = {\n message: `Hello ${name} from foo!`,\n }\n\n return new Response(JSON.stringify(data), {\n headers: { 'Content-Type': 'application/json', Connection: 'keep-alive' },\n })\n})\n```\n\n### Example Function using Node built-in API\n\n```tsx\nimport { randomBytes } from 'node:crypto'\nimport { createServer } from 'node:http'\nimport process from 'node:process'\n\nconst generateRandomString = (length) => {\n const buffer = randomBytes(length)\n return buffer.toString('hex')\n}\n\nconst randomString = generateRandomString(10)\nconsole.log(randomString)\n\nconst server = createServer((req, res) => {\n const message = `Hello`\n res.end(message)\n})\n\nserver.listen(9999)\n```\n\n### Using npm packages in Functions\n\n```tsx\nimport express from 'npm:express@4.18.2'\n\nconst app = express()\n\napp.get(/(.*)/, (req, res) => {\n res.send('Welcome to Supabase')\n})\n\napp.listen(8000)\n```\n\n### Generate embeddings using built-in @Supabase.ai API\n\n```tsx\nconst model = new Supabase.ai.Session('gte-small')\n\nDeno.serve(async (req: Request) => {\n const params = new URL(req.url).searchParams\n const input = params.get('text')\n const output = await model.run(input, { mean_pool: true, normalize: true })\n return new Response(JSON.stringify(output), {\n headers: {\n 'Content-Type': 'application/json',\n Connection: 'keep-alive',\n },\n })\n})\n```\n", + "content": "---\ndescription: Coding rules for Supabase Edge Functions\nalwaysApply: false\n---\n\n# Writing Supabase Edge Functions\n\nYou're an expert in writing TypeScript and Deno JavaScript runtime. Generate **high-quality Supabase Edge Functions** that adhere to the following best practices:\n\n## Guidelines\n\n1. Try to use Web APIs and Deno’s core APIs instead of external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws)\n2. If you are reusing utility methods between Edge Functions, add them to `supabase/functions/_shared` and import using a relative path. Do NOT have cross dependencies between Edge Functions.\n3. Do NOT use bare specifiers when importing dependencies. If you need to use an external dependency, make sure it's prefixed with either `npm:` or `jsr:`. For example, `@supabase/supabase-js` should be written as `npm:@supabase/supabase-js`.\n4. For external imports, always define a version. For example, `npm:@express` should be written as `npm:express@4.18.2`.\n5. For external dependencies, importing via `npm:` and `jsr:` is preferred. Minimize the use of imports from @`deno.land/x` , `esm.sh` and @`unpkg.com` . If you have a package from one of those CDNs, you can replace the CDN hostname with `npm:` specifier.\n6. You can also use Node built-in APIs. You will need to import them using `node:` specifier. For example, to import Node process: `import process from \"node:process\". Use Node APIs when you find gaps in Deno APIs.\n7. Do NOT use `import { serve } from \"https://deno.land/std@0.168.0/http/server.ts\"`. Instead use the built-in `Deno.serve`.\n8. Following environment variables (ie. secrets) are pre-populated in both local and hosted Supabase environments. Users don't need to manually set them:\n - SUPABASE_URL\n - SUPABASE_PUBLISHABLE_OR_ANON_KEY\n - SUPABASE_SERVICE_ROLE_KEY\n - SUPABASE_DB_URL\n9. To set other environment variables (ie. secrets) users can put them in a env file and run the `supabase secrets set --env-file path/to/env-file`\n10. A single Edge Function can handle multiple routes. It is recommended to use a library like Express or Hono to handle the routes as it's easier for developer to understand and maintain. Each route must be prefixed with `/function-name` so they are routed correctly.\n11. File write operations are ONLY permitted on `/tmp` directory. You can use either Deno or Node File APIs.\n12. Use `EdgeRuntime.waitUntil(promise)` static method to run long-running tasks in the background without blocking response to a request. Do NOT assume it is available in the request / execution context.\n\n## Example Templates\n\n### Simple Hello World Function\n\n```tsx\ninterface reqPayload {\n name: string\n}\n\nconsole.info('server started')\n\nDeno.serve(async (req: Request) => {\n const { name }: reqPayload = await req.json()\n const data = {\n message: `Hello ${name} from foo!`,\n }\n\n return new Response(JSON.stringify(data), {\n headers: { 'Content-Type': 'application/json', Connection: 'keep-alive' },\n })\n})\n```\n\n### Example Function using Node built-in API\n\n```tsx\nimport { randomBytes } from 'node:crypto'\nimport { createServer } from 'node:http'\nimport process from 'node:process'\n\nconst generateRandomString = (length) => {\n const buffer = randomBytes(length)\n return buffer.toString('hex')\n}\n\nconst randomString = generateRandomString(10)\nconsole.log(randomString)\n\nconst server = createServer((req, res) => {\n const message = `Hello`\n res.end(message)\n})\n\nserver.listen(9999)\n```\n\n### Using npm packages in Functions\n\n```tsx\nimport express from 'npm:express@4.18.2'\n\nconst app = express()\n\napp.get(/(.*)/, (req, res) => {\n res.send('Welcome to Supabase')\n})\n\napp.listen(8000)\n```\n\n### Generate embeddings using built-in @Supabase.ai API\n\n```tsx\nconst model = new Supabase.ai.Session('gte-small')\n\nDeno.serve(async (req: Request) => {\n const params = new URL(req.url).searchParams\n const input = params.get('text')\n const output = await model.run(input, { mean_pool: true, normalize: true })\n return new Response(JSON.stringify(output), {\n headers: {\n 'Content-Type': 'application/json',\n Connection: 'keep-alive',\n },\n })\n})\n```\n", "type": "registry:file", "target": "~/.cursor/rules/writing-supabase-edge-functions.mdc" }, diff --git a/apps/ui-library/public/r/dropzone-nuxtjs.json b/apps/ui-library/public/r/dropzone-nuxtjs.json new file mode 100644 index 0000000000000..75767efe59727 --- /dev/null +++ b/apps/ui-library/public/r/dropzone-nuxtjs.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "dropzone-nuxtjs", + "type": "registry:block", + "title": "Dropzone (File Upload) for Nuxt and Supabase", + "description": "Displays a control for easier uploading of files directly to Supabase Storage.", + "dependencies": [ + "@supabase/supabase-js@latest", + "@vueuse/core", + "lucide-vue-next" + ], + "registryDependencies": [ + "button" + ], + "files": [ + { + "path": "registry/default/dropzone/nuxtjs/app/components/dropzone.vue", + "content": "\n\n\n", + "type": "registry:component", + "target": "app/components/dropzone.vue" + }, + { + "path": "registry/default/dropzone/nuxtjs/app/components/dropzone-empty-state.vue", + "content": "\n\n\n", + "type": "registry:component", + "target": "app/components/dropzone-empty-state.vue" + }, + { + "path": "registry/default/dropzone/nuxtjs/app/components/dropzone-content.vue", + "content": "\n\n\n", + "type": "registry:component", + "target": "app/components/dropzone-content.vue" + }, + { + "path": "registry/default/dropzone/nuxtjs/app/composables/useSupabaseUpload.ts", + "content": "import { ref, computed, watch, onUnmounted } from 'vue'\nimport { useDropZone } from '@vueuse/core'\nimport { createClient } from \"@/lib/supabase/client\"\n\nconst supabase = createClient()\n\nexport interface FileWithPreview extends File {\n preview?: string\n errors: { code: string; message: string }[]\n}\n\nexport type UseSupabaseUploadOptions = {\n bucketName: string\n path?: string\n allowedMimeTypes?: string[]\n maxFileSize?: number\n maxFiles?: number\n cacheControl?: number\n upsert?: boolean\n}\n\nfunction validateFileType(file: File, allowedTypes: string[]) {\n if (!allowedTypes.length) return []\n const isValid = allowedTypes.some(t =>\n t.endsWith('/*')\n ? file.type.startsWith(t.replace('/*', ''))\n : file.type === t\n )\n return isValid\n ? []\n : [{ code: 'invalid-type', message: 'Invalid file type' }]\n}\n\nfunction validateFileSize(file: File, maxSize: number) {\n return file.size > maxSize\n ? [{ code: 'file-too-large', message: `File is larger than allowed size` }]\n : []\n}\n\nexport function useSupabaseUpload(options: UseSupabaseUploadOptions) {\n const {\n bucketName,\n path,\n allowedMimeTypes = [],\n maxFileSize = Number.POSITIVE_INFINITY,\n maxFiles = 1,\n cacheControl = 3600,\n upsert = false,\n } = options\n\n const files = ref([])\n const loading = ref(false)\n const errors = ref<{ name: string; message: string }[]>([])\n const successes = ref([])\n\n const isSuccess = computed(() => {\n if (!errors.value.length && !successes.value.length) return false\n return !errors.value.length && successes.value.length === files.value.length\n })\n\n const dropZoneRef = ref(null)\n\n const { isOverDropZone } = useDropZone(dropZoneRef, {\n onDrop(droppedFiles: FileWithPreview[]) {\n if (!droppedFiles) return\n\n const newFiles: FileWithPreview[] = droppedFiles.map(file => ({\n ...(file as FileWithPreview),\n preview: URL.createObjectURL(file),\n errors: [\n ...validateFileType(file, allowedMimeTypes),\n ...validateFileSize(file, maxFileSize),\n ],\n }))\n\n files.value = [...files.value, ...newFiles]\n },\n })\n\n const onUpload = async () => {\n loading.value = true\n\n try {\n const filesWithErrors = errors.value.map(e => e.name)\n\n const filesToUpload =\n filesWithErrors.length > 0\n ? files.value.filter(\n f =>\n filesWithErrors.includes(f.name) ||\n !successes.value.includes(f.name)\n )\n : files.value\n\n const responses = await Promise.all(\n filesToUpload.map(async file => {\n const { error } = await supabase.storage\n .from(bucketName)\n .upload(path ? `${path}/${file.name}` : file.name, file, {\n cacheControl: cacheControl.toString(),\n upsert,\n })\n\n return error\n ? { name: file.name, message: error.message }\n : { name: file.name, message: undefined }\n })\n )\n\n errors.value = responses.filter((r): r is { name: string; message: string } => r.message !== undefined)\n\n const successful = responses\n .filter(r => !r.message)\n .map(r => r.name)\n\n successes.value = Array.from(\n new Set([...successes.value, ...successful])\n )\n } catch (err) {\n console.error('Upload failed unexpectedly:', err)\n\n errors.value.push({\n name: 'upload',\n message: 'An unexpected error occurred during upload.',\n })\n } finally {\n loading.value = false\n }\n }\n\n\n watch(\n () => files.value.length,\n () => {\n if (!files.value.length) {\n errors.value = []\n successes.value = []\n }\n\n if (files.value.length > maxFiles) {\n errors.value.push({\n name: 'files',\n message: `You may upload up to ${maxFiles} files`,\n })\n }\n }\n )\n\n watch(\n files,\n (newFiles, oldFiles) => {\n const newPreviews = new Set(newFiles.map(f => f.preview))\n oldFiles.forEach(file => {\n if (file.preview && !newPreviews.has(file.preview)) {\n URL.revokeObjectURL(file.preview)\n }\n })\n },\n { deep: true }\n )\n\n onUnmounted(() => {\n files.value.forEach(file => {\n if (file.preview) {\n URL.revokeObjectURL(file.preview)\n }\n })\n })\n\n return {\n dropZoneRef,\n isOverDropZone,\n\n files,\n setFiles: (v: FileWithPreview[]) => (files.value = v),\n\n errors,\n setErrors: (v: { name: string; message: string }[]) => (errors.value = v),\n\n successes,\n isSuccess,\n loading,\n onUpload,\n\n maxFileSize,\n maxFiles,\n allowedMimeTypes,\n }\n}\n", + "type": "registry:component", + "target": "app/composables/useSupabaseUpload.ts" + } + ] +} \ No newline at end of file diff --git a/apps/ui-library/public/r/dropzone-vue.json b/apps/ui-library/public/r/dropzone-vue.json new file mode 100644 index 0000000000000..596e2fb612f4d --- /dev/null +++ b/apps/ui-library/public/r/dropzone-vue.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "dropzone-vue", + "type": "registry:block", + "title": "Dropzone (File Upload) for Vue and Supabase", + "description": "Displays a control for easier uploading of files directly to Supabase Storage.", + "dependencies": [ + "@supabase/supabase-js@latest", + "@vueuse/core", + "lucide-vue-next" + ], + "registryDependencies": [ + "button" + ], + "files": [ + { + "path": "registry/default/dropzone/vue/components/dropzone.vue", + "content": "\n\n\n", + "type": "registry:component", + "target": "components/dropzone.vue" + }, + { + "path": "registry/default/dropzone/vue/components/dropzone-empty-state.vue", + "content": "\n\n\n", + "type": "registry:component", + "target": "components/dropzone-empty-state.vue" + }, + { + "path": "registry/default/dropzone/vue/components/dropzone-content.vue", + "content": "\n\n\n", + "type": "registry:component", + "target": "components/dropzone-content.vue" + }, + { + "path": "registry/default/dropzone/vue/composables/useSupabaseUpload.ts", + "content": "import { ref, computed, watch, onUnmounted } from 'vue'\nimport { useDropZone } from '@vueuse/core'\nimport { createClient } from \"@/lib/supabase/client\"\n\nconst supabase = createClient()\n\nexport interface FileWithPreview extends File {\n preview?: string\n errors: { code: string; message: string }[]\n}\n\nexport type UseSupabaseUploadOptions = {\n bucketName: string\n path?: string\n allowedMimeTypes?: string[]\n maxFileSize?: number\n maxFiles?: number\n cacheControl?: number\n upsert?: boolean\n}\n\nfunction validateFileType(file: File, allowedTypes: string[]) {\n if (!allowedTypes.length) return []\n const isValid = allowedTypes.some(t =>\n t.endsWith('/*')\n ? file.type.startsWith(t.replace('/*', ''))\n : file.type === t\n )\n return isValid\n ? []\n : [{ code: 'invalid-type', message: 'Invalid file type' }]\n}\n\nfunction validateFileSize(file: File, maxSize: number) {\n return file.size > maxSize\n ? [{ code: 'file-too-large', message: `File is larger than allowed size` }]\n : []\n}\n\nexport function useSupabaseUpload(options: UseSupabaseUploadOptions) {\n const {\n bucketName,\n path,\n allowedMimeTypes = [],\n maxFileSize = Number.POSITIVE_INFINITY,\n maxFiles = 1,\n cacheControl = 3600,\n upsert = false,\n } = options\n\n const files = ref([])\n const loading = ref(false)\n const errors = ref<{ name: string; message: string }[]>([])\n const successes = ref([])\n\n const isSuccess = computed(() => {\n if (!errors.value.length && !successes.value.length) return false\n return !errors.value.length && successes.value.length === files.value.length\n })\n\n const dropZoneRef = ref(null)\n\n const { isOverDropZone } = useDropZone(dropZoneRef, {\n onDrop(droppedFiles: FileWithPreview[]) {\n if (!droppedFiles) return\n\n const newFiles: FileWithPreview[] = droppedFiles.map(file => ({\n ...(file as FileWithPreview),\n preview: URL.createObjectURL(file),\n errors: [\n ...validateFileType(file, allowedMimeTypes),\n ...validateFileSize(file, maxFileSize),\n ],\n }))\n\n files.value = [...files.value, ...newFiles]\n },\n })\n\n const onUpload = async () => {\n loading.value = true\n\n try {\n const filesWithErrors = errors.value.map(e => e.name)\n\n const filesToUpload =\n filesWithErrors.length > 0\n ? files.value.filter(\n f =>\n filesWithErrors.includes(f.name) ||\n !successes.value.includes(f.name)\n )\n : files.value\n\n const responses = await Promise.all(\n filesToUpload.map(async file => {\n const { error } = await supabase.storage\n .from(bucketName)\n .upload(path ? `${path}/${file.name}` : file.name, file, {\n cacheControl: cacheControl.toString(),\n upsert,\n })\n\n return error\n ? { name: file.name, message: error.message }\n : { name: file.name, message: undefined }\n })\n )\n\n errors.value = responses.filter((r): r is { name: string; message: string } => r.message !== undefined)\n\n const successful = responses\n .filter(r => !r.message)\n .map(r => r.name)\n\n successes.value = Array.from(\n new Set([...successes.value, ...successful])\n )\n } catch (err) {\n console.error('Upload failed unexpectedly:', err)\n\n errors.value.push({\n name: 'upload',\n message: 'An unexpected error occurred during upload.',\n })\n } finally {\n loading.value = false\n }\n }\n\n\n watch(\n () => files.value.length,\n () => {\n if (!files.value.length) {\n errors.value = []\n successes.value = []\n }\n\n if (files.value.length > maxFiles) {\n errors.value.push({\n name: 'files',\n message: `You may upload up to ${maxFiles} files`,\n })\n }\n }\n )\n\n watch(\n files,\n (newFiles, oldFiles) => {\n const newPreviews = new Set(newFiles.map(f => f.preview))\n oldFiles.forEach(file => {\n if (file.preview && !newPreviews.has(file.preview)) {\n URL.revokeObjectURL(file.preview)\n }\n })\n },\n { deep: true }\n )\n\n onUnmounted(() => {\n files.value.forEach(file => {\n if (file.preview) {\n URL.revokeObjectURL(file.preview)\n }\n })\n })\n\n return {\n dropZoneRef,\n isOverDropZone,\n\n files,\n setFiles: (v: FileWithPreview[]) => (files.value = v),\n\n errors,\n setErrors: (v: { name: string; message: string }[]) => (errors.value = v),\n\n successes,\n isSuccess,\n loading,\n onUpload,\n\n maxFileSize,\n maxFiles,\n allowedMimeTypes,\n }\n}\n", + "type": "registry:component", + "target": "composables/useSupabaseUpload.ts" + } + ] +} \ No newline at end of file diff --git a/apps/ui-library/public/r/password-based-auth-react-router.json b/apps/ui-library/public/r/password-based-auth-react-router.json index 5f24bcffc3ecd..a6d3e31ef17a3 100644 --- a/apps/ui-library/public/r/password-based-auth-react-router.json +++ b/apps/ui-library/public/r/password-based-auth-react-router.json @@ -55,7 +55,7 @@ }, { "path": "registry/default/blocks/password-based-auth-react-router/app/routes/sign-up.tsx", - "content": "import { createClient } from '@/registry/default/clients/react-router/lib/supabase/server'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { type ActionFunctionArgs, Link, redirect, useFetcher, useSearchParams } from 'react-router'\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n const { supabase } = createClient(request)\n\n const url = new URL(request.url)\n const origin = url.origin\n\n const formData = await request.formData()\n\n const email = formData.get('email') as string\n const password = formData.get('password') as string\n const repeatPassword = formData.get('repeat-password') as string\n\n if (!password) {\n return {\n error: 'Password is required',\n }\n }\n\n if (password !== repeatPassword) {\n return { error: 'Passwords do not match' }\n }\n\n const { error } = await supabase.auth.signUp({\n email,\n password,\n options: {\n emailRedirectTo: `${origin}/protected`,\n },\n })\n\n if (error) {\n return { error: error.message }\n }\n\n return redirect('/sign-up?success')\n}\n\nexport default function SignUp() {\n const fetcher = useFetcher()\n let [searchParams] = useSearchParams()\n\n const success = !!searchParams.has('success')\n const error = fetcher.data?.error\n const loading = fetcher.state === 'submitting'\n\n return (\n
\n
\n
\n {success ? (\n \n \n Thank you for signing up!\n Check your email to confirm\n \n \n

\n You've successfully signed up. Please check your email to confirm your account\n before signing in.\n

\n
\n
\n ) : (\n \n \n Sign up\n Create a new account\n \n \n \n
\n
\n \n \n
\n
\n
\n \n
\n \n
\n
\n
\n \n
\n \n
\n {error &&

{error}

}\n \n
\n
\n Already have an account?{' '}\n \n Login\n \n
\n
\n
\n
\n )}\n
\n
\n
\n )\n}\n", + "content": "import { createClient } from '@/registry/default/clients/react-router/lib/supabase/server'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { type ActionFunctionArgs, Link, redirect, useFetcher, useSearchParams } from 'react-router'\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n const { supabase } = createClient(request)\n\n const url = new URL(request.url)\n const origin = url.origin\n\n const formData = await request.formData()\n\n const email = formData.get('email') as string\n const password = formData.get('password') as string\n const repeatPassword = formData.get('repeat-password') as string\n\n if (!password) {\n return {\n error: 'Password is required',\n }\n }\n\n if (password !== repeatPassword) {\n return { error: 'Passwords do not match' }\n }\n\n const { error } = await supabase.auth.signUp({\n email,\n password,\n options: {\n emailRedirectTo: `${origin}/protected`,\n },\n })\n\n if (error) {\n return { error: error.message }\n }\n\n return redirect('/sign-up?success')\n}\n\nexport default function SignUp() {\n const fetcher = useFetcher()\n let [searchParams] = useSearchParams()\n\n const success = !!searchParams.has('success')\n const error = fetcher.data?.error\n const loading = fetcher.state === 'submitting'\n\n return (\n
\n
\n
\n {success ? (\n \n \n Thank you for signing up!\n Check your email to confirm\n \n \n

\n You've successfully signed up. Please check your email to confirm your\n account before signing in.\n

\n
\n
\n ) : (\n \n \n Sign up\n Create a new account\n \n \n \n
\n
\n \n \n
\n
\n
\n \n
\n \n
\n
\n
\n \n
\n \n
\n {error &&

{error}

}\n \n
\n
\n Already have an account?{' '}\n \n Login\n \n
\n
\n
\n
\n )}\n
\n
\n
\n )\n}\n", "type": "registry:file", "target": "app/routes/sign-up.tsx" }, diff --git a/apps/ui-library/public/r/password-based-auth-react.json b/apps/ui-library/public/r/password-based-auth-react.json index a50604835e84f..75fdd187e97c8 100644 --- a/apps/ui-library/public/r/password-based-auth-react.json +++ b/apps/ui-library/public/r/password-based-auth-react.json @@ -21,7 +21,7 @@ }, { "path": "registry/default/blocks/password-based-auth-react/components/sign-up-form.tsx", - "content": "import { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/react/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useState } from 'react'\n\nexport function SignUpForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [email, setEmail] = useState('')\n const [password, setPassword] = useState('')\n const [repeatPassword, setRepeatPassword] = useState('')\n const [error, setError] = useState(null)\n const [isLoading, setIsLoading] = useState(false)\n const [success, setSuccess] = useState(false)\n\n const handleSignUp = async (e: React.FormEvent) => {\n const supabase = createClient()\n e.preventDefault()\n setError(null)\n\n if (password !== repeatPassword) {\n setError('Passwords do not match')\n return\n }\n setIsLoading(true)\n\n try {\n const { error } = await supabase.auth.signUp({\n email,\n password,\n })\n if (error) throw error\n setSuccess(true)\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n
\n {success ? (\n \n \n Thank you for signing up!\n Check your email to confirm\n \n \n

\n You've successfully signed up. Please check your email to confirm your account before\n signing in.\n

\n
\n
\n ) : (\n \n \n Sign up\n Create a new account\n \n \n
\n
\n
\n \n setEmail(e.target.value)}\n />\n
\n
\n
\n \n
\n setPassword(e.target.value)}\n />\n
\n
\n
\n \n
\n setRepeatPassword(e.target.value)}\n />\n
\n {error &&

{error}

}\n \n
\n
\n Already have an account?{' '}\n \n Login\n \n
\n
\n
\n
\n )}\n
\n )\n}\n", + "content": "import { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/react/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useState } from 'react'\n\nexport function SignUpForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [email, setEmail] = useState('')\n const [password, setPassword] = useState('')\n const [repeatPassword, setRepeatPassword] = useState('')\n const [error, setError] = useState(null)\n const [isLoading, setIsLoading] = useState(false)\n const [success, setSuccess] = useState(false)\n\n const handleSignUp = async (e: React.FormEvent) => {\n const supabase = createClient()\n e.preventDefault()\n setError(null)\n\n if (password !== repeatPassword) {\n setError('Passwords do not match')\n return\n }\n setIsLoading(true)\n\n try {\n const { error } = await supabase.auth.signUp({\n email,\n password,\n })\n if (error) throw error\n setSuccess(true)\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n
\n {success ? (\n \n \n Thank you for signing up!\n Check your email to confirm\n \n \n

\n You've successfully signed up. Please check your email to confirm your account\n before signing in.\n

\n
\n
\n ) : (\n \n \n Sign up\n Create a new account\n \n \n
\n
\n
\n \n setEmail(e.target.value)}\n />\n
\n
\n
\n \n
\n setPassword(e.target.value)}\n />\n
\n
\n
\n \n
\n setRepeatPassword(e.target.value)}\n />\n
\n {error &&

{error}

}\n \n
\n
\n Already have an account?{' '}\n \n Login\n \n
\n
\n
\n
\n )}\n
\n )\n}\n", "type": "registry:component" }, { diff --git a/apps/ui-library/public/r/password-based-auth-tanstack.json b/apps/ui-library/public/r/password-based-auth-tanstack.json index 0d84095b2049b..9c5efdba4de88 100644 --- a/apps/ui-library/public/r/password-based-auth-tanstack.json +++ b/apps/ui-library/public/r/password-based-auth-tanstack.json @@ -58,7 +58,7 @@ }, { "path": "registry/default/blocks/password-based-auth-tanstack/routes/sign-up-success.tsx", - "content": "import {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { createFileRoute } from '@tanstack/react-router'\n\nexport const Route = createFileRoute('/sign-up-success')({\n component: SignUpSuccess,\n})\n\nfunction SignUpSuccess() {\n return (\n
\n
\n
\n \n \n Thank you for signing up!\n Check your email to confirm\n \n \n

\n You've successfully signed up. Please check your email to confirm your account\n before signing in.\n

\n
\n
\n
\n
\n
\n )\n}\n", + "content": "import {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { createFileRoute } from '@tanstack/react-router'\n\nexport const Route = createFileRoute('/sign-up-success')({\n component: SignUpSuccess,\n})\n\nfunction SignUpSuccess() {\n return (\n
\n
\n
\n \n \n Thank you for signing up!\n Check your email to confirm\n \n \n

\n You've successfully signed up. Please check your email to confirm your account\n before signing in.\n

\n
\n
\n
\n
\n
\n )\n}\n", "type": "registry:file", "target": "routes/sign-up-success.tsx" }, diff --git a/apps/ui-library/public/r/platform-kit-nextjs.json b/apps/ui-library/public/r/platform-kit-nextjs.json index f840a00a1504f..1df40283f0f1a 100644 --- a/apps/ui-library/public/r/platform-kit-nextjs.json +++ b/apps/ui-library/public/r/platform-kit-nextjs.json @@ -50,7 +50,7 @@ }, { "path": "registry/default/platform/platform-kit-nextjs/app/api/supabase-proxy/[...path]/route.ts", - "content": "import { NextResponse } from 'next/server'\n\nasync function forwardToSupabaseAPI(request: Request, method: string, params: { path: string[] }) {\n if (!process.env.SUPABASE_MANAGEMENT_API_TOKEN) {\n console.error('Supabase Management API token is not configured.')\n return NextResponse.json({ message: 'Server configuration error.' }, { status: 500 })\n }\n\n const { path } = params\n const apiPath = path.join('/')\n\n const url = new URL(request.url)\n url.protocol = 'https'\n url.hostname = 'api.supabase.com'\n url.port = '443'\n url.pathname = apiPath\n\n const projectRef = path[2]\n\n // Implement your permission check here (e.g. check if the user is a member of the project)\n // In this example, everyone can access all projects\n const userHasPermissionForProject = Boolean(projectRef)\n\n if (!userHasPermissionForProject) {\n return NextResponse.json(\n { message: 'You do not have permission to access this project.' },\n { status: 403 }\n )\n }\n\n try {\n const forwardHeaders: HeadersInit = {\n Authorization: `Bearer ${process.env.SUPABASE_MANAGEMENT_API_TOKEN}`,\n }\n\n // Copy relevant headers from the original request\n const contentType = request.headers.get('content-type')\n if (contentType) {\n forwardHeaders['Content-Type'] = contentType\n }\n\n const fetchOptions: RequestInit = {\n method,\n headers: forwardHeaders,\n }\n\n // Include body for methods that support it\n if (method !== 'GET' && method !== 'HEAD') {\n try {\n const body = await request.text()\n if (body) {\n fetchOptions.body = body\n }\n } catch (error) {\n // Handle cases where body is not readable\n console.warn('Could not read request body:', error)\n }\n }\n\n const response = await fetch(url, fetchOptions)\n\n // Get response body\n const responseText = await response.text()\n let responseData\n\n try {\n responseData = responseText ? JSON.parse(responseText) : null\n } catch {\n responseData = responseText\n }\n\n // Return the response with the same status\n return NextResponse.json(responseData, { status: response.status })\n } catch (error: any) {\n console.error('Supabase API proxy error:', error)\n const errorMessage = error.message || 'An unexpected error occurred.'\n return NextResponse.json({ message: errorMessage }, { status: 500 })\n }\n}\n\nexport async function GET(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'GET', resolvedParams)\n}\n\nexport async function HEAD(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'HEAD', resolvedParams)\n}\n\nexport async function POST(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'POST', resolvedParams)\n}\n\nexport async function PUT(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'PUT', resolvedParams)\n}\n\nexport async function DELETE(\n request: Request,\n { params }: { params: Promise<{ path: string[] }> }\n) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'DELETE', resolvedParams)\n}\n\nexport async function PATCH(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'PATCH', resolvedParams)\n}\n", + "content": "import { NextResponse } from 'next/server'\n\nasync function forwardToSupabaseAPI(request: Request, method: string, params: { path: string[] }) {\n // eslint-disable-next-line turbo/no-undeclared-env-vars\n if (!process.env.SUPABASE_MANAGEMENT_API_TOKEN) {\n console.error('Supabase Management API token is not configured.')\n return NextResponse.json({ message: 'Server configuration error.' }, { status: 500 })\n }\n\n const { path } = params\n const apiPath = path.join('/')\n\n const url = new URL(request.url)\n url.protocol = 'https'\n url.hostname = 'api.supabase.com'\n url.port = '443'\n url.pathname = apiPath\n\n const projectRef = path[2]\n\n // Implement your permission check here (e.g. check if the user is a member of the project)\n // In this example, everyone can access all projects\n const userHasPermissionForProject = Boolean(projectRef)\n\n if (!userHasPermissionForProject) {\n return NextResponse.json(\n { message: 'You do not have permission to access this project.' },\n { status: 403 }\n )\n }\n\n try {\n const forwardHeaders: HeadersInit = {\n // eslint-disable-next-line turbo/no-undeclared-env-vars\n Authorization: `Bearer ${process.env.SUPABASE_MANAGEMENT_API_TOKEN}`,\n }\n\n // Copy relevant headers from the original request\n const contentType = request.headers.get('content-type')\n if (contentType) {\n forwardHeaders['Content-Type'] = contentType\n }\n\n const fetchOptions: RequestInit = {\n method,\n headers: forwardHeaders,\n }\n\n // Include body for methods that support it\n if (method !== 'GET' && method !== 'HEAD') {\n try {\n const body = await request.text()\n if (body) {\n fetchOptions.body = body\n }\n } catch (error) {\n // Handle cases where body is not readable\n console.warn('Could not read request body:', error)\n }\n }\n\n const response = await fetch(url, fetchOptions)\n\n // Get response body\n const responseText = await response.text()\n let responseData\n\n try {\n responseData = responseText ? JSON.parse(responseText) : null\n } catch {\n responseData = responseText\n }\n\n // Return the response with the same status\n return NextResponse.json(responseData, { status: response.status })\n } catch (error: any) {\n console.error('Supabase API proxy error:', error)\n const errorMessage = error.message || 'An unexpected error occurred.'\n return NextResponse.json({ message: errorMessage }, { status: 500 })\n }\n}\n\nexport async function GET(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'GET', resolvedParams)\n}\n\nexport async function HEAD(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'HEAD', resolvedParams)\n}\n\nexport async function POST(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'POST', resolvedParams)\n}\n\nexport async function PUT(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'PUT', resolvedParams)\n}\n\nexport async function DELETE(\n request: Request,\n { params }: { params: Promise<{ path: string[] }> }\n) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'DELETE', resolvedParams)\n}\n\nexport async function PATCH(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'PATCH', resolvedParams)\n}\n", "type": "registry:page", "target": "app/api/supabase-proxy/[...path]/route.ts" }, @@ -106,7 +106,7 @@ }, { "path": "registry/default/platform/platform-kit-nextjs/components/supabase-manager/suggestions.tsx", - "content": "'use client'\n\nimport { useGetSuggestions } from '@/registry/default/platform/platform-kit-nextjs/hooks/use-suggestions'\nimport { Alert, AlertDescription, AlertTitle } from '@/registry/default/components/ui/alert'\nimport { Terminal } from 'lucide-react'\nimport { Badge } from '@/registry/default/components/ui/badge'\nimport { useMemo } from 'react'\n\nimport ReactMarkdown from 'react-markdown'\nimport { Skeleton } from '@/registry/default/components/ui/skeleton'\n\nexport function SuggestionsManager({ projectRef }: { projectRef: string }) {\n const { data: suggestions, isLoading, error } = useGetSuggestions(projectRef)\n\n const sortedSuggestions = useMemo(() => {\n if (!suggestions) return []\n const levelOrder = { ERROR: 1, WARN: 2, INFO: 3 }\n return [...suggestions].sort((a: any, b: any) => {\n const levelA = levelOrder[a.level as keyof typeof levelOrder] || 99\n const levelB = levelOrder[b.level as keyof typeof levelOrder] || 99\n return levelA - levelB\n })\n }, [suggestions])\n\n const getBadgeVariant = (level: 'ERROR' | 'WARN' | 'INFO') => {\n switch (level) {\n case 'ERROR':\n return 'destructive'\n case 'WARN':\n return 'secondary'\n default:\n return 'outline'\n }\n }\n\n return (\n
\n

Suggestions

\n

\n Improve your project's security and performance.\n

\n {isLoading && (\n
\n \n \n \n \n
\n )}\n {error && (\n \n \n Error fetching suggestions\n \n {(error as any)?.message || 'An unexpected error occurred. Please try again.'}\n \n \n )}\n {suggestions && (\n
\n {sortedSuggestions.length > 0 ? (\n
\n {sortedSuggestions.map((suggestion: any) => (\n \n
\n
\n

{suggestion.title}

\n
\n \n {suggestion.level}\n \n {suggestion.type && (\n \n {suggestion.type.charAt(0).toUpperCase() + suggestion.type.slice(1)}\n \n )}\n
\n
\n
\n \n {children}\n \n ) : (\n
\n                                {children}\n                              
\n )\n },\n }}\n >\n {suggestion.detail}\n \n
\n
\n
\n ))}\n
\n ) : (\n \n \n No suggestions found\n \n Your project looks good! No suggestions at this time.\n \n \n )}\n
\n )}\n
\n )\n}\n", + "content": "'use client'\n\nimport { useGetSuggestions } from '@/registry/default/platform/platform-kit-nextjs/hooks/use-suggestions'\nimport { Alert, AlertDescription, AlertTitle } from '@/registry/default/components/ui/alert'\nimport { Terminal } from 'lucide-react'\nimport { Badge } from '@/registry/default/components/ui/badge'\nimport { useMemo } from 'react'\n\nimport ReactMarkdown from 'react-markdown'\nimport { Skeleton } from '@/registry/default/components/ui/skeleton'\n\nexport function SuggestionsManager({ projectRef }: { projectRef: string }) {\n const { data: suggestions, isLoading, error } = useGetSuggestions(projectRef)\n\n const sortedSuggestions = useMemo(() => {\n if (!suggestions) return []\n const levelOrder = { ERROR: 1, WARN: 2, INFO: 3 }\n return [...suggestions].sort((a: any, b: any) => {\n const levelA = levelOrder[a.level as keyof typeof levelOrder] || 99\n const levelB = levelOrder[b.level as keyof typeof levelOrder] || 99\n return levelA - levelB\n })\n }, [suggestions])\n\n const getBadgeVariant = (level: 'ERROR' | 'WARN' | 'INFO') => {\n switch (level) {\n case 'ERROR':\n return 'destructive'\n case 'WARN':\n return 'secondary'\n default:\n return 'outline'\n }\n }\n\n return (\n
\n

Suggestions

\n

\n Improve your project's security and performance.\n

\n {isLoading && (\n
\n \n \n \n \n
\n )}\n {error && (\n \n \n Error fetching suggestions\n \n {(error as any)?.message || 'An unexpected error occurred. Please try again.'}\n \n \n )}\n {suggestions && (\n
\n {sortedSuggestions.length > 0 ? (\n
\n {sortedSuggestions.map((suggestion: any) => (\n \n
\n
\n

{suggestion.title}

\n
\n \n {suggestion.level}\n \n {suggestion.type && (\n \n {suggestion.type.charAt(0).toUpperCase() + suggestion.type.slice(1)}\n \n )}\n
\n
\n
\n \n {children}\n \n ) : (\n
\n                                {children}\n                              
\n )\n },\n }}\n >\n {suggestion.detail}\n \n
\n
\n
\n ))}\n
\n ) : (\n \n \n No suggestions found\n \n Your project looks good! No suggestions at this time.\n \n \n )}\n
\n )}\n
\n )\n}\n", "type": "registry:component" }, { diff --git a/apps/ui-library/public/r/realtime-cursor-nuxtjs.json b/apps/ui-library/public/r/realtime-cursor-nuxtjs.json new file mode 100644 index 0000000000000..d5007491b6740 --- /dev/null +++ b/apps/ui-library/public/r/realtime-cursor-nuxtjs.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "realtime-cursor-nuxtjs", + "type": "registry:component", + "title": "Realtime Cursor for Nuxt and Supabase", + "description": "Component which renders realtime cursors from other users in a room.", + "dependencies": [ + "@supabase/supabase-js@latest", + "@vueuse/core", + "lucide-vue-next" + ], + "files": [ + { + "path": "registry/default/realtime-cursor/nuxtjs/app/components/cursor.vue", + "content": "\n\n\n", + "type": "registry:component", + "target": "app/components/cursor.vue" + }, + { + "path": "registry/default/realtime-cursor/nuxtjs/app/components/realtime-cursors.vue", + "content": "\n\n\n", + "type": "registry:component", + "target": "app/components/realtime-cursors.vue" + }, + { + "path": "registry/default/realtime-cursor/nuxtjs/app/composables/useRealtimeCursors.ts", + "content": "import { ref, reactive, onMounted, onUnmounted } from 'vue'\nimport { createClient } from '@/lib/supabase/client'\nimport { REALTIME_SUBSCRIBE_STATES, type RealtimeChannel } from '@supabase/supabase-js'\n\n/**\n * Throttle a callback to a certain delay.\n * It will only call the callback if the delay has passed,\n * using the arguments from the last call.\n */\nfunction useThrottleCallback(\n callback: (...args: Params) => void,\n delay: number\n) {\n let lastCall = 0\n let timeout: ReturnType | null = null\n\n const run = (...args: Params) => {\n const now = Date.now()\n const remainingTime = delay - (now - lastCall)\n\n if (remainingTime <= 0) {\n if (timeout) {\n clearTimeout(timeout)\n timeout = null\n }\n lastCall = now\n callback(...args)\n } else if (!timeout) {\n timeout = setTimeout(() => {\n lastCall = Date.now()\n timeout = null\n callback(...args)\n }, remainingTime)\n }\n }\n\n const cancel = () => {\n if (timeout) {\n clearTimeout(timeout)\n timeout = null\n }\n }\n\n return { run, cancel }\n}\n\nconst supabase = createClient()\n\nconst generateRandomColor = () =>\n `hsl(${Math.floor(Math.random() * 360)}, 100%, 70%)`\n\nconst generateRandomNumber = () =>\n Math.floor(Math.random() * 100)\n\nconst EVENT_NAME = 'realtime-cursor-move'\n\nexport type CursorEventPayload = {\n position: { x: number; y: number }\n user: { id: number; name: string }\n color: string\n timestamp: number\n}\n\nexport function useRealtimeCursors({\n roomName,\n username,\n throttleMs,\n}: {\n roomName: string\n username: string\n throttleMs: number\n}) {\n const color = generateRandomColor()\n const userId = generateRandomNumber()\n\n const cursors = reactive>({})\n const cursorPayload = ref(null)\n const channelRef = ref(null)\n\n const sendCursor = (event: MouseEvent) => {\n const payload: CursorEventPayload = {\n position: {\n x: event.clientX,\n y: event.clientY,\n },\n user: {\n id: userId,\n name: username,\n },\n color,\n timestamp: Date.now(),\n }\n\n cursorPayload.value = payload\n\n channelRef.value?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload,\n })\n }\n\n const { run: handleMouseMove, cancel: cancelThrottle } =\n useThrottleCallback(sendCursor, throttleMs)\n\n onMounted(() => {\n const channel = supabase.channel(roomName)\n\n channel\n .on('system', {}, (payload: CursorEventPayload) => {\n console.error('Realtime system error:', payload)\n\n // Defensive cleanup\n Object.keys(cursors).forEach((k) => delete cursors[k])\n channelRef.value = null\n })\n .on('presence', { event: 'leave' }, ({ leftPresences }) => {\n leftPresences.forEach(({ key }) => {\n delete cursors[key]\n })\n })\n .on('presence', { event: 'join' }, () => {\n if (!cursorPayload.value) return\n\n channelRef.value?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload: cursorPayload.value,\n })\n })\n .on('broadcast', { event: EVENT_NAME }, ({ payload }: { payload: CursorEventPayload }) => {\n if (payload.user.id === userId) return\n\n cursors[payload.user.id] = payload\n })\n .subscribe(async (status) => {\n if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {\n try {\n await channel.track({ key: userId })\n channelRef.value = channel\n } catch (err) {\n console.error('Failed to track presence for current user:', err)\n channelRef.value = null\n }\n } else {\n Object.keys(cursors).forEach((k) => delete cursors[k])\n channelRef.value = null\n }\n })\n\n window.addEventListener('mousemove', handleMouseMove)\n })\n\n onUnmounted(() => {\n window.removeEventListener('mousemove', handleMouseMove)\n\n cancelThrottle()\n\n if (channelRef.value) {\n channelRef.value.unsubscribe()\n channelRef.value = null\n }\n\n Object.keys(cursors).forEach((k) => delete cursors[k])\n })\n\n return { cursors }\n}\n", + "type": "registry:component", + "target": "app/composables/useRealtimeCursors.ts" + } + ] +} \ No newline at end of file diff --git a/apps/ui-library/public/r/realtime-cursor-vue.json b/apps/ui-library/public/r/realtime-cursor-vue.json new file mode 100644 index 0000000000000..9d4de1a8a462a --- /dev/null +++ b/apps/ui-library/public/r/realtime-cursor-vue.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "realtime-cursor-vue", + "type": "registry:component", + "title": "Realtime Cursor for Vue and Supabase", + "description": "Component which renders realtime cursors from other users in a room.", + "dependencies": [ + "@supabase/supabase-js@latest", + "@vueuse/core", + "lucide-vue-next" + ], + "files": [ + { + "path": "registry/default/realtime-cursor/vue/components/cursor.vue", + "content": "\n\n\n", + "type": "registry:component", + "target": "components/cursor.vue" + }, + { + "path": "registry/default/realtime-cursor/vue/components/realtime-cursors.vue", + "content": "\n\n\n", + "type": "registry:component", + "target": "components/realtime-cursors.vue" + }, + { + "path": "registry/default/realtime-cursor/vue/composables/useRealtimeCursors.ts", + "content": "import { ref, reactive, onMounted, onUnmounted } from 'vue'\nimport { createClient } from '@/lib/supabase/client'\nimport { REALTIME_SUBSCRIBE_STATES, type RealtimeChannel } from '@supabase/supabase-js'\n\n/**\n * Throttle a callback to a certain delay.\n * It will only call the callback if the delay has passed,\n * using the arguments from the last call.\n */\nfunction useThrottleCallback(\n callback: (...args: Params) => void,\n delay: number\n) {\n let lastCall = 0\n let timeout: ReturnType | null = null\n\n const run = (...args: Params) => {\n const now = Date.now()\n const remainingTime = delay - (now - lastCall)\n\n if (remainingTime <= 0) {\n if (timeout) {\n clearTimeout(timeout)\n timeout = null\n }\n lastCall = now\n callback(...args)\n } else if (!timeout) {\n timeout = setTimeout(() => {\n lastCall = Date.now()\n timeout = null\n callback(...args)\n }, remainingTime)\n }\n }\n\n const cancel = () => {\n if (timeout) {\n clearTimeout(timeout)\n timeout = null\n }\n }\n\n return { run, cancel }\n}\n\nconst supabase = createClient()\n\nconst generateRandomColor = () =>\n `hsl(${Math.floor(Math.random() * 360)}, 100%, 70%)`\n\nconst generateRandomNumber = () =>\n Math.floor(Math.random() * 100)\n\nconst EVENT_NAME = 'realtime-cursor-move'\n\nexport type CursorEventPayload = {\n position: { x: number; y: number }\n user: { id: number; name: string }\n color: string\n timestamp: number\n}\n\nexport function useRealtimeCursors({\n roomName,\n username,\n throttleMs,\n}: {\n roomName: string\n username: string\n throttleMs: number\n}) {\n const color = generateRandomColor()\n const userId = generateRandomNumber()\n\n const cursors = reactive>({})\n const cursorPayload = ref(null)\n const channelRef = ref(null)\n\n const sendCursor = (event: MouseEvent) => {\n const payload: CursorEventPayload = {\n position: {\n x: event.clientX,\n y: event.clientY,\n },\n user: {\n id: userId,\n name: username,\n },\n color,\n timestamp: Date.now(),\n }\n\n cursorPayload.value = payload\n\n channelRef.value?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload,\n })\n }\n\n const { run: handleMouseMove, cancel: cancelThrottle } =\n useThrottleCallback(sendCursor, throttleMs)\n\n onMounted(() => {\n const channel = supabase.channel(roomName)\n\n channel\n .on('system', {}, (payload: CursorEventPayload) => {\n console.error('Realtime system error:', payload)\n\n // Defensive cleanup\n Object.keys(cursors).forEach((k) => delete cursors[k])\n channelRef.value = null\n })\n .on('presence', { event: 'leave' }, ({ leftPresences }) => {\n leftPresences.forEach(({ key }) => {\n delete cursors[key]\n })\n })\n .on('presence', { event: 'join' }, () => {\n if (!cursorPayload.value) return\n\n channelRef.value?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload: cursorPayload.value,\n })\n })\n .on('broadcast', { event: EVENT_NAME }, ({ payload }: { payload: CursorEventPayload }) => {\n if (payload.user.id === userId) return\n\n cursors[payload.user.id] = payload\n })\n .subscribe(async (status) => {\n if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {\n try {\n await channel.track({ key: userId })\n channelRef.value = channel\n } catch (err) {\n console.error('Failed to track presence for current user:', err)\n channelRef.value = null\n }\n } else {\n Object.keys(cursors).forEach((k) => delete cursors[k])\n channelRef.value = null\n }\n })\n\n window.addEventListener('mousemove', handleMouseMove)\n })\n\n onUnmounted(() => {\n window.removeEventListener('mousemove', handleMouseMove)\n\n cancelThrottle()\n\n if (channelRef.value) {\n channelRef.value.unsubscribe()\n channelRef.value = null\n }\n\n Object.keys(cursors).forEach((k) => delete cursors[k])\n })\n\n return { cursors }\n}\n", + "type": "registry:component", + "target": "composables/useRealtimeCursors.ts" + } + ] +} \ No newline at end of file diff --git a/apps/ui-library/public/r/registry.json b/apps/ui-library/public/r/registry.json index 595c4484a22b7..41edf2c6e6e7a 100644 --- a/apps/ui-library/public/r/registry.json +++ b/apps/ui-library/public/r/registry.json @@ -1949,6 +1949,134 @@ "@supabase/supabase-js@latest" ] }, + { + "name": "dropzone-nuxtjs", + "type": "registry:block", + "title": "Dropzone (File Upload) for Nuxt and Supabase", + "description": "Displays a control for easier uploading of files directly to Supabase Storage.", + "registryDependencies": [ + "button" + ], + "files": [ + { + "path": "registry/default/dropzone/nuxtjs/app/components/dropzone.vue", + "type": "registry:component", + "target": "app/components/dropzone.vue" + }, + { + "path": "registry/default/dropzone/nuxtjs/app/components/dropzone-empty-state.vue", + "type": "registry:component", + "target": "app/components/dropzone-empty-state.vue" + }, + { + "path": "registry/default/dropzone/nuxtjs/app/components/dropzone-content.vue", + "type": "registry:component", + "target": "app/components/dropzone-content.vue" + }, + { + "path": "registry/default/dropzone/nuxtjs/app/composables/useSupabaseUpload.ts", + "type": "registry:component", + "target": "app/composables/useSupabaseUpload.ts" + } + ], + "dependencies": [ + "@supabase/supabase-js@latest", + "@vueuse/core", + "lucide-vue-next" + ] + }, + { + "name": "dropzone-vue", + "type": "registry:block", + "title": "Dropzone (File Upload) for Vue and Supabase", + "description": "Displays a control for easier uploading of files directly to Supabase Storage.", + "registryDependencies": [ + "button" + ], + "files": [ + { + "path": "registry/default/dropzone/vue/components/dropzone.vue", + "type": "registry:component", + "target": "components/dropzone.vue" + }, + { + "path": "registry/default/dropzone/vue/components/dropzone-empty-state.vue", + "type": "registry:component", + "target": "components/dropzone-empty-state.vue" + }, + { + "path": "registry/default/dropzone/vue/components/dropzone-content.vue", + "type": "registry:component", + "target": "components/dropzone-content.vue" + }, + { + "path": "registry/default/dropzone/vue/composables/useSupabaseUpload.ts", + "type": "registry:component", + "target": "composables/useSupabaseUpload.ts" + } + ], + "dependencies": [ + "@supabase/supabase-js@latest", + "@vueuse/core", + "lucide-vue-next" + ] + }, + { + "name": "realtime-cursor-nuxtjs", + "type": "registry:component", + "title": "Realtime Cursor for Nuxt and Supabase", + "description": "Component which renders realtime cursors from other users in a room.", + "files": [ + { + "path": "registry/default/realtime-cursor/nuxtjs/app/components/cursor.vue", + "type": "registry:component", + "target": "app/components/cursor.vue" + }, + { + "path": "registry/default/realtime-cursor/nuxtjs/app/components/realtime-cursors.vue", + "type": "registry:component", + "target": "app/components/realtime-cursors.vue" + }, + { + "path": "registry/default/realtime-cursor/nuxtjs/app/composables/useRealtimeCursors.ts", + "type": "registry:component", + "target": "app/composables/useRealtimeCursors.ts" + } + ], + "dependencies": [ + "@supabase/supabase-js@latest", + "@vueuse/core", + "lucide-vue-next" + ] + }, + { + "name": "realtime-cursor-vue", + "type": "registry:component", + "title": "Realtime Cursor for Vue and Supabase", + "description": "Component which renders realtime cursors from other users in a room.", + "files": [ + { + "path": "registry/default/realtime-cursor/vue/components/cursor.vue", + "type": "registry:component", + "target": "components/cursor.vue" + }, + { + "path": "registry/default/realtime-cursor/vue/components/realtime-cursors.vue", + "type": "registry:component", + "target": "components/realtime-cursors.vue" + }, + { + "path": "registry/default/realtime-cursor/vue/composables/useRealtimeCursors.ts", + "type": "registry:component", + "target": "composables/useRealtimeCursors.ts" + } + ], + "dependencies": [ + "@supabase/supabase-js@latest", + "@vueuse/core", + "lucide-vue-next" + ] + }, { "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "ai-editor-rules", diff --git a/blocks/vue/package.json b/blocks/vue/package.json index 6412d7024b8b0..1eea69f6a699f 100644 --- a/blocks/vue/package.json +++ b/blocks/vue/package.json @@ -10,15 +10,17 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@supabase/ssr": "^0.7.0", + "@supabase/supabase-js": "catalog:", + "@vueuse/core": "^14.1.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "h3": "^1.15.5", + "lucide-vue-next": "^0.562.0", "nuxt": "^4.0.3", - "class-variance-authority": "^0.7.1", "tailwind-merge": "^3.3.1", - "clsx": "^2.1.1", "vue": "^3.5.21", - "vue-router": "^4.5.1", - "@supabase/ssr": "^0.7.0", - "@supabase/supabase-js": "catalog:" + "vue-router": "^4.5.1" }, "devDependencies": { "shadcn": "^3.0.0", diff --git a/blocks/vue/registry/default/dropzone/nuxtjs/app/components/dropzone-content.vue b/blocks/vue/registry/default/dropzone/nuxtjs/app/components/dropzone-content.vue new file mode 100644 index 0000000000000..079976099be68 --- /dev/null +++ b/blocks/vue/registry/default/dropzone/nuxtjs/app/components/dropzone-content.vue @@ -0,0 +1,132 @@ + + + diff --git a/blocks/vue/registry/default/dropzone/nuxtjs/app/components/dropzone-empty-state.vue b/blocks/vue/registry/default/dropzone/nuxtjs/app/components/dropzone-empty-state.vue new file mode 100644 index 0000000000000..d288cfe6615c0 --- /dev/null +++ b/blocks/vue/registry/default/dropzone/nuxtjs/app/components/dropzone-empty-state.vue @@ -0,0 +1,37 @@ + + + diff --git a/blocks/vue/registry/default/dropzone/nuxtjs/app/components/dropzone.vue b/blocks/vue/registry/default/dropzone/nuxtjs/app/components/dropzone.vue new file mode 100644 index 0000000000000..ebd43607e57ba --- /dev/null +++ b/blocks/vue/registry/default/dropzone/nuxtjs/app/components/dropzone.vue @@ -0,0 +1,83 @@ + + + diff --git a/blocks/vue/registry/default/dropzone/nuxtjs/app/composables/useSupabaseUpload.ts b/blocks/vue/registry/default/dropzone/nuxtjs/app/composables/useSupabaseUpload.ts new file mode 100644 index 0000000000000..b057c6ef7cf3a --- /dev/null +++ b/blocks/vue/registry/default/dropzone/nuxtjs/app/composables/useSupabaseUpload.ts @@ -0,0 +1,190 @@ +import { ref, computed, watch, onUnmounted } from 'vue' +import { useDropZone } from '@vueuse/core' +// @ts-ignore +import { createClient } from "@/lib/supabase/client" + +const supabase = createClient() + +export interface FileWithPreview extends File { + preview?: string + errors: { code: string; message: string }[] +} + +export type UseSupabaseUploadOptions = { + bucketName: string + path?: string + allowedMimeTypes?: string[] + maxFileSize?: number + maxFiles?: number + cacheControl?: number + upsert?: boolean +} + +function validateFileType(file: File, allowedTypes: string[]) { + if (!allowedTypes.length) return [] + const isValid = allowedTypes.some(t => + t.endsWith('/*') + ? file.type.startsWith(t.replace('/*', '')) + : file.type === t + ) + return isValid + ? [] + : [{ code: 'invalid-type', message: 'Invalid file type' }] +} + +function validateFileSize(file: File, maxSize: number) { + return file.size > maxSize + ? [{ code: 'file-too-large', message: `File is larger than allowed size` }] + : [] +} + +export function useSupabaseUpload(options: UseSupabaseUploadOptions) { + const { + bucketName, + path, + allowedMimeTypes = [], + maxFileSize = Number.POSITIVE_INFINITY, + maxFiles = 1, + cacheControl = 3600, + upsert = false, + } = options + + const files = ref([]) + const loading = ref(false) + const errors = ref<{ name: string; message: string }[]>([]) + const successes = ref([]) + + const isSuccess = computed(() => { + if (!errors.value.length && !successes.value.length) return false + return !errors.value.length && successes.value.length === files.value.length + }) + + const dropZoneRef = ref(null) + + const { isOverDropZone } = useDropZone(dropZoneRef, { + onDrop(droppedFiles: File[] | null) { + if (!droppedFiles) return + + const newFiles: FileWithPreview[] = droppedFiles.map(file => ({ + ...(file as FileWithPreview), + preview: URL.createObjectURL(file), + errors: [ + ...validateFileType(file, allowedMimeTypes), + ...validateFileSize(file, maxFileSize), + ], + })) + + files.value = [...files.value, ...newFiles] + }, + }) + + const onUpload = async () => { + loading.value = true + + try { + const filesWithErrors = errors.value.map(e => e.name) + + const filesToUpload = + filesWithErrors.length > 0 + ? files.value.filter( + f => + filesWithErrors.includes(f.name) || + !successes.value.includes(f.name) + ) + : files.value + + const responses = await Promise.all( + filesToUpload.map(async file => { + const { error } = await supabase.storage + .from(bucketName) + .upload(path ? `${path}/${file.name}` : file.name, file, { + cacheControl: cacheControl.toString(), + upsert, + }) + + return error + ? { name: file.name, message: error.message } + : { name: file.name, message: undefined } + }) + ) + + errors.value = responses.filter((r): r is { name: string; message: string } => r.message !== undefined) + + const successful = responses + .filter(r => !r.message) + .map(r => r.name) + + successes.value = Array.from( + new Set([...successes.value, ...successful]) + ) + } catch (err) { + console.error('Upload failed unexpectedly:', err) + + errors.value.push({ + name: 'upload', + message: 'An unexpected error occurred during upload.', + }) + } finally { + loading.value = false + } + } + + + watch( + () => files.value.length, + () => { + if (!files.value.length) { + errors.value = [] + successes.value = [] + } + + if (files.value.length > maxFiles) { + errors.value.push({ + name: 'files', + message: `You may upload up to ${maxFiles} files`, + }) + } + } + ) + + watch( + files, + (newFiles, oldFiles) => { + const newPreviews = new Set(newFiles.map(f => f.preview)) + oldFiles.forEach(file => { + if (file.preview && !newPreviews.has(file.preview)) { + URL.revokeObjectURL(file.preview) + } + }) + }, + { deep: true } + ) + + onUnmounted(() => { + files.value.forEach(file => { + if (file.preview) { + URL.revokeObjectURL(file.preview) + } + }) + }) + + return { + dropZoneRef, + isOverDropZone, + + files, + setFiles: (v: FileWithPreview[]) => (files.value = v), + + errors, + setErrors: (v: { name: string; message: string }[]) => (errors.value = v), + + successes, + isSuccess, + loading, + onUpload, + + maxFileSize, + maxFiles, + allowedMimeTypes, + } +} diff --git a/blocks/vue/registry/default/dropzone/nuxtjs/registry-item.json b/blocks/vue/registry/default/dropzone/nuxtjs/registry-item.json new file mode 100644 index 0000000000000..e4be40a8c6cf4 --- /dev/null +++ b/blocks/vue/registry/default/dropzone/nuxtjs/registry-item.json @@ -0,0 +1,30 @@ +{ + "name": "dropzone-nuxtjs", + "type": "registry:block", + "title": "Dropzone (File Upload) for Nuxt and Supabase", + "description": "Displays a control for easier uploading of files directly to Supabase Storage.", + "registryDependencies": ["button"], + "files": [ + { + "path": "registry/default/dropzone/nuxtjs/app/components/dropzone.vue", + "type": "registry:component", + "target": "app/components/dropzone.vue" + }, + { + "path": "registry/default/dropzone/nuxtjs/app/components/dropzone-empty-state.vue", + "type": "registry:component", + "target": "app/components/dropzone-empty-state.vue" + }, + { + "path": "registry/default/dropzone/nuxtjs/app/components/dropzone-content.vue", + "type": "registry:component", + "target": "app/components/dropzone-content.vue" + }, + { + "path": "registry/default/dropzone/nuxtjs/app/composables/useSupabaseUpload.ts", + "type": "registry:component", + "target": "app/composables/useSupabaseUpload.ts" + } + ], + "dependencies": ["@supabase/supabase-js@latest", "@vueuse/core", "lucide-vue-next"] +} \ No newline at end of file diff --git a/blocks/vue/registry/default/dropzone/vue/components/dropzone-content.vue b/blocks/vue/registry/default/dropzone/vue/components/dropzone-content.vue new file mode 100644 index 0000000000000..7f1a3dc5bf239 --- /dev/null +++ b/blocks/vue/registry/default/dropzone/vue/components/dropzone-content.vue @@ -0,0 +1,132 @@ + + + diff --git a/blocks/vue/registry/default/dropzone/vue/components/dropzone-empty-state.vue b/blocks/vue/registry/default/dropzone/vue/components/dropzone-empty-state.vue new file mode 100644 index 0000000000000..7c96dd495b97c --- /dev/null +++ b/blocks/vue/registry/default/dropzone/vue/components/dropzone-empty-state.vue @@ -0,0 +1,37 @@ + + + diff --git a/blocks/vue/registry/default/dropzone/vue/components/dropzone.vue b/blocks/vue/registry/default/dropzone/vue/components/dropzone.vue new file mode 100644 index 0000000000000..107dd5bcd3db4 --- /dev/null +++ b/blocks/vue/registry/default/dropzone/vue/components/dropzone.vue @@ -0,0 +1,82 @@ + + + diff --git a/blocks/vue/registry/default/dropzone/vue/composables/useSupabaseUpload.ts b/blocks/vue/registry/default/dropzone/vue/composables/useSupabaseUpload.ts new file mode 100644 index 0000000000000..b057c6ef7cf3a --- /dev/null +++ b/blocks/vue/registry/default/dropzone/vue/composables/useSupabaseUpload.ts @@ -0,0 +1,190 @@ +import { ref, computed, watch, onUnmounted } from 'vue' +import { useDropZone } from '@vueuse/core' +// @ts-ignore +import { createClient } from "@/lib/supabase/client" + +const supabase = createClient() + +export interface FileWithPreview extends File { + preview?: string + errors: { code: string; message: string }[] +} + +export type UseSupabaseUploadOptions = { + bucketName: string + path?: string + allowedMimeTypes?: string[] + maxFileSize?: number + maxFiles?: number + cacheControl?: number + upsert?: boolean +} + +function validateFileType(file: File, allowedTypes: string[]) { + if (!allowedTypes.length) return [] + const isValid = allowedTypes.some(t => + t.endsWith('/*') + ? file.type.startsWith(t.replace('/*', '')) + : file.type === t + ) + return isValid + ? [] + : [{ code: 'invalid-type', message: 'Invalid file type' }] +} + +function validateFileSize(file: File, maxSize: number) { + return file.size > maxSize + ? [{ code: 'file-too-large', message: `File is larger than allowed size` }] + : [] +} + +export function useSupabaseUpload(options: UseSupabaseUploadOptions) { + const { + bucketName, + path, + allowedMimeTypes = [], + maxFileSize = Number.POSITIVE_INFINITY, + maxFiles = 1, + cacheControl = 3600, + upsert = false, + } = options + + const files = ref([]) + const loading = ref(false) + const errors = ref<{ name: string; message: string }[]>([]) + const successes = ref([]) + + const isSuccess = computed(() => { + if (!errors.value.length && !successes.value.length) return false + return !errors.value.length && successes.value.length === files.value.length + }) + + const dropZoneRef = ref(null) + + const { isOverDropZone } = useDropZone(dropZoneRef, { + onDrop(droppedFiles: File[] | null) { + if (!droppedFiles) return + + const newFiles: FileWithPreview[] = droppedFiles.map(file => ({ + ...(file as FileWithPreview), + preview: URL.createObjectURL(file), + errors: [ + ...validateFileType(file, allowedMimeTypes), + ...validateFileSize(file, maxFileSize), + ], + })) + + files.value = [...files.value, ...newFiles] + }, + }) + + const onUpload = async () => { + loading.value = true + + try { + const filesWithErrors = errors.value.map(e => e.name) + + const filesToUpload = + filesWithErrors.length > 0 + ? files.value.filter( + f => + filesWithErrors.includes(f.name) || + !successes.value.includes(f.name) + ) + : files.value + + const responses = await Promise.all( + filesToUpload.map(async file => { + const { error } = await supabase.storage + .from(bucketName) + .upload(path ? `${path}/${file.name}` : file.name, file, { + cacheControl: cacheControl.toString(), + upsert, + }) + + return error + ? { name: file.name, message: error.message } + : { name: file.name, message: undefined } + }) + ) + + errors.value = responses.filter((r): r is { name: string; message: string } => r.message !== undefined) + + const successful = responses + .filter(r => !r.message) + .map(r => r.name) + + successes.value = Array.from( + new Set([...successes.value, ...successful]) + ) + } catch (err) { + console.error('Upload failed unexpectedly:', err) + + errors.value.push({ + name: 'upload', + message: 'An unexpected error occurred during upload.', + }) + } finally { + loading.value = false + } + } + + + watch( + () => files.value.length, + () => { + if (!files.value.length) { + errors.value = [] + successes.value = [] + } + + if (files.value.length > maxFiles) { + errors.value.push({ + name: 'files', + message: `You may upload up to ${maxFiles} files`, + }) + } + } + ) + + watch( + files, + (newFiles, oldFiles) => { + const newPreviews = new Set(newFiles.map(f => f.preview)) + oldFiles.forEach(file => { + if (file.preview && !newPreviews.has(file.preview)) { + URL.revokeObjectURL(file.preview) + } + }) + }, + { deep: true } + ) + + onUnmounted(() => { + files.value.forEach(file => { + if (file.preview) { + URL.revokeObjectURL(file.preview) + } + }) + }) + + return { + dropZoneRef, + isOverDropZone, + + files, + setFiles: (v: FileWithPreview[]) => (files.value = v), + + errors, + setErrors: (v: { name: string; message: string }[]) => (errors.value = v), + + successes, + isSuccess, + loading, + onUpload, + + maxFileSize, + maxFiles, + allowedMimeTypes, + } +} diff --git a/blocks/vue/registry/default/dropzone/vue/registry-item.json b/blocks/vue/registry/default/dropzone/vue/registry-item.json new file mode 100644 index 0000000000000..03578ca5cce05 --- /dev/null +++ b/blocks/vue/registry/default/dropzone/vue/registry-item.json @@ -0,0 +1,30 @@ +{ + "name": "dropzone-vue", + "type": "registry:block", + "title": "Dropzone (File Upload) for Vue and Supabase", + "description": "Displays a control for easier uploading of files directly to Supabase Storage.", + "registryDependencies": ["button"], + "files": [ + { + "path": "registry/default/dropzone/vue/components/dropzone.vue", + "type": "registry:component", + "target": "components/dropzone.vue" + }, + { + "path": "registry/default/dropzone/vue/components/dropzone-empty-state.vue", + "type": "registry:component", + "target": "components/dropzone-empty-state.vue" + }, + { + "path": "registry/default/dropzone/vue/components/dropzone-content.vue", + "type": "registry:component", + "target": "components/dropzone-content.vue" + }, + { + "path": "registry/default/dropzone/vue/composables/useSupabaseUpload.ts", + "type": "registry:component", + "target": "composables/useSupabaseUpload.ts" + } + ], + "dependencies": ["@supabase/supabase-js@latest", "@vueuse/core", "lucide-vue-next"] +} \ No newline at end of file diff --git a/blocks/vue/registry/default/realtime-cursor/nuxtjs/app/components/cursor.vue b/blocks/vue/registry/default/realtime-cursor/nuxtjs/app/components/cursor.vue new file mode 100644 index 0000000000000..2ac794bba46f8 --- /dev/null +++ b/blocks/vue/registry/default/realtime-cursor/nuxtjs/app/components/cursor.vue @@ -0,0 +1,33 @@ + + + diff --git a/blocks/vue/registry/default/realtime-cursor/nuxtjs/app/components/realtime-cursors.vue b/blocks/vue/registry/default/realtime-cursor/nuxtjs/app/components/realtime-cursors.vue new file mode 100644 index 0000000000000..fe58ee1098996 --- /dev/null +++ b/blocks/vue/registry/default/realtime-cursor/nuxtjs/app/components/realtime-cursors.vue @@ -0,0 +1,37 @@ + + + diff --git a/blocks/vue/registry/default/realtime-cursor/nuxtjs/app/composables/useRealtimeCursors.ts b/blocks/vue/registry/default/realtime-cursor/nuxtjs/app/composables/useRealtimeCursors.ts new file mode 100644 index 0000000000000..7fccf11e5c65c --- /dev/null +++ b/blocks/vue/registry/default/realtime-cursor/nuxtjs/app/composables/useRealtimeCursors.ts @@ -0,0 +1,169 @@ +import { ref, reactive, onMounted, onUnmounted } from 'vue' +import { REALTIME_SUBSCRIBE_STATES, type RealtimeChannel } from '@supabase/supabase-js' +// @ts-ignore +import { createClient } from '@/lib/supabase/client' + +/** + * Throttle a callback to a certain delay. + * It will only call the callback if the delay has passed, + * using the arguments from the last call. + */ +function useThrottleCallback( + callback: (...args: Params) => void, + delay: number +) { + let lastCall = 0 + let timeout: ReturnType | null = null + + const run = (...args: Params) => { + const now = Date.now() + const remainingTime = delay - (now - lastCall) + + if (remainingTime <= 0) { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + lastCall = now + callback(...args) + } else if (!timeout) { + timeout = setTimeout(() => { + lastCall = Date.now() + timeout = null + callback(...args) + }, remainingTime) + } + } + + const cancel = () => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + } + + return { run, cancel } +} + +const supabase = createClient() + +const generateRandomColor = () => + `hsl(${Math.floor(Math.random() * 360)}, 100%, 70%)` + +const generateRandomNumber = () => + Math.floor(Math.random() * 100) + +const EVENT_NAME = 'realtime-cursor-move' + +export type CursorEventPayload = { + position: { x: number; y: number } + user: { id: number; name: string } + color: string + timestamp: number +} + +export function useRealtimeCursors({ + roomName, + username, + throttleMs, +}: { + roomName: string + username: string + throttleMs: number +}) { + const color = generateRandomColor() + const userId = generateRandomNumber() + + const cursors = reactive>({}) + const cursorPayload = ref(null) + const channelRef = ref(null) + + const sendCursor = (event: MouseEvent) => { + const payload: CursorEventPayload = { + position: { + x: event.clientX, + y: event.clientY, + }, + user: { + id: userId, + name: username, + }, + color, + timestamp: Date.now(), + } + + cursorPayload.value = payload + + channelRef.value?.send({ + type: 'broadcast', + event: EVENT_NAME, + payload, + }) + } + + const { run: handleMouseMove, cancel: cancelThrottle } = + useThrottleCallback(sendCursor, throttleMs) + + onMounted(() => { + const channel = supabase.channel(roomName) + + channel + .on('system', {}, (payload: CursorEventPayload) => { + console.error('Realtime system error:', payload) + + // Defensive cleanup + Object.keys(cursors).forEach((k) => delete cursors[k]) + channelRef.value = null + }) + .on('presence', { event: 'leave' }, ({ leftPresences }: { leftPresences: Array<{ key: string }> }) => { + leftPresences.forEach(({ key }) => { + delete cursors[key] + }) + }) + .on('presence', { event: 'join' }, () => { + if (!cursorPayload.value) return + + channelRef.value?.send({ + type: 'broadcast', + event: EVENT_NAME, + payload: cursorPayload.value, + }) + }) + .on('broadcast', { event: EVENT_NAME }, ({ payload }: { payload: CursorEventPayload }) => { + if (payload.user.id === userId) return + + cursors[payload.user.id] = payload + }) + .subscribe(async (status: REALTIME_SUBSCRIBE_STATES) => { + if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { + try { + await channel.track({ key: userId }) + channelRef.value = channel + } catch (err) { + console.error('Failed to track presence for current user:', err) + channelRef.value = null + } + } else { + Object.keys(cursors).forEach((k) => delete cursors[k]) + channelRef.value = null + } + }) + + window.addEventListener('mousemove', handleMouseMove) + }) + + onUnmounted(() => { + window.removeEventListener('mousemove', handleMouseMove) + + cancelThrottle() + + if (channelRef.value) { + channelRef.value.unsubscribe() + channelRef.value = null + } + + Object.keys(cursors).forEach((k) => delete cursors[k]) + }) + + return { cursors } +} diff --git a/blocks/vue/registry/default/realtime-cursor/nuxtjs/registry-item.json b/blocks/vue/registry/default/realtime-cursor/nuxtjs/registry-item.json new file mode 100644 index 0000000000000..3a2adc712634d --- /dev/null +++ b/blocks/vue/registry/default/realtime-cursor/nuxtjs/registry-item.json @@ -0,0 +1,24 @@ +{ + "name": "realtime-cursor-nuxtjs", + "type": "registry:component", + "title": "Realtime Cursor for Nuxt and Supabase", + "description": "Component which renders realtime cursors from other users in a room.", + "files": [ + { + "path": "registry/default/realtime-cursor/nuxtjs/app/components/cursor.vue", + "type": "registry:component", + "target": "app/components/cursor.vue" + }, + { + "path": "registry/default/realtime-cursor/nuxtjs/app/components/realtime-cursors.vue", + "type": "registry:component", + "target": "app/components/realtime-cursors.vue" + }, + { + "path": "registry/default/realtime-cursor/nuxtjs/app/composables/useRealtimeCursors.ts", + "type": "registry:component", + "target": "app/composables/useRealtimeCursors.ts" + } + ], + "dependencies": ["@supabase/supabase-js@latest", "@vueuse/core", "lucide-vue-next"] +} \ No newline at end of file diff --git a/blocks/vue/registry/default/realtime-cursor/vue/components/cursor.vue b/blocks/vue/registry/default/realtime-cursor/vue/components/cursor.vue new file mode 100644 index 0000000000000..2ac794bba46f8 --- /dev/null +++ b/blocks/vue/registry/default/realtime-cursor/vue/components/cursor.vue @@ -0,0 +1,33 @@ + + + diff --git a/blocks/vue/registry/default/realtime-cursor/vue/components/realtime-cursors.vue b/blocks/vue/registry/default/realtime-cursor/vue/components/realtime-cursors.vue new file mode 100644 index 0000000000000..f74df808c6483 --- /dev/null +++ b/blocks/vue/registry/default/realtime-cursor/vue/components/realtime-cursors.vue @@ -0,0 +1,37 @@ + + + diff --git a/blocks/vue/registry/default/realtime-cursor/vue/composables/useRealtimeCursors.ts b/blocks/vue/registry/default/realtime-cursor/vue/composables/useRealtimeCursors.ts new file mode 100644 index 0000000000000..7fccf11e5c65c --- /dev/null +++ b/blocks/vue/registry/default/realtime-cursor/vue/composables/useRealtimeCursors.ts @@ -0,0 +1,169 @@ +import { ref, reactive, onMounted, onUnmounted } from 'vue' +import { REALTIME_SUBSCRIBE_STATES, type RealtimeChannel } from '@supabase/supabase-js' +// @ts-ignore +import { createClient } from '@/lib/supabase/client' + +/** + * Throttle a callback to a certain delay. + * It will only call the callback if the delay has passed, + * using the arguments from the last call. + */ +function useThrottleCallback( + callback: (...args: Params) => void, + delay: number +) { + let lastCall = 0 + let timeout: ReturnType | null = null + + const run = (...args: Params) => { + const now = Date.now() + const remainingTime = delay - (now - lastCall) + + if (remainingTime <= 0) { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + lastCall = now + callback(...args) + } else if (!timeout) { + timeout = setTimeout(() => { + lastCall = Date.now() + timeout = null + callback(...args) + }, remainingTime) + } + } + + const cancel = () => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + } + + return { run, cancel } +} + +const supabase = createClient() + +const generateRandomColor = () => + `hsl(${Math.floor(Math.random() * 360)}, 100%, 70%)` + +const generateRandomNumber = () => + Math.floor(Math.random() * 100) + +const EVENT_NAME = 'realtime-cursor-move' + +export type CursorEventPayload = { + position: { x: number; y: number } + user: { id: number; name: string } + color: string + timestamp: number +} + +export function useRealtimeCursors({ + roomName, + username, + throttleMs, +}: { + roomName: string + username: string + throttleMs: number +}) { + const color = generateRandomColor() + const userId = generateRandomNumber() + + const cursors = reactive>({}) + const cursorPayload = ref(null) + const channelRef = ref(null) + + const sendCursor = (event: MouseEvent) => { + const payload: CursorEventPayload = { + position: { + x: event.clientX, + y: event.clientY, + }, + user: { + id: userId, + name: username, + }, + color, + timestamp: Date.now(), + } + + cursorPayload.value = payload + + channelRef.value?.send({ + type: 'broadcast', + event: EVENT_NAME, + payload, + }) + } + + const { run: handleMouseMove, cancel: cancelThrottle } = + useThrottleCallback(sendCursor, throttleMs) + + onMounted(() => { + const channel = supabase.channel(roomName) + + channel + .on('system', {}, (payload: CursorEventPayload) => { + console.error('Realtime system error:', payload) + + // Defensive cleanup + Object.keys(cursors).forEach((k) => delete cursors[k]) + channelRef.value = null + }) + .on('presence', { event: 'leave' }, ({ leftPresences }: { leftPresences: Array<{ key: string }> }) => { + leftPresences.forEach(({ key }) => { + delete cursors[key] + }) + }) + .on('presence', { event: 'join' }, () => { + if (!cursorPayload.value) return + + channelRef.value?.send({ + type: 'broadcast', + event: EVENT_NAME, + payload: cursorPayload.value, + }) + }) + .on('broadcast', { event: EVENT_NAME }, ({ payload }: { payload: CursorEventPayload }) => { + if (payload.user.id === userId) return + + cursors[payload.user.id] = payload + }) + .subscribe(async (status: REALTIME_SUBSCRIBE_STATES) => { + if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { + try { + await channel.track({ key: userId }) + channelRef.value = channel + } catch (err) { + console.error('Failed to track presence for current user:', err) + channelRef.value = null + } + } else { + Object.keys(cursors).forEach((k) => delete cursors[k]) + channelRef.value = null + } + }) + + window.addEventListener('mousemove', handleMouseMove) + }) + + onUnmounted(() => { + window.removeEventListener('mousemove', handleMouseMove) + + cancelThrottle() + + if (channelRef.value) { + channelRef.value.unsubscribe() + channelRef.value = null + } + + Object.keys(cursors).forEach((k) => delete cursors[k]) + }) + + return { cursors } +} diff --git a/blocks/vue/registry/default/realtime-cursor/vue/registry-item.json b/blocks/vue/registry/default/realtime-cursor/vue/registry-item.json new file mode 100644 index 0000000000000..728f0007f767e --- /dev/null +++ b/blocks/vue/registry/default/realtime-cursor/vue/registry-item.json @@ -0,0 +1,24 @@ +{ + "name": "realtime-cursor-vue", + "type": "registry:component", + "title": "Realtime Cursor for Vue and Supabase", + "description": "Component which renders realtime cursors from other users in a room.", + "files": [ + { + "path": "registry/default/realtime-cursor/vue/components/cursor.vue", + "type": "registry:component", + "target": "components/cursor.vue" + }, + { + "path": "registry/default/realtime-cursor/vue/components/realtime-cursors.vue", + "type": "registry:component", + "target": "components/realtime-cursors.vue" + }, + { + "path": "registry/default/realtime-cursor/vue/composables/useRealtimeCursors.ts", + "type": "registry:component", + "target": "composables/useRealtimeCursors.ts" + } + ], + "dependencies": ["@supabase/supabase-js@latest", "@vueuse/core", "lucide-vue-next"] +} diff --git a/blocks/vue/registry/dropzone.ts b/blocks/vue/registry/dropzone.ts new file mode 100644 index 0000000000000..f3be8d0af4159 --- /dev/null +++ b/blocks/vue/registry/dropzone.ts @@ -0,0 +1,6 @@ +import { type RegistryItem } from 'shadcn/schema' + +import nuxtjs from './default/dropzone/nuxtjs/registry-item.json' with { type: 'json' } +import vue from './default/dropzone/vue/registry-item.json' with { type: 'json' } + +export const dropzone = [nuxtjs, vue] as RegistryItem[] diff --git a/blocks/vue/registry/index.ts b/blocks/vue/registry/index.ts index 0ce964f3b342c..235404a5d7cde 100644 --- a/blocks/vue/registry/index.ts +++ b/blocks/vue/registry/index.ts @@ -1,7 +1,9 @@ import { clients } from './clients' +import { dropzone } from './dropzone' import { passwordBasedAuth } from './password-based-auth' +import { realtimeCursor } from './realtime-cursor' import { socialAuth } from './social-auth' -const blocks = [...clients, ...passwordBasedAuth, ...socialAuth] +const blocks = [...clients, ...passwordBasedAuth, ...socialAuth, ...dropzone, ...realtimeCursor] export { blocks } diff --git a/blocks/vue/registry/realtime-cursor.ts b/blocks/vue/registry/realtime-cursor.ts new file mode 100644 index 0000000000000..9540c26c74fe1 --- /dev/null +++ b/blocks/vue/registry/realtime-cursor.ts @@ -0,0 +1,6 @@ +import { type RegistryItem } from 'shadcn/schema' + +import nuxtjs from './default/realtime-cursor/nuxtjs/registry-item.json' with { type: 'json' } +import vue from './default/realtime-cursor/vue/registry-item.json' with { type: 'json' } + +export const realtimeCursor = [nuxtjs, vue] as RegistryItem[] diff --git a/packages/ui-patterns/src/MetricCard/index.tsx b/packages/ui-patterns/src/MetricCard/index.tsx index 653ec273a7284..6d8b86cedc85a 100644 --- a/packages/ui-patterns/src/MetricCard/index.tsx +++ b/packages/ui-patterns/src/MetricCard/index.tsx @@ -1,28 +1,28 @@ 'use client' +import dayjs from 'dayjs' +import { ChevronRight, HelpCircle } from 'lucide-react' +import Link from 'next/link' import * as React from 'react' import { useContext } from 'react' +import { + Area, + AreaChart, + Tooltip as RechartsTooltip, + TooltipProps as RechartsTooltipProps, + ResponsiveContainer, +} from 'recharts' import { Button, + Card, + CardContent, + CardTitle, + Skeleton, Tooltip, TooltipContent, TooltipTrigger, - Card, - CardTitle, cn, - CardContent, - Skeleton, } from 'ui' -import { ExternalLink, HelpCircle } from 'lucide-react' -import Link from 'next/link' -import { - AreaChart, - Area, - ResponsiveContainer, - Tooltip as RechartsTooltip, - TooltipProps as RechartsTooltipProps, -} from 'recharts' -import dayjs from 'dayjs' interface MetricCardContextValue { isLoading?: boolean @@ -46,7 +46,11 @@ interface MetricCardProps extends React.HTMLAttributes { const MetricCard = React.forwardRef( ({ isLoading = false, isDisabled = false, className, children, ...props }, ref) => { return ( - + {children} @@ -59,10 +63,11 @@ MetricCard.displayName = 'MetricCard' interface MetricCardHeaderProps extends React.HTMLAttributes { href?: string children: React.ReactNode + linkTooltip?: string } const MetricCardHeader = React.forwardRef( - ({ className, href, children, ...props }, ref) => { + ({ className, href, children, linkTooltip, ...props }, ref) => { return (
{...props} >
{children}
- {href && ( - - )} + {href ? ( + + + + + {linkTooltip ? {linkTooltip} : null} + + ) : null}
) } @@ -132,7 +147,7 @@ const MetricCardLabel = React.forwardRef( - {tooltip} + {tooltip} )} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a1e6d88148b0..aa8623e41db4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1876,6 +1876,9 @@ importers: '@supabase/supabase-js': specifier: 'catalog:' version: 2.93.2 + '@vueuse/core': + specifier: ^14.1.0 + version: 14.1.0(vue@3.5.21(typescript@5.9.2)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1885,6 +1888,9 @@ importers: h3: specifier: ^1.15.5 version: 1.15.5 + lucide-vue-next: + specifier: ^0.562.0 + version: 0.562.0(vue@3.5.21(typescript@5.9.2)) nuxt: specifier: ^4.0.3 version: 4.1.2(@electric-sql/pglite@0.2.15)(@parcel/watcher@2.5.1)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.21)(aws4fetch@1.0.20)(db0@0.3.2(@electric-sql/pglite@0.2.15)(drizzle-orm@0.44.2(@electric-sql/pglite@0.2.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(pg@8.16.3)))(drizzle-orm@0.44.2(@electric-sql/pglite@0.2.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(pg@8.16.3))(encoding@0.1.13)(eslint@9.37.0(jiti@2.5.1)(supports-color@8.1.1))(ioredis@5.7.0(supports-color@8.1.1))(magicast@0.3.5)(rollup@4.50.2)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@5.9.2)(vite@7.1.11(@types/node@22.13.14)(jiti@2.5.1)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1) @@ -9751,6 +9757,9 @@ packages: '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/webxr@0.5.20': resolution: {integrity: sha512-JGpU6qiIJQKUuVSKx1GtQnHJGxRjtfGIhzO2ilq43VZZS//f1h1Sgexbdk+Lq+7569a6EYhOWrUpIruR/1Enmg==} @@ -10032,6 +10041,19 @@ packages: '@vue/shared@3.5.21': resolution: {integrity: sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==} + '@vueuse/core@14.1.0': + resolution: {integrity: sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/metadata@14.1.0': + resolution: {integrity: sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==} + + '@vueuse/shared@14.1.0': + resolution: {integrity: sha512-EcKxtYvn6gx1F8z9J5/rsg3+lTQnvOruQd8fUecW99DCK04BkWD7z5KQ/wTAx+DazyoEE9dJt/zV8OIEQbM6kw==} + peerDependencies: + vue: ^3.5.0 + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -14610,6 +14632,11 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lucide-vue-next@0.562.0: + resolution: {integrity: sha512-LN0BLGKMFulv0lnfK29r14DcngRUhIqdcaL0zXTt2o0oS9odlrjCGaU3/X9hIihOjjN8l8e+Y9G/famcNYaI7Q==} + peerDependencies: + vue: '>=3.0.1' + lunr@2.3.9: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} @@ -29318,6 +29345,8 @@ snapshots: '@types/uuid@9.0.8': {} + '@types/web-bluetooth@0.0.21': {} + '@types/webxr@0.5.20': {} '@types/ws@8.18.1': @@ -29763,6 +29792,19 @@ snapshots: '@vue/shared@3.5.21': {} + '@vueuse/core@14.1.0(vue@3.5.21(typescript@5.9.2))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.1.0 + '@vueuse/shared': 14.1.0(vue@3.5.21(typescript@5.9.2)) + vue: 3.5.21(typescript@5.9.2) + + '@vueuse/metadata@14.1.0': {} + + '@vueuse/shared@14.1.0(vue@3.5.21(typescript@5.9.2))': + dependencies: + vue: 3.5.21(typescript@5.9.2) + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -34898,6 +34940,10 @@ snapshots: dependencies: react: 18.3.1 + lucide-vue-next@0.562.0(vue@3.5.21(typescript@5.9.2)): + dependencies: + vue: 3.5.21(typescript@5.9.2) + lunr@2.3.9: {} luxon@3.5.0: {}