first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

19
ui/hooks/use-mobile.ts Normal file
View 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
View 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 };
}

View 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
View 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
View 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]);
}

View 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
View 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;
}