From cb9824f83a27ae7099f70ddb755ed408b9c439bd Mon Sep 17 00:00:00 2001 From: Martastain Date: Mon, 25 Dec 2023 13:18:55 +0100 Subject: [PATCH 01/64] fixed some docstrings --- backend/api/auth.py | 25 ++++++++++++++++++++----- backend/api/delete.py | 2 +- backend/api/jobs/jobs.py | 7 +++---- backend/api/services.py | 4 +--- backend/api/sessions.py | 15 +++++++++++---- backend/api/set.py | 10 ++++++---- backend/api/solve.py | 4 +++- backend/nebula/version.py | 1 - backend/server/endpoints.py | 2 +- 9 files changed, 46 insertions(+), 24 deletions(-) diff --git a/backend/api/auth.py b/backend/api/auth.py index 8830a5ee..4bdd5f7d 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -66,16 +66,19 @@ async def check_failed_login(ip_address: str) -> None: async def set_failed_login(ip_address: str): ns = "login-failed-ip" - failed_attempts = await nebula.redis.incr(ns, ip_address) + failed_attempts_str = await nebula.redis.incr(ns, ip_address) + failed_attempts = int(failed_attempts_str) if failed_attempts_str else 0 + await nebula.redis.expire( ns, ip_address, 600 ) # this is just for the clean-up, it cannot be used to reset the counter if failed_attempts > nebula.config.max_failed_login_attempts: + ban_time = nebula.config.failed_login_ban_time or 0 await nebula.redis.set( "banned-ip-until", ip_address, - time.time() + nebula.config.failed_login_ban_time, + str(time.time() + ban_time), ) @@ -84,7 +87,15 @@ async def clear_failed_login(ip_address: str): class LoginRequest(APIRequest): - """Login using a username and password""" + """Login using a username and password + + This request will return an access token that can be used in the + Authorization header for the subsequent requests. + If the login fails, request will return 401 Unauthorized. + + If the login fails too many (configurable) times, + the IP address will be banned for a certain amount of time (configurable). + """ name: str = "login" response_model = LoginResponseModel @@ -94,6 +105,7 @@ async def handle( request: Request, payload: LoginRequestModel, ) -> LoginResponseModel: + if request is not None: await check_failed_login(get_real_ip(request)) @@ -113,7 +125,10 @@ async def handle( class LogoutRequest(APIRequest): - """Log out the current user""" + """Log out the current user. + + This request will invalidate the access token used in the Authorization header. + """ name: str = "logout" title: str = "Logout" @@ -144,7 +159,7 @@ async def handle( self, request: PasswordRequestModel, user: CurrentUser, - ): + ) -> Response: if request.login: if not user.is_admin: raise nebula.UnauthorizedException( diff --git a/backend/api/delete.py b/backend/api/delete.py index 18f14436..ef811efc 100644 --- a/backend/api/delete.py +++ b/backend/api/delete.py @@ -69,7 +69,7 @@ async def handle( case _: # do not delete bins directly raise nebula.NotImplementedException( - f"Deleting {request.obejct_type} is not implemented" + f"Deleting {request.object_type} is not implemented" ) # Delete simple objects diff --git a/backend/api/jobs/jobs.py b/backend/api/jobs/jobs.py index 8ac9d3d7..346142cd 100644 --- a/backend/api/jobs/jobs.py +++ b/backend/api/jobs/jobs.py @@ -182,18 +182,17 @@ async def set_priority(id_job: int, priority: int, user: nebula.User) -> None: class JobsRequest(APIRequest): - """List and control jobs""" + """Get list of jobs, abort or restart them""" name: str = "jobs" - title: str = "Get list of jobs, abort or restart them" + title: str = "List and control jobs" response_model = JobsResponseModel async def handle( self, request: JobsRequestModel, user: CurrentUser, - ) -> JobsResponseModel: - + ) -> JobsResponseModel | Response: if request.abort: await abort_job(request.abort, user) diff --git a/backend/api/services.py b/backend/api/services.py index 70e95da9..67b5e8d2 100644 --- a/backend/api/services.py +++ b/backend/api/services.py @@ -45,7 +45,7 @@ class ServicesResponseModel(ResponseModel): class Request(APIRequest): - """Get a list of objects""" + """List and control installed services.""" name: str = "services" title: str = "Service control" @@ -56,8 +56,6 @@ async def handle( request: ServiceRequestModel, user: CurrentUser, ) -> ServicesResponseModel: - """List and control installed services.""" - if request.stop: nebula.log.info(f"Stopping service {request.stop}", user=user.name) await nebula.db.execute( diff --git a/backend/api/sessions.py b/backend/api/sessions.py index 42800d7f..c598e3ea 100644 --- a/backend/api/sessions.py +++ b/backend/api/sessions.py @@ -12,6 +12,8 @@ class SessionsRequest(RequestModel): class Sessions(APIRequest): + """List user sessions.""" + name = "sessions" title = "List sessions" response_model = list[SessionModel] @@ -21,7 +23,6 @@ async def handle( request: SessionsRequest, user: CurrentUser, ) -> list[SessionModel]: - """Create or update an object.""" id_user = request.id_user @@ -46,16 +47,22 @@ class InvalidateSessionRequest(RequestModel): class InvalidateSession(APIRequest): + """Invalidate a user session. + + This endpoint is used to invalidate an user session. It can be used + to remotely log out a user. If the user is an admin, it can also be + used to log out other users. + """ + name = "invalidate_session" - title = "Invalidate session" + title = "Invalidate a session" responses = [204, 201] async def handle( self, payload: InvalidateSessionRequest, user: CurrentUser, - ) -> None: - """Create or update an object.""" + ) -> Response: session = await Session.check(payload.token) if session is None: diff --git a/backend/api/set.py b/backend/api/set.py index 25934bd3..ac7bafc5 100644 --- a/backend/api/set.py +++ b/backend/api/set.py @@ -146,8 +146,10 @@ async def can_modify_object(obj, user: nebula.User): class OperationsRequest(APIRequest): + """Create or update multiple objects in one requests.""" + name: str = "ops" - title: str = "Create / update multiple objects at once" + title: str = "Save multiple objects" response_model = OperationsResponseModel async def handle( @@ -155,7 +157,6 @@ async def handle( request: OperationsRequestModel, user: CurrentUser, ) -> OperationsResponseModel: - """Create or update multiple objects in one requests.""" pool = await nebula.db.pool() result = [] @@ -264,8 +265,10 @@ async def handle( class SetRequest(APIRequest): + """Create or update an object.""" + name = "set" - title = "Create or update an object" + title = "Save an object" response_model = OperationResponseModel async def handle( @@ -273,7 +276,6 @@ async def handle( request: OperationModel, user: CurrentUser, ) -> OperationResponseModel: - """Create or update an object.""" operation = OperationsRequest() result = await operation.handle( diff --git a/backend/api/solve.py b/backend/api/solve.py index 074fb81c..aef79a1a 100644 --- a/backend/api/solve.py +++ b/backend/api/solve.py @@ -53,7 +53,7 @@ class SolveRequestModel(RequestModel): class Request(APIRequest): - """Browse the assets database.""" + """Solve a rundown placeholder""" name: str = "solve" responses: list[int] = [200] @@ -63,6 +63,8 @@ async def handle( request: SolveRequestModel, user: CurrentUser, ) -> Response: + # TODO: check permissions + assert user is not None solver = get_solver(request.solver) diff --git a/backend/nebula/version.py b/backend/nebula/version.py index f8d2069e..58e57409 100644 --- a/backend/nebula/version.py +++ b/backend/nebula/version.py @@ -1,2 +1 @@ __version__ = "6.0.3" - diff --git a/backend/server/endpoints.py b/backend/server/endpoints.py index 02d17ac2..66266ef8 100644 --- a/backend/server/endpoints.py +++ b/backend/server/endpoints.py @@ -101,7 +101,7 @@ def install_endpoints(app: fastapi.FastAPI): app.router.add_api_route( route, endpoint.handle, # type: ignore - name=endpoint.name, + name=endpoint.title or endpoint.name, operation_id=slugify(endpoint.name, separator="_"), methods=endpoint.methods, description=docstring, From cdb1bbe05941b46ecff5441eea521e2cdaa25994 Mon Sep 17 00:00:00 2001 From: Martastain Date: Wed, 3 Jan 2024 21:37:57 +0100 Subject: [PATCH 02/64] version bump to 6.0.4 --- backend/nebula/version.py | 3 +-- backend/pyproject.toml | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/backend/nebula/version.py b/backend/nebula/version.py index f8d2069e..bb788580 100644 --- a/backend/nebula/version.py +++ b/backend/nebula/version.py @@ -1,2 +1 @@ -__version__ = "6.0.3" - +__version__ = "6.0.4" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 97366b46..c486d8a9 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "nebula" -version = "6.0.3" +version = "6.0.4" description = "Open source broadcast automation system" authors = ["Nebula Broadcast "] @@ -28,8 +28,8 @@ gunicorn = "^20.1.0" [tool.poetry.dev-dependencies] pytest = "^7.0" black = "^22.1.0" -mypy = "^0.941" -ruff = "^0.1.3" +mypy = "^1.8" +ruff = "^0.1.9" pytest-asyncio = "^0.20.3" [tool.ruff] From 46e420a94ed53b57895533c320e0bf31c21ef534 Mon Sep 17 00:00:00 2001 From: Martastain Date: Wed, 3 Jan 2024 21:38:13 +0100 Subject: [PATCH 03/64] typing fixes --- backend/api/auth.py | 2 +- backend/nebula/redis.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/api/auth.py b/backend/api/auth.py index 8830a5ee..0e166939 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -75,7 +75,7 @@ async def set_failed_login(ip_address: str): await nebula.redis.set( "banned-ip-until", ip_address, - time.time() + nebula.config.failed_login_ban_time, + str(int(time.time() + nebula.config.failed_login_ban_time)), ) diff --git a/backend/nebula/redis.py b/backend/nebula/redis.py index 531cdbb8..128010e3 100644 --- a/backend/nebula/redis.py +++ b/backend/nebula/redis.py @@ -1,7 +1,7 @@ from typing import Any from redis import asyncio as aioredis -from redis.client import PubSub +from redis.asyncio.client import PubSub from nebula.config import config from nebula.log import log @@ -27,7 +27,7 @@ async def connect(cls) -> None: else: cls.connected = True return - cls.connected = False + cls.connected = False raise ConnectionError("Redis is not connected") @classmethod @@ -59,7 +59,7 @@ async def delete(cls, namespace: str, key: str) -> None: await cls.redis_pool.delete(f"{namespace}-{key}") @classmethod - async def incr(cls, namespace: str, key: str) -> None: + async def incr(cls, namespace: str, key: str) -> int: """Increment a value in Redis""" if not cls.connected: await cls.connect() From 815cc52a3a786ac4e402cf7270a458e6bb031438 Mon Sep 17 00:00:00 2001 From: Martastain Date: Thu, 4 Jan 2024 08:19:37 +0100 Subject: [PATCH 04/64] reduce server start-up verbosity --- backend/server/endpoints.py | 2 +- backend/server/server.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/server/endpoints.py b/backend/server/endpoints.py index 02d17ac2..e0cc9186 100644 --- a/backend/server/endpoints.py +++ b/backend/server/endpoints.py @@ -74,7 +74,7 @@ def install_endpoints(app: fastapi.FastAPI): endpoint_names.add(endpoint.name) route = endpoint.path or f"/api/{endpoint.name}" - nebula.log.debug("Adding endpoint", route) + nebula.log.trace("Adding endpoint", route) additional_params = {} diff --git a/backend/server/server.py b/backend/server/server.py index c26354c4..f630d58e 100644 --- a/backend/server/server.py +++ b/backend/server/server.py @@ -153,7 +153,7 @@ def install_frontend_plugins(app: FastAPI): if not os.path.isdir(plugin_path): continue - nebula.log.debug(f"Mounting frontend plugin {plugin_name}: {plugin_path}") + nebula.log.trace(f"Mounting frontend plugin {plugin_name}: {plugin_path}") app.mount( f"/plugins/{plugin_name}", StaticFiles(directory=plugin_path, html=True), From 8fad457574b05f77e43de449ba9e6040c92843f8 Mon Sep 17 00:00:00 2001 From: Martastain Date: Tue, 9 Jan 2024 23:10:17 +0100 Subject: [PATCH 05/64] support passing api key as query parameter --- backend/server/dependencies.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/server/dependencies.py b/backend/server/dependencies.py index 95e860b7..cdce3e78 100644 --- a/backend/server/dependencies.py +++ b/backend/server/dependencies.py @@ -18,9 +18,12 @@ async def access_token(authorization: str = Header(None)) -> str | None: AccessToken = Annotated[str | None, Depends(access_token)] -async def api_key(x_api_key: str | None = Header(None)) -> str | None: - """Return the API key provided in the request headers.""" - return x_api_key +async def api_key( + x_api_key: str | None = Header(None), + api_key: str | None = Query(None), +) -> str | None: + """Return the API key provided in the request headers or query parameters.""" + return api_key or x_api_key ApiKey = Annotated[str | None, Depends(api_key)] From 88309df38be8a7f3b56eefb34cbc6a9fec8f9c4b Mon Sep 17 00:00:00 2001 From: Martastain Date: Tue, 9 Jan 2024 23:10:56 +0100 Subject: [PATCH 06/64] error rate monitor --- backend/server/websocket.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/backend/server/websocket.py b/backend/server/websocket.py index 9f600347..1fa01f7b 100644 --- a/backend/server/websocket.py +++ b/backend/server/websocket.py @@ -82,8 +82,11 @@ def is_valid(self) -> bool: class Messaging(BackgroundTask): + error_rate_data: list[float] = [] + def initialize(self) -> None: self.clients: dict[str, Client] = {} + self.error_rate_data = [] async def join(self, websocket: WebSocket): if not self.is_running: @@ -134,6 +137,9 @@ async def run(self) -> None: "data": data[4], } + if message["topic"] == "log" and message["data"].get("level", 0) > 3: + self.handle_error_log() + clients = list(self.clients.values()) for client in clients: for topic in client.topics: @@ -148,5 +154,17 @@ async def run(self) -> None: nebula.log.warn("Stopping redis2ws") + def handle_error_log(self): + """When an error log is received, we want to keep error rate""" + now = time.time() + # delete timestamps older than 5 minutes from the list + self.error_rate_data = [t for t in self.error_rate_data if now - t < 300] + self.error_rate_data.append(now) + + @property + def error_rate(self) -> float: + """Returns the error rate in the last 5 minutes""" + return len(self.error_rate_data) + messaging = Messaging() From 419e1b479921fbc9f93bca72f1f823471e6b2a08 Mon Sep 17 00:00:00 2001 From: Martastain Date: Wed, 10 Jan 2024 15:22:44 +0100 Subject: [PATCH 07/64] streamlined error handling during endpoint load --- backend/server/endpoints.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/server/endpoints.py b/backend/server/endpoints.py index e0cc9186..c51195e9 100644 --- a/backend/server/endpoints.py +++ b/backend/server/endpoints.py @@ -45,10 +45,8 @@ def find_api_endpoints() -> Generator[APIRequest, None, None]: try: module = import_module(module_name, module_path) - except ModuleNotFoundError: - nebula.log.error(f"Module {module_name} not found in {module_path}") except ImportError: - nebula.log.traceback(f"Failed to import module {module_name}") + nebula.log.traceback(f"Failed to load endpoint {module_name}") # Find API endpoints in module and yield them From 3c856e9d343e9c7be2c761e3f40508a61473ce47 Mon Sep 17 00:00:00 2001 From: Martastain Date: Wed, 10 Jan 2024 20:13:06 +0100 Subject: [PATCH 08/64] refactor: split browser formatting helpers --- frontend/src/containers/Browser/Browser.jsx | 125 +---------------- .../src/containers/Browser/Formatting.jsx | 128 ++++++++++++++++++ 2 files changed, 130 insertions(+), 123 deletions(-) create mode 100644 frontend/src/containers/Browser/Formatting.jsx diff --git a/frontend/src/containers/Browser/Browser.jsx b/frontend/src/containers/Browser/Browser.jsx index a3950222..2d08ba29 100644 --- a/frontend/src/containers/Browser/Browser.jsx +++ b/frontend/src/containers/Browser/Browser.jsx @@ -1,10 +1,8 @@ import nebula from '/src/nebula' -import styled from 'styled-components' import { useEffect, useState } from 'react' import { useSelector, useDispatch } from 'react-redux' -import { Timecode } from '@wfoxall/timeframe' -import { Table, Timestamp, Navbar, Button, Spacer } from '/src/components' +import { Table, Navbar, Button, Spacer } from '/src/components' import { setCurrentView, setSelectedAssets, @@ -13,129 +11,10 @@ import { import { useLocalStorage } from '/src/hooks' import BrowserNav from './BrowserNav' +import { getColumnWidth, getFormatter, formatRowHighlightColor } from './Formatting.jsx' const ROWS_PER_PAGE = 200 -const QCState = styled.div` - display: inline-block; - &::before { - content: '⚑'; - } - - &.qc-state-3 { - color: var(--color-red); - } - - &.qc-state-4 { - color: var(--color-green); - } -` - -const formatRowHighlightColor = (rowData) => { - switch (rowData['status']) { - case 0: - return 'var(--color-red)' - case 2: - return 'var(--color-yellow)' // creating - case 3: - return 'var(--color-violet)' // trashed - case 4: - return 'var(--color-blue)' // archived - case 5: - return 'var(--color-yellow)' // reset - case 6: - return 'var(--color-red)' // corrupted - case 11: - return 'var(--color-yellow)' // retrieving - default: - return 'transparent' - } -} - -// Column width - -const getColumnWidth = (key) => { - if (!['title', 'subtitle', 'description'].includes(key)) return '1px' -} - -// Field formatters - -const getFormatter = (key) => { - if (['title', 'subtitle', 'description'].includes(key)) - return (rowData, key) => {rowData[key]} - - switch (key) { - case 'qc/state': - return (rowData, key) => ( - - - - ) - - case 'id_folder': - return (rowData, key) => { - const folder = nebula.settings.folders.find( - (f) => f.id === rowData[key] - ) - return {folder?.name} - } - - case 'duration': - return (rowData, key) => { - const fps = rowData['video/fps_f'] || 25 - const duration = rowData[key] || 0 - const timecode = new Timecode(duration * fps, fps) - return {timecode.toString().substring(0, 11)} - } - - case 'created_by': - return (rowData, key) => { - return {nebula.getUserName(rowData[key])} - } - - case 'updated_by': - return (rowData, key) => { - return {nebula.getUserName(rowData[key])} - } - - default: - const metaType = nebula.metaType(key) - switch (metaType.type) { - case 'boolean': - return (rowData, key) => {rowData[key] ? '✓' : ''} - - case 'datetime': - return (rowData, key) => ( - - {' '} - - ) - - case 'select': - return (rowData, key) => { - if (!metaType.cs) return {rowData[key]} - - const option = nebula - .csOptions(metaType.cs) - .find((opt) => opt.value === rowData[key]) - - return {option?.title} - } - - case 'list': - return (rowData, key) => { - if (!metaType.cs) return {rowData[key].join(', ')} - const options = nebula - .csOptions(metaType.cs) - .filter((opt) => rowData[key].includes(opt.value)) - return {options.map((opt) => opt.title).join(', ')} - } - - default: - return (rowData, key) => {rowData[key]} - } // switch metaType - } // end switch key -} // end getFormatter const Pagination = ({ page, setPage, hasMore }) => { if (page > 1 || hasMore) diff --git a/frontend/src/containers/Browser/Formatting.jsx b/frontend/src/containers/Browser/Formatting.jsx new file mode 100644 index 00000000..2abe6b8c --- /dev/null +++ b/frontend/src/containers/Browser/Formatting.jsx @@ -0,0 +1,128 @@ +import nebula from '/src/nebula' +import styled from 'styled-components' +import { Timecode } from '@wfoxall/timeframe' +import { Timestamp } from '/src/components' + +const QCState = styled.div` + display: inline-block; + &::before { + content: '⚑'; + } + + &.qc-state-3 { + color: var(--color-red); + } + + &.qc-state-4 { + color: var(--color-green); + } +` + +const formatRowHighlightColor = (rowData) => { + switch (rowData['status']) { + case 0: + return 'var(--color-red)' + case 2: + return 'var(--color-yellow)' // creating + case 3: + return 'var(--color-violet)' // trashed + case 4: + return 'var(--color-blue)' // archived + case 5: + return 'var(--color-yellow)' // reset + case 6: + return 'var(--color-red)' // corrupted + case 11: + return 'var(--color-yellow)' // retrieving + default: + return 'transparent' + } +} + +// Column width + +const getColumnWidth = (key) => { + if (!['title', 'subtitle', 'description'].includes(key)) return '1px' +} + +// Field formatters + +const getFormatter = (key) => { + if (['title', 'subtitle', 'description'].includes(key)) + return (rowData, key) => {rowData[key]} + + switch (key) { + case 'qc/state': + return (rowData, key) => ( + + + + ) + + case 'id_folder': + return (rowData, key) => { + const folder = nebula.settings.folders.find( + (f) => f.id === rowData[key] + ) + return {folder?.name} + } + + case 'duration': + return (rowData, key) => { + const fps = rowData['video/fps_f'] || 25 + const duration = rowData[key] || 0 + const timecode = new Timecode(duration * fps, fps) + return {timecode.toString().substring(0, 11)} + } + + case 'created_by': + return (rowData, key) => { + return {nebula.getUserName(rowData[key])} + } + + case 'updated_by': + return (rowData, key) => { + return {nebula.getUserName(rowData[key])} + } + + default: + const metaType = nebula.metaType(key) + switch (metaType.type) { + case 'boolean': + return (rowData, key) => {rowData[key] ? '✓' : ''} + + case 'datetime': + return (rowData, key) => ( + + {' '} + + ) + + case 'select': + return (rowData, key) => { + if (!metaType.cs) return {rowData[key]} + + const option = nebula + .csOptions(metaType.cs) + .find((opt) => opt.value === rowData[key]) + + return {option?.title} + } + + case 'list': + return (rowData, key) => { + if (!metaType.cs) return {rowData[key].join(', ')} + const options = nebula + .csOptions(metaType.cs) + .filter((opt) => rowData[key].includes(opt.value)) + return {options.map((opt) => opt.title).join(', ')} + } + + default: + return (rowData, key) => {rowData[key]} + } // switch metaType + } // end switch key +} // end getFormatter + + +export { getColumnWidth, getFormatter, formatRowHighlightColor } From ca3149229392f40ca263bb18f9c2a3e910860ee9 Mon Sep 17 00:00:00 2001 From: Martastain Date: Wed, 10 Jan 2024 21:32:31 +0100 Subject: [PATCH 09/64] basic multiselection --- frontend/src/components/table/cells.jsx | 4 +- frontend/src/components/table/container.jsx | 11 +++- frontend/src/components/table/index.jsx | 11 +++- frontend/src/containers/Browser/Browser.jsx | 59 ++++++++++++++++++++- frontend/src/index.sass | 1 + 5 files changed, 79 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/table/cells.jsx b/frontend/src/components/table/cells.jsx index 1d683649..21e48df1 100644 --- a/frontend/src/components/table/cells.jsx +++ b/frontend/src/components/table/cells.jsx @@ -48,8 +48,8 @@ const DataRow = ({ rowHighlightColor, selected = false, }) => { - const handleClick = () => { - if (onRowClick) onRowClick(rowData) + const handleClick = (event) => { + if (onRowClick) onRowClick(rowData, event) } const rowStyle = {} diff --git a/frontend/src/components/table/container.jsx b/frontend/src/components/table/container.jsx index c7dbfeaa..8b51b53f 100644 --- a/frontend/src/components/table/container.jsx +++ b/frontend/src/components/table/container.jsx @@ -9,6 +9,10 @@ const TableWrapper = styled.div` width: 100%; border-collapse: collapse; + :focus { + outline: none; + } + tr { border-left: 2px solid var(--color-surface-02); } @@ -23,6 +27,7 @@ const TableWrapper = styled.div` padding: 4px 8px; max-width: 300px; color: var(--color-text); + user-select: none; } th div { @@ -101,12 +106,16 @@ const TableWrapper = styled.div` cursor: pointer; background-color: transparent; &:hover { - background-color: var(--color-surface-04); + background-color: var(--color-surface-03); } &.selected { background-color: var(--color-surface-05); color: var(--color-text-hl); + + &:hover { + background-color: var(--color-surface-06); + } } &:before { diff --git a/frontend/src/components/table/index.jsx b/frontend/src/components/table/index.jsx index 10220f32..a2b89417 100644 --- a/frontend/src/components/table/index.jsx +++ b/frontend/src/components/table/index.jsx @@ -11,6 +11,7 @@ const Table = ({ style, keyField, onRowClick, + onKeyDown, selection, rowHighlightColor, sortBy, @@ -36,6 +37,12 @@ const Table = ({ ) }, [columns, sortBy, sortDirection, onSort]) + const handleKeyDown = (event) => { + if (onKeyDown) { + onKeyDown(event) + } + } + const body = useMemo(() => { return ( @@ -65,13 +72,13 @@ const Table = ({ } return ( - + {loading && (
)} - +
{head} {body}
diff --git a/frontend/src/containers/Browser/Browser.jsx b/frontend/src/containers/Browser/Browser.jsx index 2d08ba29..0bb40a55 100644 --- a/frontend/src/containers/Browser/Browser.jsx +++ b/frontend/src/containers/Browser/Browser.jsx @@ -40,6 +40,7 @@ const BrowserTable = () => { const currentView = useSelector((state) => state.context.currentView?.id) const searchQuery = useSelector((state) => state.context.searchQuery) const selectedAssets = useSelector((state) => state.context.selectedAssets) + const focusedAsset = useSelector((state) => state.context.focusedAsset) const browserRefresh = useSelector((state) => state.context.browserRefresh) const dispatch = useDispatch() @@ -97,11 +98,64 @@ const BrowserTable = () => { loadData() }, [currentView, searchQuery, browserRefresh, sortBy, sortDirection, page]) - const onRowClick = (rowData) => { - dispatch(setSelectedAssets([rowData.id])) + + const onRowClick = (rowData, event) => { + let newSelectedAssets = []; + if (event.ctrlKey) { + if (selectedAssets.includes(rowData.id)) { + newSelectedAssets = selectedAssets.filter(obj => obj !== rowData.id); + } else { + newSelectedAssets = [...selectedAssets, rowData.id]; + } + } else if (event.shiftKey) { + + const clickedIndex = data.findIndex((row) => row.id === rowData.id); + const focusedIndex = data.findIndex((row) => row.id === focusedAsset) || + data.findIndex((row) => selectedAssets.includes(row.id)) || + clickedIndex || + 0; + + + const min = Math.min(clickedIndex, focusedIndex); + const max = Math.max(clickedIndex, focusedIndex); + + // Get the ids of the rows in the range + const rangeIds = data.slice(min, max + 1).map(row => row.id); + + newSelectedAssets = [...new Set([...selectedAssets, ...rangeIds])]; + + } else { + newSelectedAssets = [rowData.id]; + } + + dispatch(setSelectedAssets(newSelectedAssets)) dispatch(setFocusedAsset(rowData.id)) } + + const focusNext = (offset) => { + if (!focusedAsset) return + const nextIndex = data.findIndex((row) => row.id === focusedAsset) + offset + if (nextIndex < data.length) { + const nextRow = data[nextIndex] + dispatch(setSelectedAssets([nextRow.id])) + dispatch(setFocusedAsset(nextRow.id)) + } + } + + + const onKeyDown = (e) => { + if (e.key === 'ArrowDown') { + focusNext(1) + e.preventDefault() + } + if (e.key === 'ArrowUp') { + focusNext(-1) + e.preventDefault() + } + } + + return ( <>
@@ -112,6 +166,7 @@ const BrowserTable = () => { keyField="id" selection={selectedAssets} onRowClick={onRowClick} + onKeyDown={onKeyDown} rowHighlightColor={formatRowHighlightColor} loading={loading} sortBy={sortBy} diff --git a/frontend/src/index.sass b/frontend/src/index.sass index 0d2e15bd..e5169c6d 100644 --- a/frontend/src/index.sass +++ b/frontend/src/index.sass @@ -8,6 +8,7 @@ --color-surface-03: #24202e --color-surface-04: #2e2a38 --color-surface-05: #302c3a + --color-surface-06: #3a3544 --color-text: #d7d4d5 --color-text-hl: #fbfbfb --color-text-dim: #ababab From 9e76ccda50c2994b8deeb3c3df7e9d0ba9848914 Mon Sep 17 00:00:00 2001 From: Martastain Date: Wed, 10 Jan 2024 21:41:36 +0100 Subject: [PATCH 10/64] multiselect support for send-to --- frontend/src/containers/SendTo.jsx | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/frontend/src/containers/SendTo.jsx b/frontend/src/containers/SendTo.jsx index 186236c0..71a8bd0d 100644 --- a/frontend/src/containers/SendTo.jsx +++ b/frontend/src/containers/SendTo.jsx @@ -1,24 +1,26 @@ import nebula from '/src/nebula' import { useState, useEffect, useMemo } from 'react' +import { useSelector } from 'react-redux' import { toast } from 'react-toastify' import { Dialog, Button } from '/src/components' -const SendToDialog = ({ assets, onHide }) => { +const SendToDialog = ({ onHide }) => { const [sendToOptions, setSendToOptions] = useState([]) + const selectedAssets = useSelector((state) => state.context.selectedAssets) const loadOptions = () => { - nebula.request('actions', { ids: assets }).then((response) => { + nebula.request('actions', { ids: selectedAssets }).then((response) => { setSendToOptions(response.data.actions) }) } useEffect(() => { loadOptions() - }, [assets]) + }, [selectedAssets]) const onSend = (action) => { nebula - .request('send', { ids: assets, id_action: action }) + .request('send', { ids: selectedAssets, id_action: action }) .then(() => { toast.success('Job request accepted') onHide() @@ -29,7 +31,13 @@ const SendToDialog = ({ assets, onHide }) => { } const body = useMemo(() => { - if (sendToOptions.length === 0) return null + if (sendToOptions.length === 0) { + return ( +

+ No actions available for the current selection +

+ ) + } return ( <> {sendToOptions.map((option) => { @@ -50,11 +58,14 @@ const SendToDialog = ({ assets, onHide }) => { return