mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 10:25:06 +03:00
Merge branch 'master' into feat/pricing-updates
This commit is contained in:
commit
ca1f73633f
115 files changed
+7355
-1291
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/*"
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
+22
@@ -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>
|
||||
),
|
||||
},
|
||||
]
|
||||
+23
@@ -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>
|
||||
),
|
||||
},
|
||||
]
|
||||
+43
@@ -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,
|
||||
},
|
||||
]
|
||||
+40
@@ -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
|
||||
+72
@@ -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
|
||||
+67
@@ -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
|
||||
+64
@@ -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
|
||||
+79
@@ -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
|
||||
+63
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
// 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: [],
|
||||
})),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
Generated
+37
-5
@@ -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
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
+35
-31
@@ -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
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user