mirror of
https://github.com/Rohithgilla12/data-peek
synced 2026-04-21 12:57:16 +00:00
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:
parent
b99ff6e4cb
commit
a48cdd84ee
9 changed files with 657 additions and 48 deletions
|
|
@ -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) }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
7
apps/desktop/src/preload/index.d.ts
vendored
7
apps/desktop/src/preload/index.d.ts
vendored
|
|
@ -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[]>>
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
======================================== */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue