Skip to content
Merged
26 changes: 19 additions & 7 deletions src/features/ConfigFile.tsx
Original file line number Diff line number Diff line change
@@ -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<OnMount>[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 <div>Config Data Loading or Not Available</div>;
}

return <ConfigFileRender config={config} />;
return (
<div className="d-flex flex-column h-100">
<div className="mb-2 d-flex justify-content-end">
<Button variant="outline-secondary" size="sm" onClick={refetch} disabled={isFetching}>
{isFetching ? 'Refreshing…' : 'Refresh Config'}
</Button>
</div>
<div className="flex-grow-1 overflow-hidden">
<ConfigFileRender config={config} />
</div>
</div>
);
};

export default ConfigFile;
Expand Down
19 changes: 14 additions & 5 deletions src/features/DebugConsole/DebugConsole.tsx
Original file line number Diff line number Diff line change
@@ -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';
Comment thread
ndorin marked this conversation as resolved.
import {
Expand All @@ -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';
Expand All @@ -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
Comment thread
ndorin marked this conversation as resolved.
? new URL(failedUrl).origin.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:')
: null;
Expand Down Expand Up @@ -119,7 +123,12 @@ const DebugConsole = ({isConnected, join, stop, clear}: DebugConsoleProps) => {
{', accept the certificate, then try "Start Debug Session" again.'}
</Alert>
)}
<ListFiltersHeader showSearch filters={<DebugFilters />} />
<ListFiltersHeader
showSearch
searchValue={searchText}
onSearchChange={(val) => dispatch(debugConsoleActions.setSearchText(val))}
filters={<DebugFilters />}
/>
<ConsoleWindow filteredItems={filteredItems}/>
</div>

Expand Down
71 changes: 42 additions & 29 deletions src/features/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -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";
Comment thread
ndorin marked this conversation as resolved.
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 = () => {
Expand All @@ -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<string | null>(null);
const [isLoading, setIsLoading] = useState(false);

Expand All @@ -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));

Expand All @@ -70,9 +78,14 @@ const LoginForm = () => {
}

return (
<div className="d-flex flex-column justify-content-center align-items-center h-100">
<h1 className="mb-5 text-center">PepperDash Essentials Developer Tools</h1>
<div className="w-100" style={{ maxWidth: '360px' }}>
<div className="d-flex flex-column justify-content-center align-items-center h-100 position-relative">
<span className="position-absolute top-0 end-0 p-2 text-muted small">
Version: {APP_VERSION}
</span>
<h1 className="mb-5 text-center">
PepperDash Essentials Developer Tools
</h1>
<div className="w-100" style={{ maxWidth: "360px" }}>
<h2 className="mb-4">Sign In</h2>
{error && <Alert variant="danger">{error}</Alert>}
<Form onSubmit={handleSubmit}>
Expand Down Expand Up @@ -105,7 +118,7 @@ const LoginForm = () => {
Signing in…
</>
) : (
'Sign In'
"Sign In"
)}
</Button>
</Form>
Expand Down
66 changes: 50 additions & 16 deletions src/shared/FilterSearchText.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof setTimeout> | null>(null);
const PARAM = "searchText";
const [searchParams, setSearchParams] = useSearchParams();
const [searchText, setSearchText] = useState<string>('');
const [searchText, setSearchText] = useState<string>(controlledValue ?? "");

Comment thread
ndorin marked this conversation as resolved.
/* FUNCTIONS *******************************************************/
/** Handles search text change, after 1s debounce */
function searchTextChange(change: ChangeEvent<HTMLInputElement>) {
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(" ");
Comment thread
ndorin marked this conversation as resolved.
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]);
Comment thread
ndorin marked this conversation as resolved.
Comment thread
ndorin marked this conversation as resolved.

/* RENDER **********************************************************/
return (
Expand All @@ -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;
10 changes: 8 additions & 2 deletions src/shared/ListFiltersHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@ const ListFiltersHeader = ({
groupBy,
listTypeButtons,
showSearch,
searchValue,
onSearchChange,
rightContent,
}: ListFiltersHeaderProps) => {
return (
<div className="d-flex justify-content-between mb-2 user-select-none align-items-center flex-nowrap">
<div className="ps-2 d-flex justify-content-between mb-2 user-select-none align-items-center flex-nowrap">
<div className="row row-cols-sm-auto g-3 user-select-none flex-nowrap">
{showSearch && (
<div className="col-8">
<FilterSearchText />
{onSearchChange !== undefined
? <FilterSearchText value={searchValue ?? ''} onChangeValue={onSearchChange} />
: <FilterSearchText />}
</div>
Comment thread
ndorin marked this conversation as resolved.
)}
<div className="col-16 d-none d-lg-block">{filters}</div>
Expand All @@ -37,6 +41,8 @@ export default ListFiltersHeader;

interface ListFiltersHeaderProps {
showSearch?: boolean;
searchValue?: string;
onSearchChange?: (val: string) => void;
filters: ReactNode;
groupBy?: ReactNode;
listTypeButtons?: ReactNode;
Expand Down