Files
supabase/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx
Jordi Enric a7dda67549 feat(studio): add Multigres logs collection for HA projects (#46499)
## Problem

High availability (Multigres) projects don't expose Multigres service
logs in the Studio logs UI, so users on HA projects have no entry point
to inspect them.

## Fix

Add a `Multigres` logs collection, gated behind the `multigresLogs`
ConfigCat flag **and** the project's `high_availability` flag
(`useShowMultigresLogs`):

- New `Multigres` entry in the logs sidebar `Collections`, linking to a
new `multigres-logs` page that queries the `multigres_logs` table.
- Wire `multigres_logs` through the logs constants, types, table SQL,
query type, and service labels.
- Row formatting: parse the JSON `event_message` and render `level`
through `SeverityFormatter` and `msg` through `TextFormatter`, matching
the other service collections (instead of dumping raw JSON).
- `WARN` severity is now styled like `WARNING` (amber), since Multigres
emits `level: WARN`.
- Log detail drawer: parse the JSON `event_message` and spread its keys
onto the log so each field (level, msg, query, error, connection_id,
etc.) renders as its own collapsible row.
- Single-log query omits the `metadata` column for `multigres_logs` (the
table has no such column), fixing an `INVALID_ARGUMENT` error when
opening the detail drawer.
- Event chart: parse the level out of `event_message` via `JSON_VALUE`
so error/warning bars are counted (the table has no top-level level
column).
- Add the `multigres_logs` source schema to the Field Reference drawer,
same gating.

## Why a feature flag

`high_availability` is an existing product feature that predates
Multigres, so existing HA projects could otherwise see a broken
collection querying a `multigres_logs` table they don't have. Requiring
the `multigresLogs` flag ships the feature dark and decouples rollout
from HA status. The flag must be created in ConfigCat before enabling;
until then `useFlag` returns false and the feature stays hidden.

## How to test

- Enable the `multigresLogs` flag (or override locally) and open a
project where `high_availability` is `true`.
- Navigate to `Logs`. Confirm a `Multigres` entry appears under
`Collections` (after `Replication`).
- Open it: the page loads at `/project/<ref>/logs/multigres-logs` and
queries `multigres_logs`.
- Confirm rows show a colored severity pill (including amber `WARN`) and
a readable message rather than raw JSON.
- Confirm the chart counts error/warning bars correctly.
- Click a row: the detail drawer shows each parsed field as its own row,
with no error.
- Open the Field Reference drawer and confirm `Multigres` is listed as a
source.
- With the flag off, or on a non-HA project, confirm the collection and
Field Reference source are both hidden.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Multigres added as a dedicated log source with its own Logs page,
sidebar entry, and query type.
* Log list and preview now parse Multigres payloads to surface
timestamp, severity, and formatted message.
* Multigres integrated into charting, prompt labels, and field-reference
UI (hidden unless enabled).
* New hook controls showing Multigres UI only when feature flag + HA
project condition are met.
* **Bug Fixes**
  * Severity rendering treats "WARN" the same as "WARNING".
* **Tests**
  * Unit tests added for Multigres parsing and the show-Multigres hook.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:00:19 +02:00

391 lines
12 KiB
TypeScript

import { IS_PLATFORM, useFlag, useParams } from 'common'
import { ChevronRight, CircleHelpIcon, Plus } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import React, { useState } from 'react'
import {
Badge,
Button,
cn,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
Separator,
} from 'ui'
import {
InnerSideBarEmptyPanel,
InnerSideBarFilters,
InnerSideBarFilterSearchInput,
InnerSideMenuItem,
} from 'ui-patterns/InnerSideMenu'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { FeaturePreviewSidebarPanel } from '../../ui/FeaturePreviewSidebarPanel'
import {
useFeaturePreviewModal,
useUnifiedLogsPreview,
} from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useIsETLPrivateAlpha } from '@/components/interfaces/Database/Replication/useIsETLPrivateAlpha'
import { LOG_DRAIN_TYPES } from '@/components/interfaces/LogDrains/LogDrains.constants'
import SavedQueriesItem from '@/components/interfaces/Settings/Logs/Logs.SavedQueriesItem'
import { LogsSidebarItem } from '@/components/interfaces/Settings/Logs/SidebarV2/SidebarItem'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { useContentQuery } from '@/data/content/content-query'
import { useReplicationSourcesQuery } from '@/data/replication/sources-query'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useShowMultigresLogs } from '@/hooks/misc/useShowMultigresLogs'
export function SidebarCollapsible({
children,
title,
defaultOpen,
}: {
children: React.ReactNode
title: string
defaultOpen?: boolean
}) {
return (
<Collapsible defaultOpen={defaultOpen}>
<CollapsibleTrigger className="flex items-center gap-x-2 px-4 [&[data-state=open]>svg]:rotate-90! pb-2">
<ChevronRight
size={16}
className={'text-foreground-light transition-transform duration-200'}
/>
<span className="text-foreground-light font-mono text-sm uppercase">{title}</span>
</CollapsibleTrigger>
<CollapsibleContent>{children}</CollapsibleContent>
</Collapsible>
)
}
export function LogsSidebarMenuV2() {
const router = useRouter()
const { ref } = useParams() as { ref: string }
const unifiedLogsFlagEnabled = useFlag('unifiedLogs')
const { selectFeaturePreview } = useFeaturePreviewModal()
const { enable: enableUnifiedLogs, isEligible: isUnifiedLogsEligible } = useUnifiedLogsPreview()
const [searchText, setSearchText] = useState('')
const {
projectAuthAll: authEnabled,
projectStorageAll: storageEnabled,
realtimeAll: realtimeEnabled,
logsTemplates: templatesEnabled,
logsCollections: collectionsEnabled,
} = useIsFeatureEnabled([
'project_storage:all',
'project_auth:all',
'realtime:all',
'logs:templates',
'logs:collections',
])
const enablePgReplicate = useIsETLPrivateAlpha()
const { data: etlData, isPending: isETLLoading } = useReplicationSourcesQuery(
{
projectRef: ref,
},
{
enabled: enablePgReplicate,
retry: false,
refetchOnMount: false,
refetchOnWindowFocus: false,
}
)
// [Jordi] We only want to show ETL logs if the user has the feature enabled AND they're using the feature aka they've created a source.
const showETLLogs = enablePgReplicate && (etlData?.sources?.length ?? 0) > 0 && !isETLLoading
const { hasAccess: hasDedicatedPooler } = useCheckEntitlements('dedicated_pooler')
const showMultigresLogs = useShowMultigresLogs()
const { data: savedQueriesRes, isPending: savedQueriesLoading } = useContentQuery({
projectRef: ref,
type: 'log_sql',
})
const savedQueries = [...(savedQueriesRes?.content ?? [])]
.filter((c) => c.type === 'log_sql')
.sort((a, b) => a.name.localeCompare(b.name))
function isActive(path: string) {
return router.asPath.includes(path)
}
const BASE_COLLECTIONS = [
{
name: 'API Gateway',
key: 'edge-logs',
url: `/project/${ref}/logs/edge-logs`,
items: [],
},
{
name: 'Postgres',
key: 'postgres-logs',
url: `/project/${ref}/logs/postgres-logs`,
items: [],
},
{
name: 'PostgREST',
key: 'postgrest-logs',
url: `/project/${ref}/logs/postgrest-logs`,
items: [],
},
IS_PLATFORM
? {
name: hasDedicatedPooler ? 'Shared Pooler' : 'Pooler',
key: 'pooler-logs',
url: `/project/${ref}/logs/pooler-logs`,
items: [],
}
: null,
hasDedicatedPooler && IS_PLATFORM
? {
name: 'Dedicated Pooler',
key: 'dedicated-pooler-logs',
url: `/project/${ref}/logs/dedicated-pooler-logs`,
items: [],
}
: null,
authEnabled
? {
name: 'Auth',
key: 'auth-logs',
url: `/project/${ref}/logs/auth-logs`,
items: [],
}
: null,
storageEnabled
? {
name: 'Storage',
key: 'storage-logs',
url: `/project/${ref}/logs/storage-logs`,
items: [],
}
: null,
realtimeEnabled
? {
name: 'Realtime',
key: 'realtime-logs',
url: `/project/${ref}/logs/realtime-logs`,
items: [],
}
: null,
{
name: 'Edge Functions',
key: 'edge-functions-logs',
url: `/project/${ref}/logs/edge-functions-logs`,
items: [],
},
{
name: 'Cron',
key: 'pg_cron',
url: `/project/${ref}/logs/pgcron-logs`,
items: [],
},
showETLLogs
? {
name: 'Replication',
key: 'replication_logs',
url: `/project/${ref}/logs/replication-logs`,
items: [],
}
: null,
showMultigresLogs
? {
name: 'Multigres',
key: 'multigres-logs',
url: `/project/${ref}/logs/multigres-logs`,
items: [],
}
: null,
].filter((x) => x !== null)
const OPERATIONAL_COLLECTIONS = IS_PLATFORM
? [
{
name: 'Postgres Version Upgrade',
key: 'pg-upgrade-logs',
url: `/project/${ref}/logs/pg-upgrade-logs`,
items: [],
},
]
: []
const filteredLogs = BASE_COLLECTIONS.filter((collection) => {
return collection?.name.toLowerCase().includes(searchText.toLowerCase())
})
const filteredOperationalLogs = OPERATIONAL_COLLECTIONS.filter((collection) => {
return collection?.name.toLowerCase().includes(searchText.toLowerCase())
})
return (
<div className="pb-4 relative">
{IS_PLATFORM && !unifiedLogsFlagEnabled && (
<FeaturePreviewSidebarPanel
className="mx-4 mt-4"
illustration={<Badge variant="default">Coming soon</Badge>}
title="New logs"
description="Get early access"
actions={
<Link href="https://forms.supabase.com/unified-logs-signup" target="_blank">
<Button type="default" size="tiny">
Early access
</Button>
</Link>
}
/>
)}
{isUnifiedLogsEligible && (
<FeaturePreviewSidebarPanel
className="mx-4 mt-4"
title="Introducing unified logs"
description="A unified view across all services with improved filtering and real-time updates."
illustration={<Badge variant="success">New</Badge>}
actions={
<>
<Button
size="tiny"
type="default"
onClick={() => {
enableUnifiedLogs()
router.push(`/project/${ref}/logs`)
}}
>
Enable preview
</Button>
<ButtonTooltip
type="default"
className="px-1.5"
icon={<CircleHelpIcon />}
onClick={() => selectFeaturePreview('supabase-ui-preview-unified-logs')}
tooltip={{ content: { side: 'bottom', text: 'More information' } }}
/>
</>
}
/>
)}
<div
className={cn(
'flex gap-x-2 items-center sticky top-0 bg-background-200 z-1 px-4',
!templatesEnabled ? 'pt-4' : 'py-4'
)}
>
<InnerSideBarFilters className="w-full p-0 gap-0">
<InnerSideBarFilterSearchInput
name="search-collections"
placeholder="Search collections..."
aria-labelledby="Search collections"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
></InnerSideBarFilterSearchInput>
</InnerSideBarFilters>
<Button
type="default"
icon={<Plus className="text-foreground" />}
className="w-[26px]"
onClick={() => router.push(`/project/${ref}/logs/explorer`)}
/>
</div>
{templatesEnabled && (
<div className="px-2">
<InnerSideMenuItem
title="Templates"
isActive={isActive(`/project/${ref}/logs/explorer/templates`)}
href={`/project/${ref}/logs/explorer/templates`}
>
Templates
</InnerSideMenuItem>
</div>
)}
<Separator className="my-4" />
{collectionsEnabled && (
<>
<SidebarCollapsible title="Collections" defaultOpen={true}>
{filteredLogs.map((collection) => {
const isItemActive = isActive(collection?.url ?? '')
return (
<LogsSidebarItem
key={collection?.key ?? ''}
isActive={isItemActive}
href={collection?.url ?? ''}
label={collection?.name ?? ''}
/>
)
})}
</SidebarCollapsible>
{OPERATIONAL_COLLECTIONS.length > 0 && (
<>
<Separator className="my-4" />
<SidebarCollapsible title="Database operations" defaultOpen={true}>
{filteredOperationalLogs.map((collection) => (
<LogsSidebarItem
key={collection.key}
isActive={isActive(collection.url)}
href={collection.url}
label={collection.name}
/>
))}
</SidebarCollapsible>
</>
)}
<Separator className="my-4" />
</>
)}
<SidebarCollapsible title="Queries" defaultOpen={true}>
{savedQueriesLoading && (
<div className="p-4">
<GenericSkeletonLoader />
</div>
)}
{savedQueries.length === 0 && (
<InnerSideBarEmptyPanel
className="mx-4"
title="No queries created yet"
description={
IS_PLATFORM ? 'Create and save your queries to use them in the explorer' : undefined
}
actions={
<Button asChild type="default">
<Link href={`/project/${ref}/logs/explorer`}>Create query</Link>
</Button>
}
/>
)}
{savedQueries.map((query) => (
<SavedQueriesItem item={query} key={query.id} />
))}
</SidebarCollapsible>
<Separator className="my-4" />
<FeaturePreviewSidebarPanel
className="mx-4 mt-4"
title="Capture your logs"
description="Send logs to your preferred observability or storage platform."
illustration={
<div className="flex items-center gap-4">
{LOG_DRAIN_TYPES.filter((t) =>
['datadog', 'sentry', 'webhook', 'loki'].includes(t.value)
).map((type) =>
React.cloneElement(type.icon, { key: type.name, height: 20, width: 20 })
)}
</div>
}
actions={
<Button asChild type="default">
<Link href={`/project/${ref}/settings/log-drains`}>Go to Log Drains</Link>
</Button>
}
/>
</div>
)
}