first commit
This commit is contained in:
19
ui/hooks/use-mobile.ts
Normal file
19
ui/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
}
|
||||
21
ui/hooks/use-toast.ts
Normal file
21
ui/hooks/use-toast.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { toast as sonnerToast } from "sonner";
|
||||
|
||||
export interface Toast {
|
||||
title: string;
|
||||
description?: string;
|
||||
variant?: "default" | "destructive";
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const toast = ({ title, description, variant }: Toast) => {
|
||||
const message = description ? `${title}: ${description}` : title;
|
||||
|
||||
if (variant === "destructive") {
|
||||
sonnerToast.error(message);
|
||||
} else {
|
||||
sonnerToast.success(message);
|
||||
}
|
||||
};
|
||||
|
||||
return { toast };
|
||||
}
|
||||
32
ui/hooks/useCopyToClipboard.ts
Normal file
32
ui/hooks/useCopyToClipboard.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface UseCopyToClipboardOptions {
|
||||
successMessage?: string;
|
||||
errorMessage?: string;
|
||||
resetDelay?: number;
|
||||
}
|
||||
|
||||
export function useCopyToClipboard(options: UseCopyToClipboardOptions = {}) {
|
||||
const { successMessage = "Copied to clipboard", errorMessage = "Failed to copy", resetDelay = 2000 } = options;
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const copy = useCallback(
|
||||
async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
toast.success(successMessage);
|
||||
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setCopied(false), resetDelay);
|
||||
} catch {
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
},
|
||||
[successMessage, errorMessage, resetDelay],
|
||||
);
|
||||
|
||||
return { copy, copied };
|
||||
}
|
||||
33
ui/hooks/useDebounce.ts
Normal file
33
ui/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export function useDebouncedValue(value: any, delay: number): any {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export const useDebouncedFunction = <T extends (...args: any[]) => any>(func: T, delay: number): ((...args: Parameters<T>) => void) => {
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const debouncedFunction = useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => func(...args), delay);
|
||||
},
|
||||
[func, delay],
|
||||
);
|
||||
|
||||
return debouncedFunction;
|
||||
};
|
||||
26
ui/hooks/useStoreSync.tsx
Normal file
26
ui/hooks/useStoreSync.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { baseApi } from "@/lib/store/apis/baseApi";
|
||||
import { useAppDispatch } from "@/lib/store/hooks";
|
||||
import { useEffect } from "react";
|
||||
import { useWebSocket } from "./useWebSocket";
|
||||
|
||||
/**
|
||||
* Hook that subscribes to WebSocket messages for real-time cache updates.
|
||||
*
|
||||
* Handles store_update messages to invalidate RTK Query cache tags (triggers refetch).
|
||||
*/
|
||||
export function useStoreSync() {
|
||||
const { subscribe } = useWebSocket();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
const unsubTagSync = subscribe("store_update", (data) => {
|
||||
if (data.tags && Array.isArray(data.tags)) {
|
||||
dispatch(baseApi.util.invalidateTags(data.tags));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubTagSync();
|
||||
};
|
||||
}, [subscribe, dispatch]);
|
||||
}
|
||||
62
ui/hooks/useTablePageSize.ts
Normal file
62
ui/hooks/useTablePageSize.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { RefObject, useCallback, useEffect, useState } from "react";
|
||||
|
||||
const ROW_HEIGHT = 48; // h-12 = 3rem = 48px
|
||||
const HEADER_HEIGHT = 44; // approximate table header height
|
||||
const STATUS_ROW_HEIGHT = 48; // the "Listening for logs..." row (h-12)
|
||||
const MIN_PAGE_SIZE = 5; // minimum items per page
|
||||
|
||||
interface UseTablePageSizeOptions {
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
export function useTablePageSize(containerRef: RefObject<HTMLElement | null>, options: UseTablePageSizeOptions = {}): number | null {
|
||||
const { debounceMs = 150 } = options;
|
||||
const [pageSize, setPageSize] = useState<number | null>(null);
|
||||
|
||||
const calculatePageSize = useCallback((height: number): number => {
|
||||
const availableHeight = height - HEADER_HEIGHT - STATUS_ROW_HEIGHT;
|
||||
const calculated = Math.floor(availableHeight / ROW_HEIGHT);
|
||||
return Math.max(calculated, MIN_PAGE_SIZE);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current;
|
||||
if (!element) return;
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const handleResize = (entries: ResizeObserverEntry[]) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
|
||||
const height = entry.contentRect.height;
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
const newPageSize = calculatePageSize(height);
|
||||
setPageSize(newPageSize);
|
||||
}, debounceMs);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(element);
|
||||
|
||||
// Calculate initial size immediately
|
||||
const initialHeight = element.getBoundingClientRect().height;
|
||||
if (initialHeight > 0) {
|
||||
setPageSize(calculatePageSize(initialHeight));
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [containerRef, calculatePageSize, debounceMs]);
|
||||
|
||||
return pageSize;
|
||||
}
|
||||
182
ui/hooks/useWebSocket.tsx
Normal file
182
ui/hooks/useWebSocket.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { getApiBaseUrl } from "@/lib/utils/port";
|
||||
import { getWebSocketUrl } from "@/lib/utils/port";
|
||||
import React, { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
type MessageHandler = (data: any) => void;
|
||||
|
||||
interface WebSocketContextType {
|
||||
isConnected: boolean;
|
||||
ws: React.RefObject<WebSocket | null>;
|
||||
subscribe: (channel: string, handler: MessageHandler) => () => void;
|
||||
send: (data: any) => void;
|
||||
}
|
||||
|
||||
const WebSocketContext = createContext<WebSocketContextType | null>(null);
|
||||
|
||||
interface WebSocketProviderProps {
|
||||
children: ReactNode;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
// Global reference to maintain state across component remounts
|
||||
let globalWsRef: WebSocket | null = null;
|
||||
const messageHandlers = new Map<string, Set<MessageHandler>>();
|
||||
|
||||
export function WebSocketProvider({ children, path = "/ws" }: WebSocketProviderProps) {
|
||||
const wsRef = useRef<WebSocket | null>(globalWsRef);
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const retryCountRef = useRef(0);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
const subscribe = useCallback<(channel: string, handler: MessageHandler) => () => void>((channel, handler) => {
|
||||
if (!messageHandlers.has(channel)) {
|
||||
messageHandlers.set(channel, new Set());
|
||||
}
|
||||
messageHandlers.get(channel)!.add(handler);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const handlers = messageHandlers.get(channel);
|
||||
if (handlers) {
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
messageHandlers.delete(channel);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const send = (data: any) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
wsRef.current.send(typeof data === "string" ? data : JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.error("Error sending message:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const connect = async () => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wsUrl = getWebSocketUrl(path);
|
||||
// Obtain a short-lived, single-use ticket for WS auth instead of putting the session token in the URL.
|
||||
let wsUrlWithAuth = wsUrl;
|
||||
try {
|
||||
const resp = await fetch(`${getApiBaseUrl()}/session/ws-ticket`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (resp.ok) {
|
||||
const { ticket } = await resp.json();
|
||||
if (ticket) {
|
||||
const parsed = new URL(wsUrl);
|
||||
parsed.searchParams.set("ticket", ticket);
|
||||
wsUrlWithAuth = parsed.toString();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If ticket fetch fails, attempt connection without auth param (cookie fallback)
|
||||
}
|
||||
const ws = new WebSocket(wsUrlWithAuth);
|
||||
wsRef.current = ws;
|
||||
globalWsRef = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setIsConnected(true);
|
||||
retryCountRef.current = 0; // Reset retry count on successful connection
|
||||
|
||||
// Clear any pending reconnection attempts
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Start heartbeat/ping to keep connection alive
|
||||
if (pingTimerRef.current) {
|
||||
clearInterval(pingTimerRef.current);
|
||||
}
|
||||
pingTimerRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send("ping");
|
||||
} catch (error) {
|
||||
console.error("Error sending ping:", error);
|
||||
}
|
||||
}
|
||||
}, 25000); // Ping every 25 seconds
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
const messageType = data.type || "default";
|
||||
|
||||
// Notify all subscribers for this message type
|
||||
const handlers = messageHandlers.get(messageType);
|
||||
if (handlers) {
|
||||
handlers.forEach((handler) => handler(data));
|
||||
}
|
||||
|
||||
// Also notify wildcard subscribers
|
||||
const wildcardHandlers = messageHandlers.get("*");
|
||||
if (wildcardHandlers) {
|
||||
wildcardHandlers.forEach((handler) => handler(data));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setIsConnected(false);
|
||||
|
||||
// Clear ping timer
|
||||
if (pingTimerRef.current) {
|
||||
clearInterval(pingTimerRef.current);
|
||||
pingTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Exponential backoff: 0.5s, 1s, 2s, 4s, 8s, 16s, 32s (max)
|
||||
retryCountRef.current = Math.min(retryCountRef.current + 1, 6);
|
||||
const delay = Math.pow(2, retryCountRef.current) * 500;
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
setIsConnected(false);
|
||||
ws.close();
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
// Don't close the WebSocket on unmount since it's global
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
if (pingTimerRef.current) {
|
||||
clearInterval(pingTimerRef.current);
|
||||
pingTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [path]);
|
||||
|
||||
return <WebSocketContext.Provider value={{ isConnected, ws: wsRef, subscribe, send }}>{children}</WebSocketContext.Provider>;
|
||||
}
|
||||
|
||||
export function useWebSocket() {
|
||||
const context = useContext(WebSocketContext);
|
||||
if (!context) {
|
||||
throw new Error("useWebSocket must be used within a WebSocketProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user