Pull master and resolve conflicts
No files matched your search
@@ -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 @@
|
||||
நாங்கள் தற்போது பொது பீட்டாவில் இருக்கிறோம். முக்கிய துப்பிப்புகளைப் பற்றி அறிவிக்க இந்த ரெப்போவின் "வெளியீடுகளைப்" பாருங்கள்.
|
||||
|
||||
<kbd><img src="https://gitcdn.link/repo/supabase/supabase/master/web/static/watch-repo.gif" alt="இந்த ரெப்போவைப் பாருங்கள்"/></kbd>
|
||||
|
||||
|
||||
---
|
||||
|
||||
## இது எவ்வாறு செயல்படுகிறது
|
||||
@@ -182,8 +182,8 @@
|
||||
</table>
|
||||
|
||||
## மொழிபெயர்ப்புகள்
|
||||
- [மொழிபெயர்ப்புகளின் பட்டியல்](/i18n/languages.md) <!--- Keep only this -->
|
||||
|
||||
- [மொழிபெயர்ப்புகளின் பட்டியல்](/i18n/languages.md) <!--- Keep only this -->
|
||||
|
||||
## ஸ்பான்சர்கள்
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
<div className="py-4 w-full h-full flex-col space-y-12">
|
||||
<div
|
||||
className={[
|
||||
'transition-all duration-500 delay-200',
|
||||
'flex flex-col items-center gap-6 text-center',
|
||||
'mt-16 opacity-100 scale-100 justify-center',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="relative border border-scale-600 border-dashed dark:border-scale-400 w-32 h-4 rounded px-2 flex items-center"></div>
|
||||
<div className="relative border border-scale-600 border-dashed dark:border-scale-400 w-32 h-4 rounded px-2 flex items-center">
|
||||
<div className="absolute right-1 -bottom-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{!error && (
|
||||
<div
|
||||
className={`transition-all
|
||||
duration-500
|
||||
delay-200
|
||||
|
||||
flex
|
||||
flex-col
|
||||
items-center
|
||||
|
||||
gap-6
|
||||
text-center
|
||||
mt-16
|
||||
opacity-100
|
||||
scale-100
|
||||
|
||||
justify-center
|
||||
`}
|
||||
>
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="relative border border-scale-600 border-dashed dark:border-scale-400 w-32 h-4 rounded px-2 flex items-center"></div>
|
||||
<div className="relative border border-scale-600 border-dashed dark:border-scale-400 w-32 h-4 rounded px-2 flex items-center">
|
||||
<div className="absolute right-1 -bottom-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 px-5">
|
||||
<h3 className="text-lg text-scale-1200">No results</h3>
|
||||
<p className="text-sm text-scale-900">
|
||||
Try another search, or adjusting the filters
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 px-5">
|
||||
<h3 className="text-lg text-scale-1200">No results</h3>
|
||||
<p className="text-sm text-scale-900">
|
||||
Try another search, or adjusting the filters
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex justify-center px-5">
|
||||
<Alert
|
||||
variant="danger"
|
||||
title="Sorry! An error occured when fetching data."
|
||||
withIcon
|
||||
className="max-w-xl"
|
||||
className="w-1/2"
|
||||
>
|
||||
<Input.TextArea
|
||||
size="small"
|
||||
value={JSON.stringify(error, null, 2)}
|
||||
size="tiny"
|
||||
value={
|
||||
typeof error === 'string' ? error : JSON.stringify(error, null, 2)
|
||||
}
|
||||
borderless
|
||||
className="font-mono w-full mt-4"
|
||||
copy
|
||||
|
||||
@@ -1,35 +1,41 @@
|
||||
import { Button, Dropdown, IconClock } from '@supabase/ui'
|
||||
import { Alert, Button, Dropdown, IconClock } from '@supabase/ui'
|
||||
import { DatePicker } from 'components/ui/DatePicker'
|
||||
import { useState, useEffect } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { DatetimeHelper, getDefaultHelper, LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD } from '.'
|
||||
|
||||
type ToFrom = { to: string; from: string }
|
||||
interface Props {
|
||||
to?: string
|
||||
from?: string
|
||||
onChange: ({ to, from }: ToFrom) => void
|
||||
to: string
|
||||
from: string
|
||||
onChange: React.ComponentProps<typeof DatePicker>["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<Props> = ({ to, from, onChange }) => {
|
||||
const [helperValue, setHelperValue] = useState<string>('')
|
||||
|
||||
const DatePickers: React.FC<Props> = ({ to, from, onChange, helpers }) => {
|
||||
const defaultHelper = getDefaultHelper(helpers)
|
||||
const [helperValue, setHelperValue] = useState<string>(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 (
|
||||
<div className="flex items-center">
|
||||
<Dropdown
|
||||
@@ -38,12 +44,9 @@ const DatePickers: React.FC<Props> = ({ to, from, onChange }) => {
|
||||
align="start"
|
||||
overlay={
|
||||
<>
|
||||
<Dropdown.RadioGroup
|
||||
onChange={handleHelperChange}
|
||||
value={helperValue || DEFAULT_HELPER_VALUE}
|
||||
>
|
||||
{HELPERS.map((helper) => (
|
||||
<Dropdown.Radio key={helper.value} value={helper.value}>
|
||||
<Dropdown.RadioGroup onChange={handleHelperChange} value={selectedHelper?.text || ''}>
|
||||
{helpers.map((helper) => (
|
||||
<Dropdown.Radio key={helper.text} value={helper.text}>
|
||||
{helper.text}
|
||||
</Dropdown.Radio>
|
||||
))}
|
||||
@@ -57,18 +60,27 @@ const DatePickers: React.FC<Props> = ({ to, from, onChange }) => {
|
||||
icon={<IconClock size={12} />}
|
||||
className="rounded-r-none"
|
||||
>
|
||||
{selectedHelper?.text}
|
||||
{selectedHelper?.text || defaultHelper.text}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<DatePicker
|
||||
triggerButtonClassName="rounded-l-none"
|
||||
triggerButtonType={helperValue ? 'default' : 'secondary'}
|
||||
onChange={(value: ToFrom) => {
|
||||
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 (
|
||||
<Alert title={""} variant="warning" className="mx-3 pl-2 pr-2 pt-1 pb-2">
|
||||
Large ranges may result in memory errors for big projects.
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
default:
|
||||
return ''
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -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<Props> = ({
|
||||
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<Props> = ({
|
||||
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<Props> = ({
|
||||
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<Props> = ({
|
||||
<LogEventChart
|
||||
data={!isLoading ? logData : undefined}
|
||||
onBarClick={(timestampMicro) => {
|
||||
handleSearch({ query: filters.search_query as string, toMicro: timestampMicro })
|
||||
const to = unixMicroToIsoTimestamp(timestampMicro)
|
||||
handleSearch({ query: filters.search_query as string, to, from: null })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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<typeof DatePickers>['onChange']
|
||||
defaultTo: string
|
||||
defaultFrom: string
|
||||
warnings: LogsWarning[]
|
||||
}
|
||||
|
||||
const LogsQueryPanel: React.FC<Props> = ({
|
||||
@@ -21,6 +43,10 @@ const LogsQueryPanel: React.FC<Props> = ({
|
||||
onSave,
|
||||
onSelectSource,
|
||||
isLoading,
|
||||
defaultFrom,
|
||||
defaultTo,
|
||||
onDateChange,
|
||||
warnings,
|
||||
}) => (
|
||||
<div
|
||||
className="
|
||||
@@ -63,6 +89,41 @@ bg-panel-header-light dark:bg-panel-header-dark
|
||||
Templates
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<DatePickers
|
||||
to={defaultTo}
|
||||
from={defaultFrom}
|
||||
onChange={onDateChange}
|
||||
helpers={EXPLORER_DATEPICKER_HELPERS}
|
||||
/>
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
className={` transition-all duration-300 ${
|
||||
warnings.length > 0 ? 'opacity-100' : 'invisible w-0 h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<Popover
|
||||
portalled
|
||||
overlay={
|
||||
<Alert variant="warning" title="">
|
||||
<div className="flex flex-col gap-3">
|
||||
{warnings.map((warning, index) => (
|
||||
<p key={index}>
|
||||
{warning.text}{' '}
|
||||
{warning.link && (
|
||||
<Link href={warning.link}>{warning.linkText || 'View'}</Link>
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</Alert>
|
||||
}
|
||||
>
|
||||
<Badge color="yellow">
|
||||
{warnings.length} {warnings.length > 1 ? 'warnings' : 'warning'}
|
||||
</Badge>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
@@ -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<Props> = ({
|
||||
Refresh
|
||||
</Button>
|
||||
)
|
||||
|
||||
const handleSearch = (partial: Partial<{ query: string; to: string; from: string }>) =>
|
||||
onSearch({ query: search, ...partial })
|
||||
const handleSearch = (partial: Partial<Parameters<LogSearchCallback>[0]>) =>
|
||||
onSearch({ query: search, to: partial?.to || null, from: partial?.from || null })
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -147,11 +146,10 @@ const PreviewFilterPanel: FC<Props> = ({
|
||||
</form>
|
||||
|
||||
<DatePickers
|
||||
onChange={async (e: { to: string; from: string }) => {
|
||||
handleSearch(e)
|
||||
}}
|
||||
onChange={handleSearch}
|
||||
to={defaultToValue}
|
||||
from={defaultFromValue}
|
||||
helpers={PREVIEWER_DATEPICKER_HELPERS}
|
||||
/>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -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<boolean>(false)
|
||||
|
||||
const [showTime, setShowTime] = useState<boolean>(true)
|
||||
|
||||
const [appliedStartDate, setAppliedStartDate] = useState<any>(null)
|
||||
const [appliedEndDate, setAppliedEndDate] = useState<any>(null)
|
||||
|
||||
const [startDate, setStartDate] = useState<any>(START_DATE_DEFAULT)
|
||||
const [endDate, setEndDate] = useState<any>(END_DATE_DEFAULT)
|
||||
|
||||
const [appliedStartDate, setAppliedStartDate] = useState<null | Date>(null)
|
||||
const [appliedEndDate, setAppliedEndDate] = useState<null | Date>(null)
|
||||
const [startDate, setStartDate] = useState<Date | null>(START_DATE_DEFAULT)
|
||||
const [endDate, setEndDate] = useState<Date | null>(END_DATE_DEFAULT)
|
||||
const [startTime, setStartTime] = useState<any>(START_TIME_DEFAULT)
|
||||
const [endTime, setEndTime] = useState<any>(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 (
|
||||
<>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(e) => setOpen(e)}
|
||||
size="small"
|
||||
align="center"
|
||||
side="bottom"
|
||||
header={
|
||||
<>
|
||||
<div className="flex justify-between items-stretch py-2">
|
||||
<div className="grow flex flex-col gap-1">
|
||||
<TimeSplitInput
|
||||
type="start"
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
time={startTime}
|
||||
setTime={setStartTime}
|
||||
setStartTime={setStartTime}
|
||||
setEndTime={setEndTime}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(e) => setOpen(e)}
|
||||
size="small"
|
||||
align="center"
|
||||
side="bottom"
|
||||
header={
|
||||
<>
|
||||
<div className="flex justify-between items-stretch py-2">
|
||||
<div className="grow flex flex-col gap-1">
|
||||
<TimeSplitInput
|
||||
type="start"
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
time={startTime}
|
||||
setTime={setStartTime}
|
||||
setStartTime={setStartTime}
|
||||
setEndTime={setEndTime}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
w-12
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
text-scale-900
|
||||
`}
|
||||
>
|
||||
<IconArrowRight strokeWidth={1.5} size={14} />
|
||||
</div>
|
||||
<div className="grow flex flex-col gap-1">
|
||||
<TimeSplitInput
|
||||
type="end"
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
time={endTime}
|
||||
setTime={setEndTime}
|
||||
setStartTime={setStartTime}
|
||||
setEndTime={setEndTime}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
<IconArrowRight strokeWidth={1.5} size={14} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
overlay={
|
||||
<>
|
||||
<div className="px-3 py-4">
|
||||
<DatePicker
|
||||
selected={startDate}
|
||||
onChange={(dates) => {
|
||||
handleDatePickerChange(dates)
|
||||
}}
|
||||
dateFormat="MMMM d, yyyy h:mm aa"
|
||||
<div className="grow flex flex-col gap-1">
|
||||
<TimeSplitInput
|
||||
type="end"
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
time={endTime}
|
||||
setTime={setEndTime}
|
||||
setStartTime={setStartTime}
|
||||
setEndTime={setEndTime}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
selectsRange
|
||||
// showTimeSelect
|
||||
// nextMonthButtonLabel=">"
|
||||
// previousMonthButtonLabel="<"
|
||||
// popperClassName="react-datepicker-left"
|
||||
// customInput={<ButtonInput />}
|
||||
// dateFormat={DEFAULT_DATE_FORMAT}
|
||||
// dateFormat={(locale, date) => dayjs(date).format('MM-YYYY')}
|
||||
inline
|
||||
dayClassName={() => 'cursor-pointer'}
|
||||
renderCustomHeader={({
|
||||
date,
|
||||
decreaseMonth,
|
||||
increaseMonth,
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}) => (
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<button
|
||||
onClick={decreaseMonth}
|
||||
disabled={prevMonthButtonDisabled}
|
||||
type="button"
|
||||
className={`
|
||||
${prevMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
|
||||
text-scale-1100 hover:text-scale-1200 focus:outline-none
|
||||
`}
|
||||
>
|
||||
<IconChevronLeft size={16} strokeWidth={2} />
|
||||
</button>
|
||||
<span className="text-sm text-scale-1100">{format(date, 'MMMM yyyy')}</span>
|
||||
<button
|
||||
onClick={increaseMonth}
|
||||
disabled={nextMonthButtonDisabled}
|
||||
type="button"
|
||||
className={`
|
||||
${nextMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
|
||||
text-scale-1100 hover:text-scale-1200 focus:outline-none
|
||||
`}
|
||||
>
|
||||
<IconChevronRight size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Popover.Seperator />
|
||||
<div className="flex items-center justify-end gap-2 py-2 pb-4 px-3">
|
||||
<Button type="default" onClick={() => handleClear()}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button onClick={() => handleSubmit()}>Apply</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type={triggerButtonType}
|
||||
as="span"
|
||||
icon={<IconCalendar />}
|
||||
className={triggerButtonClassName}
|
||||
>
|
||||
{/* Custom */}
|
||||
{appliedStartDate && appliedEndDate && appliedStartDate !== appliedEndDate ? (
|
||||
<>
|
||||
{format(new Date(appliedStartDate), 'dd MMM')} -{' '}
|
||||
{format(new Date(appliedEndDate), 'dd MMM')}
|
||||
</>
|
||||
) : appliedStartDate || appliedEndDate ? (
|
||||
format(new Date(appliedStartDate || appliedEndDate), 'dd MMM')
|
||||
) : (
|
||||
'Custom'
|
||||
)}
|
||||
</Button>
|
||||
</Popover>
|
||||
|
||||
{/* <div className="relative w-40">
|
||||
<DatePicker
|
||||
selected={endDate}
|
||||
onChange={(date) => setEndDate(date)}
|
||||
selectsEnd
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
nextMonthButtonLabel=">"
|
||||
previousMonthButtonLabel="<"
|
||||
popperClassName="react-datepicker-right"
|
||||
customInput={<ButtonInput />}
|
||||
renderCustomHeader={({
|
||||
date,
|
||||
decreaseMonth,
|
||||
increaseMonth,
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}) => (
|
||||
<>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
overlay={
|
||||
<>
|
||||
<div className="px-3 py-4">
|
||||
<DatePicker
|
||||
selected={startDate}
|
||||
onChange={(dates) => {
|
||||
handleDatePickerChange(dates)
|
||||
}}
|
||||
dateFormat="MMMM d, yyyy h:mm aa"
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
selectsRange
|
||||
inline
|
||||
dayClassName={() => 'cursor-pointer'}
|
||||
renderCustomHeader={({
|
||||
date,
|
||||
decreaseMonth,
|
||||
increaseMonth,
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}) => (
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<button
|
||||
@@ -282,27 +187,19 @@ function _DatePicker({
|
||||
disabled={prevMonthButtonDisabled}
|
||||
type="button"
|
||||
className={`
|
||||
${
|
||||
prevMonthButtonDisabled &&
|
||||
'cursor-not-allowed opacity-50'
|
||||
}
|
||||
${prevMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
|
||||
text-scale-1100 hover:text-scale-1200 focus:outline-none
|
||||
`}
|
||||
>
|
||||
<IconChevronLeft size={16} strokeWidth={2} />
|
||||
</button>
|
||||
<span className="text-sm text-scale-1100">
|
||||
{format(date, 'MMMM yyyy')}
|
||||
</span>
|
||||
<span className="text-sm text-scale-1100">{format(date, 'MMMM yyyy')}</span>
|
||||
<button
|
||||
onClick={increaseMonth}
|
||||
disabled={nextMonthButtonDisabled}
|
||||
type="button"
|
||||
className={`
|
||||
${
|
||||
nextMonthButtonDisabled &&
|
||||
'cursor-not-allowed opacity-50'
|
||||
}
|
||||
${nextMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
|
||||
text-scale-1100 hover:text-scale-1200 focus:outline-none
|
||||
`}
|
||||
>
|
||||
@@ -310,51 +207,43 @@ function _DatePicker({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center max-w-2xl py-20 mx-auto space-x-4">
|
||||
<span className="font-medium text-gray-900">Default Components:</span>
|
||||
<div className="relative w-40">
|
||||
<DatePicker
|
||||
selected={startDate}
|
||||
onChange={(date) => setStartDate(date)}
|
||||
selectsStart
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
nextMonthButtonLabel=">"
|
||||
previousMonthButtonLabel="<"
|
||||
popperClassName="react-datepicker-left"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative w-40">
|
||||
<DatePicker
|
||||
selected={endDate}
|
||||
onChange={(date) => setEndDate(date)}
|
||||
selectsEnd
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
nextMonthButtonLabel=">"
|
||||
previousMonthButtonLabel="<"
|
||||
popperClassName="react-datepicker-right"
|
||||
/>
|
||||
</div> */}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{renderFooter({
|
||||
from: startDate?.toISOString() || null,
|
||||
to: endDate?.toISOString() || null,
|
||||
})}
|
||||
<Popover.Seperator />
|
||||
<div className="flex items-center justify-end gap-2 py-2 pb-4 px-3">
|
||||
<Button type="default" onClick={() => handleClear()}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button onClick={() => handleSubmit()}>Apply</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type={triggerButtonType}
|
||||
as="span"
|
||||
icon={<IconCalendar />}
|
||||
className={triggerButtonClassName}
|
||||
>
|
||||
{/* Custom */}
|
||||
{appliedStartDate && appliedEndDate && appliedStartDate !== appliedEndDate ? (
|
||||
<>
|
||||
{format(new Date(appliedStartDate), 'dd MMM')} -{' '}
|
||||
{format(new Date(appliedEndDate), 'dd MMM')}
|
||||
</>
|
||||
) : appliedStartDate || appliedEndDate ? (
|
||||
format(new Date((appliedStartDate || appliedEndDate)!), 'dd MMM')
|
||||
) : (
|
||||
'Custom'
|
||||
)}
|
||||
</Button>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
const ButtonInput = forwardRef(({ value, onClick }: any, ref): any => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
// @ts-ignore
|
||||
ref={ref}
|
||||
type="button"
|
||||
className="inline-flex justify-start w-full px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-0 focus:ring-blue-500"
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
))
|
||||
|
||||
export default _DatePicker
|
||||
@@ -1 +1,2 @@
|
||||
export { default as DatePicker } from './DatePicker'
|
||||
export type {DatePickerProps} from "./DatePicker"
|
||||
@@ -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<SetStateAction<LogsEndpointParams>>
|
||||
}
|
||||
function useLogsPreview(
|
||||
projectRef: string,
|
||||
table: LogsTableName,
|
||||
filterOverride?: Filters
|
||||
): [Data, Handlers] {
|
||||
const defaultHelper = getDefaultHelper(PREVIEWER_DATEPICKER_HELPERS)
|
||||
const [latestRefresh, setLatestRefresh] = useState<string>(new Date().toISOString())
|
||||
|
||||
const [filters, setFilters] = useState<Filters>({ ...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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<SetStateAction<LogsEndpointParams>>
|
||||
}
|
||||
|
||||
const useLogsQuery = (
|
||||
projectRef: string,
|
||||
initialParams: Partial<LogsEndpointParams> = {}
|
||||
): [Data, Handlers] => {
|
||||
const defaultHelper = getDefaultHelper(EXPLORER_DATEPICKER_HELPERS)
|
||||
const [params, setParams] = useState<LogsEndpointParams>({
|
||||
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<Logs>(
|
||||
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
|
||||
@@ -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<string>(uuidv4())
|
||||
const [editorValue, setEditorValue] = useState<string>('')
|
||||
const [saveModalOpen, setSaveModalOpen] = useState<boolean>(false)
|
||||
|
||||
const [warnings, setWarnings] = useState<LogsWarning[]>([])
|
||||
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 (
|
||||
<LogsExplorerLayout>
|
||||
<div className="h-full flex flex-col flex-grow gap-4">
|
||||
<div className="border rounded">
|
||||
<LogsQueryPanel
|
||||
defaultFrom={params.iso_timestamp_start || ''}
|
||||
defaultTo={params.iso_timestamp_end || ''}
|
||||
onDateChange={handleDateChange}
|
||||
onSelectSource={handleInsertSource}
|
||||
onClear={handleClear}
|
||||
onRun={handleRun}
|
||||
@@ -87,6 +126,7 @@ export const LogsExplorerPage: NextPage = () => {
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
onSave={handleOnSave}
|
||||
isLoading={isLoading}
|
||||
warnings={warnings}
|
||||
/>
|
||||
|
||||
<div className="min-h-[7rem] h-48">
|
||||
|
||||
@@ -63,3 +63,17 @@ test('toggle histogram', async () => {
|
||||
userEvent.click(toggle)
|
||||
expect(mockFn).toBeCalled()
|
||||
})
|
||||
|
||||
|
||||
test('error message handling', async () => {
|
||||
const {rerender} = render(<LogTable error="some \nstring" />)
|
||||
await expect(screen.findByText("some \nstring")).rejects.toThrow()
|
||||
await screen.findByDisplayValue(/some/)
|
||||
await screen.findByDisplayValue(/string/)
|
||||
|
||||
rerender(<LogTable error={{my_error: "some \nstring"}} />)
|
||||
await screen.findByText(/some \\nstring/)
|
||||
await screen.findByText(/some/)
|
||||
await screen.findByText(/string/)
|
||||
await screen.findByText(/my_error/)
|
||||
})
|
||||
@@ -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(
|
||||
<DatePickers
|
||||
helpers={PREVIEWER_DATEPICKER_HELPERS}
|
||||
to={to.toISOString()}
|
||||
from={from.toISOString()}
|
||||
/>
|
||||
)
|
||||
userEvent.click(await screen.findByText(RegExp(from.format('DD MMM'))))
|
||||
await screen.findByText(/memory errors/)
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
|
||||
|
||||
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(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
|
||||
|
||||
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(
|
||||
<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />
|
||||
)
|
||||
test('bug: nav to explorer preserves newlines', async () => {
|
||||
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
|
||||
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(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
|
||||
userEvent.click(await screen.findByRole('button', { name: 'Status' }))
|
||||
|
||||
@@ -14,7 +14,7 @@ test.todo('templates')
|
||||
test('run and clear', async () => {
|
||||
const mockRun = jest.fn()
|
||||
const mockClear = jest.fn()
|
||||
render(<LogsQueryPanel onRun={mockRun} onClear={mockClear} hasEditorValue/>)
|
||||
render(<LogsQueryPanel warnings={[]} onRun={mockRun} onClear={mockClear} hasEditorValue />)
|
||||
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()
|
||||
|
||||
})
|
||||
@@ -50,10 +50,10 @@ test('Manual refresh', async () => {
|
||||
})
|
||||
test('Datepicker dropdown', async () => {
|
||||
render(<PreviewFilterPanel />)
|
||||
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')
|
||||
|
||||
@@ -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(<LogsExplorerPage />)
|
||||
const { container } = render(<LogsExplorerPage />)
|
||||
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(<LogsExplorerPage />)
|
||||
// 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(<LogsExplorerPage />)
|
||||
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(<LogsExplorerPage />)
|
||||
await screen.findByText('1 warning')
|
||||
})
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
<iframe
|
||||
className="w-full"
|
||||
width="700"
|
||||
height="350"
|
||||
src="https://www.youtube-nocookie.com/embed/videoseries?list=PL5S4mPUpp4OtKHdiuNkt8GewVo3dc-oHW"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
||||
## 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
|
||||
|
||||
<iframe
|
||||
className="w-full"
|
||||
width="700"
|
||||
height="350"
|
||||
src="https://www.youtube-nocookie.com/embed/uPN5BbIuJw8"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
||||
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 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
|
||||
|
||||
<iframe
|
||||
className="w-full"
|
||||
width="700"
|
||||
height="350"
|
||||
src="https://www.youtube-nocookie.com/embed/vLSynn8_SN4"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
||||
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 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
|
||||
|
||||
<iframe
|
||||
className="w-full"
|
||||
width="700"
|
||||
height="350"
|
||||
src="https://www.youtube-nocookie.com/embed/YjvQsNEOs60"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
||||
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
|
||||
|
||||
<iframe
|
||||
className="w-full"
|
||||
width="700"
|
||||
height="350"
|
||||
src="https://www.youtube-nocookie.com/embed/64Z6Ku0t334"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
||||
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 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
|
||||
|
||||
<iframe
|
||||
className="w-full"
|
||||
width="700"
|
||||
height="350"
|
||||
src="https://www.youtube-nocookie.com/embed/IGYcN1Hvc84"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
||||
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
|
||||
|
||||
<iframe
|
||||
className="w-full"
|
||||
width="700"
|
||||
height="350"
|
||||
src="https://www.youtube-nocookie.com/embed/ZZE9BabdTxE"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
||||
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
|
||||
|
||||
<iframe
|
||||
className="w-full"
|
||||
width="700"
|
||||
height="350"
|
||||
src="https://www.youtube-nocookie.com/embed/SfAxGVjJ6Q0"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
||||
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 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
|
||||
|
||||
<iframe
|
||||
className="w-full"
|
||||
width="700"
|
||||
height="350"
|
||||
src="https://www.youtube-nocookie.com/embed/nFWUCNbCFhI"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
||||
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
|
||||
|
||||
<iframe
|
||||
className="w-full"
|
||||
width="700"
|
||||
height="350"
|
||||
src="https://www.youtube-nocookie.com/embed/FAqQxT-peuc"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
||||
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.
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 707 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 831 KiB |
|
After Width: | Height: | Size: 829 KiB |
|
After Width: | Height: | Size: 836 KiB |
|
After Width: | Height: | Size: 359 KiB |
|
After Width: | Height: | Size: 116 KiB |
@@ -5,9 +5,17 @@
|
||||
<link>https://supabase.com</link>
|
||||
<description>Latest news from Supabase</description>
|
||||
<language>en</language>
|
||||
<lastBuildDate>Sun, 17 Apr 2022 16:00:00 GMT</lastBuildDate>
|
||||
<lastBuildDate>Tue, 19 Apr 2022 16:00:00 GMT</lastBuildDate>
|
||||
<atom:link href="https://supabase.com/blog/rss.xml" rel="self" type="application/rss+xml"/>
|
||||
|
||||
<item>
|
||||
<guid>https://supabase.com/blog/2022/04/20/partner-gallery-works-with-supabase</guid>
|
||||
<title>Works With Supabase - announcing our Partner Gallery</title>
|
||||
<link>https://supabase.com/blog/2022/04/20/partner-gallery-works-with-supabase</link>
|
||||
<description>Introducing our Partner Gallery - open source and made with Supabase.</description>
|
||||
<pubDate>Tue, 19 Apr 2022 16:00:00 GMT</pubDate>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<guid>https://supabase.com/blog/2022/04/18/bring-the-func-hackathon-winners</guid>
|
||||
<title>Bring the Func Hackathon Winners 2022</title>
|
||||
@@ -15,7 +23,7 @@
|
||||
<description>Celebrating many amazing OSS Hackathon projects using GraphQL and Edge Functions.</description>
|
||||
<pubDate>Sun, 17 Apr 2022 16:00:00 GMT</pubDate>
|
||||
</item>
|
||||
|
||||
|
||||
<item>
|
||||
<guid>https://supabase.com/blog/2022/04/15/beta-update-march-2022</guid>
|
||||
<title>Supabase Beta March 2022</title>
|
||||
|
||||
@@ -613,4 +613,40 @@
|
||||
<changefreq>weekly</changefreq>
|
||||
<changefreq>0.5</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/blog/2022/04/01/hackathon-bring-the-func</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<changefreq>0.5</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/blog/2022/04/01/supabase-realtime-with-multiplayer-features</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<changefreq>0.5</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/blog/2022/04/01/supabrew</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<changefreq>0.5</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/blog/2022/04/15/beta-update-march-2022</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<changefreq>0.5</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/blog/2022/04/18/bring-the-func-hackathon-winners</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<changefreq>0.5</changefreq>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/blog/2022/04/20/partner-gallery-works-with-supabase</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<changefreq>0.5</changefreq>
|
||||
</url>
|
||||
</urlset>
|
||||