diff --git a/backend/app/deps/authorization_deps.py b/backend/app/deps/authorization_deps.py index af6104e01..308406e4f 100644 --- a/backend/app/deps/authorization_deps.py +++ b/backend/app/deps/authorization_deps.py @@ -3,6 +3,7 @@ from app.models.datasets import DatasetDB, DatasetStatus from app.models.files import FileDB, FileStatus from app.models.groups import GroupDB +from app.models.listeners import EventListenerDB from app.models.metadata import MetadataDB from app.routers.authentication import get_admin, get_admin_mode from beanie import PydanticObjectId @@ -389,6 +390,41 @@ async def __call__( raise HTTPException(status_code=404, detail=f"Group {group_id} not found") +class ListenerAuthorization: + """We use class dependency so that we can provide the `permission` parameter to the dependency. + For more info see https://fastapi.tiangolo.com/advanced/advanced-dependencies/. + Regular users are not allowed to run non-active listeners""" + + # def __init__(self, optional_arg: str = None): + # self.optional_arg = optional_arg + + async def __call__( + self, + listener_id: str, + current_user: str = Depends(get_current_username), + admin_mode: bool = Depends(get_admin_mode), + admin: bool = Depends(get_admin), + ): + # If the current user is admin and has turned on admin_mode, user has access irrespective of any role assigned + if admin and admin_mode: + return True + + # Else check if listener is active or current user is the creator of the extractor + if ( + listener := await EventListenerDB.get(PydanticObjectId(listener_id)) + ) is not None: + if listener.active is True or ( + listener.creator and listener.creator.email == current_user + ): + return True + else: + raise HTTPException( + status_code=403, + detail=f"User `{current_user} does not have permission on listener `{listener_id}`", + ) + raise HTTPException(status_code=404, detail=f"Listener {listener_id} not found") + + class CheckStatus: """We use class dependency so that we can provide the `permission` parameter to the dependency. For more info see https://fastapi.tiangolo.com/advanced/advanced-dependencies/.""" diff --git a/backend/app/models/listeners.py b/backend/app/models/listeners.py index 2d855c6ff..1bd33789b 100644 --- a/backend/app/models/listeners.py +++ b/backend/app/models/listeners.py @@ -69,6 +69,7 @@ class EventListenerDB(Document, EventListenerBase): modified: datetime = Field(default_factory=datetime.now) lastAlive: datetime = None alive: Optional[bool] = None # made up field to indicate if extractor is alive + active: bool = False properties: Optional[ExtractorInfo] = None class Settings: diff --git a/backend/app/routers/feeds.py b/backend/app/routers/feeds.py index 42f9dc110..0500bc30a 100644 --- a/backend/app/routers/feeds.py +++ b/backend/app/routers/feeds.py @@ -1,11 +1,13 @@ from typing import List, Optional +from app.deps.authorization_deps import ListenerAuthorization from app.keycloak_auth import get_current_user, get_current_username from app.models.feeds import FeedDB, FeedIn, FeedOut from app.models.files import FileOut from app.models.listeners import EventListenerDB, FeedListener from app.models.users import UserOut from app.rabbitmq.listeners import submit_file_job +from app.routers.authentication import get_admin, get_admin_mode from app.search.connect import check_search_result from beanie import PydanticObjectId from fastapi import APIRouter, Depends, HTTPException @@ -23,7 +25,7 @@ async def disassociate_listener_db(feed_id: str, listener_id: str): if (feed := await FeedDB.get(PydanticObjectId(feed_id))) is not None: new_listeners = [] for feed_listener in feed.listeners: - if feed_listener.listener_id != listener_id: + if feed_listener.listener_id != PydanticObjectId(listener_id): new_listeners.append(feed_listener) feed.listeners = new_listeners await feed.save() @@ -122,6 +124,8 @@ async def associate_listener( feed_id: str, listener: FeedListener, user=Depends(get_current_user), + admin=Depends(get_admin), + admin_mode=Depends(get_admin_mode), ): """Associate an existing Event Listener with a Feed, e.g. so it will be triggered on new Feed results. @@ -131,22 +135,35 @@ async def associate_listener( """ if (feed := await FeedDB.get(PydanticObjectId(feed_id))) is not None: if ( - await EventListenerDB.get(PydanticObjectId(listener.listener_id)) + listener_db := await EventListenerDB.get( + PydanticObjectId(listener.listener_id) + ) ) is not None: - feed.listeners.append(listener) - await feed.save() - return feed.dict() + if ( + (admin and admin_mode) + or (listener_db.creator and listener_db.creator.email == user.email) + or listener_db.active + ): + feed.listeners.append(listener) + await feed.save() + return feed.dict() + else: + raise HTTPException( + status_code=403, + detail=f"User {user} doesn't have permission to submit job to listener {listener.listener_id}", + ) raise HTTPException( status_code=404, detail=f"listener {listener.listener_id} not found" ) raise HTTPException(status_code=404, detail=f"feed {feed_id} not found") -@router.delete("/{feed_id}/listeners/{listener_id}", response_model=FeedOut) +@router.delete("/{feed_id}/listeners/{listener_id}") async def disassociate_listener( feed_id: str, listener_id: str, user=Depends(get_current_user), + allow: bool = Depends(ListenerAuthorization()), ): """Disassociate an Event Listener from a Feed. diff --git a/backend/app/routers/listeners.py b/backend/app/routers/listeners.py index 3febfe633..949dea0f8 100644 --- a/backend/app/routers/listeners.py +++ b/backend/app/routers/listeners.py @@ -5,6 +5,7 @@ from typing import List, Optional from app.config import settings +from app.deps.authorization_deps import ListenerAuthorization from app.keycloak_auth import get_current_user, get_current_username, get_user from app.models.config import ConfigEntryDB from app.models.feeds import FeedDB, FeedListener @@ -18,6 +19,7 @@ from app.models.pages import Paged, _construct_page_metadata, _get_page_query from app.models.search import SearchCriteria from app.models.users import UserOut +from app.routers.authentication import get_admin, get_admin_mode from app.routers.feeds import disassociate_listener_db from beanie import PydanticObjectId from beanie.operators import Or, RegEx @@ -198,6 +200,8 @@ async def search_listeners( heartbeat_interval: Optional[int] = settings.listener_heartbeat_interval, user=Depends(get_current_username), process: Optional[str] = None, + admin=Depends(get_admin), + admin_mode=Depends(get_admin_mode), ): """Search all Event Listeners in the db based on text. @@ -221,6 +225,8 @@ async def search_listeners( aggregation_pipeline.append( {"$match": {"properties.process.dataset": {"$exists": True}}} ) + if not admin or not admin_mode: + aggregation_pipeline.append({"$match": {"active": True}}) # Add pagination aggregation_pipeline.append( _get_page_query(skip, limit, sort_field="name", ascending=True) @@ -260,7 +266,11 @@ async def list_default_labels(user=Depends(get_current_username)): @router.get("/{listener_id}", response_model=EventListenerOut) -async def get_listener(listener_id: str, user=Depends(get_current_username)): +async def get_listener( + listener_id: str, + user=Depends(get_current_username), + allow: bool = Depends(ListenerAuthorization()), +): """Return JSON information about an Event Listener if it exists.""" if ( listener := await EventListenerDB.get(PydanticObjectId(listener_id)) @@ -274,6 +284,7 @@ async def check_listener_livelihood( listener_id: str, heartbeat_interval: Optional[int] = settings.listener_heartbeat_interval, user=Depends(get_current_username), + allow: bool = Depends(ListenerAuthorization()), ): """Return JSON information about an Event Listener if it exists.""" if ( @@ -293,6 +304,9 @@ async def get_listeners( label: Optional[str] = None, alive_only: Optional[bool] = False, process: Optional[str] = None, + all: Optional[bool] = False, + admin=Depends(get_admin), + admin_mode=Depends(get_admin_mode), ): """Get a list of all Event Listeners in the db. @@ -303,6 +317,7 @@ async def get_listeners( category -- filter by category has to be exact match label -- filter by label has to be exact match alive_only -- filter by alive status + all -- boolean stating if we want to show all listeners irrespective of admin and admin_mode """ # First compute alive flag for all listeners aggregation_pipeline = [ @@ -325,6 +340,9 @@ async def get_listeners( aggregation_pipeline.append( {"$match": {"properties.process.dataset": {"$exists": True}}} ) + # Non admin users can access only active listeners unless all is turned on for Extractor page + if not all and (not admin or not admin_mode): + aggregation_pipeline.append({"$match": {"active": True}}) # Add pagination aggregation_pipeline.append( _get_page_query(skip, limit, sort_field="name", ascending=True) @@ -351,6 +369,7 @@ async def edit_listener( listener_id: str, listener_in: EventListenerIn, user_id=Depends(get_user), + allow: bool = Depends(ListenerAuthorization()), ): """Update the information about an existing Event Listener.. @@ -374,10 +393,62 @@ async def edit_listener( raise HTTPException(status_code=404, detail=f"listener {listener_id} not found") +@router.put("/{listener_id}/enable", response_model=EventListenerOut) +async def enable_listener( + listener_id: str, + user_id=Depends(get_user), + allow: bool = Depends(ListenerAuthorization()), +): + """Enable an Event Listener. Only admins can enable listeners. + + Arguments: + listener_id -- UUID of the listener to be enabled + """ + return await _set_active_flag(listener_id, True) + + +@router.put("/{listener_id}/disable", response_model=EventListenerOut) +async def disable_listener( + listener_id: str, + user_id=Depends(get_user), + allow: bool = Depends(ListenerAuthorization()), +): + """Disable an Event Listener. Only admins can enable listeners. + + Arguments: + listener_id -- UUID of the listener to be enabled + """ + return await _set_active_flag(listener_id, False) + + +async def _set_active_flag( + listener_id: str, + active: bool, + allow: bool = Depends(ListenerAuthorization()), +): + """Set the active flag of an Event Listener. Only admins can enable/disable listeners. + + Arguments: + listener_id -- UUID of the listener to be enabled/disabled + """ + listener = await EventListenerDB.find_one( + EventListenerDB.id == ObjectId(listener_id) + ) + if listener: + try: + listener.active = active + await listener.save() + return listener.dict() + except Exception as e: + raise HTTPException(status_code=500, detail=e.args[0]) + raise HTTPException(status_code=404, detail=f"listener {listener_id} not found") + + @router.delete("/{listener_id}") async def delete_listener( listener_id: str, user=Depends(get_current_username), + allow: bool = Depends(ListenerAuthorization()), ): """Remove an Event Listener from the database. Will not clear event history for the listener.""" listener = await EventListenerDB.find_one( diff --git a/backend/app/tests/test_extractors.py b/backend/app/tests/test_extractors.py index c3bcb72e8..ff27e4f3d 100644 --- a/backend/app/tests/test_extractors.py +++ b/backend/app/tests/test_extractors.py @@ -60,3 +60,23 @@ def test_v1_mime_trigger(client: TestClient, headers: dict): ) assert response.status_code == 200 assert len(response.json()) > 0 + + +def test_enable_disable_extractor(client: TestClient, headers: dict): + # create a new extractor + ext_name = "test.v1_extractor" + extractor_id = register_v1_extractor(client, headers, ext_name).get("id") + + # enable the extractor + response = client.put( + f"{settings.API_V2_STR}/listeners/{extractor_id}/enable", headers=headers + ) + assert response.status_code == 200 + assert response.json()["active"] is True + + # disable the extractor + response = client.put( + f"{settings.API_V2_STR}/listeners/{extractor_id}/disable", headers=headers + ) + assert response.status_code == 200 + assert response.json()["active"] is False diff --git a/frontend/src/actions/listeners.js b/frontend/src/actions/listeners.js index 41d24f42e..061a44d44 100644 --- a/frontend/src/actions/listeners.js +++ b/frontend/src/actions/listeners.js @@ -10,7 +10,8 @@ export function fetchListeners( category = null, label = null, aliveOnly = false, - process = null + process = null, + all = false ) { return (dispatch) => { // TODO: Parameters for dates? paging? @@ -21,7 +22,8 @@ export function fetchListeners( category, label, aliveOnly, - process + process, + all ) .then((json) => { dispatch({ @@ -49,6 +51,45 @@ export function fetchListeners( }; } +export const TOGGLE_ACTIVE_FLAG_LISTENER = "TOGGLE_ACTIVE_FLAG_LISTENER"; +export function enableListener(id) { + return (dispatch) => { + return V2.ListenersService.enableListenerApiV2ListenersListenerIdEnablePut( + id + ) + .then((json) => { + // We could have called fetchListeners but it would be an overhead since we are just toggling the active flag for one listener. + // Hence we create a separate action to update the particular listener in state + dispatch({ + type: TOGGLE_ACTIVE_FLAG_LISTENER, + listener: json, + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, enableListener(id))); + }); + }; +} + +export function disableListener(id) { + return (dispatch) => { + return V2.ListenersService.disableListenerApiV2ListenersListenerIdDisablePut( + id + ) + .then((json) => { + // We could have called fetchListeners but it would be an overhead since we are just toggling the active flag for one listener. + // Hence we create a separate action to update the particular listener in state + dispatch({ + type: TOGGLE_ACTIVE_FLAG_LISTENER, + listener: json, + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, disableListener(id))); + }); + }; +} + export const SEARCH_LISTENERS = "SEARCH_LISTENERS"; export function queryListeners( diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index fbf1e119d..0a99302c9 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -39,6 +39,7 @@ import { AdminPanelSettings } from "@mui/icons-material"; import ManageAccountsIcon from "@mui/icons-material/ManageAccounts"; import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings"; import { Footer } from "./navigation/Footer"; +import BuildIcon from "@mui/icons-material/Build"; const drawerWidth = 240; @@ -419,6 +420,17 @@ export default function PersistentDrawerLeft(props) { + + + + + + + + + + +
diff --git a/frontend/src/components/listeners/AllListeners.tsx b/frontend/src/components/listeners/AllListeners.tsx new file mode 100644 index 000000000..6d97535df --- /dev/null +++ b/frontend/src/components/listeners/AllListeners.tsx @@ -0,0 +1,402 @@ +import React, { ChangeEvent, useEffect, useState } from "react"; +import { + Box, + Divider, + FormControl, + FormControlLabel, + Grid, + Input, + InputLabel, + List, + MenuItem, + Pagination, + Paper, + Select, + Switch, + Tooltip, +} from "@mui/material"; + +import Table from "@mui/material/Table"; + +import { RootState } from "../../types/data"; +import { useDispatch, useSelector } from "react-redux"; +import { + fetchListenerCategories, + fetchListenerLabels, + fetchListeners, + queryListeners, + enableListener as enableListenerAction, + disableListener as disableListenerAction, +} from "../../actions/listeners"; +import ListenerItem from "./ListenerItem"; +import SubmitExtraction from "./SubmitExtraction"; +import { capitalize } from "../../utils/common"; +import config from "../../app.config"; +import { GenericSearchBox } from "../search/GenericSearchBox"; +import Layout from "../Layout"; +import TableCell from "@mui/material/TableCell"; +import TableRow from "@mui/material/TableRow"; +import TableBody from "@mui/material/TableBody"; +import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord"; + +type ListenerProps = { + process?: string; +}; + +export function AllListeners(props: ListenerProps) { + const { process } = props; + // Redux connect equivalent + const dispatch = useDispatch(); + const listListeners = ( + skip: number | undefined, + limit: number | undefined, + heartbeatInterval: number | undefined, + selectedCategory: string | null, + selectedLabel: string | null, + aliveOnly: boolean | undefined, + process: string | undefined + ) => + dispatch( + fetchListeners( + skip, + limit, + heartbeatInterval, + selectedCategory, + selectedLabel, + aliveOnly, + process, + true + ) + ); + const searchListeners = ( + text: string, + skip: number | undefined, + limit: number | undefined, + heartbeatInterval: number | undefined, + process: string | undefined + ) => dispatch(queryListeners(text, skip, limit, heartbeatInterval, process)); + const listAvailableCategories = () => dispatch(fetchListenerCategories()); + const listAvailableLabels = () => dispatch(fetchListenerLabels()); + + const enableListener = (id: string) => dispatch(enableListenerAction(id)); + const disableListener = (id: string) => dispatch(disableListenerAction(id)); + + const listeners = useSelector( + (state: RootState) => state.listener.listeners.data + ); + const pageMetadata = useSelector( + (state: RootState) => state.listener.listeners.metadata + ); + const categories = useSelector( + (state: RootState) => state.listener.categories + ); + const admin = useSelector((state: RootState) => state.user.profile.admin); + const adminMode = useSelector((state: RootState) => state.user.adminMode); + const labels = useSelector((state: RootState) => state.listener.labels); + + const [currPageNum, setCurrPageNum] = useState(1); + const [limit] = useState(config.defaultExtractors); + const [openSubmitExtraction, setOpenSubmitExtraction] = + useState(false); + const [infoOnly, setInfoOnly] = useState(true); + const [selectedExtractor, setSelectedExtractor] = useState(); + const [searchText, setSearchText] = useState(""); + const [selectedCategory, setSelectedCategory] = useState(""); + const [selectedLabel, setSelectedLabel] = useState(""); + const [aliveOnly, setAliveOnly] = useState(false); + + // component did mount + useEffect(() => { + listListeners(0, limit, 0, null, null, aliveOnly, process); + listAvailableCategories(); + listAvailableLabels(); + }, [adminMode]); + + // search + useEffect(() => { + // reset page and reset category with each new search term + setCurrPageNum(1); + setSelectedCategory(""); + + if (searchText !== "") searchListeners(searchText, 0, limit, 0, process); + else + listListeners( + 0, + limit, + 0, + selectedCategory, + selectedLabel, + aliveOnly, + process + ); + }, [searchText, adminMode]); + + useEffect(() => { + // reset page and reset search text with each new search term + setCurrPageNum(1); + setSearchText(""); + listListeners( + 0, + limit, + 0, + selectedCategory, + selectedLabel, + aliveOnly, + process + ); + }, [aliveOnly, adminMode]); + + // any of the change triggers timer to fetch the extractor status + useEffect(() => { + if (searchText !== "") { + const interval = setInterval(() => { + handleListenerSearch(); + }, config.extractorLivelihoodInterval); + return () => clearInterval(interval); + } else { + // set the interval to fetch the job's log + const interval = setInterval(() => { + listListeners( + (currPageNum - 1) * limit, + limit, + 0, + selectedCategory, + selectedLabel, + aliveOnly, + process + ); + }, config.extractorLivelihoodInterval); + return () => clearInterval(interval); + } + }, [ + searchText, + listeners, + currPageNum, + selectedCategory, + selectedLabel, + aliveOnly, + adminMode, + ]); + + const handleListenerSearch = () => { + setSelectedCategory(""); + searchListeners(searchText, (currPageNum - 1) * limit, limit, 0, process); + }; + + const handleCategoryChange = (event: React.ChangeEvent) => { + const selectedCategoryValue = (event.target as HTMLInputElement).value; + setSelectedCategory(selectedCategoryValue); + setSearchText(""); + listListeners( + 0, + limit, + 0, + selectedCategoryValue, + selectedLabel, + aliveOnly, + process + ); + }; + + const handleLabelChange = (event: React.ChangeEvent) => { + const selectedLabelValue = (event.target as HTMLInputElement).value; + setSelectedLabel(selectedLabelValue); + setSearchText(""); + listListeners( + 0, + limit, + 0, + selectedCategory, + selectedLabelValue, + aliveOnly, + process + ); + }; + + const handlePageChange = (_: ChangeEvent, value: number) => { + const newSkip = (value - 1) * limit; + setCurrPageNum(value); + if (searchText !== "") + searchListeners(searchText, newSkip, limit, 0, process); + else listListeners(newSkip, limit, 0, null, null, aliveOnly, process); + }; + + const handleSubmitExtractionClose = () => { + // Cleanup the form + setOpenSubmitExtraction(false); + }; + + return ( + +
+ + + {/*searchbox*/} + + + + + {/*categories*/} + + + Filter by category + + + + + + Filter by labels + + + + + { + setAliveOnly(!aliveOnly); + }} + /> + } + label="Alive Extractors" + /> + + + + + + + + {listeners !== undefined ? ( + listeners.map((listener) => { + return ( + + + + + + {/* extractor["alive"] ? (*/} + {/* */} + {/* */} + {/* */} + {/*) : (*/} + {/* */} + {/* */} + {/* */} + {admin && adminMode ? ( + { + if (listener.active) { + disableListener(listener.id); + } else { + enableListener(listener.id); + } + }} + /> + ) : ( + + + + )} + + + + ); + }) + ) : ( + <> + )} + + + + +
+
+
+
+ +
+
+ ); +} diff --git a/frontend/src/components/listeners/ListenerItem.tsx b/frontend/src/components/listeners/ListenerItem.tsx index b149ded1a..473e8419d 100644 --- a/frontend/src/components/listeners/ListenerItem.tsx +++ b/frontend/src/components/listeners/ListenerItem.tsx @@ -15,6 +15,7 @@ type ListenerCardProps = { setOpenSubmitExtraction: any; setInfoOnly: any; setSelectedExtractor: any; + showSubmit: boolean; }; export default function ListenerItem(props: ListenerCardProps) { @@ -28,6 +29,7 @@ export default function ListenerItem(props: ListenerCardProps) { setOpenSubmitExtraction, setInfoOnly, setSelectedExtractor, + showSubmit, } = props; return ( @@ -91,20 +93,22 @@ export default function ListenerItem(props: ListenerCardProps) { margin: "auto", }} > - { - setOpenSubmitExtraction(true); - setSelectedExtractor(extractor); - setInfoOnly(false); - }} - > - - + {showSubmit && ( + { + setOpenSubmitExtraction(true); + setSelectedExtractor(extractor); + setInfoOnly(false); + }} + > + + + )} ); diff --git a/frontend/src/components/listeners/Listeners.tsx b/frontend/src/components/listeners/Listeners.tsx index 4556bb40d..e7c2f7d01 100644 --- a/frontend/src/components/listeners/Listeners.tsx +++ b/frontend/src/components/listeners/Listeners.tsx @@ -78,6 +78,7 @@ export function Listeners(props: ListenerProps) { (state: RootState) => state.listener.categories ); const labels = useSelector((state: RootState) => state.listener.labels); + const adminMode = useSelector((state: RootState) => state.user.adminMode); const [currPageNum, setCurrPageNum] = useState(1); const [limit] = useState(config.defaultExtractors); @@ -95,7 +96,7 @@ export function Listeners(props: ListenerProps) { listListeners(0, limit, 0, null, null, aliveOnly, process); listAvailableCategories(); listAvailableLabels(); - }, []); + }, [adminMode]); // search useEffect(() => { @@ -114,7 +115,7 @@ export function Listeners(props: ListenerProps) { aliveOnly, process ); - }, [searchText]); + }, [searchText, adminMode]); useEffect(() => { // reset page and reset search text with each new search term @@ -129,7 +130,7 @@ export function Listeners(props: ListenerProps) { aliveOnly, process ); - }, [aliveOnly]); + }, [aliveOnly, adminMode]); // any of the change triggers timer to fetch the extractor status useEffect(() => { @@ -160,6 +161,7 @@ export function Listeners(props: ListenerProps) { selectedCategory, selectedLabel, aliveOnly, + adminMode, ]); const handleListenerSearch = () => { @@ -305,6 +307,7 @@ export function Listeners(props: ListenerProps) { setInfoOnly={setInfoOnly} setOpenSubmitExtraction={setOpenSubmitExtraction} setSelectedExtractor={setSelectedExtractor} + showSubmit={true} /> diff --git a/frontend/src/openapi/v2/models/EventListenerOut.ts b/frontend/src/openapi/v2/models/EventListenerOut.ts index a6a57f20f..0e7581766 100644 --- a/frontend/src/openapi/v2/models/EventListenerOut.ts +++ b/frontend/src/openapi/v2/models/EventListenerOut.ts @@ -18,5 +18,6 @@ export type EventListenerOut = { modified?: string; lastAlive?: string; alive?: boolean; + active?: boolean; properties?: ExtractorInfo; } diff --git a/frontend/src/openapi/v2/services/FeedsService.ts b/frontend/src/openapi/v2/services/FeedsService.ts index 03e1fde19..18301925e 100644 --- a/frontend/src/openapi/v2/services/FeedsService.ts +++ b/frontend/src/openapi/v2/services/FeedsService.ts @@ -105,16 +105,21 @@ export class FeedsService { * listener: JSON object with "listener_id" field and "automatic" bool field (whether to auto-trigger on new data) * @param feedId * @param requestBody + * @param datasetId * @returns FeedOut Successful Response * @throws ApiError */ public static associateListenerApiV2FeedsFeedIdListenersPost( feedId: string, requestBody: FeedListener, + datasetId?: string, ): CancelablePromise { return __request({ method: 'POST', path: `/api/v2/feeds/${feedId}/listeners`, + query: { + 'dataset_id': datasetId, + }, body: requestBody, mediaType: 'application/json', errors: { @@ -132,16 +137,21 @@ export class FeedsService { * listener_id: UUID of Event Listener that should be disassociated * @param feedId * @param listenerId - * @returns FeedOut Successful Response + * @param datasetId + * @returns any Successful Response * @throws ApiError */ public static disassociateListenerApiV2FeedsFeedIdListenersListenerIdDelete( feedId: string, listenerId: string, - ): CancelablePromise { + datasetId?: string, + ): CancelablePromise { return __request({ method: 'DELETE', path: `/api/v2/feeds/${feedId}/listeners/${listenerId}`, + query: { + 'dataset_id': datasetId, + }, errors: { 422: `Validation Error`, }, diff --git a/frontend/src/openapi/v2/services/ListenersService.ts b/frontend/src/openapi/v2/services/ListenersService.ts index d0fa80add..bc64e818e 100644 --- a/frontend/src/openapi/v2/services/ListenersService.ts +++ b/frontend/src/openapi/v2/services/ListenersService.ts @@ -32,6 +32,7 @@ export class ListenersService { * category -- filter by category has to be exact match * label -- filter by label has to be exact match * alive_only -- filter by alive status + * all -- boolean stating if we want to show all listeners irrespective of admin and admin_mode * @param skip * @param limit * @param heartbeatInterval @@ -39,6 +40,8 @@ export class ListenersService { * @param label * @param aliveOnly * @param process + * @param all + * @param datasetId * @returns Paged Successful Response * @throws ApiError */ @@ -50,6 +53,8 @@ export class ListenersService { label?: string, aliveOnly: boolean = false, process?: string, + all: boolean = false, + datasetId?: string, ): CancelablePromise { return __request({ method: 'GET', @@ -62,6 +67,8 @@ export class ListenersService { 'label': label, 'alive_only': aliveOnly, 'process': process, + 'all': all, + 'dataset_id': datasetId, }, errors: { 422: `Validation Error`, @@ -103,6 +110,7 @@ export class ListenersService { * @param limit * @param heartbeatInterval * @param process + * @param datasetId * @returns Paged Successful Response * @throws ApiError */ @@ -112,6 +120,7 @@ export class ListenersService { limit: number = 2, heartbeatInterval: number = 300, process?: string, + datasetId?: string, ): CancelablePromise { return __request({ method: 'GET', @@ -122,6 +131,7 @@ export class ListenersService { 'limit': limit, 'heartbeat_interval': heartbeatInterval, 'process': process, + 'dataset_id': datasetId, }, errors: { 422: `Validation Error`, @@ -159,15 +169,20 @@ export class ListenersService { * Get Listener * Return JSON information about an Event Listener if it exists. * @param listenerId + * @param datasetId * @returns EventListenerOut Successful Response * @throws ApiError */ public static getListenerApiV2ListenersListenerIdGet( listenerId: string, + datasetId?: string, ): CancelablePromise { return __request({ method: 'GET', path: `/api/v2/listeners/${listenerId}`, + query: { + 'dataset_id': datasetId, + }, errors: { 422: `Validation Error`, }, @@ -183,16 +198,21 @@ export class ListenersService { * listener_in -- JSON object including updated information * @param listenerId * @param requestBody + * @param datasetId * @returns EventListenerOut Successful Response * @throws ApiError */ public static editListenerApiV2ListenersListenerIdPut( listenerId: string, requestBody: EventListenerIn, + datasetId?: string, ): CancelablePromise { return __request({ method: 'PUT', path: `/api/v2/listeners/${listenerId}`, + query: { + 'dataset_id': datasetId, + }, body: requestBody, mediaType: 'application/json', errors: { @@ -205,15 +225,20 @@ export class ListenersService { * Delete Listener * Remove an Event Listener from the database. Will not clear event history for the listener. * @param listenerId + * @param datasetId * @returns any Successful Response * @throws ApiError */ public static deleteListenerApiV2ListenersListenerIdDelete( listenerId: string, + datasetId?: string, ): CancelablePromise { return __request({ method: 'DELETE', path: `/api/v2/listeners/${listenerId}`, + query: { + 'dataset_id': datasetId, + }, errors: { 422: `Validation Error`, }, @@ -225,18 +250,75 @@ export class ListenersService { * Return JSON information about an Event Listener if it exists. * @param listenerId * @param heartbeatInterval + * @param datasetId * @returns boolean Successful Response * @throws ApiError */ public static checkListenerLivelihoodApiV2ListenersListenerIdStatusGet( listenerId: string, heartbeatInterval: number = 300, + datasetId?: string, ): CancelablePromise { return __request({ method: 'GET', path: `/api/v2/listeners/${listenerId}/status`, query: { 'heartbeat_interval': heartbeatInterval, + 'dataset_id': datasetId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Enable Listener + * Enable an Event Listener. Only admins can enable listeners. + * + * Arguments: + * listener_id -- UUID of the listener to be enabled + * @param listenerId + * @param datasetId + * @returns EventListenerOut Successful Response + * @throws ApiError + */ + public static enableListenerApiV2ListenersListenerIdEnablePut( + listenerId: string, + datasetId?: string, + ): CancelablePromise { + return __request({ + method: 'PUT', + path: `/api/v2/listeners/${listenerId}/enable`, + query: { + 'dataset_id': datasetId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Disable Listener + * Disable an Event Listener. Only admins can enable listeners. + * + * Arguments: + * listener_id -- UUID of the listener to be enabled + * @param listenerId + * @param datasetId + * @returns EventListenerOut Successful Response + * @throws ApiError + */ + public static disableListenerApiV2ListenersListenerIdDisablePut( + listenerId: string, + datasetId?: string, + ): CancelablePromise { + return __request({ + method: 'PUT', + path: `/api/v2/listeners/${listenerId}/disable`, + query: { + 'dataset_id': datasetId, }, errors: { 422: `Validation Error`, diff --git a/frontend/src/reducers/listeners.ts b/frontend/src/reducers/listeners.ts index 935288b58..bd51e6fac 100644 --- a/frontend/src/reducers/listeners.ts +++ b/frontend/src/reducers/listeners.ts @@ -5,6 +5,7 @@ import { RECEIVE_LISTENER_JOBS, RECEIVE_LISTENER_LABELS, RECEIVE_LISTENERS, + TOGGLE_ACTIVE_FLAG_LISTENER, RESET_JOB_SUMMARY, RESET_JOB_UPDATES, SEARCH_LISTENERS, @@ -29,6 +30,25 @@ const listeners = (state = defaultState, action: DataAction) => { switch (action.type) { case RECEIVE_LISTENERS: return Object.assign({}, state, { listeners: action.listeners }); + case TOGGLE_ACTIVE_FLAG_LISTENER: + // @ts-ignore + // eslint-disable-next-line no-case-declarations + const updatedListeners = state.listeners.data.map((listener) => { + // Check if the current listener matches the one being toggled + if (listener.id === action.listener.id) { + // Toggle the active flag of the matched item + action.listener.alive = listener.alive; + return action.listener; + } else return listener; + }); + return Object.assign({}, state, { + listeners: { + metadata: state.listeners.metadata, + data: updatedListeners, + }, + }); + + //return { ...state, listeners: updatedListeners }; case SEARCH_LISTENERS: return Object.assign({}, state, { listeners: action.listeners }); case RECEIVE_LISTENER_CATEGORIES: diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 0e2cca00b..b709d770b 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -41,6 +41,7 @@ import { ManageUsers } from "./components/users/ManageUsers"; import config from "./app.config"; import { MetadataDefinitions } from "./components/metadata/MetadataDefinitions"; import { MetadataDefinitionEntry } from "./components/metadata/MetadataDefinitionEntry"; +import { AllListeners } from "./components/listeners/AllListeners"; // https://dev.to/iamandrewluca/private-route-in-react-router-v6-lg5 const PrivateRoute = (props): JSX.Element => { @@ -231,6 +232,14 @@ export const AppRoutes = (): JSX.Element => { } /> + + + + } + />