mirror of
https://github.com/supabase/supabase.git
synced 2026-10-19 08:09:11 +03:00
Merge branch 'master' into chore/update-support-page
This commit is contained in:
132 files changed
+8960
-1643
No files matched your search
@@ -106,3 +106,7 @@ typings/
|
||||
!docker/.env
|
||||
!docker/supabase/.env
|
||||
!docker/supabase-traefik/.env
|
||||
|
||||
# Supabase
|
||||
**/supabase/.branches
|
||||
**/supabase/.temp
|
||||
@@ -151,7 +151,7 @@ services:
|
||||
|
||||
db:
|
||||
container_name: supabase-db
|
||||
image: supabase/postgres:14.1.0.15
|
||||
image: supabase/postgres:14.1.0.19
|
||||
command: postgres -c config_file=/etc/postgresql/postgresql.conf
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -8,3 +8,106 @@
|
||||
.dark .docs-story {
|
||||
@apply bg-scale-200;
|
||||
}
|
||||
|
||||
/* // date picker */
|
||||
/* !!!! THIS IS A COPY OF THE styles/date-picker.scss !! */
|
||||
|
||||
.react-datepicker__input-container input {
|
||||
@apply block w-full text-base md:text-sm bg-white border border-scale-300 rounded shadow-sm;
|
||||
}
|
||||
|
||||
.react-datepicker-popper {
|
||||
@apply z-40 w-72 text-sm bg-scale-100 dark:bg-scale-300 shadow px-3 py-2 border-2 border-scale-200 rounded;
|
||||
}
|
||||
|
||||
.react-datepicker-left {
|
||||
@apply absolute left-0 right-auto top-11 transform-none !important;
|
||||
}
|
||||
|
||||
.react-datepicker-right {
|
||||
@apply absolute right-0 left-auto top-11 transform-none !important;
|
||||
}
|
||||
|
||||
.react-datepicker__portal {
|
||||
@apply absolute z-10 w-72 text-sm transform-none bg-white shadow px-3 py-2 top-12 right-0 border-2 border-scale-200 rounded;
|
||||
}
|
||||
|
||||
.react-datepicker__month-container {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.react-datepicker__month {
|
||||
@apply flex flex-col;
|
||||
@apply -space-y-px;
|
||||
}
|
||||
|
||||
.react-datepicker__current-month {
|
||||
@apply ml-2.5 text-lg font-semibold text-scale-800;
|
||||
}
|
||||
|
||||
.react-datepicker__week {
|
||||
@apply flex -space-x-px;
|
||||
}
|
||||
|
||||
.react-datepicker__day-names {
|
||||
@apply flex -space-x-px text-scale-900 font-normal text-center text-xs;
|
||||
}
|
||||
|
||||
.react-datepicker__day-name {
|
||||
@apply w-10 h-10 flex items-center justify-center py-1 rounded-full;
|
||||
}
|
||||
|
||||
.react-datepicker__navigation {
|
||||
@apply absolute top-2;
|
||||
}
|
||||
|
||||
.react-datepicker__navigation--previous {
|
||||
@apply right-12 w-10 h-10 rounded transition flex items-center justify-center hover:bg-scale-200;
|
||||
}
|
||||
|
||||
.react-datepicker__navigation--next {
|
||||
@apply right-4 w-10 h-10 rounded transition flex items-center justify-center hover:bg-scale-200;
|
||||
}
|
||||
|
||||
.react-datepicker__day {
|
||||
@apply w-10 h-10 flex items-center font-normal justify-center text-sm leading-loose transition text-scale-1100;
|
||||
/* @apply mb-1 py-1; */
|
||||
@apply -space-y-px;
|
||||
@apply border border-scale-500;
|
||||
}
|
||||
|
||||
.react-datepicker__day--disabled {
|
||||
@apply cursor-not-allowed opacity-40 hover:bg-transparent;
|
||||
}
|
||||
|
||||
.react-datepicker__day--outside-month {
|
||||
@apply text-scale-800;
|
||||
}
|
||||
|
||||
.react-datepicker__day--in-range {
|
||||
@apply bg-brand-800 text-scale-fixed-100 border-brand-800;
|
||||
}
|
||||
|
||||
.react-datepicker__day--in-selecting-range {
|
||||
@apply bg-brand-700 text-scale-fixed-100 border-brand-700;
|
||||
}
|
||||
|
||||
.react-datepicker__day--selecting-range-start {
|
||||
@apply bg-scale-200;
|
||||
}
|
||||
|
||||
.react-datepicker__day--selecting-range-end {
|
||||
@apply bg-scale-200;
|
||||
}
|
||||
|
||||
.react-datepicker__day--selected {
|
||||
@apply bg-brand-800 text-white;
|
||||
}
|
||||
|
||||
.react-datepicker__day--range-start {
|
||||
@apply bg-brand-900 text-white hover:text-scale-700 hover:bg-white;
|
||||
}
|
||||
|
||||
.react-datepicker__day--range-end {
|
||||
@apply bg-brand-900 text-white hover:text-scale-700 hover:bg-white;
|
||||
}
|
||||
@@ -0,0 +1,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,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,326 @@
|
||||
export const LOGS_DATA = [
|
||||
{
|
||||
id: 1,
|
||||
url: '/readededeaded/functions/hello-world',
|
||||
method: 'POST',
|
||||
runtime: 'Node14',
|
||||
memory: 256,
|
||||
created_at: 1646524256,
|
||||
user_agent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36',
|
||||
memory_used: '113',
|
||||
init_duration: '431.44',
|
||||
duration: '1394.57 ms',
|
||||
edge_status: '200',
|
||||
function_status: '200',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
url: '/readededeaded/functions/hello-world',
|
||||
method: 'POST',
|
||||
runtime: 'Node14',
|
||||
memory: 256,
|
||||
created_at: 1646524256,
|
||||
user_agent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36',
|
||||
memory_used: '113',
|
||||
init_duration: '431.44',
|
||||
duration: '1394.57 ms',
|
||||
edge_status: '200',
|
||||
function_status: '200',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
url: '/readededeaded/functions/hello-world',
|
||||
method: 'POST',
|
||||
runtime: 'Node14',
|
||||
memory: 512,
|
||||
created_at: 1646524256,
|
||||
user_agent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36',
|
||||
memory_used: '113',
|
||||
init_duration: '431.44',
|
||||
duration: '1394.57 ms',
|
||||
edge_status: '200',
|
||||
function_status: '200',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
url: '/readededeaded/functions/hello-world',
|
||||
method: 'POST',
|
||||
runtime: 'Node14',
|
||||
memory: 512,
|
||||
created_at: 1646524256,
|
||||
user_agent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36',
|
||||
memory_used: '113',
|
||||
init_duration: '431.44',
|
||||
duration: '1394.57 ms',
|
||||
edge_status: '200',
|
||||
function_status: '200',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
url: '/readededeaded/functions/hello-world',
|
||||
method: 'POST',
|
||||
runtime: 'Node14',
|
||||
memory: 512,
|
||||
created_at: 1646524256,
|
||||
user_agent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36',
|
||||
memory_used: '113',
|
||||
init_duration: '431.44',
|
||||
duration: '1394.57 ms',
|
||||
edge_status: '200',
|
||||
function_status: '200',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
url: '/readededeaded/functions/hello-world',
|
||||
method: 'POST',
|
||||
runtime: 'Node14',
|
||||
memory: 512,
|
||||
created_at: 1646524256,
|
||||
user_agent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36',
|
||||
memory_used: '113',
|
||||
init_duration: '431.44',
|
||||
duration: '1394.57 ms',
|
||||
edge_status: '200',
|
||||
function_status: '200',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
url: '/readededeaded/functions/hello-world',
|
||||
method: 'POST',
|
||||
runtime: 'Node14',
|
||||
memory: 512,
|
||||
created_at: 1646524256,
|
||||
user_agent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36',
|
||||
memory_used: '113',
|
||||
init_duration: '431.44',
|
||||
duration: '1394.57 ms',
|
||||
edge_status: '200',
|
||||
function_status: '200',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
url: '/readededeaded/functions/hello-world',
|
||||
method: 'POST',
|
||||
runtime: 'Node14',
|
||||
memory: 512,
|
||||
created_at: 1646524256,
|
||||
user_agent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36',
|
||||
memory_used: '113',
|
||||
init_duration: '431.44',
|
||||
duration: '1394.57 ms',
|
||||
edge_status: '200',
|
||||
function_status: '200',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
url: '/readededeaded/functions/hello-world',
|
||||
method: 'POST',
|
||||
runtime: 'Node14',
|
||||
memory: 512,
|
||||
created_at: 1646524256,
|
||||
user_agent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36',
|
||||
memory_used: '113',
|
||||
init_duration: '431.44',
|
||||
duration: '1394.57 ms',
|
||||
edge_status: '200',
|
||||
function_status: '200',
|
||||
},
|
||||
]
|
||||
|
||||
export const REQUEST_SAMPLE = {
|
||||
custom_fields: {
|
||||
'0': {
|
||||
name: 'Project name',
|
||||
value: '1a1a1a1a1a1a1a1a1a1a1a',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const RESPONSE_SAMPLE = [
|
||||
{
|
||||
id: 'in_1a1a1a1a1a1a1a1a1a1a1a',
|
||||
object: '1a1a1a1a1a1a1a1a1a1a1a',
|
||||
account_country: 'US',
|
||||
account_name: '1a1a1a1a1a1a1a1a1a1a1a',
|
||||
account_tax_ids: null,
|
||||
amount_due: 0,
|
||||
amount_paid: 0,
|
||||
amount_remaining: 0,
|
||||
application_fee_amount: null,
|
||||
attempt_count: 0,
|
||||
attempted: false,
|
||||
auto_advance: true,
|
||||
automatic_tax: {
|
||||
enabled: false,
|
||||
status: null,
|
||||
},
|
||||
billing_reason: '1a1a1a1a1a1a1a1a1a1a1a',
|
||||
charge: null,
|
||||
collection_method: 'charge_automatically',
|
||||
created: 1646526032,
|
||||
currency: 'usd',
|
||||
custom_fields: [
|
||||
{
|
||||
name: 'Project name',
|
||||
value: '1a1a1a1a1a1a1a1a1a1a1a',
|
||||
},
|
||||
],
|
||||
customer: 'cus_in_1a1a1a1a1a1a1a1a1a1a1a',
|
||||
customer_address: null,
|
||||
customer_email: 'in_1a1a1a1a1a1a1a1a1a1a1a@gmail.com',
|
||||
customer_name: null,
|
||||
customer_phone: null,
|
||||
customer_shipping: null,
|
||||
customer_tax_exempt: 'none',
|
||||
customer_tax_ids: [],
|
||||
default_payment_method: null,
|
||||
default_source: null,
|
||||
default_tax_rates: [],
|
||||
description: null,
|
||||
discount: null,
|
||||
discounts: [],
|
||||
due_date: null,
|
||||
ending_balance: null,
|
||||
footer: null,
|
||||
hosted_invoice_url: null,
|
||||
invoice_pdf: null,
|
||||
last_finalization_error: null,
|
||||
lines: {
|
||||
object: 'list',
|
||||
data: [
|
||||
{
|
||||
id: 'il_in_1a1a1a1a1a1a1a1a1a1a1a',
|
||||
object: 'line_item',
|
||||
amount: 0,
|
||||
currency: 'usd',
|
||||
description: '1 × Free tier (at $0.00 / month)',
|
||||
discount_amounts: [],
|
||||
discountable: true,
|
||||
discounts: [],
|
||||
livemode: true,
|
||||
metadata: {
|
||||
project_id: '29167',
|
||||
project_ref: '1a1a1a1a1a1a1a1a1a1a1a',
|
||||
project_name: '1a1a1a1a1a1a1a1a1a1a1a',
|
||||
organization_id: '1a1a1a1a1a1a1a1a1a1a1a',
|
||||
organization_slug:
|
||||
'1a1a1a1a1a1a1a1a1a1a1a-1a1a1a1a1a1a1a1a1a1a1a-1a1a1a1a1a1a1a1a1a1a1a',
|
||||
},
|
||||
period: {
|
||||
end: 1649204401,
|
||||
start: 1646526001,
|
||||
},
|
||||
plan: {
|
||||
id: 'price_1a1a1a1a1a1a1a1a1a1a1a',
|
||||
object: 'plan',
|
||||
active: true,
|
||||
aggregate_usage: null,
|
||||
amount: 0,
|
||||
amount_decimal: '0',
|
||||
billing_scheme: 'per_unit',
|
||||
created: 1615645622,
|
||||
currency: 'usd',
|
||||
interval: 'month',
|
||||
interval_count: 1,
|
||||
livemode: true,
|
||||
metadata: {
|
||||
price: '0000',
|
||||
},
|
||||
nickname: null,
|
||||
product: 'prod_1a1a1a1a1a1a1a1a1a1a1a',
|
||||
tiers_mode: null,
|
||||
transform_usage: null,
|
||||
trial_period_days: null,
|
||||
usage_type: 'licensed',
|
||||
},
|
||||
price: {
|
||||
id: 'price_1a1a1a1a1a1a1a1a1a1a1a',
|
||||
object: 'price',
|
||||
active: true,
|
||||
billing_scheme: 'per_unit',
|
||||
created: 1615645622,
|
||||
currency: 'usd',
|
||||
livemode: true,
|
||||
lookup_key: null,
|
||||
metadata: {
|
||||
price: '0000',
|
||||
},
|
||||
nickname: null,
|
||||
product: 'prod_1a1a1a1a1a1a1a1a1a1a1a',
|
||||
recurring: {
|
||||
aggregate_usage: null,
|
||||
interval: 'month',
|
||||
interval_count: 1,
|
||||
trial_period_days: null,
|
||||
usage_type: 'licensed',
|
||||
},
|
||||
tax_behavior: 'unspecified',
|
||||
tiers_mode: null,
|
||||
transform_quantity: null,
|
||||
type: 'recurring',
|
||||
unit_amount: 0,
|
||||
unit_amount_decimal: '0',
|
||||
},
|
||||
proration: false,
|
||||
proration_details: {
|
||||
credited_items: null,
|
||||
},
|
||||
quantity: 1,
|
||||
subscription: 'sub_1a1a1a1a1a1a1a1a1a1a1a',
|
||||
subscription_item: 'si_1a1a1a1a1a1a1a1a1a1a1a',
|
||||
tax_amounts: [],
|
||||
tax_rates: [],
|
||||
type: 'subscription',
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
total_count: 1,
|
||||
url: '/v1/invoices/in_1a1a1a1a1a1a1a1a1a1a1a/lines',
|
||||
},
|
||||
livemode: true,
|
||||
metadata: {},
|
||||
next_payment_attempt: 1646529632,
|
||||
number: null,
|
||||
on_behalf_of: null,
|
||||
paid: false,
|
||||
paid_out_of_band: false,
|
||||
payment_intent: null,
|
||||
payment_settings: {
|
||||
payment_method_options: null,
|
||||
payment_method_types: null,
|
||||
},
|
||||
period_end: 1646526001,
|
||||
period_start: 1644106801,
|
||||
post_payment_credit_notes_amount: 0,
|
||||
pre_payment_credit_notes_amount: 0,
|
||||
quote: null,
|
||||
receipt_number: null,
|
||||
starting_balance: 0,
|
||||
statement_descriptor: null,
|
||||
status: 'draft',
|
||||
status_transitions: {
|
||||
finalized_at: null,
|
||||
marked_uncollectible_at: null,
|
||||
paid_at: null,
|
||||
voided_at: null,
|
||||
},
|
||||
subscription: 'sub_1a1a1a1a1a1a1a1a1a1a1a',
|
||||
subtotal: 0,
|
||||
tax: null,
|
||||
test_clock: null,
|
||||
total: 0,
|
||||
total_discount_amounts: [],
|
||||
total_tax_amounts: [],
|
||||
transfer_data: null,
|
||||
webhooks_delivered_at: null,
|
||||
},
|
||||
]
|
||||
@@ -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,141 @@
|
||||
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, centered }: { children?: React.ReactNode; centered?: boolean }) => {
|
||||
const { functions, ui } = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
const { id, ref } = router.query
|
||||
|
||||
useEffect(() => {
|
||||
if (ui.selectedProjectRef) {
|
||||
functions.load()
|
||||
}
|
||||
}, [ui.selectedProjectRef])
|
||||
|
||||
if (!functions.isLoaded) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Loading active={true}>loading</Loading>
|
||||
</BaseLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const item = id ? functions.byId(id) : null
|
||||
const name = item?.name || ''
|
||||
|
||||
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="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>
|
||||
|
||||
{name && (
|
||||
<>
|
||||
<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>
|
||||
{item && <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,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
|
||||
+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,39 @@
|
||||
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')
|
||||
|
||||
const tabs = [
|
||||
{ id: 'query', label: 'Query' },
|
||||
logsSourcesPage ? { id: 'sources', label: 'Sources' } : null,
|
||||
logsSavedQueries ? { id: 'saved', label: 'Saved' } : null,
|
||||
{ id: 'templates', label: 'Templates' },
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultActiveId="1"
|
||||
type="underlined"
|
||||
size="medium"
|
||||
activeId={!activeRoute ? indexRoute : activeRoute}
|
||||
onClick={(e: string) => {
|
||||
router.push(`/project/${ref}/logs-explorer/${e === indexRoute ? '' : e}`)
|
||||
}}
|
||||
>
|
||||
{/* TODO: type properly */}
|
||||
{(tabs as any[]).map(({ id, label }) => (
|
||||
<Tabs.Panel id={id} label={label} />
|
||||
))}
|
||||
</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,98 @@
|
||||
import { Button, Dropdown, Typography, IconChevronDown, IconPlay } from '@supabase/ui'
|
||||
import Flag from 'components/ui/Flag/Flag'
|
||||
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 && (
|
||||
<Flag name="logsSavedQueries">
|
||||
<Button type="default" onClick={() => onSave()} disabled={!hasEditorValue}>
|
||||
Save query
|
||||
</Button>
|
||||
</Flag>
|
||||
)}
|
||||
</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'
|
||||
@@ -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.isLoaded) {
|
||||
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 FunctionsLayout from 'components/interfaces/Functions/FunctionsLayout'
|
||||
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?.slug}`,
|
||||
description: 'This will overwrite the deployed function with your new function',
|
||||
jsx: () => {
|
||||
return (
|
||||
<>
|
||||
<span className="text-brand-1100">supabase</span> functions deploy{' '}
|
||||
{selectedFunction?.slug}
|
||||
</>
|
||||
)
|
||||
},
|
||||
comment: 'Deploy a new version',
|
||||
},
|
||||
{
|
||||
command: `supabase functions delete ${selectedFunction?.slug}`,
|
||||
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?.slug}
|
||||
</>
|
||||
)
|
||||
},
|
||||
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=VALUE1 NAME2=VALUE2`,
|
||||
description: 'This will set secrets for your project',
|
||||
jsx: () => {
|
||||
return (
|
||||
<>
|
||||
<span className="text-brand-1100">supabase</span> secrets set NAME1=VALUE1 NAME2=VALUE2
|
||||
</>
|
||||
)
|
||||
},
|
||||
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 (
|
||||
<FunctionsLayout>
|
||||
<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?.slug}`}
|
||||
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?.slug}`}</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>
|
||||
</FunctionsLayout>
|
||||
)
|
||||
}
|
||||
|
||||
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 FunctionsLayout from 'components/interfaces/Functions/FunctionsLayout'
|
||||
|
||||
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 (
|
||||
<FunctionsLayout>
|
||||
<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>
|
||||
</FunctionsLayout>
|
||||
)
|
||||
}
|
||||
|
||||
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 FunctionsLayout from 'components/interfaces/Functions/FunctionsLayout'
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<FunctionsLayout title={title}>
|
||||
<LogsPreviewer
|
||||
projectRef={ref as string}
|
||||
queryType={'fn_edge'}
|
||||
override={{ key: 'function_id', value: id }}
|
||||
/>
|
||||
</FunctionsLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(observer(LogPage))
|
||||
@@ -0,0 +1,334 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
IconCalendar,
|
||||
IconChevronRight,
|
||||
IconExternalLink,
|
||||
IconGlobe,
|
||||
IconSearch,
|
||||
Input,
|
||||
Popover,
|
||||
SidePanel,
|
||||
} from '@supabase/ui'
|
||||
|
||||
import { withAuth } from 'hooks'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import FunctionsLayout from 'components/interfaces/Functions/FunctionsLayout'
|
||||
import FunctionLogsItem from 'components/interfaces/Functions/FunctionLogsItem'
|
||||
import { LOGS_DATA } from 'components/interfaces/Functions/Functions.data'
|
||||
|
||||
const EmptyFunctions = () => {
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<Button iconRight={<IconChevronRight />}>Get started</Button>
|
||||
<Button type="link" iconRight={<IconExternalLink />}>
|
||||
Documentation
|
||||
</Button>
|
||||
</div>
|
||||
<div className="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 FunctionsList = () => {
|
||||
const [detailVisible, setDetailVisible] = useState(false)
|
||||
|
||||
const router = useRouter()
|
||||
const { ref } = router.query
|
||||
|
||||
const PageState = {
|
||||
functions: [
|
||||
{
|
||||
name: 'hello-world',
|
||||
id: 1,
|
||||
runtime: 'Node14',
|
||||
memory: 256,
|
||||
created_at: 1646524256,
|
||||
},
|
||||
{
|
||||
name: 'charge',
|
||||
id: 2,
|
||||
runtime: 'Node14',
|
||||
memory: 256,
|
||||
created_at: 1646524256,
|
||||
},
|
||||
{
|
||||
name: 'projects',
|
||||
id: 3,
|
||||
runtime: 'Node14',
|
||||
memory: 512,
|
||||
created_at: 1646524256,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const logDetails = (
|
||||
<div className="grid grid-cols-12 gap-2">
|
||||
<span className="col-span-3 text-sm text-scale-900 w-32">Status</span>
|
||||
<div className="col-span-7">
|
||||
<div className="flex">
|
||||
<div className="text-sm bg-scale-500 text-scale-1200 rounded px-2">200 OK</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3 text-sm text-scale-900 w-32">ID</div>
|
||||
<div className="col-span-7 text-sm text-scale-1200">
|
||||
<span className="">{LOGS_DATA[0].id}</span>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3 text-sm text-scale-900 w-32">Time</div>
|
||||
<div className="col-span-7 text-sm text-scale-1200">
|
||||
<span className="">{dayjs(LOGS_DATA[0].created_at).format('DD MMM, YYYY HH:mm')}</span>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3 text-sm text-scale-900 w-32">IP Address</div>
|
||||
<div className="col-span-7 text-sm text-scale-1200">
|
||||
{/*
|
||||
// @ts-ignore */}
|
||||
<span className="">{LOGS_DATA[0].ip_address}</span>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3 text-sm text-scale-900 w-32">API Version</div>
|
||||
<div className="col-span-7 text-sm text-scale-1200">
|
||||
<span className="">v0.1</span>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3 text-sm text-scale-900 w-32">Source</div>
|
||||
<div className="col-span-7 text-sm text-scale-1200">
|
||||
<span className="">Something</span>
|
||||
</div>
|
||||
|
||||
{/* <div className="col-span-3 text-sm text-scale-900 w-32 grow">User agent</div>
|
||||
<div className="col-span-7 flex text-scale-1200">
|
||||
<span className="">{LOGS_DATA[0].user_agent}</span>
|
||||
</div> */}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input icon={<IconSearch size={14} />} placeholder="Filter by anything.." size="tiny" />
|
||||
<div className="flex -space-x-px">
|
||||
<Button
|
||||
type="secondary"
|
||||
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
|
||||
>
|
||||
Last Hour
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<IconCalendar />}
|
||||
style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }}
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
</div>
|
||||
<Popover
|
||||
size="medium"
|
||||
align="end"
|
||||
portalled
|
||||
showClose
|
||||
side="bottom"
|
||||
header={
|
||||
<div className="flex justify-between items-center">
|
||||
<Button size="tiny" type="default">
|
||||
Clear
|
||||
</Button>
|
||||
<h5 className="text-sm text-scale-1200">Status</h5>
|
||||
<Button type="primary">Save</Button>
|
||||
</div>
|
||||
}
|
||||
onOpenChange={function noRefCheck() {}}
|
||||
overlay={[
|
||||
<>
|
||||
<div className="py-6 space-y-4">
|
||||
<Checkbox
|
||||
className="px-3"
|
||||
defaultValue="100%"
|
||||
description="Show all 200 responses"
|
||||
label="Succeeded"
|
||||
size="medium"
|
||||
/>
|
||||
<Popover.Seperator />
|
||||
<Checkbox
|
||||
className="px-3"
|
||||
defaultValue="100%"
|
||||
description="Show all 5XX responses"
|
||||
label="Failed"
|
||||
size="medium"
|
||||
/>
|
||||
</div>
|
||||
</>,
|
||||
]}
|
||||
>
|
||||
<Button as="span" type="default">
|
||||
Status
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
<Button
|
||||
type="default"
|
||||
iconRight={<IconExternalLink size={14} />}
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`http://localhost:8082/project/${ref}/logs/database?q=timestamp+%3C+TIMESTAMP_SUB%28CURRENT_TIMESTAMP%28%29%2C+INTERVAL+10+MINUTE%29&s=&te=`
|
||||
)
|
||||
}
|
||||
>
|
||||
Open in logs explorer
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 h-full">
|
||||
<div>
|
||||
<Table
|
||||
head={
|
||||
<>
|
||||
<Table.th className="w-16">Response</Table.th>
|
||||
<Table.th className="w-16">Method</Table.th>
|
||||
<Table.th>URL</Table.th>
|
||||
<Table.th>Timestamp</Table.th>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
{LOGS_DATA.length > 0 &&
|
||||
LOGS_DATA.map((x: any) => (
|
||||
<FunctionLogsItem
|
||||
key={x.id}
|
||||
log={x}
|
||||
onClick={() => {
|
||||
setDetailVisible(true)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-l border-r px-6 py-4 space-y-6">
|
||||
<h3 className="text-xl font-medium text-scale-1200 mb-4">
|
||||
POST /readededeaded/functions/hello-world
|
||||
</h3>
|
||||
{logDetails}
|
||||
<SidePanel.Seperator />
|
||||
<h3 className="text-base text-scale-1200">Response body</h3>
|
||||
</div>
|
||||
</div>
|
||||
<SidePanel
|
||||
size="large"
|
||||
header={'POST /readededeaded/functions/hello-world'}
|
||||
visible={detailVisible}
|
||||
onCancel={() => setDetailVisible(false)}
|
||||
>
|
||||
<div className="py-4 flex flex-col space-y-8">
|
||||
<SidePanel.Content>{logDetails}</SidePanel.Content>
|
||||
<SidePanel.Seperator />
|
||||
<SidePanel.Content>
|
||||
<h3 className="text-base text-scale-1200">Response body</h3>
|
||||
</SidePanel.Content>
|
||||
</div>
|
||||
</SidePanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PageLayout = () => {
|
||||
const [pageState, setPageState] = useState('list')
|
||||
|
||||
const Selection = () => (
|
||||
<div>
|
||||
<Button onClick={() => setPageState('empty')}>empty list</Button>
|
||||
<Button onClick={() => setPageState('list')}>list</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<FunctionsLayout>
|
||||
<FunctionsList />
|
||||
</FunctionsLayout>
|
||||
)
|
||||
}
|
||||
|
||||
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 FunctionsLayout from 'components/interfaces/Functions/FunctionsLayout'
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<FunctionsLayout title={title}>
|
||||
<LogsPreviewer
|
||||
projectRef={ref as string}
|
||||
queryType={'functions'}
|
||||
override={{ key: 'metadata.function_id', value: id }}
|
||||
/>
|
||||
</FunctionsLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(observer(LogPage))
|
||||
@@ -0,0 +1,10 @@
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { withAuth } from 'hooks'
|
||||
|
||||
import FunctionsLayout from 'components/interfaces/Functions/FunctionsLayout'
|
||||
|
||||
const PageLayout = () => {
|
||||
return <FunctionsLayout>Triggers</FunctionsLayout>
|
||||
}
|
||||
|
||||
export default withAuth(observer(PageLayout))
|
||||
@@ -0,0 +1,263 @@
|
||||
import Link from 'next/link'
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Button, IconBookOpen, IconSearch, IconTerminal, Input, Loading } from '@supabase/ui'
|
||||
|
||||
import { useStore, withAuth } from 'hooks'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { API_URL, PROJECT_STATUS } from 'lib/constants'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import FunctionsLayout from 'components/interfaces/Functions/FunctionsLayout'
|
||||
import FunctionsListItem from 'components/interfaces/Functions/FunctionsListItem'
|
||||
import CommandRender from 'components/interfaces/Functions/CommandRender'
|
||||
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: `Associate the current folder with your Supabase 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/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/index.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: 'Invokes the hello function',
|
||||
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: 'Invoke 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 py-12">
|
||||
<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 { functions } = useStore()
|
||||
const hasFunctions = functions.list().length > 0
|
||||
|
||||
return (
|
||||
<FunctionsLayout centered={!hasFunctions}>
|
||||
{hasFunctions ? <FunctionsList functions={functions.list()} /> : <EmptyFunctions />}
|
||||
</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,12 +50,14 @@ export class RootStore implements IRootStore {
|
||||
() => this.ui.selectedProject,
|
||||
(selectedProject) => {
|
||||
if (selectedProject) {
|
||||
// @ts-ignore
|
||||
this.content = new ProjectContentStore(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.meta = new MetaStore(this, {
|
||||
projectRef: '',
|
||||
@@ -64,6 +71,7 @@ export class RootStore implements IRootStore {
|
||||
setProjectRef(value?: string) {
|
||||
if (this.ui.selectedProject?.ref === value) return
|
||||
this.ui.setProjectRef(value)
|
||||
this.functions.setProjectRef(value)
|
||||
}
|
||||
|
||||
setOrganizationSlug(value?: string) {
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface IUiStore {
|
||||
theme: 'dark' | 'light'
|
||||
themeOption: 'dark' | 'light' | 'system'
|
||||
|
||||
selectedProjectRef?: string
|
||||
isDarkTheme: boolean
|
||||
selectedProject?: Project
|
||||
selectedOrganization?: Organization
|
||||
|
||||
@@ -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,205 @@
|
||||
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
|
||||
|
||||
baseUrl: string
|
||||
projectRef: string
|
||||
|
||||
load: () => void
|
||||
list: (filter?: any) => any[]
|
||||
byId: (id?: any) => any
|
||||
reports: (filter?: any) => any[]
|
||||
sqlSnippets: (filter?: any) => any[]
|
||||
setProjectRef: (ref?: string) => void
|
||||
}
|
||||
|
||||
export default class ProjectFunctionsStore implements IProjectFunctionsStore {
|
||||
rootStore: IRootStore
|
||||
|
||||
STATES = {
|
||||
INITIAL: 'initial',
|
||||
LOADING: 'loading',
|
||||
ERROR: 'error',
|
||||
LOADED: 'loaded',
|
||||
}
|
||||
|
||||
baseUrl: string
|
||||
projectRef: string
|
||||
// @ts-ignore
|
||||
data: UserContentMap = []
|
||||
|
||||
state = this.STATES.INITIAL
|
||||
error = null
|
||||
|
||||
constructor(rootStore: IRootStore, options: { projectRef: string }) {
|
||||
const { projectRef } = options
|
||||
this.projectRef = projectRef
|
||||
this.rootStore = rootStore
|
||||
this.baseUrl = ``
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
setProjectRef(ref?: string) {
|
||||
if (ref) {
|
||||
this.projectRef = ref
|
||||
this.baseUrl = `${process.env.NEXT_PUBLIC_API_ADMIN_URL}/projects/${ref}/functions`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,39 @@
|
||||
---
|
||||
id: logs
|
||||
title: Log Browser
|
||||
title: Logging
|
||||
description: Getting started with Supabase Platform Log Browser
|
||||
---
|
||||
|
||||
The Supabase Platform provides a log browser that allows log tracing and debugging. Currently, PostgreSQL and Cloudflare edge logs are available.
|
||||
|
||||
The features discussed in this article are only available through the Supabase platform and are not available on self-hosted.
|
||||
:::note
|
||||
|
||||
The log browser can be accessed through:
|
||||
The features discussed in this article are only available through the Supabase Platform and are not available on self-hosted.
|
||||
|
||||
- `Project Overview > Settings > API`
|
||||
- `Project Overview > Settings > Database`
|
||||
:::
|
||||
|
||||
## Log Explorer
|
||||
|
||||

|
||||
|
||||
The log browser can be accessed in the sidebar under `Log Explorer`. The Log Explorer is for exploring all the logs within your project and is not specific to any product.
|
||||
|
||||
## Product Logs
|
||||
|
||||
As well as a Log Explorer, Supabase provides a logging interface specific to each product.
|
||||
|
||||
### API Logs
|
||||
|
||||

|
||||
|
||||
The API Logs can be found under `Database > API Logs`. These show all network requests for the REST and GraphQL [API](/docs/guides/api).
|
||||
|
||||
### Postgres Logs
|
||||
|
||||

|
||||
|
||||
The Postgres Logs can be found under `Database > Postgres Logs`. These show all queries and activity for your [Database](/docs/guides/database).
|
||||
|
||||
## Basic Querying Features
|
||||
|
||||
- Simple regex-based search on the event message.
|
||||
@@ -28,9 +47,7 @@ To access advanced querying features, you will need to enable **custom queries**
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
`SELECT` queries allow complex and powerful aggregated result sets, letting you to perform in-depth analysis of your log data.
|
||||
`select` queries allow complex and powerful aggregated result sets, letting you to perform in-depth analysis of your log data.
|
||||
|
||||
For example, you may enter the following into the API logs dashboard to query for each user's IP address:
|
||||
|
||||
@@ -55,14 +72,14 @@ The table references are as follows:
|
||||
|
||||
Note that cross-table joining is currently not supported.
|
||||
|
||||
#### Unnesting Arrays
|
||||
### Unnesting Arrays
|
||||
|
||||
In the above example, in order to query the metadata field, we need to unnest the field and add it as a join. Wehn we click on an individual log, we can see that log metadata is stored as an array of objects.
|
||||
|
||||
In order to query any value that is an array, we would need to `UNNEST()` that field and add it to the query as a join, thereby allowing us to reference the nested fields within the array.
|
||||
|
||||
|
||||
#### Functions
|
||||
### Functions
|
||||
|
||||
You may have also noticed from the above examples that we are able to use certain SQL functions within our queries. The querying engine currently supported is BigQuery, hence you may use [any valid BigQuery function](https://cloud.google.com/bigquery/docs/reference/standard-sql/functions-and-operators) within your query.
|
||||
|
||||
@@ -71,7 +88,7 @@ You may have also noticed from the above examples that we are able to use certai
|
||||
|
||||
Each log timestamp is stored as a `TIMESTAMP` data type. In order to utilize the `timestamp` field in a query, you would need to use the appropriate [timestamp functions](https://cloud.google.com/bigquery/docs/reference/standard-sql/timestamp_functions#timestamp).
|
||||
|
||||
### Template Queries
|
||||
## Template Queries
|
||||
|
||||
Templates are available to help shorten the time needed to craft these queries. There are examples for each complexity level, which can serve as a foundation for your custom queries.
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 952 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1021 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 427 KiB After Width: | Height: | Size: 901 KiB |
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user