diff --git a/src/features/ConfigFile.tsx b/src/features/ConfigFile.tsx index 07c8ae9..e2980bb 100644 --- a/src/features/ConfigFile.tsx +++ b/src/features/ConfigFile.tsx @@ -1,20 +1,32 @@ -import { Editor, OnMount, useMonaco } from "@monaco-editor/react"; -import { skipToken } from "@reduxjs/toolkit/query"; -import { useEffect, useRef } from "react"; -import useAppParams from "../shared/hooks/useAppParams"; -import { useGetConfigQuery } from "../store/apiSlice"; +import { Editor, OnMount, useMonaco } from '@monaco-editor/react'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { useEffect, useRef } from 'react'; +import { Button } from 'react-bootstrap'; +import useAppParams from '../shared/hooks/useAppParams'; +import { useGetConfigQuery } from '../store/apiSlice'; type IConfigViewer = Parameters[0]; const ConfigFile = () => { const { appId } = useAppParams(); - const { data: config } = useGetConfigQuery(appId ? { appId } : skipToken); + const { data: config, refetch, isFetching } = useGetConfigQuery(appId ? { appId } : skipToken); if (!config) { return
Config Data Loading or Not Available
; } - return ; + return ( +
+
+ +
+
+ +
+
+ ); }; export default ConfigFile; diff --git a/src/features/DebugConsole/DebugConsole.tsx b/src/features/DebugConsole/DebugConsole.tsx index 24e7f67..9324274 100644 --- a/src/features/DebugConsole/DebugConsole.tsx +++ b/src/features/DebugConsole/DebugConsole.tsx @@ -1,7 +1,6 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { useState } from "react"; import { Alert, Button, Form } from "react-bootstrap"; -import { useSelector } from 'react-redux'; import ListFiltersHeader from "../../shared/ListFiltersHeader"; import useAppParams from '../../shared/hooks/useAppParams'; import { @@ -10,7 +9,10 @@ import { useSetLoadConfigMutation, useSetRestartMutation } from "../../store/apiSlice"; -import { RootState } from '../../store/store'; +import { selectSearchText } from '../../store/debugConsole/debugConsoleSelectors'; +import { debugConsoleActions } from '../../store/debugConsole/debugConsoleSlice'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import type { RootState } from '../../store/store'; import ConsoleWindow from "./ConsoleWindow"; import { DebugFilters } from "./DebugFilters"; import MinimumLogLevelDropdown from './MinimumLogLevelDropdown'; @@ -21,8 +23,10 @@ const DebugConsole = ({isConnected, join, stop, clear}: DebugConsoleProps) => { //* HOOKS ***********************************************************/ const [showModal, setShowModal] = useState(false); const { appId } = useAppParams(); - const messages = useSelector((state: RootState) => state.websocket.messages); - const failedUrl = useSelector((state: RootState) => state.websocket.failedUrl); + const dispatch = useAppDispatch(); + const messages = useAppSelector((state: RootState) => state.websocket.messages); + const failedUrl = useAppSelector((state: RootState) => state.websocket.failedUrl); + const searchText = useAppSelector(selectSearchText); const certUrl = failedUrl ? new URL(failedUrl).origin.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:') : null; @@ -119,7 +123,12 @@ const DebugConsole = ({isConnected, join, stop, clear}: DebugConsoleProps) => { {', accept the certificate, then try "Start Debug Session" again.'} )} - } /> + dispatch(debugConsoleActions.setSearchText(val))} + filters={} + /> diff --git a/src/features/LoginForm.tsx b/src/features/LoginForm.tsx index 79fdd96..959cea3 100644 --- a/src/features/LoginForm.tsx +++ b/src/features/LoginForm.tsx @@ -1,15 +1,26 @@ -import { FormEvent, useState } from 'react'; -import { Alert, Button, Form, Spinner } from 'react-bootstrap'; -import { Navigate, useLocation, useNavigate } from 'react-router-dom'; -import useAppParams from '../shared/hooks/useAppParams'; -import { useSetLoginCredentialsMutation } from '../store/apiSlice'; -import { selectAvailableApps, selectIsAuthenticated } from '../store/auth/authSelectors'; -import { authActions } from '../store/auth/authSlice'; -import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { FormEvent, useState } from "react"; +import { Alert, Button, Form, Spinner } from "react-bootstrap"; +import { Navigate, useLocation, useNavigate } from "react-router-dom"; +import useAppParams from "../shared/hooks/useAppParams"; +import { useSetLoginCredentialsMutation } from "../store/apiSlice"; +import { + selectAvailableApps, + selectIsAuthenticated, +} from "../store/auth/authSelectors"; +import { authActions } from "../store/auth/authSlice"; +import { useAppDispatch, useAppSelector } from "../store/hooks"; const ALL_APP_IDS = [ - 'app01', 'app02', 'app03', 'app04', 'app05', - 'app06', 'app07', 'app08', 'app09', 'app10', + "app01", + "app02", + "app03", + "app04", + "app05", + "app06", + "app07", + "app08", + "app09", + "app10", ]; const LoginForm = () => { @@ -20,8 +31,8 @@ const LoginForm = () => { const navigate = useNavigate(); const location = useLocation(); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -42,26 +53,23 @@ const LoginForm = () => { setError(null); setIsLoading(true); - try { - // First authenticate using the current (or first) appId to confirm credentials are valid - await setLoginCredentials({ appId: probeAppId, username, password }).unwrap(); - } catch { - setIsLoading(false); - setError('Invalid credentials. Please try again.'); - return; - } - - // Credentials are valid — now probe all slots in parallel to discover which are running + // Probe all slots in parallel — credentials are valid if at least one succeeds const results = await Promise.allSettled( ALL_APP_IDS.map((id) => - setLoginCredentials({ appId: id, username, password }).unwrap() - ) + setLoginCredentials({ appId: id, username, password }).unwrap(), + ), ); const availableApps = ALL_APP_IDS.filter( - (_, i) => results[i].status === 'fulfilled' + (_, i) => results[i].status === "fulfilled", ); + if (availableApps.length === 0) { + setIsLoading(false); + setError("Invalid credentials. Please try again."); + return; + } + setIsLoading(false); dispatch(authActions.loginSuccess(availableApps)); @@ -70,9 +78,14 @@ const LoginForm = () => { } return ( -
-

PepperDash Essentials Developer Tools

-
+
+ + Version: {APP_VERSION} + +

+ PepperDash Essentials Developer Tools +

+

Sign In

{error && {error}}
@@ -105,7 +118,7 @@ const LoginForm = () => { Signing in… ) : ( - 'Sign In' + "Sign In" )}
diff --git a/src/shared/FilterSearchText.tsx b/src/shared/FilterSearchText.tsx index 1cd6597..101015e 100644 --- a/src/shared/FilterSearchText.tsx +++ b/src/shared/FilterSearchText.tsx @@ -1,38 +1,59 @@ -import { ChangeEvent, useEffect, useState } from 'react'; +import { ChangeEvent, useEffect, useRef, useState } from 'react'; import { FormControl } from 'react-bootstrap'; import { useSearchParams } from 'react-router-dom'; export const FilterSearchText = ({ disabled, placeholder, + value: controlledValue, + onChangeValue, }: FilterSearchTextProps) => { /* HOOKS ***********************************************************/ - /** Debounce timer for search box */ - let searchTimerHandle: NodeJS.Timeout; - const PARAM = 'searchText'; + const timerRef = useRef | null>(null); + const PARAM = "searchText"; const [searchParams, setSearchParams] = useSearchParams(); - const [searchText, setSearchText] = useState(''); + const [searchText, setSearchText] = useState(controlledValue ?? ""); /* FUNCTIONS *******************************************************/ /** Handles search text change, after 1s debounce */ function searchTextChange(change: ChangeEvent) { setSearchText(change.target.value); - if (searchTimerHandle) clearTimeout(searchTimerHandle); - searchTimerHandle = setTimeout(() => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + timerRef.current = null; const val: string = change.target.value.trim(); - const tokens = val.split(' '); - searchParams.delete(PARAM); - if (val.length) tokens.forEach((t) => searchParams.append(PARAM, t)); - setSearchParams(searchParams); + + if (onChangeValue) { + // Controlled (Redux) mode — call the provided callback + onChangeValue(val); + } else { + // URL params mode (default) + const tokens = val.split(" "); + searchParams.delete(PARAM); + if (val.length) tokens.forEach((t) => searchParams.append(PARAM, t)); + setSearchParams(searchParams); + } }, 1000); } /* EFFECTS *********************************************************/ - /** Watch params for relevant changes and update dropdowns **/ + /** Clear any pending debounce timer on unmount */ + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + /** In URL-params mode, sync local state from params. In controlled mode, sync from prop. **/ useEffect(() => { - setSearchText(searchParams.getAll(PARAM).join(' ')); - }, [searchParams]); + if (onChangeValue) { + setSearchText(controlledValue ?? ""); + } else { + setSearchText(searchParams.getAll(PARAM).join(" ")); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controlledValue, searchParams]); /* RENDER **********************************************************/ return ( @@ -49,7 +70,20 @@ export const FilterSearchText = ({ ); }; -interface FilterSearchTextProps { +type FilterSearchTextBaseProps = { disabled?: boolean; placeholder?: string; -} +}; +type FilterSearchTextControlledProps = FilterSearchTextBaseProps & { + /** Controlled value (Redux mode). */ + value: string; + onChangeValue: (val: string) => void; +}; +type FilterSearchTextUncontrolledProps = FilterSearchTextBaseProps & { + value?: undefined; + onChangeValue?: undefined; +}; + +type FilterSearchTextProps = + | FilterSearchTextControlledProps + | FilterSearchTextUncontrolledProps; diff --git a/src/shared/ListFiltersHeader.tsx b/src/shared/ListFiltersHeader.tsx index c6c5d7c..86ca4af 100644 --- a/src/shared/ListFiltersHeader.tsx +++ b/src/shared/ListFiltersHeader.tsx @@ -7,14 +7,18 @@ const ListFiltersHeader = ({ groupBy, listTypeButtons, showSearch, + searchValue, + onSearchChange, rightContent, }: ListFiltersHeaderProps) => { return ( -
+
{showSearch && (
- + {onSearchChange !== undefined + ? + : }
)}
{filters}
@@ -37,6 +41,8 @@ export default ListFiltersHeader; interface ListFiltersHeaderProps { showSearch?: boolean; + searchValue?: string; + onSearchChange?: (val: string) => void; filters: ReactNode; groupBy?: ReactNode; listTypeButtons?: ReactNode;