diff --git a/i18n/README.ta.md b/i18n/README.ta.md index e38182f55d0..2c65e4b44e0 100644 --- a/i18n/README.ta.md +++ b/i18n/README.ta.md @@ -10,10 +10,10 @@ - [x] ஹோஸ்ட் செய்யப்பட்ட பூஸ்ட்ஜீஆர்இஎஸ் தரவுத்தளம். [ஆவணம்](https://supabase.com/docs/guides/database) - [x] அங்கீகாரம் மற்றும் அங்கீகாரம்.. [ஆவணம்](https://supabase.com/docs/guides/auth) -- [x] தானாக உருவாக்கப்பட்ட ஏபிஐ கள். +- [x] தானாக உருவாக்கப்பட்ட ஏபிஐ கள். - [x] ராஸ்ட். [ஆவணம்](https://supabase.com/docs/guides/api#rest-api) - [x] நிகழ்நேர சந்தாக்கள். [ஆவணம்](https://supabase.com/docs/guides/api#realtime-api) - - [x] கிராப்க்யூஎல் (பீட்டா). [ஆவணம்](https://supabase.com/docs/guides/api#graphql-api) + - [x] கிராப்க்யூஎல் (பீட்டா). [ஆவணம்](https://supabase.com/docs/guides/api#graphql-api) - [x] செயல்பாடுகள். - [x] தரவுத்தளம் செயல்பாடுகள். [ஆவணம்](https://supabase.com/docs/guides/database/functions) - [x] விளிம்பு செயல்பாடுகள் [ஆவணம்](https://supabase.com/docs/guides/functions) @@ -45,7 +45,7 @@ நாங்கள் தற்போது பொது பீட்டாவில் இருக்கிறோம். முக்கிய துப்பிப்புகளைப் பற்றி அறிவிக்க இந்த ரெப்போவின் "வெளியீடுகளைப்" பாருங்கள். இந்த ரெப்போவைப் பாருங்கள் - + --- ## இது எவ்வாறு செயல்படுகிறது @@ -182,8 +182,8 @@ ## மொழிபெயர்ப்புகள் -- [மொழிபெயர்ப்புகளின் பட்டியல்](/i18n/languages.md) +- [மொழிபெயர்ப்புகளின் பட்டியல்](/i18n/languages.md) ## ஸ்பான்சர்கள் diff --git a/studio/components/interfaces/Settings/Logs/LogTable.tsx b/studio/components/interfaces/Settings/Logs/LogTable.tsx index 710616fe345..33bad467f84 100644 --- a/studio/components/interfaces/Settings/Logs/LogTable.tsx +++ b/studio/components/interfaces/Settings/Logs/LogTable.tsx @@ -1,15 +1,6 @@ import dayjs from 'dayjs' import { useEffect, useState, useMemo } from 'react' -import { - Alert, - Button, - IconDownloadCloud, - IconEye, - IconEyeOff, - IconLoader, - Input, - Typography, -} from '@supabase/ui' +import { Alert, Button, IconEye, IconEyeOff, Input } from '@supabase/ui' import DataGrid from '@supabase/react-data-grid' import LogSelection from './LogSelection' @@ -262,52 +253,69 @@ const LogTable = ({ !isLoading ? ( <>
-
-
-
-
-
- - - + {!error && ( +
+ <> +
+
+
+
+ + + +
+
-
+
+

No results

+

+ Try another search, or adjusting the filters +

+
+
-
-

No results

-

- Try another search, or adjusting the filters -

-
-
+ )} {error && (
void + to: string + from: string + onChange: React.ComponentProps["onChange"] + helpers: DatetimeHelper[] } - -const HELPERS = [ - { text: 'Last hour', value: '1_hour' }, - { text: 'Last 3 hours', value: '3_hour' }, - { text: 'Last day', value: '1_day' }, -] -const DEFAULT_HELPER_VALUE = HELPERS[0].value - -const DatePickers: React.FC = ({ to, from, onChange }) => { - const [helperValue, setHelperValue] = useState('') - +const DatePickers: React.FC = ({ to, from, onChange, helpers }) => { + const defaultHelper = getDefaultHelper(helpers) + const [helperValue, setHelperValue] = useState(to || from ? '' : defaultHelper.text) const handleHelperChange = (newValue: string) => { setHelperValue(newValue) - const toDate = new Date() - const fromDate = { - '1_hour': new Date(toDate.getTime() - 60 * 60 * 1000), - '3_hour': new Date(toDate.getTime() - 3 * 60 * 60 * 1000), - '1_day': new Date(toDate.getTime() - 24 * 60 * 60 * 1000), - }[newValue] - if (onChange) onChange({ to: toDate.toISOString(), from: fromDate?.toISOString() as string }) + const selectedHelper = helpers.find((h) => h.text === newValue) + if (onChange && selectedHelper) { + onChange({ to: selectedHelper.calcTo(), from: selectedHelper.calcFrom() }) + } } - const selectedHelper = HELPERS.find((h) => h.value === (helperValue || DEFAULT_HELPER_VALUE)) + + const selectedHelper = helpers.find((helper) => { + if (to === helper.calcTo() && from === helper.calcFrom()) { + return true + } else if (helper.text === helperValue) { + return true + } else { + return false + } + }) + useEffect(() => { + if (selectedHelper && helperValue !== selectedHelper.text) { + setHelperValue(selectedHelper.text) + } + }, [selectedHelper]) + return (
= ({ to, from, onChange }) => { align="start" overlay={ <> - - {HELPERS.map((helper) => ( - + + {helpers.map((helper) => ( + {helper.text} ))} @@ -57,18 +60,27 @@ const DatePickers: React.FC = ({ to, from, onChange }) => { icon={} className="rounded-r-none" > - {selectedHelper?.text} + {selectedHelper?.text || defaultHelper.text} { + triggerButtonType={selectedHelper ? 'default' : 'secondary'} + onChange={(value) => { setHelperValue('') if (onChange) onChange(value) }} to={!helperValue ? to : undefined} from={!helperValue ? from : undefined} + renderFooter={({ to, from }) => { + if (to && from && Math.abs(dayjs(from).diff(dayjs(to), 'day')) > LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD) { + return ( + + Large ranges may result in memory errors for big projects. + + ) + } + }} />
) diff --git a/studio/components/interfaces/Settings/Logs/Logs.constants.ts b/studio/components/interfaces/Settings/Logs/Logs.constants.ts index 75577262633..17ed2ce2dd8 100644 --- a/studio/components/interfaces/Settings/Logs/Logs.constants.ts +++ b/studio/components/interfaces/Settings/Logs/Logs.constants.ts @@ -1,4 +1,7 @@ -import { FilterTableSet, LogTemplate } from '.' +import dayjs from 'dayjs' +import { DatetimeHelper, FilterTableSet, LogTemplate } from '.' + +export const LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD = 4 export const TEMPLATES: LogTemplate[] = [ { @@ -185,10 +188,8 @@ export const LOG_TYPE_LABEL_MAPPING: { [k: string]: string } = { database: 'Database', } - - const _SQL_FILTER_COMMON = { - 'search_query': (value: string) => `regexp_contains(event_message, '${value}')` + search_query: (value: string) => `regexp_contains(event_message, '${value}')`, } export const SQL_FILTER_TEMPLATES: any = { @@ -424,3 +425,43 @@ export const LOGS_TAILWIND_CLASSES = { log_selection_x_padding: 'px-8', space_y: 'px-6', } + +export const PREVIEWER_DATEPICKER_HELPERS: DatetimeHelper[] = [ + { + text: 'Last hour', + calcFrom: () => dayjs().subtract(1, 'hour').startOf('hour').toISOString(), + calcTo: () => '', + }, + { + text: 'Last 3 hours', + calcFrom: () => dayjs().subtract(3, 'hour').startOf('hour').toISOString(), + calcTo: () => '', + }, + { + text: 'Last day', + calcFrom: () => dayjs().subtract(1, 'day').startOf('day').toISOString(), + calcTo: () => '', + default: true, + }, +] +export const EXPLORER_DATEPICKER_HELPERS: DatetimeHelper[] = [ + { + text: 'Last day', + calcFrom: () => dayjs().subtract(1, 'day').startOf('day').toISOString(), + calcTo: () => '', + default: true, + }, + { + text: 'Last 3 days', + calcFrom: () => dayjs().subtract(3, 'day').startOf('day').toISOString(), + calcTo: () => '', + }, + { + text: 'Last 7 days', + calcFrom: () => dayjs().subtract(7, 'day').startOf('day').toISOString(), + calcTo: () => '', + }, +] + +export const getDefaultHelper = (helpers: DatetimeHelper[]) => + helpers.find((helper) => helper.default) || helpers[0] diff --git a/studio/components/interfaces/Settings/Logs/Logs.types.ts b/studio/components/interfaces/Settings/Logs/Logs.types.ts index 2d2036353d8..52ff5db6b35 100644 --- a/studio/components/interfaces/Settings/Logs/Logs.types.ts +++ b/studio/components/interfaces/Settings/Logs/Logs.types.ts @@ -1,19 +1,29 @@ +import React from "react" + interface Metadata { [key: string]: string | number | Object | Object[] } -export type LogSearchCallback = (filters: { query: string; to?: string; from?: string; fromMicro?: number, toMicro?: number }) => void +export type DatePickerToFrom = { to: string | null; from: string | null } + +export type LogSearchCallback = ( + filters: { + query: string + } & DatePickerToFrom +) => void + +export interface LogsWarning { + text: string | React.ReactNode + link?: string + linkText?: string +} export interface LogsEndpointParams { // project ref project: string - // micro unix timestamp - timestamp_start?: string - // micro timestamp - timestamp_end?: string - period_start?: string - period_end?: string - sql: string - rawSql: string + iso_timestamp_start?: string + iso_timestamp_end?: string + sql?: string + rawSql?: string } export interface LogData { @@ -65,7 +75,6 @@ export interface FilterObject { te?: string } - export interface FilterSet { label: string key: string @@ -91,3 +100,10 @@ export type Override = { key: string value: string | string[] | undefined } + +export interface DatetimeHelper { + text: string + calcTo: () => string + calcFrom: () => string + default?: boolean +} diff --git a/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/studio/components/interfaces/Settings/Logs/Logs.utils.ts index 3dac12eca1b..13ad0208445 100644 --- a/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -1,37 +1,45 @@ -import { get } from "lodash" -import { Filters, LogsTableName, SQL_FILTER_TEMPLATES } from "." +import { Filters, LogsTableName, SQL_FILTER_TEMPLATES } from '.' +import dayjs from 'dayjs' +import { get } from 'lodash' + +/** + * Convert a micro timestamp from number/string to iso timestamp + */ +export const unixMicroToIsoTimestamp = (unix: string | number): string => { + return dayjs.unix(Number(unix) / 1000).toISOString() +} /** * Recursively retrieve all nested object key paths. - * + * * TODO: move to utils - * + * * @param obj any object * @param parent a string representing the parent key * @returns string[] all dot paths for keys. */ const getDotKeys = (obj: { [k: string]: unknown }, parent?: string): string[] => { - const keys = Object.keys(obj).filter(k => obj[k]) - return keys.flatMap(k => { - const currKey = parent ? `${parent}.${k}` : k - if (typeof obj[k] === 'object') { - return getDotKeys(obj[k] as any, currKey) - } else { - return [currKey] - } - }) + const keys = Object.keys(obj).filter((k) => obj[k]) + return keys.flatMap((k) => { + const currKey = parent ? `${parent}.${k}` : k + if (typeof obj[k] === 'object') { + return getDotKeys(obj[k] as any, currKey) + } else { + return [currKey] + } + }) } /** * Root keys in the filter object are considered to be AND filters. * Nested keys under a root key are considered to be OR filters. - * + * * For example: * ``` * {my_value: 'something', nested: {id: 123, test: 123 }} * ``` * This would be converted into `WHERE (my_value = 'something') and (id = 123 or test = 123) - * + * * The template of the filter determines the actual filter statement. If no template is provided, a generic equality statement will be used. * This only applies for root keys of the filter. * For example: @@ -39,60 +47,71 @@ const getDotKeys = (obj: { [k: string]: unknown }, parent?: string): string[] => * {'my.nested.value': 123} * ``` * with no template, it will be converted into `WHERE (my.nested.value = 123) - * - * @returns a whewhere statement with WHERE clause. + * + * @returns a where statement with WHERE clause. */ const _genWhereStatement = (table: LogsTableName, filters: Filters) => { - const keys = Object.keys(filters) - const filterTemplates = SQL_FILTER_TEMPLATES[table] - const _resolveTemplateToStatement = (dotKey: string): string | null => { - const template = filterTemplates[dotKey] - const value = get(filters, dotKey) - if (value !== undefined && typeof template === 'function') { - return template(value) - } else if (template === undefined) { - // resolve unknwon filters (possibly from filter overrides) - // no template, set a default - if (typeof value === 'string') { - return `${dotKey} = '${value}'` - } else { - return `${dotKey} = ${value}` - } - } else if (value === undefined && typeof template === 'function') { - return null - } else { - return template - } - } - - const statement = keys.map(rootKey => { - if (typeof filters[rootKey] === 'object') { - // join all statements with an OR - const nestedStatement = getDotKeys(filters[rootKey] as Filters, rootKey).map(_resolveTemplateToStatement).filter(Boolean).join(" or ") - return `(${nestedStatement})` - } else { - const nestedStatement = _resolveTemplateToStatement(rootKey) - if (nestedStatement === null) return null - return `(${nestedStatement})` - } - - }).filter(Boolean) - // join all root statements with AND - .join(" and ") - - if (statement) { - return 'where ' + statement + const keys = Object.keys(filters) + const filterTemplates = SQL_FILTER_TEMPLATES[table] + const _resolveTemplateToStatement = (dotKey: string): string | null => { + const template = filterTemplates[dotKey] + const value = get(filters, dotKey) + if (value !== undefined && typeof template === 'function') { + return template(value) + } else if (template === undefined) { + // resolve unknwon filters (possibly from filter overrides) + // no template, set a default + if (typeof value === 'string') { + return `${dotKey} = '${value}'` + } else { + return `${dotKey} = ${value}` + } + } else if (value === undefined && typeof template === 'function') { + return null + } else if (template && value === false) { + // template present, but value is false + return null } else { - return '' + return template } + } + + const statement = keys + .map((rootKey) => { + if (typeof filters[rootKey] === 'object') { + // join all statements with an OR + const nestedStatements = getDotKeys(filters[rootKey] as Filters, rootKey) + .map(_resolveTemplateToStatement) + .filter(Boolean) + + if (nestedStatements.length > 0) { + return `(${nestedStatements.join(' or ')})` + } else { + return null + } + } else { + const nestedStatement = _resolveTemplateToStatement(rootKey) + if (nestedStatement === null) return null + return `(${nestedStatement})` + } + }) + .filter(Boolean) + // join all root statements with AND + .join(' and ') + + if (statement) { + return 'where ' + statement + } else { + return '' + } } export const genDefaultQuery = (table: LogsTableName, filters: Filters) => { - const where = _genWhereStatement(table, filters) + const where = _genWhereStatement(table, filters) - switch (table) { - case 'edge_logs': - return `select id, timestamp, event_message, metadata, request, response, request.method, request.path, response.status_code + switch (table) { + case 'edge_logs': + return `select id, timestamp, event_message, metadata, request, response, request.method, request.path, response.status_code from ${table} cross join unnest(metadata) as m cross join unnest(m.request) as request @@ -100,27 +119,27 @@ export const genDefaultQuery = (table: LogsTableName, filters: Filters) => { ${where} limit 100 ` - break + break - case 'postgres_logs': - return `select postgres_logs.timestamp, id, event_message, metadata, metadataparsed.error_severity from ${table} + case 'postgres_logs': + return `select postgres_logs.timestamp, id, event_message, metadata, metadataparsed.error_severity from ${table} cross join unnest(metadata) as m cross join unnest(m.parsed) as metadataparsed ${where} limit 100 ` - break + break - case 'function_logs': - return `select id, ${table}.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.level, metadata from ${table} + case 'function_logs': + return `select id, ${table}.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.level, metadata from ${table} cross join unnest(metadata) as metadata ${where} limit 100 ` - break + break - case 'function_edge_logs': - return `select id, ${table}.timestamp, event_message, response.status_code, response, request, request.method, m.function_id, m.execution_time_ms, m.deployment_id, m.version from ${table} + case 'function_edge_logs': + return `select id, ${table}.timestamp, event_message, response.status_code, response, request, request.method, m.function_id, m.execution_time_ms, m.deployment_id, m.version from ${table} cross join unnest(metadata) as m cross join unnest(m.response) as response cross join unnest(m.request) as request @@ -128,8 +147,8 @@ export const genDefaultQuery = (table: LogsTableName, filters: Filters) => { limit 100 ` - default: - return "" - break - } -} \ No newline at end of file + default: + return '' + break + } +} diff --git a/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx b/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx index acf657d4900..e99ad93ee2a 100644 --- a/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx +++ b/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useReducer, useState } from 'react' +import React, { useEffect, useState } from 'react' import { useRouter } from 'next/router' import { Typography, IconAlertCircle, IconRewind, Button, Card, Input } from '@supabase/ui' @@ -10,15 +10,13 @@ import { LogsTableName, QueryType, LogEventChart, - FilterObject, Filters, + unixMicroToIsoTimestamp, } from 'components/interfaces/Settings/Logs' -import dayjs from 'dayjs' import useLogsPreview from 'hooks/analytics/useLogsPreview' import PreviewFilterPanel from 'components/interfaces/Settings/Logs/PreviewFilterPanel' import { LOGS_TABLES } from './Logs.constants' -import { Override } from './Logs.types' import ShimmerLine from 'components/ui/ShimmerLine' import LoadingOpacity from 'components/ui/LoadingOpacity' @@ -47,29 +45,26 @@ export const LogsPreviewer: React.FC = ({ tableName, }) => { const router = useRouter() - const { s, te, ts } = router.query + const { s, ite, its } = router.query const [showChart, setShowChart] = useState(true) const table = !tableName ? LOGS_TABLES[queryType] : tableName const [ - { error, logData, params, newCount, filters, isLoading, oldestTimestamp }, - { loadOlder, setFilters, refresh, setTo, setFrom }, + { error, logData, params, newCount, filters, isLoading }, + { loadOlder, setFilters, refresh, setParams }, ] = useLogsPreview(projectRef as string, table, filterOverride) useEffect(() => { setFilters((prev) => ({ ...prev, search_query: s as string })) - if (te) { - setTo(te as string) - } else { - setTo('') + if (ite || its) { + setParams((prev) => ({ + ...prev, + iso_timestamp_start: (its || '') as string, + iso_timestamp_end: (ite || '') as string, + })) } - if (ts) { - setFrom(ts as string) - } else { - setFrom('') - } - }, [s, te, ts]) + }, [s, ite, its]) const onSelectTemplate = (template: LogTemplate) => { setFilters((prev: any) => ({ ...prev, search_query: template.searchString })) @@ -81,33 +76,27 @@ export const LogsPreviewer: React.FC = ({ pathname: router.pathname, query: { ...router.query, - te: undefined, - ts: undefined, + ite: undefined, + its: undefined, // ...whereFilters, }, }) } - const handleSearch: LogSearchCallback = async ({ query, to, from, fromMicro, toMicro }) => { - let toValue, fromValue - if (to || toMicro) { - toValue = toMicro ? toMicro : dayjs(to).valueOf() * 1000 - - setTo(String(toValue)) - } - if (from || fromMicro) { - fromValue = fromMicro ? fromMicro : dayjs(from).valueOf() * 1000 - setFrom(String(fromValue)) - } - setFilters((prev) => ({ ...prev, search_query: query || '' })) - + const handleSearch: LogSearchCallback = async ({ query = '', to, from }) => { + setParams((prev) => ({ + ...prev, + iso_timestamp_start: from || prev.iso_timestamp_start || '', + iso_timestamp_end: to || prev.iso_timestamp_end || '', + })) + setFilters((prev) => ({ ...prev, search_query: query })) router.push({ pathname: router.pathname, query: { ...router.query, s: query || '', - ts: fromValue, - te: toValue, + its: from || its || '', + ite: to || ite || '', }, }) } @@ -123,18 +112,16 @@ export const LogsPreviewer: React.FC = ({ onRefresh={handleRefresh} onSearch={handleSearch} defaultSearchValue={filters.search_query as string} - defaultToValue={ - params.timestamp_end ? dayjs(Number(params.timestamp_end) / 1000).toISOString() : '' - } - defaultFromValue={ - params.timestamp_start - ? dayjs(Number(params.timestamp_start) / 1000).toISOString() - : oldestTimestamp - ? dayjs(Number(oldestTimestamp) / 1000).toISOString() - : '' - } + defaultToValue={params.iso_timestamp_end} + defaultFromValue={params.iso_timestamp_start} onExploreClick={() => { - router.push(`/project/${projectRef}/logs-explorer?q=${encodeURIComponent(params.rawSql)}`) + router.push( + `/project/${projectRef}/logs-explorer?q=${encodeURIComponent( + params.rawSql || '' + )}&its=${encodeURIComponent(params.iso_timestamp_start || '')}&ite=${encodeURIComponent( + params.iso_timestamp_end || '' + )}` + ) }} onSelectTemplate={onSelectTemplate} filters={filters} @@ -157,7 +144,8 @@ export const LogsPreviewer: React.FC = ({ { - handleSearch({ query: filters.search_query as string, toMicro: timestampMicro }) + const to = unixMicroToIsoTimestamp(timestampMicro) + handleSearch({ query: filters.search_query as string, to, from: null }) }} /> )} diff --git a/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx b/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx index 91d60213a50..8f4d6a1e93a 100644 --- a/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx +++ b/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx @@ -1,6 +1,24 @@ -import { Button, Dropdown, Typography, IconChevronDown, IconPlay } from '@supabase/ui' +import { + Button, + Dropdown, + Typography, + IconChevronDown, + IconPlay, + Badge, + Popover, + Alert, +} from '@supabase/ui' import Flag from 'components/ui/Flag/Flag' -import { LogsTableName, LOGS_SOURCE_DESCRIPTION, LogTemplate } from '.' +import { + EXPLORER_DATEPICKER_HELPERS, + LogsTableName, + LogsWarning, + LOGS_SOURCE_DESCRIPTION, + LogTemplate, +} from '.' +import DatePickers from './Logs.DatePickers' +import Link from 'next/link' + interface Props { templates?: LogTemplate[] onSelectTemplate: (template: LogTemplate) => void @@ -10,6 +28,10 @@ interface Props { onSave?: () => void hasEditorValue: boolean isLoading: boolean + onDateChange: React.ComponentProps['onChange'] + defaultTo: string + defaultFrom: string + warnings: LogsWarning[] } const LogsQueryPanel: React.FC = ({ @@ -21,6 +43,10 @@ const LogsQueryPanel: React.FC = ({ onSave, onSelectSource, isLoading, + defaultFrom, + defaultTo, + onDateChange, + warnings, }) => (
+
0 ? 'opacity-100' : 'invisible w-0 h-0 opacity-0' + }`} + > + +
+ {warnings.map((warning, index) => ( +

+ {warning.text}{' '} + {warning.link && ( + {warning.linkText || 'View'} + )} +

+ ))} +
+ + } + > + + {warnings.length} {warnings.length > 1 ? 'warnings' : 'warning'} + +
+
+
diff --git a/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx b/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx index 47cd0ce3ef8..ccb80f13030 100644 --- a/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx +++ b/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx @@ -9,7 +9,7 @@ import { IconEye, IconEyeOff, } from '@supabase/ui' -import { Filters, LogSearchCallback, LogTemplate } from '.' +import { Filters, LogSearchCallback, LogTemplate, PREVIEWER_DATEPICKER_HELPERS } from '.' import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc' import { FILTER_OPTIONS, LogsTableName } from './Logs.constants' @@ -101,9 +101,8 @@ const PreviewFilterPanel: FC = ({ Refresh ) - - const handleSearch = (partial: Partial<{ query: string; to: string; from: string }>) => - onSearch({ query: search, ...partial }) + const handleSearch = (partial: Partial[0]>) => + onSearch({ query: search, to: partial?.to || null, from: partial?.from || null }) return (
= ({ { - handleSearch(e) - }} + onChange={handleSearch} to={defaultToValue} from={defaultFromValue} + helpers={PREVIEWER_DATEPICKER_HELPERS} />
diff --git a/studio/components/ui/DatePicker/DatePicker.tsx b/studio/components/ui/DatePicker/DatePicker.tsx index 417a50e48d9..ce1d7cf7874 100644 --- a/studio/components/ui/DatePicker/DatePicker.tsx +++ b/studio/components/ui/DatePicker/DatePicker.tsx @@ -1,9 +1,5 @@ -import React, { useState, forwardRef, useEffect } from 'react' - +import React, { useState, useEffect } from 'react' import DatePicker from 'react-datepicker' - -// import dayjs from 'dayjs' - import { Button, Popover, @@ -11,33 +7,26 @@ import { IconChevronRight, IconArrowRight, IconCalendar, - IconGlobe, - Input, - Toggle, - Select, } from '@supabase/ui' import { format } from 'date-fns' - import TimeSplitInput from './TimeSplitInput' -// import DateSplitInput from './DateSplitInput' -import DividerSlash from './DividerSlash' - import dayjs from 'dayjs' import { ButtonProps } from '@supabase/ui/dist/cjs/components/Button/Button' +import { DatePickerToFrom } from 'components/interfaces/Settings/Logs' var utc = require('dayjs/plugin/utc') var timezone = require('dayjs/plugin/timezone') // dependent on utc plugin - dayjs.extend(utc) dayjs.extend(timezone) -interface RootProps { - onChange?: ({ to, from }: { to: string; from: string }) => void +export interface DatePickerProps { + onChange?: (args: DatePickerToFrom) => void to?: string from?: string triggerButtonType?: ButtonProps['type'] triggerButtonClassName?: string + renderFooter?: (args: DatePickerToFrom) => React.ReactNode | void } const START_DATE_DEFAULT = new Date() @@ -52,24 +41,31 @@ function _DatePicker({ onChange, triggerButtonType = 'default', triggerButtonClassName = '', -}: RootProps) { + renderFooter = () => null, +}: DatePickerProps) { const [open, setOpen] = useState(false) - - const [showTime, setShowTime] = useState(true) - - const [appliedStartDate, setAppliedStartDate] = useState(null) - const [appliedEndDate, setAppliedEndDate] = useState(null) - - const [startDate, setStartDate] = useState(START_DATE_DEFAULT) - const [endDate, setEndDate] = useState(END_DATE_DEFAULT) - + const [appliedStartDate, setAppliedStartDate] = useState(null) + const [appliedEndDate, setAppliedEndDate] = useState(null) + const [startDate, setStartDate] = useState(START_DATE_DEFAULT) + const [endDate, setEndDate] = useState(END_DATE_DEFAULT) const [startTime, setStartTime] = useState(START_TIME_DEFAULT) const [endTime, setEndTime] = useState(END_TIME_DEFAULT) useEffect(() => { - if (!to || !from) { + if (!from) { setAppliedStartDate(null) + } else if (from !== appliedStartDate?.toISOString()) { + const start = dayjs(from).toDate() + setAppliedStartDate(start) + setStartDate(start) + } + + if (!to) { setAppliedEndDate(null) + } else if (to !== appliedEndDate?.toISOString()) { + const end = dayjs(to).toDate() + setAppliedEndDate(end) + setEndDate(end) } }, [to, from]) @@ -113,168 +109,77 @@ function _DatePicker({ setAppliedStartDate(null) setAppliedEndDate(null) } - - const DEFAULT_DATE_FORMAT = 'ddd MMM YYYY HH:mm:ss' - return ( - <> - setOpen(e)} - size="small" - align="center" - side="bottom" - header={ - <> -
-
- -
-
setOpen(e)} + size="small" + align="center" + side="bottom" + header={ + <> +
+
+ +
+
- -
-
- -
+ > +
- - } - overlay={ - <> -
- { - handleDatePickerChange(dates) - }} - dateFormat="MMMM d, yyyy h:mm aa" +
+ } - // dateFormat={DEFAULT_DATE_FORMAT} - // dateFormat={(locale, date) => dayjs(date).format('MM-YYYY')} - inline - dayClassName={() => 'cursor-pointer'} - renderCustomHeader={({ - date, - decreaseMonth, - increaseMonth, - prevMonthButtonDisabled, - nextMonthButtonDisabled, - }) => ( -
-
- - {format(date, 'MMMM yyyy')} - -
-
- )} />
- - -
- - -
- - } - > - - - - {/*
- setEndDate(date)} - selectsEnd - startDate={startDate} - endDate={endDate} - nextMonthButtonLabel=">" - previousMonthButtonLabel="<" - popperClassName="react-datepicker-right" - customInput={} - renderCustomHeader={({ - date, - decreaseMonth, - increaseMonth, - prevMonthButtonDisabled, - nextMonthButtonDisabled, - }) => ( - <> +
+ + } + overlay={ + <> +
+ { + handleDatePickerChange(dates) + }} + dateFormat="MMMM d, yyyy h:mm aa" + startDate={startDate} + endDate={endDate} + selectsRange + inline + dayClassName={() => 'cursor-pointer'} + renderCustomHeader={({ + date, + decreaseMonth, + increaseMonth, + prevMonthButtonDisabled, + nextMonthButtonDisabled, + }) => (
- - {format(date, 'MMMM yyyy')} - + {format(date, 'MMMM yyyy')}
- - )} - /> -
-
-
- Default Components: -
- setStartDate(date)} - selectsStart - startDate={startDate} - endDate={endDate} - nextMonthButtonLabel=">" - previousMonthButtonLabel="<" - popperClassName="react-datepicker-left" - /> -
-
- setEndDate(date)} - selectsEnd - startDate={startDate} - endDate={endDate} - nextMonthButtonLabel=">" - previousMonthButtonLabel="<" - popperClassName="react-datepicker-right" - /> -
*/} - + )} + /> +
+ {renderFooter({ + from: startDate?.toISOString() || null, + to: endDate?.toISOString() || null, + })} + +
+ + +
+ + } + > + + ) } -const ButtonInput = forwardRef(({ value, onClick }: any, ref): any => ( - -)) - export default _DatePicker diff --git a/studio/components/ui/DatePicker/index.tsx b/studio/components/ui/DatePicker/index.tsx index 25865361516..cb250378e19 100644 --- a/studio/components/ui/DatePicker/index.tsx +++ b/studio/components/ui/DatePicker/index.tsx @@ -1 +1,2 @@ export { default as DatePicker } from './DatePicker' +export type {DatePickerProps} from "./DatePicker" \ No newline at end of file diff --git a/studio/hooks/analytics/useLogsPreview.tsx b/studio/hooks/analytics/useLogsPreview.tsx index b372099b09d..3d01a59f8c8 100644 --- a/studio/hooks/analytics/useLogsPreview.tsx +++ b/studio/hooks/analytics/useLogsPreview.tsx @@ -5,17 +5,22 @@ import { genCountQuery, genDefaultQuery, genQueryParams, + getDefaultHelper, LogData, Logs, LogsEndpointParams, LogsTableName, + PREVIEWER_DATEPICKER_HELPERS, } from 'components/interfaces/Settings/Logs' import { Dispatch, SetStateAction, useEffect, useState } from 'react' import useSWR from 'swr' import useSWRInfinite, { SWRInfiniteKeyLoader } from 'swr/infinite' import { API_URL } from 'lib/constants' import { get } from 'lib/common/fetch' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' +dayjs.extend(utc) interface Data { logData: LogData[] error: string | Object | null @@ -30,14 +35,14 @@ interface Handlers { loadOlder: () => void refresh: () => void setFilters: (filters: Filters | ((previous: Filters) => Filters)) => void - setFrom: (value: string) => void - setTo: (value: string) => void + setParams: Dispatch> } function useLogsPreview( projectRef: string, table: LogsTableName, filterOverride?: Filters ): [Data, Handlers] { + const defaultHelper = getDefaultHelper(PREVIEWER_DATEPICKER_HELPERS) const [latestRefresh, setLatestRefresh] = useState(new Date().toISOString()) const [filters, setFilters] = useState({ ...filterOverride }) @@ -46,10 +51,8 @@ function useLogsPreview( project: projectRef, sql: '', rawSql: '', - period_start: '', - period_end: '', - timestamp_start: '', - timestamp_end: '', + iso_timestamp_start: defaultHelper.calcFrom(), + iso_timestamp_end: defaultHelper.calcTo(), }) useEffect(() => { @@ -79,8 +82,9 @@ function useLogsPreview( } else { const len = prevPageData.result.length const { timestamp: tsLimit }: LogData = prevPageData.result[len - 1] + const isoTsLimit = dayjs.utc(Number(tsLimit / 1000)).toISOString() // create new key from params - queryParams = genQueryParams({ ...params, timestamp_end: String(tsLimit) } as any) + queryParams = genQueryParams({ ...params, iso_timestamp_end: isoTsLimit } as any) } return `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${queryParams}` } @@ -105,7 +109,7 @@ function useLogsPreview( return `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${genQueryParams({ ...params, sql: genCountQuery(table), - period_start: String(latestRefresh), + iso_timestamp_start: latestRefresh, } as any)}` } @@ -157,11 +161,10 @@ function useLogsPreview( oldestTimestamp: oldestTimestamp ? String(oldestTimestamp) : undefined, }, { - setFrom: (value) => setParams((prev) => ({ ...prev, timestamp_start: value })), - setTo: (value) => setParams((prev) => ({ ...prev, timestamp_end: value })), setFilters: handleSetFilters, refresh, loadOlder: () => setSize((prev) => prev + 1), + setParams, }, ] } diff --git a/studio/hooks/analytics/useLogsQuery.tsx b/studio/hooks/analytics/useLogsQuery.tsx index a9523b3951c..3fe3dc72655 100644 --- a/studio/hooks/analytics/useLogsQuery.tsx +++ b/studio/hooks/analytics/useLogsQuery.tsx @@ -1,9 +1,13 @@ -import { cleanQuery, genQueryParams } from 'components/interfaces/Settings/Logs' +import { + cleanQuery, + EXPLORER_DATEPICKER_HELPERS, + genQueryParams, + getDefaultHelper, +} from 'components/interfaces/Settings/Logs' import { Dispatch, SetStateAction, useState } from 'react' import { LogsEndpointParams, Logs, LogData } from 'components/interfaces/Settings/Logs/Logs.types' -import useSWRInfinite, { SWRInfiniteKeyLoader } from 'swr/infinite/dist/infinite' import { API_URL } from 'lib/constants' -import useSWR, { mutate } from 'swr' +import useSWR from 'swr' import { get } from 'lib/common/fetch' interface Data { params: LogsEndpointParams @@ -14,18 +18,20 @@ interface Data { interface Handlers { changeQuery: (newQuery?: string) => void runQuery: () => void + setParams: Dispatch> } const useLogsQuery = ( projectRef: string, initialParams: Partial = {} ): [Data, Handlers] => { + const defaultHelper = getDefaultHelper(EXPLORER_DATEPICKER_HELPERS) const [params, setParams] = useState({ project: projectRef, sql: '', rawSql: '', - // timestamp_start: '', - // timestamp_end: '', + iso_timestamp_start: defaultHelper.calcFrom(), + iso_timestamp_end: defaultHelper.calcTo(), ...initialParams, }) @@ -36,7 +42,9 @@ const useLogsQuery = ( isValidating: isLoading, mutate, } = useSWR( - params.sql ? `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${queryParams}` : null, + params.sql + ? `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${queryParams}` + : null, get, { revalidateOnFocus: false } ) @@ -51,7 +59,7 @@ const useLogsQuery = ( return [ { params, isLoading, logData: data?.result ? data?.result : [], error }, - { changeQuery, runQuery: () => mutate() }, + { changeQuery, runQuery: () => mutate(), setParams }, ] } export default useLogsQuery diff --git a/studio/pages/project/[ref]/logs-explorer/index.tsx b/studio/pages/project/[ref]/logs-explorer/index.tsx index 85059a869e4..026f43bf5ec 100644 --- a/studio/pages/project/[ref]/logs-explorer/index.tsx +++ b/studio/pages/project/[ref]/logs-explorer/index.tsx @@ -2,12 +2,15 @@ import React, { useEffect, useState } from 'react' import { NextPage } from 'next' import { useRouter } from 'next/router' import { observer } from 'mobx-react-lite' -import { Typography, IconAlertCircle, Card, Input, Alert, Modal, Form, Button } from '@supabase/ui' +import { Input, Modal, Form, Button } from '@supabase/ui' import { useStore, withAuth } from 'hooks' import CodeEditor from 'components/ui/CodeEditor' import { + DatePickerToFrom, LogsQueryPanel, LogsTableName, + LogsWarning, + LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD, LogTable, LogTemplate, TEMPLATES, @@ -17,37 +20,58 @@ import useLogsQuery from 'hooks/analytics/useLogsQuery' import { LogsExplorerLayout } from 'components/layouts' import ShimmerLine from 'components/ui/ShimmerLine' import LoadingOpacity from 'components/ui/LoadingOpacity' -import { LogSqlSnippets, UserContent } from 'types' +import { UserContent } from 'types' import toast from 'react-hot-toast' +import dayjs from 'dayjs' export const LogsExplorerPage: NextPage = () => { const router = useRouter() - const { ref, q } = router.query + const { ref, q, ite, its } = router.query const [editorId, setEditorId] = useState(uuidv4()) const [editorValue, setEditorValue] = useState('') const [saveModalOpen, setSaveModalOpen] = useState(false) - + const [warnings, setWarnings] = useState([]) const { content } = useStore() - const [{ logData, error, isLoading }, { changeQuery, runQuery }] = useLogsQuery(ref as string) + const [{ params, logData, error, isLoading }, { changeQuery, runQuery, setParams }] = + useLogsQuery(ref as string, { + iso_timestamp_start: (its || '') as string, + iso_timestamp_end: (ite || '') as string, + }) useEffect(() => { // on mount, set initial values - if (q !== undefined && q !== '') { - changeQuery(q as string) - runQuery() + if (q) { onSelectTemplate({ mode: 'custom', searchString: q as string, }) } - }, [q]) + }, []) + + useEffect(() => { + let newWarnings = [] + const start = params.iso_timestamp_start ? dayjs(params.iso_timestamp_start) : dayjs() + const end = params.iso_timestamp_end ? dayjs(params.iso_timestamp_end) : dayjs() + const daysDiff = Math.abs(start.diff(end, 'days')) + if ( + editorValue && + !editorValue.includes('limit') && + daysDiff > LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD + ) { + newWarnings.push({ text: 'When querying large date ranges, include a LIMIT clause.' }) + } + setWarnings(newWarnings) + }, [editorValue, params.iso_timestamp_start, params.iso_timestamp_end]) const onSelectTemplate = (template: LogTemplate) => { setEditorValue(template.searchString) changeQuery(template.searchString) setEditorId(uuidv4()) - runQuery() + router.push({ + pathname: router.pathname, + query: { ...router.query, q: template.searchString }, + }) } const handleRun = () => { @@ -74,11 +98,26 @@ export const LogsExplorerPage: NextPage = () => { setSaveModalOpen(!saveModalOpen) } + const handleDateChange = ({ to, from }: DatePickerToFrom) => { + setParams((prev) => ({ + ...prev, + iso_timestamp_start: from || '', + iso_timestamp_end: to || '', + })) + router.push({ + pathname: router.pathname, + query: { ...router.query, its: from || '', ite: to || '' }, + }) + } + return (
{ onSelectTemplate={onSelectTemplate} onSave={handleOnSave} isLoading={isLoading} + warnings={warnings} />
diff --git a/studio/tests/pages/projects/LogTable.test.js b/studio/tests/pages/projects/LogTable.test.js index 56b596396fc..458aa84a6f0 100644 --- a/studio/tests/pages/projects/LogTable.test.js +++ b/studio/tests/pages/projects/LogTable.test.js @@ -63,3 +63,17 @@ test('toggle histogram', async () => { userEvent.click(toggle) expect(mockFn).toBeCalled() }) + + +test('error message handling', async () => { + const {rerender} = render() + await expect(screen.findByText("some \nstring")).rejects.toThrow() + await screen.findByDisplayValue(/some/) + await screen.findByDisplayValue(/string/) + + rerender() + await screen.findByText(/some \\nstring/) + await screen.findByText(/some/) + await screen.findByText(/string/) + await screen.findByText(/my_error/) +}) diff --git a/studio/tests/pages/projects/Logs.Datepickers.test.js b/studio/tests/pages/projects/Logs.Datepickers.test.js new file mode 100644 index 00000000000..2a578d480fb --- /dev/null +++ b/studio/tests/pages/projects/Logs.Datepickers.test.js @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { PREVIEWER_DATEPICKER_HELPERS } from 'components/interfaces/Settings/Logs' +import DatePickers from 'components/interfaces/Settings/Logs/Logs.DatePickers' +import dayjs from 'dayjs' + +test('renders warning', async () => { + const from = dayjs().subtract(60, 'days') + const to = dayjs() + render( + + ) + userEvent.click(await screen.findByText(RegExp(from.format('DD MMM')))) + await screen.findByText(/memory errors/) +}) diff --git a/studio/tests/pages/projects/Logs.utils.test.js b/studio/tests/pages/projects/Logs.utils.test.js index 053d45e670a..eb3d0a56fe1 100644 --- a/studio/tests/pages/projects/Logs.utils.test.js +++ b/studio/tests/pages/projects/Logs.utils.test.js @@ -1,6 +1,19 @@ -import { genDefaultQuery, LogsTableName } from 'components/interfaces/Settings/Logs' +import { + genDefaultQuery, + LogsTableName, + SQL_FILTER_TEMPLATES, +} from 'components/interfaces/Settings/Logs' describe.each(Object.values(LogsTableName))('%s', (table) => { + const templates = SQL_FILTER_TEMPLATES[table] + const stringTemplateKey = Object.keys(templates).find((key) => { + if (typeof templates[key] === 'string' && key.split('.').length === 2) { + return true + } else { + false + } + }) + const [root, child] = (stringTemplateKey || '').split('.') describe.each([ { filter: { search_query: '' } }, { filter: { search_query: undefined } }, @@ -27,15 +40,25 @@ describe.each(Object.values(LogsTableName))('%s', (table) => { filter: { 'override.nested': 'something' }, contains: ['override.nested', 'something'], }, - ])('generates sql for filter $filter', ({ filter, contains = [] }) => { + // check for when string templates are set to false. + ...(stringTemplateKey + ? [ + { + filter: { search_query: 'some-search-query', [root]: { [child]: false } }, + excludes: [templates[stringTemplateKey], '()'], + contains: ['some-search-query'], + }, + ] + : []), + ])('generates sql for filter $filter', ({ filter, contains = [], excludes = [] }) => { test('generates query correctly', async () => { const generated = genDefaultQuery(table, filter) // should not contain functions expect(generated).not.toMatch(/function[ A-Za-z_-]+\(.+\).+\{.+return.+\}/) expect(generated).not.toMatch(/\=\>|\$\{.+\}/) expect(generated).not.toContain('return') - contains.forEach((str) => expect(generated).toContain(str)) + excludes.forEach((str) => expect(generated).not.toContain(str)) }) }) }) diff --git a/studio/tests/pages/projects/LogsPreviewer.test.js b/studio/tests/pages/projects/LogsPreviewer.test.js index 9b736329441..9a41d6d7978 100644 --- a/studio/tests/pages/projects/LogsPreviewer.test.js +++ b/studio/tests/pages/projects/LogsPreviewer.test.js @@ -170,40 +170,36 @@ test('te= query param will populate the timestamp to input', async () => { // get time 20 mins before const newDate = new Date() newDate.setMinutes(new Date().getMinutes() - 20) - const isoString = newDate.toISOString() - const unixMicro = newDate.getTime() * 1000 //microseconds + const iso = newDate.toISOString() const router = defaultRouterMock() - router.query = { ...router.query, te: unixMicro } + router.query = { ...router.query, ite: iso } useRouter.mockReturnValue(router) render() await waitFor(() => { expect(get).toHaveBeenCalledWith( - expect.stringContaining(`timestamp_end=${encodeURIComponent(unixMicro)}`) + expect.stringContaining(`iso_timestamp_end=${encodeURIComponent(iso)}`) ) }) userEvent.click(await screen.findByText('Custom')) - expect(get).toHaveBeenCalledWith(expect.stringContaining('timestamp_end=' + unixMicro)) }) test('ts= query param will populate the timestamp from input', async () => { // get time 20 mins before const newDate = new Date() newDate.setMinutes(new Date().getMinutes() - 20) - const unixMicro = newDate.getTime() * 1000 //microseconds + const iso = newDate.toISOString() const router = defaultRouterMock() - router.query = { ...router.query, ts: unixMicro } + router.query = { ...router.query, its: iso } useRouter.mockReturnValue(router) render() await waitFor(() => { expect(get).toHaveBeenCalledWith( - expect.stringContaining(`timestamp_start=${encodeURIComponent(unixMicro)}`) + expect.stringContaining(`iso_timestamp_start=${encodeURIComponent(iso)}`) ) }) userEvent.click(await screen.findByText('Custom')) await screen.findByText(new RegExp(newDate.getFullYear())) - await screen.findByText(/Apply/) - expect(get).toHaveBeenCalledWith(expect.stringContaining('timestamp_start=' + unixMicro)) }) test('load older btn will fetch older logs', async () => { @@ -284,14 +280,12 @@ test('bug: nav backwards with params change results in ui changing', async () => await screen.findByDisplayValue('simple-query') }) -test("bug: nav to explorer preserves newlines", async ()=>{ - render( - - ) +test('bug: nav to explorer preserves newlines', async () => { + render() const router = useRouter() userEvent.click(await screen.findByText(/Explore/)) - await expect(router.push).toBeCalledWith(expect.stringContaining(encodeURIComponent("\n"))) -} + await expect(router.push).toBeCalledWith(expect.stringContaining(encodeURIComponent('\n'))) +}) test('filters alter generated query', async () => { render() userEvent.click(await screen.findByRole('button', { name: 'Status' })) diff --git a/studio/tests/pages/projects/LogsQueryPanel.test.js b/studio/tests/pages/projects/LogsQueryPanel.test.js index 0eeb11152c5..5c3e8614eea 100644 --- a/studio/tests/pages/projects/LogsQueryPanel.test.js +++ b/studio/tests/pages/projects/LogsQueryPanel.test.js @@ -14,7 +14,7 @@ test.todo('templates') test('run and clear', async () => { const mockRun = jest.fn() const mockClear = jest.fn() - render() + render() await expect(screen.findByPlaceholderText(/Search/)).rejects.toThrow() const run = await screen.findByText(/Run/) userEvent.click(run) @@ -22,5 +22,4 @@ test('run and clear', async () => { const clear = await screen.findByText(/Clear/) userEvent.click(clear) expect(mockClear).toBeCalled() - }) diff --git a/studio/tests/pages/projects/PreviewFilterPanel.test.js b/studio/tests/pages/projects/PreviewFilterPanel.test.js index 2165d4b7261..b478aabcacf 100644 --- a/studio/tests/pages/projects/PreviewFilterPanel.test.js +++ b/studio/tests/pages/projects/PreviewFilterPanel.test.js @@ -50,10 +50,10 @@ test('Manual refresh', async () => { }) test('Datepicker dropdown', async () => { render() - clickDropdown(await screen.findByText(/Last hour/)) + clickDropdown(await screen.findByText(/Last day/)) userEvent.click(await screen.findByText(/Last 3 hours/)) await screen.findByText(/Last 3 hours/) - await expect(screen.findByText(/Last hour/)).rejects.toThrow() + await expect(screen.findByText(/Last day/)).rejects.toThrow() }) test.todo('timestamp to/from filter default value') diff --git a/studio/tests/pages/projects/logs-query.test.js b/studio/tests/pages/projects/logs-query.test.js index 3979f81ef69..a08cf7ba040 100644 --- a/studio/tests/pages/projects/logs-query.test.js +++ b/studio/tests/pages/projects/logs-query.test.js @@ -62,7 +62,8 @@ const LogsExplorerPage = (props) => ( import { render, fireEvent, waitFor, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { logDataFixture } from '../../fixtures' - +import { clickDropdown } from 'tests/helpers' +import dayjs from 'dayjs' beforeEach(() => { // reset mocks between tests get.mockReset() @@ -80,7 +81,7 @@ test('can display log data', async () => { }), ], }) - const {container} = render() + const { container } = render() let editor = container.querySelector('.monaco-editor') await waitFor(() => { editor = container.querySelector('.monaco-editor') @@ -90,7 +91,7 @@ test('can display log data', async () => { userEvent.type(editor, 'select \ncount(*) as my_count \nfrom edge_logs') userEvent.click(await screen.findByText(/Run/)) - const row = await screen.findByText("some-event-happened") + const row = await screen.findByText('some-event-happened') userEvent.click(row) await screen.findByText(/something_value/) }) @@ -105,6 +106,27 @@ test('q= query param will populate the query input', async () => { expect(get).toHaveBeenCalledWith(expect.stringContaining('sql=some_query')) }) }) +test('ite= and its= query param will populate the datepicker', async () => { + const router = defaultRouterMock() + const start = dayjs().subtract(1, 'day') + const end = dayjs() + router.query = { + ...router.query, + type: 'api', + q: 'some_query', + its: start.toISOString(), + ite: end.toISOString(), + } + useRouter.mockReturnValue(router) + render() + // should populate editor with the query param + await waitFor(() => { + expect(get).toHaveBeenCalledWith( + expect.stringContaining(encodeURIComponent(start.toISOString())) + ) + expect(get).toHaveBeenCalledWith(expect.stringContaining(encodeURIComponent(end.toISOString()))) + }) +}) test('custom sql querying', async () => { get.mockImplementation((url) => { @@ -155,3 +177,31 @@ test('custom sql querying', async () => { // should not see chronological features await expect(screen.findByText(/Load older/)).rejects.toThrow() }) + +test('datepicker interaction updates query params', async () => { + render() + clickDropdown(await screen.findByText(/Last day/)) + userEvent.click(await screen.findByText(/Last 3 days/)) + + const router = useRouter() + expect(router.push).toBeCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + its: expect.any(String), + }), + }) + ) +}) + +test('query warnings', async () => { + const router = defaultRouterMock() + router.query = { + ...router.query, + q: 'some_query', + its: dayjs().subtract(10, 'days').toISOString(), + ite: dayjs().toISOString(), + } + useRouter.mockReturnValue(router) + render() + await screen.findByText('1 warning') +}) diff --git a/www/_blog/2022-04-20-partner-gallery-works-with-supabase.mdx b/www/_blog/2022-04-20-partner-gallery-works-with-supabase.mdx new file mode 100644 index 00000000000..eaea01bbd94 --- /dev/null +++ b/www/_blog/2022-04-20-partner-gallery-works-with-supabase.mdx @@ -0,0 +1,218 @@ +--- +title: Works With Supabase - announcing our Partner Gallery +description: Introducing our Partner Gallery - open source and made with Supabase. +author: alaister,shanerice,thor_schaeff +image: partner-gallery/partner-showcase-thumb.png +thumb: partner-gallery/partner-showcase-thumb.png +tags: + - community + - partnerships +date: '2022-04-20' +toc_depth: 3 +--- + +# Introducing our Partner Gallery - open source and made with Supabase + +Supabase is a collaborative effort across many open-source projects, and we’re incredibly fortunate that you all have rallied around us to make Supabase what it is today. + +To keep track of all the great tools and people that “Work With Supabase”, we’ve built a [Partner Gallery](/partners) that showcases integration partners who are extending how Supabase works, as well as experts who are certified to help you build new, exciting things. + +![partner-gallery-screenshot.png](/images/blog/partner-gallery/partner-gallery-screenshot.png) + +As with anything at Supabase, the Partner Gallery is built on top of the [Supabase stack](/docs/architecture) and is, of course, open-source. If you’re looking to build a partner gallery yourself, feel free to use our template as a start: [github.com/supabase-community/partner-gallery-example](https://github.com/supabase-community/partner-gallery-example). It’s packed with goodies like [Postgres Full Text Search](/docs/guides/database/full-text-search), `next-image` with Supabase Storage, and sending emails based on an insert trigger with Supabase Edge Functions. + +# Partner showcase - new integrations and expert services + + + +## Expert services + +Some agencies and developer experts have specialized experience building with Supabase. If you’re looking for the right partner to bring your application to life, check out the experts on [supabase.com/partners/experts](/partners/experts). + +If you have expertise developing with Supabase and want to offer your expert services, you can [apply to become a partner today](/partners/integrations#become-a-partner). + +Please join us in welcoming our first two experts! + +### Morrow + + + +Morrow is an app development agency headquartered in Bristol, UK. Their mission is to consult, build and support cutting-edge innovation. + +Their specialisms are working with Supabase, React Native, Netlify and Swell to produce bespoke solutions that are robust and beautiful. + +Morrow was awarded UK App Dev Agency of the Year 2022. + +### Quinence + +![Quinence](/images/blog/partner-gallery/expert-quinence.png) + +Quinence is a boutique product development studio from Singapore. + +They build digital products that delight. You give them an idea, and they can deliver the complete experience with hyper-scalable infrastructure. + +## Integrations + +There are so many fantastic tools out there making it easier than ever to build stunning applications or helping you with a specific workflow. We’re ecstatic that many of them chose to integrate with Supabase to give you even more superpowers. + +We’re starting off with a selection across auth, developer tools, and low-code tools. If you’ve built a tool that integrates with Supabase, we’d love to [hear from you](/partners/integrations#become-a-partner)! + +## Auth + +### Clerk + + + +Clerk authenticates users, manages session tokens, and provides user management functionality that can be used in combination with the authorization logic available in Supabase through PostgreSQL Row Level Security (RLS) policies. + +### Stytch + +![Stytch](/images/blog/partner-gallery/integration-stytch.png) + +Stytch provides an all-in-one platform for passwordless auth. Stytch makes it easy for you to embed passwordless solutions into your websites and apps for better security, better conversion rates, and a better end user experience. + +## DevTools + +### Prisma + + + +Prisma is an open source next-generation ORM. It consists of the following parts: + +- Prisma Client: Auto-generated and type-safe query builder for Node.js & TypeScript. +- Prisma Migrate: Migration system. +- Prisma Studio: GUI to view and edit data in your database. + +### Snaplet + + + +Snaplet is a developer tool that copies a Postgres database, transforming personal information, so that you can safely code against actual data. This functionality makes it possible to easily achieve environment parity in Supabase. + +### Vercel + +![Vercel](/images/blog/partner-gallery/integration-vercel.png) + +Vercel enables developers to build and publish wonderful things. They build products for developers and designers. And those who aspire to become one. This makes it the perfect tool for building web apps with Supabase. + +### pgMustard + + + +pgMustard speeds up your journey from knowing which query is a problem to working out what can be done about it. + +Reading EXPLAIN ANALYZE output can be tough. + +Even if you know how, Postgres includes so much useful information, it is easy to miss something important. And for queries that do a lot, the plans are even more useful-yet-time-consuming. + +## Low-Code + +### Clutch + + + +What can I achieve with Clutch & Supabase? Anything you can think of where a database would be necessary for your project. Examples include building a blog, real-time chat app, booking platform, and todo list, Supabase, and Clutch provides all the necessary tools to build your projects quickly and efficiently. + +### Appsmith + + + +Appsmith is an open-source framework for building internal tools. It lets you drag-and-drop UI components to build pages, connect to any API, database or GraphQL source and write logic with JavaScript objects. + +### Draftbit + +![Draftbit](/images/blog/partner-gallery/integration-draftbit.png) + +Draftbit is a "pro-code" low-code mobile app building platform. Draftbit exports React Native source code that is 100% run on open-source languages and libraries. + +### Plasmic + + + +By connecting Supabase with Plasmic, a visual builder for the web, you can build data-backed applications without code. While many users leverage Plasmic to quickly launch and iterate on landing pages, Plasmic can be used as a general-purpose visual builder for React, which can be used to design and implement fully featured read-write applications. + +### Teta + + + +Teta is an online cooperative app builder. It allows designers and developers to collaborate in real-time on the design and development of mobile applications, through a convenient visual interface that allows the export of Flutter code. diff --git a/www/lib/authors.json b/www/lib/authors.json index e884a8cead3..26ed81b8b90 100644 --- a/www/lib/authors.json +++ b/www/lib/authors.json @@ -120,5 +120,21 @@ "position": "Engineering", "author_url": "https://github.com/dthyresson", "author_image_url": "https://github.com/dthyresson.png" + }, + { + "author_id": "shanerice", + "author": "Shane Rice", + "username": "shanerice", + "position": "Marketing", + "author_url": "https://github.com/shanerice", + "author_image_url": "https://github.com/shanerice.png" + }, + { + "author_id": "alaister", + "author": "Alaister Young", + "username": "alaister", + "position": "Engineering", + "author_url": "https://github.com/alaister", + "author_image_url": "https://github.com/alaister.png" } ] diff --git a/www/public/images/blog/partner-gallery/expert-quinence.png b/www/public/images/blog/partner-gallery/expert-quinence.png new file mode 100644 index 00000000000..88237a3a556 Binary files /dev/null and b/www/public/images/blog/partner-gallery/expert-quinence.png differ diff --git a/www/public/images/blog/partner-gallery/integration-draftbit.png b/www/public/images/blog/partner-gallery/integration-draftbit.png new file mode 100644 index 00000000000..75283abc08b Binary files /dev/null and b/www/public/images/blog/partner-gallery/integration-draftbit.png differ diff --git a/www/public/images/blog/partner-gallery/integration-stytch.png b/www/public/images/blog/partner-gallery/integration-stytch.png new file mode 100644 index 00000000000..c61fabae6b0 Binary files /dev/null and b/www/public/images/blog/partner-gallery/integration-stytch.png differ diff --git a/www/public/images/blog/partner-gallery/integration-vercel.png b/www/public/images/blog/partner-gallery/integration-vercel.png new file mode 100644 index 00000000000..e5f420918c0 Binary files /dev/null and b/www/public/images/blog/partner-gallery/integration-vercel.png differ diff --git a/www/public/images/blog/partner-gallery/partner-gallery-screenshot.png b/www/public/images/blog/partner-gallery/partner-gallery-screenshot.png new file mode 100644 index 00000000000..dc5844bb30a Binary files /dev/null and b/www/public/images/blog/partner-gallery/partner-gallery-screenshot.png differ diff --git a/www/public/images/blog/partner-gallery/partner-showcase-intro.png b/www/public/images/blog/partner-gallery/partner-showcase-intro.png new file mode 100644 index 00000000000..c88f7f17bef Binary files /dev/null and b/www/public/images/blog/partner-gallery/partner-showcase-intro.png differ diff --git a/www/public/images/blog/partner-gallery/partner-showcase-thumb.png b/www/public/images/blog/partner-gallery/partner-showcase-thumb.png new file mode 100644 index 00000000000..b81458ce748 Binary files /dev/null and b/www/public/images/blog/partner-gallery/partner-showcase-thumb.png differ diff --git a/www/public/rss.xml b/www/public/rss.xml index df51ba5771c..d0f17d88f03 100644 --- a/www/public/rss.xml +++ b/www/public/rss.xml @@ -5,9 +5,17 @@ https://supabase.com Latest news from Supabase en - Sun, 17 Apr 2022 16:00:00 GMT + Tue, 19 Apr 2022 16:00:00 GMT + + https://supabase.com/blog/2022/04/20/partner-gallery-works-with-supabase + Works With Supabase - announcing our Partner Gallery + https://supabase.com/blog/2022/04/20/partner-gallery-works-with-supabase + Introducing our Partner Gallery - open source and made with Supabase. + Tue, 19 Apr 2022 16:00:00 GMT + + https://supabase.com/blog/2022/04/18/bring-the-func-hackathon-winners Bring the Func Hackathon Winners 2022 @@ -15,7 +23,7 @@ Celebrating many amazing OSS Hackathon projects using GraphQL and Edge Functions. Sun, 17 Apr 2022 16:00:00 GMT - + https://supabase.com/blog/2022/04/15/beta-update-march-2022 Supabase Beta March 2022 diff --git a/www/public/sitemap_www.xml b/www/public/sitemap_www.xml index 6b86fd7a2a0..170d40bb3ef 100644 --- a/www/public/sitemap_www.xml +++ b/www/public/sitemap_www.xml @@ -613,4 +613,40 @@ weekly 0.5 + + + https://supabase.com/blog/2022/04/01/hackathon-bring-the-func + weekly + 0.5 + + + + https://supabase.com/blog/2022/04/01/supabase-realtime-with-multiplayer-features + weekly + 0.5 + + + + https://supabase.com/blog/2022/04/01/supabrew + weekly + 0.5 + + + + https://supabase.com/blog/2022/04/15/beta-update-march-2022 + weekly + 0.5 + + + + https://supabase.com/blog/2022/04/18/bring-the-func-hackathon-winners + weekly + 0.5 + + + + https://supabase.com/blog/2022/04/20/partner-gallery-works-with-supabase + weekly + 0.5 +