feat(pg-notify): live connection status strip + manual reconnect

The panel had no surface for connection health: the main process
silently auto-reconnected but the renderer never learned about it,
isConnected got stuck true, and there was no way to trigger a reconnect.

- Broadcast PgNotificationConnectionStatus over pg-notify:status with
  state, retry attempt, next retry time, backoff, and last error
- Add forceReconnect() + IPC handlers (pg-notify:reconnect, get-status,
  get-all-statuses) and preload bridge
- Replace boolean isConnected with a per-connection status map in the
  renderer store; hydrate on init
- New PgNotificationStatusStrip: terminal-style vitals row with pulsing
  state dot, pg_notify@host, channel count, live rx rate, uptime, a
  30s canvas sparkline of event rate, retry countdown + progress bar,
  and a Retry now button when disconnected or reconnecting
- Flash new events with a 2px emerald left border and row fade
- Drop the now-redundant Stats collapsible (live in the strip)

All motion is motion-safe gated and under 600ms.
This commit is contained in:
Rohith Gilla 2026-04-11 11:29:55 +05:30
parent b99ff6e4cb
commit a48cdd84ee
No known key found for this signature in database
9 changed files with 657 additions and 48 deletions

View file

@ -6,7 +6,10 @@ import {
send,
getChannels,
getHistory,
clearHistory
clearHistory,
forceReconnect,
getStatus,
getAllStatuses
} from '../pg-notification-listener'
import { createLogger } from '../lib/logger'
@ -78,4 +81,32 @@ export function registerPgNotifyHandlers(): void {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
ipcMain.handle('pg-notify:reconnect', async (_event, connectionId: string) => {
try {
await forceReconnect(connectionId)
return { success: true }
} catch (error) {
log.error('pg-notify:reconnect error:', error)
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
ipcMain.handle('pg-notify:get-status', async (_event, connectionId: string) => {
try {
return { success: true, data: getStatus(connectionId) }
} catch (error) {
log.error('pg-notify:get-status error:', error)
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
ipcMain.handle('pg-notify:get-all-statuses', async () => {
try {
return { success: true, data: getAllStatuses() }
} catch (error) {
log.error('pg-notify:get-all-statuses error:', error)
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
}

View file

@ -4,7 +4,13 @@ import { BrowserWindow, app } from 'electron'
import { join } from 'path'
import { readFileSync } from 'fs'
import Database from 'better-sqlite3'
import type { ConnectionConfig, PgNotificationEvent, PgNotificationChannel } from '@shared/index'
import type {
ConnectionConfig,
PgNotificationEvent,
PgNotificationChannel,
PgNotificationConnectionStatus,
PgNotificationConnectionState
} from '@shared/index'
import { createTunnel, closeTunnel, TunnelSession } from './ssh-tunnel-service'
import { createLogger } from './lib/logger'
@ -53,6 +59,43 @@ interface ListenerEntry {
connectedSince: number
reconnectTimer?: ReturnType<typeof setTimeout>
destroyed: boolean
config: ConnectionConfig
status: PgNotificationConnectionStatus
}
const statuses = new Map<string, PgNotificationConnectionStatus>()
function setStatus(
connectionId: string,
patch: Partial<PgNotificationConnectionStatus> & { state: PgNotificationConnectionState }
): PgNotificationConnectionStatus {
const existing = statuses.get(connectionId) ?? {
connectionId,
state: 'idle' as PgNotificationConnectionState,
retryAttempt: 0
}
const next: PgNotificationConnectionStatus = { ...existing, ...patch, connectionId }
statuses.set(connectionId, next)
const entry = listeners.get(connectionId)
if (entry) entry.status = next
broadcastStatus(next)
return next
}
function broadcastStatus(status: PgNotificationConnectionStatus): void {
BrowserWindow.getAllWindows().forEach((w) => {
if (!w.isDestroyed()) {
w.webContents.send('pg-notify:status', status)
}
})
}
export function getStatus(connectionId: string): PgNotificationConnectionStatus | null {
return statuses.get(connectionId) ?? null
}
export function getAllStatuses(): PgNotificationConnectionStatus[] {
return Array.from(statuses.values())
}
let sqliteDb: Database.Database | null = null
@ -101,6 +144,13 @@ async function connectListener(
closeTunnel(existing.tunnelSession)
}
const prior = statuses.get(connectionId)
setStatus(connectionId, {
state: prior && prior.state !== 'idle' ? 'reconnecting' : 'connecting',
nextRetryAt: undefined,
backoffMs: undefined
})
let tunnelSession: TunnelSession | null = null
try {
if (config.ssh) {
@ -118,7 +168,9 @@ async function connectListener(
tunnelSession,
channels: new Set(channels),
connectedSince: Date.now(),
destroyed: false
destroyed: false,
config,
status: statuses.get(connectionId)!
}
listeners.set(connectionId, entry)
@ -138,12 +190,17 @@ async function connectListener(
client.on('error', (err) => {
if (entry.destroyed) return
log.error(`pg notification client error for ${connectionId}:`, err)
setStatus(connectionId, {
state: 'error',
lastError: err instanceof Error ? err.message : String(err)
})
scheduleReconnect(connectionId, config, entry.channels, backoffMs)
})
client.on('end', () => {
if (entry.destroyed) return
log.warn(`pg notification client disconnected for ${connectionId}, reconnecting...`)
setStatus(connectionId, { state: 'disconnected' })
scheduleReconnect(connectionId, config, entry.channels, backoffMs)
})
@ -153,9 +210,22 @@ async function connectListener(
await client.query(`LISTEN ${quoteIdent(channel)}`)
log.debug(`Listening on channel "${channel}" for connection ${connectionId}`)
}
setStatus(connectionId, {
state: 'connected',
connectedSince: Date.now(),
retryAttempt: 0,
lastError: undefined,
nextRetryAt: undefined,
backoffMs: undefined
})
} catch (err) {
log.error(`Failed to connect listener for ${connectionId}:`, err)
closeTunnel(tunnelSession)
setStatus(connectionId, {
state: 'error',
lastError: err instanceof Error ? err.message : String(err)
})
scheduleReconnect(connectionId, config, channels, backoffMs)
}
}
@ -170,8 +240,19 @@ function scheduleReconnect(
if (entry?.destroyed) return
const nextBackoff = Math.min(backoffMs * 2, MAX_BACKOFF_MS)
const nextRetryAt = Date.now() + backoffMs
log.debug(`Reconnecting ${connectionId} in ${backoffMs}ms`)
const prior = statuses.get(connectionId)
setStatus(connectionId, {
state: 'reconnecting',
retryAttempt: (prior?.retryAttempt ?? 0) + 1,
nextRetryAt,
backoffMs
})
if (entry && entry.reconnectTimer) clearTimeout(entry.reconnectTimer)
const timer = setTimeout(() => {
const current = listeners.get(connectionId)
if (current?.destroyed) return
@ -183,6 +264,19 @@ function scheduleReconnect(
}
}
export async function forceReconnect(connectionId: string): Promise<void> {
const entry = listeners.get(connectionId)
if (!entry) {
throw new Error('No listener registered for this connection')
}
if (entry.reconnectTimer) {
clearTimeout(entry.reconnectTimer)
entry.reconnectTimer = undefined
}
log.debug(`Force-reconnecting ${connectionId}`)
await connectListener(connectionId, entry.config, new Set(entry.channels), 1000)
}
function quoteIdent(name: string): string {
return `"${name.replace(/"/g, '""')}"`
}
@ -352,6 +446,7 @@ export async function cleanup(): Promise<void> {
}
closeTunnel(entry.tunnelSession)
listeners.delete(connectionId)
statuses.delete(connectionId)
}
if (sqliteDb) {

View file

@ -43,6 +43,7 @@ import type {
DataGenProgress,
PgNotificationEvent,
PgNotificationChannel,
PgNotificationConnectionStatus,
ActiveQuery,
TableSizeInfo,
CacheStats,
@ -420,6 +421,12 @@ interface DataPeekApi {
) => Promise<IpcResponse<PgNotificationEvent[]>>
clearHistory: (connectionId: string) => Promise<IpcResponse<void>>
onEvent: (callback: (event: PgNotificationEvent) => void) => () => void
onStatus: (callback: (status: PgNotificationConnectionStatus) => void) => () => void
reconnect: (connectionId: string) => Promise<IpcResponse<void>>
getStatus: (
connectionId: string
) => Promise<IpcResponse<PgNotificationConnectionStatus | null>>
getAllStatuses: () => Promise<IpcResponse<PgNotificationConnectionStatus[]>>
}
health: {
activeQueries: (config: ConnectionConfig) => Promise<IpcResponse<ActiveQuery[]>>

View file

@ -52,6 +52,7 @@ import type {
DataGenProgress,
PgNotificationEvent,
PgNotificationChannel,
PgNotificationConnectionStatus,
ActiveQuery,
TableSizeInfo,
CacheStats,
@ -540,7 +541,23 @@ const api = {
const handler = (_: unknown, event: PgNotificationEvent): void => callback(event)
ipcRenderer.on('pg-notify:event', handler)
return () => ipcRenderer.removeListener('pg-notify:event', handler)
}
},
onStatus: (
callback: (status: PgNotificationConnectionStatus) => void
): (() => void) => {
const handler = (_: unknown, status: PgNotificationConnectionStatus): void =>
callback(status)
ipcRenderer.on('pg-notify:status', handler)
return () => ipcRenderer.removeListener('pg-notify:status', handler)
},
reconnect: (connectionId: string): Promise<IpcResponse<void>> =>
ipcRenderer.invoke('pg-notify:reconnect', connectionId),
getStatus: (
connectionId: string
): Promise<IpcResponse<PgNotificationConnectionStatus | null>> =>
ipcRenderer.invoke('pg-notify:get-status', connectionId),
getAllStatuses: (): Promise<IpcResponse<PgNotificationConnectionStatus[]>> =>
ipcRenderer.invoke('pg-notify:get-all-statuses')
},
pgDump: {
export: (

View file

@ -393,6 +393,26 @@ input[type='number'] {
animation: cell-save-flash 500ms ease-out forwards;
}
@keyframes pgnotify-flash {
0% {
background-color: oklch(0.7 0.18 155 / 0.18);
}
100% {
background-color: transparent;
}
}
@keyframes pgnotify-bar {
0% {
opacity: 1;
transform: scaleY(1);
}
100% {
opacity: 0;
transform: scaleY(1);
}
}
/* ========================================
Pokemon Buddy Animations
======================================== */

View file

@ -0,0 +1,374 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { RotateCw, Loader2 } from 'lucide-react'
import { cn } from '@data-peek/ui'
import type { PgNotificationConnectionStatus } from '@data-peek/shared'
import { usePgNotificationStore } from '@/stores'
interface Props {
connectionId: string
hostLabel: string
channelCount: number
}
function formatUptime(fromMs?: number): string {
if (!fromMs) return '00:00:00'
const seconds = Math.max(0, Math.floor((Date.now() - fromMs) / 1000))
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
const pad = (n: number): string => n.toString().padStart(2, '0')
return `${pad(h)}:${pad(m)}:${pad(s)}`
}
function formatCountdown(targetMs?: number): string {
if (!targetMs) return ''
const remaining = Math.max(0, targetMs - Date.now())
const seconds = Math.ceil(remaining / 1000)
return `${seconds}s`
}
type Tone = 'ok' | 'warn' | 'err' | 'idle'
function stateToTone(state?: PgNotificationConnectionStatus['state']): Tone {
switch (state) {
case 'connected':
return 'ok'
case 'connecting':
case 'reconnecting':
return 'warn'
case 'error':
case 'disconnected':
return 'err'
default:
return 'idle'
}
}
function toneClasses(tone: Tone): {
dot: string
text: string
border: string
glow: string
bar: string
} {
switch (tone) {
case 'ok':
return {
dot: 'bg-emerald-500 shadow-[0_0_12px_rgba(16,185,129,0.6)]',
text: 'text-emerald-500',
border: 'border-emerald-500/30',
glow: 'from-emerald-500/10 via-emerald-500/5 to-transparent',
bar: 'bg-emerald-500'
}
case 'warn':
return {
dot: 'bg-amber-500 shadow-[0_0_12px_rgba(245,158,11,0.6)]',
text: 'text-amber-500',
border: 'border-amber-500/30',
glow: 'from-amber-500/15 via-amber-500/5 to-transparent',
bar: 'bg-amber-500'
}
case 'err':
return {
dot: 'bg-rose-500 shadow-[0_0_12px_rgba(244,63,94,0.6)]',
text: 'text-rose-500',
border: 'border-rose-500/30',
glow: 'from-rose-500/15 via-rose-500/5 to-transparent',
bar: 'bg-rose-500'
}
default:
return {
dot: 'bg-muted-foreground/50',
text: 'text-muted-foreground',
border: 'border-border',
glow: 'from-muted/20 via-transparent to-transparent',
bar: 'bg-muted-foreground/40'
}
}
}
function stateLabel(status?: PgNotificationConnectionStatus | null): string {
if (!status) return 'idle'
switch (status.state) {
case 'connected':
return 'alive'
case 'connecting':
return 'connecting'
case 'reconnecting':
return 'reconnecting'
case 'disconnected':
return 'disconnected'
case 'error':
return 'error'
default:
return 'idle'
}
}
function Sparkline({
samples,
tone,
width = 160,
height = 28
}: {
samples: number[]
tone: Tone
width?: number
height?: number
}) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = width * dpr
canvas.height = height * dpr
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, width, height)
const max = Math.max(1, ...samples)
const n = samples.length
if (n === 0) return
const stepX = width / Math.max(1, n - 1)
const toneColors: Record<Tone, { stroke: string; fill: string }> = {
ok: { stroke: 'rgba(16, 185, 129, 0.95)', fill: 'rgba(16, 185, 129, 0.18)' },
warn: { stroke: 'rgba(245, 158, 11, 0.95)', fill: 'rgba(245, 158, 11, 0.18)' },
err: { stroke: 'rgba(244, 63, 94, 0.95)', fill: 'rgba(244, 63, 94, 0.18)' },
idle: { stroke: 'rgba(148, 163, 184, 0.6)', fill: 'rgba(148, 163, 184, 0.12)' }
}
const colors = toneColors[tone]
const points: Array<[number, number]> = samples.map((v, i) => {
const x = i * stepX
const y = height - 2 - (v / max) * (height - 4)
return [x, y]
})
ctx.beginPath()
ctx.moveTo(0, height)
points.forEach(([x, y], i) => {
if (i === 0) ctx.lineTo(x, y)
else {
const [px, py] = points[i - 1]
const cx = (px + x) / 2
ctx.quadraticCurveTo(px, py, cx, (py + y) / 2)
}
})
const last = points[points.length - 1]
ctx.lineTo(last[0], height)
ctx.closePath()
ctx.fillStyle = colors.fill
ctx.fill()
ctx.beginPath()
points.forEach(([x, y], i) => {
if (i === 0) {
ctx.moveTo(x, y)
} else {
const [px, py] = points[i - 1]
const cx = (px + x) / 2
ctx.quadraticCurveTo(px, py, cx, (py + y) / 2)
}
})
ctx.strokeStyle = colors.stroke
ctx.lineWidth = 1.25
ctx.lineJoin = 'round'
ctx.stroke()
// head dot
const [hx, hy] = last
ctx.beginPath()
ctx.arc(hx, hy, 2, 0, Math.PI * 2)
ctx.fillStyle = colors.stroke
ctx.fill()
}, [samples, tone, width, height])
return (
<canvas
ref={canvasRef}
style={{ width, height }}
className="shrink-0"
aria-hidden="true"
/>
)
}
const SPARK_BUCKETS = 30
const SPARK_WINDOW_MS = 30_000
export function PgNotificationStatusStrip({
connectionId,
hostLabel,
channelCount
}: Props) {
const status = usePgNotificationStore((s) => s.statuses[connectionId])
const reconnect = usePgNotificationStore((s) => s.reconnect)
const events = usePgNotificationStore((s) => s.events)
const stats = usePgNotificationStore((s) => s.stats)
const [now, setNow] = useState(() => Date.now())
const [isManualReconnecting, setIsManualReconnecting] = useState(false)
const [reconnectError, setReconnectError] = useState<string | null>(null)
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [])
const samples = useMemo(() => {
const buckets = new Array<number>(SPARK_BUCKETS).fill(0)
const windowStart = now - SPARK_WINDOW_MS
const bucketMs = SPARK_WINDOW_MS / SPARK_BUCKETS
for (const ev of events) {
if (ev.receivedAt < windowStart) continue
const idx = Math.min(
SPARK_BUCKETS - 1,
Math.max(0, Math.floor((ev.receivedAt - windowStart) / bucketMs))
)
buckets[idx]++
}
return buckets
}, [events, now])
const tone = stateToTone(status?.state)
const t = toneClasses(tone)
const label = stateLabel(status)
const connected = status?.state === 'connected'
const reconnecting = status?.state === 'reconnecting' || status?.state === 'connecting'
const failed = status?.state === 'error' || status?.state === 'disconnected'
const uptime = connected ? formatUptime(status?.connectedSince) : '00:00:00'
const countdown = reconnecting && status?.nextRetryAt ? formatCountdown(status.nextRetryAt) : ''
const countdownPct = useMemo(() => {
if (!status?.nextRetryAt || !status?.backoffMs) return 0
const start = status.nextRetryAt - status.backoffMs
const pct = (now - start) / status.backoffMs
return Math.min(1, Math.max(0, pct))
}, [status?.nextRetryAt, status?.backoffMs, now])
const handleReconnect = useCallback(async () => {
setIsManualReconnecting(true)
setReconnectError(null)
try {
await reconnect(connectionId)
} catch (err) {
setReconnectError(err instanceof Error ? err.message : 'Failed to reconnect')
} finally {
setIsManualReconnecting(false)
}
}, [connectionId, reconnect])
return (
<div
className={cn(
'relative overflow-hidden border-b shrink-0 font-mono text-xs',
t.border
)}
>
<div
className={cn(
'absolute inset-0 bg-gradient-to-r pointer-events-none transition-opacity duration-500',
t.glow
)}
/>
<div className="relative flex items-center gap-3 px-3 py-2">
<div className="flex items-center gap-2 shrink-0">
<span
className={cn(
'size-2 rounded-full transition-all duration-300',
t.dot,
connected && 'motion-safe:animate-[pulse_2s_ease-in-out_infinite]',
reconnecting && 'motion-safe:animate-ping'
)}
/>
<span className={cn('uppercase tracking-wider font-semibold', t.text)}>{label}</span>
</div>
<span className="text-muted-foreground/70 shrink-0"></span>
<span className="text-muted-foreground truncate">
<span className="text-foreground/80">pg_notify</span>
<span className="text-muted-foreground">@</span>
<span className="text-foreground/70">{hostLabel}</span>
</span>
<span className="text-muted-foreground/70 shrink-0"></span>
<span className="text-muted-foreground shrink-0 tabular-nums">
ch <span className="text-foreground/80">{channelCount}</span>
</span>
<span className="text-muted-foreground/70 shrink-0"></span>
<span className="text-muted-foreground shrink-0 tabular-nums">
rx{' '}
<span className="text-foreground/80">
{stats.eventsPerSecond.toFixed(1)}
</span>
/s
</span>
<span className="text-muted-foreground/70 shrink-0"></span>
<span className="text-muted-foreground shrink-0 tabular-nums">
up <span className={cn('text-foreground/80', connected && 'tabular-nums')}>{uptime}</span>
</span>
<div className="flex-1 flex items-center justify-end gap-3 min-w-0">
<Sparkline samples={samples} tone={tone} />
{reconnecting && countdown && (
<span className="text-amber-500 shrink-0 tabular-nums">
retry in {countdown}
</span>
)}
{(failed || reconnecting) && (
<button
type="button"
onClick={handleReconnect}
disabled={isManualReconnecting}
className={cn(
'shrink-0 inline-flex items-center gap-1.5 px-2 py-1 rounded border text-xs transition-all',
'hover:bg-foreground/5 active:scale-[0.98]',
t.border,
t.text
)}
title="Reconnect now"
>
{isManualReconnecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RotateCw className="size-3" />
)}
Retry now
</button>
)}
</div>
</div>
{reconnecting && status?.backoffMs && (
<div className="relative h-px bg-border/40 overflow-hidden">
<div
className={cn('absolute inset-y-0 left-0', t.bar)}
style={{ width: `${countdownPct * 100}%`, transition: 'width 1s linear' }}
/>
</div>
)}
{(failed || reconnectError) && (
<div className="relative px-3 pb-1.5 text-[11px] text-rose-500/80 truncate">
{reconnectError ?? status?.lastError ?? 'Connection lost'}
</div>
)}
</div>
)
}

View file

@ -8,7 +8,6 @@ import {
ChevronDown,
ChevronUp,
X,
Activity,
Loader2,
AlertCircle
} from 'lucide-react'
@ -22,6 +21,7 @@ import {
} from '@/stores'
import type { PgNotificationsTab } from '@/stores/tab-store'
import type { PgNotificationEvent } from '@data-peek/shared'
import { PgNotificationStatusStrip } from './pg-notification-status-strip'
interface Props {
tabId: string
@ -45,7 +45,7 @@ function tryPrettyJson(payload: string): string | null {
}
}
function EventRow({ event }: { event: PgNotificationEvent }) {
function EventRow({ event, isFresh }: { event: PgNotificationEvent; isFresh: boolean }) {
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
@ -60,9 +60,18 @@ function EventRow({ event }: { event: PgNotificationEvent }) {
return (
<div
className="border-b border-border/50 last:border-0 px-3 py-2 hover:bg-muted/30 cursor-pointer transition-colors"
className={cn(
'relative border-b border-border/50 last:border-0 px-3 py-2 hover:bg-muted/30 cursor-pointer transition-colors',
isFresh && 'motion-safe:animate-[pgnotify-flash_600ms_ease-out]'
)}
onClick={() => setExpanded((v) => !v)}
>
{isFresh && (
<span
aria-hidden
className="absolute left-0 top-0 bottom-0 w-[2px] bg-emerald-500/80 motion-safe:animate-[pgnotify-bar_700ms_ease-out_forwards]"
/>
)}
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-muted-foreground shrink-0 font-mono">
{formatTimestamp(event.receivedAt)}
@ -114,7 +123,6 @@ export function PgNotificationsPanel({ tabId }: Props) {
const channels = usePgNotificationStore((s) => s.channels)
const events = usePgNotificationStore((s) => s.events)
const stats = usePgNotificationStore((s) => s.stats)
const filter = usePgNotificationStore((s) => s.filter)
const subscribe = usePgNotificationStore((s) => s.subscribe)
const unsubscribe = usePgNotificationStore((s) => s.unsubscribe)
@ -128,11 +136,11 @@ export function PgNotificationsPanel({ tabId }: Props) {
const [sendChannel, setSendChannel] = useState('')
const [sendPayload, setSendPayload] = useState('')
const [isSendOpen, setIsSendOpen] = useState(false)
const [isStatsOpen, setIsStatsOpen] = useState(false)
const [isSubscribing, setIsSubscribing] = useState(false)
const [isSending, setIsSending] = useState(false)
const [subscribeError, setSubscribeError] = useState<string | null>(null)
const [sendError, setSendError] = useState<string | null>(null)
const mountedAtRef = useRef<number>(Date.now())
const scrollRef = useRef<HTMLDivElement>(null)
const autoScrollRef = useRef(true)
@ -229,8 +237,17 @@ export function PgNotificationsPanel({ tabId }: Props) {
)
}
const hostLabel = `${activeConnection.host}${activeConnection.database ? '/' + activeConnection.database : ''}`
return (
<div className="flex flex-1 flex-col overflow-hidden h-full">
{tab?.connectionId && (
<PgNotificationStatusStrip
connectionId={tab.connectionId}
hostLabel={hostLabel}
channelCount={activeChannels.length}
/>
)}
<div className="flex flex-col gap-3 p-3 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<div className="flex-1 flex gap-2">
@ -340,45 +357,21 @@ export function PgNotificationsPanel({ tabId }: Props) {
</p>
</div>
) : (
filteredEvents.map((event) => <EventRow key={event.id} event={event} />)
filteredEvents.map((event) => (
<EventRow
key={event.id}
event={event}
isFresh={event.receivedAt > mountedAtRef.current && Date.now() - event.receivedAt < 1500}
/>
))
)}
</div>
</ScrollArea>
<div className="border-t border-border shrink-0">
<Collapsible open={isStatsOpen} onOpenChange={setIsStatsOpen}>
<CollapsibleTrigger asChild>
<button className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-full">
<Activity className="size-3" />
<span>Stats</span>
{isStatsOpen ? (
<ChevronDown className="size-3 ml-auto" />
) : (
<ChevronUp className="size-3 ml-auto" />
)}
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 pb-2 flex gap-6 text-xs">
<div>
<span className="text-muted-foreground">Events/sec: </span>
<span className="font-mono">{stats.eventsPerSecond.toFixed(2)}</span>
</div>
<div>
<span className="text-muted-foreground">Total: </span>
<span className="font-mono">{stats.totalEvents}</span>
</div>
<div>
<span className="text-muted-foreground">Avg payload: </span>
<span className="font-mono">{stats.avgPayloadSize}B</span>
</div>
</div>
</CollapsibleContent>
</Collapsible>
<Collapsible open={isSendOpen} onOpenChange={setIsSendOpen}>
<CollapsibleTrigger asChild>
<button className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-full border-t border-border">
<button className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-full">
<Send className="size-3" />
<span>Send notification</span>
{isSendOpen ? (

View file

@ -3,6 +3,7 @@ import type {
PgNotificationEvent,
PgNotificationChannel,
PgNotificationStats,
PgNotificationConnectionStatus,
ConnectionConfig
} from '@shared/index'
@ -23,7 +24,7 @@ interface PgNotificationState {
channels: Map<string, PgNotificationChannel>
events: PgNotificationEvent[]
stats: PgNotificationStats
isConnected: boolean
statuses: Record<string, PgNotificationConnectionStatus>
filter: PgNotificationFilter
recentTimestamps: EventTimestamp[]
@ -37,6 +38,9 @@ interface PgNotificationState {
clearEvents: () => void
pushEvent: (event: PgNotificationEvent) => void
refreshChannels: (connectionId: string) => Promise<void>
setStatus: (status: PgNotificationConnectionStatus) => void
hydrateStatuses: () => Promise<void>
reconnect: (connectionId: string) => Promise<void>
}
function computeStats(
@ -62,11 +66,11 @@ function computeStats(
}
}
export const usePgNotificationStore = create<PgNotificationState>((set) => ({
export const usePgNotificationStore = create<PgNotificationState>((set, get) => ({
channels: new Map(),
events: [],
stats: { eventsPerSecond: 0, totalEvents: 0, avgPayloadSize: 0 },
isConnected: false,
statuses: {},
filter: { channel: '', search: '' },
recentTimestamps: [],
@ -88,7 +92,7 @@ export const usePgNotificationStore = create<PgNotificationState>((set) => ({
const existing = channels.get(channel)!
channels.set(channel, { ...existing, isListening: true })
}
return { channels, isConnected: true }
return { channels }
})
},
@ -183,24 +187,74 @@ export const usePgNotificationStore = create<PgNotificationState>((set) => ({
return { channels }
})
}
},
setStatus: (status) => {
set((state) => ({
statuses: { ...state.statuses, [status.connectionId]: status }
}))
},
hydrateStatuses: async () => {
const response = await window.api.pgNotify.getAllStatuses()
if (response.success && response.data) {
const next: Record<string, PgNotificationConnectionStatus> = {}
for (const s of response.data) next[s.connectionId] = s
set({ statuses: next })
}
},
reconnect: async (connectionId) => {
const current = get().statuses[connectionId]
set({
statuses: {
...get().statuses,
[connectionId]: {
connectionId,
state: 'reconnecting',
retryAttempt: (current?.retryAttempt ?? 0) + 1,
connectedSince: current?.connectedSince,
lastError: current?.lastError,
nextRetryAt: undefined,
backoffMs: undefined
}
}
})
const response = await window.api.pgNotify.reconnect(connectionId)
if (!response.success) {
throw new Error(response.error ?? 'Failed to reconnect')
}
}
}))
let unsubscribeEventListener: (() => void) | null = null
let unsubscribeStatusListener: (() => void) | null = null
export function initPgNotificationListener(): () => void {
if (unsubscribeEventListener) {
unsubscribeEventListener()
}
if (unsubscribeEventListener) unsubscribeEventListener()
if (unsubscribeStatusListener) unsubscribeStatusListener()
unsubscribeEventListener = window.api.pgNotify.onEvent((event) => {
usePgNotificationStore.getState().pushEvent(event)
})
unsubscribeStatusListener = window.api.pgNotify.onStatus((status) => {
usePgNotificationStore.getState().setStatus(status)
})
usePgNotificationStore
.getState()
.hydrateStatuses()
.catch(() => {})
return () => {
if (unsubscribeEventListener) {
unsubscribeEventListener()
unsubscribeEventListener = null
}
if (unsubscribeStatusListener) {
unsubscribeStatusListener()
unsubscribeStatusListener = null
}
}
}

View file

@ -2018,6 +2018,24 @@ export interface PgNotificationStats {
connectedSince?: number;
}
export type PgNotificationConnectionState =
| "idle"
| "connecting"
| "connected"
| "reconnecting"
| "disconnected"
| "error";
export interface PgNotificationConnectionStatus {
connectionId: string;
state: PgNotificationConnectionState;
connectedSince?: number;
lastError?: string;
retryAttempt: number;
nextRetryAt?: number;
backoffMs?: number;
}
export interface ActiveQuery {
pid: number;
user: string;