Merge branch 'master' into feat/pricing-updates

This commit is contained in:
Jonathan Summers-Muir committed 2022-03-30 17:16:20 +02:00
commit ca1f73633f
115 files changed
+7355 -1291

No files matched your search

+10 -6
View File
@@ -8,13 +8,17 @@
[Supabase](https://supabase.com) is an open source Firebase alternative. We're building the features of Firebase using enterprise-grade open source tools.
- [x] Hosted Postgres Database
- [x] Realtime subscriptions
- [x] Authentication and authorization
- [x] Auto-generated APIs
- [x] Hosted Postgres Database. [Docs](https://supabase.com/docs/guides/database)
- [x] Authentication and Authorization. [Docs](https://supabase.com/docs/guides/auth)
- [x] Auto-generated APIs.
- [x] REST. [Docs](https://supabase.com/docs/guides/api#rest-api)
- [x] Realtime subscriptions. [Docs](https://supabase.com/docs/guides/api#realtime-api)
- [x] GraphQL (Beta). [Docs](https://supabase.com/docs/guides/api#graphql-api)
- [x] Functions.
- [x] Database Functions. [Docs](https://supabase.com/docs/guides/database/functions)
- [ ] Edge Functions (coming soon).
- [x] File Storage. [Docs](https://supabase.com/docs/guides/storage)
- [x] Dashboard
- [x] Storage
- [ ] Functions (coming soon)
## Documentation
+2 -2
View File
@@ -85,7 +85,7 @@ services:
restart: unless-stopped
environment:
PGRST_DB_URI: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/postgres
PGRST_DB_SCHEMAS: public,storage
PGRST_DB_SCHEMAS: public,storage, graphql_public
PGRST_DB_ANON_ROLE: anon
PGRST_JWT_SECRET: ${JWT_SECRET}
PGRST_DB_USE_LEGACY_GUCS: "false"
@@ -151,7 +151,7 @@ services:
db:
container_name: supabase-db
image: supabase/postgres:14.1.0
image: supabase/postgres:14.1.0.15
command: postgres -c config_file=/etc/postgresql/postgresql.conf
restart: unless-stopped
ports:
+26
View File
@@ -95,6 +95,32 @@ services:
- admin
- anon
## Secure GraphQL routes
- name: graphql-v1
_comment: "PostgREST: /rest/v1/* -> http://rest:3000/rpc/graphql"
url: http://rest:3000/rpc/graphql
routes:
- name: graphql-v1-all
strip_path: true
paths:
- /graphql/v1
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: true
- name: request-transformer
config:
add:
headers:
- Content-Profile:graphql_public
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Secure Realtime routes
- name: realtime-v1
_comment: "Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*"
+141
View File
@@ -0,0 +1,141 @@
create schema if not exists graphql_public;
-- GraphQL Placeholder Entrypoint
create or replace function graphql_public.graphql(
"operationName" text default null,
query text default null,
variables jsonb default null,
extensions jsonb default null
)
returns jsonb
language plpgsql
as $$
DECLARE
server_version float;
BEGIN
server_version = (SELECT (SPLIT_PART((select version()), ' ', 2))::float);
IF server_version >= 14 THEN
RETURN jsonb_build_object(
'data', null::jsonb,
'errors', array['pg_graphql extension is not enabled.']
);
ELSE
RETURN jsonb_build_object(
'data', null::jsonb,
'errors', array['pg_graphql is only available on projects running Postgres 14 onwards.']
);
END IF;
END;
$$;
grant usage on schema graphql_public to postgres, anon, authenticated, service_role;
alter default privileges in schema graphql_public grant all on tables to postgres, anon, authenticated, service_role;
alter default privileges in schema graphql_public grant all on functions to postgres, anon, authenticated, service_role;
alter default privileges in schema graphql_public grant all on sequences to postgres, anon, authenticated, service_role;
alter default privileges for user supabase_admin in schema graphql_public grant all
on sequences to postgres, anon, authenticated, service_role;
alter default privileges for user supabase_admin in schema graphql_public grant all
on tables to postgres, anon, authenticated, service_role;
alter default privileges for user supabase_admin in schema graphql_public grant all
on functions to postgres, anon, authenticated, service_role;
-- Trigger upon enabling pg_graphql
CREATE OR REPLACE FUNCTION extensions.grant_pg_graphql_access()
RETURNS event_trigger
LANGUAGE plpgsql
AS $func$
DECLARE
func_is_graphql_resolve bool;
BEGIN
func_is_graphql_resolve = (
SELECT n.proname = 'resolve'
FROM pg_event_trigger_ddl_commands() AS ev
LEFT JOIN pg_catalog.pg_proc AS n
ON ev.objid = n.oid
);
IF func_is_graphql_resolve
THEN
grant usage on schema graphql to postgres, anon, authenticated, service_role;
grant all on function graphql.resolve to postgres, anon, authenticated, service_role;
alter default privileges in schema graphql grant all on tables to postgres, anon, authenticated, service_role;
alter default privileges in schema graphql grant all on functions to postgres, anon, authenticated, service_role;
alter default privileges in schema graphql grant all on sequences to postgres, anon, authenticated, service_role;
create or replace function graphql_public.graphql(
"operationName" text default null,
query text default null,
variables jsonb default null,
extensions jsonb default null
)
returns jsonb
language sql
as $$
SELECT graphql.resolve(query, coalesce(variables, '{}'));
$$;
grant select on graphql.field, graphql.type, graphql.enum_value to postgres, anon, authenticated, service_role;
grant execute on function graphql.resolve to postgres, anon, authenticated, service_role;
END IF;
END;
$func$;
CREATE EVENT TRIGGER issue_pg_graphql_access ON ddl_command_end WHEN TAG in ('CREATE FUNCTION')
EXECUTE PROCEDURE extensions.grant_pg_graphql_access();
COMMENT ON FUNCTION extensions.grant_pg_graphql_access IS 'Grants access to pg_graphql';
-- Trigger upon dropping the pg_graphql extension
CREATE OR REPLACE FUNCTION extensions.set_graphql_placeholder()
RETURNS event_trigger
LANGUAGE plpgsql
AS $func$
DECLARE
graphql_is_dropped bool;
BEGIN
graphql_is_dropped = (
SELECT ev.schema_name = 'graphql_public'
FROM pg_event_trigger_dropped_objects() AS ev
WHERE ev.schema_name = 'graphql_public'
);
IF graphql_is_dropped
THEN
create or replace function graphql_public.graphql(
"operationName" text default null,
query text default null,
variables jsonb default null,
extensions jsonb default null
)
returns jsonb
language plpgsql
as $$
DECLARE
server_version float;
BEGIN
server_version = (SELECT (SPLIT_PART((select version()), ' ', 2))::float);
IF server_version >= 14 THEN
RETURN jsonb_build_object(
'data', null::jsonb,
'errors', array['pg_graphql extension is not enabled.']
);
ELSE
RETURN jsonb_build_object(
'data', null::jsonb,
'errors', array['pg_graphql is only available on projects running Postgres 14 onwards.']
);
END IF;
END;
$$;
END IF;
END;
$func$;
CREATE EVENT TRIGGER issue_graphql_placeholder ON sql_drop WHEN TAG in ('DROP EXTENSION')
EXECUTE PROCEDURE extensions.set_graphql_placeholder();
COMMENT ON FUNCTION extensions.set_graphql_placeholder IS 'Reintroduces placeholder function for graphql_public.graphql';
+103
View File
@@ -8,3 +8,106 @@
.dark .docs-story {
@apply bg-scale-200;
}
/* // date picker */
/* !!!! THIS IS A COPY OF THE styles/date-picker.scss !! */
.react-datepicker__input-container input {
@apply block w-full text-base md:text-sm bg-white border border-scale-300 rounded shadow-sm;
}
.react-datepicker-popper {
@apply z-40 w-72 text-sm bg-scale-100 dark:bg-scale-300 shadow px-3 py-2 border-2 border-scale-200 rounded;
}
.react-datepicker-left {
@apply absolute left-0 right-auto top-11 transform-none !important;
}
.react-datepicker-right {
@apply absolute right-0 left-auto top-11 transform-none !important;
}
.react-datepicker__portal {
@apply absolute z-10 w-72 text-sm transform-none bg-white shadow px-3 py-2 top-12 right-0 border-2 border-scale-200 rounded;
}
.react-datepicker__month-container {
@apply flex flex-col;
}
.react-datepicker__month {
@apply flex flex-col;
@apply -space-y-px;
}
.react-datepicker__current-month {
@apply ml-2.5 text-lg font-semibold text-scale-800;
}
.react-datepicker__week {
@apply flex -space-x-px;
}
.react-datepicker__day-names {
@apply flex -space-x-px text-scale-900 font-normal text-center text-xs;
}
.react-datepicker__day-name {
@apply w-10 h-10 flex items-center justify-center py-1 rounded-full;
}
.react-datepicker__navigation {
@apply absolute top-2;
}
.react-datepicker__navigation--previous {
@apply right-12 w-10 h-10 rounded transition flex items-center justify-center hover:bg-scale-200;
}
.react-datepicker__navigation--next {
@apply right-4 w-10 h-10 rounded transition flex items-center justify-center hover:bg-scale-200;
}
.react-datepicker__day {
@apply w-10 h-10 flex items-center font-normal justify-center text-sm leading-loose transition text-scale-1100;
/* @apply mb-1 py-1; */
@apply -space-y-px;
@apply border border-scale-500;
}
.react-datepicker__day--disabled {
@apply cursor-not-allowed opacity-40 hover:bg-transparent;
}
.react-datepicker__day--outside-month {
@apply text-scale-800;
}
.react-datepicker__day--in-range {
@apply bg-brand-800 text-scale-fixed-100 border-brand-800;
}
.react-datepicker__day--in-selecting-range {
@apply bg-brand-700 text-scale-fixed-100 border-brand-700;
}
.react-datepicker__day--selecting-range-start {
@apply bg-scale-200;
}
.react-datepicker__day--selecting-range-end {
@apply bg-scale-200;
}
.react-datepicker__day--selected {
@apply bg-brand-800 text-white;
}
.react-datepicker__day--range-start {
@apply bg-brand-900 text-white hover:text-scale-700 hover:bg-white;
}
.react-datepicker__day--range-end {
@apply bg-brand-900 text-white hover:text-scale-700 hover:bg-white;
}
@@ -0,0 +1,22 @@
import dayjs from 'dayjs'
import { ResponseCodeFormatter } from '../LogsFormatters'
export default [
{
formatter: (data: any) => (
<div className="flex h-full w-full items-center justify-between gap-3">
<div className="flex h-full w-full items-center gap-4">
<ResponseCodeFormatter row={data} value={data.row.metadata[0].response[0].status_code} />
<span className="text-xs w-14">{data.row.metadata[0].request[0].method}</span>
<span className="font-mono text-xs">{data.row.metadata[0].request[0].path}</span>
</div>
<div>
<span className="flex w-full h-full items-center gap-1">
<span className="text-xs">{dayjs(data?.row?.timestamp / 1000).format('DD MMM')}</span>
<span className="text-xs">{dayjs(data?.row?.timestamp / 1000).format('HH:mm:ss')}</span>
</span>
</div>
</div>
),
},
]
@@ -0,0 +1,23 @@
import dayjs from 'dayjs'
import { SeverityFormatter } from '../LogsFormatters'
export default [
{
formatter: (data: any) => (
<div className="flex w-full items-center gap-2 h-full">
<div className="flex items-center gap-2 h-full">
<span className="w-24 flex items-center gap-1">
<span className="text-xs">{dayjs(data?.row?.timestamp / 1000).format('DD MMM')}</span>
<span className="text-xs">{dayjs(data?.row?.timestamp / 1000).format('HH:mm:ss')}</span>
</span>
<div className="w-16 flex items-center">
<SeverityFormatter value={data?.row?.metadata[0].parsed[0].error_severity} />
</div>
</div>
<div className="flex truncate">
<span className="font-mono text-xs truncate">{data.row.event_message}</span>
</div>
</div>
),
},
]
@@ -0,0 +1,43 @@
import dayjs from 'dayjs'
import { HeaderFormmater, ResponseCodeFormatter } from '../LogsFormatters'
export default [
{
key: 'timestamp',
headerRenderer: () => (
<div className="flex w-full justify-end h-full">
<HeaderFormmater value={'timestamp'} />
</div>
),
name: 'timestamp',
formatter: (data: any) => (
<span className="flex w-full h-full items-center gap-1">
<span className="text-xs">{dayjs(data?.row?.timestamp / 1000).format('DD MMM')}</span>
<span className="text-xs">{dayjs(data?.row?.timestamp / 1000).format('HH:mm:ss')}</span>
{/* {data?.row?.timestamp} */}
</span>
),
width: 128,
},
{
key: 'status_code',
headerRenderer: () => <HeaderFormmater value={'Status'} />,
name: 'status_code',
formatter: (data: any) => <ResponseCodeFormatter row={data} value={data.row.status_code} />,
width: 0,
resizable: true,
},
{
key: 'method',
headerRenderer: () => <HeaderFormmater value={'method'} />,
width: 0,
resizable: true,
},
{
key: 'id',
headerRenderer: () => <HeaderFormmater value={'id'} />,
name: 'id',
resizable: true,
},
]
@@ -0,0 +1,40 @@
import dayjs from 'dayjs'
import { HeaderFormmater, SeverityFormatter } from '../LogsFormatters'
export default [
{
key: 'timestamp',
headerRenderer: () => (
<div className="flex w-full justify-end h-full">
<HeaderFormmater value={'timestamp'} />
</div>
),
name: 'timestamp',
formatter: (data: any) => (
<span className="flex w-full h-full items-center gap-1">
<span className="text-xs !text-scale-1100">
{dayjs(data?.row?.timestamp / 1000).format('DD MMM')}
</span>
<span className="text-xs !text-scale-1100">
{dayjs(data?.row?.timestamp / 1000).format('HH:mm:ss')}
</span>
{/* {data?.row?.timestamp} */}
</span>
),
width: 128,
resizable: true,
},
{
key: 'level',
headerRenderer: () => <HeaderFormmater value={'Level'} />,
name: 'level',
formatter: (data: any) => <SeverityFormatter value={data.row.level} />,
width: 24,
resizable: true,
},
{
key: 'event_message',
headerRenderer: () => <HeaderFormmater value={'Event message'} />,
resizable: true,
},
]
@@ -16,9 +16,8 @@ const LogEventChart: React.FC<Props> = ({ data, onBarClick }) => {
return (
<BarChart
className="py-1 px-2"
minimalHeader
minmalChart
chartSize="tiny"
data={aggregated}
attribute="count"
label="Events"
@@ -29,13 +28,35 @@ const LogEventChart: React.FC<Props> = ({ data, onBarClick }) => {
}}
customDateFormat="MMM D, HH:mm"
displayDateInUtc
noDataMessage={''}
/>
)
}
const useAggregated = (data: LogData[]) => {
const truncateToMinute = (micro: number) => Math.floor(micro / 1000 / 1000 / 60) * 60
const truncateToHour = (micro: number) => Math.floor(micro / 1000 / 1000 / 60 / 60) * 60 * 60
const getDiffMinute = (currentTimestamp: number, olderTimestamp: number) =>
Math.round((currentTimestamp - olderTimestamp) / 1000 / 60)
const getDiffHour = (currentTimestamp: number, olderTimestamp: number) =>
Math.round((currentTimestamp - olderTimestamp) / 1000 / 60 / 60)
const diffMultiplierMinute = (v: number) => v * 60
const diffMultiplierHour = (v: number) => v * 60 * 60
return useMemo(() => {
const oldest = data[data.length - 1]
if (!oldest) return
const latest = data[0]
const oldestDayjs = dayjs(oldest.timestamp / 1000)
const latestDayjs = dayjs(latest.timestamp / 1000)
let truncFunc = truncateToMinute
let getDiff = getDiffMinute
let diffMultiplier = diffMultiplierMinute
if (Math.abs(oldestDayjs.diff(latestDayjs, 'day', true)) > 0.25) {
truncFunc = truncateToHour
getDiff = getDiffHour
diffMultiplier = diffMultiplierHour
}
const countMap = data
.map((d) => {
// truncate to per-minute
@@ -55,12 +76,10 @@ const useAggregated = (data: LogData[]) => {
if (!oldestEvent) return []
const currentTimestamp = new Date().getTime()
const oldestTimestampMicro = oldestEvent.timestamp
const latestTimestamp = truncateToMinute(data[0]['timestamp'])
const minutesDifference = Math.round(
(currentTimestamp - oldestTimestampMicro / 1000) / 1000 / 60
)
for (const minToAdd of Array.from(Array(minutesDifference).keys())) {
const tsToCheck = truncateToMinute(oldestTimestampMicro) + minToAdd * 60
const latestTimestamp = truncFunc(data[0]['timestamp'])
const diff = getDiff(currentTimestamp, oldestTimestampMicro / 1000)
for (const toAdd of Array.from(Array(diff).keys())) {
const tsToCheck = truncFunc(oldestTimestampMicro) + diffMultiplier(toAdd)
if (!(tsToCheck in countMap) && tsToCheck <= latestTimestamp) {
countMap[tsToCheck] = 0
}
@@ -1,262 +0,0 @@
import React, { FC, useEffect, useState } from 'react'
import {
Button,
Input,
Dropdown,
Typography,
IconChevronDown,
IconRefreshCw,
IconX,
Toggle,
IconSearch,
IconClock,
Popover,
} from '@supabase/ui'
import { LogSearchCallback, LogTemplate } from '.'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
interface Props {
defaultSearchValue?: string
defaultFromValue?: string
defaultToValue?: string
templates?: any
isLoading: boolean
isCustomQuery: boolean
newCount: number
onRefresh?: () => void
onSearch?: LogSearchCallback
onCustomClick?: () => void
onSelectTemplate: (template: LogTemplate) => void
isShowingEventChart: boolean
onToggleEventChart: () => void
}
dayjs.extend(utc)
/**
* Logs control panel header + wrapper
*/
const LogPanel: FC<Props> = ({
templates = [],
isLoading,
isCustomQuery,
newCount,
onRefresh,
onSearch = () => {},
defaultSearchValue = '',
defaultFromValue = '',
defaultToValue = '',
onCustomClick,
onSelectTemplate,
isShowingEventChart,
onToggleEventChart,
}) => {
const [search, setSearch] = useState('')
const [to, setTo] = useState({ value: '', error: '' })
const [from, setFrom] = useState({ value: '', error: '' })
const [defaultTimestamp, setDefaultTimestamp] = useState(dayjs().utc().toISOString())
// Sync local state with provided default value
useEffect(() => {
if (search !== defaultSearchValue) {
setSearch(defaultSearchValue)
}
}, [defaultSearchValue])
useEffect(() => {
if (to.value !== defaultToValue) {
setTo({ value: defaultToValue, error: '' })
}
if (from.value !== defaultFromValue) {
setFrom({ value: defaultFromValue, error: '' })
}
}, [defaultToValue, defaultFromValue])
const handleToChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
if (value !== '' && isNaN(Date.parse(value))) {
setTo({ value, error: 'Invalid ISO 8601 timestamp' })
} else {
setTo({ value, error: '' })
}
}
const handleFromChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
if (value !== '' && isNaN(Date.parse(value))) {
setFrom({ value, error: 'Invalid ISO 8601 timestamp' })
} else {
setFrom({ value, error: '' })
}
}
const handleReset = async () => {
setTo({ value: '', error: '' })
setFrom({ value: '', error: '' })
const value = dayjs().utc().toISOString()
setDefaultTimestamp(value)
onSearch({ query: search, to: '', from: '' })
}
const handleSearch = () => onSearch({ query: search, to: to.value, from: from.value })
const showReset = to.value !== '' || from.value !== ''
return (
<div className="bg-panel-header-light dark:bg-panel-header-dark">
<div className="px-2 py-1 flex items-center justify-between w-full">
<div className="flex flex-row gap-x-4 items-center">
{!isCustomQuery && (
<Button
type="text"
icon={
<div className="relative">
{newCount > 0 && (
<div
className={[
'absolute flex items-center justify-center -top-3 right-3',
'h-4 w-4 z-50',
].join(' ')}
>
<div className="absolute z-20">
<Typography.Text style={{ fontSize: '0.6rem' }} className="opacity-80">
{newCount}
</Typography.Text>
</div>
<div className="bg-green-800 rounded-full w-full h-full animate-ping opacity-60"></div>
<div className="absolute z-60 top-0 right-0 bg-green-900 opacity-80 rounded-full w-full h-full"></div>
</div>
)}
<IconRefreshCw />
</div>
}
loading={isLoading}
onClick={onRefresh}
>
Refresh
</Button>
)}
<Dropdown
side="bottom"
align="start"
overlay={templates.map((template: LogTemplate) => (
<Dropdown.Item key={template.label} onClick={() => onSelectTemplate(template)}>
<Typography.Text>{template.label}</Typography.Text>
</Dropdown.Item>
))}
>
<Button as="span" type="text" iconRight={<IconChevronDown />}>
Templates
</Button>
</Dropdown>
{!isCustomQuery && (
<Button as="span" type="outline" onClick={onCustomClick}>
Explore via query
</Button>
)}
</div>
<div className="flex items-center gap-x-4">
{!isCustomQuery && (
<>
<div className="flex flex-row">
<Popover
side="bottom"
align="end"
portalled
overlay={
<>
<Input
label="From"
labelOptional="UTC"
value={from.value === '' ? defaultTimestamp : from.value}
onChange={handleFromChange}
error={from.error}
className="w-72 p-3"
/>
<Input
label="To"
labelOptional="UTC"
value={to.value === '' ? defaultTimestamp : to.value}
onChange={handleToChange}
error={to.error}
className="w-72 p-3"
/>
<div className="flex flex-row justify-end pb-2 px-4">
<Button
key="set"
size="tiny"
title="Set"
type="secondary"
onClick={handleSearch}
>
Set
</Button>
</div>
</>
}
>
<Button
as="span"
size="tiny"
className={showReset ? '!rounded-r-none' : ''}
type={showReset ? 'outline' : 'text'}
icon={<IconClock size="tiny" />}
>
{to.value || from.value ? 'Custom' : 'Now'}
</Button>
</Popover>
{showReset && (
<Button
size="tiny"
className={showReset ? '!rounded-l-none' : ''}
icon={<IconX size="tiny" />}
type="outline"
title="Clear timestamp filter"
onClick={handleReset}
/>
)}
</div>
{!isCustomQuery && (
<div className="flex items-center space-x-2">
<p className="text-xs">Show event chart</p>
<Toggle size="tiny" checked={isShowingEventChart} onChange={onToggleEventChart} />
</div>
)}
{/* wrap with form so that if user presses enter, the search value will submit automatically */}
<form
id="log-panel-search"
onSubmit={(e) => {
// prevent redirection
e.preventDefault()
handleSearch()
}}
>
<Input
placeholder="Search events"
onChange={(e) => setSearch(e.target.value)}
value={search}
actions={[
search && (
<IconX
key="clear-search"
size="tiny"
className="cursor-pointer mx-1"
title="Clear search"
onClick={() => setSearch('')}
/>
),
<Button key="go" size="tiny" title="Go" type="default" onClick={handleSearch}>
<IconSearch size={16} />
</Button>,
]}
/>
</form>
</>
)}
</div>
</div>
</div>
)
}
export default LogPanel
@@ -1,67 +1,135 @@
import dayjs from 'dayjs'
import { FC } from 'react'
import { Typography, IconX, Input } from '@supabase/ui'
import { IconX } from '@supabase/ui'
import { LogData } from './Logs.types'
import { LogData, QueryType } from './Logs.types'
import DatabaseApiSelectionRender, {
DatabaseApiSelectionHeaderRender,
} from './LogSelectionRenderers/DatabaseApiSelectionRender'
import DatabasePostgresSelectionRender from './LogSelectionRenderers/DatabasePostgresSelectionRender'
import FunctionInvocationSelectionRender, {
FunctionInvocationHeaderRender,
} from './LogSelectionRenderers/FunctionInvocationSelectionRender'
import FunctionLogsSelectionRender from './LogSelectionRenderers/FunctionLogsSelectionRender'
import DefaultSelectionRenderer from './LogSelectionRenderers/DefaultSelectionRenderer'
interface Props {
log: LogData
log: LogData | null
onClose: () => void
queryType?: QueryType
isLoading?: boolean
}
/**
* Log selection display
*/
const LogSelection: FC<Props> = ({ log, onClose }) => {
const LogSelection: FC<Props> = ({ log, onClose, queryType, isLoading }) => {
const Formatter = () => {
switch (queryType) {
case 'api':
return <DatabaseApiSelectionRender log={log} />
case 'database':
return <DatabasePostgresSelectionRender log={log} />
case 'fn_edge':
return <FunctionInvocationSelectionRender log={log} />
break
case 'functions':
return <FunctionLogsSelectionRender log={log} />
break
default:
return <DefaultSelectionRenderer log={log} />
}
}
function header() {
switch (queryType) {
case 'api':
return DatabaseApiSelectionHeaderRender(log)
break
case 'fn_edge':
return FunctionInvocationHeaderRender(log)
default:
return null
}
}
return (
<div
className={[
'h-full flex flex-col flex-grow border border-l-0',
'relative h-full flex flex-col flex-grow border border-l',
'border-panel-border-light dark:border-panel-border-dark',
'bg-gray-200 overflow-y-scroll',
].join(' ')}
>
<div
className={[
'bg-panel-header-light dark:bg-panel-header-dark',
'border-b border-panel-border-interior-light',
'dark:border-panel-border-interior-dark',
].join(' ')}
className={
`overflow-y-scroll transition-all
bg-scale-200 absolute w-full h-full text-center flex-col gap-2 flex items-center justify-center opacity-0 ` +
(log ? 'opacity-0 z-0' : 'opacity-100 z-10')
}
>
<div className="px-6 py-4 flex items-center">
<div className="flex flex-row justify-between items-center w-full">
<Typography.Title level={5}>{dayjs(log.timestamp / 1000).toString()}</Typography.Title>
<div className="cursor-pointer" onClick={onClose}>
<IconX size={14} />
<div
className={
`transition-all
duration-500
delay-300
w-full
flex
flex-col
justify-center
items-center
gap-6
max-w-sm
text-center scale-95 opacity-0 ` +
(log || isLoading ? 'mt-0 opacity-0 scale-95' : 'mt-8 opacity-100 scale-100')
}
>
<div className="relative border border-scale-600 dark:border-scale-400 w-32 h-4 rounded px-2 flex items-center">
<div className="h-0.5 rounded-full w-2/3 bg-scale-600 dark:bg-scale-500"></div>
<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="1"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"
/>
</svg>
</div>
</div>
<div className="flex flex-col gap-1">
<h3 className="text-sm text-scale-1200">Select an Event</h3>
<p className="text-xs text-scale-900">
Select an Event to view the code snippet (pretty view) or complete JSON payload (raw
view).
</p>
</div>
</div>
</div>
<div className="flex-grow overflow-y-auto bg-panel-body-light dark:bg-panel-body-dark">
<div className="p-4 flex-col space-y-4">
<div className="space-y-2">
<p className="text-sm">Event message</p>
<Input
copy
readOnly
size="small"
layout="vertical"
className="text-sm input-mono"
value={log.event_message}
/>
</div>
<div className="space-y-2">
<p className="text-sm">Metadata</p>
<Input.TextArea
copy
readOnly
size="small"
layout="vertical"
rows={20}
className="input-mono"
value={JSON.stringify(log.metadata, null, 2)}
/>
</div>
<div
className="
relative
flex-grow
h-px
bg-scale-300
"
>
<div
className="transition absolute cursor-pointer top-6 right-6 text-scale-900 hover:text-scale-1200"
onClick={onClose}
>
<IconX size={14} strokeWidth={2} />
</div>
<div className="bg-scale-300 py-8 flex flex-col space-y-6">{log && <Formatter />}</div>
</div>
</div>
)
@@ -0,0 +1,13 @@
import { QueryType } from './Logs.types'
const SelectionRenderHandler = ({ type }: { type: QueryType }) => {
switch (type) {
case 'api':
break
default:
break
}
}
export default SelectionRenderHandler
@@ -0,0 +1,72 @@
import React from 'react'
import { LOGS_TAILWIND_CLASSES } from '../Logs.constants'
import LogsDivider from '../Logs.Divider'
import { jsonSyntaxHighlight, ResponseCodeFormatter } from '../LogsFormatters'
const DatabaseApiSelectionRender = ({ log }: any) => {
const request = log?.metadata[0]?.request[0]
const response = log?.metadata[0]?.response[0]
const method = log?.metadata[0]?.request[0]?.method
const status = log?.metadata[0]?.response[0].status_code
const ipAddress = log?.metadata[0]?.request[0].headers[0]?.cf_connecting_ip
const countryOrigin = log?.metadata[0]?.request[0].headers[0]?.cf_ipcountry
const clientInfo = log?.metadata[0]?.request[0].headers[0]?.x_client_info
const referer = log?.metadata[0]?.request[0].headers[0]?.referer
const DetailedRow = ({ label, value }: { label: string; value: string | React.ReactNode }) => {
return (
<div className="grid grid-cols-12">
<span className="text-scale-900 text-sm col-span-4">{label}</span>
<span className="text-scale-1200 text-base col-span-8">{value}</span>
</div>
)
}
return (
<>
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding} space-y-2`}>
<DetailedRow label="Status" value={<ResponseCodeFormatter value={status} />} />
<DetailedRow label="Method" value={method} />
<DetailedRow label="Timestamp" value={log.timestamp} />
<DetailedRow label="IP Address" value={ipAddress} />
<DetailedRow label="Origin Country" value={countryOrigin} />
{clientInfo && <DetailedRow label="Client" value={clientInfo} />}
{referer && <DetailedRow label="Referer" value={referer} />}
</div>
<LogsDivider />
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding}`}>
<h3 className="text-lg text-scale-1200 mb-4">Request body</h3>
<pre className="text-sm syntax-highlight overflow-x-auto">
<div
className="text-wrap"
dangerouslySetInnerHTML={{
__html: request ? jsonSyntaxHighlight(log.metadata[0].request[0]) : '',
}}
/>
</pre>
</div>
<LogsDivider />
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding}`}>
<h3 className="text-lg text-scale-1200 mb-4">
Response{method ? ` ${method}` : null} body
</h3>
<pre className="text-sm syntax-highlight overflow-x-auto">
<div
dangerouslySetInnerHTML={{
__html: response ? jsonSyntaxHighlight(log.metadata[0].response[0]) : '',
}}
/>
</pre>
</div>
</>
)
}
export const DatabaseApiSelectionHeaderRender = (log: any) => {
const method = log?.metadata[0]?.request[0]?.method
const path = log?.metadata[0]?.request[0]?.path
return `${method} ${path}`
}
export default DatabaseApiSelectionRender
@@ -0,0 +1,67 @@
import { Alert } from '@supabase/ui'
import React from 'react'
import { LOGS_TAILWIND_CLASSES } from '../Logs.constants'
import LogsDivider from '../Logs.Divider'
import { jsonSyntaxHighlight, SeverityFormatter } from '../LogsFormatters'
const DatabasePostgresSelectionRender = ({ log }: any) => {
const postgresUsername = log?.metadata[0]?.parsed[0]?.user_name
const sessionId = log?.metadata[0]?.parsed[0]?.session_id
const hint = log?.metadata[0]?.parsed[0]?.hint
const DetailedRow = ({ label, value }: { label: string; value: string | React.ReactNode }) => {
return (
<div className="grid grid-cols-12">
<span className="text-scale-900 text-sm col-span-4">{label}</span>
<span className="text-scale-1200 text-base col-span-8">{value}</span>
</div>
)
}
return (
<>
<div className={LOGS_TAILWIND_CLASSES.log_selection_x_padding}>
<span className="text-scale-900 text-sm col-span-4">Event message</span>
<div
className="text-xs text-wrap font-mono text-scale-1200 mt-2"
dangerouslySetInnerHTML={{
__html: log.event_message,
}}
/>
</div>
<LogsDivider />
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding} space-y-2`}>
<DetailedRow label="Severity" value={<SeverityFormatter value={log.error_severity} />} />
<DetailedRow label="Postgres Username" value={postgresUsername} />
<DetailedRow label="Session ID" value={sessionId} />
</div>
{hint && (
<div className={`mt-4 ${LOGS_TAILWIND_CLASSES.log_selection_x_padding}`}>
<Alert variant="warning" withIcon title={log?.metadata[0]?.parsed[0]?.hint} />
</div>
)}
<LogsDivider />
<div className={LOGS_TAILWIND_CLASSES.log_selection_x_padding}>
<h3 className="text-lg text-scale-1200 mb-4">Metadata</h3>
<pre className="text-sm syntax-highlight overflow-x-auto">
<div
className="text-wrap"
dangerouslySetInnerHTML={{
__html: log.metadata ? jsonSyntaxHighlight(log.metadata[0]) : '',
}}
/>
</pre>
</div>
</>
)
}
export const DatabaseApiSelectionHeaderRender = (log: any) => {
const method = log?.metadata[0]?.request[0]?.method
const path = log?.metadata[0]?.request[0]?.path
return `${method} ${path}`
}
export default DatabasePostgresSelectionRender
@@ -0,0 +1,64 @@
import { LOGS_TAILWIND_CLASSES } from '../Logs.constants'
import { jsonSyntaxHighlight } from '../LogsFormatters'
const DefaultSelectionRenderer = ({ log }: any) => {
const DetailedRow = ({
label,
value,
code,
}: {
label: string
value: string | React.ReactNode
code?: boolean
}) => {
return (
<div className="grid grid-cols-12">
<span className="text-scale-900 text-sm col-span-4">{label}</span>
<span className={`text-scale-1200 text-sm col-span-8 ${code && 'text-xs font-mono'}`}>
{value}
</span>
</div>
)
}
const DetailedJsonRow = ({
label,
value,
code,
}: {
label: string
value: Object
code?: boolean
}) => {
return (
<div className="grid grid-cols-12">
<span className="text-scale-900 text-sm col-span-4">{label}</span>
<span className={`text-scale-1200 text-sm col-span-8 ${code && 'text-xs font-mono'}`}>
<pre
dangerouslySetInnerHTML={{
__html: jsonSyntaxHighlight(value),
}}
/>
</span>
</div>
)
}
return (
<div className="overflow-hidden overflow-x-auto space-y-6">
{Object.entries(log).map(([key, value], index) => {
return (
<div key={`${key}-${index}`} className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding}`}>
{value && typeof value === 'object' ? (
<DetailedJsonRow label={key} value={value} />
) : (
<DetailedRow label={key} value={String(value)} />
)}
</div>
)
})}
</div>
)
}
export default DefaultSelectionRenderer
@@ -0,0 +1,79 @@
import dayjs from 'dayjs'
import { LOGS_TAILWIND_CLASSES } from '../Logs.constants'
import { jsonSyntaxHighlight, ResponseCodeFormatter } from '../LogsFormatters'
const FunctionInvocationSelectionRender = ({ log }: any) => {
const functionId = log.metadata
const request = log?.request
const response = log?.response
const method = log?.method
const status = log?.status_code
const requestUrl = request?.url
const host = request?.host
const DetailedRow = ({
label,
value,
code,
}: {
label: string
value: string | React.ReactNode
code?: boolean
}) => {
return (
<div className="grid grid-cols-12">
<span className="text-scale-900 text-sm col-span-4">{label}</span>
<span className={`text-scale-1200 text-sm col-span-8 ${code && 'text-xs font-mono'}`}>
{value}
</span>
</div>
)
}
return (
<>
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding} space-y-2`}>
<DetailedRow label="Status" value={<ResponseCodeFormatter value={status} />} />
<DetailedRow label="Method" value={method} />
<DetailedRow label="Timestamp" value={dayjs(log.timestamp).toISOString()} />
<DetailedRow label="Deployment ID" value={log.deployment_id} />
<DetailedRow label="Function ID" value={log.function_id} />
<DetailedRow label="Log ID" value={log.id} />
<DetailedRow label="Request url" value={requestUrl} />
<DetailedRow label="Host" value={host} />
</div>
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding}`}>
<h3 className="text-lg text-scale-1200 mb-4">Request body</h3>
<pre className="text-sm syntax-highlight overflow-x-auto">
<div
className="text-wrap"
dangerouslySetInnerHTML={{
__html: request ? jsonSyntaxHighlight(request) : '',
}}
/>
</pre>
</div>
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding}`}>
<h3 className="text-lg text-scale-1200 mb-4">
Response{method ? ` ${method}` : null} body
</h3>
<pre className="text-sm syntax-highlight overflow-x-auto">
<div
dangerouslySetInnerHTML={{
__html: response ? jsonSyntaxHighlight(response) : '',
}}
/>
</pre>
</div>
</>
)
}
export const FunctionInvocationHeaderRender = (log: any) => {
const method = log?.method
const path = log?.request?.url
return `${method} ${path}`
}
export default FunctionInvocationSelectionRender
@@ -0,0 +1,63 @@
import dayjs from 'dayjs'
import { LOGS_TAILWIND_CLASSES } from '../Logs.constants'
import { jsonSyntaxHighlight, ResponseCodeFormatter, SeverityFormatter } from '../LogsFormatters'
const FunctionLogsSelectionRender = ({ log }: any) => {
const request = log?.request
const DetailedRow = ({
label,
value,
code,
}: {
label: string
value: string | React.ReactNode
code?: boolean
}) => {
return (
<div className="grid grid-cols-12">
<span className="text-scale-900 text-sm col-span-4">{label}</span>
<span className={`text-scale-1200 text-sm col-span-8 ${code && 'text-xs font-mono'}`}>
{value}
</span>
</div>
)
}
return (
<>
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding}`}>
<span className="text-scale-900 text-sm col-span-4">Event message</span>
<div
className="text-xs text-wrap font-mono text-scale-1200 mt-2"
dangerouslySetInnerHTML={{
__html: log.event_message,
}}
/>
</div>
<div className="h-px w-full bg-panel-border-interior-light dark:bg-panel-border-interior-dark"></div>
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding} space-y-2`}>
<DetailedRow label="Severity" value={<SeverityFormatter value={log.metadata.level} />} />
<DetailedRow label="Log ID" value={log.id} />
<DetailedRow label="Deployment version" value={log?.metadata?.version} />
<DetailedRow label="Timestamp" value={dayjs(log.timestamp).toISOString()} />
<DetailedRow label="Execution ID" value={log.metadata.execution_id} />
<DetailedRow label="Deployment ID" value={log.metadata.deployment_id} />
<DetailedRow label="Function ID" value={log.metadata.function_id} />
</div>
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding}`}>
<h3 className="text-lg text-scale-1200 mb-4">Metadata</h3>
<pre className="text-sm syntax-highlight overflow-x-auto">
<div
className="text-wrap"
dangerouslySetInnerHTML={{
__html: log.metadata ? jsonSyntaxHighlight(log.metadata) : '',
}}
/>
</pre>
</div>
</>
)
}
export default FunctionLogsSelectionRender
@@ -1,15 +1,34 @@
import dayjs from 'dayjs'
import { useEffect, useState, useMemo } from 'react'
import { Typography } from '@supabase/ui'
import {
Alert,
Button,
IconDownloadCloud,
IconEye,
IconEyeOff,
IconLoader,
Input,
Typography,
} from '@supabase/ui'
import DataGrid from '@supabase/react-data-grid'
import LogSelection from './LogSelection'
import { LogData } from './Logs.types'
import { isNil } from 'lodash'
import { LogData, QueryType } from './Logs.types'
import { SeverityFormatter, ResponseCodeFormatter, HeaderFormmater } from './LogsFormatters'
// column renders
import DatabaseApiColumnRender from './LogColumnRenderers/DatabaseApiColumnRender'
import DatabasePostgresColumnRender from './LogColumnRenderers/DatabasePostgresColumnRender'
import CSVButton from 'components/ui/CSVButton'
interface Props {
isCustomQuery: boolean
data?: Array<LogData | Object>
queryType?: QueryType
onHistogramToggle?: () => void
isHistogramShowing?: boolean
isLoading?: boolean
error?: any
showDownload?: boolean
}
type LogMap = { [id: string]: LogData }
@@ -18,108 +37,347 @@ type LogMap = { [id: string]: LogData }
*
* When in custom data display mode, the side panel will not open when focusing on logs.
*/
const LogTable = ({ isCustomQuery, data = [] }: Props) => {
const LogTable = ({
data = [],
queryType,
onHistogramToggle,
isHistogramShowing,
isLoading,
showDownload,
error,
}: Props) => {
const [focusedLog, setFocusedLog] = useState<LogData | null>(null)
const columnNames = Object.keys(data[0] || {})
// whether the data structure is LogData format.
const hasLogDataFormat =
columnNames.includes('timestamp') &&
columnNames.includes('event_message') &&
columnNames.length === 4
const hasId = columnNames.includes('id')
const hasTimestamp = columnNames.includes('timestamp')
const DEFAULT_COLUMNS = columnNames.map((v) => {
let formatter = undefined
const firstRow: any = data[0]
const value = firstRow?.[v]
if (typeof value === 'object') {
formatter = () => `[Object]`
}
return { key: v, name: v, resizable: true, formatter, header: v, minWidth: 128 }
})
let columns
if (!queryType) {
columns = DEFAULT_COLUMNS
} else {
switch (queryType) {
case 'api':
columns = DatabaseApiColumnRender
break
case 'database':
columns = DatabasePostgresColumnRender
break
case 'fn_edge':
columns = [
{
key: 'timestamp',
headerRenderer: () => (
<div className="flex w-full justify-end h-full">
<HeaderFormmater value={'timestamp'} />
</div>
),
name: 'timestamp',
formatter: (data: any) => (
<span className="flex w-full h-full items-center gap-1">
<span className="text-xs">
{dayjs(data?.row?.timestamp / 1000).format('DD MMM')}
</span>
<span className="text-xs">
{dayjs(data?.row?.timestamp / 1000).format('HH:mm:ss')}
</span>
</span>
),
width: 128,
},
{
key: 'status_code',
headerRenderer: () => <HeaderFormmater value={'Status'} />,
name: 'status_code',
formatter: (data: any) => (
<ResponseCodeFormatter row={data} value={data.row.status_code} />
),
width: 0,
resizable: true,
},
{
key: 'method',
headerRenderer: () => <HeaderFormmater value={'method'} />,
width: 0,
resizable: true,
},
{
key: 'id',
headerRenderer: () => <HeaderFormmater value={'id'} />,
name: 'id',
resizable: true,
},
]
break
case 'functions':
columns = [
{
key: 'timestamp',
headerRenderer: () => (
<div className="flex w-full justify-end h-full">
<HeaderFormmater value={'timestamp'} />
</div>
),
name: 'timestamp',
formatter: (data: any) => (
<span className="flex w-full h-full items-center gap-1">
<span className="text-xs !text-scale-1100">
{dayjs(data?.row?.timestamp / 1000).format('DD MMM')}
</span>
<span className="text-xs !text-scale-1100">
{dayjs(data?.row?.timestamp / 1000).format('HH:mm:ss')}
</span>
</span>
),
width: 128,
resizable: true,
},
{
key: 'level',
headerRenderer: () => <HeaderFormmater value={'Level'} />,
name: 'level',
formatter: (data: any) => <SeverityFormatter value={data.row.level} />,
width: 24,
resizable: true,
},
{
key: 'event_message',
headerRenderer: () => <HeaderFormmater value={'Event message'} />,
resizable: true,
},
]
break
default:
columns = DEFAULT_COLUMNS
break
}
}
const columns = (hasLogDataFormat ? ['timestamp', 'event_message'] : columnNames).map((v) => ({
key: v,
name: v,
width: hasLogDataFormat && v === 'timestamp' ? 210 : undefined,
resizable: true,
headerRenderer: () => {
return <div className="flex items-center text-xs font-mono h-full">{v}</div>
},
formatter: ({ row }: any) => {
let value = row[v]
if (hasLogDataFormat && v === 'timestamp') {
value = dayjs(Number(row['timestamp']) / 1000).toISOString()
}
return (
<p
className={[
'block whitespace-wrap font-mono',
`${hasLogDataFormat && row.id === focusedLog?.id ? 'font-bold' : ''}`,
`${hasLogDataFormat && v === 'timestamp' ? 'text-green-900' : ''}`,
].join(' ')}
>
{value}
</p>
)
},
}))
const stringData = JSON.stringify(data)
const logMap = useMemo(() => {
if (!hasLogDataFormat) return {} as LogMap
const logData = data as LogData[]
return logData.reduce((acc: LogMap, d: LogData) => {
const [dedupedData, logMap] = useMemo<[LogData[], LogMap]>(() => {
const deduped = [...new Set(data)] as LogData[]
if (!hasId) {
return [deduped, {} as LogMap]
}
const map = deduped.reduce((acc: LogMap, d: LogData) => {
acc[d.id] = d
return acc
}, {}) as LogMap
return [deduped, map]
}, [stringData])
useEffect(() => {
if (isNil(data)) return
if (!hasId || data === null) return
if (focusedLog && !(focusedLog.id in logMap)) {
setFocusedLog(null)
}
}
}, [stringData])
if (!data) return null
// [Joshen] Hmm quite hacky now, but will do
const maxHeight = isCustomQuery ? 'calc(100vh - 42px - 10rem)' : 'calc(100vh - 42px - 3rem)'
const maxHeight = !queryType ? 'calc(100vh - 42px - 10rem)' : 'calc(100vh - 42px - 3rem)'
const logDataRows = useMemo(() => {
if (!hasLogDataFormat) return data
return Object.values(logMap).sort((a, b) => b.timestamp - a.timestamp)
if (hasId && hasTimestamp) {
return Object.values(logMap).sort((a, b) => b.timestamp - a.timestamp)
} else {
return dedupedData
}
}, [stringData])
return (
<section className="flex flex-1 flex-row" style={{ maxHeight }}>
<DataGrid
style={{ height: '100%' }}
className="flex-grow flex-1"
onSelectedCellChange={({ idx, rowIdx }) => {
if (!hasLogDataFormat) return
setFocusedLog(data[rowIdx] as LogData)
}}
noRowsFallback={
<div className="p-4">
<Typography.Text type="secondary" small className="font-mono">
No data returned from query
</Typography.Text>
<>
<section
className={'flex flex-1 flex-col ' + (!queryType ? 'shadow-lg' : '')}
style={{ maxHeight }}
>
{!queryType && (
<div>
<div
className="
w-full bg-scale-100 dark:bg-scale-300
rounded-tl rounded-tr
border-t
border-l
border-r
flex items-center justify-between
px-5 py-2
"
>
<div className="flex items-center gap-2">
{data && data.length ? (
<>
<span className="text-sm text-scale-1200">Query results</span>
<span className="text-sm text-scale-1100">{data && data.length}</span>
</>
) : (
<span className="text-xs text-scale-1200">Results will be shown below</span>
)}
</div>
<div className="flex items-center gap-2">
{onHistogramToggle && (
<Button
type="default"
icon={isHistogramShowing ? <IconEye /> : <IconEyeOff />}
onClick={onHistogramToggle}
>
Histogram
</Button>
)}
{showDownload && <CSVButton data={data}>Download</CSVButton>}
</div>
</div>
</div>
}
columns={columns as any}
rowClass={(r) => {
if (!hasLogDataFormat) return 'cursor-pointer'
const row = r as LogData
return `${row.id === focusedLog?.id ? 'bg-green-800' : 'cursor-pointer'}`
}}
rows={logDataRows}
rowKeyGetter={(r) => {
if (!hasLogDataFormat) return Object.keys(r)[0]
const row = r as LogData
return row.id
}}
onRowClick={(r) => {
if (!hasLogDataFormat) return
const row = r as LogData
setFocusedLog(logMap[row.id])
}}
/>
{focusedLog && (
<div className="w-2/5 flex flex-col">
<LogSelection onClose={() => setFocusedLog(null)} log={focusedLog} />
)}
<div
className={'flex flex-row flex-grow h-full ' + (!queryType ? 'border-l border-r' : '')}
>
<DataGrid
style={{ height: '100%' }}
className={`
flex-grow flex-1
${!queryType ? 'data-grid--logs-explorer' : ' data-grid--simple-logs'}
`}
rowHeight={40}
headerRowHeight={queryType ? 0 : 28}
onSelectedCellChange={({ idx, rowIdx }) => {
if (!hasId) return
setFocusedLog(data[rowIdx] as LogData)
}}
noRowsFallback={
!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
`}
>
<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 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"
>
<Input.TextArea
size="small"
value={JSON.stringify(error, null, 2)}
borderless
className="font-mono w-full mt-4"
copy
rows={12}
/>
</Alert>
</div>
)}
</div>
</>
) : null
}
columns={columns as any}
rowClass={(r) => {
const row = r as LogData
let classes = []
classes.push(
`${
row.id === focusedLog?.id ? '!bg-scale-400 rdg-row--focussed' : 'cursor-pointer'
}`
)
return classes.join(' ')
}}
rows={logDataRows}
rowKeyGetter={(r) => {
if (!hasId) return Object.keys(r)[0]
const row = r as LogData
return row.id
}}
onRowClick={(r) => setFocusedLog(r)}
/>
{logDataRows.length > 0 ? (
<div
className={
queryType
? 'w-1/2 flex flex-col'
: focusedLog
? 'w-1/2 flex flex-col'
: 'w-0 hidden'
}
>
<LogSelection
isLoading={isLoading}
onClose={() => setFocusedLog(null)}
log={focusedLog}
queryType={queryType}
/>
</div>
) : null}
</div>
)}
</section>
</section>
</>
)
}
export default LogTable
@@ -0,0 +1,77 @@
import { Button, Dropdown, IconClock } from '@supabase/ui'
import { DatePicker } from 'components/ui/DatePicker'
import { useState, useEffect } from 'react'
type ToFrom = { to: string; from: string }
interface Props {
to?: string
from?: string
onChange: ({ to, from }: ToFrom) => void
}
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 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.value === (helperValue || DEFAULT_HELPER_VALUE))
return (
<div className="flex items-center">
<Dropdown
size="small"
side="bottom"
align="start"
overlay={
<>
<Dropdown.RadioGroup
onChange={handleHelperChange}
value={helperValue || DEFAULT_HELPER_VALUE}
>
{HELPERS.map((helper) => (
<Dropdown.Radio key={helper.value} value={helper.value}>
{helper.text}
</Dropdown.Radio>
))}
</Dropdown.RadioGroup>
</>
}
>
<Button
as="span"
type={helperValue ? 'secondary' : 'default'}
icon={<IconClock size={12} />}
className="rounded-r-none"
>
{selectedHelper?.text}
</Button>
</Dropdown>
<DatePicker
triggerButtonClassName="rounded-l-none"
triggerButtonType={helperValue ? 'default' : 'secondary'}
onChange={(value: ToFrom) => {
setHelperValue('')
if (onChange) onChange(value)
}}
to={!helperValue ? to : undefined}
from={!helperValue ? from : undefined}
/>
</div>
)
}
export default DatePickers
@@ -0,0 +1,7 @@
const LogsDivider = () => {
return (
<div className="h-px w-full bg-panel-border-interior-light dark:bg-panel-border-interior-dark"></div>
)
}
export default LogsDivider
@@ -0,0 +1,73 @@
import dayjs from 'dayjs'
import { FC, useState } from 'react'
import { Button, IconChevronRight, IconMaximize2, IconPlay } from '@supabase/ui'
import Table from 'components/to-be-cleaned/Table'
import CodeEditor from 'components/ui/CodeEditor'
import { useRouter } from 'next/router'
interface Props {
item: any
}
const SavedQueriesItem: FC<Props> = ({ item }: Props) => {
const [expand, setExpand] = useState<boolean>(false)
const router = useRouter()
const { ref } = router.query
console.log('item in component', item)
return (
<>
<Table.tr key={item.id} className="expandable-tr">
<Table.td className="whitespace-nowrap">
<div className="flex items-center gap-2">
<button onClick={() => setExpand(!expand)}>
<div className={'transition ' + (expand ? 'rotate-90' : 'rotate-0')}>
<IconChevronRight strokeWidth={2} size={14} />
</div>
</button>
<span className="text-sm text-scale-1200">{item.name}</span>
</div>
</Table.td>
<Table.td className="">
<span className="text-scale-1100">{item.description}</span>
</Table.td>
<Table.td className="">
<span className="text-scale-1100">
{dayjs(item.inserted_at).format('DD MMM, YYYY HH:mm')}
</span>
</Table.td>
<Table.td className="">
<span className="text-scale-1100">
{dayjs(item.updated_at).format('DD MMM, YYYY HH:mm')}
</span>
</Table.td>
<Table.td className=" text-right">
<Button
type="alternative"
iconRight={<IconPlay size={10} />}
onClick={() =>
router.push(`/project/${ref}/logs-explorer?sql=${encodeURI(item.content.sql)}`)
}
>
Run
</Button>
</Table.td>
</Table.tr>
<>
<td
className={
'bg-scale-100 border-l border-r !pt-0 !pb-0 text-scale-1200 transition-all expanded-row-content ' +
(expand ? ' h-auto opacity-100' : 'h-0 opacity-0')
}
colSpan={5}
>
{/* <CodeEditor defaultValue={item.content.sql} language="pgsql" classname /> */}
{expand && <pre className="text-sm break-words py-4 px-3 ">{item.content.sql}</pre>}
</td>
</>
</>
)
}
export default SavedQueriesItem
@@ -15,185 +15,290 @@ export const TEMPLATES: LogTemplate[] = [
},
{
label: 'Commits By User',
description: 'Count of commits made by users on the database',
mode: 'custom',
searchString: `SELECT
searchString: `select
p.user_name, count(*) as count
FROM postgres_logs
LEFT JOIN UNNEST(metadata) as m ON TRUE
LEFT JOIN UNNEST(m.parsed) AS p ON TRUE
WHERE
REGEXP_CONTAINS(event_message, 'COMMIT')
GROUP BY
from postgres_logs
left join unnest(metadata) as m on true
left join unnest(m.parsed) as p on true
where
regexp_contains(event_message, 'COMMIT')
group by
p.user_name
`,
for: ['database'],
},
{
label: 'Metadata IP',
description: 'List all IP addresses that used the Supabase API',
mode: 'custom',
searchString: `SELECT timestamp, h.x_real_ip
FROM edge_logs
LEFT JOIN UNNEST(metadata) as m ON TRUE
LEFT JOIN UNNEST(m.request) AS r ON TRUE
LEFT JOIN UNNEST(r.headers) AS h ON TRUE
WHERE h.x_real_ip IS NOT NULL
searchString: `select timestamp, h.x_real_ip
from edge_logs
left join unnest(metadata) as m on true
left join unnest(m.request) as r on true
left join unnest(r.headers) as h on true
where h.x_real_ip is not null
`,
for: ['api'],
},
{
label: 'Requests by Country',
description: 'List all ISO 3166-1 alpha-2 country codes that used the Supabase API',
mode: 'custom',
searchString: `SELECT
searchString: `select
cf.country, count(*) as count
FROM edge_logs
LEFT JOIN UNNEST(metadata) as m ON TRUE
LEFT JOIN UNNEST(m.request) AS r ON TRUE
LEFT JOIN UNNEST(r.cf) AS cf ON TRUE
GROUP BY
from edge_logs
left join unnest(metadata) as m on true
left join unnest(m.request) as r on true
left join unnest(r.cf) as cf on true
group by
cf.country
ORDER BY
count DESC
order by
count desc
`,
for: ['api'],
},
{
label: 'Slow Response Time',
mode: 'custom',
description: 'List all Supabase API requests that are slow',
searchString: `select
timestamp,
event_message,
r.origin_time
FROM edge_logs
CROSS JOIN unnest(metadata) as m
CROSS JOIN unnest(m.response) as r
WHERE
from edge_logs
cross join unnest(metadata) as m
cross join unnest(m.response) as r
where
r.origin_time > 1000
ORDER BY
timestamp DESC
LIMIT 100
order by
timestamp desc
limit 100
`,
for: ['api'],
},
{
label: '500 Request Codes',
description: 'List all Supabase API requests that responded witha 5XX status code',
mode: 'custom',
searchString: `SELECT
timestamp,
event_message,
r.status_code
FROM edge_logs
from edge_logs
cross join unnest(metadata) as m
cross join unnest(m.response) as r
WHERE
where
r.status_code >= 500
ORDER BY
order by
timestamp desc
LIMIT 100
limit 100
`,
for: ['api'],
},
{
label: 'Top Paths',
description: 'List the most requested Supabase API paths',
mode: 'custom',
searchString: `SELECT
r.path as path,
r.search as params,
count(timestamp) as c
FROM edge_logs
from edge_logs
cross join unnest(metadata) as m
cross join unnest(m.request) as r
GROUP BY
group by
path,
params
ORDER BY
order by
c desc
LIMIT 100
limit 100
`,
for: ['api'],
},
{
label: 'REST Requests',
description: 'List all PostgREST requests',
mode: 'custom',
searchString: `SELECT
timestamp,
event_message
FROM edge_logs
from edge_logs
cross join unnest(metadata) as m
cross join unnest(m.request) as r
WHERE
where
path like '%rest/v1%'
ORDER BY
order by
timestamp desc
LIMIT 100
limit 100
`,
for: ['api'],
},
{
label: 'Errors',
description: 'List all Postgres error messages with ERROR, FATAL, or PANIC severity',
mode: 'custom',
searchString: `SELECT
t.timestamp,
p.error_severity,
event_message
FROM
from
postgres_logs as t
cross join unnest(metadata) as m
cross join unnest(m.parsed) as p
WHERE
where
p.error_severity in ('ERROR', 'FATAL', 'PANIC')
ORDER BY
order by
timestamp desc
LIMIT 100
limit 100
`,
for: ['database'],
},
{
label: 'Error Count by User',
mode: 'custom',
searchString: `SELECT
searchString: `select
count(t.timestamp) as count,
p.user_name,
p.error_severity
FROM
from
postgres_logs as t
cross join unnest(metadata) as m
cross join unnest(m.parsed) as p
WHERE
where
p.error_severity in ('ERROR', 'FATAL', 'PANIC')
GROUP BY
group by
p.user_name,
p.error_severity
ORDER BY
order by
count desc
LIMIT 100
limit 100
`,
for: ['database'],
},
]
export const LOG_TYPE_LABEL_MAPPING: { [k: string]: string } = {
explorer: "Explorer",
explorer: 'Explorer',
api: 'API',
database: 'Database',
}
export const genDefaultQuery = (table: string, where: string = ''): string => `SELECT
id, timestamp, event_message, metadata
FROM
${table}${where ? ' WHERE\n ' + where : ''}
LIMIT 100
export const genDefaultQuery = (table: LogsTableName, where: string | undefined) => {
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
cross join unnest(m.response) as response
${where}
limit 100
`
break
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
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
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
${where}
limit 100
`
default:
return ""
break
}
}
export const SQL_FILTER_TEMPLATES: any = {
postgres_logs: {
'severity.error': `metadataParsed.error_severity in ('ERROR', 'FATAL', 'PANIC')`,
'severity.noError': `metadataParsed.error_severity not in ('ERROR', 'FATAL', 'PANIC')`,
'severity.log': `metadataParsed.error_severity = 'LOG'`,
},
edge_logs: {
'status_code.error': `response.status_code between 500 and 599`,
'status_code.success': `response.status_code between 200 and 299`,
'status_code.warning': `response.status_code between 400 and 499`,
'product.database': `request.path like '/rest/%' or request.path like '/graphql/%'`,
'product.storage': `request.path like '/storage/%'`,
'product.auth': `request.path like '/auth/%'`,
'product.realtime': `request.path like '/realtime/%'`,
'method.get': `request.method = 'GET'`,
'method.post': `request.method = 'POST'`,
'method.del': `request.method = 'DEL'`,
'method.options': `request.method = 'OPTIONS'`,
},
function_edge_logs: {
'status_code.error': `response.status_code between 500 and 599`,
'status_code.success': `response.status_code between 200 and 299`,
'status_code.warning': `response.status_code between 400 and 499`,
},
function_logs: {
'severity.error': `metadata.level = 'error'`,
'severity.notError': `metadata.level != 'error'`,
'severity.log': `metadata.level = 'log'`,
'severity.info': `metadata.level = 'info'`,
'severity.debug': `metadata.level = 'debug'`,
},
}
// export const genDefaultQuery = (table: string, where: string = ''): string => `SELECT
// id, timestamp, event_message, metadata
// FROM
// ${table}${where ? ' WHERE\n ' + where : ''}
// LIMIT 100
// `
// export const cleanQuery = (str: string) => str.replaceAll(/\n/g, ' ')
export const cleanQuery = (str: string) => str.replace(/\n/g, ' ')
// .replace(/\n.*\-\-.*(\n)?$?/, "")
export enum LogsTableName {
EDGE = 'edge_logs',
POSTGRES = 'postgres_logs',
FUNCTIONS = 'function_logs',
FN_EDGE = 'function_edge_logs',
}
export const LOGS_TABLES = {
api: LogsTableName.EDGE,
database: LogsTableName.POSTGRES,
functions: LogsTableName.FUNCTIONS,
fn_edge: LogsTableName.FN_EDGE,
}
export const LOGS_SOURCE_DESCRIPTION = {
[LogsTableName.EDGE]: 'Logs obtained from the network edge, containing all API requests.',
[LogsTableName.POSTGRES]: 'Database logs obtained directly from Postgres.',
[LogsTableName.FUNCTIONS]: 'Function logs generated from runtime execution.',
[LogsTableName.FN_EDGE]: 'Function call logs, containing the request and response.',
}
export const genCountQuery = (table: string): string => `SELECT count(*) as count FROM ${table}`
export const genQueryParams = (params: { [k: string ]: string }) => {
export const genQueryParams = (params: { [k: string]: string }) => {
// remove keys which are empty strings, null, or undefined
for (const k in params) {
const v = params[k]
@@ -204,3 +309,175 @@ export const genQueryParams = (params: { [k: string ]: string }) => {
const qs = new URLSearchParams(params).toString()
return qs
}
interface Filter {
label: string
key: string
options: {
key: string
label: string
description?: string
}[]
}
type FilterOptions = {
[table: string]: {
[filterName: string]: Filter
}
}
export const FILTER_OPTIONS: FilterOptions = {
// Postgres logs
postgres_logs: {
severity: {
label: 'Severity',
key: 'severity',
options: [
{
key: 'error',
label: 'Error',
description: 'Show all events with ERROR, PANIC, or FATAL',
},
{
key: 'noError',
label: 'No Error',
description: 'Show all non-error events',
},
{
key: 'log',
label: 'Log',
description: 'Show all events that are log severity',
},
],
},
},
// Edge logs
edge_logs: {
status_code: {
label: 'Status',
key: 'status_code',
options: [
{
key: 'error',
label: 'Error',
description: '500 error codes',
},
{
key: 'success',
label: 'Success',
description: '200 codes',
},
{
key: 'warning',
label: 'Warning',
description: '400 codes',
},
],
},
product: {
label: 'Product',
key: 'product',
options: [
{
key: 'database',
label: 'Database',
description: '',
},
{
key: 'auth',
label: 'Auth',
description: '',
},
{
key: 'storage',
label: 'Storage',
description: '',
},
{
key: 'realtime',
label: 'Realtime',
description: '',
},
],
},
method: {
label: 'Method',
key: 'method',
options: [
{
key: 'get',
label: 'GET',
description: '',
},
{
key: 'options',
label: 'OPTIONS',
description: '',
},
{
key: 'post',
label: 'POST',
description: '',
},
],
},
},
// function_edge_logs
function_edge_logs: {
status_code: {
label: 'Status',
key: 'status_code',
options: [
{
key: 'error',
label: 'Error',
description: '500 error codes',
},
{
key: 'success',
label: 'Success',
description: '200 codes',
},
{
key: 'warning',
label: 'Warning',
description: '400 codes',
},
],
},
},
// function_logs
function_logs: {
severity: {
label: 'Severity',
key: 'severity',
options: [
{
key: 'error',
label: 'Error',
description: 'Show all events that have error severity',
},
{
key: 'info',
label: 'Info',
description: 'Show all events that have error severity',
},
{
key: 'debug',
label: 'Debug',
description: 'Show all events that have error severity',
},
{
key: 'log',
label: 'Log',
description: 'Show all events that are log severity',
},
],
},
},
}
export const LOGS_TAILWIND_CLASSES = {
log_selection_x_padding: 'px-8',
space_y: 'px-6',
}
@@ -0,0 +1,141 @@
import { Button, Checkbox, Form, Popover } from '@supabase/ui'
import { pluckObjectFields } from 'lib/helpers'
import { useState } from 'react'
export const LogsFilter = ({ options, filtersState, dispatchFilters, classes }: any) => {
const [open, setOpen] = useState(false)
function handleReset() {
// console.log('handleReset ran!')
const empty = {
[options.key]: [],
}
dispatchFilters({ ...empty })
setOpen(!open)
}
// console.log('filters', filters)
// console.log('item in filter', options)
// console.log('options.key', options.key)
const filteredKey = filtersState[options.key]
// loop through options in saved filters
// and check if any options are active
function checkIsActive() {
let active = false
options.options.map((x: any) => {
if (filteredKey && filteredKey.includes(x.key)) {
active = true
}
})
return active
}
const isActive = checkIsActive()
function initialValues() {
let compiledOptions = {}
options.options.map((x: any) => {
compiledOptions = {
...compiledOptions,
[x.key]: filteredKey && filteredKey.includes(x.key) ? true : false,
}
})
return compiledOptions
}
return (
<div className="flex items-center">
<Popover
open={open}
align="end"
size="medium"
onOpenChange={() => setOpen(!open)}
overlay={
<>
<Form
initialValues={initialValues()}
onSubmit={(values: any, { setSubmitting, handleReset }: any) => {
setSubmitting(true)
setOpen(false)
// console.log('values in Logs.filter submit', values)
const payload: any = {
[options.key]: [],
}
Object.keys(values).map((filterKey) => {
if (values[filterKey]) {
payload[options.key].push(filterKey)
}
})
console.log('payload', payload)
// console.log('submit form ran')
dispatchFilters({ ...payload })
// handleReset()
}}
>
{({ isSubmitting }: { isSubmitting: boolean }) => (
<>
<div className="py-6 space-y-4">
{options.options.map((x: any, i: number) => {
return (
<>
<Checkbox
id={x.key}
className="px-3"
label={x.label}
description={x.description}
size="medium"
/>
{i !== options.options.length - 1 && <Popover.Seperator />}
</>
)
})}
</div>
<div
className="flex items-center justify-end gap-2
bg-scale-200 dark:bg-scale-400 py-2 px-3
border-t border-scale-400 dark:border-scale-500
"
>
<Button
size="tiny"
type="default"
onClick={() => handleReset()}
htmlType="button"
>
Clear
</Button>
<Button loading={isSubmitting} type="primary" htmlType="submit">
Save
</Button>
</div>
</>
)}
</Form>
</>
}
portalled
showClose
side="bottom"
>
<Button
as="span"
type={isActive ? 'secondary' : 'default'}
onClick={() => setOpen(!open)}
className={classes}
>
{options.label}
</Button>
</Popover>
</div>
)
}
@@ -0,0 +1,78 @@
import { LogsTableName, SQL_FILTER_TEMPLATES } from './Logs.constants'
import { FilterObject, Override } from './Logs.types'
export function filterSqlWhereBuilder(
filters: FilterObject | any,
table: LogsTableName,
override?: Override,
search?: string
) {
// remove any filter arrays that are empty
const filtersSanitized: any = Object.values(filters).filter((x: any) => x && x.length > 0)
const keys = Object.keys(filters)
const hasOverride = override && override.value && override.key
// build array of where statements
// first item is `where` if there are any filters
let whereArray: string[] =
(filtersSanitized.length > 0 || hasOverride) && !search ? ['where'] : search ? ['and'] : []
if (filtersSanitized.length === 0 && !hasOverride) return whereArray
if (hasOverride) {
// count = +1
const sql = `(${override?.key}='${override?.value}')`
whereArray.push(sql)
if (filtersSanitized.length > 0) whereArray.push('and')
}
if (filtersSanitized.length === 0) return whereArray
keys.map((x: string, i) => {
let count = 0
// do not parse empty key
if (!x) return
const filterKeyArray: string[] = []
filters[x].map((value: string, i: number) => {
// first line should be WHERE
const last = i === filters[x].length - 1
if (count === 0) {
count = +1
// the first argument begins with a `(`
// if this is also the last argument, it is closed with a `)`
const sql = `( ${SQL_FILTER_TEMPLATES[table][`${x}.${value}`]}${last ? ' ) ' : ''}`
filterKeyArray.push(sql)
return sql
} else {
count = +1
// remaining lines use `or` as standard
// if this is also the last argument, it is closed with a `)`
const sql = ` or ${SQL_FILTER_TEMPLATES[table][`${x}.${value}`]}${last ? ' ) ' : ''}`
filterKeyArray.push(sql)
return sql
}
})
whereArray.push(...filterKeyArray)
// if there are multiple filters in an 'and' sequence
// then `and` is inserted between them
if (filtersSanitized.length > 0 && i < filtersSanitized.length - 1) {
whereArray.push(' and ')
}
return filterKeyArray
})
return whereArray
}
export function filterReducer(state: FilterObject, action: any) {
const oldState = { ...state }
const newState = { ...oldState, ...action }
return newState
}
@@ -21,10 +21,12 @@ export interface LogData {
timestamp: number
event_message: string
metadata: Metadata
[other: string]: unknown
}
export interface LogTemplate {
label?: string
description?: string
mode: 'custom' | 'simple'
for?: string[]
searchString: string
@@ -44,3 +46,26 @@ type LFResponse<T> = {
export type Count = LFResponse<CountData>
export type Logs = LFResponse<LogData>
export type QueryType = 'api' | 'database' | 'functions' | 'fn_edge'
export type Mode = 'simple' | 'custom'
export type Table = 'edge_logs' | 'postgres_logs'
export interface FilterObject {
// severity?: string[]
// status_code?: string[]
// `q` for the editor query.
q?: string
// `s` for search query.
s?: string
// `te` for timestamp start value.
te?: string
}
export type Override = {
key: string
value: string | string[] | undefined
}
@@ -0,0 +1,215 @@
/*
* Response Code
*
* for http response codes
*/
import { IconAlertCircle, IconInfo } from '@supabase/ui'
export const ResponseCodeFormatter = ({ value }: any) => {
if (!value) {
return (
<div>
<label className="text-xs text-scale-800">No data</label>
</div>
)
}
const split = value.toString().split('')[0]
// console.log(split)
switch (split) {
// 2XX || 1XX responses
case '1':
case '2':
return (
<div className="flex items-center h-full">
<div
className="relative rounded px-2 py-1 text-center h-6 flex justify-center items-center
bg-scale-300 dark:bg-scale-600
"
>
<label className="block font-mono text-sm text-scale-900">{value}</label>
</div>
</div>
)
break
// 5XX responses
case '5':
return (
<div className="flex items-center h-full">
<div
className="relative rounded px-2 py-1 text-center h-6 flex justify-center items-center
bg-red-400
"
>
<label className="block font-mono text-sm text-red-1100">{value}</label>
</div>
</div>
)
break
// 4XX || 3XX responses
case '4':
case '3':
return (
<div className="flex items-center h-full">
<div
className="relative rounded px-2 py-1 text-center h-6 flex justify-center items-center
bg-amber-400
"
>
<label className="block font-mono text-sm text-amber-1100 dark:text-amber-900">
{value}
</label>
</div>
</div>
)
break
// All other responses
default:
return (
<div className="flex items-center h-full">
<div
className="relative rounded px-2 py-1 text-center h-6 flex justify-center items-center
bg-scale-300
"
>
<label className="block font-mono text-sm text-scale-900">{value}</label>
</div>
</div>
)
break
}
}
/*
* Response Code
*
* for http response codes
*/
export const SeverityFormatter = ({ value }: { value: string }) => {
if (!value) {
return (
<div>
<label className="text-xs text-scale-800">No data</label>
</div>
)
}
value = value.toUpperCase()
switch (value) {
case 'PANIC':
case 'FATAL':
case 'ERROR':
return (
<div className="flex items-center h-full gap-1">
<div className=" p-0.5 rounded !text-red-900">
<IconAlertCircle size={14} strokeWidth={2} />
</div>
<span className="!text-red-900 !block titlecase">{value}</span>
</div>
)
break
case 'INFO':
case 'DEBUG':
return (
<div className="flex items-center h-full gap-1">
<div className=" p-0.5 rounded !text-blue-900">
<IconAlertCircle size={14} strokeWidth={2} />
</div>
<span className="!text-blue-900 !block titlecase">{value}</span>
</div>
)
break
case 'LOG':
return (
<div className="flex items-center h-full gap-1">
<div className=" p-0.5 rounded !text-blue-900">
<IconInfo size={14} strokeWidth={2} />
</div>
<span className="!text-blue-900 !block titlecase">{value}</span>
</div>
)
break
case 'WARNING':
return (
<div className="flex items-center h-full gap-1">
<div className=" p-0.5 rounded !text-amber-900">
<IconAlertCircle size={14} strokeWidth={2} />
</div>
<span className="!text-amber-900 !block titlecase">{value}</span>
</div>
)
break
// All other responses
default:
return (
<div className="flex items-center h-full">
<div className="relative rounded px-2 py-1 text-center h-6 flex justify-center items-center bg-scale-300">
<label className="block font-mono text-sm text-scale-900">{value}</label>
</div>
</div>
)
break
}
}
/*
* Header Formatter
*
* for http response codes
*/
export const HeaderFormmater = ({ value }: any) => {
return <div className="text-scale-900 font-normal flex items-center text-xs h-full">{value}</div>
}
/*
* JSON Syntax Highlighter
*
* for http response codes
*/
export function jsonSyntaxHighlight(input: Object) {
let json: string = JSON.stringify(input, null, 2)
json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// console.log('json', json)
const newJson = json.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
function (match) {
var cls = 'number text-tomato-900'
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = 'key text-scale-1200'
} else {
cls = 'string text-brand-1100'
}
} else if (/true|false/.test(match)) {
cls = 'boolean text-blue-900'
} else if (/null/.test(match)) {
cls = 'null text-amber-1100'
}
return '<span class="' + cls + '">' + match + '</span>'
}
)
const jsonWithLineWraps = newJson.split(`\n`).map((x) => {
return `<span class="line text-xs">${x}</span>`
})
return jsonWithLineWraps.join('\n')
}
@@ -0,0 +1,32 @@
import { Tabs } from '@supabase/ui'
import { useFlag } from 'hooks'
import { useRouter } from 'next/router'
const LogsNavigation = () => {
const router = useRouter()
const activeRoute = router.pathname.split('/')[4]
const { ref } = router.query
const indexRoute = 'query'
const logsSourcesPage = useFlag('logsSourcesPage')
const logsSavedQueries = useFlag('logsSavedQueries')
return (
<Tabs
defaultActiveId="1"
type="underlined"
size="medium"
activeId={!activeRoute ? indexRoute : activeRoute}
onClick={(e: string) => {
router.push(`/project/${ref}/logs-explorer/${e === indexRoute ? '' : e}`)
}}
>
<Tabs.Panel id="query" label="Query" />
{logsSourcesPage && <Tabs.Panel id="sources" label="Sources" />}
{/* <Tabs.Panel id="recent" label={'Recent'} /> */}
{logsSavedQueries && <Tabs.Panel id="saved" label="Saved" />}
<Tabs.Panel id="templates" label="Templates" />
</Tabs>
)
}
export default LogsNavigation
@@ -0,0 +1,232 @@
import React, { useEffect, useReducer, useState } from 'react'
import { useRouter } from 'next/router'
import {
Typography,
IconLoader,
IconAlertCircle,
IconRewind,
Button,
Card,
Input,
} from '@supabase/ui'
import {
LogTable,
LogTemplate,
TEMPLATES,
LogSearchCallback,
LogsTableName,
filterSqlWhereBuilder,
filterReducer,
QueryType,
LogEventChart,
} 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 ShimmeringLoader from 'components/ui/ShimmeringLoader'
import ShimmerLine from 'components/ui/ShimmerLine'
import LoadingOpacity from 'components/ui/LoadingOpacity'
/**
* Acts as a container component for the entire log display
*
* ## Query Params Syncing
* Query params are synced on query submission.
*
* params used are:
* - `s` for search query.
* - `te` for timestamp start value.
*/
interface Props {
projectRef: string
queryType: QueryType
override?: Override
condensedLayout?: boolean
tableName?: LogsTableName
}
export const LogsPreviewer: React.FC<Props> = ({
projectRef,
queryType,
override,
condensedLayout = false,
tableName,
}) => {
const router = useRouter()
const { s, te, ts } = router.query
const [showChart, setShowChart] = useState(true)
const [whereFilters, dispatchWhereFilters] = useReducer(filterReducer, {})
const table = !tableName ? LOGS_TABLES[queryType] : tableName
const [
{ error, logData, params, newCount, filters, isLoading, oldestTimestamp },
{ loadOlder, setFilters, refresh, setTo, setFrom },
] = useLogsPreview(projectRef as string, table, {
initialFilters: { search_query: s as string },
whereStatementFactory: (filterObj) => `${
filterObj.search_query
? `where REGEXP_CONTAINS(event_message, '${filterObj.search_query}')`
: ''
}
${filterSqlWhereBuilder(whereFilters, table, override, filterObj.search_query).join('\n')}`,
})
useEffect(() => {
setFilters((prev) => ({ ...prev, search_query: s as string }))
if (te) {
setTo(te as string)
} else {
setTo('')
}
if (ts) {
setFrom(ts as string)
} else {
setFrom('')
}
}, [s, te, ts])
const onSelectTemplate = (template: LogTemplate) => {
setFilters((prev) => ({ ...prev, search_query: template.searchString }))
}
useEffect(() => {
// runs when any of the filters change
handleRefresh()
}, [whereFilters])
const handleRefresh = () => {
refresh()
router.push({
pathname: router.pathname,
query: {
...router.query,
te: undefined,
ts: 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 || '' }))
router.push({
pathname: router.pathname,
query: {
...router.query,
s: query || '',
ts: fromValue,
te: toValue,
},
})
}
return (
<div className="h-full flex flex-col flex-grow">
<PreviewFilterPanel
csvData={logData}
isLoading={isLoading}
newCount={newCount}
templates={TEMPLATES.filter(
(template) => queryType && template.for?.includes(queryType) && template.mode === 'simple'
)}
onRefresh={handleRefresh}
onSearch={handleSearch}
defaultSearchValue={filters.search_query}
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()
: ''
}
onExploreClick={() => {
router.push(`/project/${projectRef}/logs-explorer?q=${params.rawSql}`)
}}
onSelectTemplate={onSelectTemplate}
dispatchWhereFilters={dispatchWhereFilters}
whereFilters={whereFilters}
table={table}
condensedLayout={condensedLayout}
isShowingEventChart={showChart}
onToggleEventChart={() => setShowChart(!showChart)}
/>
<div
className={
'transition-all duration-500 ' +
(showChart && !isLoading && logData.length > 0
? 'opacity-100 h-24 pt-4 mb-4'
: 'opacity-0 h-0')
}
>
<div className={condensedLayout ? 'px-4' : ''}>
{showChart && (
<LogEventChart
data={!isLoading ? logData : undefined}
onBarClick={(timestampMicro) => {
handleSearch({ query: filters.search_query, toMicro: timestampMicro })
}}
/>
)}
</div>
</div>
<div className="flex flex-col flex-grow relative pt-4">
<ShimmerLine active={isLoading} />
<LoadingOpacity active={isLoading}>
<LogTable
isLoading={isLoading}
data={logData}
queryType={queryType}
isHistogramShowing={showChart}
onHistogramToggle={() => setShowChart(!showChart)}
/>
</LoadingOpacity>
{!error && (
<div className="p-2">
<Button onClick={loadOlder} icon={<IconRewind />} type="default">
Load older
</Button>
</div>
)}
{error && (
<div className="flex w-full h-full justify-center items-center mx-auto">
<Card className="flex flex-col gap-y-2 w-2/5 bg-scale-400">
<div className="flex flex-row gap-x-2 py-2">
<IconAlertCircle size={16} />
<Typography.Text type="secondary">
Sorry! An error occured when fetching data.
</Typography.Text>
</div>
<Input.TextArea
label="Error Messages"
value={JSON.stringify(error, null, 2)}
borderless
className=" border-t-2 border-scale-800 pt-2 font-mono"
/>
</Card>
</div>
)}
</div>
</div>
)
}
export default LogsPreviewer
@@ -0,0 +1,95 @@
import { Button, Dropdown, Typography, IconChevronDown, IconPlay } from '@supabase/ui'
import { LogsTableName, LOGS_SOURCE_DESCRIPTION, LogTemplate } from '.'
interface Props {
templates?: LogTemplate[]
onSelectTemplate: (template: LogTemplate) => void
onSelectSource: (source: LogsTableName) => void
onRun: () => void
onClear: () => void
onSave?: () => void
hasEditorValue: boolean
isLoading: boolean
}
const LogsQueryPanel: React.FC<Props> = ({
templates = [],
onSelectTemplate,
hasEditorValue,
onRun,
onClear,
onSave,
onSelectSource,
isLoading,
}) => (
<div
className="
border
border-panel-border-light dark:border-panel-border-dark rounded rounded-bl-none rounded-br-none
bg-panel-header-light dark:bg-panel-header-dark
"
>
<div className="px-5 py-2 flex items-center justify-between w-full">
<div className="flex flex-row gap-x-4 items-center justify-between w-full">
<div className="flex items-center gap-2">
<Dropdown
side="bottom"
align="start"
overlay={Object.values(LogsTableName).map((source) => (
<Dropdown.Item key={source} onClick={() => onSelectSource(source)}>
<div className="flex flex-col gap-1">
<span className="font-mono text-white font-bold">{source}</span>
<span className="text-scale-1100">{LOGS_SOURCE_DESCRIPTION[source]}</span>
</div>
</Dropdown.Item>
))}
>
<Button as="span" type="default" iconRight={<IconChevronDown />}>
Insert source
</Button>
</Dropdown>
<Dropdown
side="bottom"
align="start"
overlay={templates.map((template: LogTemplate) => (
<Dropdown.Item key={template.label} onClick={() => onSelectTemplate(template)}>
<Typography.Text>{template.label}</Typography.Text>
</Dropdown.Item>
))}
>
<Button as="span" type="default" iconRight={<IconChevronDown />}>
Templates
</Button>
</Dropdown>
</div>
<div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Button type="default" onClick={onClear}>
Clear query
</Button>
{onSave && (
<Button type="default" onClick={() => onSave()} disabled={!hasEditorValue}>
Save query
</Button>
)}
</div>
<Button
type={hasEditorValue ? 'primary' : 'alternative'}
disabled={!hasEditorValue}
onClick={onRun}
iconRight={<IconPlay />}
loading={isLoading}
>
Run
</Button>
</div>
</div>
</div>
</div>
</div>
)
export default LogsQueryPanel
@@ -0,0 +1,206 @@
import React, { FC, useEffect, useState } from 'react'
import {
Button,
Input,
Typography,
IconRefreshCw,
IconSearch,
IconExternalLink,
IconEye,
IconEyeOff,
} from '@supabase/ui'
import { LogSearchCallback, LogTemplate } from '.'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import { FILTER_OPTIONS, LogsTableName } from './Logs.constants'
import { LogsFilter } from './Logs.filter'
import DatePickers from './Logs.DatePickers'
import CSVButton from 'components/ui/CSVButton'
interface Props {
defaultSearchValue?: string
defaultToValue?: string
defaultFromValue?: string
templates?: any
isLoading: boolean
newCount: number
onRefresh?: () => void
onSearch?: LogSearchCallback
onExploreClick?: () => void
onSelectTemplate: (template: LogTemplate) => void
dispatchWhereFilters: (x: any) => void
whereFilters: any
table: LogsTableName
condensedLayout: Boolean
isShowingEventChart: boolean
onToggleEventChart: () => void
csvData?: unknown[]
}
dayjs.extend(utc)
/**
* Logs control panel header + wrapper
*/
const PreviewFilterPanel: FC<Props> = ({
isLoading,
newCount,
onRefresh,
onSearch = () => {},
defaultSearchValue = '',
defaultToValue = '',
defaultFromValue = '',
onExploreClick,
dispatchWhereFilters,
whereFilters: filters,
table,
condensedLayout,
isShowingEventChart,
onToggleEventChart,
csvData,
}) => {
const [search, setSearch] = useState('')
const hasEdits = search !== defaultSearchValue
// Sync local state with provided default value
useEffect(() => {
if (search !== defaultSearchValue) {
setSearch(defaultSearchValue)
}
}, [defaultSearchValue])
const RefreshButton = () => (
<Button
type="default"
icon={
<div className="relative">
{newCount > 0 && (
<div
className={[
'absolute flex items-center justify-center -top-3 right-3',
'h-4 w-4 z-50',
].join(' ')}
>
<div className="absolute z-20">
<Typography.Text style={{ fontSize: '0.6rem' }} className="opacity-80">
{newCount}
</Typography.Text>
</div>
<div className="bg-green-800 rounded-full w-full h-full animate-ping opacity-60"></div>
<div className="absolute z-60 top-0 right-0 bg-green-900 opacity-80 rounded-full w-full h-full"></div>
</div>
)}
<IconRefreshCw size={10} />
</div>
}
loading={isLoading}
disabled={isLoading}
onClick={onRefresh}
>
Refresh
</Button>
)
const handleSearch = (partial: Partial<{ query: string; to: string; from: string }>) =>
onSearch({ query: search, ...partial })
return (
<div
className={'flex items-center justify-between w-full' + (condensedLayout ? ' px-5 pt-4' : '')}
>
<div className="flex flex-row gap-4 items-center">
<form
id="log-panel-search"
onSubmit={(e) => {
// prevent redirection
e.preventDefault()
handleSearch({})
}}
>
<Input
className="w-60"
size="tiny"
placeholder="Search events"
onChange={(e) => setSearch(e.target.value)}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
setSearch(e.target.value)
handleSearch({ query: e.target.value })
}}
icon={
<div className="text-scale-900">
<IconSearch size={14} />
</div>
}
value={search}
actions={
hasEdits && (
<button
onClick={() => handleSearch({})}
className="text-scale-1100 hover:text-scale-1200 mx-2"
>
{'↲'}
</button>
)
}
/>
</form>
<DatePickers
onChange={async (e: { to: string; from: string }) => {
handleSearch(e)
}}
to={defaultToValue}
from={defaultFromValue}
/>
<div>
<RefreshButton />
</div>
<div className="flex items-center">
{FILTER_OPTIONS[table] &&
Object.values(FILTER_OPTIONS[table]).map((x, i: number) => {
const classes = []
if (Object.values(FILTER_OPTIONS[table]).length >= 2) {
if (i === 0) {
classes.push('rounded-tr-none rounded-br-none')
} else if (i === Object.values(FILTER_OPTIONS[table]).length - 1) {
classes.push('rounded-tl-none rounded-bl-none')
} else {
classes.push('rounded-none')
}
}
return (
<LogsFilter
classes={classes.join(' ')}
key={`${x.key}-filter`}
options={x}
dispatchFilters={dispatchWhereFilters}
filtersState={filters}
/>
)
})}
</div>
<div className="flex items-center space-x-2">
<Button
type="default"
onClick={() => onToggleEventChart()}
icon={isShowingEventChart ? <IconEye /> : <IconEyeOff />}
>
Chart
</Button>
</div>
<CSVButton data={csvData} disabled={!Boolean(csvData)} title="Download data" />
</div>
<Button type="secondary" onClick={onExploreClick} iconRight={<IconExternalLink size={10} />}>
Explore via query
</Button>
</div>
)
}
export default PreviewFilterPanel
@@ -1,5 +1,8 @@
export * from './Logs.types'
export * from './Logs.constants'
export {default as LogPanel} from "./LogPanel"
export {default as LogTable} from "./LogTable"
export {default as LogEventChart} from "./LogEventChart"
export * from './Logs.helpers'
export * from './Logs.filter'
export { default as LogsSavedQueriesItem } from './Logs.SavedQueriesItem'
export { default as LogsQueryPanel } from './LogsQueryPanel'
export { default as LogTable } from './LogTable'
export { default as LogEventChart } from './LogEventChart'
@@ -0,0 +1,55 @@
import { IconCheck, IconClipboard } from '@supabase/ui'
import { useState } from 'react'
const CommandRender = ({ commands }: any) => {
console.log('commands', commands)
return (
<div className="space-y-4">
{commands.map((item: any) => {
const [isCopied, setIsCopied] = useState(false)
return (
<div>
<span className="font-mono text-xs text-scale-900">{`> ${item.comment}`}</span>
<div className="flex items-center gap-2">
<div className="flex gap-2 text-scale-1200 font-mono text-sm font-normal">
<span className="text-scale-900">$</span>
<span>
<span className="">{item.jsx ? item.jsx() : null} </span>
<button
type="button"
className="text-scale-900 hover:text-scale-1200"
onClick={() => {
function onCopy(value: any) {
setIsCopied(true)
navigator.clipboard.writeText(value).then()
setTimeout(function () {
setIsCopied(false)
}, 3000)
}
onCopy(item.command)
}}
>
{isCopied ? (
<div className="text-brand-900">
<IconCheck size={14} strokeWidth={3} />
</div>
) : (
<div className="relative">
<div className="block">
<IconClipboard size={14} />
</div>
</div>
)}
</button>
</span>
</div>
</div>
<span className="text-xs text-scale-900">{item.description}</span>
</div>
)
})}
</div>
)
}
export default CommandRender
@@ -0,0 +1,118 @@
import BaseLayout from 'components/layouts'
import { observer } from 'mobx-react-lite'
import { useStore, withAuth } from 'hooks'
import { IconAlertCircle, IconCode, IconSlash, Loading, Tabs } from '@supabase/ui'
import { Tab } from '@headlessui/react'
import FunctionsNav from './FunctionsNav'
import React, { useEffect } from 'react'
import { useRouter } from 'next/router'
import Link from 'next/link'
import WarningBanner from 'components/ui/WarningBanner'
import { WARNING_MESSAGE } from './Functions.Constants'
const PageLayout = ({ children }: { children?: React.ReactNode }) => {
const { functions, ui } = useStore()
const router = useRouter()
const { id, ref } = router.query
useEffect(() => {
functions.load()
}, [ui.selectedProject])
if (!functions.list()) {
return (
<BaseLayout>
<Loading active={true}>loading</Loading>
</BaseLayout>
)
}
const item = functions.byId(id)
const name = item?.name || ''
return (
<BaseLayout>
<WarningBanner title={WARNING_MESSAGE.title} description={WARNING_MESSAGE.description} />
<div className="h-full flex flex-col flex-grow py-10">
<div
className="
w-full mx-auto
transition-all
px-5
lg:px-20
xl:px-24
1xl:px-28
2xl:px-32
flex flex-col
gap-8
"
>
<div className="flex items-center gap-3">
<div
className="w-6 h-6 bg-brand-300 border border-brand-600 rounded text-brand-900
flex items-center justify-center
"
>
<IconCode size={14} strokeWidth={3} />
</div>
<Link href={`/project/${ref}/functions`}>
<h1
className="
transition-colors
text-2xl text-scale-1200
cursor-pointer
hover:text-scale-1100"
>
Functions
</h1>
</Link>
<span className="text-scale-800 dark:text-scale-700">
<svg
viewBox="0 0 24 24"
width="16"
height="16"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
shapeRendering="geometricPrecision"
>
<path d="M16 3.549L7.12 20.600"></path>
</svg>
</span>
<h5 className="text-2xl text-scale-1200">{name}</h5>
</div>
<FunctionsNav item={item} />
</div>
<div
// weird repetative styling
className="
h-full flex flex-col flex-grow
w-full mx-auto
transition-all
px-5
lg:px-20
xl:px-24
1xl:px-28
2xl:px-32
"
>
{children}
</div>
</div>
</BaseLayout>
)
}
export default withAuth(observer(PageLayout))
@@ -0,0 +1,44 @@
import dayjs from 'dayjs'
import { FC } from 'react'
import { observer } from 'mobx-react-lite'
import { Badge, IconClipboard, IconExternalLink, IconGlobe, Typography } from '@supabase/ui'
import Table from 'components/to-be-cleaned/Table'
import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock'
import router, { useRouter } from 'next/router'
// import logDropdown from './logDropdown'
interface Props {
log: any
onClick: () => void
}
const FunctionLogsItem: FC<Props> = ({ log, onClick }) => {
const islogConfirmed = log.email_confirmed_at || log.phone_confirmed_at
const router = useRouter()
const { ref } = router.query
return (
<Table.tr key={log.id} onClick={onClick}>
<Table.td className="whitespace-nowrap">
<div className="flex items-center gap-2">
<div className="bg-scale-500 px-2 rounded">{log.function_status}</div>
</div>
</Table.td>
<Table.td className="">
<span className="text-xs text-scale-1100">{log.method}</span>
</Table.td>
<Table.td className="">
<span className="text-xs text-scale-1100 font-mono">{log.url}</span>
</Table.td>
<Table.td className="">
<span className="text-scale-1100">
{dayjs(log.created_at).format('DD MMM, YYYY HH:mm')}
</span>
</Table.td>
</Table.tr>
)
}
export default observer(FunctionLogsItem)
@@ -0,0 +1,4 @@
export const WARNING_MESSAGE = {
title: ' Supabase Functions is pre release',
description: 'Functions are currently experimental, and will be prone to breaking changes',
}
@@ -0,0 +1,9 @@
export interface Function {
id: string
slug: string
name: string
version: 9
status: 'ACTIVE' | 'INACTIVE' | 'THROTTLE'
created_at: number
updated_at: number
}
@@ -0,0 +1,86 @@
import BaseLayout from 'components/layouts'
import { observer } from 'mobx-react-lite'
import { useStore, withAuth } from 'hooks'
import { IconCode, IconSlash, Loading, Tabs } from '@supabase/ui'
import { Tab } from '@headlessui/react'
import FunctionsNav from './FunctionsNav'
import React, { useEffect } from 'react'
import { useRouter } from 'next/router'
import WarningBanner from 'components/ui/WarningBanner'
import { WARNING_MESSAGE } from './Functions.Constants'
const PageLayout = ({ children, centered }: { children?: React.ReactNode; centered?: boolean }) => {
const { functions, ui } = useStore()
const router = useRouter()
const { id } = router.query
useEffect(() => {
functions.load()
}, [ui.selectedProject])
if (!functions.isInitialized) {
return (
<BaseLayout>
<Loading active={true}>loading</Loading>
</BaseLayout>
)
}
return (
<BaseLayout>
<WarningBanner title={WARNING_MESSAGE.title} description={WARNING_MESSAGE.description} />
{centered ? (
<>
<div className="mx-auto max-w-5xl py-32 px-5">
<div className="flex items-center gap-3">
<div
className="w-6 h-6 bg-brand-300 border border-brand-600 rounded text-brand-900
flex items-center justify-center
"
>
<IconCode size={14} strokeWidth={3} />
</div>
<h1 className="text-2xl text-scale-1200">Functions</h1>
</div>
{children}
</div>
</>
) : (
<div
className="
py-10
w-full mx-auto
transition-all
px-5
lg:px-20
xl:px-24
1xl:px-28
2xl:px-32
flex flex-col
gap-8
"
>
<div className="flex items-center gap-3">
<div
className="w-6 h-6 bg-brand-300 border border-brand-600 rounded text-brand-900
flex items-center justify-center
"
>
<IconCode size={14} strokeWidth={3} />
</div>
<h1 className="text-2xl text-scale-1200">Functions</h1>
</div>
{children}
</div>
)}
</BaseLayout>
)
}
export default withAuth(observer(PageLayout))
@@ -0,0 +1,69 @@
import dayjs from 'dayjs'
import { FC } from 'react'
import { observer } from 'mobx-react-lite'
import { IconClipboard, IconGlobe } from '@supabase/ui'
import Table from 'components/to-be-cleaned/Table'
import { useRouter } from 'next/router'
import { useStore } from 'hooks'
// import _functionDropdown from './_functionDropdown'
interface Props {
function: any
}
const FunctionsListItem: FC<Props> = ({ function: item }) => {
const { ui } = useStore()
console.log('_function', item)
const is_functionConfirmed = item.email_confirmed_at || item.phone_confirmed_at
const router = useRouter()
const ref = ui?.selectedProject?.ref
return (
<Table.tr
key={item.id}
onClick={() => {
router.push(`/project/${ref}/functions/${item.id}`)
}}
>
<Table.td className="whitespace-nowrap">
<div className="flex items-center gap-2">
<span className="text-base text-scale-1200">{item.name}</span>
</div>
</Table.td>
<Table.td className="whitespace-nowrap flex flex-col gap-0">
<span className="text-sm text-scale-1100 flex items-center gap-1">
<IconGlobe size={14} />
HTTP Request
</span>
<span
className="text-xs text-scale-900 flex gap-2 items-center
font-mono"
>
{`https://${ref}.supabase.com/functions/${item.name}`}
<IconClipboard size={12} />
</span>
</Table.td>
<Table.td className="hidden 2xl:table-cell">
<span className="text-scale-1100">{item.status}</span>
</Table.td>
<Table.td className="hidden 2xl:table-cell">
<span className="text-scale-1100">
{dayjs(item.created_at).format('DD MMM, YYYY HH:mm')}
</span>
</Table.td>
<Table.td className="hidden 2xl:table-cell">
<span className="text-scale-1100">
{dayjs(item.updated_at).format('DD MMM, YYYY HH:mm')}
</span>
</Table.td>
<Table.td className="hidden 2xl:table-cell">
<span className="text-scale-1100">Version {item.version}</span>
</Table.td>
</Table.tr>
)
}
export default observer(FunctionsListItem)
@@ -0,0 +1,29 @@
import { Tabs } from '@supabase/ui'
import { useRouter } from 'next/router'
// @ts-ignore
const FunctionsNav = ({ item }) => {
const router = useRouter()
const activeRoute = router.pathname.split('/')[5]
const { ref } = router.query
return (
<Tabs
defaultActiveId="1"
type="underlined"
size="medium"
activeId={!activeRoute ? 'metrics' : activeRoute}
onChange={(e: string) =>
router.push(`/project/${ref}/functions/${item.id}/${e === 'metrics' ? '' : e}`)
}
>
<Tabs.Panel id="metrics" label="Metrics" />
<Tabs.Panel id="details" label="Details" />
{/* <Tabs.Panel id="triggers" label="Triggers" /> */}
<Tabs.Panel id="invocations" label="Invocations" />
<Tabs.Panel id="logs" label="Logs" />
</Tabs>
)
}
export default FunctionsNav
@@ -26,60 +26,82 @@ export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => {
url: `/project/${ref}/database/replication`,
items: [],
},
],
},
...(IS_PLATFORM
? [
{
title: 'Settings',
items: [
{
name: 'Backups',
key: 'backups',
url: `/project/${ref}/database/backups`,
items: [],
},
{
name: 'Connection Pooling',
key: 'pooling',
url: `/project/${ref}/database/pooling`,
items: [],
},
],
},
]
{
title: 'Logs',
items: [
{
name: 'API logs',
key: 'api-logs',
url: `/project/${ref}/database/api-logs`,
items: [],
},
{
name: 'Postgres logs',
key: 'postgres-logs',
url: `/project/${ref}/database/postgres-logs`,
items: [],
},
],
},
]
: []),
...(IS_PLATFORM
? [
{
title: 'Alpha Preview',
isPreview: true,
items: [
{
name: 'Triggers',
key: 'triggers',
url: `/project/${ref}/database/triggers`,
items: [],
},
{
name: 'Functions',
key: 'functions',
url: `/project/${ref}/database/functions`,
items: [],
},
...(showHooksRoute
? [
{
name: 'Function Hooks',
key: 'hooks',
url: `/project/${ref}/database/hooks`,
items: [],
},
]
: []),
],
},
]
{
title: 'Settings',
items: [
{
name: 'Backups',
key: 'backups',
url: `/project/${ref}/database/backups`,
items: [],
},
{
name: 'Connection Pooling',
key: 'pooling',
url: `/project/${ref}/database/pooling`,
items: [],
},
],
},
]
: []),
...(IS_PLATFORM
? [
{
title: 'Alpha Preview',
isPreview: true,
items: [
{
name: 'Triggers',
key: 'triggers',
url: `/project/${ref}/database/triggers`,
items: [],
},
{
name: 'Functions',
key: 'functions',
url: `/project/${ref}/database/functions`,
items: [],
},
...(showHooksRoute
? [
{
name: 'Function Hooks',
key: 'hooks',
url: `/project/${ref}/database/hooks`,
items: [],
},
]
: []),
],
},
]
: []),
]
}
@@ -0,0 +1,83 @@
import React, { useEffect } from 'react'
import BaseLayout from 'components/layouts'
import { observer } from 'mobx-react-lite'
import { useStore, withAuth } from 'hooks'
import { Badge, IconList, Loading } from '@supabase/ui'
import LogsNavigation from 'components/interfaces/Settings/Logs/LogsNavigation'
const PageLayout = ({
subtitle,
children,
}: {
subtitle?: React.ReactNode
children?: React.ReactNode
}) => {
const { content, ui } = useStore()
useEffect(() => {
content.load()
}, [ui.selectedProject])
if (!content.isInitialized) {
return (
<BaseLayout>
<Loading active={true}>loading</Loading>
</BaseLayout>
)
}
return (
<BaseLayout>
<div className="h-full flex flex-col flex-grow">
<div
className="
pt-10
pb-2
w-full mx-auto
transition-all
px-5
lg:px-20
xl:px-24
1xl:px-28
2xl:px-32
flex flex-col
gap-8
"
>
<div className="flex items-center gap-3">
<div
className="w-6 h-6 bg-brand-300 border border-brand-600 rounded text-brand-900
flex items-center justify-center
"
>
<IconList size={14} strokeWidth={3} />
</div>
<h1 className="text-2xl text-scale-1200">Logs Explorer</h1>
{subtitle && <Badge color="scale">{subtitle}</Badge>}
</div>
<LogsNavigation />
</div>
<div
// weird repetitive styling
className="
h-full flex flex-col flex-grow
w-full mx-auto
transition-all
px-5
lg:px-20
xl:px-24
1xl:px-28
2xl:px-32
"
>
{children}
</div>
</div>
</BaseLayout>
)
}
export default withAuth(observer(PageLayout))
@@ -5,13 +5,19 @@ import {
IconSettings,
IconDatabase,
IconBarChart,
IconList,
IconCode,
} from '@supabase/ui'
import SVG from 'react-inlinesvg'
import { IS_PLATFORM } from 'lib/constants'
import { Route } from 'components/ui/ui.types'
import { useFlag } from 'hooks'
export const generateProductRoutes = (ref: string): Route[] => {
const functionsUi = useFlag('functionsUi')
return [
{
key: 'editor',
@@ -59,11 +65,32 @@ export const generateProductRoutes = (ref: string): Route[] => {
icon: <IconDatabase size={18} strokeWidth={2} />,
link: `/project/${ref}/database/tables`,
},
...(IS_PLATFORM && functionsUi
? [
{
key: 'functions',
label: 'Functions',
icon: <IconCode size={18} strokeWidth={2} />,
link: `/project/${ref}/functions`,
},
]
: []),
]
}
export const generateOtherRoutes = (ref: string) => {
const logsExplorer = useFlag('logsExplorer')
return [
...(IS_PLATFORM && logsExplorer
? [
{
key: 'logsExplorer',
label: 'Logs Explorer',
icon: <IconList size={18} strokeWidth={2} />,
link: `/project/${ref}/logs-explorer`,
},
]
: []),
...(IS_PLATFORM
? [
{
@@ -19,15 +19,11 @@ const SettingsLayout: FC<Props> = ({ title, children }) => {
const router = useRouter()
const page = router.pathname.split('/')[4]
const logType = router.query.type
const pageKey = !isUndefined(logType) ? `logs-${logType}` : page
return (
<BaseLayout
title={title || 'Settings'}
product="Settings"
productMenu={<ProductMenu page={pageKey} menu={generateSettingsMenu(projectRef)} />}
productMenu={<ProductMenu page={page} menu={generateSettingsMenu(projectRef)} />}
>
<main style={{ maxHeight: '100vh' }} className="flex-1 overflow-y-auto">
{children}
@@ -2,7 +2,6 @@ import { LOG_TYPE_LABEL_MAPPING } from 'components/interfaces/Settings/Logs'
import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types'
export const generateSettingsMenu = (ref: string): ProductMenuGroup[] => {
const logTypes: string[] = ['explorer', 'database', 'api']
return [
{
@@ -27,14 +26,5 @@ export const generateSettingsMenu = (ref: string): ProductMenuGroup[] => {
},
],
},
{
title: 'Logs',
items: logTypes.map((type: string) => ({
name: LOG_TYPE_LABEL_MAPPING[type],
key: `logs-${type}`,
url: `/project/${ref}/settings/logs/${type}`,
items: [],
})),
},
]
}
+3 -1
View File
@@ -11,7 +11,7 @@ import AccountLayout from './AccountLayout/AccountLayout'
import WizardLayout from './WizardLayout'
import VercelIntegrationLayout from './VercelIntegrationLayout'
import BillingLayout from './BillingLayout'
import LogsExplorerLayout from './LogsExplorerLayout/LogsExplorerLayout'
export {
AuthLayout,
DatabaseLayout,
@@ -24,5 +24,7 @@ export {
WizardLayout,
VercelIntegrationLayout,
BillingLayout,
LogsExplorerLayout
}
export default ProjectLayout
@@ -31,6 +31,8 @@ interface Props {
hideChartType?: boolean
data?: ChartData
isLoading?: boolean
format?: string
highlightedValue?: string | number
onBarClick?: (v: any) => void
}
@@ -57,6 +59,8 @@ const ChartHandler: FC<Props> = ({
hideChartType = false,
data,
isLoading,
format,
highlightedValue,
onBarClick,
}) => {
const router = useRouter()
@@ -114,12 +118,13 @@ const ChartHandler: FC<Props> = ({
}
}, [startDate])
const highlightedValue =
provider === 'daily-stats'
? chartData?.total
: provider === 'log-stats'
? chartData?.totalGrouped?.[attribute]
: chartData?.totalAverage
highlightedValue = highlightedValue
? highlightedValue
: provider === 'daily-stats'
? chartData?.total
: provider === 'log-stats'
? chartData?.totalGrouped?.[attribute]
: chartData?.totalAverage
if (loading) {
return (
@@ -131,6 +136,7 @@ const ChartHandler: FC<Props> = ({
}
if (isUndefined(chartData)) {
console.log('i am undefined chart data')
return (
<div className="w-full h-52 flex flex-col space-y-4 items-center justify-center">
<IconAlertCircle className="text-scale-700" />
@@ -160,7 +166,7 @@ const ChartHandler: FC<Props> = ({
data={chartData?.data ?? []}
attribute={attribute}
yAxisLimit={chartData?.yAxisLimit}
format={chartData?.format}
format={format || chartData?.format}
highlightedValue={highlightedValue}
label={label}
customDateFormat={customDateFormat}
@@ -169,7 +175,7 @@ const ChartHandler: FC<Props> = ({
) : (
<AreaChart
data={chartData?.data ?? []}
format={chartData?.format}
format={format || chartData?.format}
attribute={attribute}
yAxisLimit={chartData?.yAxisLimit}
highlightedValue={highlightedValue}
@@ -133,7 +133,7 @@ const Header = ({
)
}
const NoData = () => (
const NoData = ({ title = 'No data to show', message = 'May take 24 hours for data to show' }) => (
<div
className="
h-full w-full
@@ -144,8 +144,8 @@ const NoData = () => (
>
<IconBarChart2 className="text-scale-800" />
<div>
<p className="text-scale-1100 text-xs">No data to show</p>
<p className="text-scale-900 text-xs">May take 24 hours for data to show</p>
<p className="text-scale-1100 text-xs">{title}</p>
<p className="text-scale-900 text-xs">{message}</p>
</div>
</div>
)
@@ -175,8 +175,10 @@ export function BarChart({
label,
onBarClick,
minimalHeader,
minimalChart,
chartSize = 'normal',
className = '',
noDataTitle,
noDataMessage,
}: any) {
const hasData = data ? dataCheck(data, attribute) : true
@@ -201,7 +203,11 @@ export function BarChart({
const day = (value: number | string) => (displayDateInUtc ? dayjs(value).utc() : dayjs(value))
// For future reference: https://github.com/supabase/supabase/pull/5311#discussion_r800852828
const chartHeight = minimalChart ? 96 : 160
const chartHeight = {
tiny: 76,
small: 96,
normal: 160,
}[chartSize as string] as number
return (
<Loading active={!data}>
@@ -291,7 +297,7 @@ export function BarChart({
)}
</>
) : (
<NoData />
<NoData title={noDataTitle} message={noDataMessage} />
)}
</div>
</div>
+12 -4
View File
@@ -7,9 +7,17 @@ type TableProps = {
className?: string
containerClassName?: string
borderless?: boolean
headTrClasses?: string
}
function Table({ body, head, className, containerClassName, borderless }: TableProps) {
function Table({
body,
head,
className,
containerClassName,
borderless,
headTrClasses,
}: TableProps) {
let containerClasses = ['table-container']
if (containerClassName) containerClasses.push(containerClassName)
if (borderless) containerClasses.push('table-container--borderless')
@@ -21,7 +29,7 @@ function Table({ body, head, className, containerClassName, borderless }: TableP
<div className={containerClasses.join(' ')}>
<table className={classes.join(' ')}>
<thead>
<tr>{head}</tr>
<tr className={headTrClasses}>{head}</tr>
</thead>
<tbody>{body}</tbody>
</table>
@@ -68,8 +76,8 @@ type TdProps = {
colSpan?: number
className?: string
style?: React.CSSProperties
} & React.HTMLProps<HTMLTableCellElement>
const Td: React.FC<TdProps> = ({ children, colSpan, className, style , ...rest}) => {
} & React.HTMLProps<HTMLTableCellElement>
const Td: React.FC<TdProps> = ({ children, colSpan, className, style, ...rest }) => {
return (
<td className={className} colSpan={colSpan} style={style} {...rest}>
{children}
+70
View File
@@ -0,0 +1,70 @@
import { Button, IconDownloadCloud } from '@supabase/ui'
import { ButtonProps } from '@supabase/ui/dist/cjs/components/Button/Button'
import { flattenDeep } from 'lodash'
import React, { useMemo, useRef } from 'react'
import { CSVLink } from 'react-csv'
interface Props {
buttonType?: ButtonProps['type']
onClick?: ButtonProps['onClick']
disabled?: ButtonProps['disabled']
icon?: React.ReactNode
data?: unknown[]
title?: string
}
const CSVButton: React.FC<Props> = ({
onClick,
buttonType = 'default',
icon,
children,
disabled,
data,
title,
}) => {
const csvRef = useRef(null)
const handleDownload = () => {
;(csvRef.current as any)?.link.click()
}
const formattedData = useMemo(() => {
const first = data?.[0]
if (!first || !data) return
const keys = Object.keys(first as any)
return data.map((datum: any) => {
return keys.reduce((acc: any, key) => {
if (typeof datum[key] === 'object') {
acc[key] = JSON.stringify(datum[key]) as string
} else {
acc[key] = String(datum[key])
}
return acc
}, {})
})
// retrieve dot notation of all keys
}, [JSON.stringify(data)])
return (
<>
<CSVLink
ref={csvRef}
className="hidden"
data={formattedData || ([] as any)}
filename={`supabase_logs.csv`}
title={title}
/>
<Button
type={buttonType}
icon={icon || <IconDownloadCloud />}
disabled={disabled}
onClick={(e) => {
if (onClick) onClick(e)
handleDownload()
}}
>
{children}
</Button>
</>
)
}
export default CSVButton
+4 -1
View File
@@ -13,6 +13,7 @@ interface Props {
imgAlt?: string
onClick?: () => void
icon?: React.ReactNode
containerHeightClassName?: string
}
const CardButton: FC<Props> = ({
@@ -26,6 +27,7 @@ const CardButton: FC<Props> = ({
imgAlt,
onClick,
icon,
containerHeightClassName="h-32"
}) => {
const LinkContainer = ({ children }: { children: React.ReactNode }) => (
<Link href={linkHref}>{children}</Link>
@@ -42,8 +44,9 @@ const CardButton: FC<Props> = ({
'group relative text-left',
'bg-panel-header-light dark:bg-panel-header-dark',
'border border-panel-border-light dark:border-panel-border-dark',
'rounded-md py-4 px-6 flex flex-row h-32',
'rounded-md py-4 px-6 flex flex-row',
'transition ease-in-out duration-150',
containerHeightClassName
]
if (isLink) {
@@ -0,0 +1,35 @@
import React, { useState } from 'react'
import { DatePicker } from '.'
import ReactDatePicker from 'react-datepicker'
export default {
title: 'Data/DatePicker',
component: DatePicker,
}
export const Default = (args: any) => (
<div style={{ margin: '0 auto', minHeight: '420px', marginTop: '220px' }}>
<DatePicker />
</div>
)
export const Simple = () => {
const [startDate, setStartDate] = useState(new Date())
const [endDate, setEndDate] = useState(null)
const onChange = (dates: any) => {
const [start, end] = dates
setStartDate(start)
setEndDate(end)
}
return (
<ReactDatePicker
selected={startDate}
onChange={onChange}
startDate={startDate}
endDate={endDate}
selectsRange
inline
/>
)
}
@@ -0,0 +1,362 @@
import React, { useState, forwardRef, useEffect } from 'react'
import DatePicker from 'react-datepicker'
// import dayjs from 'dayjs'
import {
Button,
Popover,
IconChevronLeft,
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'
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
to?: string
from?: string
triggerButtonType?: ButtonProps['type']
triggerButtonClassName?: string
}
const START_DATE_DEFAULT = new Date()
const END_DATE_DEFAULT = new Date()
// console.log('START_DATE_DEFAULT', START_DATE_DEFAULT)
const START_TIME_DEFAULT = { HH: '00', mm: '00', ss: '00' }
const END_TIME_DEFAULT = { HH: '23', mm: '59', ss: '59' }
function _DatePicker({
to,
from,
onChange,
triggerButtonType = 'default',
triggerButtonClassName = '',
}: RootProps) {
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 [startTime, setStartTime] = useState<any>(START_TIME_DEFAULT)
const [endTime, setEndTime] = useState<any>(END_TIME_DEFAULT)
useEffect(() => {
if (!to || !from) {
setAppliedStartDate(null)
setAppliedEndDate(null)
}
}, [to, from])
function handleDatePickerChange(dates: [from: Date | null, to: Date | null]) {
const [from, to] = dates
setStartDate(from)
setEndDate(to)
}
function handleSubmit() {
setOpen(false)
setAppliedStartDate(startDate)
setAppliedEndDate(endDate)
const payload = {
from: dayjs
.utc(startDate)
.second(startTime.ss)
.minute(startTime.mm)
.hour(startTime.HH)
.toISOString(),
to: dayjs
.utc(endDate || startDate)
.second(endTime.ss)
.minute(endTime.mm)
.hour(endTime.HH)
.toISOString(),
}
if (onChange) onChange(payload)
}
function handleClear() {
setOpen(false)
setStartDate(START_DATE_DEFAULT)
setEndDate(END_DATE_DEFAULT)
setStartTime(START_TIME_DEFAULT)
setEndTime(END_TIME_DEFAULT)
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={`
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>
</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
// 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 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>
</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> */}
</>
)
}
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
@@ -0,0 +1,27 @@
export type Time = {
HH: string
mm: string
ss: string
}
export type TimeType = 'HH' | 'mm' | 'ss'
export interface TimeSplitInputProps {
time: Time
setTime: (x: Time) => void
type: 'start' | 'end'
setStartTime: (x: Time) => void
setEndTime: (x: Time) => void
startTime: Time
endTime: Time
startDate: any
endDate: any
}
export type Date = {
YYYY: string
MM: string
DD: string
}
export type DateType = 'YYYY' | 'MM' | 'DD'
@@ -0,0 +1,19 @@
export default function DividerSlash() {
return (
<span className="text-scale-900">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
shapeRendering="geometricPrecision"
>
<path d="M16 3.549L7.12 20.600"></path>
</svg>
</span>
)
}
@@ -0,0 +1,254 @@
import React, { useEffect, useState } from 'react'
import { IconClock } from '@supabase/ui'
import { format } from 'date-fns'
import { TimeType, Time, TimeSplitInputProps } from './DatePicker.types'
const TimeSplitInput = ({
type,
time,
setTime,
setStartTime,
setEndTime,
startTime,
endTime,
startDate,
endDate,
}: TimeSplitInputProps) => {
const [focus, setFocus] = useState(false)
function handleOnBlur() {
const _time = time
if (_time.HH.length === 1) _time.HH = '0' + _time.HH
if (_time.mm.length === 1) _time.mm = '0' + _time.mm
if (_time.ss.length === 1) _time.ss = '0' + _time.ss
if (!_time.HH) _time.HH = '00'
if (!_time.mm) _time.mm = '00'
if (!_time.ss) _time.ss = '00'
let endTimeChanges = false
let endTimePayload = endTime
let startTimeChanges = false
let startTimePayload = startTime
// Only run time conflicts if
// startDate and endDate are the same date
// console.log(startDate)
// console.log(endDate)
if (format(new Date(startDate), 'dd/mm/yyyy') == format(new Date(endDate), 'dd/mm/yyyy')) {
// checks if start time is ahead of end time
if (type === 'start') {
// console.log('HH in start switch')
if (_time.HH && Number(_time.HH) > Number(endTime.HH)) {
endTimePayload.HH = _time.HH
endTimeChanges = true
}
if (
// also check the hour
_time.HH &&
Number(_time.HH) >= Number(endTime.HH) &&
// check the minutes
_time.mm &&
Number(_time.mm) > Number(endTime.mm)
) {
endTimePayload.mm = _time.mm
endTimeChanges = true
}
if (
// also check the hour
_time.HH &&
Number(_time.HH) >= Number(endTime.HH) &&
// check the minutes
_time.mm &&
Number(_time.mm) >= Number(endTime.mm) &&
// check the seconds
_time.ss &&
Number(_time.ss) > Number(endTime.ss)
) {
endTimePayload.ss = _time.ss
endTimeChanges = true
}
}
if (type === 'end') {
// console.log('HH in start switch')
if (_time.HH && Number(_time.HH) < Number(startTime.HH)) {
startTimePayload.HH = _time.HH
startTimeChanges = true
}
if (
// also check the hour
_time.HH &&
Number(_time.HH) <= Number(startTime.HH) &&
// check the minutes
_time.mm &&
Number(_time.mm) < Number(startTime.mm)
) {
startTimePayload.mm = _time.mm
startTimeChanges = true
}
if (
// also check the hour
_time.HH &&
Number(_time.HH) <= Number(startTime.HH) &&
// check the minutes
_time.mm &&
Number(_time.mm) <= Number(startTime.mm) &&
// check the seconds
_time.ss &&
Number(_time.ss) < Number(startTime.ss)
) {
startTimePayload.ss = _time.ss
startTimeChanges = true
}
}
}
setTime({ ..._time })
if (endTimeChanges) {
setEndTime({ ...endTimePayload })
}
if (startTimeChanges) {
setStartTime({ ...startTimePayload })
}
setFocus(false)
}
function handleOnChange(value: string, valueType: TimeType) {
// console.log('handleOnChange')
const payload = {
HH: time.HH,
mm: time.mm,
ss: time.ss,
}
if (value.length > 2) return
switch (valueType) {
case 'HH':
if (value && Number(value) > 23) return
break
case 'mm':
if (value && Number(value) > 59) return
break
case 'ss':
if (value && Number(value) > 59) return
break
default:
break
}
// console.log('got here')
payload[valueType] = value
setTime({ ...payload })
// if (endTimeChanges) {
// setEndTime(endTimePayload)
// }
}
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
event.target.select()
setFocus(true)
}
useEffect(() => {
handleOnBlur()
}, [startDate, endDate])
return (
<div
className={`
flex items-center justify-center gap-0
text-xs text-scale-1100 bg-scale-100 dark:bg-scaleA-300 border border-scale-700 h-7 rounded
${focus && ' outline outline-2 outline-scale-500 border-scale-800'}
`}
>
<div className="text-scale-900 mr-1">
<IconClock size={14} strokeWidth={1.5} />
</div>
<input
type="text"
onBlur={() => handleOnBlur()}
onFocus={handleFocus}
pattern="[0-23]*"
placeholder="00"
onChange={(e) => handleOnChange(e.target.value, 'HH')}
aria-label="Hours"
className="
w-4
text-center
text-xs
p-0
text-scale-1200 bg-transparent outline-none
border-none
ring-none
ring-0
focus:ring-0
"
value={time.HH}
/>
<span className="text-scale-900">:</span>
<input
type="text"
onBlur={() => handleOnBlur()}
onFocus={handleFocus}
pattern="[0-12]*"
placeholder="00"
onChange={(e) => handleOnChange(e.target.value, 'mm')}
aria-label="Minutes"
className="
w-4
text-center
text-xs
p-0
text-scale-1200 bg-transparent outline-none
border-none
ring-none
ring-0
focus:ring-0
"
value={time.mm}
/>
<span className="text-scale-900">:</span>
<input
type="text"
onBlur={() => handleOnBlur()}
onFocus={handleFocus}
pattern="[0-59]*"
placeholder="00"
onChange={(e) => handleOnChange(e.target.value, 'ss')}
aria-label="Seconds"
className="
w-4
text-center
text-xs
p-0
text-scale-1200 bg-transparent outline-none
border-none
ring-none
ring-0
focus:ring-0
"
value={time.ss}
/>
</div>
)
}
export default TimeSplitInput
@@ -0,0 +1 @@
export { default as DatePicker } from './DatePicker'
+15
View File
@@ -0,0 +1,15 @@
import React from 'react'
const LoadingOpacity = ({ children, active }: { children?: React.ReactNode; active: boolean }) => {
return (
<div
className={
'flex h-full flex-grow transition-opacity ' + (active ? 'opacity-30' : 'opacity-100')
}
>
{children}
</div>
)
}
export default LoadingOpacity
+5
View File
@@ -0,0 +1,5 @@
const ShimmerLine = ({ active }: { active: boolean }) => {
return active ? <div className="logs-shimmering-loader w-full h-0.5"></div> : null
}
export default ShimmerLine
+19
View File
@@ -0,0 +1,19 @@
import { IconAlertCircle } from '@supabase/ui'
const WarningBanner = ({ title, description }: { title: string; description: string }) => {
return (
<div className="bg-yellow-900 px-6 py-4">
<div className="flex gap-3">
<div className="mt-1 text-yellow-1100 dark:text-yellow-800">
<IconAlertCircle size={14} strokeWidth={2} />
</div>
<div className="flex flex-col lg:flex-row lg:items-center lg:gap-3">
<h1 className="text-base text-scale-1200 dark:text-scale-100">{title}</h1>
<p className="text-sm text-yellow-1100 dark:text-yellow-800">{description}</p>
</div>
</div>
</div>
)
}
export default WarningBanner
+9 -8
View File
@@ -53,22 +53,21 @@ function useLogsPreview<Filters>(
const [latestRefresh, setLatestRefresh] = useState<string>(new Date().toISOString())
const [filters, setFilters] = useState<Filters>(options.initialFilters)
const [params, setParams] = useState<LogsEndpointParams>({
project: projectRef,
sql: cleanQuery(genDefaultQuery(table)),
rawSql: genDefaultQuery(table),
sql: cleanQuery(genDefaultQuery(table, options.whereStatementFactory(filters))),
rawSql: genDefaultQuery(table, options.whereStatementFactory(filters)),
period_start: '',
period_end: '',
timestamp_start: '',
timestamp_end: '',
})
const [filters, setFilters] = useState<Filters>(options.initialFilters)
useEffect(() => {
if (filters !== {}) {
const generatedSql = genDefaultQuery(table, options.whereStatementFactory(filters))
setParams((prev) => ({ ...prev, sql: cleanQuery(generatedSql), rawSql: generatedSql }))
refresh()
}
}, [JSON.stringify(filters)])
@@ -111,9 +110,11 @@ function useLogsPreview<Filters>(
const { data: countData } = useSWR<Count>(countUrl, get, { refreshInterval: 5000 })
const newCount = countData?.result?.[0]?.count ?? 0
const refresh = () => {
const refresh = async () => {
const generatedSql = genDefaultQuery(table, options.whereStatementFactory(filters))
setParams((prev) => ({ ...prev, sql: cleanQuery(generatedSql), rawSql: generatedSql }))
setLatestRefresh(new Date().toISOString())
setParams({ ...params, timestamp_end: '' })
setSize(1)
}
let error: null | string | object = swrError ? swrError.message : null
+1 -1
View File
@@ -36,7 +36,7 @@ const useLogsQuery = (
isValidating: isLoading,
mutate,
} = useSWR<Logs>(
`${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${queryParams}`,
params.sql ? `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${queryParams}` : null,
get,
{ revalidateOnFocus: false }
)
+37 -5
View File
@@ -3057,8 +3057,7 @@
"@popperjs/core": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.4.tgz",
"integrity": "sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg==",
"dev": true
"integrity": "sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg=="
},
"@radix-ui/colors": {
"version": "0.1.8",
@@ -6618,6 +6617,18 @@
"@types/react": "*"
}
},
"@types/react-datepicker": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/@types/react-datepicker/-/react-datepicker-4.3.4.tgz",
"integrity": "sha512-5nTTz37KdTUgMZ1AAxztMWNtEnIMVRo8oCAEhIv0a6uUqDjvSKaMyPRpBV+8chi6f/A8wlTKJIpojpXca2dx3A==",
"dev": true,
"requires": {
"@popperjs/core": "^2.9.2",
"@types/react": "*",
"date-fns": "^2.0.1",
"react-popper": "^2.2.5"
}
},
"@types/react-dom": {
"version": "17.0.11",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz",
@@ -9827,6 +9838,11 @@
}
}
},
"date-fns": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.28.0.tgz",
"integrity": "sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw=="
},
"dayjs": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.0.tgz",
@@ -17663,6 +17679,19 @@
"resolved": "https://registry.npmjs.org/react-csv/-/react-csv-2.0.3.tgz",
"integrity": "sha512-exyAdFLAxtuM4wNwLYrlKyPYLiJ7e0mv9tqPAd3kq+k1CiJFtznevR3yP0icv5q/y200w+lzNgi7TQn1Wrhu0w=="
},
"react-datepicker": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-4.7.0.tgz",
"integrity": "sha512-FS8KgbwqpxmJBv/bUdA42MYqYZa+fEYcpc746DZiHvVE2nhjrW/dg7c5B5fIUuI8gZET6FOzuDgezNcj568Czw==",
"requires": {
"@popperjs/core": "^2.9.2",
"classnames": "^2.2.6",
"date-fns": "^2.24.0",
"prop-types": "^15.7.2",
"react-onclickoutside": "^6.12.0",
"react-popper": "^2.2.5"
}
},
"react-dnd": {
"version": "14.0.5",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz",
@@ -17862,11 +17891,15 @@
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
"react-onclickoutside": {
"version": "6.12.1",
"resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.12.1.tgz",
"integrity": "sha512-a5Q7CkWznBRUWPmocCvE8b6lEYw1s6+opp/60dCunhO+G6E4tDTO2Sd2jKE+leEnnrLAE2Wj5DlDHNqj5wPv1Q=="
},
"react-popper": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.2.5.tgz",
"integrity": "sha512-kxGkS80eQGtLl18+uig1UIf9MKixFSyPxglsgLBxlYnyDf65BiY9B3nZSc6C9XUNDgStROB0fMQlTEz1KxGddw==",
"dev": true,
"requires": {
"react-fast-compare": "^3.0.1",
"warning": "^4.0.2"
@@ -17875,8 +17908,7 @@
"react-fast-compare": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz",
"integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==",
"dev": true
"integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
}
}
},
+3 -1
View File
@@ -48,6 +48,7 @@
"react": "^17.0.2",
"react-beautiful-dnd": "^13.1.0",
"react-csv": "^2.0.3",
"react-datepicker": "^4.6.0",
"react-dom": "^17.0.2",
"react-grid-layout": "^1.3.0",
"react-hot-toast": "^2.1.1",
@@ -73,7 +74,6 @@
"@storybook/addon-links": "^6.4.19",
"@storybook/react": "^6.4.19",
"@storybook/testing-library": "^0.0.9",
"storybook-dark-mode": "^1.0.4",
"@tailwindcss/typography": "^0.5.2",
"@testing-library/dom": "^8.11.1",
"@testing-library/react": "^12.1.2",
@@ -85,6 +85,7 @@
"@types/react": "17.0.11",
"@types/react-beautiful-dnd": "^13.1.2",
"@types/react-csv": "^1.1.2",
"@types/react-datepicker": "^4.3.4",
"@types/react-dom": "^17.0.11",
"@types/react-grid-layout": "^1.3.0",
"@types/react-virtualized-auto-sizer": "^1.0.1",
@@ -98,6 +99,7 @@
"jest": "^27.4.0",
"jest-canvas-mock": "^2.3.1",
"postcss": "^8.4.6",
"storybook-dark-mode": "^1.0.4",
"tailwindcss": "^3.0.22",
"ts-jest": "^27.0.7",
"typescript": "4.3.4"
+2
View File
@@ -7,6 +7,8 @@ import 'styles/toast.scss'
import 'styles/code.scss'
import 'styles/monaco.scss'
import 'styles/contextMenu.scss'
import 'styles/react-data-grid-logs.scss'
import 'styles/date-picker.scss'
import Head from 'next/head'
import type { AppProps } from 'next/app'
@@ -0,0 +1,27 @@
import React from 'react'
import { NextPage } from 'next'
import { useRouter } from 'next/router'
import { observer } from 'mobx-react-lite'
import { withAuth } from 'hooks'
import { DatabaseLayout } from 'components/layouts/'
import { LogsTableName } from 'components/interfaces/Settings/Logs'
import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer'
export const LogPage: NextPage = () => {
const router = useRouter()
const { ref } = router.query
return (
<DatabaseLayout>
<LogsPreviewer
projectRef={ref as string}
condensedLayout={true}
// @ts-ignore
tableName={'edge_logs'}
queryType={'api'}
/>
</DatabaseLayout>
)
}
export default withAuth(observer(LogPage))
@@ -0,0 +1,27 @@
import React from 'react'
import { NextPage } from 'next'
import { useRouter } from 'next/router'
import { observer } from 'mobx-react-lite'
import { withAuth } from 'hooks'
import { DatabaseLayout } from 'components/layouts/'
import { LogsTableName } from 'components/interfaces/Settings/Logs'
import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer'
export const LogPage: NextPage = () => {
const router = useRouter()
const { ref } = router.query
return (
<DatabaseLayout>
<LogsPreviewer
projectRef={ref as string}
condensedLayout={true}
// @ts-ignore
tableName={'postgres_logs'}
queryType={'database'}
/>
</DatabaseLayout>
)
}
export default withAuth(observer(LogPage))
@@ -0,0 +1,210 @@
import { observer } from 'mobx-react-lite'
import { useStore, withAuth } from 'hooks'
import FunctionLayout from '../../../../../components/interfaces/functions/FunctionLayout'
import CommandRender from '../../../../../components/interfaces/functions/CommandRender'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { toJS } from 'mobx'
import { IconExternalLink, IconGlobe, IconTerminal, Loading } from '@supabase/ui'
import Link from 'next/link'
import dayjs from 'dayjs'
const PageLayout = () => {
const router = useRouter()
const { ref, id } = router.query
const { functions, ui } = useStore()
const [selectedFunction, setSelectedFunction] = useState<any>(null)
useEffect(() => {
setSelectedFunction(functions.byId(id))
}, [functions.isLoaded, ui.selectedProject])
// if (!functions.isLoading) return <></>
console.log('selected', functions.byId(id))
const managementCommands: any = [
{
command: `supabase functions deploy ${selectedFunction?.name}`,
description: 'This will overwrite the deployed function with your new function',
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> functions deploy{' '}
{selectedFunction?.name}
</>
)
},
comment: 'Deploy a new version',
},
{
command: `supabase functions deploy ${selectedFunction?.name}`,
description: 'This will remove the function and all the logs associated with it',
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> functions delete{' '}
{selectedFunction?.name}
</>
)
},
comment: 'Delete the function',
},
]
const secretCommands: any = [
{
command: `supabase secrets list`,
description: 'This will list all the secrets for your project',
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> secrets list
</>
)
},
comment: 'View all secrets',
},
{
command: `supabase secrets set NAME1=VALUE NAME2=VALUE`,
description: 'This will set secrets for your project',
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> secrets set NAME=VALUE NAME=VALUE
</>
)
},
comment: 'Set secrets for your project',
},
{
command: `supabase secrets unset NAME1 NAME2 `,
description: 'This will deleet secrets for your project',
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> secrets unset NAME1 NAME2
</>
)
},
comment: 'Set secrets for your project',
},
]
return (
<FunctionLayout>
<div className="grid gap-y-12 lg:grid-cols-2 lg:gap-x-16">
<div>
<div
className="
px-12 py-8 bg-scale-100 dark:bg-scale-300 rounded border drop-shadow-sm
"
>
{console.log('inside the render', functions.byId(id))}
<div>{selectedFunction?.createdAt}</div>
<div className="space-y-4">
<div>
<span className="block text-scale-900 text-xs mb-1">Function name</span>
<div className="text-base text-scale-1100">{selectedFunction?.name}</div>
</div>
<div>
<span className="block text-scale-900 text-xs mb-1">Status</span>
<div className="flex items-center gap-2">
<div
className="text-base bg-brand-300 dark:bg-brand-100 px-3 py-0.5 rounded-full lowercasefirst-letter:
flex items-center gap-3 lowercase text-brand-900
"
>
{selectedFunction?.status}
<div className="relative w-2 h-2">
<span className="flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-brand-800 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-brand-900"></span>
</span>
</div>
</div>
<span className="text-xs text-scale-900">This function is running correctly</span>
</div>
</div>
<div>
<span className="block text-scale-900 text-xs mb-1">Endpoint url</span>
<Link
href={`https://${ref}.functions.supabase.co/${selectedFunction?.name}`}
passHref
>
<a
className="flex items-center gap-2 group text-scale-1100 hover:text-scale-1200"
target="_target"
>
<span className="underline">{`https://${ref}.functions.supabase.co/${selectedFunction?.name}`}</span>
<IconExternalLink size={14} />
</a>
</Link>
</div>
<div>
<span className="block text-scale-900 text-xs mb-1">Created at</span>
<div className="text-base text-scale-1100">
{selectedFunction?.created_at &&
dayjs(selectedFunction.created_at).format('dddd, MMMM D, YYYY h:mm A')}
</div>
</div>
<div>
<span className="block text-scale-900 text-xs mb-1">Updated at</span>
<div className="text-base text-scale-1100">
{selectedFunction?.updated_at &&
dayjs(selectedFunction.updated_at).format('dddd, MMMM D, YYYY h:mm A')}
</div>
</div>
<div>
<span className="block text-scale-900 text-xs mb-1">Version</span>
<div className="text-base text-scale-1100">v {selectedFunction?.version}</div>
</div>
<div>
<span className="block text-scale-900 text-xs mb-1">Regions</span>
<div className="flex items-center gap-3">
<div className="text-base text-scale-1100 flex items-center gap-2">
<IconGlobe />
<span>Earth</span>
</div>
<span className="text-xs text-scale-900">Supabase functions run on the edge</span>
</div>
</div>
</div>
</div>
</div>
<div>
<div
className="px-12 py-8
bg-scale-100 dark:bg-scale-300 border drop-shadow-sm
rounded
space-y-6"
>
<div className="flex items-center gap-3">
<div className="border p-2 flex items-center justify-center w-8 h-8 text-scale-100 dark:text-scale-1200 bg-scale-1200 dark:bg-scale-100 rounded">
<IconTerminal strokeWidth={2} />
</div>
<h4>Command line access</h4>
</div>
<h5 className="text-base">Deployment management</h5>
<CommandRender commands={managementCommands} />
<h5 className="text-base">Secrets management</h5>
<CommandRender commands={secretCommands} />
</div>
</div>
</div>
</FunctionLayout>
)
}
export default withAuth(observer(PageLayout))
@@ -0,0 +1,213 @@
import { observer } from 'mobx-react-lite'
import { withAuth } from 'hooks'
import useSWR from 'swr'
import dayjs from 'dayjs'
import { useState } from 'react'
import { useRouter } from 'next/router'
import { Button } from '@supabase/ui'
import Panel from 'components/to-be-cleaned/Panel'
import ChartHandler from 'components/to-be-cleaned/Charts/ChartHandler'
import { get } from 'lib/common/fetch'
import { API_URL, DATE_FORMAT } from 'lib/constants'
import FunctionLayout from '../../../../../components/interfaces/functions/FunctionLayout'
const CHART_INTERVALS = [
{
key: '5min',
label: '5 min',
startValue: 1,
startUnit: 'hour',
format: 'MMM D, h:mma',
},
{ key: '15min', label: '15 min', startValue: 24, startUnit: 'hour', format: 'MMM D, ha' },
{ key: '1hr', label: '1 hour', startValue: 7, startUnit: 'day', format: 'MMM D' },
{ key: '1day', label: '1 day', startValue: 7, startUnit: 'day', format: 'MMM D' },
{ key: '7day', label: '7 days', startValue: 7, startUnit: 'day', format: 'MMM D' },
]
function calculateHighlightedValue(array: any, attribute: string, options?: { sum: boolean }) {
if (!array) return ''
var total = 0
var count = 0
array.forEach(function (item: any, index: number) {
const _item = item[attribute]
total += _item
count++
})
if (options?.sum) {
return total
} else {
return total / count
}
}
const PageLayout = () => {
const [interval, setInterval] = useState<string>('15min')
const router = useRouter()
const { ref, id } = router.query
const url = `${API_URL}/projects/${ref}/analytics/endpoints/functions.inv-stats`
const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1]
const { data, error }: any = useSWR(
`${url}?interval=${selectedInterval.key}&function_id=${id}`,
get
)
const startDate = dayjs()
.subtract(selectedInterval.startValue, selectedInterval.startUnit)
.format(DATE_FORMAT)
const endDate = dayjs().format(DATE_FORMAT)
const charts: any = {}
charts.data = data?.result
const datetimeFormat = selectedInterval.format || 'MMM D, ha'
const handleBarClick = (v: any, search: string) => {
if (!v || !v.activePayload?.[0]?.payload) return
// returns rechart internal tooltip data type
const payload = v.activePayload[0].payload
const timestamp = payload.timestamp
const timestampDigits = String(timestamp).length
if (timestampDigits < 16) {
// pad unix timestamp with additional 0 and then forward
const paddedTimestamp = String(timestamp) + '0'.repeat(16 - timestampDigits)
router.push(`/project/${ref}/functions/${id}/logs?te=${paddedTimestamp}`)
} else {
router.push(`/project/${ref}/functions/${id}/logs?te=${timestamp}`)
}
}
return (
<FunctionLayout>
<div className="space-y-6 py-2">
<div className="flex flex-row items-center gap-2">
<div className="flex items-center">
{CHART_INTERVALS.map((item, i) => {
const classes = []
if (i === 0) {
classes.push('rounded-tr-none rounded-br-none')
} else if (i === CHART_INTERVALS.length - 1) {
classes.push('rounded-tl-none rounded-bl-none')
} else {
classes.push('rounded-none')
}
return (
<Button
key={`function-filter-${i}`}
type={interval === item.key ? 'secondary' : 'default'}
onClick={() => setInterval(item.key)}
className={classes.join(' ')}
>
{item.label}
</Button>
)
})}
</div>
<span className="text-scale-1000 text-xs">
Statistics for past {selectedInterval.label}
</span>
</div>
<div className="">
{startDate && endDate && (
<>
<div className="grid grid-cols-1 md:gap-4 md:grid-cols-2 lg:grid-cols-2 lg:gap-8">
<Panel key="database-chart">
<Panel.Content className="space-y-4">
{/*
// @ts-ignore */}
<ChartHandler
label="Execution time"
defaultChartStyle="line"
startDate={startDate}
endDate={endDate}
attribute={'avg_execution_time'}
provider="log-stats"
// interval="1d"
hideChartType
customDateFormat={datetimeFormat}
data={charts}
format="ms"
isLoading={!charts.data && !error ? true : false}
highlightedValue={calculateHighlightedValue(
data?.result,
'avg_execution_time'
)}
// onBarClick={(v) => handleBarClick(v, '/rest')}
/>
</Panel.Content>
</Panel>
<Panel key="invocation-chart">
<Panel.Content className="space-y-4">
{/*
// @ts-ignore */}
<ChartHandler
label="Invocations"
startDate={startDate}
endDate={endDate}
attribute={'count'}
provider="log-stats"
// interval="1d"
hideChartType
customDateFormat={datetimeFormat}
data={charts}
isLoading={!charts.data && !error ? true : false}
highlightedValue={calculateHighlightedValue(data?.result, 'count', {
sum: true,
})}
onBarClick={(v) => handleBarClick(v, '/auth')}
/>
</Panel.Content>
</Panel>
</div>
<Panel key="error-chart">
<Panel.Content className="space-y-4">
{/*
// @ts-ignore */}
<ChartHandler
defaultChartStyle="line"
label="Error count"
startDate={startDate}
endDate={endDate}
attribute={'min_execution_time'}
provider="log-stats"
// interval="1d"
hideChartType
customDateFormat={datetimeFormat}
data={charts}
isLoading={!charts.data && !error ? true : false}
highlightedValue={calculateHighlightedValue(
data?.result,
'min_execution_time',
{
sum: true,
}
)}
onBarClick={(v) => handleBarClick(v, '/auth')}
/>
</Panel.Content>
</Panel>
</>
)}
</div>
</div>
</FunctionLayout>
)
}
export default withAuth(observer(PageLayout))
@@ -0,0 +1,32 @@
import React from 'react'
import { NextPage } from 'next'
import { useRouter } from 'next/router'
import { observer } from 'mobx-react-lite'
import { withAuth } from 'hooks'
import { LOG_TYPE_LABEL_MAPPING } from 'components/interfaces/Settings/Logs'
import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer'
import FunctionLayout from '../../../../../components/interfaces/functions/FunctionLayout'
/**
* Placeholder page for logs previewers until we figure out where to slot them
*/
export const LogPage: NextPage = () => {
const router = useRouter()
const { ref, type, id } = router.query
const title = `Invocation Logs - ${
LOG_TYPE_LABEL_MAPPING[type as keyof typeof LOG_TYPE_LABEL_MAPPING]
}`
return (
<FunctionLayout title={title}>
<LogsPreviewer
projectRef={ref as string}
queryType={'fn_edge'}
override={{ key: 'function_id', value: id }}
/>
</FunctionLayout>
)
}
export default withAuth(observer(LogPage))
@@ -0,0 +1,32 @@
import React from 'react'
import { NextPage } from 'next'
import { useRouter } from 'next/router'
import { observer } from 'mobx-react-lite'
import { withAuth } from 'hooks'
import { LOG_TYPE_LABEL_MAPPING } from 'components/interfaces/Settings/Logs'
import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer'
import FunctionLayout from '../../../../../components/interfaces/functions/FunctionLayout'
/**
* Placeholder page for logs previewers until we figure out where to slot them
*/
export const LogPage: NextPage = () => {
const router = useRouter()
const { ref, type, id } = router.query
const title = `Function Logs - ${
LOG_TYPE_LABEL_MAPPING[type as keyof typeof LOG_TYPE_LABEL_MAPPING]
}`
return (
<FunctionLayout title={title}>
<LogsPreviewer
projectRef={ref as string}
queryType={'functions'}
override={{ key: 'metadata.function_id', value: id }}
/>
</FunctionLayout>
)
}
export default withAuth(observer(LogPage))
@@ -0,0 +1,10 @@
import { observer } from 'mobx-react-lite'
import { withAuth } from 'hooks'
import FunctionLayout from '../../../../../components/interfaces/functions/FunctionLayout'
const PageLayout = () => {
return <FunctionLayout>Triggers</FunctionLayout>
}
export default withAuth(observer(PageLayout))
@@ -0,0 +1,295 @@
import { observer } from 'mobx-react-lite'
import { useStore, withAuth } from 'hooks'
import FunctionsLayout from '../../../../components/interfaces/functions/FunctionsLayout'
import { Button, IconBookOpen, IconSearch, IconTerminal, Input, Loading } from '@supabase/ui'
import { useEffect } from 'react'
import Table from 'components/to-be-cleaned/Table'
import FunctionsListItem from '../../../../components/interfaces/functions/FunctionsListItem'
import { useRouter } from 'next/router'
import { post } from 'lib/common/fetch'
import { API_URL, PROJECT_STATUS } from 'lib/constants'
import CommandRender from '../../../../components/interfaces/functions/CommandRender'
import Link from 'next/link'
import { Function } from '../../../../components/interfaces/functions/Functions.types'
const EmptyFunctions = () => {
const router = useRouter()
const { ref } = router.query
interface Commands {
command: string
description: string
jsx?: () => void
comment?: string
}
const commands: Commands[] = [
{
command: 'supabase init ',
description: "Needed if the project hasen't been inited before",
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> init
</>
)
},
comment: 'Initialize Supabase locally',
},
{
command: 'supabase login',
description: "Writes the user's API token to disk",
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> login
</>
)
},
comment: 'Login using your access token',
},
{
command: `supabase link --ref ${ref}`,
description: `Sets the Supabase project to be associated with your project ${ref}`,
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> link --ref {ref}
</>
)
},
comment: 'Link this project',
},
{
command: 'supabase functions new hello',
description: ' creates a function stub at ./functions/hello.ts',
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> functions new hello
</>
)
},
comment: 'Create a function',
},
{
command: 'supabase functions deploy hello',
description: 'Deploys function at ./functions/hello.ts',
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> functions deploy hello
</>
)
},
comment: 'Deploy your function',
},
{
command: `curl -L -X POST 'https://${ref}.functions.supabase.co/hello' -H 'Authorization: Bearer [YOUR ANON KEY]'`,
description: 'Deploys function at ./functions/hello.ts',
jsx: () => {
return (
<>
<span className="text-brand-1100">curl</span> -L -X POST 'https://{ref}
.functions.supabase.co/hello' -H 'Authorization: Bearer [YOUR ANON KEY]'
</>
)
},
comment: 'Deploy your function',
},
]
return (
<>
<div className="grid lg:grid-cols-12 gap-y-12 lg:gap-x-16 max-w-7xl py-12">
<div className="space-y-4 col-span-5">
<p className="text-scale-1200 text-base max-w-lg">
Scalable pay-as-you-go functions as a service (FaaS) to run your code with zero server
management.
</p>
<p className="text-scale-1100 text-sm max-w-lg">
No servers to provision, manage, or upgrade Automatically scale based on the load
Integrated monitoring, logging, and debugging capability Built-in security at role and
per function level based on the principle of least privilege Key networking capabilities
for hybrid and multi-cloud scenarios
</p>
<div className="flex gap-2">
<Link passHref href="https://supabase.com/docs/functions">
<Button as="a" type="default" iconRight={<IconBookOpen />}>
Documentation
</Button>
</Link>
</div>
</div>
<div
className="
col-span-7
bg-scale-100 dark:bg-scale-300 px-8 py-6
shadow
border rounded
space-y-8"
>
<div className="flex items-center gap-3">
<div className="border p-2 flex items-center justify-center w-8 h-8 bg-scale-100 rounded">
<IconTerminal strokeWidth={2} />
</div>
<h4>Terminal instructions</h4>
</div>
<div className="space-y-4">
<CommandRender commands={commands} />
</div>
</div>
</div>
</>
)
}
const FunctionsList = ({ functions }: { functions: Function[] }) => {
return (
<>
<div className="flex flex-col gap-3">
<div className="flex justify-between items-center">
<span className="text-sm text-scale-900">{`${functions.length} function${
functions.length > 1 ? 's' : ''
} deployed`}</span>
<Input icon={<IconSearch size={14} />} size="tiny" />
</div>
<div>
<Table
head={
<>
<Table.th>Name</Table.th>
<Table.th>Trigger</Table.th>
<Table.th>Status</Table.th>
<Table.th className="hidden 2xl:table-cell">Created</Table.th>
<Table.th className="hidden 2xl:table-cell">Last updated</Table.th>
<Table.th className="hidden 2xl:table-cell">Version</Table.th>
</>
}
body={
<>
{functions.length > 0 &&
functions.map((item: any) => <FunctionsListItem key={item.id} function={item} />)}
</>
}
/>
</div>
</div>
{/* <div
className="
col-span-2
bg-scale-300 rounded px-10 py-8 flex flex-col gap-8"
>
<h2 className="text-sm text-scale-1100">Function examples</h2>
<div className="grid lg:grid-cols-3 gap-8">
<div className="flex flex-col gap-3">
<div
className="
h-10 w-10
bg-indigo-900
bordershadow-indigo-900
rounded-md
text-scale-fixed-100
flex items-center justify-center
"
>
<IconGlobe />
</div>
<div className="">
<h3 className="text-scale-1200">Stripe payments</h3>
<p className="text-scale-1100">
Charge a payment credit card when a database event occurs
</p>
</div>
</div>
<div className="flex flex-col gap-3">
<div
className="
h-10 w-10
bg-indigo-900
bordershadow-indigo-900
rounded-md
text-scale-fixed-100
flex items-center justify-center
"
>
<IconGlobe />
</div>
<div className="">
<h3 className="text-scale-1200">Stripe payments</h3>
<p className="text-scale-1100">
Charge a payment credit card when a database event occurs
</p>
</div>
</div>
<div className="flex flex-col gap-3">
<div
className="
h-10 w-10
bg-indigo-900
bordershadow-indigo-900
rounded-md
text-scale-fixed-100
flex items-center justify-center
"
>
<IconGlobe />
</div>
<div className="">
<h3 className="text-scale-1200">Stripe payments</h3>
<p className="text-scale-1100">
Charge a payment credit card when a database event occurs
</p>
</div>
</div>
</div>
</div> */}
</>
)
}
const PageLayout = () => {
const router = useRouter()
const { ui, functions } = useStore()
const project = ui.selectedProject
useEffect(() => {
if (project && project.status === PROJECT_STATUS.INACTIVE) {
post(`${API_URL}/projects/${ui.selectedProject}/restore`, {})
}
}, [project])
useEffect(() => {
functions.load()
}, [ui.selectedProject])
if (functions.isLoading)
return (
<FunctionsLayout>
<Loading active={true}>loading</Loading>
</FunctionsLayout>
)
if (functions.list().length <= 0) {
// if (true) {
return (
<FunctionsLayout centered={true}>
<EmptyFunctions />
</FunctionsLayout>
)
} else {
return (
<FunctionsLayout>
<FunctionsList functions={functions.list()} />
</FunctionsLayout>
)
}
}
export default withAuth(observer(PageLayout))
@@ -0,0 +1,190 @@
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 { useStore, withAuth } from 'hooks'
import CodeEditor from 'components/ui/CodeEditor'
import {
LogsQueryPanel,
LogsTableName,
LogTable,
LogTemplate,
TEMPLATES,
} from 'components/interfaces/Settings/Logs'
import { uuidv4 } from 'lib/helpers'
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 toast from 'react-hot-toast'
export const LogsExplorerPage: NextPage = () => {
const router = useRouter()
const { ref, q } = router.query
const [editorId, setEditorId] = useState<string>(uuidv4())
const [editorValue, setEditorValue] = useState<string>('')
const [saveModalOpen, setSaveModalOpen] = useState<boolean>(false)
const { content } = useStore()
const [{ logData, error, isLoading }, { changeQuery, runQuery }] = useLogsQuery(ref as string)
useEffect(() => {
// on mount, set initial values
if (q !== undefined && q !== '') {
changeQuery(q as string)
runQuery()
onSelectTemplate({
mode: 'custom',
searchString: q as string,
})
}
}, [q])
const onSelectTemplate = (template: LogTemplate) => {
setEditorValue(template.searchString)
changeQuery(template.searchString)
setEditorId(uuidv4())
runQuery()
}
const handleRun = () => {
changeQuery(editorValue)
runQuery()
router.push({
pathname: router.pathname,
query: { ...router.query, q: editorValue },
})
}
const handleClear = () => {
setEditorValue('')
setEditorId(uuidv4())
changeQuery('')
}
const handleInsertSource = (source: LogsTableName) => {
setEditorValue((prev) => prev + source)
setEditorId(uuidv4())
}
function handleOnSave() {
setSaveModalOpen(!saveModalOpen)
}
return (
<LogsExplorerLayout>
<div className="h-full flex flex-col flex-grow gap-4">
<div className="border rounded">
<LogsQueryPanel
onSelectSource={handleInsertSource}
onClear={handleClear}
onRun={handleRun}
hasEditorValue={Boolean(editorValue)}
templates={TEMPLATES.filter((template) => template.mode === 'custom')}
onSelectTemplate={onSelectTemplate}
onSave={handleOnSave}
isLoading={isLoading}
/>
<div className="min-h-[7rem] h-48">
<ShimmerLine active={isLoading} />
<CodeEditor
id={editorId}
language="pgsql"
defaultValue={editorValue}
onInputChange={(v) => setEditorValue(v || '')}
onInputRun={handleRun}
/>
</div>
</div>
<div className="flex flex-col flex-grow relative pb-8">
<LoadingOpacity active={isLoading}>
<div className="flex flex-grow h-full">
<LogTable data={logData} error={error} />
</div>
</LoadingOpacity>
</div>
</div>
<Modal
size="medium"
onCancel={() => setSaveModalOpen(!saveModalOpen)}
header="Save log query"
visible={saveModalOpen}
hideFooter
>
<Form
initialValues={{
name: '',
desdcription: '',
}}
onSubmit={async (values: any, { setSubmitting }: any) => {
setSubmitting(true)
const payload: UserContent = {
name: values.name,
description: values.name,
type: 'log_sql',
content: {
content_id: editorId,
sql: editorValue,
schema_version: '1',
favorite: false,
},
visibility: 'user',
}
try {
const { data: query, error } = await content.create(payload)
if (error) throw error
console.log(query)
setSubmitting(false)
setSaveModalOpen(false)
toast.success(`Saved "${values.name}" log query`)
} catch (error: any) {
console.error(error)
setSubmitting(false)
setSaveModalOpen(false)
toast.error(error.message)
}
}}
>
{({ isSubmitting }: any) => (
<>
<div className="py-4">
<Modal.Content>
<div className="space-y-6">
<Input layout="horizontal" label="Name" id="name" />
<Input.TextArea
layout="horizontal"
labelOptional="Optional"
label="Description"
id="description"
rows={2}
/>
</div>
</Modal.Content>
</div>
<div className="bg-scale-300 py-3 border-t">
<Modal.Content>
<div className="flex gap-2 items-center justify-end">
<Button size="tiny" type="default">
Cancel
</Button>
<Button size="tiny" loading={isSubmitting}>
Save
</Button>
</div>
</Modal.Content>
</div>
</>
)}
</Form>
</Modal>
</LogsExplorerLayout>
)
}
export default withAuth(observer(LogsExplorerPage))
@@ -0,0 +1,73 @@
import React, { useEffect } from 'react'
import { NextPage } from 'next'
import Link from 'next/link'
import { observer } from 'mobx-react-lite'
import { IconSave, Loading } from '@supabase/ui'
import { useStore, withAuth } from 'hooks'
import { LogsSavedQueriesItem } from 'components/interfaces/Settings/Logs'
import LogsExplorerLayout from 'components/layouts/LogsExplorerLayout/LogsExplorerLayout'
import Table from 'components/to-be-cleaned/Table'
import { useRouter } from 'next/router'
export const LogsExplorerPage: NextPage = () => {
const { content, ui } = useStore()
const router = useRouter()
const { ref } = router.query
useEffect(() => {
content.load()
}, [ui.selectedProject])
if (content.isLoaded)
return (
<LogsExplorerLayout>
<Loading active={true}>loading</Loading>
</LogsExplorerLayout>
)
const saved = content.list()
console.log('saved in parent', saved.length)
return (
<LogsExplorerLayout>
<div className="flex flex-col gap-3">
{saved[0].length > 0 && (
<Table
headTrClasses="expandable-tr"
head={
<>
<Table.th>Name</Table.th>
<Table.th>Description</Table.th>
<Table.th className="">Created</Table.th>
<Table.th className="">Last updated</Table.th>
<Table.th className=""></Table.th>
</>
}
body={
saved.length > 0 &&
saved[0].map((item: any) => <LogsSavedQueriesItem key={item.id} item={item} />)
}
/>
)}
</div>
{saved[0].length === 0 && (
<>
<div className="items-center flex flex-col gap-1 my-auto justify-center h-full flex-grow">
<IconSave className="animate-bounce" />
<h3 className="text-lg text-scale-1200">No Saved Queries Yet</h3>
<p className="text-sm text-scale-900">
Saved queries will appear here. Queries can be saved from the{' '}
<Link href={`/project/${ref}/logs-explorer`}>
<span className="font-bold underline text-white cursor-pointer">Query</span>
</Link>{' '}
tab.
</p>
</div>
</>
)}
</LogsExplorerLayout>
)
}
export default withAuth(observer(LogsExplorerPage))
@@ -0,0 +1,49 @@
import React from 'react'
import { NextPage } from 'next'
import { useRouter } from 'next/router'
import { observer } from 'mobx-react-lite'
import { IconCode, Badge, IconDatabase, Icon, IconCloud } from '@supabase/ui'
import { withAuth } from 'hooks'
import { LogsTableName, LOGS_SOURCE_DESCRIPTION } from 'components/interfaces/Settings/Logs'
import LogsExplorerLayout from 'components/layouts/LogsExplorerLayout/LogsExplorerLayout'
import CardButton from 'components/ui/CardButton'
export const LogsExplorerPage: NextPage = () => {
const router = useRouter()
const { ref } = router.query
// @ts-ignore
const sources = Object.values(LogsTableName).sort((a, b) => a - b)
return (
<LogsExplorerLayout>
<div className="grid grid-cols-3 gap-6">
{sources.map((source) => (
<CardButton
title={source}
icon={
<div
className="h-6 w-6 text-scale-100 flex items-center justify-center rounded
bg-scale-1200
transition-colors
duration-400
group-hover:bg-brand-900
group-hover:text-brand-1200
"
>
<div className="scale-100 group-hover:scale-110">
{source === LogsTableName.POSTGRES && <IconDatabase size={12} strokeWidth={2} />}
{source === LogsTableName.EDGE && <IconCloud size={12} strokeWidth={2} />}
{source === LogsTableName.FUNCTIONS && <IconCode size={12} strokeWidth={2} />}
{source === LogsTableName.FN_EDGE && <IconCode size={12} strokeWidth={2} />}
</div>
</div>
}
linkHref={`/project/${ref}/logs-explorer/sources/${source}`}
description={LOGS_SOURCE_DESCRIPTION[source]}
/>
))}
</div>
</LogsExplorerLayout>
)
}
export default withAuth(observer(LogsExplorerPage))
@@ -0,0 +1,35 @@
import React from 'react'
import { NextPage } from 'next'
import { useRouter } from 'next/router'
import { observer } from 'mobx-react-lite'
import { withAuth } from 'hooks'
import { LogsExplorerLayout } from 'components/layouts/'
import { LogsTableName } from 'components/interfaces/Settings/Logs'
import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer'
export const LogPage: NextPage = () => {
const router = useRouter()
const { ref, source } = router.query
// quick fix to get correct queryType
const queryType = {
edge_logs: 'api',
postgres_logs: 'database',
function_logs: 'functions',
function_edge_logs: 'fn_edge',
}
return (
<LogsExplorerLayout subtitle={<span className="font-mono">{source}</span>}>
<LogsPreviewer
projectRef={ref as string}
condensedLayout={false}
tableName={source as LogsTableName}
// @ts-ignore
queryType={queryType[source]}
/>
</LogsExplorerLayout>
)
}
export default withAuth(observer(LogPage))
@@ -0,0 +1,77 @@
import React, { useState } from 'react'
import { NextPage } from 'next'
import { useRouter } from 'next/router'
import { observer, useStaticRendering } from 'mobx-react-lite'
import { IconCode, Badge, Collapsible, Button, Popover } from '@supabase/ui'
import { withAuth } from 'hooks'
import { TEMPLATES } from 'components/interfaces/Settings/Logs'
import LogsExplorerLayout from 'components/layouts/LogsExplorerLayout/LogsExplorerLayout'
import CardButton from 'components/ui/CardButton'
export const LogsExplorerPage: NextPage = () => {
const router = useRouter()
const { ref } = router.query
return (
<LogsExplorerLayout>
<div>
<div className="grid grid-cols-3 gap-6">
{TEMPLATES.filter((template) => template.mode === 'custom').map((template) => {
const [showPreview, setShowPreview] = useState(false)
return (
<CardButton
title={template.label}
icon={
<div
className="h-6 w-6 text-scale-100 flex items-center justify-center rounded
bg-scale-1200
transition-colors
duration-400
group-hover:bg-brand-900
group-hover:text-brand-1200
"
>
<div className="scale-100 group-hover:scale-110">
<IconCode size={12} strokeWidth={2} />
</div>
</div>
}
containerHeightClassName="h-40"
linkHref={`/project/${ref}/logs-explorer?q=${encodeURI(template.searchString)}`}
description={template.description}
footer={
<div className="flex flex-row justify-end">
<Popover
onOpenChange={setShowPreview}
open={showPreview}
className="rounded-lg bg-scale-100"
size="content"
overlay={
<pre className="rounded-lg whitespace-pre-line bg-scale-100 p-4 text-sm break-words">
{template.searchString}
</pre>
}
>
<Button
type="default"
as="span"
onClick={(e) => {
e.preventDefault()
setShowPreview(!showPreview)
}}
>
Preview
</Button>
</Popover>
</div>
}
/>
)
})}
</div>
</div>
</LogsExplorerLayout>
)
}
export default withAuth(observer(LogsExplorerPage))
@@ -1,209 +0,0 @@
import useSWR from 'swr'
import React, { useEffect, useState } from 'react'
import { NextPage } from 'next'
import { useRouter } from 'next/router'
import { observer } from 'mobx-react-lite'
import {
Typography,
IconLoader,
IconAlertCircle,
IconRewind,
Button,
IconInfo,
Card,
Input,
} from '@supabase/ui'
import { withAuth } from 'hooks'
import { get } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { SettingsLayout } from 'components/layouts/'
import CodeEditor from 'components/ui/CodeEditor'
import {
LogPanel,
LogTable,
LogEventChart,
Count,
Logs,
LogTemplate,
TEMPLATES,
LogData,
LogSearchCallback,
LOG_TYPE_LABEL_MAPPING,
genDefaultQuery,
genCountQuery,
LogsTableName,
} from 'components/interfaces/Settings/Logs'
import { uuidv4 } from 'lib/helpers'
import useSWRInfinite, { SWRInfiniteKeyLoader } from 'swr/infinite'
import { isUndefined } from 'lodash'
import dayjs from 'dayjs'
import InformationBox from 'components/ui/InformationBox'
import useLogsPreview from 'hooks/analytics/useLogsPreview'
/**
* Acts as a container component for the entire log display
*
* ## Query Params Syncing
* Query params are synced on query submission.
*
* params used are:
* - `q` for the editor query.
* - `s` for search query.
* - `te` for timestamp start value.
*/
export const LogPage: NextPage = () => {
const router = useRouter()
const { ref, type, s, te, ts } = router.query
const [showChart, setShowChart] = useState(true)
const table = type === 'api' ? LogsTableName.EDGE : LogsTableName.POSTGRES
const [
{ error, logData, params, newCount, filters, isLoading, oldestTimestamp },
{ loadOlder, setFilters, refresh, setTo, setFrom },
] = useLogsPreview(ref as string, table, {
initialFilters: { search_query: s as string },
whereStatementFactory: (filterObj) =>
`${
filterObj.search_query ? `REGEXP_CONTAINS(event_message, '${filterObj.search_query}')` : ''
}`,
})
const title = `Logs - ${LOG_TYPE_LABEL_MAPPING[type as keyof typeof LOG_TYPE_LABEL_MAPPING]}`
useEffect(() => {
if (filters.search_query !== s) {
setFilters((prev) => ({ ...prev, search_query: s as string }))
}
if (te !== params.timestamp_end) {
setTo(te as string)
}
if (ts !== params.timestamp_start) {
setFrom(ts as string)
}
}, [s, te, ts])
const onSelectTemplate = (template: LogTemplate) => {
setFilters((prev) => ({ ...prev, search_query: template.searchString }))
}
const handleRefresh = () => {
refresh()
router.push({
pathname: router.pathname,
query: {
...router.query,
te: undefined,
ts: undefined,
},
})
}
const handleSearch: LogSearchCallback = ({ 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 || '' }))
router.push({
pathname: router.pathname,
query: {
...router.query,
s: query || '',
ts: fromValue,
te: toValue,
},
})
}
return (
<SettingsLayout title={title}>
<div className="h-full flex flex-col flex-grow">
<LogPanel
isShowingEventChart={showChart}
onToggleEventChart={() => setShowChart(!showChart)}
isCustomQuery={false}
isLoading={isLoading}
newCount={newCount}
templates={TEMPLATES.filter(
(template) => template.for?.includes(type as string) && template.mode === 'simple'
)}
onRefresh={handleRefresh}
onSearch={handleSearch}
defaultSearchValue={filters.search_query}
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()
: ''
}
onCustomClick={() => {
router.push(`/project/${ref}/settings/logs/explorer?q=${params.rawSql}`)
}}
onSelectTemplate={onSelectTemplate}
/>
{showChart && (
<div>
<LogEventChart
data={!isLoading ? logData : undefined}
onBarClick={(timestampMicro) => {
handleSearch({ query: filters.search_query, toMicro: timestampMicro })
}}
/>
</div>
)}
<div className="flex flex-col flex-grow relative">
{isLoading && (
<div
className={[
'absolute top-0 w-full h-full flex items-center justify-center',
'bg-gray-100 opacity-75 z-50',
].join(' ')}
>
<IconLoader className="animate-spin" />
</div>
)}
<LogTable data={logData} isCustomQuery={false} />
<div className="p-2">
<Button onClick={() => loadOlder()} icon={<IconRewind />} type="default">
Load older
</Button>
</div>
{error && (
<div className="flex w-full h-full justify-center items-center mx-auto">
<Card className="flex flex-col gap-y-2 w-2/5 bg-scale-400">
<div className="flex flex-row gap-x-2 py-2">
<IconAlertCircle size={16} />
<Typography.Text type="secondary">
Sorry! An error occured when fetching data.
</Typography.Text>
</div>
<Input.TextArea
label="Error Messages"
value={JSON.stringify(error, null, 2)}
borderless
className=" border-t-2 border-scale-800 pt-2 font-mono"
/>
</Card>
</div>
)}
</div>
</div>
</SettingsLayout>
)
}
export default withAuth(observer(LogPage))
@@ -1,179 +0,0 @@
import useSWR from 'swr'
import React, { useEffect, useState } from 'react'
import { NextPage } from 'next'
import { useRouter } from 'next/router'
import { observer } from 'mobx-react-lite'
import {
Typography,
IconLoader,
IconAlertCircle,
IconRewind,
Button,
IconInfo,
Card,
Input,
} from '@supabase/ui'
import { withAuth } from 'hooks'
import { get } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { SettingsLayout } from 'components/layouts/'
import CodeEditor from 'components/ui/CodeEditor'
import {
LogPanel,
LogTable,
LogEventChart,
Count,
Logs,
LogTemplate,
TEMPLATES,
LogData,
LogSearchCallback,
LOG_TYPE_LABEL_MAPPING,
genDefaultQuery,
genCountQuery,
LogsTableName,
} from 'components/interfaces/Settings/Logs'
import { uuidv4 } from 'lib/helpers'
import useSWRInfinite, { SWRInfiniteKeyLoader } from 'swr/infinite'
import { isUndefined } from 'lodash'
import dayjs from 'dayjs'
import InformationBox from 'components/ui/InformationBox'
import useLogsPreview from 'hooks/analytics/useLogsPreview'
import useLogsQuery from 'hooks/analytics/useLogsQuery'
/**
* Acts as a container component for the entire log display
*
* ## Query Params Syncing
* Query params are synced on query submission.
*
* params used are:
* - `q` for the editor query.
* - `s` for search query.
* - `te` for timestamp start value.
*/
export const LogsExplorerPage: NextPage = () => {
const router = useRouter()
const { ref, type, q } = router.query
const [editorId, setEditorId] = useState<string>(uuidv4())
const [editorValue, setEditorValue] = useState('')
const title = `Logs - All`
const [{ logData, params, error, isLoading }, { changeQuery, runQuery }] = useLogsQuery(
ref as string
)
useEffect(() => {
// on mount, set initial values
if (q !== undefined && q !== '') {
changeQuery(q as string)
runQuery()
onSelectTemplate({
mode: 'custom',
searchString: q as string,
})
}
}, [q])
const onSelectTemplate = (template: LogTemplate) => {
setEditorValue(template.searchString)
changeQuery(template.searchString)
setEditorId(uuidv4())
runQuery()
}
const handleEditorSubmit = () => {
changeQuery(editorValue)
runQuery()
router.push({
pathname: router.pathname,
query: { ...router.query, q: editorValue },
})
}
return (
<SettingsLayout title={title}>
<div className="h-full flex flex-col flex-grow">
<LogPanel
isShowingEventChart={false}
onToggleEventChart={() => null}
isCustomQuery={true}
isLoading={isLoading}
newCount={0}
templates={TEMPLATES.filter((template) => template.mode === 'custom')}
onRefresh={() => null}
onSearch={() => null}
defaultSearchValue={''}
defaultToValue={
params.timestamp_end
? dayjs(Number(params.timestamp_end) / 1000).toISOString()
: params.timestamp_end
}
onCustomClick={() => null}
onSelectTemplate={onSelectTemplate}
/>
<React.Fragment>
<div className="min-h-[7rem] h-28">
<CodeEditor
id={editorId}
language="pgsql"
defaultValue={editorValue}
onInputChange={(v) => setEditorValue(v || '')}
onInputRun={handleEditorSubmit}
/>
</div>
<div className="flex flex-row justify-end items-center px-2 py-1 w-full">
<div className="flex flex-row gap-x-2 justify-end p-2">
<Button
type="text"
onClick={() => {
setEditorValue('')
setEditorId(uuidv4())
}}
>
Reset
</Button>
<Button type={editorValue ? 'secondary' : 'text'} onClick={handleEditorSubmit}>
Run
</Button>
</div>
</div>
</React.Fragment>
<div className="flex flex-col flex-grow relative">
{isLoading && (
<div
className={[
'absolute top-0 w-full h-full flex items-center justify-center',
'bg-gray-100 opacity-75 z-50',
].join(' ')}
>
<IconLoader className="animate-spin" />
</div>
)}
<LogTable data={logData} isCustomQuery={true} />
{error && (
<div className="flex w-full h-full justify-center items-center mx-auto">
<Card className="flex flex-col gap-y-2 w-2/5 bg-scale-400">
<div className="flex flex-row gap-x-2 py-2">
<IconAlertCircle size={16} />
<Typography.Text type="secondary">
Sorry! An error occured when fetching data.
</Typography.Text>
</div>
<Input.TextArea
label="Error Messages"
value={JSON.stringify(error, null, 2)}
borderless
className=" border-t-2 border-scale-800 pt-2 font-mono"
/>
</Card>
</div>
)}
</div>
</div>
</SettingsLayout>
)
}
export default withAuth(observer(LogsExplorerPage))
+9
View File
@@ -3,6 +3,7 @@ import AppStore, { IAppStore } from './app/AppStore'
import MetaStore, { IMetaStore } from './pgmeta/MetaStore'
import UiStore, { IUiStore } from './UiStore'
import ProjectContentStore, { IProjectContentStore } from './content/ProjectContentStore'
import ProjectFunctionsStore, { IProjectFunctionsStore } from './functions/ProjectFunctionsStore'
// Temporary disable mobx warnings
// TODO: need to remove this after refactoring old stores.
@@ -13,6 +14,7 @@ configure({
export interface IRootStore {
ui: IUiStore
content: IProjectContentStore
functions: IProjectFunctionsStore
meta: IMetaStore
app: IAppStore
setProjectRef: (value?: string) => void
@@ -21,12 +23,15 @@ export interface IRootStore {
export class RootStore implements IRootStore {
ui: IUiStore
content: IProjectContentStore
functions: IProjectFunctionsStore
meta: IMetaStore
app: IAppStore
constructor() {
this.ui = new UiStore(this)
// @ts-ignore
this.content = new ProjectContentStore(this, { projectRef: '' })
this.functions = new ProjectFunctionsStore(this, { projectRef: '' })
this.meta = new MetaStore(this, {
projectRef: '',
connectionString: '',
@@ -45,13 +50,17 @@ export class RootStore implements IRootStore {
() => this.ui.selectedProject,
(selectedProject) => {
if (selectedProject) {
// @ts-ignore
this.content = new ProjectContentStore(this, { projectRef: selectedProject.ref })
this.functions = new ProjectFunctionsStore(this, { projectRef: selectedProject.ref })
this.meta = new MetaStore(this, {
projectRef: selectedProject.ref,
connectionString: selectedProject.connectionString ?? '',
})
} else {
// @ts-ignore
this.content = new ProjectContentStore(this, { projectRef: '' })
this.functions = new ProjectFunctionsStore(this, { projectRef: '' })
this.meta = new MetaStore(this, {
projectRef: '',
connectionString: '',
@@ -12,9 +12,11 @@ import { API_URL } from 'lib/constants'
export interface IProjectContentStore {
isLoading: boolean
isInitialized: boolean
isLoaded: boolean
error: any
load: () => void
create: (x: UserContent) => { data: UserContent; error: { error: { message: string } } }
list: (filter?: any) => any[]
reports: (filter?: any) => any[]
sqlSnippets: (filter?: any) => any[]
@@ -51,6 +53,10 @@ export default class ProjectContentStore implements IProjectContentStore {
return this.state !== this.STATES.INITIAL
}
get isLoaded() {
return this.state !== this.STATES.LOADED
}
async fetchData() {
const headers = {
'Content-Type': 'application/json',
@@ -110,6 +116,7 @@ export default class ProjectContentStore implements IProjectContentStore {
}
sqlSnippets(filter?: any) {
console.log('i ran')
let arr = Object.values(this.data)
if (!!filter) {
const arr_filtered = arr
@@ -127,6 +134,7 @@ export default class ProjectContentStore implements IProjectContentStore {
}
}
// @ts-ignore
async create(payload: any) {
try {
const headers = {
@@ -0,0 +1,192 @@
import { makeAutoObservable, toJS } from 'mobx'
import { keyBy } from 'lodash'
import { get, post, patch, delete_ } from 'lib/common/fetch'
import { UserContent, UserContentMap } from 'types'
import { IRootStore } from '../RootStore'
// [Joshen] This will be the new ProjectContentStore
// but use the one under the stores folder projectContentStore first while we transition
export interface IProjectFunctionsStore {
isLoading: boolean
isInitialized: boolean
isLoaded: boolean
error: any
load: () => void
list: (filter?: any) => any[]
byId: (id?: any) => any
reports: (filter?: any) => any[]
sqlSnippets: (filter?: any) => any[]
}
export default class ProjectFunctionsStore implements IProjectFunctionsStore {
rootStore: IRootStore
STATES = {
INITIAL: 'initial',
LOADING: 'loading',
ERROR: 'error',
LOADED: 'loaded',
}
baseUrl: string
// @ts-ignore
data: UserContentMap = []
state = this.STATES.INITIAL
error = null
constructor(rootStore: IRootStore, options: { projectRef: string }) {
const { projectRef } = options
this.rootStore = rootStore
this.baseUrl = `${process.env.NEXT_PUBLIC_API_ADMIN_URL}/projects/${projectRef}/functions`
makeAutoObservable(this)
}
get isLoading() {
return this.state === this.STATES.INITIAL || this.state === this.STATES.LOADING
}
get isInitialized() {
return this.state !== this.STATES.INITIAL
}
get isLoaded() {
return this.state !== this.STATES.LOADED
}
async fetchData() {
const headers = {
'Content-Type': 'application/json',
}
const response = await get(this.baseUrl, { headers })
if (response.error) {
throw response.error
}
this.data = keyBy(response, 'id')
return response
}
async load() {
let { LOADING, ERROR, LOADED } = this.STATES
try {
this.error = null
this.state = LOADING
await this.fetchData()
this.state = LOADED
return this.data
} catch (e: any) {
console.error('Failed to load project content', e.message)
this.error = e
this.state = ERROR
}
}
async loadPersistentData() {}
async loadRemotePersistentData(userId: any) {
const sqlSnippets = this.sqlSnippets((x: any) => x.owner_id === userId)
}
list(filter?: any) {
const arr = Object.values(this.data)
if (!!filter) {
return arr.filter(filter).sort((a, b) => a.name.localeCompare(b.name))
} else {
return arr.sort((a, b) => a.name.localeCompare(b.name))
}
}
byId(id: any) {
return toJS(this.data)[id]
}
reports(filter?: any) {
let arr = Object.values(this.data)
if (!!filter) {
return arr
.filter((x) => x.type == 'report')
.filter(filter)
.sort((a, b) => a.name.localeCompare(b.name))
} else {
return arr.filter((x) => x.type == 'report').sort((a, b) => a.name.localeCompare(b.name))
}
}
sqlSnippets(filter?: any) {
let arr = Object.values(this.data)
if (!!filter) {
const arr_filtered = arr
.filter((x) => x.type == 'sql')
.filter(filter)
.sort((a, b) => a.name.localeCompare(b.name))
return arr_filtered
} else {
const arr_filtered = arr
.filter((x) => x.type == 'sql')
.sort((a, b) => a.name.localeCompare(b.name))
return arr_filtered
}
}
async create(payload: any) {
try {
const headers = {
'Content-Type': 'application/json',
}
const created = await post(this.baseUrl, payload, { headers })
if (created.error) throw created.error
this.data[created['id']] = created
return { data: created, error: null }
} catch (error) {
return { data: null, error }
}
}
async update(id: any, updates: any, type: UserContent['type']) {
try {
const headers = {
'Content-Type': 'application/json',
}
let payload = {
...updates,
type,
id,
}
const url = `${this.baseUrl}?id=${id}`
const updated = await patch(url, payload, { headers })
if (updated.error) throw updated.error
this.data[id] = updated
return { data: updated, error: null }
} catch (error) {
return { data: null, error }
}
}
async updateSql(id: any, updates: any) {
return this.update(id, updates, 'sql')
}
async updateReport(id: any, updates: any) {
return this.update(id, updates, 'report')
}
async del(id: any) {
try {
const headers = {
'Content-Type': 'application/json',
}
const url = `${this.baseUrl}?id=${id}`
const deleted = await delete_(url, {}, { headers })
if (deleted.error) throw deleted.error
delete this.data[id]
return { data: true, error: null }
} catch (error) {
return { data: false, error }
}
}
}
+101
View File
@@ -0,0 +1,101 @@
/* // date picker */
.react-datepicker__input-container input {
@apply block w-full text-base md:text-sm bg-white border border-scale-300 rounded shadow-sm;
}
.react-datepicker-popper {
@apply z-40 w-72 text-sm bg-scale-100 dark:bg-scale-300 shadow px-3 py-2 border-2 border-scale-200 rounded;
}
.react-datepicker-left {
@apply absolute left-0 right-auto top-11 transform-none #{!important};
}
.react-datepicker-right {
@apply absolute right-0 left-auto top-11 transform-none #{!important};
}
.react-datepicker__portal {
@apply absolute z-10 w-72 text-sm transform-none bg-white shadow px-3 py-2 top-12 right-0 border-2 border-scale-200 rounded;
}
.react-datepicker__month-container {
@apply flex flex-col;
}
.react-datepicker__month {
@apply flex flex-col;
@apply -space-y-px;
}
.react-datepicker__current-month {
@apply ml-2.5 text-lg font-semibold text-scale-800;
}
.react-datepicker__week {
@apply flex -space-x-px;
}
.react-datepicker__day-names {
@apply flex -space-x-px text-scale-900 font-normal text-center text-xs;
}
.react-datepicker__day-name {
@apply w-10 h-10 flex items-center justify-center py-1 rounded-full;
}
.react-datepicker__navigation {
@apply absolute top-2;
}
.react-datepicker__navigation--previous {
@apply right-12 w-10 h-10 rounded transition flex items-center justify-center hover:bg-scale-200;
}
.react-datepicker__navigation--next {
@apply right-4 w-10 h-10 rounded transition flex items-center justify-center hover:bg-scale-200;
}
.react-datepicker__day {
@apply w-10 h-10 flex items-center font-normal justify-center text-sm leading-loose transition text-scale-1100;
/* @apply mb-1 py-1; */
@apply -space-y-px;
@apply border border-scale-500;
}
.react-datepicker__day--disabled {
@apply cursor-not-allowed opacity-40 hover:bg-transparent;
}
.react-datepicker__day--outside-month {
@apply text-scale-800;
}
.react-datepicker__day--in-range {
@apply bg-brand-800 text-scale-fixed-100 border-brand-800;
}
.react-datepicker__day--in-selecting-range {
@apply bg-brand-700 text-scale-fixed-100 border-brand-700;
}
.react-datepicker__day--selecting-range-start {
@apply bg-scale-200;
}
.react-datepicker__day--selecting-range-end {
@apply bg-scale-200;
}
.react-datepicker__day--selected {
@apply bg-brand-800 text-white;
}
.react-datepicker__day--range-start {
@apply bg-brand-900 text-white hover:text-scale-700 hover:bg-white;
}
.react-datepicker__day--range-end {
@apply bg-brand-900 text-white hover:text-scale-700 hover:bg-white;
}
+15
View File
@@ -188,3 +188,18 @@ div[data-radix-portal]:not(.portal--toast) {
div.fixed.inset-0 {
z-index: 11 !important;
}
.recharts-tooltip-cursor {
@apply fill-transparent;
}
.expandable-tr {
display: grid;
grid-template-columns: repeat(5, 1fr);
}
.expanded-row-content {
display: grid;
grid-column: 1/-1;
justify-content: flex-start;
}
+95
View File
@@ -0,0 +1,95 @@
.data-grid--simple-logs {
.rdg-cell,
.rdg-cell span {
@apply text-scale-1100 font-normal #{!important};
border-left: none;
border-right: none;
}
.rdg-cell:first-child {
@apply pl-5;
}
.rdg-cell:last-child {
@apply pr-5;
}
.rdg-row {
&.rdg-row--focussed {
@apply border-r border-brand-900;
border-right-width: 4px;
}
&.rdg-row--focussed .rdg-cell,
&.rdg-row--focussed .rdg-cell span {
@apply text-scale-1200 font-semibold #{!important};
}
}
}
.data-grid--logs-explorer {
@apply transition-all overflow-hidden overflow-x-scroll;
.rdg-cell,
.rdg-cell span {
@apply text-scale-1100 font-normal #{!important};
}
.rdg-cell:first-child {
@apply pl-5;
}
.rdg-cell:last-child {
@apply pr-5;
}
.rdg-row {
&.rdg-row--focussed {
@apply border-r border-brand-900;
border-right-width: 4px;
}
&.rdg-row--focussed .rdg-cell,
&.rdg-row--focussed .rdg-cell span {
@apply text-scale-1200 font-semibold #{!important};
}
}
}
.syntax-highlight {
counter-reset: line-numbering;
font-family: Menlo, Monaco, monospace;
color: var(--colors-scale10);
word-break: break-word;
}
.syntax-highlight .line::before {
content: counter(line-numbering);
counter-increment: line-numbering;
padding-right: 0px;
display: inline-block;
color: var(--colors-scale8);
/* space after numbers */
width: 2rem;
text-align: left;
}
.logs-shimmering-loader {
animation: shimmer 1.5s infinite linear;
background: linear-gradient(
to right,
var(--colors-scale2) 0%,
var(--colors-brand9) 25%,
var(--colors-brand4) 35%,
var(--colors-scale2) 45%,
var(--colors-scale3) 75%
);
background-size: 3000px 100%;
}
@keyframes logsShimmer {
0% {
background-position: -1000px 0;
}
50% {
background-position: 1000px 0;
}
}
+4 -4
View File
@@ -77,7 +77,7 @@ module.exports = ui({
}),
divideColor: (theme) => ({
...theme('colors'),
DEFAULT: 'var(--colors-scale3)',
DEFAULT: 'var(--colors-scale6)',
dark: 'var(--colors-scale2)',
}),
extend: {
@@ -222,7 +222,7 @@ module.exports = ui({
dark: 'var(--colors-scale4)', // '#1a1a1a',
},
'table-border': {
light: 'var(--colors-scale4)', // gray[100],
light: 'var(--colors-scale5)', // gray[100],
dark: 'var(--colors-scale4)', // '#1a1a1a',
},
@@ -240,11 +240,11 @@ module.exports = ui({
dark: 'var(--colors-scale3)',
},
'panel-border': {
light: 'var(--colors-scale4)',
light: 'var(--colors-scale5)',
dark: 'var(--colors-scale4)',
},
'panel-border-interior': {
light: 'var(--colors-scale2)',
light: 'var(--colors-scale4)',
dark: 'var(--colors-scale4)',
},
'panel-border-hover': {
@@ -1,140 +0,0 @@
import LogPanel from 'components/interfaces/Settings/Logs/LogPanel'
import { render, waitFor, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { getToggleByText } from 'tests/helpers'
jest.mock('components/ui/Flag/Flag')
import Flag from 'components/ui/Flag/Flag'
Flag.mockImplementation(({ children }) => <>{children}</>)
jest.mock('hooks')
import { useFlag } from 'hooks'
useFlag.mockReturnValue(true)
test('templates', async () => {
const mockFn = jest.fn()
render(<LogPanel templates={[{ label: 'Some option', onClick: mockFn }]} />)
const search = screen.getByPlaceholderText(/Search/)
userEvent.type(search, '12345')
// TODO templates dropdown interaction currently cannot be tested
// https://github.com/supabase/ui/issues/299
// userEvent.click(screen.getByText("Templates"))
// screen.debug()
// await waitFor(() => screen.getByText("Some option"))
// userEvent.click(screen.getByText("Some option"))
// expect(mockFn).toBeCalled()
})
test('custom mode', async () => {
render(<LogPanel isCustomQuery />)
await waitFor(() => {
expect(() => screen.getByPlaceholderText(/Search/)).toThrow()
})
})
test('toggle event chart', async () => {
const mockFn = jest.fn()
const { rerender } = render(<LogPanel onToggleEventChart={mockFn} isShowingEventChart={true} />)
const toggle = getToggleByText(/Show event chart/)
userEvent.click(toggle)
expect(mockFn).toBeCalled()
rerender(<LogPanel isShowingEventChart={false} />)
})
test('filter input change and submit', async () => {
const mockFn = jest.fn()
render(<LogPanel onSearch={mockFn} />)
const search = screen.getByPlaceholderText(/Search/)
userEvent.type(search, '12345')
expect(mockFn).not.toBeCalled()
userEvent.click(screen.getByTitle('Go'))
expect(mockFn).toBeCalled()
})
test('filter input value', async () => {
render(<LogPanel defaultSearchValue={'1234'} />)
screen.getByDisplayValue('1234')
})
test('Manual refresh', async () => {
const mockFn = jest.fn()
render(<LogPanel onRefresh={mockFn} />)
let btn
await waitFor(() => {
btn = screen.getByText(/Refresh/)
})
userEvent.click(btn)
expect(mockFn).toBeCalled()
})
test('reset search filter', async () => {
const { rerender } = render(<LogPanel />)
expect(() => screen.getByTitle(/Clear search/)).toThrow()
rerender(<LogPanel defaultSearchValue="something123" />)
await waitFor(() => screen.getByDisplayValue(/something123/))
userEvent.click(screen.getByTitle(/Clear search/))
expect(() => screen.getByTitle(/Clear search/)).toThrow()
expect(() => screen.getByDisplayValue(/something123/)).toThrow()
})
test('timestamp to/from filter default value', async () => {
render(<LogPanel defaultToValue="2022-01-18T10:43:39+0000" />)
userEvent.click(await screen.findByText('Custom'))
await screen.findByDisplayValue('2022-01-18T10:43:39+0000')
// TODO: use screen.findByLabelText when https://github.com/supabase/ui/issues/310 is resolved
await screen.findByText('To')
await screen.findByText('From')
})
test('timestamp to/from filter error handling', async () => {
const mockFn = jest.fn()
render(<LogPanel onSearch={mockFn} />)
userEvent.click(await screen.findByText(/Now/))
// display iso timestamp
const year = new Date().getFullYear()
const inputs = await screen.findAllByDisplayValue(RegExp(year))
expect(inputs.length).toBe(2)
const input = inputs[0]
userEvent.clear(input)
userEvent.type(input, '123456')
await screen.findByText(/[iI]nvalid ISO 8601 timestamp/)
})
test('timestamp to/from filter value change', async () => {
const mockFn = jest.fn()
render(<LogPanel onSearch={mockFn} />)
userEvent.click(await screen.findByText(/Now/))
// display iso timestamp
const year = new Date().getFullYear()
const inputs = await screen.findAllByDisplayValue(RegExp(year))
for (const input of inputs) {
// replace the input's value
userEvent.clear(input)
// get time 20 mins before
const newDate = new Date()
newDate.setMinutes(new Date().getMinutes() - 20)
userEvent.type(input, newDate.toISOString())
}
// input actions
const set = await screen.findByRole('button', { name: 'Set' })
userEvent.click(set)
expect(mockFn).toBeCalled()
await screen.findByText('Custom')
await screen.findByTitle(/Clear timestamp filter/)
})
test('custom query mode hides elements', async () => {
const { rerender } = render(<LogPanel isCustomQuery={false} />)
await screen.findByPlaceholderText(/Search/)
await screen.findByText('Now')
rerender(<LogPanel isCustomQuery={true} />)
await expect(screen.findByPlaceholderText(/Search/)).rejects.toThrow()
await expect(screen.findByText('Now')).rejects.toThrow()
await expect(screen.findByText('Custom')).rejects.toThrow()
})
+15 -7
View File
@@ -1,5 +1,6 @@
import LogTable from 'components/interfaces/Settings/Logs/LogTable'
import { render, fireEvent, waitFor, screen } from '@testing-library/react'
import { render, waitFor, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
test('can display log data', async () => {
render(
<LogTable
@@ -16,9 +17,8 @@ test('can display log data', async () => {
/>
)
await waitFor(() => screen.getByText(/happened/))
const row = screen.getByText(/happened/)
fireEvent.click(row)
const row = await screen.findByText(/some-uuid/)
userEvent.click(row)
await waitFor(() => screen.getByText(/my_key/))
await waitFor(() => screen.getByText(/something_value/))
})
@@ -32,20 +32,20 @@ test('dedupes log lines with exact id', async () => {
id: 'some-uuid',
timestamp: 1621323232312,
event_message: 'some event happened',
metadata: {}
metadata: {},
},
{
id: 'some-uuid',
timestamp: 1621323232312,
event_message: 'some event happened',
metadata: {}
metadata: {},
},
]}
/>
)
// should only have one element, this line will fail if there are >1 element
await waitFor(() => screen.getByText(/happened/))
await screen.findByText(/some-uuid/)
})
test('can display custom columns and headers based on data input', async () => {
@@ -55,3 +55,11 @@ test('can display custom columns and headers based on data input', async () => {
await waitFor(() => screen.getByText(/kinda/))
await waitFor(() => screen.getByText(/123456/))
})
test('toggle histogram', async () => {
const mockFn = jest.fn()
render(<LogTable onHistogramToggle={mockFn} isHistogramShowing={true} />)
const toggle = await screen.getByText(/Histogram/)
userEvent.click(toggle)
expect(mockFn).toBeCalled()
})
@@ -0,0 +1,285 @@
// mock the fetch function
jest.mock('lib/common/fetch')
import { get } from 'lib/common/fetch'
// mock mobx
jest.mock('mobx-react-lite')
import { observer } from 'mobx-react-lite'
observer.mockImplementation((v) => v)
// mock the router
jest.mock('next/router')
import { useRouter } from 'next/router'
const defaultRouterMock = () => {
const router = jest.fn()
router.query = {}
router.push = jest.fn()
router.pathname = 'logs/path'
return router
}
useRouter.mockReturnValue(defaultRouterMock())
// mock monaco editor
jest.mock('@monaco-editor/react')
import Editor, { useMonaco } from '@monaco-editor/react'
Editor = jest.fn()
Editor.mockImplementation((props) => {
return (
<textarea className="monaco-editor" onChange={(e) => props.onChange(e.target.value)}></textarea>
)
})
useMonaco.mockImplementation((v) => v)
// mock usage flags
jest.mock('components/ui/Flag/Flag')
import Flag from 'components/ui/Flag/Flag'
Flag.mockImplementation(({ children }) => <>{children}</>)
jest.mock('hooks')
import { useFlag } from 'hooks'
useFlag.mockReturnValue(true)
import { SWRConfig } from 'swr'
jest.mock('components/interfaces/Settings/Logs/LogsPreviewer')
import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer'
LogsPreviewer.mockImplementation((props) => {
const Comp = jest.requireActual('components/interfaces/Settings/Logs/LogsPreviewer').default
// wrap with SWR to reset the cache each time
return (
<SWRConfig
value={{
provider: () => new Map(),
shouldRetryOnError: false,
}}
>
<Comp {...props} />
</SWRConfig>
)
})
import { render, fireEvent, waitFor, screen, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { getToggleByText } from '../../helpers'
import { wait } from '@testing-library/user-event/dist/utils'
import { logDataFixture } from '../../fixtures'
import { LogsTableName } from 'components/interfaces/Settings/Logs'
beforeEach(() => {
// reset mocks between tests
get.mockReset()
useRouter.mockReset()
useRouter.mockReturnValue(defaultRouterMock())
})
test('can display log data and metadata', async () => {
get.mockResolvedValue({
result: [
logDataFixture({
id: 'some-event-happened-id',
metadata: {
my_key: 'something_value',
},
}),
],
})
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
fireEvent.click(await screen.findByText(/some-event-happened-id/))
await screen.findByText(/my_key/)
await screen.findByText(/something_value/)
})
test('Refresh page', async () => {
get.mockImplementation((url) => {
if (url.includes('count')) return { result: { count: 0 } }
return {
result: [
logDataFixture({
id: 'some-event-id',
metadata: { my_key: 'something_value' },
}),
],
}
})
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
const row = await screen.findByText(/some-event-id/)
fireEvent.click(row)
await screen.findByText(/my_key/)
})
test('Search will trigger a log refresh', async () => {
get.mockImplementation((url) => {
if (url.includes('something')) {
return {
result: [logDataFixture({ id: 'some-event-id' })],
}
}
return { result: [] }
})
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
userEvent.type(screen.getByPlaceholderText(/Search events/), 'something{enter}')
await waitFor(
() => {
expect(get).toHaveBeenCalledWith(expect.stringContaining('something'))
// updates router query params
const router = useRouter()
expect(router.push).toHaveBeenCalledWith(
expect.objectContaining({
pathname: expect.any(String),
query: expect.objectContaining({
s: expect.stringContaining('something'),
}),
})
)
},
{ timeout: 1500 }
)
await screen.findByText(/some-event-id/)
})
test('poll count for new messages', async () => {
get.mockImplementation((url) => {
if (url.includes('count')) {
return { result: [{ count: 125 }] }
}
return {
result: [logDataFixture({ id: 'some-uuid123' })],
}
})
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
await waitFor(() => screen.queryByText(/some-uuid123/) === null)
// should display new logs count
await waitFor(() => screen.getByText(/125/))
userEvent.click(screen.getByText(/Refresh/))
await waitFor(() => screen.queryByText(/125/) === null)
await screen.findByText(/some-uuid123/)
})
test('s= query param will populate the search bar', async () => {
const router = defaultRouterMock()
router.query = { ...router.query, s: 'someSearch' }
useRouter.mockReturnValue(router)
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
// should populate search input with the search param
await screen.findByDisplayValue('someSearch')
expect(get).toHaveBeenCalledWith(expect.stringContaining('someSearch'))
})
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 router = defaultRouterMock()
router.query = { ...router.query, te: unixMicro }
useRouter.mockReturnValue(router)
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
await waitFor(() => {
expect(get).toHaveBeenCalledWith(
expect.stringContaining(`timestamp_end=${encodeURIComponent(unixMicro)}`)
)
})
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 router = defaultRouterMock()
router.query = { ...router.query, ts: unixMicro }
useRouter.mockReturnValue(router)
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
await waitFor(() => {
expect(get).toHaveBeenCalledWith(
expect.stringContaining(`timestamp_start=${encodeURIComponent(unixMicro)}`)
)
})
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 () => {
get.mockImplementation((url) => {
if (url.includes('count')) {
return {}
}
return {
result: [logDataFixture({ id: 'first event' })],
}
})
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
// should display first log but not second
await waitFor(() => screen.getByText('first event'))
await expect(screen.findByText('second event')).rejects.toThrow()
get.mockResolvedValueOnce({
result: [logDataFixture({ id: 'second event' })],
})
// should display first and second log
userEvent.click(await screen.findByText('Load older'))
await screen.findByText('first event')
await screen.findByText('second event')
expect(get).toHaveBeenCalledWith(expect.stringContaining('timestamp_end='))
})
test('bug: load older btn does not error out when previous page is empty', async () => {
// bugfix for https://sentry.io/organizations/supabase/issues/2903331460/?project=5459134&referrer=slack
get.mockImplementation((url) => {
if (url.includes('count')) {
return {}
}
return { result: [] }
})
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
userEvent.click(await screen.findByText('Load older'))
// NOTE: potential race condition, since we are asserting that something DOES NOT EXIST
// wait for 500s to make sure all ui logic is complete
// need to wrap in act because internal react state is changing during this time.
await act(async () => await wait(100))
// clicking load older multiple times should not give error
await waitFor(() => {
expect(screen.queryByText(/Sorry/)).toBeNull()
expect(screen.queryByText(/An error occured/)).toBeNull()
expect(screen.queryByText(/undefined/)).toBeNull()
})
})
test('log event chart hide', async () => {
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
await screen.findByText('Events')
const toggle = await screen.findByText(/Chart/)
userEvent.click(toggle)
await expect(screen.findByText('Events')).rejects.toThrow()
})
test('bug: nav backwards with params change results in ui changing', async () => {
// bugfix for https://sentry.io/organizations/supabase/issues/2903331460/?project=5459134&referrer=slack
get.mockImplementation((url) => {
if (url.includes('count')) {
return {}
}
return { data: [] }
})
const { container, rerender } = render(
<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />
)
await expect(screen.findByDisplayValue('simple-query')).rejects.toThrow()
const router = defaultRouterMock()
router.query = { ...router.query, s: 'simple-query' }
useRouter.mockReturnValue(router)
rerender(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
await screen.findByDisplayValue('simple-query')
})
@@ -0,0 +1,26 @@
import LogsQueryPanel from 'components/interfaces/Settings/Logs/LogsQueryPanel'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
jest.mock('components/ui/Flag/Flag')
import Flag from 'components/ui/Flag/Flag'
Flag.mockImplementation(({ children }) => <>{children}</>)
jest.mock('hooks')
import { useFlag } from 'hooks'
useFlag.mockReturnValue(true)
test.todo('templates')
test('run and clear', async () => {
const mockRun = jest.fn()
const mockClear = jest.fn()
render(<LogsQueryPanel onRun={mockRun} onClear={mockClear} hasEditorValue/>)
await expect(screen.findByPlaceholderText(/Search/)).rejects.toThrow()
const run = await screen.findByText(/Run/)
userEvent.click(run)
expect(mockRun).toBeCalled()
const clear = await screen.findByText(/Clear/)
userEvent.click(clear)
expect(mockClear).toBeCalled()
})
@@ -0,0 +1,95 @@
import PreviewFilterPanel from 'components/interfaces/Settings/Logs/PreviewFilterPanel'
import { render, waitFor, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
jest.mock('components/ui/Flag/Flag')
import Flag from 'components/ui/Flag/Flag'
Flag.mockImplementation(({ children }) => <>{children}</>)
jest.mock('hooks')
import { useFlag } from 'hooks'
import { clickDropdown } from 'tests/helpers'
useFlag.mockReturnValue(true)
test.todo('templates')
// , async () => {
// const mockFn = jest.fn()
// render(<PreviewFilterPanel templates={[{ label: 'Some option', onClick: mockFn }]} />)
// const search = screen.getByPlaceholderText(/Search/)
// userEvent.type(search, '12345')
// // TODO templates dropdown interaction currently cannot be tested
// // https://github.com/supabase/ui/issues/299
// // userEvent.click(screen.getByText("Templates"))
// // screen.debug()
// // await waitFor(() => screen.getByText("Some option"))
// // userEvent.click(screen.getByText("Some option"))
// // expect(mockFn).toBeCalled()
// })
test('filter input change and submit', async () => {
const mockFn = jest.fn()
render(<PreviewFilterPanel onSearch={mockFn} />)
expect(mockFn).not.toBeCalled()
const search = screen.getByPlaceholderText(/Search/)
userEvent.type(search, '12345{enter}')
expect(mockFn).toBeCalled()
})
test('filter input value', async () => {
render(<PreviewFilterPanel defaultSearchValue={'1234'} />)
await screen.findByDisplayValue('1234')
})
test('Manual refresh', async () => {
const mockFn = jest.fn()
render(<PreviewFilterPanel onRefresh={mockFn} />)
const btn = await screen.findByText(/Refresh/)
userEvent.click(btn)
expect(mockFn).toBeCalled()
})
test('Datepicker dropdown', async () => {
render(<PreviewFilterPanel />)
clickDropdown(await screen.findByText(/Last hour/))
userEvent.click(await screen.findByText(/Last 3 hours/))
await screen.findByText(/Last 3 hours/)
await expect(screen.findByText(/Last hour/)).rejects.toThrow()
})
test.todo('timestamp to/from filter default value')
// , async () => {
// render(<PreviewFilterPanel defaultToValue="2022-01-18T10:43:39+0000" />)
// userEvent.click(await screen.findByText('Custom'))
// await screen.findByDisplayValue('2022-01-18T10:43:39+0000')
// // TODO: use screen.findByLabelText when https://github.com/supabase/ui/issues/310 is resolved
// await screen.findByText('To')
// await screen.findByText('From')
// })
test.todo('timestamp to/from filter value change')
// , async () => {
// const mockFn = jest.fn()
// render(<PreviewFilterPanel onSearch={mockFn} />)
// userEvent.click(await screen.findByText(/Now/))
// // display iso timestamp
// const year = new Date().getFullYear()
// const inputs = await screen.findAllByDisplayValue(RegExp(year))
// for (const input of inputs) {
// // replace the input's value
// userEvent.clear(input)
// // get time 20 mins before
// const newDate = new Date()
// newDate.setMinutes(new Date().getMinutes() - 20)
// userEvent.type(input, newDate.toISOString())
// }
// // input actions
// const set = await screen.findByRole('button', { name: 'Set' })
// userEvent.click(set)
// expect(mockFn).toBeCalled()
// await screen.findByText('Custom')
// await screen.findByTitle(/Clear timestamp filter/)
// })
@@ -2,11 +2,6 @@
jest.mock('lib/common/fetch')
import { get } from 'lib/common/fetch'
// mock the settings layout
jest.mock('components/layouts', () => ({
SettingsLayout: jest.fn().mockImplementation(({ children }) => <div>{children}</div>),
}))
// mock mobx
jest.mock('mobx-react-lite')
import { observer } from 'mobx-react-lite'
@@ -17,7 +12,7 @@ jest.mock('next/router')
import { useRouter } from 'next/router'
const defaultRouterMock = () => {
const router = jest.fn()
router.query = { ref: '123', type: 'auth' }
router.query = {}
router.push = jest.fn()
router.pathname = 'logs/path'
return router
@@ -44,10 +39,10 @@ import { useFlag } from 'hooks'
useFlag.mockReturnValue(true)
import { SWRConfig } from 'swr'
jest.mock('pages/project/[ref]/settings/logs/[type]')
import { LogPage } from 'pages/project/[ref]/settings/logs/[type]'
LogPage.mockImplementation((props) => {
const Page = jest.requireActual('pages/project/[ref]/settings/logs/[type]').LogPage
jest.mock('components/interfaces/Settings/Logs/LogsPreviewer')
import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer'
LogsPreviewer.mockImplementation((props) => {
const Comp = jest.requireActual('components/interfaces/Settings/Logs/LogsPreviewer').default
// wrap with SWR to reset the cache each time
return (
<SWRConfig
@@ -56,7 +51,7 @@ LogPage.mockImplementation((props) => {
shouldRetryOnError: false,
}}
>
<Page {...props} />
<Comp {...props} />
</SWRConfig>
)
})
@@ -66,6 +61,8 @@ import userEvent from '@testing-library/user-event'
import { getToggleByText } from '../../helpers'
import { wait } from '@testing-library/user-event/dist/utils'
import { logDataFixture } from '../../fixtures'
import { LogsTableName } from 'components/interfaces/Settings/Logs'
import { find } from 'lodash'
beforeEach(() => {
// reset mocks between tests
get.mockReset()
@@ -76,15 +73,15 @@ test('can display log data and metadata', async () => {
get.mockResolvedValue({
result: [
logDataFixture({
event_message: 'some event happened',
id: 'some-event-happened-id',
metadata: {
my_key: 'something_value',
},
}),
],
})
render(<LogPage />)
fireEvent.click(await screen.findByText(/happened/))
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
fireEvent.click(await screen.findByText(/some-event-happened-id/))
await screen.findByText(/my_key/)
await screen.findByText(/something_value/)
})
@@ -95,39 +92,31 @@ test('Refresh page', async () => {
return {
result: [
logDataFixture({
event_message: 'some event happened',
id: 'some-event-id',
metadata: { my_key: 'something_value' },
}),
],
}
})
render(<LogPage />)
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
const row = await screen.findByText(/happened/)
get.mockResolvedValueOnce({ result: [] })
const row = await screen.findByText(/some-event-id/)
fireEvent.click(row)
await waitFor(() => screen.getByText(/my_key/))
// simulate refresh
userEvent.click(screen.getByText(/Refresh/))
// when log line unmounts and it was focused, should close focus panel
await waitFor(() => screen.queryByText(/my_key/) === null, { timeout: 1000 })
await waitFor(() => screen.queryByText(/happened/) === null, { timeout: 1000 })
await screen.findByText(/my_key/)
})
test('Search will trigger a log refresh', async () => {
get.mockImplementation((url) => {
if (url.includes('something')) {
return {
result: [logDataFixture({ event_message: 'some event happened' })],
result: [logDataFixture({ id: 'some-event-id' })],
}
}
return { result: [] }
})
render(<LogPage />)
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
userEvent.type(screen.getByPlaceholderText(/Search/), 'something')
userEvent.click(screen.getByTitle('Go'))
userEvent.type(screen.getByPlaceholderText(/Search events/), 'something{enter}')
await waitFor(
() => {
@@ -146,7 +135,7 @@ test('Search will trigger a log refresh', async () => {
},
{ timeout: 1500 }
)
await screen.findByText(/happened/)
await screen.findByText(/some-event-id/)
})
test('poll count for new messages', async () => {
@@ -155,24 +144,24 @@ test('poll count for new messages', async () => {
return { result: [{ count: 125 }] }
}
return {
result: [logDataFixture({ event_message: 'something happened' })],
result: [logDataFixture({ id: 'some-uuid123' })],
}
})
render(<LogPage />)
await waitFor(() => screen.queryByText(/happened/) === null)
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
await waitFor(() => screen.queryByText(/some-uuid123/) === null)
// should display new logs count
await waitFor(() => screen.getByText(/125/))
userEvent.click(screen.getByText(/Refresh/))
await waitFor(() => screen.queryByText(/125/) === null)
await waitFor(() => screen.getByText(/happened/))
await screen.findByText(/some-uuid123/)
})
test('s= query param will populate the search bar', async () => {
const router = defaultRouterMock()
router.query = { ...router.query, type: 'api', s: 'someSearch' }
router.query = { ...router.query, s: 'someSearch' }
useRouter.mockReturnValue(router)
render(<LogPage />)
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
// should populate search input with the search param
await screen.findByDisplayValue('someSearch')
expect(get).toHaveBeenCalledWith(expect.stringContaining('someSearch'))
@@ -187,7 +176,7 @@ test('te= query param will populate the timestamp to input', async () => {
const router = defaultRouterMock()
router.query = { ...router.query, te: unixMicro }
useRouter.mockReturnValue(router)
render(<LogPage />)
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
await waitFor(() => {
expect(get).toHaveBeenCalledWith(
@@ -195,7 +184,7 @@ test('te= query param will populate the timestamp to input', async () => {
)
})
userEvent.click(await screen.findByText('Custom'))
await screen.findByDisplayValue(isoString)
expect(get).toHaveBeenCalledWith(expect.stringContaining('timestamp_end=' + unixMicro))
})
test('ts= query param will populate the timestamp from input', async () => {
// get time 20 mins before
@@ -206,7 +195,7 @@ test('ts= query param will populate the timestamp from input', async () => {
const router = defaultRouterMock()
router.query = { ...router.query, ts: unixMicro }
useRouter.mockReturnValue(router)
render(<LogPage />)
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
await waitFor(() => {
expect(get).toHaveBeenCalledWith(
@@ -214,26 +203,27 @@ test('ts= query param will populate the timestamp from input', async () => {
)
})
userEvent.click(await screen.findByText('Custom'))
await screen.findByDisplayValue(isoString)
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 () => {
get.mockImplementation((url) => {
if (url.includes('count')) {
return {}
}
return {
result: [logDataFixture({ event_message: 'first event' })],
result: [logDataFixture({ id: 'first event' })],
}
})
render(<LogPage />)
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
// should display first log but not second
await waitFor(() => screen.getByText('first event'))
await expect(screen.findByText('second event')).rejects.toThrow()
get.mockResolvedValueOnce({
result: [logDataFixture({ event_message: 'second event' })],
result: [logDataFixture({ id: 'second event' })],
})
// should display first and second log
userEvent.click(await screen.findByText('Load older'))
@@ -250,7 +240,7 @@ test('bug: load older btn does not error out when previous page is empty', async
}
return { result: [] }
})
render(<LogPage />)
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
userEvent.click(await screen.findByText('Load older'))
// NOTE: potential race condition, since we are asserting that something DOES NOT EXIST
@@ -267,9 +257,9 @@ test('bug: load older btn does not error out when previous page is empty', async
})
test('log event chart hide', async () => {
render(<LogPage />)
render(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
await screen.findByText('Events')
const toggle = getToggleByText(/Show event chart/)
const toggle = await screen.findByText(/Chart/)
userEvent.click(toggle)
await expect(screen.findByText('Events')).rejects.toThrow()
})
@@ -282,14 +272,16 @@ test('bug: nav backwards with params change results in ui changing', async () =>
}
return { data: [] }
})
const { container, rerender } = render(<LogPage />)
const { container, rerender } = render(
<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />
)
await expect(screen.findByDisplayValue('simple-query')).rejects.toThrow()
const router = defaultRouterMock()
router.query = { ...router.query, s: 'simple-query' }
useRouter.mockReturnValue(router)
rerender(<LogPage />)
rerender(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
await screen.findByDisplayValue('simple-query')
})
@@ -4,7 +4,7 @@ import { get } from 'lib/common/fetch'
// mock the settings layout
jest.mock('components/layouts', () => ({
SettingsLayout: jest.fn().mockImplementation(({ children }) => <div>{children}</div>),
LogsExplorerLayout: jest.fn().mockImplementation(({ children }) => <div>{children}</div>),
}))
// mock mobx
@@ -17,7 +17,7 @@ jest.mock('next/router')
import { useRouter } from 'next/router'
const defaultRouterMock = () => {
const router = jest.fn()
router.query = { ref: '123', type: 'auth' }
router.query = { ref: '123' }
router.push = jest.fn()
router.pathname = 'logs/path'
return router
@@ -40,67 +40,71 @@ jest.mock('components/ui/Flag/Flag')
import Flag from 'components/ui/Flag/Flag'
Flag.mockImplementation(({ children }) => <>{children}</>)
jest.mock('hooks')
import { useFlag } from 'hooks'
import { useStore, useFlag } from 'hooks'
useFlag.mockReturnValue(true)
useStore.mockImplementation(() => ({
content: jest.fn(),
}))
import { SWRConfig } from 'swr'
jest.mock('pages/project/[ref]/settings/logs/explorer')
import { LogsExplorerPage } from 'pages/project/[ref]/settings/logs/explorer'
LogsExplorerPage.mockImplementation((props) => {
const Page = jest.requireActual('pages/project/[ref]/settings/logs/explorer').LogsExplorerPage
// wrap with SWR to reset the cache each time
return (
<SWRConfig
value={{
provider: () => new Map(),
shouldRetryOnError: false,
}}
>
<Page {...props} />
</SWRConfig>
)
})
import { LogsExplorerPage as Page } from 'pages/project/[ref]/logs-explorer/index'
const LogsExplorerPage = (props) => (
<SWRConfig
value={{
provider: () => new Map(),
shouldRetryOnError: false,
}}
>
<Page {...props} />
</SWRConfig>
)
import { render, fireEvent, waitFor, screen, act } from '@testing-library/react'
import { render, fireEvent, waitFor, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { getToggleByText } from '../../helpers'
import { wait } from '@testing-library/user-event/dist/utils'
import { logDataFixture } from '../../fixtures'
beforeEach(() => {
// reset mocks between tests
get.mockReset()
useRouter.mockReset()
useRouter.mockReturnValue(defaultRouterMock())
})
test('can display log data and metadata', async () => {
test('can display log data', async () => {
get.mockResolvedValue({
result: [
logDataFixture({
event_message: 'some event happened',
id: 'some-event-happened',
metadata: {
my_key: 'something_value',
},
}),
],
})
render(<LogsExplorerPage />)
fireEvent.click(await screen.findByText(/happened/))
await screen.findByText(/my_key/)
const {container} = render(<LogsExplorerPage />)
let editor = container.querySelector('.monaco-editor')
await waitFor(() => {
editor = container.querySelector('.monaco-editor')
expect(editor).toBeTruthy()
})
// type new query
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")
screen.debug()
userEvent.click(row)
await screen.findByText(/something_value/)
})
test('q= query param will populate the query input', async () => {
const router = defaultRouterMock()
router.query = { ...router.query, type: 'api', q: 'some_query', s: 'someSearch' }
router.query = { ...router.query, type: 'api', q: 'some_query' }
useRouter.mockReturnValue(router)
render(<LogsExplorerPage />)
// should populate editor with the query param
await waitFor(() => {
expect(get).toHaveBeenCalledWith(expect.stringContaining('sql=some_query'))
})
// query takes precedence of search queryparam
expect(() => !screen.queryByDisplayValue(/someSearch/))
})
test('custom sql querying', async () => {
+5 -16
View File
@@ -1,11 +1,7 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -17,16 +13,9 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"baseUrl": "."
"baseUrl": ".",
"downlevelIteration": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.js",
"**/*.jsx"
],
"exclude": [
"node_modules"
]
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
"exclude": ["node_modules"]
}
+24 -2
View File
@@ -2,9 +2,9 @@ export interface UserContent {
id?: string
name: string
description?: string
type: 'sql' | 'report'
type: 'sql' | 'report' | 'log_sql'
visibility: 'user' | 'project' | 'org' | 'public'
content: Dashboards.Content | SqlSnippets.Content
content: Dashboards.Content | SqlSnippets.Content | LogSqlSnippets.Content
owner_id?: number // user id
last_updated_by?: number // user id
inserted_at?: string // '2021-08-26T08:24:52.040695+00:00'
@@ -120,3 +120,25 @@ export namespace SqlSnippets {
favorite: boolean
}
}
export namespace LogSqlSnippets {
/**
* To be stored in the database: public.user_content.content
* In this case there is only one thing to store, but it's good to
* nest it in an object for future expansion.
*/
export interface Content {
// unique id of the sql snippet, possibly to used so snippets can support versioning
content_id: string
// A full SQL query - this will be hashed on the /content endpoint
sql: string
// we can add some versioning to this schema in case we need to change the format.
schema_version: string
// show sql snippet as a favorite.
// this could be problematic if sql snippets have visibility that is != 'user'
favorite: boolean
}
}
+193 -63
View File
@@ -13,18 +13,18 @@ Supabase generates three types of API directly from your database schema.
- REST - interact with your database through a restful interface.
- Realtime - listen to database changes.
- GraphQL - [coming soon](https://supabase.com/blog/2021/12/03/pg-graphql).
- GraphQL - [in beta](/blog/2021/12/03/pg-graphql).
The APIs are:
- Instant and auto-generated: as you update your database the changes are immediately accessible through your API.
- Self documenting: Supabase generates documentation in the Dashboard which updates as you make database changes.
- Secure: the API is configured to work with PostgreSQL's Row Level Security, provisioned behind an API gateway with key-auth enabled.
- Fast: our benchmarks for basic reads are more than 300% faster than Firebase. The API is a very thin layer on top of Postgres, which does most of the heavy lifting.
- Scalable: the API can serve thousands of simultaneous requests, and works well for Serverless workloads.
- **Instant and auto-generated.** <br />As you update your database the changes are immediately accessible through your API.
- **Self documenting.** <br />Supabase generates documentation in the Dashboard which updates as you make database changes.
- **Secure.** <br />The API is configured to work with PostgreSQL's Row Level Security, provisioned behind an API gateway with key-auth enabled.
- **Fast.** <br />Our benchmarks for basic reads are more than 300% faster than Firebase. The API is a very thin layer on top of Postgres, which does most of the heavy lifting.
- **Scalable.** <br />The API can serve thousands of simultaneous requests, and works well for Serverless workloads.
### RESTful API
### REST API
Supabase provides a RESTful API using [PostgREST](https://postgrest.org/). This is a very thin API layer on top of Postgres.
It provides everything you need from a CRUD API:
@@ -45,20 +45,29 @@ It provides everything you need from a CRUD API:
allowFullScreen
></iframe>
### GraphQL API
:::note
GraphQL is in Beta, and may have breaking changes. It is only available on self-hosted setups and Supabase projects created after 28th March 2022.
:::
GraphQL in Supabase works through [pg_graphql](/blog/2021/12/03/pg-graphql), an open source PostgreSQL extension for GraphQL.
To get started with GraphQL you must enable the `pg_graphql` extension in your project.
<video width="99%" muted playsInline controls="true">
<source src="/docs/videos/api/api-enable-graphql.mp4" type="video/mp4" muted playsInline />
</video>
### Realtime API
Supabase provides a Realtime API using [Realtime](https://github.com/supabase/realtime). You can use this to listen to database changes over websockets.
Realtime leverages PostgreSQL's built-in logical replication. You can manage your Realtime API simply by managing Postgres publications.
### GraphQL API
[Coming soon](https://supabase.com/blog/2021/12/03/pg-graphql). We are developing `pg_graphql`, an open source PostgreSQL extension for GraphQL.
`pg_graphql` inspects an existing PostgreSQL schema and reflects a GraphQL schema with resolvers. We will expose `pg_graphql` via the existing REST interface.
## Getting started
After you have added tables or functions to your database, you can use the API.
All APIs are auto-created from Database tables. After you have added tables or functions to your database, you can use the APIs provided.
### Creating API Routes
@@ -105,19 +114,10 @@ create table todos (
</TabItem>
</Tabs>
### API URL and Keys
Every Supabase project has a unique API URL. Your API is secured behind an API gateway which requires an API Key for every request.
You can find the Keys inside the Dashboard.
<Tabs
defaultValue="UI"
values={[
{label: 'UI', value: 'UI'}
]}>
<TabItem value="UI">
```sh
1. Go to the "Settings" section.
@@ -130,23 +130,30 @@ values={[
<source src="/docs/videos/api/api-url-and-key.mp4" type="video/mp4" muted playsInline />
</video>
</TabItem>
</Tabs>
The REST API and the GraphQL API are both accessible through this URL:
- REST: `https://<project_ref>.supabase.com/rest/v1`
- GraphQL: `https://<project_ref>.supabase.com/rest/v1`
Both of these routes require the `anon` key to be passed through an `apikey` header.
#### API Keys
#### Details and links:
You can find the Keys inside the Dashboard in the same location as the URL above.
You are provided with two keys initially:
You are provided with two keys:
- an `anon` key, which is safe to be used in a browser context.
- a `service_role` key, which should only be used on a server. This key can bypass Row Level Security.
- a `service_role` key, which should only be used on a server. This key can bypass Row Level Security. NEVER use this key in a browser.
### Accessing the Docs
Supabase generates documentation in the Dashboard which updates as you make database changes.
#### REST API
Let's view the documentation for the `todos` table which we created in the first step.
Supabase generates documentation in the Dashboard which updates as you make database changes.
Let's view the documentation for a `countries` table which we created in our database.
<Tabs
defaultValue="UI"
@@ -161,16 +168,32 @@ values={[
3. Switch between the Javascript and the cURL docs using the tabs.
```
<video width="99%" muted playsInline controls="true">
<source src="/docs/videos/api/api-docs.mp4" type="video/mp4" muted playsInline />
</video>
</TabItem>
</Tabs>
#### GraphQL
### Using the API
The GraphQL Endpoint that we provide (`https://<project_ref>.supabase.com/rest/v1`) is compatible with any GraphiQL implementation that can pass an `apikey` header.
Some suggested applications:
- [paw.cloud](https://paw.cloud)
- [insomnia.rest](https://insomnia.rest)
- [postman.com/graphql](https://www.postman.com/graphql/)
- Self-hosted GraphiQL: GraphiQL can be served through a simple HTML file. See [this discussion](https://github.com/supabase/supabase/discussions/6144) for more details.
## Using the API
### REST API
You can interact with your API directly via HTTP requests, or you can use the client libraries which we provide.
Let's see how to make a request to the `todos` table which we created in the first step,
using the API URL (`[SUPABASE_URL]`) and Key (`[SUPABASE_ANON_KEY]`) we provided:
using the API URL (`SUPABASE_URL`) and Key (`SUPABASE_ANON_KEY`) we provided:
<Tabs
@@ -184,10 +207,10 @@ values={[
```javascript
// Initialize the JS client
import { createClient } from '@supabase/supabase-js'
const supabase = createClient([SUPABASE_URL], [SUPABASE_ANON_KEY])
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
// Make a request
let { data: todos, error } = await supabase
const { data: todos, error } = await supabase
.from('todos')
.select('*')
```
@@ -197,51 +220,102 @@ let { data: todos, error } = await supabase
```bash
# Append /rest/v1/ to your URL, and then use the table name as the route
curl '[SUPABASE_URL]/rest/v1/todos' \
-H "apikey: [SUPABASE_ANON_KEY]" \
-H "Authorization: Bearer SUPABASE_ANON_KEY"
curl '<SUPABASE_URL>/rest/v1/todos' \
-H "apikey: <SUPABASE_ANON_KEY>" \
-H "Authorization: Bearer <SUPABASE_ANON_KEY>"
```
</TabItem>
</Tabs>
#### Details and links:
- When you create a table in Postgres, Row Level Security is disabled by default. Make sure you secure it by [enabling RLS](/docs/guides/api#securing-your-routes).
- Never expose the `service_role` key in a browser or anywhere where a user can see it.
- JS Reference: [select()](/docs/reference/javascript/select),
JS Reference: [select()](/docs/reference/javascript/select),
[insert()](/docs/reference/javascript/insert),
[update()](/docs/reference/javascript/update),
[upsert()](/docs/reference/javascript/upsert),
[delete()](/docs/reference/javascript/delete),
[rpc()](/docs/reference/javascript/rpc) (call Postgres functions).
### Safeguards towards accidental deletes and updates
For new projects, by default, the Postgres extension [safeupdate](https://github.com/eradman/pg-safeupdate) is enabled for all queries coming from the API.
This ensures that any `delete()` or `update()` would fail if there are no accompanying filters provided.
This can be disabled by running the following query on the SQL editor on the dashboard:
### GraphQL API
```sql
alter role "authenticator"
set "session_preload_libraries" to default;
:::note
While pg_graphql is in beta, we have disabled GraphQL auto-refreshing. If you want to refresh your GraphQL introspection, call `select graphql.rebuild_schema();` to rebuild your GraphQL schema.
:::
You can use any GraphQL client with the Supabase GraphQL API. For our GraphQL example we will use [urlql](https://formidable.com/open-source/urql/docs/).
<Tabs
defaultValue="Javascript"
values={[
{label: 'Javascript', value: 'Javascript'},
{label: 'cURL', value: 'cURL'},
]}>
<TabItem value="Javascript">
```javascript
import { createClient, useQuery } from 'urql'
// Prepare API key and Authorization header
const headers = {
apikey: <SUPABASE_ANON_KEY>,
authorization: `Bearer: ${<SUPABASE_ANON_KEY}`>
}
// Create GraphQL client
// See: https://formidable.com/open-source/urql/docs/basics/react-preact/#setting-up-the-client
const client = createClient({
url: '<SUPABASE_URL>/grapqhl/v1',
fetchOptions: function createFetchOptions() {
return { headers }
},
})
// Prepare our GraphQL query
const TodosQuery = `
query {
todos {
id
title
}
}
`
// Query for the data (React)
const [result, reexecuteQuery] = useQuery({
query: TodosQuery,
})
// Read the result
const { data, fetching, error } = result
```
To reenable it :
```sql
alter role "authenticator"
set "session_preload_libraries" = 'safeupdate';
</TabItem>
<TabItem value="cURL">
```bash
# Append /grapqhl/v1/ to your URL, and then use the table name as the route
curl --request POST '<SUPABASE_URL>/grapqhl/v1' \
-H 'apikey: <SUPABASE_ANON_KEY>' \
-H 'Authorization: Bearer <SUPABASE_ANON_KEY>' \
-d '{ "query":"{ todos(first: 3) { edges { node { id } } } }" }'
```
The above can also be run to enable it for existing projects. Settings are applied immediately and as such no restarts are needed after running any of the two.
### Managing Realtime
</TabItem>
</Tabs>
The Realtime API works through PostgreSQL's replication functionality. Postgres sends database changes to a "publication"
called `supabase_realtime`, and by managing this publication you can control which data is broadcast.
### Realtime API
By default Realtime is disabled on your database. Let's turn on Realtime for the `todos` table.
:::note
Realtime only works for tables in the public schema.
:::
<Tabs
defaultValue="UI"
values={[
@@ -271,14 +345,49 @@ alter publication supabase_realtime add table products;
</TabItem>
</Tabs>
#### Details and links:
Now we can listen to any new data that is inserted into the `todo` table:
- Realtime only works for tables in the public schema.
- [JS Reference](/docs/reference/javascript/subscribe): Subscribe to database changes using the realtime client
<Tabs
defaultValue="Javascript"
values={[
{label: 'Javascript', value: 'Javascript'}
]}>
<TabItem value="Javascript">
```javascript
// Initialize the JS client
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
// Create a function to handle inserts
const handleInserts = (payload) => {
console.log('Change received!', payload)
}
// Listen to unserts
const { data: todos, error } = await supabase
.from('todos')
.in('INSERT', handleUpdates)
.subscribe()
```
</TabItem>
</Tabs>
Use [subscribe()](/docs/reference/javascript/subscribe) to listen to database changes.
The Realtime API works through PostgreSQL's replication functionality. Postgres sends database changes to a [publication](/docs/guides/database/replication#publications)
called `supabase_realtime`, and by managing this publication you can control which data is broadcast.
## API Security
### Securing your Routes
Your API is designed to work with Postgres Row Level Security. If you use Supabase [Auth](/docs/guides/auth), you can restrict data based on the logged-in user.
To control access to your data, you can use [Policies](/docs/guides/auth#policies).
When you create a table in Postgres, Row Level Security is disabled by default. Make sure you secure it by [enabling RLS](/docs/guides/api#securing-your-routes).
<Tabs
defaultValue="UI"
values={[
@@ -303,8 +412,29 @@ alter table todos enable row level security;
</TabItem>
</Tabs>
### The `service_role` key
#### Details and links:
- Your API is designed to work with Postgres Row Level Security. If you use Supabase [Auth](/docs/guides/auth), you can restrict data based on the logged-in user.
- To control access to your data, you can use [Policies](/docs/guides/auth#policies).
Never expose the `service_role` key in a browser or anywhere where a user can see it. This Key can is designed to bypass Row Level Security - so it should only be used on a private server.
We have [partnered with GitHub](/blog/2022/03/28/community-day#supabase-is-now-a-github-secret-scanning-partner) to scan for Supabase `service_role` keys pushed to public repositories.
If they detect any keys with service_role privileges being pushed to GitHub, they will forward the API key to us, so that we can automatically revoke the detected secrets and notify you, protecting your data against malicious actors.
### Safeguards towards accidental deletes and updates
For new projects, by default, the Postgres extension [safeupdate](https://github.com/eradman/pg-safeupdate) is enabled for all queries coming from the API.
This ensures that any `delete()` or `update()` would fail if there are no accompanying filters provided.
This can be disabled by running the following query on the SQL editor on the dashboard:
```sql
alter role "authenticator"
set "session_preload_libraries" to default;
```
To reenable it :
```sql
alter role "authenticator"
set "session_preload_libraries" = 'safeupdate';
```
The above can also be run to enable it for existing projects. Settings are applied immediately and as such no restarts are needed after running any of the two.
+2 -2
View File
@@ -40,8 +40,8 @@ module.exports = {
},
},
algolia: {
apiKey: '766d56f13dd1e82f43253559b7c86636',
appId: 'supabase',
appId: 'W4BIZ4FKU9',
apiKey: 'ac408c5c0a323ee4d7952cee149d1474',
indexName: 'supabase',
},
image: '/img/supabase-og-image.png', // used for meta tag, in particular og:image and twitter:image
+298 -1
View File
@@ -9,7 +9,9 @@ Supabase™ is a trademark of Supabase Inc.
If you're a creator/contributor to one of these packages, thanks!
Last updated: 2022-03-24
Last updated: 2022-03-29
JavaScript/TypeScript
-- ------------------------------------ ---------------------- -------------------------------------------------------------------- --------------------------------------------------------------------------------------- -------------------------
0 lodash@4.17.21 MIT https://github.com/lodash/lodash https://github.com/lodash/lodash/raw/master/LICENSE @supabase/ui
@@ -110,3 +112,298 @@ Last updated: 2022-03-24
95 stripe@8.145.0 MIT https://github.com/stripe/stripe-node https://github.com/stripe/stripe-node/raw/master/LICENSE stripe-sync-engine
96 yesql@5.0.0 ISC https://github.com/pihvi/yesql https://github.com/pihvi/yesql stripe-sync-engine
-- ------------------------------------ ---------------------- -------------------------------------------------------------------- --------------------------------------------------------------------------------------- -------------------------
Elixir
Id Name Version License
-- --------------------------------- ------- ----------
0 castore 0.1.11 Apache 2.0
1 certifi 2.5.3 BSD
2 combine 0.10.0 MIT
3 connection 1.1.0 Apache 2.0
4 cowboy 2.8.0 ISC
5 cowboy_telemetry 0.3.1 Apache 2.0
6 cowlib 2.9.1 ISC
7 db_connection 2.4.1 Apache 2.0
8 decimal 2.0.0 Apache 2.0
9 ecto 3.7.1 Apache 2.0
10 ecto_sql 3.7.1 Apache 2.0
11 epgsql 4.6.0 BSD
12 file_system 0.2.10 WTFPL
13 finch 0.7.0 MIT
14 gettext 0.18.2 Apache 2.0
15 hackney 1.17.0 Apache 2.0
16 httpoison 1.8.0 MIT
17 idna 6.1.1 MIT
18 jason 1.2.2 Apache 2.0
19 joken 2.3.0 Apache 2.0
20 jose 1.11.1 MIT
21 metrics 1.0.1 BSD
22 mime 1.5.0 Apache 2.0
23 mimerl 1.2.0 MIT
24 mint 1.3.0 Apache 2.0
25 nimble_options 0.3.5 Apache 2.0
26 nimble_pool 0.2.4 Apache 2.0
27 parse_trans 3.3.1 Apache 2.0
28 phoenix 1.5.8 MIT
29 phoenix_html 2.14.3 MIT
30 phoenix_live_reload 1.3.0 MIT
31 phoenix_pubsub 2.0.0 MIT
32 plug 1.11.0 Apache 2.0
33 plug_cowboy 2.4.1 Apache 2.0
34 plug_crypto 1.2.1 Apache 2.0
35 postgrex 0.15.13 Apache 2.0
36 prom_ex 1.3.0 MIT
37 ranch 1.7.1 ISC
38 retry 0.14.1 Apache 2.0
39 ssl_verify_fun 1.1.6 MIT
40 telemetry 0.4.2 Apache 2.0
41 telemetry_metrics 0.6.1 Apache 2.0
42 telemetry_metrics_prometheus_core 1.0.1 Apache 2.0
43 telemetry_poller 0.5.1 Apache 2.0
44 timex 3.6.3 MIT
45 tzdata 1.0.5 MIT
46 unicode_util_compat 0.7.0 Apache 2.0
47 bunt 0.2.0 MIT
48 credo 1.5.5 MIT
49 dialyxir 1.1.0 Apache 2.0
50 earmark_parser 1.4.12 Apache 2.0
51 erlex 0.2.6 Apache 2.0
52 ex_doc 0.24.1 Apache 2.0
53 file_system 0.2.10 WTFPL
54 jason 1.2.2 Apache 2.0
55 makeup 1.0.5 BSD
56 makeup_elixir 0.15.1 BSD
57 makeup_erlang 0.1.1 BSD
58 nimble_parsec 1.1.0 Apache 2.0
59 warpath 0.6.0 MIT
-- --------------------------------- ------- ----------
Go
ID Package Link Version
--- ----------------------------------------------------------- -------------------------------------
License Type
------------------------------------------------------
0 github.com/BurntSushi/toml v1.0.0 MIT
1 github.com/adrg/xdg v0.4.0 MIT
2 github.com/alecthomas/chroma v0.10.0 MIT
3 github.com/aymerick/douceur v0.2.0 MIT
4 github.com/charmbracelet/bubbles v0.10.3 MIT
5 github.com/charmbracelet/bubbletea v0.20.0 MIT
6 github.com/charmbracelet/glamour v0.5.0 MIT
7 github.com/charmbracelet/harmonica v0.1.0 MIT
8 github.com/charmbracelet/lipgloss v0.5.0 MIT
9 github.com/containerd/console v1.0.3 Apache 2.0
10 github.com/containerd/containerd v1.6.2 Apache 2.0
11 github.com/dlclark/regexp2 v1.4.0 MIT
12 github.com/docker/distribution v2.8.1 Apache 2.0
13 github.com/docker/docker v20.10.14 Apache 2.0,MIT
14 github.com/docker/go-connections v0.4.0 Apache 2.0
15 github.com/docker/go-units v0.4.0 Apache 2.0
16 github.com/fsnotify/fsnotify v1.5.1 New BSD
17 github.com/gogo/protobuf v1.3.2 New BSD
18 github.com/golang/protobuf v1.5.2 New BSD
19 github.com/gorilla/css v1.0.0 New BSD
20 github.com/hashicorp/hcl v1.0.0 Mozilla Public License 2.0
21 github.com/jackc/chunkreader/v2 v2.0.1 MIT
22 github.com/jackc/pgconn v1.11.0 MIT
23 github.com/jackc/pgio v1.0.0 MIT
24 github.com/jackc/pgpassfile v1.0.0 MIT
25 github.com/jackc/pgproto3/v2 v2.2.0 MIT
26 github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b MIT
27 github.com/jackc/pgtype v1.10.0 MIT
28 github.com/jackc/pgx/v4 v4.15.0 MIT
29 github.com/joho/godotenv v1.4.0 MIT
30 github.com/lucasb-eyer/go-colorful v1.2.0 MIT
31 github.com/magiconair/properties v1.8.6 Simplified BSD
32 github.com/mattn/go-isatty v0.0.14 MIT
33 github.com/mattn/go-runewidth v0.0.13 MIT
34 github.com/microcosm-cc/bluemonday v1.0.18 New BSD
35 github.com/mitchellh/mapstructure v1.4.3 MIT
36 github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 Apache 2.0
37 github.com/morikuni/aec v1.0.0 MIT
38 github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 MIT
39 github.com/muesli/reflow v0.3.0 MIT
40 github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 MIT
41 github.com/olekukonko/tablewriter v0.0.5 MIT
42 github.com/opencontainers/go-digest v1.0.0 Apache 2.0
43 github.com/opencontainers/image-spec v1.0.2 Apache 2.0
44 github.com/pelletier/go-toml v1.9.4 Apache 2.0
45 github.com/pkg/errors v0.9.1 Simplified BSD
46 github.com/rivo/uniseg v0.2.0 MIT
47 github.com/sirupsen/logrus v1.8.1 MIT
48 github.com/spf13/afero v1.8.2 Apache 2.0
49 github.com/spf13/cast v1.4.1 MIT
50 github.com/spf13/cobra v1.4.0 Apache 2.0
51 github.com/spf13/jwalterweatherman v1.1.0 MIT
52 github.com/spf13/pflag v1.0.5 New BSD
53 github.com/spf13/viper v1.10.1 MIT
54 github.com/subosito/gotenv v1.2.0 MIT
55 github.com/withfig/autocomplete-tools/packages/cobra v1.1.3 MIT
56 github.com/yuin/goldmark v1.4.11 MIT
57 github.com/yuin/goldmark-emoji v1.0.1 MIT
58 golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064 New BSD
59 golang.org/x/net v0.0.0-20220225172249-27dd8689420f New BSD
60 golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 New BSD
61 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 New BSD
62 golang.org/x/text v0.3.7 New BSD
63 google.golang.org/genproto v0.0.0-20220323144105-ec3c684e5b14 Apache 2.0
64 google.golang.org/grpc v1.45.0 Apache 2.0
65 google.golang.org/protobuf v1.28.0 New BSD
66 gopkg.in/ini.v1 v1.66.4 Apache 2.0
67 gopkg.in/yaml.v2 v2.4.0 Apache 2.0,MIT
68 github.com/aws/aws-sdk-go-v2 v1.8.0 Apache 2.0,New BSD
69 github.com/aws/aws-sdk-go-v2/config v1.6.0 Apache 2.0
70 github.com/aws/aws-sdk-go-v2/credentials v1.3.2 Apache 2.0
71 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.4.0 Apache 2.0
72 github.com/aws/aws-sdk-go-v2/internal/ini v1.2.0 Apache 2.0
73 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.2.2 Apache 2.0
74 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.5.0 Apache 2.0
75 github.com/aws/aws-sdk-go-v2/service/sso v1.3.2 Apache 2.0
76 github.com/aws/aws-sdk-go-v2/service/sts v1.6.1 Apache 2.0
77 github.com/aws/smithy-go v1.7.0 Apache 2.0
78 github.com/coreos/go-semver v0.3.0 Apache 2.0
79 github.com/coreos/go-systemd/v22 v22.3.2 Apache 2.0
80 github.com/fsnotify/fsnotify v1.4.9 New BSD
81 github.com/gogo/protobuf v1.3.2 New BSD
82 github.com/golang/protobuf v1.5.2 New BSD
83 github.com/hashicorp/hcl v1.0.0 Mozilla Public License 2.0
84 github.com/magiconair/properties v1.8.5 Simplified BSD
85 github.com/mitchellh/mapstructure v1.4.1 MIT
86 github.com/pelletier/go-toml v1.9.3 Apache 2.0
87 github.com/pkg/errors v0.9.1 Simplified BSD
88 github.com/spf13/afero v1.6.0 Apache 2.0
89 github.com/spf13/cast v1.3.1 MIT
90 github.com/spf13/cobra v1.2.1 Apache 2.0,GPLv2,GPLv3,LGPL,MIT,New BSD,Simplified BSD
91 github.com/spf13/jwalterweatherman v1.1.0 MIT
92 github.com/spf13/pflag v1.0.5 New BSD
93 github.com/spf13/viper v1.8.1 MIT
94 github.com/subosito/gotenv v1.2.0 MIT
95 go.etcd.io/etcd/api/v3 v3.5.0 Apache 2.0
96 go.etcd.io/etcd/client/pkg/v3 v3.5.0 Apache 2.0
97 go.etcd.io/etcd/client/v3 v3.5.0 Apache 2.0
98 go.uber.org/atomic v1.9.0 MIT
99 go.uber.org/multierr v1.7.0 MIT
100 go.uber.org/zap v1.19.0 MIT
101 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 New BSD
102 golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40 New BSD
103 golang.org/x/text v0.3.5 New BSD
104 google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c Apache 2.0
105 google.golang.org/grpc v1.38.0 Apache 2.0
106 google.golang.org/protobuf v1.26.0 New BSD
107 gopkg.in/ini.v1 v1.62.0 Apache 2.0
108 gopkg.in/yaml.v2 v2.4.0 Apache 2.0,MIT
109 github.com/coreos/go-oidc v2.2.1 Apache 2.0
110 github.com/kelseyhightower/envconfig v1.4.0 MIT
111 github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 Apache 2.0
112 golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 New BSD
113 golang.org/x/net v0.0.0-20200707034311-ab3426394381 New BSD
114 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d New BSD
115 gopkg.in/square/go-jose.v2 v2.5.1 Apache 2.0,New BSD
116 cloud.google.com/go v0.67.0 Apache 2.0,New BSD
117 github.com/GoogleCloudPlatform/cloudsql-proxy v0.0.0-20170623214735-571947b0f240 Apache 2.0
118 github.com/Masterminds/semver/v3 v3.1.1 MIT
119 github.com/aymerick/douceur v0.2.0 MIT
120 github.com/badoux/checkmail v0.0.0-20170203135005-d0a759655d62 MIT
121 github.com/beevik/etree v1.1.0 Simplified BSD
122 github.com/coreos/go-oidc/v3 v3.0.0 Apache 2.0
123 github.com/didip/tollbooth/v5 v5.1.1 MIT
124 github.com/fatih/color v1.10.0 MIT
125 github.com/fatih/structs v1.1.0 MIT
126 github.com/go-chi/chi v4.0.2 MIT
127 github.com/go-sql-driver/mysql v1.5.0 Mozilla Public License 2.0
128 github.com/gobuffalo/envy v1.9.0 MIT
129 github.com/gobuffalo/fizz v1.13.0 MIT
130 github.com/gobuffalo/flect v0.2.2 MIT
131 github.com/gobuffalo/github_flavored_markdown v1.1.0 MIT,Simplified BSD
132 github.com/gobuffalo/helpers v0.6.1 MIT
133 github.com/gobuffalo/nulls v0.4.0 MIT
134 github.com/gobuffalo/packd v1.0.0 MIT
135 github.com/gobuffalo/plush/v4 v4.1.0 MIT
136 github.com/gobuffalo/pop/v5 v5.3.3 MIT
137 github.com/gobuffalo/tags/v3 v3.1.0 MIT
138 github.com/gobuffalo/validate/v3 v3.3.0 MIT
139 github.com/gofrs/uuid v4.0.0 MIT
140 github.com/golang-jwt/jwt v3.2.1 MIT
141 github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e Apache 2.0
142 github.com/golang/protobuf v1.4.2 New BSD
143 github.com/googleapis/gax-go/v2 v2.0.5 New BSD
144 github.com/gorilla/context v1.1.1 New BSD
145 github.com/gorilla/css v1.0.0 New BSD
146 github.com/gorilla/securecookie v1.1.1 New BSD
147 github.com/gorilla/sessions v1.1.1 New BSD
148 github.com/imdario/mergo v0.0.0-20160216103600-3e95a51e0639 New BSD
149 github.com/jackc/chunkreader/v2 v2.0.1 MIT
150 github.com/jackc/pgconn v1.8.0 MIT
151 github.com/jackc/pgio v1.0.0 MIT
152 github.com/jackc/pgpassfile v1.0.0 MIT
153 github.com/jackc/pgproto3/v2 v2.0.7 MIT
154 github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b MIT
155 github.com/jackc/pgtype v1.6.2 MIT
156 github.com/jackc/pgx/v4 v4.10.1 MIT
157 github.com/jmoiron/sqlx v1.3.1 MIT
158 github.com/joho/godotenv v1.3.0 MIT
159 github.com/jonboulle/clockwork v0.2.2 Apache 2.0
160 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 MIT
161 github.com/kelseyhightower/envconfig v1.4.0 MIT
162 github.com/lestrrat-go/jwx v0.9.0 MIT
163 github.com/luna-duclos/instrumentedsql v1.1.3 MIT
164 github.com/mattermost/xml-roundtrip-validator v0.1.0 Apache 2.0
165 github.com/mattn/go-colorable v0.1.8 MIT
166 github.com/mattn/go-isatty v0.0.12 MIT
167 github.com/microcosm-cc/bluemonday v1.0.16 New BSD
168 github.com/mitchellh/mapstructure v1.1.2 MIT
169 github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 MIT
170 github.com/netlify/mailme v1.1.1 MIT
171 github.com/netlify/netlify-commons v0.32.0 MIT
172 github.com/opentracing/opentracing-go v1.1.0 Apache 2.0
173 github.com/patrickmn/go-cache v2.1.0 MIT
174 github.com/philhofer/fwd v1.0.0 MIT
175 github.com/pkg/errors v0.9.1 Simplified BSD
176 github.com/rogpeppe/go-internal v1.8.0 New BSD
177 github.com/rs/cors v1.6.0 MIT
178 github.com/russellhaering/gosaml2 v0.6.1-0.20210916051624-757d23f1bc28 Apache 2.0
179 github.com/russellhaering/goxmldsig v1.1.1 Apache 2.0
180 github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35 MIT
181 github.com/sergi/go-diff v1.1.0 Apache 2.0,MIT
182 github.com/sethvargo/go-password v0.2.0 MIT
183 github.com/sirupsen/logrus v1.7.0 MIT
184 github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d New BSD
185 github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e New BSD
186 github.com/spf13/cobra v1.1.3 Apache 2.0,GPLv2,GPLv3,LGPL,MIT,New BSD,Simplified BSD
187 github.com/spf13/pflag v1.0.5 New BSD
188 github.com/tinylib/msgp v1.1.0 MIT
189 go.opencensus.io v0.22.4 Apache 2.0
190 golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad New BSD
191 golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba New BSD
192 golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 New BSD
193 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a New BSD
194 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e New BSD
195 golang.org/x/text v0.3.7 New BSD
196 golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 New BSD
197 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 New BSD
198 google.golang.org/api v0.32.0 New BSD
199 google.golang.org/genproto v0.0.0-20200929141702-51c3e5b607fe Apache 2.0
200 google.golang.org/grpc v1.32.0 Apache 2.0
201 google.golang.org/protobuf v1.25.0 New BSD
202 gopkg.in/DataDog/dd-trace-go.v1 v1.12.1 New BSD
203 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df MIT
204 gopkg.in/square/go-jose.v2 v2.5.1 Apache 2.0,New BSD
205 gopkg.in/yaml.v2 v2.4.0 Apache 2.0,MIT
206 github.com/aws/aws-sdk-go v1.36.20 Apache 2.0,New BSD
207 github.com/beorn7/perks v1.0.1 MIT
208 github.com/cespare/xxhash/v2 v2.1.1 MIT
209 github.com/golang/protobuf v1.4.3 New BSD
210 github.com/jmespath/go-jmespath v0.4.0 Apache 2.0
211 github.com/matttproud/golang_protobuf_extensions v1.0.1 Apache 2.0
212 github.com/prometheus/client_golang v1.9.0 Apache 2.0
213 github.com/prometheus/client_model v0.2.0 Apache 2.0
214 github.com/prometheus/common v0.15.0 Apache 2.0,New BSD
215 github.com/prometheus/procfs v0.2.0 Apache 2.0
216 github.com/sirupsen/logrus v1.6.0 MIT
217 golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e New BSD
218 google.golang.org/protobuf v1.23.0 New BSD
219 gopkg.in/yaml.v2 v2.3.0 Apache 2.0,MIT
--- ----------------------------------------------------------- ------------------------------------- ------------------------------------------------------
Binary file not shown.
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more