diff --git a/apps/desktop/src/main/ipc/pg-notify-handlers.ts b/apps/desktop/src/main/ipc/pg-notify-handlers.ts index 04f4f8a..3919ea4 100644 --- a/apps/desktop/src/main/ipc/pg-notify-handlers.ts +++ b/apps/desktop/src/main/ipc/pg-notify-handlers.ts @@ -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) } + } + }) } diff --git a/apps/desktop/src/main/pg-notification-listener.ts b/apps/desktop/src/main/pg-notification-listener.ts index 5307d83..f015656 100644 --- a/apps/desktop/src/main/pg-notification-listener.ts +++ b/apps/desktop/src/main/pg-notification-listener.ts @@ -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 destroyed: boolean + config: ConnectionConfig + status: PgNotificationConnectionStatus +} + +const statuses = new Map() + +function setStatus( + connectionId: string, + patch: Partial & { 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 { + 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 { } closeTunnel(entry.tunnelSession) listeners.delete(connectionId) + statuses.delete(connectionId) } if (sqliteDb) { diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts index 55f4f92..6bae1c1 100644 --- a/apps/desktop/src/preload/index.d.ts +++ b/apps/desktop/src/preload/index.d.ts @@ -43,6 +43,7 @@ import type { DataGenProgress, PgNotificationEvent, PgNotificationChannel, + PgNotificationConnectionStatus, ActiveQuery, TableSizeInfo, CacheStats, @@ -420,6 +421,12 @@ interface DataPeekApi { ) => Promise> clearHistory: (connectionId: string) => Promise> onEvent: (callback: (event: PgNotificationEvent) => void) => () => void + onStatus: (callback: (status: PgNotificationConnectionStatus) => void) => () => void + reconnect: (connectionId: string) => Promise> + getStatus: ( + connectionId: string + ) => Promise> + getAllStatuses: () => Promise> } health: { activeQueries: (config: ConnectionConfig) => Promise> diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 2a2cdd3..31347b2 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -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> => + ipcRenderer.invoke('pg-notify:reconnect', connectionId), + getStatus: ( + connectionId: string + ): Promise> => + ipcRenderer.invoke('pg-notify:get-status', connectionId), + getAllStatuses: (): Promise> => + ipcRenderer.invoke('pg-notify:get-all-statuses') }, pgDump: { export: ( diff --git a/apps/desktop/src/renderer/src/assets/global.css b/apps/desktop/src/renderer/src/assets/global.css index 62d0d2d..77aa4fd 100644 --- a/apps/desktop/src/renderer/src/assets/global.css +++ b/apps/desktop/src/renderer/src/assets/global.css @@ -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 ======================================== */ diff --git a/apps/desktop/src/renderer/src/components/pg-notification-status-strip.tsx b/apps/desktop/src/renderer/src/components/pg-notification-status-strip.tsx new file mode 100644 index 0000000..be35256 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/pg-notification-status-strip.tsx @@ -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(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 = { + 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 ( +