diff --git a/apps/docs/spec/cli_v1_commands.yaml b/apps/docs/spec/cli_v1_commands.yaml index 15be75a9ee1f5..f68d0515e6acc 100644 --- a/apps/docs/spec/cli_v1_commands.yaml +++ b/apps/docs/spec/cli_v1_commands.yaml @@ -1,7 +1,7 @@ clispec: '001' info: id: cli - version: 2.30.4 + version: 2.31.4 title: Supabase CLI language: sh source: https://github.com/supabase/cli diff --git a/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx b/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx index 6bcd2ba8bbbac..ad99d760f6cce 100644 --- a/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx +++ b/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx @@ -1,4 +1,4 @@ -import { Code, Wind } from 'lucide-react' +import { Circle, Code, Minus, Plus, Wind } from 'lucide-react' import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' @@ -34,15 +34,31 @@ const fileKey = (fullPath: string) => basename(fullPath) const getStatusColor = (status: FileStatus): string => { switch (status) { case 'added': - return 'bg-brand' + return 'text-brand' case 'removed': - return 'bg-destructive' + return 'text-destructive' case 'modified': - return 'bg-warning' + return 'text-warning' case 'unchanged': - return 'bg-muted' + return 'text-muted' default: - return 'bg-muted' + return 'text-muted' + } +} + +// Helper to get the status icon for file indicators +const getStatusIcon = (status: FileStatus) => { + switch (status) { + case 'added': + return Plus + case 'removed': + return Minus + case 'modified': + return Circle + case 'unchanged': + return Circle + default: + return Circle } } @@ -65,8 +81,12 @@ const FunctionDiff = ({ } }, [allFileKeys, activeFileKey]) - const currentFile = currentBody.find((f) => fileKey(f.name) === activeFileKey) - const mainFile = mainBody.find((f) => fileKey(f.name) === activeFileKey) + const currentFile = currentBody.find( + (f: EdgeFunctionBodyData[number]) => fileKey(f.name) === activeFileKey + ) + const mainFile = mainBody.find( + (f: EdgeFunctionBodyData[number]) => fileKey(f.name) === activeFileKey + ) const language = useMemo(() => { if (!activeFileKey) return 'plaintext' @@ -96,28 +116,31 @@ const FunctionDiff = ({
diff --git a/apps/studio/components/interfaces/Database/Backups/PITR/TimezoneSelection.tsx b/apps/studio/components/interfaces/Database/Backups/PITR/TimezoneSelection.tsx index d527da4b6d495..18c443b0ef84f 100644 --- a/apps/studio/components/interfaces/Database/Backups/PITR/TimezoneSelection.tsx +++ b/apps/studio/components/interfaces/Database/Backups/PITR/TimezoneSelection.tsx @@ -48,7 +48,7 @@ export const TimezoneSelection = ({ : 'Select timezone...'} - + diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx index 442540ebe0946..edfab6d923992 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx @@ -4,9 +4,12 @@ import { ReplicationPipelinesData } from 'data/replication/pipelines-query' import { ResponseError } from 'types' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' import RowMenu from './RowMenu' -import PipelineStatus, { PipelineStatusRequestStatus } from './PipelineStatus' +import PipelineStatus, { PipelineStatusRequestStatus, PipelineStatusName } from './PipelineStatus' import { useParams } from 'common' -import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query' +import { + ReplicationPipelineStatusData, + useReplicationPipelineStatusQuery, +} from 'data/replication/pipeline-status-query' import { useState } from 'react' import { toast } from 'sonner' import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation' @@ -65,10 +68,22 @@ const DestinationRow = ({ const { mutateAsync: startPipeline } = useStartPipelineMutation() const { mutateAsync: stopPipeline } = useStopPipelineMutation() const pipelineStatus = pipelineStatusData?.status + const getStatusName = ( + status: ReplicationPipelineStatusData['status'] | undefined + ): string | undefined => { + if (status && typeof status === 'object' && 'name' in status) { + return status.name + } + + return undefined + } + + const statusName = getStatusName(pipelineStatus) if ( (requestStatus === PipelineStatusRequestStatus.EnableRequested && - pipelineStatus === 'Started') || - (requestStatus === PipelineStatusRequestStatus.DisableRequested && pipelineStatus === 'Stopped') + (statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED)) || + (requestStatus === PipelineStatusRequestStatus.DisableRequested && + (statusName === PipelineStatusName.STOPPED || statusName === PipelineStatusName.FAILED)) ) { setRequestStatus(PipelineStatusRequestStatus.None) } @@ -190,7 +205,7 @@ const DestinationRow = ({ sourceId, destinationId: destinationId, pipelineId: pipeline?.id, - enabled: pipelineStatusData?.status === 'Started', + enabled: statusName === PipelineStatusName.STARTED, }} /> diff --git a/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx index 6e8be284823f0..6fb360e6d972e 100644 --- a/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx @@ -2,8 +2,21 @@ import AlertError from 'components/ui/AlertError' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' import { cn } from 'ui' import { ResponseError } from 'types' -import { Loader2 } from 'lucide-react' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui' +import { Loader2, ChevronDown, ChevronRight, Copy, AlertTriangle } from 'lucide-react' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, + Button, + Collapsible_Shadcn_ as Collapsible, + CollapsibleContent_Shadcn_ as CollapsibleContent, + CollapsibleTrigger_Shadcn_ as CollapsibleTrigger, +} from 'ui' +import { useState, useEffect, useRef } from 'react' +import { copyToClipboard } from 'ui' +import { toast } from 'sonner' +import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query' export enum PipelineStatusRequestStatus { None = 'None', @@ -11,8 +24,19 @@ export enum PipelineStatusRequestStatus { DisableRequested = 'DisableRequested', } +export enum PipelineStatusName { + FAILED = 'failed', + STARTING = 'starting', + STARTED = 'started', + STOPPED = 'stopped', + UNKNOWN = 'unknown', +} + +// Type alias for better readability +type FailedStatus = Extract + interface PipelineStatusProps { - pipelineStatus: string | undefined + pipelineStatus: ReplicationPipelineStatusData['status'] | undefined error: ResponseError | null isLoading: boolean isError: boolean @@ -28,6 +52,175 @@ const PipelineStatus = ({ isSuccess, requestStatus, }: PipelineStatusProps) => { + const [isErrorDetailsOpen, setIsErrorDetailsOpen] = useState(false) + const errorDetailsRef = useRef(null) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (errorDetailsRef.current && !errorDetailsRef.current.contains(event.target as Node)) { + setIsErrorDetailsOpen(false) + } + } + + if (isErrorDetailsOpen) { + document.addEventListener('mousedown', handleClickOutside) + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [isErrorDetailsOpen]) + + const handleCopyToClipboard = async (text: string) => { + try { + await copyToClipboard(text) + toast.success('Error details copied to clipboard') + } catch (error) { + toast.error('Failed to copy error details') + } + } + + const isFailedStatus = ( + status: ReplicationPipelineStatusData['status'] | undefined + ): status is FailedStatus => { + return ( + status !== null && + status !== undefined && + typeof status === 'object' && + status.name === PipelineStatusName.FAILED + ) + } + + const isLogLoadingState = (failedStatus: FailedStatus): boolean => { + // Right now we hardcode the error message which is returned when k8s is not able to find logs, which seems + // to be a transient error. In case we find a way to properly handle this in the backend, this hack will not + // be needed anymore. + return ( + failedStatus.message?.startsWith('unable to retrieve container logs for containerd://') === + true + ) + } + + const renderFailedStatus = (failedStatus: FailedStatus) => { + const hasDetails = + failedStatus.message || failedStatus.reason || failedStatus.exit_code !== undefined + const isLoadingLogs = isLogLoadingState(failedStatus) + + return ( +
+ + + +
+ {isLoadingLogs ? ( + + ) : ( + + )} + Failed +
+
+ +

+ {isLoadingLogs + ? 'Pipeline failed - logs are being retrieved from container' + : 'Pipeline has failed - expand for error details'} +

+
+
+
+ {hasDetails && ( + + + + + +
+ {isLoadingLogs ? ( +
+ +
+
+ Pipeline Failed - Loading logs... +
+
+ Error logs are being retrieved from the container. This may take a few + moments. +
+
+
+ ) : ( + <> +
+ + Pipeline Error Details + + {(failedStatus.message || failedStatus.reason) && ( + + )} +
+
+ {failedStatus.exit_code !== undefined && ( +
+ + Exit Code: + +
+ {failedStatus.exit_code} +
+
+ )} + {failedStatus.reason && ( +
+ Reason: +
+                            {failedStatus.reason}
+                          
+
+ )} + {failedStatus.message && ( +
+ + Message: + +
+                            {failedStatus.message}
+                          
+
+ )} +
+ + )} +
+
+
+ )} +
+ ) + } // Map backend statuses to UX-friendly display const getStatusConfig = () => { if (requestStatus === PipelineStatusRequestStatus.EnableRequested) { @@ -48,42 +241,59 @@ const PipelineStatus = ({ } } - switch (pipelineStatus) { - case 'Starting': - return { - label: 'Starting', - dot: , - color: 'text-warning-600', - tooltip: 'Pipeline is initializing and will be ready soon', - } - case 'Started': - return { - label: 'Running', - dot:
, - color: 'text-brand-600', - tooltip: 'Pipeline is active and processing data', - } - case 'Stopped': - return { - label: 'Stopped', - dot:
, - color: 'text-foreground-light', - tooltip: 'Pipeline is not running - enable to start processing', - } - case 'Unknown': - return { - label: 'Unknown', - dot:
, - color: 'text-warning-600', - tooltip: 'Pipeline status could not be determined', - } - default: - return { - label: 'Unknown', - dot:
, - color: 'text-destructive-600', - tooltip: 'Pipeline status is unclear - check logs for details', - } + // Handle Failed status object + if (isFailedStatus(pipelineStatus)) { + return { + isFailedStatus: true, + } + } + + if (pipelineStatus && typeof pipelineStatus === 'object' && 'name' in pipelineStatus) { + switch (pipelineStatus.name) { + case PipelineStatusName.STARTING: + return { + label: 'Starting', + dot: , + color: 'text-warning-600', + tooltip: 'Pipeline is initializing and will be ready soon', + } + case PipelineStatusName.STARTED: + return { + label: 'Running', + dot:
, + color: 'text-brand-600', + tooltip: 'Pipeline is active and processing data', + } + case PipelineStatusName.STOPPED: + return { + label: 'Stopped', + dot:
, + color: 'text-foreground-light', + tooltip: 'Pipeline is not running - enable to start processing', + } + case PipelineStatusName.UNKNOWN: + return { + label: 'Unknown', + dot:
, + color: 'text-warning-600', + tooltip: 'Pipeline status could not be determined', + } + default: + return { + label: 'Unknown', + dot:
, + color: 'text-destructive-600', + tooltip: 'Pipeline status is unclear - check logs for details', + } + } + } + + // Fallback for undefined or invalid status + return { + label: 'Unknown', + dot:
, + color: 'text-destructive-600', + tooltip: 'Pipeline status is unclear - check logs for details', } } @@ -94,19 +304,25 @@ const PipelineStatus = ({ {isLoading && } {isError && } {isSuccess && ( - - - -
- {statusConfig.dot} - {statusConfig.label} -
-
- -

{statusConfig.tooltip}

-
-
-
+ <> + {statusConfig.isFailedStatus ? ( +
{renderFailedStatus(pipelineStatus as FailedStatus)}
+ ) : ( + + + +
+ {statusConfig.dot} + {statusConfig.label} +
+
+ +

{statusConfig.tooltip}

+
+
+
+ )} + )} ) diff --git a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx index 2852aea357703..f09cc703093c2 100644 --- a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx +++ b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx @@ -10,9 +10,10 @@ import { DropdownMenuTrigger, } from 'ui' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { PipelineStatusName } from './PipelineStatus' interface RowMenuProps { - pipelineStatus: string | undefined + pipelineStatus: any error: ResponseError | null isLoading: boolean isError: boolean @@ -32,7 +33,16 @@ const RowMenu = ({ onEditClick, onDeleteClick, }: RowMenuProps) => { - const pipelineEnabled = pipelineStatus === 'Stopped' ? false : true + const getStatusName = (status: any) => { + if (status && typeof status === 'object' && 'name' in status) { + return status.name + } + return status + } + + const statusName = getStatusName(pipelineStatus) + const pipelineEnabled = statusName !== PipelineStatusName.STOPPED + return (
{isLoading && } diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx index a57546e61f2ec..8ff308fd8789b 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx @@ -8,6 +8,7 @@ import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui import SchemaSelector from 'components/ui/SchemaSelector' import { useSchemasQuery } from 'data/database/schemas-query' import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { Button, Form, @@ -24,6 +25,7 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { CreateWrapperSheetProps } from './CreateWrapperSheet' import InputField from './InputField' import { makeValidateRequired } from './Wrappers.utils' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' const FORM_ID = 'create-wrapper-form' @@ -63,6 +65,8 @@ export const CreateIcebergWrapperSheet = ({ onClose, }: CreateWrapperSheetProps) => { const { project } = useProjectContext() + const org = useSelectedOrganization() + const { mutate: sendEvent } = useSendEventMutation() const [createSchemaSheetOpen, setCreateSchemaSheetOpen] = useState(false) const [selectedTarget, setSelectedTarget] = useState('S3Tables') @@ -131,6 +135,17 @@ export const CreateIcebergWrapperSheet = ({ sourceSchema: values.source_schema, targetSchema: values.target_schema, }) + + sendEvent({ + action: 'foreign_data_wrapper_created', + properties: { + wrapperType: wrapperMeta.label, + }, + groups: { + project: project?.ref ?? 'Unknown', + organization: org?.slug ?? 'Unknown', + }, + }) } return ( diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx index 881db37bb74ae..d737aa6733bd4 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx @@ -11,6 +11,7 @@ import SchemaSelector from 'components/ui/SchemaSelector' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { invalidateSchemasQuery, useSchemasQuery } from 'data/database/schemas-query' import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { Button, Form, @@ -29,6 +30,7 @@ import InputField from './InputField' import { WrapperMeta } from './Wrappers.types' import { makeValidateRequired } from './Wrappers.utils' import WrapperTableEditor from './WrapperTableEditor' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' export interface CreateWrapperSheetProps { isClosing: boolean @@ -47,6 +49,8 @@ export const CreateWrapperSheet = ({ }: CreateWrapperSheetProps) => { const queryClient = useQueryClient() const { project } = useProjectContext() + const org = useSelectedOrganization() + const { mutate: sendEvent } = useSendEventMutation() const [newTables, setNewTables] = useState([]) const [isEditingTable, setIsEditingTable] = useState(false) @@ -135,6 +139,17 @@ export const CreateWrapperSheet = ({ sourceSchema: values.source_schema, targetSchema: values.target_schema, }) + + sendEvent({ + action: 'foreign_data_wrapper_created', + properties: { + wrapperType: wrapperMeta.label, + }, + groups: { + project: project?.ref ?? 'Unknown', + organization: org?.slug ?? 'Unknown', + }, + }) } return ( diff --git a/apps/studio/components/interfaces/Organization/SecuritySettings/SecuritySettings.tsx b/apps/studio/components/interfaces/Organization/SecuritySettings/SecuritySettings.tsx index 906fb82dc4e29..076e8ded568ca 100644 --- a/apps/studio/components/interfaces/Organization/SecuritySettings/SecuritySettings.tsx +++ b/apps/studio/components/interfaces/Organization/SecuritySettings/SecuritySettings.tsx @@ -15,6 +15,7 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query' import { useOrganizationMfaToggleMutation } from 'data/organizations/organization-mfa-mutation' import { useOrganizationMfaQuery } from 'data/organizations/organization-mfa-query' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useProfile } from 'lib/profile' @@ -48,6 +49,7 @@ const SecuritySettings = () => { const { data: members } = useOrganizationMembersQuery({ slug }) const canReadMfaConfig = useCheckPermissions(PermissionAction.READ, 'organizations') const canUpdateMfaConfig = useCheckPermissions(PermissionAction.UPDATE, 'organizations') + const { mutate: sendEvent } = useSendEventMutation() const isPaidPlan = selectedOrganization?.plan.id !== 'free' @@ -66,6 +68,15 @@ const SecuritySettings = () => { }, onSuccess: (data) => { toast.success('Successfully updated organization MFA settings') + sendEvent({ + action: 'organization_mfa_enforcement_updated', + properties: { + mfaEnforced: data.enforced, + }, + groups: { + organization: slug ?? 'Unknown', + }, + }) }, }) diff --git a/apps/studio/components/interfaces/Reports/SharedAPIReport.constants.ts b/apps/studio/components/interfaces/Reports/SharedAPIReport.constants.ts new file mode 100644 index 0000000000000..1c7f679f2821d --- /dev/null +++ b/apps/studio/components/interfaces/Reports/SharedAPIReport.constants.ts @@ -0,0 +1,278 @@ +import { get } from 'data/fetchers' +import { generateRegexpWhere } from './Reports.constants' +import { ReportFilterItem } from './Reports.types' +import { useQueries } from '@tanstack/react-query' +import * as Sentry from '@sentry/nextjs' + +export const SHARED_API_REPORT_SQL = { + totalRequests: { + queryType: 'logs', + sql: (filters: ReportFilterItem[], src = 'edge_logs') => ` + --reports-api-total-requests + select + cast(timestamp_trunc(t.timestamp, hour) as datetime) as timestamp, + count(t.id) as count + FROM ${src} t + cross join unnest(metadata) as m + cross join unnest(m.response) as response + cross join unnest(m.request) as request + cross join unnest(request.headers) as headers + ${generateRegexpWhere(filters)} + GROUP BY + timestamp + ORDER BY + timestamp ASC`, + }, + topRoutes: { + queryType: 'logs', + sql: (filters: ReportFilterItem[], src = 'edge_logs') => ` + -- reports-api-top-routes + select + request.path as path, + request.method as method, + request.search as search, + response.status_code as status_code, + count(t.id) as count + from ${src} t + cross join unnest(metadata) as m + cross join unnest(m.response) as response + cross join unnest(m.request) as request + cross join unnest(request.headers) as headers + ${generateRegexpWhere(filters)} + group by + request.path, request.method, request.search, response.status_code + order by + count desc + limit 10 + `, + }, + errorCounts: { + queryType: 'logs', + sql: (filters: ReportFilterItem[], src = 'edge_logs') => ` + -- reports-api-error-counts + select + cast(timestamp_trunc(t.timestamp, hour) as datetime) as timestamp, + count(t.id) as count + FROM ${src} t + cross join unnest(metadata) as m + cross join unnest(m.response) as response + cross join unnest(m.request) as request + cross join unnest(request.headers) as headers + WHERE + response.status_code >= 400 + ${generateRegexpWhere(filters, false)} + GROUP BY + timestamp + ORDER BY + timestamp ASC + `, + }, + topErrorRoutes: { + queryType: 'logs', + sql: (filters: ReportFilterItem[], src = 'edge_logs') => ` + -- reports-api-top-error-routes + select + request.path as path, + request.method as method, + request.search as search, + response.status_code as status_code, + count(t.id) as count + from ${src} t + cross join unnest(metadata) as m + cross join unnest(m.response) as response + cross join unnest(m.request) as request + cross join unnest(request.headers) as headers + where + response.status_code >= 400 + ${generateRegexpWhere(filters, false)} + group by + request.path, request.method, request.search, response.status_code + order by + count desc + limit 10 + `, + }, + responseSpeed: { + queryType: 'logs', + sql: (filters: ReportFilterItem[], src = 'edge_logs') => ` + -- reports-api-response-speed + select + cast(timestamp_trunc(t.timestamp, hour) as datetime) as timestamp, + avg(response.origin_time) as avg + FROM + ${src} t + cross join unnest(metadata) as m + cross join unnest(m.response) as response + cross join unnest(m.request) as request + cross join unnest(request.headers) as headers + ${generateRegexpWhere(filters)} + GROUP BY + timestamp + ORDER BY + timestamp ASC + `, + }, + topSlowRoutes: { + queryType: 'logs', + sql: (filters: ReportFilterItem[], src = 'edge_logs') => ` + -- reports-api-top-slow-routes + select + request.path as path, + request.method as method, + request.search as search, + response.status_code as status_code, + count(t.id) as count, + avg(response.origin_time) as avg + from ${src} t + cross join unnest(metadata) as m + cross join unnest(m.response) as response + cross join unnest(m.request) as request + cross join unnest(request.headers) as headers + ${generateRegexpWhere(filters)} + group by + request.path, request.method, request.search, response.status_code + order by + avg desc + limit 10 + `, + }, + networkTraffic: { + queryType: 'logs', + sql: (filters: ReportFilterItem[], src = 'edge_logs') => ` + -- reports-api-network-traffic + select + cast(timestamp_trunc(t.timestamp, hour) as datetime) as timestamp, + coalesce( + safe_divide( + sum( + cast(coalesce(headers.content_length, "0") as int64) + ), + 1000000 + ), + 0 + ) as ingress_mb, + coalesce( + safe_divide( + sum( + cast(coalesce(resp_headers.content_length, "0") as int64) + ), + 1000000 + ), + 0 + ) as egress_mb, + FROM + ${src} t + cross join unnest(metadata) as m + cross join unnest(m.response) as response + cross join unnest(m.request) as request + cross join unnest(request.headers) as headers + cross join unnest(response.headers) as resp_headers + ${generateRegexpWhere(filters)} + GROUP BY + timestamp + ORDER BY + timestamp ASC + `, + }, +} + +export type SharedAPIReportKey = keyof typeof SHARED_API_REPORT_SQL + +const fetchLogs = async ({ + projectRef, + sql, + start, + end, +}: { + projectRef: string + sql: string + start: string + end: string +}) => { + const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { + params: { + path: { ref: projectRef }, + query: { + sql, + iso_timestamp_start: start, + iso_timestamp_end: end, + }, + }, + }) + + if (error || data?.error) { + Sentry.captureException({ + message: 'Shared API Report Error', + data: { + error, + data, + }, + }) + throw error || data?.error + } + + return data +} + +type SharedAPIReportParams = { + src: string + filters: ReportFilterItem[] + start: string + end: string + projectRef: string + enabled?: boolean +} +export const useSharedAPIReport = ({ + src = 'edge_logs', + filters, + start, + end, + projectRef, + enabled = true, +}: SharedAPIReportParams) => { + const queries = useQueries({ + queries: Object.entries(SHARED_API_REPORT_SQL).map(([key, value]) => ({ + queryKey: ['shared-api-report', key, src, filters, start, end, projectRef], + enabled, + queryFn: () => + fetchLogs({ + projectRef, + sql: value.sql(filters, src), + start, + end, + }), + })), + }) + + const keys = Object.keys(SHARED_API_REPORT_SQL) as Array + + const data = keys.reduce( + (acc, key, i) => { + acc[key] = queries[i].data?.result || [] + return acc + }, + {} as { [K in keyof typeof SHARED_API_REPORT_SQL]: unknown[] } + ) + + const error = keys.reduce( + (acc, key, i) => { + acc[key] = queries[i].error as string + return acc + }, + {} as { [K in keyof typeof SHARED_API_REPORT_SQL]: string } + ) + + const isLoading = keys.reduce( + (acc, key, i) => { + acc[key] = queries[i].isLoading + return acc + }, + {} as { [K in keyof typeof SHARED_API_REPORT_SQL]: boolean } + ) + + return { + data, + error, + isLoading, + } +} diff --git a/apps/studio/components/interfaces/Reports/SharedAPIReport.tsx b/apps/studio/components/interfaces/Reports/SharedAPIReport.tsx new file mode 100644 index 0000000000000..8b19f008069ac --- /dev/null +++ b/apps/studio/components/interfaces/Reports/SharedAPIReport.tsx @@ -0,0 +1,91 @@ +import ReportWidget from './ReportWidget' +import { + ErrorCountsChartRenderer, + NetworkTrafficRenderer, + ResponseSpeedChartRenderer, + TopApiRoutesRenderer, + TotalRequestsChartRenderer, +} from './renderers/ApiRenderers' +import { SharedAPIReportKey, useSharedAPIReport } from './SharedAPIReport.constants' +import { useParams } from 'common' + +export function SharedAPIReport({ + filterBy, + start, + end, + hiddenReports = [], +}: { + filterBy: 'auth' | 'realtime' | 'storage' | 'graphql' | 'functions' + start: string + end: string + hiddenReports?: SharedAPIReportKey[] +}) { + const { ref } = useParams() as { ref: string } + + const { data, error, isLoading } = useSharedAPIReport({ + src: filterBy === 'functions' ? 'function_edge_logs' : 'edge_logs', + filters: [ + { + key: 'request.path', + value: `/${filterBy}`, + compare: 'matches', + }, + ], + start, + end, + projectRef: ref, + enabled: !!ref && !!filterBy, + }) + + return ( +
+ {!hiddenReports.includes('totalRequests') && ( + + )} + {!hiddenReports.includes('errorCounts') && ( + + )} + {!hiddenReports.includes('responseSpeed') && ( + + )} + {!hiddenReports.includes('networkTraffic') && ( + + )} +
+ ) +} diff --git a/apps/studio/components/layouts/AuthenticationLayout.tsx b/apps/studio/components/layouts/AuthenticationLayout.tsx index 60ad6705137dd..8f1f32dd306dc 100644 --- a/apps/studio/components/layouts/AuthenticationLayout.tsx +++ b/apps/studio/components/layouts/AuthenticationLayout.tsx @@ -6,7 +6,7 @@ import { AppBannerContextProvider } from 'components/interfaces/App/AppBannerWra export const AuthenticationLayout = ({ children }: PropsWithChildren<{}>) => { return ( -
+
{children}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx index a0a3304b43a3a..97f5bddd388e2 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx @@ -136,7 +136,7 @@ const LayoutHeader = ({ {selectedProject && ( <> - + {IS_PLATFORM && } )} diff --git a/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx b/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx index 2973ef53b7933..8723843fd13a8 100644 --- a/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx +++ b/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx @@ -21,7 +21,7 @@ const ForgotPasswordLayout = ({ const { resolvedTheme } = useTheme() return ( -
+