diff --git a/.vscode/tasks.json b/.vscode/tasks.json index f01e5b5f4..aac4f6180 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,12 +1,12 @@ { - "version": "2.0.0", - "tasks": [ - { - "type": "npm", - "script": "generate-api", - "path": "frontend", - "problemMatcher": [], - "label": "Generate frontend code from OpenAPI" - } - ] + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "generate:api", + "path": "frontend", + "problemMatcher": [], + "label": "Generate frontend code from OpenAPI" + } + ] } diff --git a/README.md b/README.md index 27f8e6469..a2b8efbe1 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ e.g. typically when a new dependency is added, the relevant component needs to b All the content in `/frontend/src/api/autogen` is auto-generated using the defined endpoints in the Python backend. In order to update the auto-generated code you can either -1. Run `npm run generate-api --prefix ./frontend`. +1. Run `npm run generate:api --prefix ./frontend`. 2. Use the VSCode tasks shortcut: a) `Ctrl + P` to open the command palette. b) Type `> Tasks` and enter to filter to commands only. diff --git a/backend_py/primary/poetry.lock b/backend_py/primary/poetry.lock index d67e1b38d..5e5a2c52e 100644 --- a/backend_py/primary/poetry.lock +++ b/backend_py/primary/poetry.lock @@ -1,5 +1,141 @@ # This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +description = "Happy Eyeballs for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, + {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, +] + +[[package]] +name = "aiohttp" +version = "3.12.15" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiohttp-3.12.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b6fc902bff74d9b1879ad55f5404153e2b33a82e72a95c89cec5eb6cc9e92fbc"}, + {file = "aiohttp-3.12.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:098e92835b8119b54c693f2f88a1dec690e20798ca5f5fe5f0520245253ee0af"}, + {file = "aiohttp-3.12.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40b3fee496a47c3b4a39a731954c06f0bd9bd3e8258c059a4beb76ac23f8e421"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ce13fcfb0bb2f259fb42106cdc63fa5515fb85b7e87177267d89a771a660b79"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3beb14f053222b391bf9cf92ae82e0171067cc9c8f52453a0f1ec7c37df12a77"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c39e87afe48aa3e814cac5f535bc6199180a53e38d3f51c5e2530f5aa4ec58c"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f1b4ce5bc528a6ee38dbf5f39bbf11dd127048726323b72b8e85769319ffc4"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1004e67962efabbaf3f03b11b4c43b834081c9e3f9b32b16a7d97d4708a9abe6"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8faa08fcc2e411f7ab91d1541d9d597d3a90e9004180edb2072238c085eac8c2"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fe086edf38b2222328cdf89af0dde2439ee173b8ad7cb659b4e4c6f385b2be3d"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:79b26fe467219add81d5e47b4a4ba0f2394e8b7c7c3198ed36609f9ba161aecb"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b761bac1192ef24e16706d761aefcb581438b34b13a2f069a6d343ec8fb693a5"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e153e8adacfe2af562861b72f8bc47f8a5c08e010ac94eebbe33dc21d677cd5b"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fc49c4de44977aa8601a00edbf157e9a421f227aa7eb477d9e3df48343311065"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2776c7ec89c54a47029940177e75c8c07c29c66f73464784971d6a81904ce9d1"}, + {file = "aiohttp-3.12.15-cp310-cp310-win32.whl", hash = "sha256:2c7d81a277fa78b2203ab626ced1487420e8c11a8e373707ab72d189fcdad20a"}, + {file = "aiohttp-3.12.15-cp310-cp310-win_amd64.whl", hash = "sha256:83603f881e11f0f710f8e2327817c82e79431ec976448839f3cd05d7afe8f830"}, + {file = "aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117"}, + {file = "aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe"}, + {file = "aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685"}, + {file = "aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b"}, + {file = "aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d"}, + {file = "aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7"}, + {file = "aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444"}, + {file = "aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3"}, + {file = "aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1"}, + {file = "aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34"}, + {file = "aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315"}, + {file = "aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd"}, + {file = "aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51"}, + {file = "aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0"}, + {file = "aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84"}, + {file = "aiohttp-3.12.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:691d203c2bdf4f4637792efbbcdcd157ae11e55eaeb5e9c360c1206fb03d4d98"}, + {file = "aiohttp-3.12.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e995e1abc4ed2a454c731385bf4082be06f875822adc4c6d9eaadf96e20d406"}, + {file = "aiohttp-3.12.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bd44d5936ab3193c617bfd6c9a7d8d1085a8dc8c3f44d5f1dcf554d17d04cf7d"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46749be6e89cd78d6068cdf7da51dbcfa4321147ab8e4116ee6678d9a056a0cf"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c643f4d75adea39e92c0f01b3fb83d57abdec8c9279b3078b68a3a52b3933b6"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a23918fedc05806966a2438489dcffccbdf83e921a1170773b6178d04ade142"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74bdd8c864b36c3673741023343565d95bfbd778ffe1eb4d412c135a28a8dc89"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a146708808c9b7a988a4af3821379e379e0f0e5e466ca31a73dbdd0325b0263"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7011a70b56facde58d6d26da4fec3280cc8e2a78c714c96b7a01a87930a9530"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3bdd6e17e16e1dbd3db74d7f989e8af29c4d2e025f9828e6ef45fbdee158ec75"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57d16590a351dfc914670bd72530fd78344b885a00b250e992faea565b7fdc05"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:bc9a0f6569ff990e0bbd75506c8d8fe7214c8f6579cca32f0546e54372a3bb54"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:536ad7234747a37e50e7b6794ea868833d5220b49c92806ae2d7e8a9d6b5de02"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f0adb4177fa748072546fb650d9bd7398caaf0e15b370ed3317280b13f4083b0"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:14954a2988feae3987f1eb49c706bff39947605f4b6fa4027c1d75743723eb09"}, + {file = "aiohttp-3.12.15-cp39-cp39-win32.whl", hash = "sha256:b784d6ed757f27574dca1c336f968f4e81130b27595e458e69457e6878251f5d"}, + {file = "aiohttp-3.12.15-cp39-cp39-win_amd64.whl", hash = "sha256:86ceded4e78a992f835209e236617bffae649371c4a50d5e5a3987f237db84b8"}, + {file = "aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2"}, +] + +[package.dependencies] +aiohappyeyeballs = ">=2.5.0" +aiosignal = ">=1.4.0" +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +propcache = ">=0.2.0" +yarl = ">=1.17.0,<2.0" + +[package.extras] +speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "brotlicffi ; platform_python_implementation != \"CPython\""] + +[[package]] +name = "aiosignal" +version = "1.4.0" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, + {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" +typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} + [[package]] name = "annotated-types" version = "0.7.0" @@ -78,6 +214,26 @@ files = [ {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, ] +[[package]] +name = "attrs" +version = "25.3.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, + {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, +] + +[package.extras] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] + [[package]] name = "azure-core" version = "1.35.0" @@ -115,6 +271,22 @@ files = [ azure-core = ">=1.24.0" opentelemetry-api = ">=1.12.0" +[[package]] +name = "azure-cosmos" +version = "4.9.0" +description = "Microsoft Azure Cosmos Client Library for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "azure_cosmos-4.9.0-py3-none-any.whl", hash = "sha256:3b60eaa01a16a857d0faf0cec304bac6fa8620a81bc268ce760339032ef617fe"}, + {file = "azure_cosmos-4.9.0.tar.gz", hash = "sha256:c70db4cbf55b0ff261ed7bb8aa325a5dfa565d3c6eaa43d75d26ae5e2ad6d74f"}, +] + +[package.dependencies] +azure-core = ">=1.30.0" +typing-extensions = ">=4.6.0" + [[package]] name = "azure-identity" version = "1.23.0" @@ -922,6 +1094,120 @@ ufo = ["fs (>=2.2.0,<3)"] unicode = ["unicodedata2 (>=15.1.0) ; python_version <= \"3.12\""] woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"] +[[package]] +name = "frozenlist" +version = "1.7.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a"}, + {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61"}, + {file = "frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718"}, + {file = "frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e"}, + {file = "frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464"}, + {file = "frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a"}, + {file = "frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750"}, + {file = "frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56"}, + {file = "frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7"}, + {file = "frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d"}, + {file = "frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2"}, + {file = "frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb"}, + {file = "frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43"}, + {file = "frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3"}, + {file = "frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a"}, + {file = "frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee"}, + {file = "frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d"}, + {file = "frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e"}, + {file = "frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1"}, + {file = "frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba"}, + {file = "frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d"}, + {file = "frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d"}, + {file = "frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf"}, + {file = "frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81"}, + {file = "frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e"}, + {file = "frozenlist-1.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cea3dbd15aea1341ea2de490574a4a37ca080b2ae24e4b4f4b51b9057b4c3630"}, + {file = "frozenlist-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7d536ee086b23fecc36c2073c371572374ff50ef4db515e4e503925361c24f71"}, + {file = "frozenlist-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dfcebf56f703cb2e346315431699f00db126d158455e513bd14089d992101e44"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:974c5336e61d6e7eb1ea5b929cb645e882aadab0095c5a6974a111e6479f8878"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c70db4a0ab5ab20878432c40563573229a7ed9241506181bba12f6b7d0dc41cb"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1137b78384eebaf70560a36b7b229f752fb64d463d38d1304939984d5cb887b6"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e793a9f01b3e8b5c0bc646fb59140ce0efcc580d22a3468d70766091beb81b35"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74739ba8e4e38221d2c5c03d90a7e542cb8ad681915f4ca8f68d04f810ee0a87"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e63344c4e929b1a01e29bc184bbb5fd82954869033765bfe8d65d09e336a677"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2ea2a7369eb76de2217a842f22087913cdf75f63cf1307b9024ab82dfb525938"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:836b42f472a0e006e02499cef9352ce8097f33df43baaba3e0a28a964c26c7d2"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e22b9a99741294b2571667c07d9f8cceec07cb92aae5ccda39ea1b6052ed4319"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:9a19e85cc503d958abe5218953df722748d87172f71b73cf3c9257a91b999890"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f22dac33bb3ee8fe3e013aa7b91dc12f60d61d05b7fe32191ffa84c3aafe77bd"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ccec739a99e4ccf664ea0775149f2749b8a6418eb5b8384b4dc0a7d15d304cb"}, + {file = "frozenlist-1.7.0-cp39-cp39-win32.whl", hash = "sha256:b3950f11058310008a87757f3eee16a8e1ca97979833239439586857bc25482e"}, + {file = "frozenlist-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:43a82fce6769c70f2f5a06248b614a7d268080a9d20f7457ef10ecee5af82b63"}, + {file = "frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e"}, + {file = "frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f"}, +] + [[package]] name = "h11" version = "0.16.0" @@ -1625,6 +1911,126 @@ requests-oauthlib = ">=0.5.0" [package.extras] async = ["aiodns ; python_version >= \"3.5\"", "aiohttp (>=3.0) ; python_version >= \"3.5\""] +[[package]] +name = "multidict" +version = "6.6.3" +description = "multidict implementation" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "multidict-6.6.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a2be5b7b35271f7fff1397204ba6708365e3d773579fe2a30625e16c4b4ce817"}, + {file = "multidict-6.6.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:12f4581d2930840295c461764b9a65732ec01250b46c6b2c510d7ee68872b140"}, + {file = "multidict-6.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dd7793bab517e706c9ed9d7310b06c8672fd0aeee5781bfad612f56b8e0f7d14"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:72d8815f2cd3cf3df0f83cac3f3ef801d908b2d90409ae28102e0553af85545a"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:531e331a2ee53543ab32b16334e2deb26f4e6b9b28e41f8e0c87e99a6c8e2d69"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42ca5aa9329a63be8dc49040f63817d1ac980e02eeddba763a9ae5b4027b9c9c"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:208b9b9757060b9faa6f11ab4bc52846e4f3c2fb8b14d5680c8aac80af3dc751"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:acf6b97bd0884891af6a8b43d0f586ab2fcf8e717cbd47ab4bdddc09e20652d8"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68e9e12ed00e2089725669bdc88602b0b6f8d23c0c95e52b95f0bc69f7fe9b55"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:05db2f66c9addb10cfa226e1acb363450fab2ff8a6df73c622fefe2f5af6d4e7"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:0db58da8eafb514db832a1b44f8fa7906fdd102f7d982025f816a93ba45e3dcb"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:14117a41c8fdb3ee19c743b1c027da0736fdb79584d61a766da53d399b71176c"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:877443eaaabcd0b74ff32ebeed6f6176c71850feb7d6a1d2db65945256ea535c"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:70b72e749a4f6e7ed8fb334fa8d8496384840319512746a5f42fa0aec79f4d61"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43571f785b86afd02b3855c5ac8e86ec921b760298d6f82ff2a61daf5a35330b"}, + {file = "multidict-6.6.3-cp310-cp310-win32.whl", hash = "sha256:20c5a0c3c13a15fd5ea86c42311859f970070e4e24de5a550e99d7c271d76318"}, + {file = "multidict-6.6.3-cp310-cp310-win_amd64.whl", hash = "sha256:ab0a34a007704c625e25a9116c6770b4d3617a071c8a7c30cd338dfbadfe6485"}, + {file = "multidict-6.6.3-cp310-cp310-win_arm64.whl", hash = "sha256:769841d70ca8bdd140a715746199fc6473414bd02efd678d75681d2d6a8986c5"}, + {file = "multidict-6.6.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:18f4eba0cbac3546b8ae31e0bbc55b02c801ae3cbaf80c247fcdd89b456ff58c"}, + {file = "multidict-6.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef43b5dd842382329e4797c46f10748d8c2b6e0614f46b4afe4aee9ac33159df"}, + {file = "multidict-6.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bd1fd5eec01494e0f2e8e446a74a85d5e49afb63d75a9934e4a5423dba21d"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5bd8d6f793a787153956cd35e24f60485bf0651c238e207b9a54f7458b16d539"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bf99b4daf908c73856bd87ee0a2499c3c9a3d19bb04b9c6025e66af3fd07462"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b9e59946b49dafaf990fd9c17ceafa62976e8471a14952163d10a7a630413a9"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e2db616467070d0533832d204c54eea6836a5e628f2cb1e6dfd8cd6ba7277cb7"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7394888236621f61dcdd25189b2768ae5cc280f041029a5bcf1122ac63df79f9"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f114d8478733ca7388e7c7e0ab34b72547476b97009d643644ac33d4d3fe1821"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cdf22e4db76d323bcdc733514bf732e9fb349707c98d341d40ebcc6e9318ef3d"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e995a34c3d44ab511bfc11aa26869b9d66c2d8c799fa0e74b28a473a692532d6"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:766a4a5996f54361d8d5a9050140aa5362fe48ce51c755a50c0bc3706460c430"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3893a0d7d28a7fe6ca7a1f760593bc13038d1d35daf52199d431b61d2660602b"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:934796c81ea996e61914ba58064920d6cad5d99140ac3167901eb932150e2e56"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9ed948328aec2072bc00f05d961ceadfd3e9bfc2966c1319aeaf7b7c21219183"}, + {file = "multidict-6.6.3-cp311-cp311-win32.whl", hash = "sha256:9f5b28c074c76afc3e4c610c488e3493976fe0e596dd3db6c8ddfbb0134dcac5"}, + {file = "multidict-6.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:bc7f6fbc61b1c16050a389c630da0b32fc6d4a3d191394ab78972bf5edc568c2"}, + {file = "multidict-6.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:d4e47d8faffaae822fb5cba20937c048d4f734f43572e7079298a6c39fb172cb"}, + {file = "multidict-6.6.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:056bebbeda16b2e38642d75e9e5310c484b7c24e3841dc0fb943206a72ec89d6"}, + {file = "multidict-6.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e5f481cccb3c5c5e5de5d00b5141dc589c1047e60d07e85bbd7dea3d4580d63f"}, + {file = "multidict-6.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10bea2ee839a759ee368b5a6e47787f399b41e70cf0c20d90dfaf4158dfb4e55"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2334cfb0fa9549d6ce2c21af2bfbcd3ac4ec3646b1b1581c88e3e2b1779ec92b"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8fee016722550a2276ca2cb5bb624480e0ed2bd49125b2b73b7010b9090e888"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5511cb35f5c50a2db21047c875eb42f308c5583edf96bd8ebf7d770a9d68f6d"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:712b348f7f449948e0a6c4564a21c7db965af900973a67db432d724619b3c680"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e15d2138ee2694e038e33b7c3da70e6b0ad8868b9f8094a72e1414aeda9c1a"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8df25594989aebff8a130f7899fa03cbfcc5d2b5f4a461cf2518236fe6f15961"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:159ca68bfd284a8860f8d8112cf0521113bffd9c17568579e4d13d1f1dc76b65"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e098c17856a8c9ade81b4810888c5ad1914099657226283cab3062c0540b0643"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:67c92ed673049dec52d7ed39f8cf9ebbadf5032c774058b4406d18c8f8fe7063"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:bd0578596e3a835ef451784053cfd327d607fc39ea1a14812139339a18a0dbc3"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:346055630a2df2115cd23ae271910b4cae40f4e336773550dca4889b12916e75"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:555ff55a359302b79de97e0468e9ee80637b0de1fce77721639f7cd9440b3a10"}, + {file = "multidict-6.6.3-cp312-cp312-win32.whl", hash = "sha256:73ab034fb8d58ff85c2bcbadc470efc3fafeea8affcf8722855fb94557f14cc5"}, + {file = "multidict-6.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:04cbcce84f63b9af41bad04a54d4cc4e60e90c35b9e6ccb130be2d75b71f8c17"}, + {file = "multidict-6.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:0f1130b896ecb52d2a1e615260f3ea2af55fa7dc3d7c3003ba0c3121a759b18b"}, + {file = "multidict-6.6.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:540d3c06d48507357a7d57721e5094b4f7093399a0106c211f33540fdc374d55"}, + {file = "multidict-6.6.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9c19cea2a690f04247d43f366d03e4eb110a0dc4cd1bbeee4d445435428ed35b"}, + {file = "multidict-6.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7af039820cfd00effec86bda5d8debef711a3e86a1d3772e85bea0f243a4bd65"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:500b84f51654fdc3944e936f2922114349bf8fdcac77c3092b03449f0e5bc2b3"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3fc723ab8a5c5ed6c50418e9bfcd8e6dceba6c271cee6728a10a4ed8561520c"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:94c47ea3ade005b5976789baaed66d4de4480d0a0bf31cef6edaa41c1e7b56a6"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dbc7cf464cc6d67e83e136c9f55726da3a30176f020a36ead246eceed87f1cd8"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:900eb9f9da25ada070f8ee4a23f884e0ee66fe4e1a38c3af644256a508ad81ca"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c6df517cf177da5d47ab15407143a89cd1a23f8b335f3a28d57e8b0a3dbb884"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ef421045f13879e21c994b36e728d8e7d126c91a64b9185810ab51d474f27e7"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c1e61bb4f80895c081790b6b09fa49e13566df8fbff817da3f85b3a8192e36b"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e5e8523bb12d7623cd8300dbd91b9e439a46a028cd078ca695eb66ba31adee3c"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ef58340cc896219e4e653dade08fea5c55c6df41bcc68122e3be3e9d873d9a7b"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc9dc435ec8699e7b602b94fe0cd4703e69273a01cbc34409af29e7820f777f1"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9e864486ef4ab07db5e9cb997bad2b681514158d6954dd1958dfb163b83d53e6"}, + {file = "multidict-6.6.3-cp313-cp313-win32.whl", hash = "sha256:5633a82fba8e841bc5c5c06b16e21529573cd654f67fd833650a215520a6210e"}, + {file = "multidict-6.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:e93089c1570a4ad54c3714a12c2cef549dc9d58e97bcded193d928649cab78e9"}, + {file = "multidict-6.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:c60b401f192e79caec61f166da9c924e9f8bc65548d4246842df91651e83d600"}, + {file = "multidict-6.6.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:02fd8f32d403a6ff13864b0851f1f523d4c988051eea0471d4f1fd8010f11134"}, + {file = "multidict-6.6.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f3aa090106b1543f3f87b2041eef3c156c8da2aed90c63a2fbed62d875c49c37"}, + {file = "multidict-6.6.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e924fb978615a5e33ff644cc42e6aa241effcf4f3322c09d4f8cebde95aff5f8"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b9fe5a0e57c6dbd0e2ce81ca66272282c32cd11d31658ee9553849d91289e1c1"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b24576f208793ebae00280c59927c3b7c2a3b1655e443a25f753c4611bc1c373"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:135631cb6c58eac37d7ac0df380294fecdc026b28837fa07c02e459c7fb9c54e"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:274d416b0df887aef98f19f21578653982cfb8a05b4e187d4a17103322eeaf8f"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e252017a817fad7ce05cafbe5711ed40faeb580e63b16755a3a24e66fa1d87c0"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4cc8d848cd4fe1cdee28c13ea79ab0ed37fc2e89dd77bac86a2e7959a8c3bc"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9e236a7094b9c4c1b7585f6b9cca34b9d833cf079f7e4c49e6a4a6ec9bfdc68f"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e0cb0ab69915c55627c933f0b555a943d98ba71b4d1c57bc0d0a66e2567c7471"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:81ef2f64593aba09c5212a3d0f8c906a0d38d710a011f2f42759704d4557d3f2"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:b9cbc60010de3562545fa198bfc6d3825df430ea96d2cc509c39bd71e2e7d648"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70d974eaaa37211390cd02ef93b7e938de564bbffa866f0b08d07e5e65da783d"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3713303e4a6663c6d01d648a68f2848701001f3390a030edaaf3fc949c90bf7c"}, + {file = "multidict-6.6.3-cp313-cp313t-win32.whl", hash = "sha256:639ecc9fe7cd73f2495f62c213e964843826f44505a3e5d82805aa85cac6f89e"}, + {file = "multidict-6.6.3-cp313-cp313t-win_amd64.whl", hash = "sha256:9f97e181f344a0ef3881b573d31de8542cc0dbc559ec68c8f8b5ce2c2e91646d"}, + {file = "multidict-6.6.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ce8b7693da41a3c4fde5871c738a81490cea5496c671d74374c8ab889e1834fb"}, + {file = "multidict-6.6.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c8161b5a7778d3137ea2ee7ae8a08cce0010de3b00ac671c5ebddeaa17cefd22"}, + {file = "multidict-6.6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1328201ee930f069961ae707d59c6627ac92e351ed5b92397cf534d1336ce557"}, + {file = "multidict-6.6.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b1db4d2093d6b235de76932febf9d50766cf49a5692277b2c28a501c9637f616"}, + {file = "multidict-6.6.3-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53becb01dd8ebd19d1724bebe369cfa87e4e7f29abbbe5c14c98ce4c383e16cd"}, + {file = "multidict-6.6.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41bb9d1d4c303886e2d85bade86e59885112a7f4277af5ad47ab919a2251f306"}, + {file = "multidict-6.6.3-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:775b464d31dac90f23192af9c291dc9f423101857e33e9ebf0020a10bfcf4144"}, + {file = "multidict-6.6.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d04d01f0a913202205a598246cf77826fe3baa5a63e9f6ccf1ab0601cf56eca0"}, + {file = "multidict-6.6.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d25594d3b38a2e6cabfdcafef339f754ca6e81fbbdb6650ad773ea9775af35ab"}, + {file = "multidict-6.6.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35712f1748d409e0707b165bf49f9f17f9e28ae85470c41615778f8d4f7d9609"}, + {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1c8082e5814b662de8589d6a06c17e77940d5539080cbab9fe6794b5241b76d9"}, + {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:61af8a4b771f1d4d000b3168c12c3120ccf7284502a94aa58c68a81f5afac090"}, + {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:448e4a9afccbf297577f2eaa586f07067441e7b63c8362a3540ba5a38dc0f14a"}, + {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:233ad16999afc2bbd3e534ad8dbe685ef8ee49a37dbc2cdc9514e57b6d589ced"}, + {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:bb933c891cd4da6bdcc9733d048e994e22e1883287ff7540c2a0f3b117605092"}, + {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:37b09ca60998e87734699e88c2363abfd457ed18cfbf88e4009a4e83788e63ed"}, + {file = "multidict-6.6.3-cp39-cp39-win32.whl", hash = "sha256:f54cb79d26d0cd420637d184af38f0668558f3c4bbe22ab7ad830e67249f2e0b"}, + {file = "multidict-6.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:295adc9c0551e5d5214b45cf29ca23dbc28c2d197a9c30d51aed9e037cb7c578"}, + {file = "multidict-6.6.3-cp39-cp39-win_arm64.whl", hash = "sha256:15332783596f227db50fb261c2c251a58ac3873c457f3a550a95d5c0aa3c770d"}, + {file = "multidict-6.6.3-py3-none-any.whl", hash = "sha256:8db10f29c7541fc5da4defd8cd697e1ca429db743fa716325f236079b96f775a"}, + {file = "multidict-6.6.3.tar.gz", hash = "sha256:798a9eb12dab0a6c2e29c1de6f3468af5cb2da6053a20dfa3344907eed0937cc"}, +] + [[package]] name = "mypy" version = "1.15.0" @@ -1690,6 +2096,18 @@ files = [ {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] +[[package]] +name = "nanoid" +version = "2.0.0" +description = "A tiny, secure, URL-friendly, unique string ID generator for Python" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "nanoid-2.0.0-py3-none-any.whl", hash = "sha256:90aefa650e328cffb0893bbd4c236cfd44c48bc1f2d0b525ecc53c3187b653bb"}, + {file = "nanoid-2.0.0.tar.gz", hash = "sha256:5a80cad5e9c6e9ae3a41fa2fb34ae189f7cb420b2a5d8f82bd9d23466e4efa68"}, +] + [[package]] name = "ndindex" version = "1.10.0" @@ -2680,6 +3098,114 @@ mmh3 = "*" redis = ">=4.2.0rc1" typing_extensions = "*" +[[package]] +name = "propcache" +version = "0.3.1" +description = "Accelerated property cache" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "propcache-0.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f27785888d2fdd918bc36de8b8739f2d6c791399552333721b58193f68ea3e98"}, + {file = "propcache-0.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4e89cde74154c7b5957f87a355bb9c8ec929c167b59c83d90654ea36aeb6180"}, + {file = "propcache-0.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:730178f476ef03d3d4d255f0c9fa186cb1d13fd33ffe89d39f2cda4da90ceb71"}, + {file = "propcache-0.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:967a8eec513dbe08330f10137eacb427b2ca52118769e82ebcfcab0fba92a649"}, + {file = "propcache-0.3.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b9145c35cc87313b5fd480144f8078716007656093d23059e8993d3a8fa730f"}, + {file = "propcache-0.3.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e64e948ab41411958670f1093c0a57acfdc3bee5cf5b935671bbd5313bcf229"}, + {file = "propcache-0.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:319fa8765bfd6a265e5fa661547556da381e53274bc05094fc9ea50da51bfd46"}, + {file = "propcache-0.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c66d8ccbc902ad548312b96ed8d5d266d0d2c6d006fd0f66323e9d8f2dd49be7"}, + {file = "propcache-0.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2d219b0dbabe75e15e581fc1ae796109b07c8ba7d25b9ae8d650da582bed01b0"}, + {file = "propcache-0.3.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd6a55f65241c551eb53f8cf4d2f4af33512c39da5d9777694e9d9c60872f519"}, + {file = "propcache-0.3.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9979643ffc69b799d50d3a7b72b5164a2e97e117009d7af6dfdd2ab906cb72cd"}, + {file = "propcache-0.3.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4cf9e93a81979f1424f1a3d155213dc928f1069d697e4353edb8a5eba67c6259"}, + {file = "propcache-0.3.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2fce1df66915909ff6c824bbb5eb403d2d15f98f1518e583074671a30fe0c21e"}, + {file = "propcache-0.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4d0dfdd9a2ebc77b869a0b04423591ea8823f791293b527dc1bb896c1d6f1136"}, + {file = "propcache-0.3.1-cp310-cp310-win32.whl", hash = "sha256:1f6cc0ad7b4560e5637eb2c994e97b4fa41ba8226069c9277eb5ea7101845b42"}, + {file = "propcache-0.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:47ef24aa6511e388e9894ec16f0fbf3313a53ee68402bc428744a367ec55b833"}, + {file = "propcache-0.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7f30241577d2fef2602113b70ef7231bf4c69a97e04693bde08ddab913ba0ce5"}, + {file = "propcache-0.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:43593c6772aa12abc3af7784bff4a41ffa921608dd38b77cf1dfd7f5c4e71371"}, + {file = "propcache-0.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a75801768bbe65499495660b777e018cbe90c7980f07f8aa57d6be79ea6f71da"}, + {file = "propcache-0.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6f1324db48f001c2ca26a25fa25af60711e09b9aaf4b28488602776f4f9a744"}, + {file = "propcache-0.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cdb0f3e1eb6dfc9965d19734d8f9c481b294b5274337a8cb5cb01b462dcb7e0"}, + {file = "propcache-0.3.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1eb34d90aac9bfbced9a58b266f8946cb5935869ff01b164573a7634d39fbcb5"}, + {file = "propcache-0.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f35c7070eeec2cdaac6fd3fe245226ed2a6292d3ee8c938e5bb645b434c5f256"}, + {file = "propcache-0.3.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b23c11c2c9e6d4e7300c92e022046ad09b91fd00e36e83c44483df4afa990073"}, + {file = "propcache-0.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3e19ea4ea0bf46179f8a3652ac1426e6dcbaf577ce4b4f65be581e237340420d"}, + {file = "propcache-0.3.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:bd39c92e4c8f6cbf5f08257d6360123af72af9f4da75a690bef50da77362d25f"}, + {file = "propcache-0.3.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b0313e8b923b3814d1c4a524c93dfecea5f39fa95601f6a9b1ac96cd66f89ea0"}, + {file = "propcache-0.3.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e861ad82892408487be144906a368ddbe2dc6297074ade2d892341b35c59844a"}, + {file = "propcache-0.3.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:61014615c1274df8da5991a1e5da85a3ccb00c2d4701ac6f3383afd3ca47ab0a"}, + {file = "propcache-0.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:71ebe3fe42656a2328ab08933d420df5f3ab121772eef78f2dc63624157f0ed9"}, + {file = "propcache-0.3.1-cp311-cp311-win32.whl", hash = "sha256:58aa11f4ca8b60113d4b8e32d37e7e78bd8af4d1a5b5cb4979ed856a45e62005"}, + {file = "propcache-0.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:9532ea0b26a401264b1365146c440a6d78269ed41f83f23818d4b79497aeabe7"}, + {file = "propcache-0.3.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f78eb8422acc93d7b69964012ad7048764bb45a54ba7a39bb9e146c72ea29723"}, + {file = "propcache-0.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:89498dd49c2f9a026ee057965cdf8192e5ae070ce7d7a7bd4b66a8e257d0c976"}, + {file = "propcache-0.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:09400e98545c998d57d10035ff623266927cb784d13dd2b31fd33b8a5316b85b"}, + {file = "propcache-0.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa8efd8c5adc5a2c9d3b952815ff8f7710cefdcaf5f2c36d26aff51aeca2f12f"}, + {file = "propcache-0.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2fe5c910f6007e716a06d269608d307b4f36e7babee5f36533722660e8c4a70"}, + {file = "propcache-0.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0ab8cf8cdd2194f8ff979a43ab43049b1df0b37aa64ab7eca04ac14429baeb7"}, + {file = "propcache-0.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:563f9d8c03ad645597b8d010ef4e9eab359faeb11a0a2ac9f7b4bc8c28ebef25"}, + {file = "propcache-0.3.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb6e0faf8cb6b4beea5d6ed7b5a578254c6d7df54c36ccd3d8b3eb00d6770277"}, + {file = "propcache-0.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1c5c7ab7f2bb3f573d1cb921993006ba2d39e8621019dffb1c5bc94cdbae81e8"}, + {file = "propcache-0.3.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:050b571b2e96ec942898f8eb46ea4bfbb19bd5502424747e83badc2d4a99a44e"}, + {file = "propcache-0.3.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e1c4d24b804b3a87e9350f79e2371a705a188d292fd310e663483af6ee6718ee"}, + {file = "propcache-0.3.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e4fe2a6d5ce975c117a6bb1e8ccda772d1e7029c1cca1acd209f91d30fa72815"}, + {file = "propcache-0.3.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:feccd282de1f6322f56f6845bf1207a537227812f0a9bf5571df52bb418d79d5"}, + {file = "propcache-0.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ec314cde7314d2dd0510c6787326bbffcbdc317ecee6b7401ce218b3099075a7"}, + {file = "propcache-0.3.1-cp312-cp312-win32.whl", hash = "sha256:7d2d5a0028d920738372630870e7d9644ce437142197f8c827194fca404bf03b"}, + {file = "propcache-0.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:88c423efef9d7a59dae0614eaed718449c09a5ac79a5f224a8b9664d603f04a3"}, + {file = "propcache-0.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f1528ec4374617a7a753f90f20e2f551121bb558fcb35926f99e3c42367164b8"}, + {file = "propcache-0.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc1915ec523b3b494933b5424980831b636fe483d7d543f7afb7b3bf00f0c10f"}, + {file = "propcache-0.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a110205022d077da24e60b3df8bcee73971be9575dec5573dd17ae5d81751111"}, + {file = "propcache-0.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d249609e547c04d190e820d0d4c8ca03ed4582bcf8e4e160a6969ddfb57b62e5"}, + {file = "propcache-0.3.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ced33d827625d0a589e831126ccb4f5c29dfdf6766cac441d23995a65825dcb"}, + {file = "propcache-0.3.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4114c4ada8f3181af20808bedb250da6bae56660e4b8dfd9cd95d4549c0962f7"}, + {file = "propcache-0.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:975af16f406ce48f1333ec5e912fe11064605d5c5b3f6746969077cc3adeb120"}, + {file = "propcache-0.3.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a34aa3a1abc50740be6ac0ab9d594e274f59960d3ad253cd318af76b996dd654"}, + {file = "propcache-0.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9cec3239c85ed15bfaded997773fdad9fb5662b0a7cbc854a43f291eb183179e"}, + {file = "propcache-0.3.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:05543250deac8e61084234d5fc54f8ebd254e8f2b39a16b1dce48904f45b744b"}, + {file = "propcache-0.3.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5cb5918253912e088edbf023788de539219718d3b10aef334476b62d2b53de53"}, + {file = "propcache-0.3.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f3bbecd2f34d0e6d3c543fdb3b15d6b60dd69970c2b4c822379e5ec8f6f621d5"}, + {file = "propcache-0.3.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aca63103895c7d960a5b9b044a83f544b233c95e0dcff114389d64d762017af7"}, + {file = "propcache-0.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a0a9898fdb99bf11786265468571e628ba60af80dc3f6eb89a3545540c6b0ef"}, + {file = "propcache-0.3.1-cp313-cp313-win32.whl", hash = "sha256:3a02a28095b5e63128bcae98eb59025924f121f048a62393db682f049bf4ac24"}, + {file = "propcache-0.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:813fbb8b6aea2fc9659815e585e548fe706d6f663fa73dff59a1677d4595a037"}, + {file = "propcache-0.3.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a444192f20f5ce8a5e52761a031b90f5ea6288b1eef42ad4c7e64fef33540b8f"}, + {file = "propcache-0.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fbe94666e62ebe36cd652f5fc012abfbc2342de99b523f8267a678e4dfdee3c"}, + {file = "propcache-0.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f011f104db880f4e2166bcdcf7f58250f7a465bc6b068dc84c824a3d4a5c94dc"}, + {file = "propcache-0.3.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e584b6d388aeb0001d6d5c2bd86b26304adde6d9bb9bfa9c4889805021b96de"}, + {file = "propcache-0.3.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a17583515a04358b034e241f952f1715243482fc2c2945fd99a1b03a0bd77d6"}, + {file = "propcache-0.3.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5aed8d8308215089c0734a2af4f2e95eeb360660184ad3912686c181e500b2e7"}, + {file = "propcache-0.3.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d8e309ff9a0503ef70dc9a0ebd3e69cf7b3894c9ae2ae81fc10943c37762458"}, + {file = "propcache-0.3.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b655032b202028a582d27aeedc2e813299f82cb232f969f87a4fde491a233f11"}, + {file = "propcache-0.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f64d91b751df77931336b5ff7bafbe8845c5770b06630e27acd5dbb71e1931c"}, + {file = "propcache-0.3.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:19a06db789a4bd896ee91ebc50d059e23b3639c25d58eb35be3ca1cbe967c3bf"}, + {file = "propcache-0.3.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:bef100c88d8692864651b5f98e871fb090bd65c8a41a1cb0ff2322db39c96c27"}, + {file = "propcache-0.3.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:87380fb1f3089d2a0b8b00f006ed12bd41bd858fabfa7330c954c70f50ed8757"}, + {file = "propcache-0.3.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e474fc718e73ba5ec5180358aa07f6aded0ff5f2abe700e3115c37d75c947e18"}, + {file = "propcache-0.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:17d1c688a443355234f3c031349da69444be052613483f3e4158eef751abcd8a"}, + {file = "propcache-0.3.1-cp313-cp313t-win32.whl", hash = "sha256:359e81a949a7619802eb601d66d37072b79b79c2505e6d3fd8b945538411400d"}, + {file = "propcache-0.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e7fb9a84c9abbf2b2683fa3e7b0d7da4d8ecf139a1c635732a8bda29c5214b0e"}, + {file = "propcache-0.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ed5f6d2edbf349bd8d630e81f474d33d6ae5d07760c44d33cd808e2f5c8f4ae6"}, + {file = "propcache-0.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:668ddddc9f3075af019f784456267eb504cb77c2c4bd46cc8402d723b4d200bf"}, + {file = "propcache-0.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0c86e7ceea56376216eba345aa1fc6a8a6b27ac236181f840d1d7e6a1ea9ba5c"}, + {file = "propcache-0.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83be47aa4e35b87c106fc0c84c0fc069d3f9b9b06d3c494cd404ec6747544894"}, + {file = "propcache-0.3.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:27c6ac6aa9fc7bc662f594ef380707494cb42c22786a558d95fcdedb9aa5d035"}, + {file = "propcache-0.3.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a956dff37080b352c1c40b2966b09defb014347043e740d420ca1eb7c9b908"}, + {file = "propcache-0.3.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82de5da8c8893056603ac2d6a89eb8b4df49abf1a7c19d536984c8dd63f481d5"}, + {file = "propcache-0.3.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c3c3a203c375b08fd06a20da3cf7aac293b834b6f4f4db71190e8422750cca5"}, + {file = "propcache-0.3.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b303b194c2e6f171cfddf8b8ba30baefccf03d36a4d9cab7fd0bb68ba476a3d7"}, + {file = "propcache-0.3.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:916cd229b0150129d645ec51614d38129ee74c03293a9f3f17537be0029a9641"}, + {file = "propcache-0.3.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:a461959ead5b38e2581998700b26346b78cd98540b5524796c175722f18b0294"}, + {file = "propcache-0.3.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:069e7212890b0bcf9b2be0a03afb0c2d5161d91e1bf51569a64f629acc7defbf"}, + {file = "propcache-0.3.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ef2e4e91fb3945769e14ce82ed53007195e616a63aa43b40fb7ebaaf907c8d4c"}, + {file = "propcache-0.3.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8638f99dca15b9dff328fb6273e09f03d1c50d9b6512f3b65a4154588a7595fe"}, + {file = "propcache-0.3.1-cp39-cp39-win32.whl", hash = "sha256:6f173bbfe976105aaa890b712d1759de339d8a7cef2fc0a1714cc1a1e1c47f64"}, + {file = "propcache-0.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:603f1fe4144420374f1a69b907494c3acbc867a581c2d49d4175b0de7cc64566"}, + {file = "propcache-0.3.1-py3-none-any.whl", hash = "sha256:9a8ecf38de50a7f518c21568c80f985e776397b902f1ce0b01f799aba1608b40"}, + {file = "propcache-0.3.1.tar.gz", hash = "sha256:40d980c33765359098837527e18eddefc9a24cea5b45e078a7f3bb5b032c6ecf"}, +] + [[package]] name = "psutil" version = "7.0.0" @@ -3959,6 +4485,125 @@ xtgeo = "*" docs = ["autoapi", "myst-parser", "sphinx", "sphinx-argparse", "sphinx-autodoc-typehints (<2.4)", "sphinx-copybutton", "sphinx-togglebutton", "sphinx_rtd_theme", "sphinxcontrib-apidoc"] tests = ["coverage (>=4.1)", "mypy", "pylint", "pytest", "pytest-cov", "pytest-mock", "pytest-runner", "pytest-xdist", "rstcheck", "ruff", "types-PyYAML"] +[[package]] +name = "yarl" +version = "1.20.0" +description = "Yet another URL library" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "yarl-1.20.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f1f6670b9ae3daedb325fa55fbe31c22c8228f6e0b513772c2e1c623caa6ab22"}, + {file = "yarl-1.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:85a231fa250dfa3308f3c7896cc007a47bc76e9e8e8595c20b7426cac4884c62"}, + {file = "yarl-1.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a06701b647c9939d7019acdfa7ebbfbb78ba6aa05985bb195ad716ea759a569"}, + {file = "yarl-1.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7595498d085becc8fb9203aa314b136ab0516c7abd97e7d74f7bb4eb95042abe"}, + {file = "yarl-1.20.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af5607159085dcdb055d5678fc2d34949bd75ae6ea6b4381e784bbab1c3aa195"}, + {file = "yarl-1.20.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:95b50910e496567434cb77a577493c26bce0f31c8a305135f3bda6a2483b8e10"}, + {file = "yarl-1.20.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b594113a301ad537766b4e16a5a6750fcbb1497dcc1bc8a4daae889e6402a634"}, + {file = "yarl-1.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:083ce0393ea173cd37834eb84df15b6853b555d20c52703e21fbababa8c129d2"}, + {file = "yarl-1.20.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f1a350a652bbbe12f666109fbddfdf049b3ff43696d18c9ab1531fbba1c977a"}, + {file = "yarl-1.20.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fb0caeac4a164aadce342f1597297ec0ce261ec4532bbc5a9ca8da5622f53867"}, + {file = "yarl-1.20.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d88cc43e923f324203f6ec14434fa33b85c06d18d59c167a0637164863b8e995"}, + {file = "yarl-1.20.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e52d6ed9ea8fd3abf4031325dc714aed5afcbfa19ee4a89898d663c9976eb487"}, + {file = "yarl-1.20.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ce360ae48a5e9961d0c730cf891d40698a82804e85f6e74658fb175207a77cb2"}, + {file = "yarl-1.20.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:06d06c9d5b5bc3eb56542ceeba6658d31f54cf401e8468512447834856fb0e61"}, + {file = "yarl-1.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c27d98f4e5c4060582f44e58309c1e55134880558f1add7a87c1bc36ecfade19"}, + {file = "yarl-1.20.0-cp310-cp310-win32.whl", hash = "sha256:f4d3fa9b9f013f7050326e165c3279e22850d02ae544ace285674cb6174b5d6d"}, + {file = "yarl-1.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc906b636239631d42eb8a07df8359905da02704a868983265603887ed68c076"}, + {file = "yarl-1.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fdb5204d17cb32b2de2d1e21c7461cabfacf17f3645e4b9039f210c5d3378bf3"}, + {file = "yarl-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eaddd7804d8e77d67c28d154ae5fab203163bd0998769569861258e525039d2a"}, + {file = "yarl-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:634b7ba6b4a85cf67e9df7c13a7fb2e44fa37b5d34501038d174a63eaac25ee2"}, + {file = "yarl-1.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d409e321e4addf7d97ee84162538c7258e53792eb7c6defd0c33647d754172e"}, + {file = "yarl-1.20.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ea52f7328a36960ba3231c6677380fa67811b414798a6e071c7085c57b6d20a9"}, + {file = "yarl-1.20.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c8703517b924463994c344dcdf99a2d5ce9eca2b6882bb640aa555fb5efc706a"}, + {file = "yarl-1.20.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:077989b09ffd2f48fb2d8f6a86c5fef02f63ffe6b1dd4824c76de7bb01e4f2e2"}, + {file = "yarl-1.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0acfaf1da020253f3533526e8b7dd212838fdc4109959a2c53cafc6db611bff2"}, + {file = "yarl-1.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4230ac0b97ec5eeb91d96b324d66060a43fd0d2a9b603e3327ed65f084e41f8"}, + {file = "yarl-1.20.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a6a1e6ae21cdd84011c24c78d7a126425148b24d437b5702328e4ba640a8902"}, + {file = "yarl-1.20.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:86de313371ec04dd2531f30bc41a5a1a96f25a02823558ee0f2af0beaa7ca791"}, + {file = "yarl-1.20.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dd59c9dd58ae16eaa0f48c3d0cbe6be8ab4dc7247c3ff7db678edecbaf59327f"}, + {file = "yarl-1.20.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a0bc5e05f457b7c1994cc29e83b58f540b76234ba6b9648a4971ddc7f6aa52da"}, + {file = "yarl-1.20.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c9471ca18e6aeb0e03276b5e9b27b14a54c052d370a9c0c04a68cefbd1455eb4"}, + {file = "yarl-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:40ed574b4df723583a26c04b298b283ff171bcc387bc34c2683235e2487a65a5"}, + {file = "yarl-1.20.0-cp311-cp311-win32.whl", hash = "sha256:db243357c6c2bf3cd7e17080034ade668d54ce304d820c2a58514a4e51d0cfd6"}, + {file = "yarl-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:8c12cd754d9dbd14204c328915e23b0c361b88f3cffd124129955e60a4fbfcfb"}, + {file = "yarl-1.20.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e06b9f6cdd772f9b665e5ba8161968e11e403774114420737f7884b5bd7bdf6f"}, + {file = "yarl-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b9ae2fbe54d859b3ade40290f60fe40e7f969d83d482e84d2c31b9bff03e359e"}, + {file = "yarl-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d12b8945250d80c67688602c891237994d203d42427cb14e36d1a732eda480e"}, + {file = "yarl-1.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:087e9731884621b162a3e06dc0d2d626e1542a617f65ba7cc7aeab279d55ad33"}, + {file = "yarl-1.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69df35468b66c1a6e6556248e6443ef0ec5f11a7a4428cf1f6281f1879220f58"}, + {file = "yarl-1.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b2992fe29002fd0d4cbaea9428b09af9b8686a9024c840b8a2b8f4ea4abc16f"}, + {file = "yarl-1.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c903e0b42aab48abfbac668b5a9d7b6938e721a6341751331bcd7553de2dcae"}, + {file = "yarl-1.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf099e2432131093cc611623e0b0bcc399b8cddd9a91eded8bfb50402ec35018"}, + {file = "yarl-1.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a7f62f5dc70a6c763bec9ebf922be52aa22863d9496a9a30124d65b489ea672"}, + {file = "yarl-1.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:54ac15a8b60382b2bcefd9a289ee26dc0920cf59b05368c9b2b72450751c6eb8"}, + {file = "yarl-1.20.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:25b3bc0763a7aca16a0f1b5e8ef0f23829df11fb539a1b70476dcab28bd83da7"}, + {file = "yarl-1.20.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b2586e36dc070fc8fad6270f93242124df68b379c3a251af534030a4a33ef594"}, + {file = "yarl-1.20.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:866349da9d8c5290cfefb7fcc47721e94de3f315433613e01b435473be63daa6"}, + {file = "yarl-1.20.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:33bb660b390a0554d41f8ebec5cd4475502d84104b27e9b42f5321c5192bfcd1"}, + {file = "yarl-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737e9f171e5a07031cbee5e9180f6ce21a6c599b9d4b2c24d35df20a52fabf4b"}, + {file = "yarl-1.20.0-cp312-cp312-win32.whl", hash = "sha256:839de4c574169b6598d47ad61534e6981979ca2c820ccb77bf70f4311dd2cc64"}, + {file = "yarl-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:3d7dbbe44b443b0c4aa0971cb07dcb2c2060e4a9bf8d1301140a33a93c98e18c"}, + {file = "yarl-1.20.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2137810a20b933b1b1b7e5cf06a64c3ed3b4747b0e5d79c9447c00db0e2f752f"}, + {file = "yarl-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:447c5eadd750db8389804030d15f43d30435ed47af1313303ed82a62388176d3"}, + {file = "yarl-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42fbe577272c203528d402eec8bf4b2d14fd49ecfec92272334270b850e9cd7d"}, + {file = "yarl-1.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18e321617de4ab170226cd15006a565d0fa0d908f11f724a2c9142d6b2812ab0"}, + {file = "yarl-1.20.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4345f58719825bba29895011e8e3b545e6e00257abb984f9f27fe923afca2501"}, + {file = "yarl-1.20.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d9b980d7234614bc4674468ab173ed77d678349c860c3af83b1fffb6a837ddc"}, + {file = "yarl-1.20.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af4baa8a445977831cbaa91a9a84cc09debb10bc8391f128da2f7bd070fc351d"}, + {file = "yarl-1.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:123393db7420e71d6ce40d24885a9e65eb1edefc7a5228db2d62bcab3386a5c0"}, + {file = "yarl-1.20.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab47acc9332f3de1b39e9b702d9c916af7f02656b2a86a474d9db4e53ef8fd7a"}, + {file = "yarl-1.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4a34c52ed158f89876cba9c600b2c964dfc1ca52ba7b3ab6deb722d1d8be6df2"}, + {file = "yarl-1.20.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:04d8cfb12714158abf2618f792c77bc5c3d8c5f37353e79509608be4f18705c9"}, + {file = "yarl-1.20.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7dc63ad0d541c38b6ae2255aaa794434293964677d5c1ec5d0116b0e308031f5"}, + {file = "yarl-1.20.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d02b591a64e4e6ca18c5e3d925f11b559c763b950184a64cf47d74d7e41877"}, + {file = "yarl-1.20.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:95fc9876f917cac7f757df80a5dda9de59d423568460fe75d128c813b9af558e"}, + {file = "yarl-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bb769ae5760cd1c6a712135ee7915f9d43f11d9ef769cb3f75a23e398a92d384"}, + {file = "yarl-1.20.0-cp313-cp313-win32.whl", hash = "sha256:70e0c580a0292c7414a1cead1e076c9786f685c1fc4757573d2967689b370e62"}, + {file = "yarl-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:4c43030e4b0af775a85be1fa0433119b1565673266a70bf87ef68a9d5ba3174c"}, + {file = "yarl-1.20.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b6c4c3d0d6a0ae9b281e492b1465c72de433b782e6b5001c8e7249e085b69051"}, + {file = "yarl-1.20.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8681700f4e4df891eafa4f69a439a6e7d480d64e52bf460918f58e443bd3da7d"}, + {file = "yarl-1.20.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:84aeb556cb06c00652dbf87c17838eb6d92cfd317799a8092cee0e570ee11229"}, + {file = "yarl-1.20.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f166eafa78810ddb383e930d62e623d288fb04ec566d1b4790099ae0f31485f1"}, + {file = "yarl-1.20.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5d3d6d14754aefc7a458261027a562f024d4f6b8a798adb472277f675857b1eb"}, + {file = "yarl-1.20.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a8f64df8ed5d04c51260dbae3cc82e5649834eebea9eadfd829837b8093eb00"}, + {file = "yarl-1.20.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d9949eaf05b4d30e93e4034a7790634bbb41b8be2d07edd26754f2e38e491de"}, + {file = "yarl-1.20.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c366b254082d21cc4f08f522ac201d0d83a8b8447ab562732931d31d80eb2a5"}, + {file = "yarl-1.20.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91bc450c80a2e9685b10e34e41aef3d44ddf99b3a498717938926d05ca493f6a"}, + {file = "yarl-1.20.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9c2aa4387de4bc3a5fe158080757748d16567119bef215bec643716b4fbf53f9"}, + {file = "yarl-1.20.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:d2cbca6760a541189cf87ee54ff891e1d9ea6406079c66341008f7ef6ab61145"}, + {file = "yarl-1.20.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:798a5074e656f06b9fad1a162be5a32da45237ce19d07884d0b67a0aa9d5fdda"}, + {file = "yarl-1.20.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f106e75c454288472dbe615accef8248c686958c2e7dd3b8d8ee2669770d020f"}, + {file = "yarl-1.20.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3b60a86551669c23dc5445010534d2c5d8a4e012163218fc9114e857c0586fdd"}, + {file = "yarl-1.20.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e429857e341d5e8e15806118e0294f8073ba9c4580637e59ab7b238afca836f"}, + {file = "yarl-1.20.0-cp313-cp313t-win32.whl", hash = "sha256:65a4053580fe88a63e8e4056b427224cd01edfb5f951498bfefca4052f0ce0ac"}, + {file = "yarl-1.20.0-cp313-cp313t-win_amd64.whl", hash = "sha256:53b2da3a6ca0a541c1ae799c349788d480e5144cac47dba0266c7cb6c76151fe"}, + {file = "yarl-1.20.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:119bca25e63a7725b0c9d20ac67ca6d98fa40e5a894bd5d4686010ff73397914"}, + {file = "yarl-1.20.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:35d20fb919546995f1d8c9e41f485febd266f60e55383090010f272aca93edcc"}, + {file = "yarl-1.20.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:484e7a08f72683c0f160270566b4395ea5412b4359772b98659921411d32ad26"}, + {file = "yarl-1.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d8a3d54a090e0fff5837cd3cc305dd8a07d3435a088ddb1f65e33b322f66a94"}, + {file = "yarl-1.20.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f0cf05ae2d3d87a8c9022f3885ac6dea2b751aefd66a4f200e408a61ae9b7f0d"}, + {file = "yarl-1.20.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a884b8974729e3899d9287df46f015ce53f7282d8d3340fa0ed57536b440621c"}, + {file = "yarl-1.20.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f8d8aa8dd89ffb9a831fedbcb27d00ffd9f4842107d52dc9d57e64cb34073d5c"}, + {file = "yarl-1.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4e88d6c3c8672f45a30867817e4537df1bbc6f882a91581faf1f6d9f0f1b5a"}, + {file = "yarl-1.20.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdb77efde644d6f1ad27be8a5d67c10b7f769804fff7a966ccb1da5a4de4b656"}, + {file = "yarl-1.20.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4ba5e59f14bfe8d261a654278a0f6364feef64a794bd456a8c9e823071e5061c"}, + {file = "yarl-1.20.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:d0bf955b96ea44ad914bc792c26a0edcd71b4668b93cbcd60f5b0aeaaed06c64"}, + {file = "yarl-1.20.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:27359776bc359ee6eaefe40cb19060238f31228799e43ebd3884e9c589e63b20"}, + {file = "yarl-1.20.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:04d9c7a1dc0a26efb33e1acb56c8849bd57a693b85f44774356c92d610369efa"}, + {file = "yarl-1.20.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:faa709b66ae0e24c8e5134033187a972d849d87ed0a12a0366bedcc6b5dc14a5"}, + {file = "yarl-1.20.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:44869ee8538208fe5d9342ed62c11cc6a7a1af1b3d0bb79bb795101b6e77f6e0"}, + {file = "yarl-1.20.0-cp39-cp39-win32.whl", hash = "sha256:b7fa0cb9fd27ffb1211cde944b41f5c67ab1c13a13ebafe470b1e206b8459da8"}, + {file = "yarl-1.20.0-cp39-cp39-win_amd64.whl", hash = "sha256:d4fad6e5189c847820288286732075f213eabf81be4d08d6cc309912e62be5b7"}, + {file = "yarl-1.20.0-py3-none-any.whl", hash = "sha256:5d0fe6af927a47a230f31e6004621fd0959eaa915fc62acfafa67ff7229a3124"}, + {file = "yarl-1.20.0.tar.gz", hash = "sha256:686d51e51ee5dfe62dec86e4866ee0e9ed66df700d55c828a615640adc885307"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" +propcache = ">=0.2.1" + [[package]] name = "zipp" version = "3.23.0" @@ -3982,4 +4627,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "ab9cb5225818d329efc08b1be7cd5e2e15ce884edb035d073112bd5199c38ceb" +content-hash = "ae5bce07aa5a9c4f3ecee96e22e1d1eb49f28d07e10b9c615187e23f922eb0db" diff --git a/backend_py/primary/primary/auth/enforce_logged_in_middleware.py b/backend_py/primary/primary/auth/enforce_logged_in_middleware.py index 4ecc4503a..9062b8fc7 100644 --- a/backend_py/primary/primary/auth/enforce_logged_in_middleware.py +++ b/backend_py/primary/primary/auth/enforce_logged_in_middleware.py @@ -128,8 +128,10 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) - path_is_protected = True - if path_to_check in ["/login", "/auth-callback"] + self._unprotected_paths: - path_is_protected = False + for unprotected in ["/login", "/auth-callback"] + self._unprotected_paths: + if path_to_check.startswith(unprotected): + path_is_protected = False + break if path_is_protected: diff --git a/backend_py/primary/primary/config.py b/backend_py/primary/primary/config.py index 14c3d9df8..510227a2e 100644 --- a/backend_py/primary/primary/config.py +++ b/backend_py/primary/primary/config.py @@ -33,3 +33,11 @@ DEFAULT_STALE_WHILE_REVALIDATE = 3600 * 24 # 24 hour REDIS_USER_SESSION_URL = "redis://redis-user-session:6379" REDIS_CACHE_URL = "redis://redis-cache:6379" + +COSMOS_DB_PROD_CONNECTION_STRING = os.environ.get("WEBVIZ_DB_CONNECTION_STRING", None) +# pylint: disable=line-too-long +COSMOS_DB_EMULATOR_URI = "https://host.docker.internal:8081/" +COSMOS_DB_EMULATOR_KEY = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==;" + +PERSISTENCE_DB_NAME = "persistence" +DASHBOARDS_CONTAINER_NAME = "dashboards" diff --git a/backend_py/primary/primary/main.py b/backend_py/primary/primary/main.py index 0132dd6b1..1126ee736 100644 --- a/backend_py/primary/primary/main.py +++ b/backend_py/primary/primary/main.py @@ -12,6 +12,7 @@ from primary.auth.auth_helper import AuthHelper from primary.auth.enforce_logged_in_middleware import EnforceLoggedInMiddleware from primary.middleware.add_process_time_to_server_timing_middleware import AddProcessTimeToServerTimingMiddleware +from primary.services.database_access.setup_local_database import maybe_setup_local_database from primary.middleware.add_browser_cache import AddBrowserCacheMiddleware from primary.routers.dev.router import router as dev_router @@ -32,6 +33,9 @@ from primary.routers.vfp.router import router as vfp_router from primary.routers.well.router import router as well_router from primary.routers.well_completions.router import router as well_completions_router +from primary.routers.persistence.sessions.router import router as sessions_router +from primary.routers.persistence.snapshots.router import router as snapshots_router +from primary.routers.persistence.snapshot_preview.router import router as snapshot_preview_router from primary.services.utils.httpx_async_client_wrapper import HTTPX_ASYNC_CLIENT_WRAPPER from primary.utils.azure_monitor_setup import setup_azure_monitor_telemetry from primary.utils.exception_handlers import configure_service_level_exception_handlers @@ -58,6 +62,9 @@ LOGGER = logging.getLogger(__name__) +# Setup Cosmos DB emulator database if running locally +maybe_setup_local_database() + def custom_generate_unique_id(route: APIRoute) -> str: return f"{route.name}" @@ -106,6 +113,9 @@ async def shutdown_event_async() -> None: app.include_router(rft_router, prefix="/rft", tags=["rft"]) app.include_router(vfp_router, prefix="/vfp", tags=["vfp"]) app.include_router(dev_router, prefix="/dev", tags=["dev"], include_in_schema=False) +app.include_router(sessions_router, prefix="/sessions", tags=["sessions"]) +app.include_router(snapshots_router, prefix="/snapshots", tags=["snapshots"]) +app.include_router(snapshot_preview_router, prefix="/snapshot-preview", tags=["snapshot_preview"]) auth_helper = AuthHelper() app.include_router(auth_helper.router) @@ -120,7 +130,7 @@ async def shutdown_event_async() -> None: # Add out custom middleware to enforce that user is logged in # Also redirects to /login endpoint for some select paths -unprotected_paths = ["/logout", "/logged_in_user", "/alive", "/openapi.json"] +unprotected_paths = ["/logout", "/logged_in_user", "/alive", "/openapi.json", "/snapshot-preview"] paths_redirected_to_login = ["/", "/alive_protected"] app.add_middleware( diff --git a/backend_py/primary/primary/routers/persistence/sessions/converters.py b/backend_py/primary/primary/routers/persistence/sessions/converters.py new file mode 100644 index 000000000..0d208ef23 --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/sessions/converters.py @@ -0,0 +1,34 @@ +from primary.services.database_access.session_access.model import SessionDocument +from primary.services.database_access.session_access.types import SessionMetadata, SessionMetadataWithId +from . import schemas + + +def to_api_session_metadata_summary(metadata: SessionMetadataWithId) -> schemas.SessionMetadataWithId: + return schemas.SessionMetadataWithId( + id=metadata.id, + title=metadata.title, + description=metadata.description, + createdAt=metadata.created_at.isoformat(), + updatedAt=metadata.updated_at.isoformat(), + version=metadata.version, + ) + + +def to_api_session_metadata(metadata: SessionMetadata) -> schemas.SessionMetadata: + return schemas.SessionMetadata( + title=metadata.title, + description=metadata.description, + createdAt=metadata.created_at.isoformat(), + updatedAt=metadata.updated_at.isoformat(), + version=metadata.version, + hash=metadata.hash, + ) + + +def to_api_session_record(document: SessionDocument) -> schemas.SessionDocument: + return schemas.SessionDocument( + id=document.id, + ownerId=document.owner_id, + metadata=to_api_session_metadata(document.metadata), + content=document.content, + ) diff --git a/backend_py/primary/primary/routers/persistence/sessions/router.py b/backend_py/primary/primary/routers/persistence/sessions/router.py new file mode 100644 index 000000000..27f2ca957 --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/sessions/router.py @@ -0,0 +1,88 @@ +import logging +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from primary.middleware.add_browser_cache import no_cache +from primary.services.database_access.session_access.session_access import SessionAccess +from primary.auth.auth_helper import AuthHelper, AuthenticatedUser +from primary.services.database_access.session_access.types import ( + NewSession, + SessionUpdate, + SortBy, + SortDirection, +) +from primary.routers.persistence.sessions.converters import ( + to_api_session_metadata_summary, + to_api_session_metadata, + to_api_session_record, +) + +from . import schemas + +LOGGER = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/sessions", response_model=List[schemas.SessionMetadataWithId]) +@no_cache +async def get_sessions_metadata( + user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), + sort_by: Optional[SortBy] = Query(None, description="Sort the result by"), + sort_direction: Optional[SortDirection] = Query(SortDirection.ASC, description="Sort direction: 'asc' or 'desc'"), + limit: Optional[int] = Query(10, ge=1, le=100, description="Limit the number of results"), +): + access = SessionAccess.create(user.get_user_id()) + async with access: + items = await access.get_filtered_sessions_metadata_for_user_async( + sort_by=sort_by, sort_direction=sort_direction, limit=limit + ) + return [to_api_session_metadata_summary(item) for item in items] + + +@router.get("/sessions/{session_id}", response_model=schemas.SessionDocument) +@no_cache +async def get_session(session_id: str, user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user)): + access = SessionAccess.create(user.get_user_id()) + async with access: + session = await access.get_session_by_id_async(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + return to_api_session_record(session) + + +@router.get("/sessions/metadata/{session_id}", response_model=schemas.SessionMetadata) +@no_cache +async def get_session_metadata(session_id: str, user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user)): + access = SessionAccess.create(user.get_user_id()) + async with access: + metadata = await access.get_session_metadata_async(session_id) + if not metadata: + raise HTTPException(status_code=404, detail="Session metadata not found") + return to_api_session_metadata(metadata) + + +@router.post("/sessions", response_model=str) +async def create_session(session: NewSession, user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user)): + access = SessionAccess.create(user.get_user_id()) + async with access: + session_id = await access.insert_session_async(session) + return session_id + + +@router.put("/sessions/{session_id}") +async def update_session( + session_id: str, + session_update: SessionUpdate, + user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), +): + access = SessionAccess.create(user.get_user_id()) + async with access: + await access.update_session_async(session_id, session_update) + + +@router.delete("/sessions/{session_id}") +async def delete_session(session_id: str, user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user)): + access = SessionAccess.create(user.get_user_id()) + async with access: + await access.delete_session_async(session_id) diff --git a/backend_py/primary/primary/routers/persistence/sessions/schemas.py b/backend_py/primary/primary/routers/persistence/sessions/schemas.py new file mode 100644 index 000000000..a79b90f24 --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/sessions/schemas.py @@ -0,0 +1,27 @@ +from typing import Optional +from pydantic import BaseModel + + +class SessionMetadataWithId(BaseModel): + id: str + title: str + description: Optional[str] + createdAt: str + updatedAt: str + version: int + + +class SessionMetadata(BaseModel): + title: str + description: Optional[str] + createdAt: str + updatedAt: str + version: int + hash: str + + +class SessionDocument(BaseModel): + id: str + ownerId: str + metadata: SessionMetadata + content: str diff --git a/backend_py/primary/primary/routers/persistence/snapshot_preview/router.py b/backend_py/primary/primary/routers/persistence/snapshot_preview/router.py new file mode 100644 index 000000000..68cacc2c1 --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/snapshot_preview/router.py @@ -0,0 +1,44 @@ +import html +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import HTMLResponse + +from primary.services.database_access.snapshot_access.snapshot_access import SnapshotAccess + +router = APIRouter() + + +@router.get("/{snapshot_id}", response_class=HTMLResponse) +async def snapshot_preview(snapshot_id: str, request: Request): + access = await SnapshotAccess.create("") + async with access: + metadata = await access.get_snapshot_metadata(snapshot_id) + if not metadata: + raise HTTPException(status_code=404, detail="Snapshot metadata not found") + + base_url = get_external_base_url(request) + snapshot_url = f"{base_url}/snapshot/{snapshot_id}" + + title = html.escape(metadata.title) + description = html.escape(metadata.description or "No description available") + + return f""" + + + + + + + + + + + Redirecting… + + + """ + + +def get_external_base_url(request: Request) -> str: + forwarded_proto = request.headers.get("x-forwarded-proto", "http") + forwarded_host = request.headers.get("x-forwarded-host", request.headers.get("host", "localhost")) + return f"{forwarded_proto}://{forwarded_host}" diff --git a/backend_py/primary/primary/routers/persistence/snapshots/converters.py b/backend_py/primary/primary/routers/persistence/snapshots/converters.py new file mode 100644 index 000000000..cba984578 --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/snapshots/converters.py @@ -0,0 +1,46 @@ +from primary.services.database_access.snapshot_access.models import SnapshotAccessLog +from primary.services.database_access.snapshot_access.types import Snapshot, SnapshotMetadata, SnapshotMetadataWithId + +from . import schemas + + +def to_api_snapshot_metadata_summary(metadata: SnapshotMetadataWithId) -> schemas.SnapshotMetadataWithId: + return schemas.SnapshotMetadataWithId( + id=metadata.id, + ownerId=metadata.owner_id, + title=metadata.title, + description=metadata.description, + createdAt=metadata.created_at.isoformat(), + updatedAt=metadata.updated_at.isoformat(), + hash=metadata.hash, + ) + + +def to_api_snapshot_metadata(metadata: SnapshotMetadata) -> schemas.SnapshotMetadata: + return schemas.SnapshotMetadata( + ownerId=metadata.owner_id, + title=metadata.title, + description=metadata.description, + createdAt=metadata.created_at.isoformat(), + updatedAt=metadata.updated_at.isoformat(), + hash=metadata.hash, + ) + + +def to_api_snapshot(snapshot: Snapshot) -> schemas.Snapshot: + return schemas.Snapshot( + id=snapshot.id, + metadata=to_api_snapshot_metadata(snapshot.metadata), + content=snapshot.content, + ) + + +def to_api_snapshot_access_log(access_log: SnapshotAccessLog, metadata: SnapshotMetadata) -> schemas.SnapshotAccessLog: + return schemas.SnapshotAccessLog( + visitorId=access_log.visitor_id, + snapshotId=access_log.snapshot_id, + visits=access_log.visits, + firstVisitedAt=access_log.first_visited_at.isoformat() if access_log.first_visited_at else None, + lastVisitedAt=access_log.last_visited_at.isoformat() if access_log.last_visited_at else None, + snapshotMetadata=to_api_snapshot_metadata(metadata), + ) diff --git a/backend_py/primary/primary/routers/persistence/snapshots/router.py b/backend_py/primary/primary/routers/persistence/snapshots/router.py new file mode 100644 index 000000000..961ff27cb --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/snapshots/router.py @@ -0,0 +1,134 @@ +import logging +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from primary.services.database_access.snapshot_access.types import ( + NewSnapshot, + SnapshotUpdate, + SortBy, + SortDirection, +) +from primary.middleware.add_browser_cache import no_cache +from primary.services.database_access.snapshot_access.snapshot_access import SnapshotAccess +from primary.services.database_access.snapshot_access.snapshot_logs_access import SnapshotLogsAccess +from primary.services.database_access.snapshot_access.query_collation_options import QueryCollationOptions + + +from primary.auth.auth_helper import AuthHelper, AuthenticatedUser +from primary.routers.persistence.snapshots.converters import ( + to_api_snapshot, + to_api_snapshot_access_log, + to_api_snapshot_metadata, + to_api_snapshot_metadata_summary, +) + + +from . import schemas + +LOGGER = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/recent_snapshots", response_model=list[schemas.SnapshotAccessLog]) +async def get_recent_snapshots( + user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), + sort_by: Optional[SortBy] = Query(SortBy.LAST_VISIT, description="Sort the result by"), + sort_direction: Optional[SortDirection] = Query(SortDirection.DESC, description="Sort direction: 'asc' or 'desc'"), + limit: Optional[int] = Query(5, ge=1, le=100, description="Limit the number of results"), + offset: Optional[int] = Query(0, ge=0, description="The offset of the results"), +) -> list[schemas.SnapshotAccessLog]: + async with ( + SnapshotAccess.create(user.get_user_id()) as snapshot_access, + SnapshotLogsAccess.create(user.get_user_id()) as log_access, + ): + collation_options = QueryCollationOptions(sort_by=sort_by, sort_dir=sort_direction, limit=limit, offset=offset) + + recent_logs = await log_access.get_access_logs_for_user_async(collation_options) + + payload: list[schemas.SnapshotAccessLog] = [] + + for log in recent_logs: + metadata = await snapshot_access.get_snapshot_metadata_async(log.snapshot_id, log.snapshot_owner_id) + + payload.append(to_api_snapshot_access_log(log, metadata)) + + return payload + + +@router.get("/snapshots", response_model=List[schemas.SnapshotMetadata]) +@no_cache +async def get_snapshots_metadata( + user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), + sort_by: Optional[SortBy] = Query(SortBy.LAST_VISIT, description="Sort the result by"), + sort_direction: Optional[SortDirection] = Query(SortDirection.DESC, description="Sort direction: 'asc' or 'desc'"), + limit: Optional[int] = Query(10, ge=1, le=100, description="Limit the number of results"), +) -> List[schemas.SnapshotMetadata]: + access = SnapshotAccess.create(user.get_user_id()) + async with access: + items = await access.get_filtered_snapshots_metadata_for_user_async( + sort_by=sort_by, sort_direction=sort_direction, limit=limit + ) + return [to_api_snapshot_metadata_summary(item) for item in items] + + +@router.get("/snapshots/{snapshot_id}", response_model=schemas.Snapshot) +@no_cache +async def get_snapshot( + snapshot_id: str, user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user) +) -> schemas.Snapshot: + access = SnapshotAccess.create(user.get_user_id()) + logs_access = SnapshotLogsAccess.create(user_id=user.get_user_id()) + + async with access, logs_access: + snapshot = await access.get_snapshot_by_id_async(snapshot_id) + if not snapshot: + raise HTTPException(status_code=404, detail="Snapshot not found") + + await logs_access.log_snapshot_visit_async(snapshot_id, snapshot.owner_id) + + return to_api_snapshot(snapshot) + + +@router.get("/snapshots/metadata/{snapshot_id}", response_model=schemas.SnapshotMetadata) +@no_cache +async def get_snapshot_metadata(snapshot_id: str, user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user)): + access = SnapshotAccess.create(user.get_user_id()) + async with access: + metadata = await access.get_snapshot_metadata_async(snapshot_id) + if not metadata: + raise HTTPException(status_code=404, detail="Session metadata not found") + return to_api_snapshot_metadata(metadata) + + +@router.post("/snapshots", response_model=str) +async def create_snapshot( + session: NewSnapshot, user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user) +) -> str: + access = SnapshotAccess.create(user.get_user_id()) + logs_access = SnapshotLogsAccess.create(user.get_user_id()) + + async with access, logs_access: + snapshot_id = await access.insert_snapshot_async(session) + + # We count snapshot creation as implicit visit. This also makes it so + await logs_access.log_snapshot_visit_async(snapshot_id=snapshot_id, snapshot_owner_id=user.get_user_id()) + return snapshot_id + + +@router.put("/snapshots/{snapshot_id}") +async def update_snapshot( + snapshot_id: str, + snapshot_update: SnapshotUpdate, + user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), +): + access = SnapshotAccess.create(user.get_user_id()) + async with access: + await access.update_snapshot_metadata_async(snapshot_id, snapshot_update) + + +@router.delete("/snapshots/{snapshot_id}") +async def delete_snapshot(snapshot_id: str, user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user)): + access = SnapshotAccess.create(user.get_user_id()) + async with access: + await access.delete_snapshot_async(snapshot_id) diff --git a/backend_py/primary/primary/routers/persistence/snapshots/schemas.py b/backend_py/primary/primary/routers/persistence/snapshots/schemas.py new file mode 100644 index 000000000..0b2469cbc --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/snapshots/schemas.py @@ -0,0 +1,40 @@ +from typing import Optional +from pydantic import BaseModel + +from primary.services.database_access.snapshot_access.util import make_access_log_item_id + + +class SnapshotMetadata(BaseModel): + ownerId: str + title: str + description: Optional[str] + createdAt: str + updatedAt: str + hash: str + + +class SnapshotMetadataWithId(SnapshotMetadata): + id: str + + +class Snapshot(BaseModel): + id: str + metadata: SnapshotMetadata + content: str + + +class SnapshotAccessLog(BaseModel): + visitorId: str + snapshotId: str + visits: int + firstVisitedAt: str | None + lastVisitedAt: str | None + + snapshotMetadata: SnapshotMetadata + + # Internal item id + @property + # pylint: disable=invalid-name + # ↳ pylint v2 will complain about names that are shorter than 3 characters + def id(self) -> str: + return make_access_log_item_id(self.snapshotId, self.visitorId) diff --git a/backend_py/primary/primary/services/database_access/__init__.py b/backend_py/primary/primary/services/database_access/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend_py/primary/primary/services/database_access/_utils.py b/backend_py/primary/primary/services/database_access/_utils.py new file mode 100644 index 000000000..95f04a475 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/_utils.py @@ -0,0 +1,13 @@ +import hashlib + + +# Utility function to hash a JSON string using SHA-256 +# This function mimics the behavior of TextEncoder in JavaScript, which encodes strings to +# UTF-8 before hashing. The output is a hexadecimal string representation of the hash. +# +# It is important that this function returns the same hash as the JavaScript version +def hash_json_string(json_string: str) -> str: + data = json_string.encode("utf-8") # Matches TextEncoder behavior + hash_bytes = hashlib.sha256(data).digest() + hash_hex = "".join(f"{b:02x}" for b in hash_bytes) + return hash_hex diff --git a/backend_py/primary/primary/services/database_access/container_access.py b/backend_py/primary/primary/services/database_access/container_access.py new file mode 100644 index 000000000..4ac2b65fd --- /dev/null +++ b/backend_py/primary/primary/services/database_access/container_access.py @@ -0,0 +1,118 @@ +import logging +from typing import Dict, Generic, List, Optional, Type, TypeVar +from azure.cosmos.aio import ContainerProxy +from azure.cosmos import exceptions +from pydantic import BaseModel, ValidationError + +from primary.services.service_exceptions import Service, ServiceRequestError +from .database_access import DatabaseAccess + + +logger = logging.getLogger(__name__) + +T = TypeVar("T", bound=BaseModel) + +""" +ContainerAccess provides access to a specific container in a Cosmos DB database. +It allows for querying, inserting, updating, and deleting items in the container. +It uses a Pydantic model for item validation and serialization. + +It is designed to be used with asynchronous context management, ensuring proper resource cleanup. +It raises ServiceRequestError for any issues encountered during operations, providing a clear error message. +""" + + +class ContainerAccess(Generic[T]): + def __init__( + self, + database_name: str, + container_name: str, + database_access: DatabaseAccess, + container: ContainerProxy, + validation_model: Type[T], + ): + self._database_name = database_name + self._container_name = container_name + self._database_access = database_access + self._container = container + self._validation_model: Type[T] = validation_model + + @classmethod + def create(cls, database_name: str, container_name: str, validation_model: Type[T]) -> "ContainerAccess[T]": + """Create a ContainerAccess instance.""" + db_access = DatabaseAccess.create(database_name) + container = db_access.get_container(container_name) + logger.debug("[ContainerAccess] Created for container '%s' in database '%s'", container_name, database_name) + return cls(database_name, container_name, db_access, container, validation_model) + + async def __aenter__(self): # pylint: disable=C9001 + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): # pylint: disable=C9001 + await self.close_async() + + def _raise_exception(self, message: str): + raise ServiceRequestError( + f"ContainerAccess ({self._database_name}, {self._container_name}): {message}", Service.DATABASE + ) + + async def query_items_async(self, query: str, parameters: Optional[List[Dict[str, object]]] = None) -> List[T]: + try: + items_iterable = self._container.query_items( + query=query, + parameters=parameters or [], + ) + items = [item async for item in items_iterable] + return [self._validation_model.model_validate(item) for item in items] + except ValidationError as validation_error: + logger.error("[ContainerAccess] Validation error in '%s': %s", self._container_name, validation_error) + raise + except exceptions.CosmosHttpResponseError as error: + self._raise_exception(error.message) + + async def get_item_async(self, item_id: str, partition_key: str) -> T: + try: + item = await self._container.read_item(item=item_id, partition_key=partition_key) + return self._validation_model.model_validate(item) + except ValidationError as validation_error: + logger.error("[ContainerAccess] Validation error in '%s': %s", self._container_name, validation_error) + raise + except exceptions.CosmosHttpResponseError as error: + self._raise_exception(error.message) + + async def insert_item_async(self, item: T) -> str: + try: + item = self._validation_model.model_validate(item).model_dump(by_alias=True, mode="json") + result = await self._container.upsert_item(item) + return result["id"] + except ValidationError as validation_error: + logger.error("[ContainerAccess] Validation error in '%s': %s", self._container_name, validation_error) + raise + except exceptions.CosmosHttpResponseError as error: + self._raise_exception(error.message) + + async def delete_item_async(self, item_id: str, partition_key: str): + try: + await self._container.delete_item(item=item_id, partition_key=partition_key) + logger.debug("[ContainerAccess] Deleted item '%s' from '%s'", item_id, self._container_name) + except exceptions.CosmosHttpResponseError as error: + self._raise_exception(error.message) + + async def update_item_async(self, item_id: str, updated_item: T): + try: + validated = self._validation_model.model_validate(updated_item).model_dump(by_alias=True, mode="json") + await self._container.upsert_item(validated) + logger.debug("[ContainerAccess] Updated item '%s' in '%s'", item_id, self._container_name) + except ValidationError as validation_error: + logger.error("[ContainerAccess] Validation error in '%s': %s", self._container_name, validation_error) + raise + except exceptions.CosmosHttpResponseError as error: + self._raise_exception(error.message) + + async def close_async(self): + """Close the container access.""" + if self._database_access: + logger.debug("[ContainerAccess] Closing access to '%s/%s'", self._database_name, self._container_name) + await self._database_access.close_async() + self._database_access = None + self._container = None diff --git a/backend_py/primary/primary/services/database_access/database_access.py b/backend_py/primary/primary/services/database_access/database_access.py new file mode 100644 index 000000000..b8bc9f369 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/database_access.py @@ -0,0 +1,54 @@ +from azure.cosmos.aio import CosmosClient, ContainerProxy +from azure.cosmos import exceptions + +from primary.config import COSMOS_DB_PROD_CONNECTION_STRING, COSMOS_DB_EMULATOR_URI, COSMOS_DB_EMULATOR_KEY +from primary.services.service_exceptions import Service, ServiceRequestError + + +class DatabaseAccess: + def __init__(self, database_name: str, client: CosmosClient): + self._database_name = database_name + self._client = client + self._database = self._client.get_database_client(database_name) + + @classmethod + def create(cls, database_name: str) -> "DatabaseAccess": + if COSMOS_DB_PROD_CONNECTION_STRING: + client = CosmosClient.from_connection_string(COSMOS_DB_PROD_CONNECTION_STRING) + elif COSMOS_DB_EMULATOR_URI and COSMOS_DB_EMULATOR_KEY: + client = CosmosClient(COSMOS_DB_EMULATOR_URI, COSMOS_DB_EMULATOR_KEY, connection_verify=False) + else: + raise ServiceRequestError( + "No Cosmos DB production connection string or emulator URI/key provided.", Service.DATABASE + ) + self = cls(database_name, client) + return self + + async def __aenter__(self): # pylint: disable=C9001 + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): # pylint: disable=C9001 + await self.close_async() + + def _raise_exception(self, message: str): + raise ServiceRequestError(f"DatabaseAccess ({self._database_name}): {message}", Service.DATABASE) + + def get_container(self, container_name: str) -> ContainerProxy: + if not self._client or not self._database: + self._raise_exception("Database client is not initialized or already closed.") + if not container_name or not isinstance(container_name, str): + self._raise_exception("Invalid container name.") + + try: + container = self._database.get_container_client(container_name) + return container + except exceptions.CosmosHttpResponseError as error: + self._raise_exception(f"Unable to access container '{container_name}': {error.message}") + + return None # unreachable; satisfies pylint R1710 + + async def close_async(self): + if self._client: + await self._client.close() + self._client = None + self._database = None diff --git a/backend_py/primary/primary/services/database_access/session_access/model.py b/backend_py/primary/primary/services/database_access/session_access/model.py new file mode 100644 index 000000000..e29d2d809 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/session_access/model.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, ConfigDict, field_validator +from primary.services.database_access.session_access.types import SessionMetadata + + +class SessionDocument(BaseModel): + id: str + owner_id: str + metadata: SessionMetadata + content: str + + model_config = ConfigDict(extra="ignore") diff --git a/backend_py/primary/primary/services/database_access/session_access/session_access.py b/backend_py/primary/primary/services/database_access/session_access/session_access.py new file mode 100644 index 000000000..4870f42e2 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/session_access/session_access.py @@ -0,0 +1,144 @@ +from typing import Optional, List +from datetime import datetime, timezone +from nanoid import generate +from azure.cosmos.exceptions import CosmosResourceNotFoundError + +from primary.services.database_access.session_access.model import SessionDocument +from primary.services.database_access._utils import hash_json_string +from primary.services.service_exceptions import Service, ServiceRequestError +from primary.services.database_access.container_access import ContainerAccess +from primary.services.database_access.session_access.types import ( + NewSession, + SessionMetadataWithId, + SessionMetadata, + SessionUpdate, + SortBy, + SortDirection, +) + + +class SessionAccess: + CONTAINER_NAME = "sessions" + DATABASE_NAME = "persistence" + + def __init__(self, user_id: str, session_container_access: ContainerAccess[SessionDocument]): + self.user_id = user_id + self.session_container_access = session_container_access + + async def __aenter__(self): # pylint: disable=C9001 + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): # pylint: disable=C9001 + await self.session_container_access.close_async() + + @classmethod + def create(cls, user_id: str): + session_container_access = ContainerAccess.create(cls.DATABASE_NAME, cls.CONTAINER_NAME, SessionDocument) + return cls(user_id=user_id, session_container_access=session_container_access) + + async def get_session_by_id_async(self, session_id: str) -> SessionDocument: + document = await self.session_container_access.get_item_async(item_id=session_id, partition_key=self.user_id) + return document + + async def get_all_sessions_metadata_for_user_async(self) -> List[SessionMetadataWithId]: + query = "SELECT * FROM c WHERE c.owner_id = @owner_id" + params = [{"name": "@owner_id", "value": self.user_id}] + items = await self.session_container_access.query_items_async(query=query, parameters=params) + return [self._to_metadata_summary(item) for item in items] + + async def get_filtered_sessions_metadata_for_user_async( + self, + sort_by: Optional[SortBy] = SortBy.CREATED_AT, + sort_direction: Optional[SortDirection] = SortDirection.ASC, + limit: Optional[int] = None, + offset: Optional[int] = 0, + ) -> List[SessionMetadataWithId]: + if not isinstance(sort_by.value, str) or not sort_by.value.isidentifier(): + raise ServiceRequestError("Invalid sort field specified.", Service.DATABASE) + + if sort_by == SortBy.TITLE_LOWER: + metadata_array = await self.get_all_sessions_metadata_for_user_async() + + reverse = sort_direction == SortDirection.DESC + metadata_array.sort(key=lambda s: s.title.lower() if s.title else "", reverse=reverse) + + return metadata_array[offset:] if limit is None else metadata_array[offset : offset + limit] + + offset_clause = f"OFFSET {offset} LIMIT {limit}" if limit is not None else "" + query = ( + f"SELECT * FROM c " + f"WHERE c.owner_id = @owner_id " + f"ORDER BY c.metadata.{sort_by.value} {sort_direction.value} " + offset_clause + ) + + params = [ + {"name": "@owner_id", "value": self.user_id}, + ] + + items = await self.session_container_access.query_items_async(query=query, parameters=params) + + return [self._to_metadata_summary(item) for item in items] + + async def get_session_metadata_async(self, session_id: str) -> SessionMetadata: + existing = await self._assert_ownership_async(session_id) + + return existing.metadata + + async def insert_session_async(self, new_session: NewSession) -> str: + now = datetime.now(timezone.utc) + session_id = str(generate(size=8)) # Generate a unique session ID + session = SessionDocument( + id=session_id, + owner_id=self.user_id, + metadata=SessionMetadata( + title=new_session.title, + description=new_session.description, + created_at=now, + updated_at=now, + version=1, + hash=hash_json_string(new_session.content), + ), + content=new_session.content, + ) + return await self.session_container_access.insert_item_async(session) + + async def delete_session_async(self, session_id: str): + await self._assert_ownership_async(session_id) + await self.session_container_access.delete_item_async(session_id, partition_key=self.user_id) + + async def update_session_async(self, session_id: str, session_update: SessionUpdate): + existing = await self._assert_ownership_async(session_id) + + updated_metadata = existing.metadata.model_copy( + update={ + "title": session_update.metadata.title, + "description": session_update.metadata.description, + "version": existing.metadata.version + 1, + "updated_at": datetime.now(timezone.utc), + "hash": hash_json_string(session_update.content), + } + ) + + updated_session = SessionDocument( + id=session_id, + owner_id=self.user_id, + content=session_update.content, + metadata=updated_metadata, + ) + + await self.session_container_access.update_item_async(session_id, updated_session) + + async def _assert_ownership_async(self, session_id: str) -> SessionDocument: + try: + session = await self.session_container_access.get_item_async(item_id=session_id, partition_key=self.user_id) + except CosmosResourceNotFoundError: + raise ServiceRequestError(f"Session with id '{session_id}' not found.", Service.DATABASE) + + if session.owner_id != self.user_id: + raise ServiceRequestError(f"You do not have permission to access session '{session_id}'.", Service.DATABASE) + + return session + + @staticmethod + def _to_metadata_summary(doc: SessionDocument) -> SessionMetadataWithId: + return SessionMetadataWithId(**doc.metadata.model_dump(), id=doc.id) diff --git a/backend_py/primary/primary/services/database_access/session_access/types.py b/backend_py/primary/primary/services/database_access/session_access/types.py new file mode 100644 index 000000000..4559374cb --- /dev/null +++ b/backend_py/primary/primary/services/database_access/session_access/types.py @@ -0,0 +1,49 @@ +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel + + +class SessionUserEditableMetadata(BaseModel): + title: str + description: Optional[str] = None + + +class SessionMetadataInternal(BaseModel): + created_at: datetime + updated_at: datetime + hash: str + version: int + + +class SessionMetadata(SessionUserEditableMetadata, SessionMetadataInternal): + pass + + +class SessionMetadataWithId(SessionMetadata): + id: str + + +class SessionUpdate(BaseModel): + id: str + metadata: SessionUserEditableMetadata + content: str + + +class NewSession(BaseModel): + title: str + description: Optional[str] + content: str + + +class SortBy(str, Enum): + CREATED_AT = "created_at" + UPDATED_AT = "updated_at" + TITLE = "title" + TITLE_LOWER = "title_lower" + + +class SortDirection(str, Enum): + ASC = "asc" + DESC = "desc" diff --git a/backend_py/primary/primary/services/database_access/setup_local_database.py b/backend_py/primary/primary/services/database_access/setup_local_database.py new file mode 100644 index 000000000..338065382 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/setup_local_database.py @@ -0,0 +1,108 @@ +""" +This file is only used for setting up the local database for development and testing purposes. +""" + +import logging +import time +from typing import Optional, List, Dict, Any +import ssl +import urllib.request +from urllib.error import URLError + +from azure.cosmos import CosmosClient, PartitionKey, DatabaseProxy + +from primary.config import COSMOS_DB_PROD_CONNECTION_STRING, COSMOS_DB_EMULATOR_URI, COSMOS_DB_EMULATOR_KEY + +LOGGER = logging.getLogger(__name__) + +# Declarative schema definition +COSMOS_SCHEMA: List[Dict[str, Any]] = [ + { + "database": "persistence", + "offer_throughput": 4000, + "containers": [ + {"id": "sessions", "partition_key": "/owner_id"}, + {"id": "snapshots_metadata", "partition_key": "/owner_id"}, + {"id": "snapshots_content", "partition_key": "/snapshot_id"}, + {"id": "snapshot_access_log", "partition_key": "/visitor_id"}, + ], + }, +] + + +def wait_for_emulator(uri: str, key: str, retries: int = 50, delay: int = 10) -> CosmosClient: + probe_url = f"{uri.rstrip('/')}/_explorer/emulator.pem" + context = ssl._create_unverified_context() + + for attempt in range(retries): + try: + with urllib.request.urlopen(probe_url, context=context) as response: + if response.status == 200: + LOGGER.info("✅ Emulator HTTPS endpoint is up. Proceeding to create CosmosClient.") + break + except URLError as e: + LOGGER.warning("⏳ Emulator cert endpoint not ready (attempt %d): %s", attempt + 1, e.reason) + time.sleep(delay) + else: + raise RuntimeError("❌ Cosmos Emulator certificate endpoint not ready after timeout") + + # Now that we know HTTPS works, create the CosmosClient + return CosmosClient(uri, key, connection_verify=False) + + +def create_database_with_retry(client: CosmosClient, db_def: Dict[str, Any], max_attempts: int = 5) -> DatabaseProxy: + db_name: str = db_def["database"] + for attempt in range(1, max_attempts + 1): + try: + return client.create_database_if_not_exists(db_name, offer_throughput=db_def.get("offer_throughput")) + except Exception as error: + LOGGER.warning("⚠️ Failed to create database '%s' (attempt %d): %s", db_name, attempt, error) + if attempt == max_attempts: + raise + time.sleep(2 * attempt) + + raise RuntimeError(f"Failed to create database '{db_name}' after {max_attempts} attempts.") + + +def maybe_setup_local_database() -> None: + if COSMOS_DB_PROD_CONNECTION_STRING: + LOGGER.info("Using production Cosmos DB - skipping local setup.") + return + + if COSMOS_DB_EMULATOR_URI is None or COSMOS_DB_EMULATOR_KEY is None: + raise ValueError("No Cosmos DB production connection string or emulator URI/key provided.") + + client: CosmosClient = wait_for_emulator(COSMOS_DB_EMULATOR_URI, COSMOS_DB_EMULATOR_KEY) + + total_containers = 0 + + for db_def in COSMOS_SCHEMA: + database: DatabaseProxy = create_database_with_retry(client, db_def) + + for container_def in db_def["containers"]: + max_attempts = 5 + for attempt in range(1, max_attempts + 1): + try: + database.create_container_if_not_exists( + id=container_def["id"], + partition_key=PartitionKey(path=container_def["partition_key"]), + offer_throughput=container_def.get("throughput"), + indexing_policy=container_def.get("indexing_policy"), + ) + LOGGER.info(" ✅ Created container '%s' (attempt %d)", container_def["id"], attempt) + break + except Exception as error: + LOGGER.warning( + " ⚠️ Failed to create container '%s' (attempt %d): %s", container_def["id"], attempt, error + ) + if attempt == max_attempts: + raise + time.sleep(2 * attempt) + + total_containers += 1 + + LOGGER.info( + "✅ Local Cosmos DB emulator setup complete: %d database(s), %d container(s).", + len(COSMOS_SCHEMA), + total_containers, + ) diff --git a/backend_py/primary/primary/services/database_access/snapshot_access/__init__.py b/backend_py/primary/primary/services/database_access/snapshot_access/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend_py/primary/primary/services/database_access/snapshot_access/models.py b/backend_py/primary/primary/services/database_access/snapshot_access/models.py new file mode 100644 index 000000000..aa4bb7038 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/snapshot_access/models.py @@ -0,0 +1,59 @@ +from datetime import datetime +from pydantic import BaseModel, ConfigDict, ValidationInfo +from pydantic import computed_field, field_validator + + +from primary.services.database_access.snapshot_access.types import SnapshotMetadata + +from .util import make_access_log_item_id + + +class SnapshotMetadataDocument(BaseModel): + id: str + snapshot_id: str + owner_id: str + metadata: SnapshotMetadata + + @field_validator("snapshot_id") + @classmethod + def validate_snapshot_id(cls, val: str, info: ValidationInfo) -> str: + if val != info.data.get("id"): + raise ValueError("snapshot_id must equal id") + return val + + model_config = ConfigDict(extra="ignore") + + +class SnapshotContentDocument(BaseModel): + id: str + snapshot_id: str + owner_id: str + content: str + + @field_validator("snapshot_id") + @classmethod + def validate_snapshot_id(cls, val: str, info: ValidationInfo) -> str: + if val != info.data.get("id"): + raise ValueError("snapshot_id must equal id") + return val + + model_config = ConfigDict(extra="ignore") + + +class SnapshotAccessLog(BaseModel): + model_config = ConfigDict(extra="ignore") + + visitor_id: str # Partition key + snapshot_id: str + snapshot_owner_id: str + visits: int = 0 + first_visited_at: datetime | None = None + last_visited_at: datetime | None = None + + # Internal item id + @computed_field # type: ignore[prop-decorator] + @property + # pylint: disable=invalid-name + # ↳ pylint v2 will complain about names that are shorter than 3 characters + def id(self) -> str: + return make_access_log_item_id(self.snapshot_id, self.visitor_id) diff --git a/backend_py/primary/primary/services/database_access/snapshot_access/query_collation_options.py b/backend_py/primary/primary/services/database_access/snapshot_access/query_collation_options.py new file mode 100644 index 000000000..cc5052e5f --- /dev/null +++ b/backend_py/primary/primary/services/database_access/snapshot_access/query_collation_options.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass +from .types import SortBy, SortDirection + + +# TODO: Generalize utility to work with any model +@dataclass +class QueryCollationOptions: + """Helper class for defining NoSQL collation options""" + + sort_by: SortBy | None = None + sort_dir: SortDirection | None = None # "asc" or "desc" + limit: int | None = None + offset: int | None = 0 + + def to_sql_query_string(self, variable_name: str = "c") -> str | None: + tokens = [] + + if self.sort_by: + tokens.append(f"ORDER BY {variable_name}.{self.sort_by.value}") + + if self.sort_dir: + tokens.append(self.sort_dir.value) + + # Zero is arguably a valid limit, so explicitly check None + if self.limit is not None: + tokens.append(f"OFFSET {self.offset} LIMIT {self.limit}") + + if tokens: + return " ".join(tokens) + + return None diff --git a/backend_py/primary/primary/services/database_access/snapshot_access/snapshot_access.py b/backend_py/primary/primary/services/database_access/snapshot_access/snapshot_access.py new file mode 100644 index 000000000..67c9dd14c --- /dev/null +++ b/backend_py/primary/primary/services/database_access/snapshot_access/snapshot_access.py @@ -0,0 +1,205 @@ +from typing import Optional, List +from datetime import datetime, timezone +from nanoid import generate +from azure.cosmos.exceptions import CosmosResourceNotFoundError + +from primary.services.database_access.snapshot_access.models import SnapshotContentDocument, SnapshotMetadataDocument +from primary.services.database_access._utils import hash_json_string +from primary.services.service_exceptions import Service, ServiceRequestError +from primary.services.database_access.container_access import ContainerAccess +from primary.services.database_access.snapshot_access.types import ( + NewSnapshot, + SnapshotMetadata, + SnapshotMetadataWithId, + Snapshot, + SnapshotUpdate, + SortBy, + SortDirection, +) + + +class SnapshotAccess: + CONTAINER_NAMES = { + "content": "snapshots_content", + "metadata": "snapshots_metadata", + } + DATABASE_NAME = "persistence" + + def __init__( + self, + user_id: str, + metadata_container_access: ContainerAccess[SnapshotMetadataDocument], + content_container_access: ContainerAccess[SnapshotContentDocument], + ): + self.user_id = user_id + self.metadata_container_access = metadata_container_access + self.content_container_access = content_container_access + + async def __aenter__(self) -> "SnapshotAccess": + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object | None + ) -> None: + await self.metadata_container_access.close_async() + await self.content_container_access.close_async() + + @classmethod + def create(cls, user_id: str) -> "SnapshotAccess": + metadata_container_access = ContainerAccess.create( + cls.DATABASE_NAME, cls.CONTAINER_NAMES["metadata"], SnapshotMetadataDocument + ) + content_container_access = ContainerAccess.create( + cls.DATABASE_NAME, cls.CONTAINER_NAMES["content"], SnapshotContentDocument + ) + return cls(user_id, metadata_container_access, content_container_access) + + async def get_snapshot_by_id_async(self, snapshot_id: str) -> Snapshot: + # We are accessing the content first as we only have the snapshot_id and not the user_id yet. + # The content container is partioned by snapshot_id, so we can query it directly. + # The metadata container is partitioned by user_id, so we need to query it after fetching the content. + content_document = await self.content_container_access.get_item_async( + item_id=snapshot_id, partition_key=snapshot_id + ) + metadata_document = await self.metadata_container_access.get_item_async( + item_id=snapshot_id, partition_key=content_document.owner_id + ) + + return Snapshot( + id=snapshot_id, + owner_id=content_document.owner_id, + metadata=SnapshotMetadata(**metadata_document.metadata.model_dump(mode="json", by_alias=True)), + content=content_document.content, + ) + + async def get_all_snapshots_metadata_for_user_async(self) -> List[SnapshotMetadataWithId]: + query = "SELECT * FROM c WHERE c.owner_id = @owner_id" + params = [{"name": "@owner_id", "value": self.user_id}] + items = await self.metadata_container_access.query_items_async(query=query, parameters=params) + return [self._to_metadata_summary(item) for item in items] + + async def get_filtered_snapshots_metadata_for_user_async( + self, + sort_by: Optional[SortBy] = SortBy.CREATED_AT, + sort_direction: Optional[SortDirection] = SortDirection.ASC, + limit: Optional[int] = None, + offset: Optional[int] = 0, + ) -> List[SnapshotMetadataWithId]: + if not isinstance(sort_by.value, str) or not sort_by.value.isidentifier(): + raise ServiceRequestError("Invalid sort field specified.", Service.DATABASE) + + if sort_by == SortBy.TITLE_LOWER: + metadata_array = await self.get_all_snapshots_metadata_for_user_async() + + reverse = sort_direction == SortDirection.DESC + metadata_array.sort(key=lambda s: s.title.lower() if s.title else "", reverse=reverse) + + return metadata_array[offset:] if limit is None else metadata_array[offset : offset + limit] + + offset_clause = f"OFFSET {offset} LIMIT {limit}" if limit is not None else "" + query = ( + f"SELECT * FROM c " + f"WHERE c.owner_id = @owner_id " + f"ORDER BY c.metadata.{sort_by.value} {sort_direction.value} " + offset_clause + ) + + params = [ + {"name": "@owner_id", "value": self.user_id}, + ] + + items = await self.metadata_container_access.query_items_async(query=query, parameters=params) + + return [self._to_metadata_summary(item) for item in items] + + async def get_snapshot_metadata_async(self, snapshot_id: str, owner_id: Optional[str] = None) -> SnapshotMetadata: + owner = owner_id or self.user_id + try: + document = await self.metadata_container_access.get_item_async(snapshot_id, partition_key=owner) + except CosmosResourceNotFoundError: + raise ServiceRequestError(f"Snapshot '{snapshot_id}' not found for user '{owner}'.", Service.DATABASE) + return document.metadata + + async def insert_snapshot_async(self, new_snapshot: NewSnapshot) -> str: + now = datetime.now(timezone.utc) + snapshot_id = generate(size=8) + + metadata = SnapshotMetadata( + owner_id=self.user_id, + title=new_snapshot.title, + description=new_snapshot.description, + created_at=now, + updated_at=now, + hash=hash_json_string(new_snapshot.content), + ) + + # Store metadata + await self.metadata_container_access.insert_item_async( + { + "id": snapshot_id, + "snapshot_id": snapshot_id, + "owner_id": self.user_id, + "metadata": metadata.model_dump(mode="json", by_alias=True), + } + ) + + # Store content + await self.content_container_access.insert_item_async( + { + "id": snapshot_id, + "snapshot_id": snapshot_id, + "owner_id": self.user_id, + "content": new_snapshot.content, + } + ) + + return snapshot_id + + async def delete_snapshot_async(self, snapshot_id: str): + await self._assert_ownership_async(snapshot_id) + await self.metadata_container_access.delete_item_async(snapshot_id, partition_key=self.user_id) + await self.content_container_access.delete_item_async(snapshot_id, partition_key=snapshot_id) + + async def update_snapshot_metadata_async(self, snapshot_id: str, snapshot_update: SnapshotUpdate): + existing = await self.metadata_container_access.get_item_async(snapshot_id, partition_key=self.user_id) + + updated_metadata = existing.metadata.model_copy( + update={ + "title": snapshot_update.metadata.title, + "description": snapshot_update.metadata.description, + "updated_at": datetime.now(timezone.utc), + } + ) + + await self.metadata_container_access.update_item_async( + snapshot_id, + { + "id": snapshot_id, + "snapshot_id": snapshot_id, + "owner_id": self.user_id, + "metadata": updated_metadata.model_dump(by_alias=True, mode="json"), + }, + ) + + async def _assert_ownership_async(self, snapshot_id: str) -> SnapshotMetadataDocument: + """Assert that the user owns the snapshot with the given ID.""" + try: + metadata = await self.metadata_container_access.get_item_async( + item_id=snapshot_id, partition_key=self.user_id + ) + except CosmosResourceNotFoundError: + raise ServiceRequestError( + f"Snapshot with id '{snapshot_id}' not found for user '{self.user_id}'.", Service.DATABASE + ) + + # Check if the snapshot belongs to the user - this should not be necessary if the partition key is set correctly, + # but it's a good practice to ensure the user has access. + if metadata.owner_id != self.user_id: + raise ServiceRequestError( + f"You do not have permission to access snapshot '{snapshot_id}'.", Service.DATABASE + ) + + return metadata + + @staticmethod + def _to_metadata_summary(doc: SnapshotMetadataDocument) -> SnapshotMetadataWithId: + return SnapshotMetadataWithId(**doc.metadata.model_dump(), id=doc.id) diff --git a/backend_py/primary/primary/services/database_access/snapshot_access/snapshot_logs_access.py b/backend_py/primary/primary/services/database_access/snapshot_access/snapshot_logs_access.py new file mode 100644 index 000000000..e870587b0 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/snapshot_access/snapshot_logs_access.py @@ -0,0 +1,98 @@ +import logging +from datetime import datetime, timezone + + +from primary.services.database_access.container_access import ContainerAccess +from primary.services.service_exceptions import ServiceRequestError + +from .query_collation_options import QueryCollationOptions +from .models import SnapshotAccessLog +from .util import make_access_log_item_id + + +LOGGER = logging.getLogger(__name__) + + +class SnapshotLogsAccess: + DATABASE_NAME = "persistence" + CONTAINER_NAME = "snapshot_access_log" + + def __init__(self, user_id: str, container_access: ContainerAccess[SnapshotAccessLog]): + self._user_id = user_id + self._container_access = container_access + + @classmethod + def create(cls, user_id: str) -> "SnapshotLogsAccess": + container_access = ContainerAccess.create(cls.DATABASE_NAME, cls.CONTAINER_NAME, SnapshotAccessLog) + + return cls(user_id, container_access) + + async def __aenter__(self) -> "SnapshotLogsAccess": + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object | None + ) -> None: + # Clean up if needed (e.g., closing DB connections) + await self._container_access.close_async() + + async def get_access_logs_for_user_async(self, collation_options: QueryCollationOptions) -> list[SnapshotAccessLog]: + query = "SELECT * FROM c WHERE c.visitor_id = @visitor_id" + params = [{"name": "@visitor_id", "value": self._user_id}] + + search_options = collation_options.to_sql_query_string("c") + + if search_options: + query = f"{query} {search_options}" + + return await self._container_access.query_items_async(query, params) # type: ignore[arg-type] + + async def create_access_log_async(self, snapshot_id: str, snapshot_owner_id: str) -> SnapshotAccessLog: + new_log = SnapshotAccessLog( + visitor_id=self._user_id, + snapshot_id=snapshot_id, + snapshot_owner_id=snapshot_owner_id, + ) + + _inserted_id = await self._container_access.insert_item_async(new_log) + + return new_log + + async def get_access_log_async(self, snapshot_id: str) -> SnapshotAccessLog: + item_id = make_access_log_item_id(snapshot_id, self._user_id) + + return await self._container_access.get_item_async(item_id, partition_key=self._user_id) + + async def get_existing_or_new_log_item_async(self, snapshot_id: str, snapshot_owner_id: str) -> SnapshotAccessLog: + """ + Returns an already stored log item if it exists, otherwise, creates a new instance. + + **Note: This does create a new entry in the database!** + """ + try: + return await self.get_access_log_async(snapshot_id) + except ServiceRequestError: + return SnapshotAccessLog( + visitor_id=self._user_id, + snapshot_id=snapshot_id, + snapshot_owner_id=snapshot_owner_id, + ) + + async def log_snapshot_visit_async(self, snapshot_id: str, snapshot_owner_id: str) -> SnapshotAccessLog: + timestamp = datetime.now(timezone.utc) + + # Should we wrap this? + # try: + # + # except Exception as e: + # raise ServiceRequestError(f"Failed to log snapshot visit: {str(e)}", Service.DATABASE) from e + log = await self.get_existing_or_new_log_item_async(snapshot_id, snapshot_owner_id) + log.visits += 1 + log.last_visited_at = timestamp + + if not log.first_visited_at: + log.first_visited_at = timestamp + + await self._container_access.update_item_async(log.id, log) + + return log diff --git a/backend_py/primary/primary/services/database_access/snapshot_access/types.py b/backend_py/primary/primary/services/database_access/snapshot_access/types.py new file mode 100644 index 000000000..add26db1a --- /dev/null +++ b/backend_py/primary/primary/services/database_access/snapshot_access/types.py @@ -0,0 +1,54 @@ +from typing import Optional +from datetime import datetime +from enum import Enum +from pydantic import BaseModel + + +class SnapshotUserEditableMetadata(BaseModel): + title: str + description: Optional[str] = None + + +class SnapshotMetadataInternal(BaseModel): + owner_id: str + created_at: datetime + updated_at: datetime + hash: str + + +class SnapshotMetadata(SnapshotUserEditableMetadata, SnapshotMetadataInternal): + pass + + +class Snapshot(BaseModel): + id: str + owner_id: str + metadata: SnapshotMetadata + content: str + + +class SnapshotMetadataWithId(SnapshotMetadata): + id: str + + +class SnapshotUpdate(BaseModel): + metadata: SnapshotUserEditableMetadata + + +class NewSnapshot(BaseModel): + title: str + description: Optional[str] + content: str + + +class SortBy(str, Enum): + CREATED_AT = "created_at" + UPDATED_AT = "updated_at" + TITLE = "title" + TITLE_LOWER = "title_lower" + LAST_VISIT = "last_visited_at" + + +class SortDirection(str, Enum): + ASC = "asc" + DESC = "desc" diff --git a/backend_py/primary/primary/services/database_access/snapshot_access/util.py b/backend_py/primary/primary/services/database_access/snapshot_access/util.py new file mode 100644 index 000000000..837a8e71f --- /dev/null +++ b/backend_py/primary/primary/services/database_access/snapshot_access/util.py @@ -0,0 +1,2 @@ +def make_access_log_item_id(snapshot_id: str, visitor_id: str) -> str: + return f"{snapshot_id}__{visitor_id}" diff --git a/backend_py/primary/primary/services/service_exceptions.py b/backend_py/primary/primary/services/service_exceptions.py index 673d1d73f..aad95adee 100644 --- a/backend_py/primary/primary/services/service_exceptions.py +++ b/backend_py/primary/primary/services/service_exceptions.py @@ -8,6 +8,7 @@ class Service(str, Enum): VDS = "vds" USER_SESSION = "user_session" SSDL = "ssdl" + DATABASE = "database" class ServiceLayerException(Exception): diff --git a/backend_py/primary/pyproject.toml b/backend_py/primary/pyproject.toml index 8e0fc3a2f..a364aa87f 100644 --- a/backend_py/primary/pyproject.toml +++ b/backend_py/primary/pyproject.toml @@ -35,6 +35,9 @@ core_utils = { path = "../libs/core_utils", develop = true } server_schemas = { path = "../libs/server_schemas", develop = true } polars = "^1.6.0" fmu-datamodels = "0.0.1" +azure-cosmos = "^4.9.0" +aiohttp = "^3.11.18" +nanoid = "^2.0.0" [tool.poetry.group.dev.dependencies] black = "^25.1.0" diff --git a/docker-compose.yml b/docker-compose.yml index faabb1500..baf0f84c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,96 +2,123 @@ # for doing development of the frontend + backend in combination. services: - frontend: - build: - context: . - dockerfile: frontend-dev.Dockerfile - ports: - - 8080:8080 - volumes: - - ./frontend/public:/usr/src/app/frontend/public - - ./frontend/src:/usr/src/app/frontend/src - - ./frontend/theme:/usr/src/app/frontend/theme - - ./frontend/index.html:/usr/src/app/frontend/index.html + frontend: + build: + context: . + dockerfile: frontend-dev.Dockerfile + ports: + - 8080:8080 + volumes: + - ./frontend/public:/usr/src/app/frontend/public + - ./frontend/src:/usr/src/app/frontend/src + - ./frontend/theme:/usr/src/app/frontend/theme + - ./frontend/index.html:/usr/src/app/frontend/index.html - backend-primary: - build: - context: . - dockerfile: ./backend_py/primary/Dockerfile - ports: - - 5000:5000 - - 5678:5678 - environment: - - UVICORN_PORT=5000 - - UVICORN_RELOAD=true - - WEBVIZ_CLIENT_SECRET - - WEBVIZ_SMDA_RESOURCE_SCOPE - - WEBVIZ_SMDA_SUBSCRIPTION_KEY - - WEBVIZ_ENTERPRISE_SUBSCRIPTION_KEY - - WEBVIZ_SSDL_RESOURCE_SCOPE - - WEBVIZ_SUMO_ENV - - WEBVIZ_VDS_HOST_ADDRESS - - APPLICATIONINSIGHTS_CONNECTION_STRING - - OTEL_RESOURCE_ATTRIBUTES=service.name=primary-backend, service.namespace=local - - CODESPACE_NAME # Automatically set env. variable by GitHub codespace - volumes: - - ./backend_py/primary/primary:/home/appuser/backend_py/primary/primary - - ./backend_py/libs:/home/appuser/backend_py/libs - command: - [ - "sh", - "-c", - "pip install debugpy && python -m debugpy --listen 0.0.0.0:5678 -m uvicorn --proxy-headers --host=0.0.0.0 primary.main:app", - ] + backend-primary: + depends_on: + cosmos-db: + condition: service_healthy + build: + context: . + dockerfile: ./backend_py/primary/Dockerfile + ports: + - 5000:5000 + - 5678:5678 + environment: + - UVICORN_PORT=5000 + - UVICORN_RELOAD=true + - WEBVIZ_CLIENT_SECRET + - WEBVIZ_SMDA_RESOURCE_SCOPE + - WEBVIZ_SMDA_SUBSCRIPTION_KEY + - WEBVIZ_ENTERPRISE_SUBSCRIPTION_KEY + - WEBVIZ_SSDL_RESOURCE_SCOPE + - WEBVIZ_SUMO_ENV + - WEBVIZ_VDS_HOST_ADDRESS + - APPLICATIONINSIGHTS_CONNECTION_STRING + - OTEL_RESOURCE_ATTRIBUTES=service.name=primary-backend, service.namespace=local + - CODESPACE_NAME # Automatically set env. variable by GitHub codespace + - WEBVIZ_DB_CONNECTION_STRING + volumes: + - ./backend_py/primary/primary:/home/appuser/backend_py/primary/primary + - ./backend_py/libs:/home/appuser/backend_py/libs + command: + [ + "sh", + "-c", + "pip install debugpy && python -m debugpy --listen 0.0.0.0:5678 -m uvicorn --proxy-headers --host=0.0.0.0 primary.main:app", + ] - surface-query: - build: - context: . - dockerfile: ./backend_go/surface-query-dev.Dockerfile - ports: - - 5001:5001 - volumes: - - ./backend_go/surface_query:/home/appuser/backend_go/surface_query + surface-query: + build: + context: . + dockerfile: ./backend_go/surface-query-dev.Dockerfile + ports: + - 5001:5001 + volumes: + - ./backend_go/surface_query:/home/appuser/backend_go/surface_query - user-mock: - build: - context: . - dockerfile: ./backend_py/user_mock/Dockerfile - ports: - - 8001:8001 - environment: - - UVICORN_PORT=8001 - - UVICORN_RELOAD=true - volumes: - - ./backend_py/user_mock/user_mock:/home/appuser/backend_py/user_mock/user_mock + user-mock: + build: + context: . + dockerfile: ./backend_py/user_mock/Dockerfile + ports: + - 8001:8001 + environment: + - UVICORN_PORT=8001 + - UVICORN_RELOAD=true + volumes: + - ./backend_py/user_mock/user_mock:/home/appuser/backend_py/user_mock/user_mock - user-grid3d-ri: - build: - context: . - dockerfile: ./backend_py/user_grid3d_ri/Dockerfile - ports: - - 8002:8002 - environment: - - UVICORN_PORT=8002 - - UVICORN_RELOAD=true - - APPLICATIONINSIGHTS_CONNECTION_STRING - - OTEL_RESOURCE_ATTRIBUTES=service.name=user-grid3d-ri, service.namespace=local - volumes: - - ./backend_py/user_grid3d_ri/user_grid3d_ri:/home/appuser/backend_py/user_grid3d_ri/user_grid3d_ri - - ./backend_py/libs:/home/appuser/backend_py/libs + user-grid3d-ri: + build: + context: . + dockerfile: ./backend_py/user_grid3d_ri/Dockerfile + ports: + - 8002:8002 + environment: + - UVICORN_PORT=8002 + - UVICORN_RELOAD=true + - APPLICATIONINSIGHTS_CONNECTION_STRING + - OTEL_RESOURCE_ATTRIBUTES=service.name=user-grid3d-ri, service.namespace=local + volumes: + - ./backend_py/user_grid3d_ri/user_grid3d_ri:/home/appuser/backend_py/user_grid3d_ri/user_grid3d_ri + - ./backend_py/libs:/home/appuser/backend_py/libs - redis-user-session: - image: bitnami/redis:6.2.10@sha256:bd42fcdab5959ce2b21b6ea8410d4b3ee87ecb2e320260326ec731ecfcffbd0e - expose: - - 6379 - environment: - - ALLOW_EMPTY_PASSWORD=yes + redis-user-session: + image: bitnami/redis:6.2.10@sha256:bd42fcdab5959ce2b21b6ea8410d4b3ee87ecb2e320260326ec731ecfcffbd0e + expose: + - 6379 + environment: + - ALLOW_EMPTY_PASSWORD=yes - redis-cache: - image: bitnami/redis:6.2.10@sha256:bd42fcdab5959ce2b21b6ea8410d4b3ee87ecb2e320260326ec731ecfcffbd0e - expose: - - 6379 - environment: - - ALLOW_EMPTY_PASSWORD=yes - # https://redis.io/docs/management/config/#configuring-redis-as-a-cache - - REDIS_EXTRA_FLAGS=--maxmemory 1gb --maxmemory-policy allkeys-lru --save '' --appendonly no --loglevel notice + redis-cache: + image: bitnami/redis:6.2.10@sha256:bd42fcdab5959ce2b21b6ea8410d4b3ee87ecb2e320260326ec731ecfcffbd0e + expose: + - 6379 + environment: + - ALLOW_EMPTY_PASSWORD=yes + # https://redis.io/docs/management/config/#configuring-redis-as-a-cache + - REDIS_EXTRA_FLAGS=--maxmemory 1gb --maxmemory-policy allkeys-lru --save '' --appendonly no --loglevel notice + + cosmos-db: + image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest + platform: linux/amd64 + tty: true + mem_limit: 4GB + ports: + - "8081:8081" # HTTPS endpoint + - "10250-10255:10250-10255" # Internal emulator ports (needed) + environment: + - AZURE_COSMOS_EMULATOR_PARTITION_COUNT=1 + - AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE=true + healthcheck: + test: curl -k https://127.0.0.1:8081/_explorer/emulator.pem || exit 1 + interval: 10s + retries: 12 # 2 minutes + start_period: 30s + timeout: 5s + volumes: + - cosmosdrive:/var/lib/cosmosdb/emulator + +volumes: + cosmosdrive: diff --git a/frontend/aliases.json b/frontend/aliases.json index 2782caac6..66b364e81 100644 --- a/frontend/aliases.json +++ b/frontend/aliases.json @@ -7,7 +7,8 @@ "@framework/*": ["./src/framework/*"], "@lib/*": ["./src/lib/*"], "@modules/*": ["./src/modules/*"], - "@modules_shared/*": ["./src/modules/_shared/*"] + "@modules_shared/*": ["./src/modules/_shared/*"], + "@src/*": ["./src/*"] } } } diff --git a/frontend/index.html b/frontend/index.html index 80a372409..e7eeafea2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,7 +9,7 @@ Webviz | FMU results visualization -
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e5def90fe..70c8082a6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "@webviz/subsurface-viewer": "^1.11.1", "@webviz/well-completions-plot": "^1.7.3", "@webviz/well-log-viewer": "^2.4.4", + "ajv": "^8.17.1", "animate.css": "^4.1.1", "axios": "^1.8.3", "culori": "^3.2.0", @@ -33,6 +34,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-plotly.js": "^2.6.0", + "react-toastify": "^11.0.5", "simplify-js": "^1.2.4", "ts-key-enum": "^2.0.13", "uuid": "^9.0.0", @@ -6382,7 +6384,8 @@ }, "node_modules/ajv": { "version": "8.17.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7289,7 +7292,9 @@ "license": "MIT" }, "node_modules/clsx": { - "version": "2.0.0", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", "engines": { "node": ">=6" @@ -12769,23 +12774,6 @@ "license": "MIT", "peer": true }, - "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, "node_modules/native-promise-only": { "version": "0.8.1", "license": "MIT", @@ -13921,6 +13909,23 @@ "license": "MIT", "peer": true }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/potpack": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", @@ -14268,6 +14273,19 @@ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-toastify": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz", + "integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + } + }, "node_modules/react-tooltip": { "version": "5.28.0", "license": "MIT", @@ -15485,6 +15503,24 @@ "react-dom": ">= 16.8.0" } }, + "node_modules/styled-components/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/styled-components/node_modules/postcss": { "version": "8.4.38", "funding": [ diff --git a/frontend/package.json b/frontend/package.json index aaa021d0c..0eb0e41e9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,10 +4,11 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite --host", - "build": "tsc && NODE_OPTIONS='--max-old-space-size=8192' vite build", + "dev": "npm run generate:module-states-map && vite --host", + "build": "npm run generate:module-states-map && tsc && NODE_OPTIONS='--max-old-space-size=8192' vite build", "preview": "vite preview", - "generate-api": "openapi-ts && node scripts/add-api-suffix.cjs --suffix '_api' --dir src/api/autogen --exportTanstackQueryFromIndex", + "generate:api": "openapi-ts && node scripts/add-api-suffix.cjs --suffix '_api' --dir src/api/autogen --exportTanstackQueryFromIndex", + "generate:module-states-map": "node scripts/generate-module-states-map.js", "typecheck": "tsc --noEmit", "lint": "eslint 'src/**/*.+(ts|tsx|js|jsx|json)' 'tests/**/*.+(ts|tsx|js|jsx|json)' --max-warnings=0", "lint:fix": "eslint 'src/**/*.+(ts|tsx|js|jsx|json)' 'tests/**/*.+(ts|tsx|js|jsx|json)' --fix", @@ -37,6 +38,7 @@ "@webviz/subsurface-viewer": "^1.11.1", "@webviz/well-completions-plot": "^1.7.3", "@webviz/well-log-viewer": "^2.4.4", + "ajv": "^8.17.1", "animate.css": "^4.1.1", "axios": "^1.8.3", "culori": "^3.2.0", @@ -49,6 +51,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-plotly.js": "^2.6.0", + "react-toastify": "^11.0.5", "simplify-js": "^1.2.4", "ts-key-enum": "^2.0.13", "uuid": "^9.0.0", diff --git a/frontend/scripts/generate-module-states-map.js b/frontend/scripts/generate-module-states-map.js new file mode 100644 index 000000000..b2b924af7 --- /dev/null +++ b/frontend/scripts/generate-module-states-map.js @@ -0,0 +1,88 @@ +#!/usr/bin/env node + +import fs from "fs"; +import path from "path"; +import glob from "fast-glob"; + +function sanitize(name) { + const sanitized = name.replace(/[^a-zA-Z0-9]/g, ""); + return /^[0-9]/.test(sanitized) ? `M_${sanitized}` : sanitized; +} + +export async function generateModuleSerializedStateMap() { + const settingsFiles = await glob("src/modules/[^_]*/settings/persistence.ts", { cwd: process.cwd() }); + const viewFiles = await glob("src/modules/[^_]*/view/persistence.ts", { cwd: process.cwd() }); + + const outputLines = []; + const viewMap = new Map(); + const settingsMap = new Map(); + const modules = new Set(); + + const moduleFolders = await glob("src/modules/!(_*)/", { onlyDirectories: true, cwd: process.cwd() }); + + for (const folder of moduleFolders) { + const match = folder.match(/src\/modules\/([^/]+)(\/)?$/); + if (!match) continue; + + const moduleName = match[1]; + modules.add(moduleName); + } + + for (const file of settingsFiles) { + const match = file.match(/src\/modules\/([^/]+)\/settings\/persistence\.ts$/); + if (!match) continue; + + const moduleName = match[1]; + const alias = `${sanitize(moduleName)}Settings`; + settingsMap.set(moduleName, alias); + + outputLines.push( + `import type { SerializedSettings as ${alias} } from "@modules/${moduleName}/settings/persistence";`, + ); + } + + for (const file of viewFiles) { + const match = file.match(/src\/modules\/([^/]+)\/view\/persistence\.ts$/); + if (!match) continue; + + const moduleName = match[1]; + const alias = `${sanitize(moduleName)}View`; + viewMap.set(moduleName, alias); + + outputLines.push(`import type { SerializedView as ${alias} } from "@modules/${moduleName}/view/persistence";`); + } + + console.log("🧪 Matched modules:", Array.from(modules)); + + console.log("🧪 Found settings files:", settingsFiles); + console.log("🧪 Found view files:", viewFiles); + + const sortedModules = Array.from(modules).sort(); + const mapEntries = []; + + for (const moduleName of sortedModules) { + const settingsAlias = settingsMap.get(moduleName) ? `${sanitize(moduleName)}Settings` : "never"; + const viewAlias = viewMap.get(moduleName) ? `${sanitize(moduleName)}View` : "never"; + + mapEntries.push( + ` "${moduleName}": {\n` + + ` settings?: ${settingsAlias === "never" ? "never" : `Partial<${settingsAlias}>`},\n` + + ` view?: ${viewAlias === "never" ? "never" : `Partial<${viewAlias}>`},\n` + + ` },`, + ); + } + + const fileContent = + `// AUTO-GENERATED FILE. DO NOT EDIT.\n` + + `// Generated by generateModuleSerializedStateMap CLI\n\n` + + `${outputLines.join("\n")}\n\n` + + `export type ModuleSerializedStateMap = {\n${mapEntries.join("\n")}\n};\n`; + + const outPath = path.resolve("src/modules/ModuleSerializedStateMap.ts"); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, fileContent + "\n", "utf-8"); + + console.log(`[CLI] ✅ ModuleSerializedStateMap written to ${outPath}`); +} + +generateModuleSerializedStateMap(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5f5a61a81..f9eadfa95 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,194 +1,18 @@ -import React from "react"; - -import { useQueryClient } from "@tanstack/react-query"; - -import FmuLogo from "@assets/fmu.svg"; -import FmuLogoAnimated from "@assets/fmuAnimated.svg"; - -import { GuiState, LeftDrawerContent } from "@framework/GuiMessageBroker"; -import { LeftNavBar, RightNavBar } from "@framework/internal/components/NavBar"; -import { SettingsContentPanels } from "@framework/internal/components/SettingsContentPanels"; -import { ToggleDevToolsButton } from "@framework/internal/components/ToggleDevToolsButton"; -import { AuthState, useAuthProvider } from "@framework/internal/providers/AuthProvider"; -import { Workbench } from "@framework/Workbench"; -import type { LayoutElement } from "@framework/Workbench"; -import { Button } from "@lib/components/Button"; -import { resolveClassNames } from "@lib/utils/resolveClassNames"; - +import { AuthenticationBoundary } from "@framework/internal/components/AuthenticationBoundary"; +import { GlobalConfirmationDialog } from "@framework/internal/components/GlobalConfirmationDialog/globalConfirmationDialog"; import "./modules/registerAllModules"; import "./templates/registerAllTemplates"; -function DataSharingLabel() { - return ( -
-

- Disclaimer: Webviz is a service provided by Equinor and is not a way of sharing - official data. Data should continue to be shared through L2S, FTP and/or Dasha. -

-

- References to e.g. earlier models, model results and data should still be done through the mentioned - tools, and not Webviz. Since Webviz is currently under heavy development and not production ready, there - is no guarantee given as of now that calculations are error-free. -

-
- ); -} - -function DevLabel() { - return ( -
- NOTE: This application is still under heavy development; bugs and occasional downtime - should be expected. Please help us improve Webviz by reporting any undesired behaviour either on{" "} - - Slack - {" "} - or{" "} - - Yammer - - . -
- ); -} - -enum InitAppState { - CheckingIfUserIsSignedIn = "checking-if-user-is-signed-in", - LoadingEnsembles = "loading-ensembles", - InitCompleted = "init-completed", -} - -const layout: LayoutElement[] = []; +import { WorkbenchWrapper } from "./WorkbenchWrapper"; function App() { - // Workbench must be kept as a state in order to keep it when any framework code is changed in dev mode. - // Otherwise, the workbench will be reset on every code change. This would cause it to loose its state and will - // cause the app to crash. - const [workbench] = React.useState(new Workbench()); - - const [isMounted, setIsMounted] = React.useState(false); - const [initAppState, setInitAppState] = React.useState(InitAppState.CheckingIfUserIsSignedIn); - - const queryClient = useQueryClient(); - const { authState } = useAuthProvider(); - - function signIn() { - window.location.href = `/api/login?redirect_url_after_login=${btoa("/")}`; - } - - React.useEffect( - function handleMountWhenSignedIn() { - function initApp() { - if (!workbench.loadLayoutFromLocalStorage()) { - workbench.makeLayout(layout); - } - - if (workbench.getLayout().length === 0) { - workbench.getGuiMessageBroker().setState(GuiState.LeftDrawerContent, LeftDrawerContent.ModulesList); - } else { - workbench - .getGuiMessageBroker() - .setState(GuiState.LeftDrawerContent, LeftDrawerContent.ModuleSettings); - } - setInitAppState(InitAppState.InitCompleted); - workbench.getGuiMessageBroker().setState(GuiState.AppInitialized, true); - } - - if (authState !== AuthState.LoggedIn || isMounted) { - return; - } - - setIsMounted(true); - - // Initialize the workbench - workbench.initWorkbenchFromLocalStorage(queryClient).finally(() => { - initApp(); - }); - - return function handleUnmount() { - workbench.clearLayout(); - workbench.resetModuleInstanceNumbers(); - workbench.stopEnsembleUpdatePolling(); - }; - }, - [authState, isMounted, queryClient, workbench], - ); - - function makeStateMessages() { - return ( -
-
- Checking if user is signed in... -
-
- Restoring working session... -
-
- ); - } - - const isInitializingApp = initAppState !== InitAppState.InitCompleted; - return ( - <> - {authState === AuthState.NotLoggedIn ? ( -
- FMU Analysis logo -

FMU Analysis

- -

Please sign in to continue.

- - -
- ) : ( - isInitializingApp && ( -
- FMU Analysis animated logo - {makeStateMessages()} - -
- ) - )} -
- <> - - - - -
- - +
+ + + + +
); } diff --git a/frontend/src/GlobalErrorBoundary.tsx b/frontend/src/GlobalErrorBoundary.tsx index 196486049..62e3f785b 100644 --- a/frontend/src/GlobalErrorBoundary.tsx +++ b/frontend/src/GlobalErrorBoundary.tsx @@ -59,7 +59,7 @@ export class GlobalErrorBoundary extends React.Component {
The application was terminated due to the following error: -
+
{this.state.error.name}: {this.state.error.message}
You can use the following URL to start a clean session: diff --git a/frontend/src/Log.ts b/frontend/src/Log.ts new file mode 100644 index 000000000..a8715b9f2 --- /dev/null +++ b/frontend/src/Log.ts @@ -0,0 +1,203 @@ +/* eslint-disable no-console */ +import { formatHex } from "culori"; +import { isArray } from "lodash"; + +import { getDebugSetting, setDebugSetting } from "@framework/internal/utils/debug"; + +let colorIndex = 0; + +function getNextColor(): string { + const goldenAngle = 137.508; // degrees + const hue = (colorIndex++ * goldenAngle) % 360; + const color = { mode: "hsl", h: hue, s: 0.7, l: 0.5 } as const; + return formatHex(color); // → "#a05ce0", etc. +} + +type CapitalLetter = + | "A" + | "B" + | "C" + | "D" + | "E" + | "F" + | "G" + | "H" + | "I" + | "J" + | "K" + | "L" + | "M" + | "N" + | "O" + | "P" + | "Q" + | "R" + | "S" + | "T" + | "U" + | "V" + | "W" + | "X" + | "Y" + | "Z"; + +type PascalCase = `${CapitalLetter}${string}`; // Start with uppercase + +function isValidLoggerName(name: string): boolean { + return /^[A-Z][a-zA-Z_]*[a-zA-Z]$/.test(name); +} + +class ConsoleLogger { + private _name: string; + private _color: string; + + constructor(name: string, color: string) { + this._name = name; + this._color = color; + } + + private _log(level: "debug" | "info" | "warn" | "error", ...args: unknown[]) { + const prefix = `%c[${this._name}]`; + const style = `color: ${this._color}; font-weight: bold`; + console[level](prefix, style, ...args); + } + + log(...args: unknown[]) { + this._log("debug", ...args); + } +} + +class Logger { + private _name: string; + private _color: string; + + console: ConsoleLogger | undefined; + + constructor(name: string, color: string) { + this._name = name; + this._color = color; + } + + enable() { + if (!this.console) { + this.console = new ConsoleLogger(this._name, this._color); + } + } + + disable() { + this.console = undefined; + } +} + +type LoggerControl = { + enable: () => void; + disable: () => void; +}; + +const DEBUG_SETTING_NAME = "enabledLoggers"; + +class GlobalLog { + private _registry: Record = {}; + private _enabledLoggers: Set = new Set(); + loggers: Record = Object.create(null); + + constructor() { + // Initialize the global logger registry from localStorage if it exists + const storedRegistry = getDebugSetting(DEBUG_SETTING_NAME); + if (storedRegistry) { + try { + const parsed = JSON.parse(storedRegistry); + if (!isArray(parsed)) { + console.error("Stored enabled loggers is not an array"); + return; + } + for (const loggerName of parsed) { + if (typeof loggerName !== "string") { + console.error(`Invalid logger name: ${loggerName}`); + continue; + } + this._enabledLoggers.add(loggerName); + } + } catch (error) { + console.error("Failed to parse enabled loggers entry from localStorage:", error); + } + } + } + + private getLogger(name: string): Logger { + return this._registry[name]; + } + + private setLoggerEnabled(name: string, enabled: boolean) { + if (enabled) { + if (!this._enabledLoggers.has(name)) { + this._enabledLoggers.add(name); + this.getLogger(name).enable(); + } + } else { + this._enabledLoggers.delete(name); + this.getLogger(name).disable(); + } + + this.updateDebugSetting(); + } + + private updateDebugSetting() { + // Update the localStorage entry with the current enabled loggers + const enabledLoggersArray = Array.from(this._enabledLoggers); + setDebugSetting(DEBUG_SETTING_NAME, JSON.stringify(enabledLoggersArray)); + } + + registerLogger(name: TName, color?: string): Logger { + if (!isValidLoggerName(name)) { + throw Error(`Invalid logger name: "${name}". Must start with an uppercase letter and end with a letter.`); + } + + if (name in this._registry) { + console.warn(`Logger with name "${name}" already exists. Returning existing logger.`); + return this.getLogger(name); + } + + const loggerColor = color ?? getNextColor(); + const logger = new Logger(name, loggerColor); + this._registry[name] = logger; + this.loggers[name] = Object.create(null); + this.loggers[name]["enable"] = () => { + this.setLoggerEnabled(name, true); + return `Logger "${name}" enabled.`; + }; + this.loggers[name]["disable"] = () => { + this.setLoggerEnabled(name, false); + return `Logger "${name}" disabled.`; + }; + + if (this._enabledLoggers.has(name)) { + this.getLogger(name).enable(); + } + + return this.getLogger(name); + } + + enableAll() { + for (const name in this._registry) { + this.setLoggerEnabled(name, true); + } + this.updateDebugSetting(); + } + + disableAll() { + for (const name in this._registry) { + this.setLoggerEnabled(name, false); + } + this.updateDebugSetting(); + } +} + +export const globalLog = new GlobalLog(); + +const log = Object.create(null); +log.loggers = globalLog.loggers; +log.enableAll = () => globalLog.enableAll(); +log.disableAll = () => globalLog.disableAll(); +// @ts-expect-error This is a global variable for easy access in the application +globalThis.log = log; diff --git a/frontend/src/WorkbenchWrapper.tsx b/frontend/src/WorkbenchWrapper.tsx new file mode 100644 index 000000000..dc99f2e3b --- /dev/null +++ b/frontend/src/WorkbenchWrapper.tsx @@ -0,0 +1,78 @@ +import React from "react"; + +import { useQueryClient } from "@tanstack/react-query"; +import { GuiState, useGuiValue } from "@framework/GuiMessageBroker"; +import { ActiveSessionBoundary } from "@framework/internal/components/ActiveSessionBoundary"; +import { ActiveSessionRecoveryDialog } from "@framework/internal/components/ActiveSessionRecoveryDialog/activeSessionRecoveryDialog"; +import { CreateSnapshotDialog } from "@framework/internal/components/CreateSnapshotDialog/createSnapshotDialog"; +import { LoadingOverlay } from "@framework/internal/components/LoadingOverlay"; +import { MultiSessionsRecoveryDialog } from "@framework/internal/components/MultiSessionsRecoveryDialog"; +import { LeftNavBar, RightNavBar } from "@framework/internal/components/NavBar"; +import { SaveSessionDialog } from "@framework/internal/components/SaveSessionDialog/saveSessionDialog"; +import { SelectEnsemblesDialog } from "@framework/internal/components/SelectEnsemblesDialog"; +import { SettingsContentPanels } from "@framework/internal/components/SettingsContentPanels"; +import { StartPage } from "@framework/internal/components/StartPage/StartPage"; +import { ToggleDevToolsButton } from "@framework/internal/components/ToggleDevToolsButton"; +import { TopBar } from "@framework/internal/components/TopBar/topBar"; +import { Workbench, WorkbenchTopic } from "@framework/Workbench"; +import "./modules/registerAllModules"; +import "./templates/registerAllTemplates"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; +import { TemplatesDialog } from "@framework/internal/components/TemplatesDialog/templatesDialog"; + +export function WorkbenchWrapper() { + // Workbench must be kept as a state in order to keep it when any framework code is changed in dev mode. + // Otherwise, the workbench will be reset on every code change. This would cause it to loose its state and will + // cause the app to crash. + const queryClient = useQueryClient(); + const [workbench] = React.useState(new Workbench(queryClient)); + const [isInitialized, setIsInitialized] = React.useState(false); + const isSessionLoading = useGuiValue(workbench.getGuiMessageBroker(), GuiState.IsLoadingSession); + const hasActiveSession = usePublishSubscribeTopicValue(workbench, WorkbenchTopic.HAS_ACTIVE_SESSION); + + React.useEffect( + function initApp() { + workbench.initialize().then(() => { + setIsInitialized(true); + }); + }, + [workbench], + ); + + let content: React.ReactNode; + if (!isInitialized) { + content = ; + } else if (isSessionLoading) { + content = ; + } else if (hasActiveSession) { + content = ( + <> +
+
+ + + +
+
+ + ); + } else { + content = ; + } + + return ( + <> + + + + + + + + + + {content} + + + ); +} diff --git a/frontend/src/api/autogen/@tanstack/react-query.gen.ts b/frontend/src/api/autogen/@tanstack/react-query.gen.ts index 4d1d7ed28..f38fafcee 100644 --- a/frontend/src/api/autogen/@tanstack/react-query.gen.ts +++ b/frontend/src/api/autogen/@tanstack/react-query.gen.ts @@ -1,7 +1,13 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options } from "@hey-api/client-axios"; -import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query"; +import { + queryOptions, + type UseMutationOptions, + infiniteQueryOptions, + type InfiniteData, + type DefaultError, +} from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { @@ -69,6 +75,20 @@ import { getRealizationData, getVfpTableNames, getVfpTable, + getSessionsMetadata, + createSession, + deleteSession, + getSession, + updateSession, + getSessionMetadata, + getRecentSnapshots, + getSnapshotsMetadata, + createSnapshot, + deleteSnapshot, + getSnapshot, + updateSnapshot, + getSnapshotMetadata, + snapshotPreview, loginRoute, authorizedCallbackRoute, getAlive, @@ -157,6 +177,30 @@ import type { GetRealizationDataData_api, GetVfpTableNamesData_api, GetVfpTableData_api, + GetSessionsMetadataData_api, + CreateSessionData_api, + CreateSessionError_api, + CreateSessionResponse_api, + DeleteSessionData_api, + DeleteSessionError_api, + GetSessionData_api, + UpdateSessionData_api, + UpdateSessionError_api, + GetSessionMetadataData_api, + GetRecentSnapshotsData_api, + GetRecentSnapshotsError_api, + GetRecentSnapshotsResponse_api, + GetSnapshotsMetadataData_api, + CreateSnapshotData_api, + CreateSnapshotError_api, + CreateSnapshotResponse_api, + DeleteSnapshotData_api, + DeleteSnapshotError_api, + GetSnapshotData_api, + UpdateSnapshotData_api, + UpdateSnapshotError_api, + GetSnapshotMetadataData_api, + SnapshotPreviewData_api, LoginRouteData_api, AuthorizedCallbackRouteData_api, GetAliveData_api, @@ -1549,6 +1593,354 @@ export const getVfpTableOptions = (options: Options) => { }); }; +export const getSessionsMetadataQueryKey = (options?: Options) => [ + createQueryKey("getSessionsMetadata", options), +]; + +export const getSessionsMetadataOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSessionsMetadata({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getSessionsMetadataQueryKey(options), + }); +}; + +export const createSessionQueryKey = (options: Options) => [ + createQueryKey("createSession", options), +]; + +export const createSessionOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await createSession({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: createSessionQueryKey(options), + }); +}; + +export const createSessionMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions< + CreateSessionResponse_api, + AxiosError, + Options + > = { + mutationFn: async (localOptions) => { + const { data } = await createSession({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const deleteSessionMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions, Options> = { + mutationFn: async (localOptions) => { + const { data } = await deleteSession({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const getSessionQueryKey = (options: Options) => [createQueryKey("getSession", options)]; + +export const getSessionOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSession({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getSessionQueryKey(options), + }); +}; + +export const updateSessionMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions, Options> = { + mutationFn: async (localOptions) => { + const { data } = await updateSession({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const getSessionMetadataQueryKey = (options: Options) => [ + createQueryKey("getSessionMetadata", options), +]; + +export const getSessionMetadataOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSessionMetadata({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getSessionMetadataQueryKey(options), + }); +}; + +export const getRecentSnapshotsQueryKey = (options?: Options) => [ + createQueryKey("getRecentSnapshots", options), +]; + +export const getRecentSnapshotsOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getRecentSnapshots({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getRecentSnapshotsQueryKey(options), + }); +}; + +const createInfiniteParams = [0], "body" | "headers" | "path" | "query">>( + queryKey: QueryKey, + page: K, +) => { + const params = queryKey[0]; + if (page.body) { + params.body = { + ...(queryKey[0].body as any), + ...(page.body as any), + }; + } + if (page.headers) { + params.headers = { + ...queryKey[0].headers, + ...page.headers, + }; + } + if (page.path) { + params.path = { + ...(queryKey[0].path as any), + ...(page.path as any), + }; + } + if (page.query) { + params.query = { + ...(queryKey[0].query as any), + ...(page.query as any), + }; + } + return params as unknown as typeof page; +}; + +export const getRecentSnapshotsInfiniteQueryKey = ( + options?: Options, +): QueryKey> => [createQueryKey("getRecentSnapshots", options, true)]; + +export const getRecentSnapshotsInfiniteOptions = (options?: Options) => { + return infiniteQueryOptions< + GetRecentSnapshotsResponse_api, + AxiosError, + InfiniteData, + QueryKey>, + number | null | Pick>[0], "body" | "headers" | "path" | "query"> + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick>[0], "body" | "headers" | "path" | "query"> = + typeof pageParam === "object" + ? pageParam + : { + query: { + offset: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await getRecentSnapshots({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getRecentSnapshotsInfiniteQueryKey(options), + }, + ); +}; + +export const getSnapshotsMetadataQueryKey = (options?: Options) => [ + createQueryKey("getSnapshotsMetadata", options), +]; + +export const getSnapshotsMetadataOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSnapshotsMetadata({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getSnapshotsMetadataQueryKey(options), + }); +}; + +export const createSnapshotQueryKey = (options: Options) => [ + createQueryKey("createSnapshot", options), +]; + +export const createSnapshotOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await createSnapshot({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: createSnapshotQueryKey(options), + }); +}; + +export const createSnapshotMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions< + CreateSnapshotResponse_api, + AxiosError, + Options + > = { + mutationFn: async (localOptions) => { + const { data } = await createSnapshot({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const deleteSnapshotMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions, Options> = { + mutationFn: async (localOptions) => { + const { data } = await deleteSnapshot({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const getSnapshotQueryKey = (options: Options) => [createQueryKey("getSnapshot", options)]; + +export const getSnapshotOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSnapshot({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getSnapshotQueryKey(options), + }); +}; + +export const updateSnapshotMutation = (options?: Partial>) => { + const mutationOptions: UseMutationOptions, Options> = { + mutationFn: async (localOptions) => { + const { data } = await updateSnapshot({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const getSnapshotMetadataQueryKey = (options: Options) => [ + createQueryKey("getSnapshotMetadata", options), +]; + +export const getSnapshotMetadataOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSnapshotMetadata({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getSnapshotMetadataQueryKey(options), + }); +}; + +export const snapshotPreviewQueryKey = (options: Options) => [ + createQueryKey("snapshotPreview", options), +]; + +export const snapshotPreviewOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await snapshotPreview({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: snapshotPreviewQueryKey(options), + }); +}; + export const loginRouteQueryKey = (options?: Options) => [createQueryKey("loginRoute", options)]; export const loginRouteOptions = (options?: Options) => { diff --git a/frontend/src/api/autogen/sdk.gen.ts b/frontend/src/api/autogen/sdk.gen.ts index e3602b509..43847200d 100644 --- a/frontend/src/api/autogen/sdk.gen.ts +++ b/frontend/src/api/autogen/sdk.gen.ts @@ -194,6 +194,44 @@ import type { GetVfpTableData_api, GetVfpTableResponse_api, GetVfpTableError_api, + GetSessionsMetadataData_api, + GetSessionsMetadataResponse_api, + GetSessionsMetadataError_api, + CreateSessionData_api, + CreateSessionResponse_api, + CreateSessionError_api, + DeleteSessionData_api, + DeleteSessionError_api, + GetSessionData_api, + GetSessionResponse_api, + GetSessionError_api, + UpdateSessionData_api, + UpdateSessionError_api, + GetSessionMetadataData_api, + GetSessionMetadataResponse_api, + GetSessionMetadataError_api, + GetRecentSnapshotsData_api, + GetRecentSnapshotsResponse_api, + GetRecentSnapshotsError_api, + GetSnapshotsMetadataData_api, + GetSnapshotsMetadataResponse_api, + GetSnapshotsMetadataError_api, + CreateSnapshotData_api, + CreateSnapshotResponse_api, + CreateSnapshotError_api, + DeleteSnapshotData_api, + DeleteSnapshotError_api, + GetSnapshotData_api, + GetSnapshotResponse_api, + GetSnapshotError_api, + UpdateSnapshotData_api, + UpdateSnapshotError_api, + GetSnapshotMetadataData_api, + GetSnapshotMetadataResponse_api, + GetSnapshotMetadataError_api, + SnapshotPreviewData_api, + SnapshotPreviewResponse_api, + SnapshotPreviewError_api, LoginRouteData_api, LoginRouteError_api, AuthorizedCallbackRouteData_api, @@ -1218,6 +1256,187 @@ export const getVfpTable = (options: Optio }); }; +/** + * Get Sessions Metadata + */ +export const getSessionsMetadata = ( + options?: Options, +) => { + return (options?.client ?? client).get({ + ...options, + url: "/sessions/sessions", + }); +}; + +/** + * Create Session + */ +export const createSession = ( + options: Options, +) => { + return (options?.client ?? client).post({ + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + url: "/sessions/sessions", + }); +}; + +/** + * Delete Session + */ +export const deleteSession = ( + options: Options, +) => { + return (options?.client ?? client).delete({ + ...options, + url: "/sessions/sessions/{session_id}", + }); +}; + +/** + * Get Session + */ +export const getSession = (options: Options) => { + return (options?.client ?? client).get({ + ...options, + url: "/sessions/sessions/{session_id}", + }); +}; + +/** + * Update Session + */ +export const updateSession = ( + options: Options, +) => { + return (options?.client ?? client).put({ + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + url: "/sessions/sessions/{session_id}", + }); +}; + +/** + * Get Session Metadata + */ +export const getSessionMetadata = ( + options: Options, +) => { + return (options?.client ?? client).get({ + ...options, + url: "/sessions/sessions/metadata/{session_id}", + }); +}; + +/** + * Get Recent Snapshots + */ +export const getRecentSnapshots = ( + options?: Options, +) => { + return (options?.client ?? client).get({ + ...options, + url: "/snapshots/recent_snapshots", + }); +}; + +/** + * Get Snapshots Metadata + */ +export const getSnapshotsMetadata = ( + options?: Options, +) => { + return (options?.client ?? client).get({ + ...options, + url: "/snapshots/snapshots", + }); +}; + +/** + * Create Snapshot + */ +export const createSnapshot = ( + options: Options, +) => { + return (options?.client ?? client).post({ + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + url: "/snapshots/snapshots", + }); +}; + +/** + * Delete Snapshot + */ +export const deleteSnapshot = ( + options: Options, +) => { + return (options?.client ?? client).delete({ + ...options, + url: "/snapshots/snapshots/{snapshot_id}", + }); +}; + +/** + * Get Snapshot + */ +export const getSnapshot = (options: Options) => { + return (options?.client ?? client).get({ + ...options, + url: "/snapshots/snapshots/{snapshot_id}", + }); +}; + +/** + * Update Snapshot + */ +export const updateSnapshot = ( + options: Options, +) => { + return (options?.client ?? client).put({ + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + url: "/snapshots/snapshots/{snapshot_id}", + }); +}; + +/** + * Get Snapshot Metadata + */ +export const getSnapshotMetadata = ( + options: Options, +) => { + return (options?.client ?? client).get({ + ...options, + url: "/snapshots/snapshots/metadata/{snapshot_id}", + }); +}; + +/** + * Snapshot Preview + */ +export const snapshotPreview = ( + options: Options, +) => { + return (options?.client ?? client).get({ + ...options, + responseType: "text", + url: "/snapshot-preview/{snapshot_id}", + }); +}; + /** * Login Route */ diff --git a/frontend/src/api/autogen/types.gen.ts b/frontend/src/api/autogen/types.gen.ts index 6d6ca30ae..597dd9e4b 100644 --- a/frontend/src/api/autogen/types.gen.ts +++ b/frontend/src/api/autogen/types.gen.ts @@ -391,6 +391,18 @@ export type NetworkNode_api = { children: Array; }; +export type NewSession_api = { + title: string; + description: string | null; + content: string; +}; + +export type NewSnapshot_api = { + title: string; + description: string | null; + content: string; +}; + export enum NodeType_api { PROD = "prod", INJ = "inj", @@ -676,6 +688,80 @@ export enum SensitivityType_api { SCENARIO = "scenario", } +export type SessionDocument_api = { + id: string; + ownerId: string; + metadata: SessionMetadata_api; + content: string; +}; + +export type SessionMetadata_api = { + title: string; + description: string | null; + createdAt: string; + updatedAt: string; + version: number; + hash: string; +}; + +export type SessionMetadataWithId_api = { + id: string; + title: string; + description: string | null; + createdAt: string; + updatedAt: string; + version: number; +}; + +export type SessionUpdate_api = { + id: string; + metadata: SessionUserEditableMetadata_api; + content: string; +}; + +export type SessionUserEditableMetadata_api = { + title: string; + description?: string | null; +}; + +export type Snapshot_api = { + id: string; + metadata: SnapshotMetadata_api; + content: string; +}; + +export type SnapshotAccessLog_api = { + visitorId: string; + snapshotId: string; + visits: number; + firstVisitedAt: string | null; + lastVisitedAt: string | null; + snapshotMetadata: SnapshotMetadata_api; +}; + +export type SnapshotMetadata_api = { + ownerId: string; + title: string; + description: string | null; + createdAt: string; + updatedAt: string; + hash: string; +}; + +export type SnapshotUpdate_api = { + metadata: SnapshotUserEditableMetadata_api; +}; + +export type SnapshotUserEditableMetadata_api = { + title: string; + description?: string | null; +}; + +export enum SortDirection_api { + ASC = "asc", + DESC = "desc", +} + export enum StatisticFunction_api { MEAN = "MEAN", MIN = "MIN", @@ -1160,6 +1246,21 @@ export type WellboreTrajectory_api = { northingArr: Array; }; +export enum PrimaryServicesDatabaseAccessSessionAccessTypesSortBy_api { + CREATED_AT = "created_at", + UPDATED_AT = "updated_at", + TITLE = "title", + TITLE_LOWER = "title_lower", +} + +export enum PrimaryServicesDatabaseAccessSnapshotAccessTypesSortBy_api { + CREATED_AT = "created_at", + UPDATED_AT = "updated_at", + TITLE = "title", + TITLE_LOWER = "title_lower", + LAST_VISITED_AT = "last_visited_at", +} + export type GetFieldsData_api = { body?: never; path?: never; @@ -1384,7 +1485,8 @@ export type GetDeltaEnsembleVectorListErrors_api = { 422: HttpValidationError_api; }; -export type GetDeltaEnsembleVectorListError_api = GetDeltaEnsembleVectorListErrors_api[keyof GetDeltaEnsembleVectorListErrors_api]; +export type GetDeltaEnsembleVectorListError_api = + GetDeltaEnsembleVectorListErrors_api[keyof GetDeltaEnsembleVectorListErrors_api]; export type GetDeltaEnsembleVectorListResponses_api = { /** @@ -1432,7 +1534,8 @@ export type GetRealizationsVectorDataErrors_api = { 422: HttpValidationError_api; }; -export type GetRealizationsVectorDataError_api = GetRealizationsVectorDataErrors_api[keyof GetRealizationsVectorDataErrors_api]; +export type GetRealizationsVectorDataError_api = + GetRealizationsVectorDataErrors_api[keyof GetRealizationsVectorDataErrors_api]; export type GetRealizationsVectorDataResponses_api = { /** @@ -1572,7 +1675,8 @@ export type GetHistoricalVectorDataErrors_api = { 422: HttpValidationError_api; }; -export type GetHistoricalVectorDataError_api = GetHistoricalVectorDataErrors_api[keyof GetHistoricalVectorDataErrors_api]; +export type GetHistoricalVectorDataError_api = + GetHistoricalVectorDataErrors_api[keyof GetHistoricalVectorDataErrors_api]; export type GetHistoricalVectorDataResponses_api = { /** @@ -1581,7 +1685,8 @@ export type GetHistoricalVectorDataResponses_api = { 200: VectorHistoricalData_api; }; -export type GetHistoricalVectorDataResponse_api = GetHistoricalVectorDataResponses_api[keyof GetHistoricalVectorDataResponses_api]; +export type GetHistoricalVectorDataResponse_api = + GetHistoricalVectorDataResponses_api[keyof GetHistoricalVectorDataResponses_api]; export type GetStatisticalVectorDataData_api = { body?: never; @@ -1623,7 +1728,8 @@ export type GetStatisticalVectorDataErrors_api = { 422: HttpValidationError_api; }; -export type GetStatisticalVectorDataError_api = GetStatisticalVectorDataErrors_api[keyof GetStatisticalVectorDataErrors_api]; +export type GetStatisticalVectorDataError_api = + GetStatisticalVectorDataErrors_api[keyof GetStatisticalVectorDataErrors_api]; export type GetStatisticalVectorDataResponses_api = { /** @@ -2084,7 +2190,8 @@ export type PostGetSurfaceIntersectionErrors_api = { 422: HttpValidationError_api; }; -export type PostGetSurfaceIntersectionError_api = PostGetSurfaceIntersectionErrors_api[keyof PostGetSurfaceIntersectionErrors_api]; +export type PostGetSurfaceIntersectionError_api = + PostGetSurfaceIntersectionErrors_api[keyof PostGetSurfaceIntersectionErrors_api]; export type PostGetSurfaceIntersectionResponses_api = { /** @@ -2237,7 +2344,8 @@ export type GetMisfitSurfaceDataResponses_api = { 200: Array; }; -export type GetMisfitSurfaceDataResponse_api = GetMisfitSurfaceDataResponses_api[keyof GetMisfitSurfaceDataResponses_api]; +export type GetMisfitSurfaceDataResponse_api = + GetMisfitSurfaceDataResponses_api[keyof GetMisfitSurfaceDataResponses_api]; export type DeprecatedGetStratigraphicUnitsData_api = { body?: never; @@ -2764,7 +2872,8 @@ export type GetRealizationFlowNetworkErrors_api = { 422: HttpValidationError_api; }; -export type GetRealizationFlowNetworkError_api = GetRealizationFlowNetworkErrors_api[keyof GetRealizationFlowNetworkErrors_api]; +export type GetRealizationFlowNetworkError_api = + GetRealizationFlowNetworkErrors_api[keyof GetRealizationFlowNetworkErrors_api]; export type GetRealizationFlowNetworkResponses_api = { /** @@ -2852,7 +2961,8 @@ export type GetWellCompletionsDataResponses_api = { 200: WellCompletionsData_api; }; -export type GetWellCompletionsDataResponse_api = GetWellCompletionsDataResponses_api[keyof GetWellCompletionsDataResponses_api]; +export type GetWellCompletionsDataResponse_api = + GetWellCompletionsDataResponses_api[keyof GetWellCompletionsDataResponses_api]; export type GetDrilledWellboreHeadersData_api = { body?: never; @@ -2874,7 +2984,8 @@ export type GetDrilledWellboreHeadersErrors_api = { 422: HttpValidationError_api; }; -export type GetDrilledWellboreHeadersError_api = GetDrilledWellboreHeadersErrors_api[keyof GetDrilledWellboreHeadersErrors_api]; +export type GetDrilledWellboreHeadersError_api = + GetDrilledWellboreHeadersErrors_api[keyof GetDrilledWellboreHeadersErrors_api]; export type GetDrilledWellboreHeadersResponses_api = { /** @@ -2941,7 +3052,8 @@ export type GetWellborePickIdentifiersErrors_api = { 422: HttpValidationError_api; }; -export type GetWellborePickIdentifiersError_api = GetWellborePickIdentifiersErrors_api[keyof GetWellborePickIdentifiersErrors_api]; +export type GetWellborePickIdentifiersError_api = + GetWellborePickIdentifiersErrors_api[keyof GetWellborePickIdentifiersErrors_api]; export type GetWellborePickIdentifiersResponses_api = { /** @@ -3122,7 +3234,8 @@ export type GetWellboreCompletionsResponses_api = { 200: Array; }; -export type GetWellboreCompletionsResponse_api = GetWellboreCompletionsResponses_api[keyof GetWellboreCompletionsResponses_api]; +export type GetWellboreCompletionsResponse_api = + GetWellboreCompletionsResponses_api[keyof GetWellboreCompletionsResponses_api]; export type GetWellboreCasingsData_api = { body?: never; @@ -3175,7 +3288,8 @@ export type GetWellborePerforationsErrors_api = { 422: HttpValidationError_api; }; -export type GetWellborePerforationsError_api = GetWellborePerforationsErrors_api[keyof GetWellborePerforationsErrors_api]; +export type GetWellborePerforationsError_api = + GetWellborePerforationsErrors_api[keyof GetWellborePerforationsErrors_api]; export type GetWellborePerforationsResponses_api = { /** @@ -3184,7 +3298,8 @@ export type GetWellborePerforationsResponses_api = { 200: Array; }; -export type GetWellborePerforationsResponse_api = GetWellborePerforationsResponses_api[keyof GetWellborePerforationsResponses_api]; +export type GetWellborePerforationsResponse_api = + GetWellborePerforationsResponses_api[keyof GetWellborePerforationsResponses_api]; export type GetWellboreLogCurveHeadersData_api = { body?: never; @@ -3210,7 +3325,8 @@ export type GetWellboreLogCurveHeadersErrors_api = { 422: HttpValidationError_api; }; -export type GetWellboreLogCurveHeadersError_api = GetWellboreLogCurveHeadersErrors_api[keyof GetWellboreLogCurveHeadersErrors_api]; +export type GetWellboreLogCurveHeadersError_api = + GetWellboreLogCurveHeadersErrors_api[keyof GetWellboreLogCurveHeadersErrors_api]; export type GetWellboreLogCurveHeadersResponses_api = { /** @@ -3298,7 +3414,8 @@ export type GetSeismicCubeMetaListResponses_api = { 200: Array; }; -export type GetSeismicCubeMetaListResponse_api = GetSeismicCubeMetaListResponses_api[keyof GetSeismicCubeMetaListResponses_api]; +export type GetSeismicCubeMetaListResponse_api = + GetSeismicCubeMetaListResponses_api[keyof GetSeismicCubeMetaListResponses_api]; export type GetInlineSliceData_api = { body?: never; @@ -3549,7 +3666,8 @@ export type GetPolygonsDirectoryResponses_api = { 200: Array; }; -export type GetPolygonsDirectoryResponse_api = GetPolygonsDirectoryResponses_api[keyof GetPolygonsDirectoryResponses_api]; +export type GetPolygonsDirectoryResponse_api = + GetPolygonsDirectoryResponses_api[keyof GetPolygonsDirectoryResponses_api]; export type GetPolygonsDataData_api = { body?: never; @@ -3828,6 +3946,435 @@ export type GetVfpTableResponses_api = { export type GetVfpTableResponse_api = GetVfpTableResponses_api[keyof GetVfpTableResponses_api]; +export type GetSessionsMetadataData_api = { + body?: never; + path?: never; + query?: { + /** + * Sort the result by + */ + sort_by?: PrimaryServicesDatabaseAccessSessionAccessTypesSortBy_api | null; + /** + * Sort direction: 'asc' or 'desc' + */ + sort_direction?: SortDirection_api | null; + /** + * Limit the number of results + */ + limit?: number | null; + t?: number; + }; + url: "/sessions/sessions"; +}; + +export type GetSessionsMetadataErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type GetSessionsMetadataError_api = GetSessionsMetadataErrors_api[keyof GetSessionsMetadataErrors_api]; + +export type GetSessionsMetadataResponses_api = { + /** + * Successful Response + */ + 200: Array; +}; + +export type GetSessionsMetadataResponse_api = GetSessionsMetadataResponses_api[keyof GetSessionsMetadataResponses_api]; + +export type CreateSessionData_api = { + body: NewSession_api; + path?: never; + query?: { + t?: number; + }; + url: "/sessions/sessions"; +}; + +export type CreateSessionErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type CreateSessionError_api = CreateSessionErrors_api[keyof CreateSessionErrors_api]; + +export type CreateSessionResponses_api = { + /** + * Successful Response + */ + 200: string; +}; + +export type CreateSessionResponse_api = CreateSessionResponses_api[keyof CreateSessionResponses_api]; + +export type DeleteSessionData_api = { + body?: never; + path: { + session_id: string; + }; + query?: { + t?: number; + }; + url: "/sessions/sessions/{session_id}"; +}; + +export type DeleteSessionErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type DeleteSessionError_api = DeleteSessionErrors_api[keyof DeleteSessionErrors_api]; + +export type DeleteSessionResponses_api = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type GetSessionData_api = { + body?: never; + path: { + session_id: string; + }; + query?: { + t?: number; + }; + url: "/sessions/sessions/{session_id}"; +}; + +export type GetSessionErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type GetSessionError_api = GetSessionErrors_api[keyof GetSessionErrors_api]; + +export type GetSessionResponses_api = { + /** + * Successful Response + */ + 200: SessionDocument_api; +}; + +export type GetSessionResponse_api = GetSessionResponses_api[keyof GetSessionResponses_api]; + +export type UpdateSessionData_api = { + body: SessionUpdate_api; + path: { + session_id: string; + }; + query?: { + t?: number; + }; + url: "/sessions/sessions/{session_id}"; +}; + +export type UpdateSessionErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type UpdateSessionError_api = UpdateSessionErrors_api[keyof UpdateSessionErrors_api]; + +export type UpdateSessionResponses_api = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type GetSessionMetadataData_api = { + body?: never; + path: { + session_id: string; + }; + query?: { + t?: number; + }; + url: "/sessions/sessions/metadata/{session_id}"; +}; + +export type GetSessionMetadataErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type GetSessionMetadataError_api = GetSessionMetadataErrors_api[keyof GetSessionMetadataErrors_api]; + +export type GetSessionMetadataResponses_api = { + /** + * Successful Response + */ + 200: SessionMetadata_api; +}; + +export type GetSessionMetadataResponse_api = GetSessionMetadataResponses_api[keyof GetSessionMetadataResponses_api]; + +export type GetRecentSnapshotsData_api = { + body?: never; + path?: never; + query?: { + /** + * Sort the result by + */ + sort_by?: PrimaryServicesDatabaseAccessSnapshotAccessTypesSortBy_api | null; + /** + * Sort direction: 'asc' or 'desc' + */ + sort_direction?: SortDirection_api | null; + /** + * Limit the number of results + */ + limit?: number | null; + /** + * The offset of the results + */ + offset?: number | null; + t?: number; + }; + url: "/snapshots/recent_snapshots"; +}; + +export type GetRecentSnapshotsErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type GetRecentSnapshotsError_api = GetRecentSnapshotsErrors_api[keyof GetRecentSnapshotsErrors_api]; + +export type GetRecentSnapshotsResponses_api = { + /** + * Successful Response + */ + 200: Array; +}; + +export type GetRecentSnapshotsResponse_api = GetRecentSnapshotsResponses_api[keyof GetRecentSnapshotsResponses_api]; + +export type GetSnapshotsMetadataData_api = { + body?: never; + path?: never; + query?: { + /** + * Sort the result by + */ + sort_by?: PrimaryServicesDatabaseAccessSnapshotAccessTypesSortBy_api | null; + /** + * Sort direction: 'asc' or 'desc' + */ + sort_direction?: SortDirection_api | null; + /** + * Limit the number of results + */ + limit?: number | null; + t?: number; + }; + url: "/snapshots/snapshots"; +}; + +export type GetSnapshotsMetadataErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type GetSnapshotsMetadataError_api = GetSnapshotsMetadataErrors_api[keyof GetSnapshotsMetadataErrors_api]; + +export type GetSnapshotsMetadataResponses_api = { + /** + * Successful Response + */ + 200: Array; +}; + +export type GetSnapshotsMetadataResponse_api = + GetSnapshotsMetadataResponses_api[keyof GetSnapshotsMetadataResponses_api]; + +export type CreateSnapshotData_api = { + body: NewSnapshot_api; + path?: never; + query?: { + t?: number; + }; + url: "/snapshots/snapshots"; +}; + +export type CreateSnapshotErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type CreateSnapshotError_api = CreateSnapshotErrors_api[keyof CreateSnapshotErrors_api]; + +export type CreateSnapshotResponses_api = { + /** + * Successful Response + */ + 200: string; +}; + +export type CreateSnapshotResponse_api = CreateSnapshotResponses_api[keyof CreateSnapshotResponses_api]; + +export type DeleteSnapshotData_api = { + body?: never; + path: { + snapshot_id: string; + }; + query?: { + t?: number; + }; + url: "/snapshots/snapshots/{snapshot_id}"; +}; + +export type DeleteSnapshotErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type DeleteSnapshotError_api = DeleteSnapshotErrors_api[keyof DeleteSnapshotErrors_api]; + +export type DeleteSnapshotResponses_api = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type GetSnapshotData_api = { + body?: never; + path: { + snapshot_id: string; + }; + query?: { + t?: number; + }; + url: "/snapshots/snapshots/{snapshot_id}"; +}; + +export type GetSnapshotErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type GetSnapshotError_api = GetSnapshotErrors_api[keyof GetSnapshotErrors_api]; + +export type GetSnapshotResponses_api = { + /** + * Successful Response + */ + 200: Snapshot_api; +}; + +export type GetSnapshotResponse_api = GetSnapshotResponses_api[keyof GetSnapshotResponses_api]; + +export type UpdateSnapshotData_api = { + body: SnapshotUpdate_api; + path: { + snapshot_id: string; + }; + query?: { + t?: number; + }; + url: "/snapshots/snapshots/{snapshot_id}"; +}; + +export type UpdateSnapshotErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type UpdateSnapshotError_api = UpdateSnapshotErrors_api[keyof UpdateSnapshotErrors_api]; + +export type UpdateSnapshotResponses_api = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type GetSnapshotMetadataData_api = { + body?: never; + path: { + snapshot_id: string; + }; + query?: { + t?: number; + }; + url: "/snapshots/snapshots/metadata/{snapshot_id}"; +}; + +export type GetSnapshotMetadataErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type GetSnapshotMetadataError_api = GetSnapshotMetadataErrors_api[keyof GetSnapshotMetadataErrors_api]; + +export type GetSnapshotMetadataResponses_api = { + /** + * Successful Response + */ + 200: SnapshotMetadata_api; +}; + +export type GetSnapshotMetadataResponse_api = GetSnapshotMetadataResponses_api[keyof GetSnapshotMetadataResponses_api]; + +export type SnapshotPreviewData_api = { + body?: never; + path: { + snapshot_id: string; + }; + query?: { + t?: number; + }; + url: "/snapshot-preview/{snapshot_id}"; +}; + +export type SnapshotPreviewErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type SnapshotPreviewError_api = SnapshotPreviewErrors_api[keyof SnapshotPreviewErrors_api]; + +export type SnapshotPreviewResponses_api = { + /** + * Successful Response + */ + 200: string; +}; + +export type SnapshotPreviewResponse_api = SnapshotPreviewResponses_api[keyof SnapshotPreviewResponses_api]; + export type LoginRouteData_api = { body?: never; path?: never; diff --git a/frontend/src/framework/ConfirmationService.ts b/frontend/src/framework/ConfirmationService.ts new file mode 100644 index 000000000..e8cd52a3d --- /dev/null +++ b/frontend/src/framework/ConfirmationService.ts @@ -0,0 +1,41 @@ +import type { ButtonProps } from "@lib/components/Button/button"; + +export type ConfirmAction = { + id: T; + label: string; + color?: ButtonProps["color"]; +}; + +export type ConfirmOptions = { + title: string; + message: string; + actions: ConfirmAction[]; +}; + +export class ConfirmationService { + private _resolver: ((result: T) => void) | null = null; + private _showDialogCallback: ((options: ConfirmOptions) => void) | null = null; + + setShowDialogCallback(callback: (options: ConfirmOptions) => void) { + this._showDialogCallback = callback; + } + + confirm(options: ConfirmOptions): Promise { + if (!this._showDialogCallback) { + throw new Error("ConfirmationService: Show dialog callback is not set."); + } + + return new Promise((resolve) => { + this._resolver = resolve; + this._showDialogCallback!(options); + }); + } + + resolve(result: T): void { + this._resolver?.(result); + this._resolver = null; // Reset resolver after resolving + } +} + +// Making a singleton instance of ConfirmationService +export const confirmationService = new ConfirmationService(); diff --git a/frontend/src/framework/EnsembleTimestampsStore.ts b/frontend/src/framework/EnsembleTimestampsStore.ts new file mode 100644 index 000000000..bbff5eff6 --- /dev/null +++ b/frontend/src/framework/EnsembleTimestampsStore.ts @@ -0,0 +1,45 @@ +import type { RegularEnsembleIdent } from "./RegularEnsembleIdent"; + +export type EnsembleTimestamps = { + caseUpdatedAtUtcMs: number; + dataUpdatedAtUtcMs: number; +}; + +class EnsembleTimestampsStoreImpl { + private _timestamps: Map = new Map(); + + setAll(timestamps: Map) { + this._timestamps = timestamps; + } + + update(timestamps: Map) { + for (const [key, value] of timestamps) { + this._timestamps.set(key, value); + } + } + + getLatestTimestamps(...idents: RegularEnsembleIdent[]): EnsembleTimestamps { + let dataUpdatedAt = 0; + let caseUpdatedAt = 0; + + for (const ident of idents) { + const ts = this._timestamps.get(ident.toString()); + if (!ts) continue; + + if (ts.dataUpdatedAtUtcMs && ts.dataUpdatedAtUtcMs > dataUpdatedAt) { + dataUpdatedAt = ts.dataUpdatedAtUtcMs; + } + if (ts.caseUpdatedAtUtcMs && ts.caseUpdatedAtUtcMs > caseUpdatedAt) { + caseUpdatedAt = ts.caseUpdatedAtUtcMs; + } + } + + return { dataUpdatedAtUtcMs: dataUpdatedAt, caseUpdatedAtUtcMs: caseUpdatedAt }; + } + + clear() { + this._timestamps.clear(); + } +} + +export const EnsembleTimestampsStore = new EnsembleTimestampsStoreImpl(); diff --git a/frontend/src/framework/GlobalAtoms.ts b/frontend/src/framework/GlobalAtoms.ts index ca67c6b69..0db45e6c4 100644 --- a/frontend/src/framework/GlobalAtoms.ts +++ b/frontend/src/framework/GlobalAtoms.ts @@ -1,4 +1,3 @@ - import { atom } from "jotai"; import { isEqual } from "lodash"; diff --git a/frontend/src/framework/GuiMessageBroker.ts b/frontend/src/framework/GuiMessageBroker.ts index d0c17c225..bf2026e13 100644 --- a/frontend/src/framework/GuiMessageBroker.ts +++ b/frontend/src/framework/GuiMessageBroker.ts @@ -8,8 +8,6 @@ import type { UnsavedChangesAction } from "./types/unsavedChangesAction"; export enum LeftDrawerContent { ModuleSettings = "ModuleSettings", - ModulesList = "ModulesList", - TemplatesList = "TemplatesList", SyncSettings = "SyncSettings", ColorPaletteSettings = "ColorPaletteSettings", } @@ -17,19 +15,29 @@ export enum LeftDrawerContent { export enum RightDrawerContent { RealizationFilterSettings = "RealizationFilterSettings", ModuleInstanceLog = "ModuleInstanceLog", + ModulesList = "ModulesList", } export enum GuiState { LeftDrawerContent = "leftDrawerContent", RightDrawerContent = "rightDrawerContent", LeftSettingsPanelWidthInPercent = "leftSettingsPanelWidthInPercent", - ActiveModuleInstanceId = "activeModuleInstanceId", DataChannelConnectionLayerVisible = "dataChannelConnectionLayerVisible", DevToolsVisible = "devToolsVisible", EditDataChannelConnections = "editDataChannelConnections", RightSettingsPanelWidthInPercent = "rightSettingsPanelWidthInPercent", AppInitialized = "appInitialized", NumberOfUnsavedRealizationFilters = "numberOfUnsavedRealizationFilters", + SaveSessionDialogOpen = "saveSessionDialogOpen", + SessionHasUnsavedChanges = "sessionHasUnsavedChanges", + IsSavingSession = "isSavingSession", + IsMakingSnapshot = "isMakingSnapshot", + IsLoadingSession = "isLoadingSession", + EnsembleDialogOpen = "ensembleDialogOpen", + MultiSessionsRecoveryDialogOpen = "multiSessionsRecoveryDialogOpen", + ActiveSessionRecoveryDialogOpen = "activeSessionRecoveryDialogOpen", + MakeSnapshotDialogOpen = "makeSnapshotDialogOpen", + TemplatesDialogOpen = "templatesDialogOpen", } export enum GuiEvent { @@ -88,25 +96,44 @@ type GuiStateValueTypes = { [GuiState.LeftDrawerContent]: LeftDrawerContent; [GuiState.RightDrawerContent]: RightDrawerContent | undefined; [GuiState.LeftSettingsPanelWidthInPercent]: number; - [GuiState.ActiveModuleInstanceId]: string; [GuiState.DataChannelConnectionLayerVisible]: boolean; [GuiState.DevToolsVisible]: boolean; [GuiState.EditDataChannelConnections]: boolean; [GuiState.RightSettingsPanelWidthInPercent]: number; [GuiState.AppInitialized]: boolean; [GuiState.NumberOfUnsavedRealizationFilters]: number; + [GuiState.SaveSessionDialogOpen]: boolean; + [GuiState.IsSavingSession]: boolean; + [GuiState.IsLoadingSession]: boolean; + [GuiState.SessionHasUnsavedChanges]: boolean; + [GuiState.EnsembleDialogOpen]: boolean; + [GuiState.MultiSessionsRecoveryDialogOpen]: boolean; + [GuiState.ActiveSessionRecoveryDialogOpen]: boolean; + [GuiState.MakeSnapshotDialogOpen]: boolean; + [GuiState.IsMakingSnapshot]: boolean; + [GuiState.TemplatesDialogOpen]: boolean; }; const defaultStates: Map = new Map(); defaultStates.set(GuiState.LeftDrawerContent, LeftDrawerContent.ModuleSettings); defaultStates.set(GuiState.RightDrawerContent, undefined); defaultStates.set(GuiState.LeftSettingsPanelWidthInPercent, 30); -defaultStates.set(GuiState.ActiveModuleInstanceId, ""); defaultStates.set(GuiState.DataChannelConnectionLayerVisible, false); defaultStates.set(GuiState.DevToolsVisible, isDevMode()); defaultStates.set(GuiState.RightSettingsPanelWidthInPercent, 0); defaultStates.set(GuiState.AppInitialized, false); defaultStates.set(GuiState.NumberOfUnsavedRealizationFilters, 0); +defaultStates.set(GuiState.SaveSessionDialogOpen, false); +defaultStates.set(GuiState.IsSavingSession, false); +defaultStates.set(GuiState.IsLoadingSession, false); +defaultStates.set(GuiState.SessionHasUnsavedChanges, false); +defaultStates.set(GuiState.EditDataChannelConnections, false); +defaultStates.set(GuiState.EnsembleDialogOpen, false); +defaultStates.set(GuiState.MultiSessionsRecoveryDialogOpen, false); +defaultStates.set(GuiState.ActiveSessionRecoveryDialogOpen, false); +defaultStates.set(GuiState.MakeSnapshotDialogOpen, false); +defaultStates.set(GuiState.IsMakingSnapshot, false); +defaultStates.set(GuiState.TemplatesDialogOpen, false); const persistentStates: GuiState[] = [ GuiState.LeftSettingsPanelWidthInPercent, diff --git a/frontend/src/framework/Module.tsx b/frontend/src/framework/Module.tsx index 53ede2286..066375be1 100644 --- a/frontend/src/framework/Module.tsx +++ b/frontend/src/framework/Module.tsx @@ -1,7 +1,9 @@ import type React from "react"; +import type { JTDSchemaType } from "ajv/dist/core"; import type { Getter, Setter } from "jotai"; +import type { AtomStoreMaster } from "./AtomStoreMaster"; import type { ChannelDefinition, ChannelReceiverDefinition } from "./DataChannelTypes"; import type { InitialSettings } from "./InitialSettings"; import type { SettingsContext, ViewContext } from "./ModuleContext"; @@ -10,7 +12,6 @@ import { ModuleInstance, ModuleInstanceTopic } from "./ModuleInstance"; import type { DrawPreviewFunc } from "./Preview"; import type { SyncSettingKey } from "./SyncSettings"; import type { InterfaceBaseType, InterfaceInitialization } from "./UniDirectionalModuleComponentsInterface"; -import type { Workbench } from "./Workbench"; import type { WorkbenchServices } from "./WorkbenchServices"; import type { WorkbenchSession } from "./WorkbenchSession"; import type { WorkbenchSettings } from "./WorkbenchSettings"; @@ -50,8 +51,9 @@ export type ModuleSettingsProps< settingsToView: Record; viewToSettings: Record; }, + TSerializedStateDef extends ModuleComponentsStateBase = NoModuleStateSchema, > = { - settingsContext: SettingsContext; + settingsContext: SettingsContext; workbenchSession: WorkbenchSession; workbenchServices: WorkbenchServices; workbenchSettings: WorkbenchSettings; @@ -63,8 +65,9 @@ export type ModuleViewProps< settingsToView: Record; viewToSettings: Record; }, + TSerializedStateDef extends ModuleComponentsStateBase = NoModuleStateSchema, > = { - viewContext: ViewContext; + viewContext: ViewContext; workbenchSession: WorkbenchSession; workbenchServices: WorkbenchServices; workbenchSettings: WorkbenchSettings; @@ -77,28 +80,87 @@ export type InterfaceEffects = (( getAtomValue: Getter, ) => void)[]; +export type JTDBaseType = Record; + +export type ModuleComponentsStateBase = { + settings?: Record; + view?: Record; +}; + +export type SerializedModuleComponentsState = { + settings: TSerializedStateDef["settings"]; + view: TSerializedStateDef["view"]; +}; + +export type NoModuleStateSchema = { + settings: Record; + view: Record; +}; + +export type ModuleStateSchema = { + settings?: JTDSchemaType; + view?: JTDSchemaType; +}; + +export interface SerializeStateFunction { + (get: Getter): T; +} + +export interface DeserializeStateFunction { + (raw: Partial, set: Setter): void; +} + +export type ModuleComponentSerializationFunctions = + TSerializedStateDef extends NoModuleStateSchema + ? { + serializeStateFunctions?: never; + deserializeStateFunctions?: never; + } + : { + serializeStateFunctions: { + settings?: SerializeStateFunction; + view?: SerializeStateFunction; + }; + deserializeStateFunctions: { + settings?: DeserializeStateFunction; + view?: DeserializeStateFunction; + }; + }; + +export function hasSerialization( + val: ModuleComponentSerializationFunctions, +): val is Exclude { + return !!(val as any).serializeStateFunctions; +} + +export type MakeReadonly = { + readonly [P in keyof T]: T[P]; +}; + export type ModuleSettings< TInterfaceTypes extends ModuleInterfaceTypes = { settingsToView: Record; viewToSettings: Record; }, -> = React.FC>; + TSerializedStateDef extends ModuleComponentsStateBase = NoModuleStateSchema, +> = React.FC>; export type ModuleView< TInterfaceTypes extends ModuleInterfaceTypes = { settingsToView: Record; viewToSettings: Record; }, -> = React.FC>; + TSerializedStateDef extends ModuleComponentsStateBase = NoModuleStateSchema, +> = React.FC>; -export enum ImportState { +export enum ImportStatus { NotImported = "NotImported", Importing = "Importing", Imported = "Imported", Failed = "Failed", } -export interface ModuleOptions { +export type ModuleOptions = { name: string; defaultTitle: string; category: ModuleCategory; @@ -110,15 +172,16 @@ export interface ModuleOptions { channelDefinitions?: ChannelDefinition[]; channelReceiverDefinitions?: ChannelReceiverDefinition[]; onInstanceUnloadFunc?: OnInstanceUnloadFunc; -} + serializedStateSchema?: ModuleStateSchema; +}; -export class Module { +export class Module { private _name: string; private _defaultTitle: string; - public viewFC: ModuleView; - public settingsFC: ModuleSettings; - protected _importState: ImportState = ImportState.NotImported; - private _moduleInstances: ModuleInstance[] = []; + public viewFC: ModuleView; + public settingsFC: ModuleSettings; + protected _importState: ImportStatus = ImportStatus.NotImported; + private _moduleInstances: ModuleInstance[] = []; private _settingsToViewInterfaceInitialization: InterfaceInitialization< Exclude > | null = null; @@ -129,7 +192,6 @@ export class Module { []; private _settingsToViewInterfaceEffects: InterfaceEffects> = []; - private _workbench: Workbench | null = null; private _syncableSettingKeys: SyncSettingKey[]; private _drawPreviewFunc: DrawPreviewFunc | null; private _onInstanceUnloadFunc: OnInstanceUnloadFunc | null; @@ -139,8 +201,11 @@ export class Module { private _category: ModuleCategory; private _devState: ModuleDevState; private _dataTagIds: ModuleDataTagId[]; + private _serializedStateSchema: ModuleStateSchema | null; + private _serializationFunctions: ModuleComponentSerializationFunctions | undefined; + private _atomStoreMaster: AtomStoreMaster | null = null; - constructor(options: ModuleOptions) { + constructor(options: ModuleOptions) { this._name = options.name; this._defaultTitle = options.defaultTitle; this._category = options.category; @@ -154,13 +219,18 @@ export class Module { this._channelDefinitions = options.channelDefinitions ?? null; this._channelReceiverDefinitions = options.channelReceiverDefinitions ?? null; this._dataTagIds = options.dataTagIds ?? []; + this._serializedStateSchema = options.serializedStateSchema ?? null; + } + + getSerializedStateSchema(): ModuleStateSchema | null { + return this._serializedStateSchema; } getDrawPreviewFunc(): DrawPreviewFunc | null { return this._drawPreviewFunc; } - getImportState(): ImportState { + getImportState(): ImportStatus { return this._importState; } @@ -188,10 +258,6 @@ export class Module { return this._description; } - setWorkbench(workbench: Workbench): void { - this._workbench = workbench; - } - setSettingsToViewInterfaceInitialization( interfaceInitialization: InterfaceInitialization>, ): void { @@ -216,6 +282,14 @@ export class Module { this._settingsToViewInterfaceEffects = atomsInitialization; } + setSerializationFunctions(serializationFunctions: ModuleComponentSerializationFunctions): void { + this._serializationFunctions = serializationFunctions; + } + + getComponentSerializationFunctions(): ModuleComponentSerializationFunctions | undefined { + return this._serializationFunctions; + } + getViewToSettingsInterfaceEffects(): InterfaceEffects> { return this._viewToSettingsInterfaceEffects; } @@ -232,19 +306,16 @@ export class Module { return this._syncableSettingKeys.includes(key); } - makeInstance(instanceNumber: number): ModuleInstance { - if (!this._workbench) { - throw new Error("Module must be added to a workbench before making an instance"); - } - - const instance = new ModuleInstance({ + makeInstance(id: string, atomStoreMaster: AtomStoreMaster): ModuleInstance { + const instance = new ModuleInstance({ module: this, - workbench: this._workbench, - instanceNumber, + atomStoreMaster, + id, channelDefinitions: this._channelDefinitions, channelReceiverDefinitions: this._channelReceiverDefinitions, }); this._moduleInstances.push(instance); + atomStoreMaster.makeAtomStoreForModuleInstance(id); this.maybeImportSelf(); return instance; } @@ -253,19 +324,14 @@ export class Module { this._onInstanceUnloadFunc?.(instanceId); } - private setImportState(state: ImportState): void { + private setImportState(state: ImportStatus): void { this._importState = state; this._moduleInstances.forEach((instance) => { - instance.notifySubscribers(ModuleInstanceTopic.IMPORT_STATE); + instance.notifySubscribers(ModuleInstanceTopic.IMPORT_STATUS); }); - - if (this._workbench && state === ImportState.Imported) { - this._workbench.maybeMakeFirstModuleInstanceActive(); - } } - private initializeModuleInstance(instance: ModuleInstance): void { - instance.initialize(); + private initializeModuleInstance(instance: ModuleInstance): void { if (this._settingsToViewInterfaceInitialization) { instance.makeSettingsToViewInterface(this._settingsToViewInterfaceInitialization); } @@ -274,11 +340,15 @@ export class Module { instance.makeViewToSettingsInterface(this._viewToSettingsInterfaceInitialization); } instance.makeViewToSettingsInterfaceEffectsAtom(); + if (this._serializationFunctions) { + instance.makeSerializer(this._serializationFunctions); + } + instance.initialize(); } - private maybeImportSelf(): void { - if (this._importState !== ImportState.NotImported) { - if (this._importState === ImportState.Imported) { + private async maybeImportSelf(): Promise { + if (this._importState !== ImportStatus.NotImported) { + if (this._importState === ImportStatus.Imported) { this._moduleInstances.forEach((instance) => { if (instance.isInitialized()) { return; @@ -289,27 +359,26 @@ export class Module { return; } - this.setImportState(ImportState.Importing); + this.setImportState(ImportStatus.Importing); const path = `/src/modules/${this._name}/loadModule.tsx`; const importer = moduleImporters[path]; if (!importer) { console.error(`Module importer not found for ${path}`); - this.setImportState(ImportState.Failed); + this.setImportState(ImportStatus.Failed); return; } - importer() - .then(() => { - this.setImportState(ImportState.Imported); - this._moduleInstances.forEach((instance) => { - this.initializeModuleInstance(instance); - }); - }) - .catch((e) => { - console.error(`Failed to import module ${this._name}`, e); - this.setImportState(ImportState.Failed); + try { + await importer(); + this.setImportState(ImportStatus.Imported); + this._moduleInstances.forEach((instance) => { + this.initializeModuleInstance(instance); }); + } catch (e) { + console.error(`Failed to initialize module ${this._name}`, e); + this.setImportState(ImportStatus.Failed); + } } } diff --git a/frontend/src/framework/ModuleContext.ts b/frontend/src/framework/ModuleContext.ts index 1b86e29cf..834fa82ca 100644 --- a/frontend/src/framework/ModuleContext.ts +++ b/frontend/src/framework/ModuleContext.ts @@ -13,7 +13,7 @@ import type { ChannelContentDefinition, KeyKind } from "./DataChannelTypes"; import { useChannelReceiver } from "./internal/DataChannels/hooks/useChannelReceiver"; import { usePublishChannelContents } from "./internal/DataChannels/hooks/usePublishChannelContents"; -import type { ModuleInterfaceTypes } from "./Module"; +import type { ModuleComponentsStateBase, ModuleInterfaceTypes, NoModuleStateSchema } from "./Module"; import type { ModuleInstance, ModuleInstanceTopicValueTypes } from "./ModuleInstance"; import { ModuleInstanceTopic, useModuleInstanceTopicValue } from "./ModuleInstance"; import type { ModuleInstanceStatusController } from "./ModuleInstanceStatusController"; @@ -21,10 +21,13 @@ import type { SyncSettingKey } from "./SyncSettings"; import type { InterfaceBaseType } from "./UniDirectionalModuleComponentsInterface"; import { useInterfaceValue } from "./UniDirectionalModuleComponentsInterface"; -export class ModuleContext { - protected _moduleInstance: ModuleInstance; +export class ModuleContext< + TInterfaceTypes extends ModuleInterfaceTypes, + TSerializedStateDef extends ModuleComponentsStateBase, +> { + protected _moduleInstance: ModuleInstance; - constructor(moduleInstance: ModuleInstance) { + constructor(moduleInstance: ModuleInstance) { this._moduleInstance = moduleInstance; } @@ -92,12 +95,18 @@ export class ModuleContext { } } -export type ViewContext = Omit< - ModuleContext, +export type ViewContext< + TInterfaceType extends InterfaceBaseType, + TSerializedStateDef extends ModuleComponentsStateBase = NoModuleStateSchema, +> = Omit< + ModuleContext, "useViewToSettingsInterfaceValue" | "useSettingsAtom" | "useSetSettingsAtom" | "useSettingsAtomValue" >; -export type SettingsContext = Omit< - ModuleContext, +export type SettingsContext< + TInterfaceType extends InterfaceBaseType, + TSerializedStateDef extends ModuleComponentsStateBase = NoModuleStateSchema, +> = Omit< + ModuleContext, "useSettingsToViewInterfaceValue" | "useViewAtom" | "useViewAtomValue" | "useSetViewAtom" >; diff --git a/frontend/src/framework/ModuleInstance.ts b/frontend/src/framework/ModuleInstance.ts index 96e85f6ab..b4972c128 100644 --- a/frontend/src/framework/ModuleInstance.ts +++ b/frontend/src/framework/ModuleInstance.ts @@ -5,18 +5,28 @@ import type { Atom } from "jotai"; import { atom } from "jotai"; import { atomEffect } from "jotai-effect"; +import type { AtomStoreMaster } from "./AtomStoreMaster"; import type { ChannelDefinition, ChannelReceiverDefinition } from "./DataChannelTypes"; import type { InitialSettings } from "./InitialSettings"; -import { ChannelManager } from "./internal/DataChannels/ChannelManager"; +import { ChannelManager, type DataChannelReceiverSubscription } from "./internal/DataChannels/ChannelManager"; import { ModuleInstanceStatusControllerInternal } from "./internal/ModuleInstanceStatusControllerInternal"; -import type { ImportState, Module, ModuleInterfaceTypes, ModuleSettings, ModuleView } from "./Module"; +import type { Dashboard } from "./internal/WorkbenchSession/Dashboard"; +import type { + ImportStatus, + Module, + ModuleComponentSerializationFunctions, + ModuleComponentsStateBase, + ModuleInterfaceTypes, + ModuleSettings, + ModuleView, +} from "./Module"; import { ModuleContext } from "./ModuleContext"; +import { ModuleInstanceSerializer } from "./ModuleInstanceSerializer"; import type { SyncSettingKey } from "./SyncSettings"; import type { InterfaceInitialization } from "./UniDirectionalModuleComponentsInterface"; import { UniDirectionalModuleComponentsInterface } from "./UniDirectionalModuleComponentsInterface"; -import type { Workbench } from "./Workbench"; -export enum ModuleInstanceState { +export enum ModuleInstanceLifeCycleState { INITIALIZING, OK, ERROR, @@ -26,34 +36,60 @@ export enum ModuleInstanceState { export enum ModuleInstanceTopic { TITLE = "title", SYNCED_SETTINGS = "synced-settings", - STATE = "state", - IMPORT_STATE = "import-state", + LIFECYCLE_STATE = "state", + IMPORT_STATUS = "import-status", + SERIALIZED_STATE = "serialized-state", } export type ModuleInstanceTopicValueTypes = { [ModuleInstanceTopic.TITLE]: string; [ModuleInstanceTopic.SYNCED_SETTINGS]: SyncSettingKey[]; - [ModuleInstanceTopic.STATE]: ModuleInstanceState; - [ModuleInstanceTopic.IMPORT_STATE]: ImportState; + [ModuleInstanceTopic.LIFECYCLE_STATE]: ModuleInstanceLifeCycleState; + [ModuleInstanceTopic.IMPORT_STATUS]: ImportStatus; + [ModuleInstanceTopic.SERIALIZED_STATE]: ModuleComponentsStateBase; }; -export interface ModuleInstanceOptions { - module: Module; - workbench: Workbench; - instanceNumber: number; +export interface ModuleInstanceOptions< + TInterfaceTypes extends ModuleInterfaceTypes, + TSerializedStateSchema extends ModuleComponentsStateBase, +> { + module: Module; + atomStoreMaster: AtomStoreMaster; + id: string; channelDefinitions: ChannelDefinition[] | null; channelReceiverDefinitions: ChannelReceiverDefinition[] | null; } -export class ModuleInstance { +export type ModuleInstanceSerializedState = { + id: string; + name: string; + dataChannelReceiverSubscriptions: DataChannelReceiverSubscription[]; + syncedSettingKeys: SyncSettingKey[]; + serializedState: StringifiedSerializedModuleState | null; +}; + +type StringifiedSerializedModuleState = { + settings?: string; + view?: string; +}; + +export type PartialSerializedModuleState = { + settings?: Partial; + view?: Partial; +}; + +export class ModuleInstance< + TInterfaceTypes extends ModuleInterfaceTypes, + TSerializedStateSchema extends ModuleComponentsStateBase, +> { private _id: string; private _title: string; private _initialized: boolean = false; - private _moduleInstanceState: ModuleInstanceState = ModuleInstanceState.INITIALIZING; + private _moduleInstanceState: ModuleInstanceLifeCycleState = ModuleInstanceLifeCycleState.INITIALIZING; private _fatalError: { err: Error; errInfo: ErrorInfo } | null = null; private _syncedSettingKeys: SyncSettingKey[] = []; - private _module: Module; - private _context: ModuleContext | null = null; + private _module: Module; + private _context: ModuleContext | null = null; private _subscribers: Map void>> = new Map(); private _initialSettings: InitialSettings | null = null; private _statusController: ModuleInstanceStatusControllerInternal = new ModuleInstanceStatusControllerInternal(); @@ -66,11 +102,18 @@ export class ModuleInstance { > | null = null; private _settingsToViewInterfaceEffectsAtom: Atom | null = null; private _viewToSettingsInterfaceEffectsAtom: Atom | null = null; + private _atomStoreMaster: AtomStoreMaster; - constructor(options: ModuleInstanceOptions) { - this._id = `${options.module.getName()}-${options.instanceNumber}`; + private _serializer: ModuleInstanceSerializer | null = null; + private _storedSerializedState: ModuleInstanceSerializedState | null = null; + private _storedTemplateState: PartialSerializedModuleState | null = null; + private _dashboard: Dashboard | null = null; + + constructor(options: ModuleInstanceOptions) { + this._id = options.id; this._title = options.module.getDefaultTitle(); this._module = options.module; + this._atomStoreMaster = options.atomStoreMaster; this._channelManager = new ChannelManager(this._id); @@ -88,6 +131,66 @@ export class ModuleInstance { } } + handleStateChange(): void { + this.notifySubscribers(ModuleInstanceTopic.SERIALIZED_STATE); + } + + serialize(): ModuleInstanceSerializedState { + return { + id: this._id, + name: this._module.getName(), + // Replace with channel manager's own serialization logic + dataChannelReceiverSubscriptions: this._channelManager + .getReceivers() + .filter((receiver) => receiver.hasActiveSubscription()) + .map((receiver) => ({ + idString: receiver.getIdString(), + listensToModuleInstanceId: receiver.getChannel()?.getManager().getModuleInstanceId() ?? "", + channelIdString: receiver.getChannel()?.getIdString() ?? "", + contentIdStrings: receiver.getContentIdStrings(), + })), + syncedSettingKeys: this._syncedSettingKeys, + serializedState: this._serializer?.getStringifiedSerializedState() ?? null, + }; + } + + initiateDeserialization(raw: ModuleInstanceSerializedState, dashboard: Dashboard): void { + this._storedSerializedState = raw; + this._dashboard = dashboard; + this.deserialize(); + } + + initiateTemplateStateApplication(initialState: PartialSerializedModuleState): void { + this._storedTemplateState = initialState; + this.applyTemplateState(); + } + + private applyTemplateState(): void { + if (this._initialized && this._storedTemplateState && this._serializer) { + this._serializer.applyTemplateState(this._storedTemplateState); + this._storedTemplateState = null; + } + } + + private deserialize(): void { + if (this._initialized && this._storedSerializedState && this._dashboard) { + this._syncedSettingKeys = this._storedSerializedState.syncedSettingKeys; + + this._id = this._storedSerializedState.id; + + if (this._storedSerializedState.serializedState && this._serializer) { + this._serializer.deserializeState(this._storedSerializedState.serializedState); + } + + this._channelManager.deserialize( + this._storedSerializedState.dataChannelReceiverSubscriptions, + this._dashboard, + ); + + this._storedSerializedState = null; + } + } + getUniDirectionalSettingsToViewInterface(): UniDirectionalModuleComponentsInterface< Exclude > { @@ -111,9 +214,11 @@ export class ModuleInstance { } initialize(): void { - this._context = new ModuleContext(this); + this._context = new ModuleContext(this); this._initialized = true; - this.setModuleInstanceState(ModuleInstanceState.OK); + this.setModuleInstanceState(ModuleInstanceLifeCycleState.OK); + this.deserialize(); + this.applyTemplateState(); } makeSettingsToViewInterface( @@ -134,6 +239,18 @@ export class ModuleInstance { this._viewToSettingsInterface = new UniDirectionalModuleComponentsInterface(interfaceInitialization); } + makeSerializer(serializationFunctions: ModuleComponentSerializationFunctions | null): void { + if (serializationFunctions) { + this._serializer = new ModuleInstanceSerializer( + this, + this._atomStoreMaster.getAtomStoreForModuleInstance(this._id), + this._module.getSerializedStateSchema(), + serializationFunctions, + this.handleStateChange.bind(this), + ); + } + } + makeSettingsToViewInterfaceEffectsAtom(): void { const effectFuncs = this.getModule().getSettingsToViewInterfaceEffects(); const getUniDirectionalSettingsToViewInterface: () => UniDirectionalModuleComponentsInterface< @@ -186,19 +303,27 @@ export class ModuleInstance { getSettingsToViewInterfaceEffectsAtom(): Atom { if (!this._settingsToViewInterfaceEffectsAtom) { - throw `Module instance '${this._title}' does not have settings to view interface effects yet. Did you forget to init the module?`; + throw new Error( + `Module instance '${this._title}' does not have settings to view interface effects yet. Did you forget to init the module?`, + ); } return this._settingsToViewInterfaceEffectsAtom; } getViewToSettingsInterfaceEffectsAtom(): Atom { if (!this._viewToSettingsInterfaceEffectsAtom) { - throw `Module instance '${this._title}' does not have view to settings interface effects yet. Did you forget to init the module?`; + throw new Error( + `Module instance '${this._title}' does not have view to settings interface effects yet. Did you forget to init the module?`, + ); } return this._viewToSettingsInterfaceEffectsAtom; } addSyncedSetting(settingKey: SyncSettingKey): void { + if (this._syncedSettingKeys.includes(settingKey)) { + console.warn(`Setting key '${settingKey}' is already synced for module instance '${this._title}'.`); + return; + } this._syncedSettingKeys.push(settingKey); this.notifySubscribers(ModuleInstanceTopic.SYNCED_SETTINGS); } @@ -220,21 +345,23 @@ export class ModuleInstance { return this._initialized; } - getViewFC(): ModuleView { + getViewFC(): ModuleView { return this._module.viewFC; } - getSettingsFC(): ModuleSettings { + getSettingsFC(): ModuleSettings { return this._module.settingsFC; } - getImportState(): ImportState { + getImportState(): ImportStatus { return this._module.getImportState(); } - getContext(): ModuleContext { + getContext(): ModuleContext { if (!this._context) { - throw `Module context is not available yet. Did you forget to init the module '${this._title}.'?`; + throw new Error( + `Module context is not available yet. Did you forget to init the module '${this._title}.'?`, + ); } return this._context; } @@ -288,18 +415,22 @@ export class ModuleInstance { if (topic === ModuleInstanceTopic.SYNCED_SETTINGS) { return this.getSyncedSettingKeys(); } - if (topic === ModuleInstanceTopic.STATE) { + if (topic === ModuleInstanceTopic.LIFECYCLE_STATE) { return this.getModuleInstanceState(); } - if (topic === ModuleInstanceTopic.IMPORT_STATE) { + if (topic === ModuleInstanceTopic.IMPORT_STATUS) { return this.getImportState(); } + if (topic === ModuleInstanceTopic.SERIALIZED_STATE) { + // !This is not reference-stable, so we return a new object each time + return this.serialize(); + } }; return snapshotGetter; } - getModule(): Module { + getModule(): Module { return this._module; } @@ -307,17 +438,17 @@ export class ModuleInstance { return this._statusController; } - private setModuleInstanceState(moduleInstanceState: ModuleInstanceState): void { + private setModuleInstanceState(moduleInstanceState: ModuleInstanceLifeCycleState): void { this._moduleInstanceState = moduleInstanceState; - this.notifySubscribers(ModuleInstanceTopic.STATE); + this.notifySubscribers(ModuleInstanceTopic.LIFECYCLE_STATE); } - getModuleInstanceState(): ModuleInstanceState { + getModuleInstanceState(): ModuleInstanceLifeCycleState { return this._moduleInstanceState; } setFatalError(err: Error, errInfo: ErrorInfo): void { - this.setModuleInstanceState(ModuleInstanceState.ERROR); + this.setModuleInstanceState(ModuleInstanceLifeCycleState.ERROR); this._fatalError = { err, errInfo, @@ -332,7 +463,7 @@ export class ModuleInstance { } reset(): Promise { - this.setModuleInstanceState(ModuleInstanceState.RESETTING); + this.setModuleInstanceState(ModuleInstanceLifeCycleState.RESETTING); return new Promise((resolve) => { this._module.onInstanceUnload(this._id); @@ -353,10 +484,20 @@ export class ModuleInstance { unload() { this._module.onInstanceUnload(this._id); } + + beforeDestroy(): void { + this._channelManager.unregisterAllChannels(); + this._channelManager.unregisterAllReceivers(); + this._context = null; + this._settingsToViewInterface = null; + this._viewToSettingsInterface = null; + this._settingsToViewInterfaceEffectsAtom = null; + this._viewToSettingsInterfaceEffectsAtom = null; + } } export function useModuleInstanceTopicValue( - moduleInstance: ModuleInstance, + moduleInstance: ModuleInstance, topic: T, ): ModuleInstanceTopicValueTypes[T] { const value = React.useSyncExternalStore( diff --git a/frontend/src/framework/ModuleInstanceSerializer.ts b/frontend/src/framework/ModuleInstanceSerializer.ts new file mode 100644 index 000000000..3fd16072c --- /dev/null +++ b/frontend/src/framework/ModuleInstanceSerializer.ts @@ -0,0 +1,259 @@ +import { Ajv } from "ajv/dist/jtd"; +import { atom, type Atom, type Setter } from "jotai"; + +import type { AtomStore } from "./AtomStoreMaster"; +import { hashJsonString, objectToJsonString } from "./internal/WorkbenchSession/utils"; +import { + hasSerialization, + type ModuleComponentsStateBase, + type ModuleStateSchema, + type ModuleComponentSerializationFunctions, +} from "./Module"; +import type { ModuleInstance, PartialSerializedModuleState } from "./ModuleInstance"; +import { isPersistableAtom, Source } from "./utils/atomUtils"; + +type StringifiedSerializedModuleComponentsState = { + settings?: string; + view?: string; +}; + +const ajv = new Ajv(); + +export class ModuleInstanceSerializer { + private _moduleInstance: ModuleInstance; + private _atomStore: AtomStore; + private _serializedStateSchema: ModuleStateSchema | null; + private _serializedState: TSerializedState | null = null; + private _serializationFunctions: ModuleComponentSerializationFunctions; + private _persistenceAtom: Atom; + private _lastSerializedHash: string | null = null; + private _debouncedNotifyChange: () => void; + + constructor( + moduleInstance: ModuleInstance, + atomStore: AtomStore, + serializedStateSchema: ModuleStateSchema | null, + serializationFunctions: ModuleComponentSerializationFunctions, + onStateChange: () => void, + ) { + this._moduleInstance = moduleInstance; + this._atomStore = atomStore; + this._serializedStateSchema = serializedStateSchema; + this._serializationFunctions = serializationFunctions; + this._debouncedNotifyChange = debounce(() => { + onStateChange?.(); + }, 200); + + this._persistenceAtom = atom((get) => { + if (hasSerialization(this._serializationFunctions)) { + const result = { + settings: this._serializationFunctions.serializeStateFunctions.settings?.(get), + view: this._serializationFunctions.serializeStateFunctions.view?.(get), + } as TSerializedState; + return (result satisfies TSerializedState) ? result : undefined; + } + return undefined; // No serialization functions provided + }); + + this._atomStore + .sub(this._persistenceAtom, () => { + this.serializeState(); + }) + .bind(this); + } + + getSerializedState(): TSerializedState | null { + return this._serializedState; + } + + getStringifiedSerializedState(): StringifiedSerializedModuleComponentsState | null { + if (!this._serializedState) { + return null; // No serialized state available + } + + const stringifiedSettings = this._serializedState.settings + ? JSON.stringify(this._serializedState.settings) + : undefined; + + const stringifiedView = this._serializedState.view ? JSON.stringify(this._serializedState.view) : undefined; + + return { + settings: stringifiedSettings, + view: stringifiedView, + }; + } + + async serializeState() { + if (!hasSerialization(this._serializationFunctions) || !this._serializedStateSchema) { + return this._serializedState || {}; + } + + const serializedSettings = this._serializationFunctions.serializeStateFunctions.settings?.( + this._atomStore.get.bind(this._atomStore), + ); + + const serializedView = this._serializationFunctions.serializeStateFunctions.view?.( + this._atomStore.get.bind(this._atomStore), + ); + + if (serializedSettings === undefined && serializedView === undefined && this._serializedState === null) { + return {}; // No state to serialize + } + + // Validate against schema + if (this._serializedStateSchema.settings) { + const validateSettings = ajv.compile(this._serializedStateSchema.settings); + const isSettingsValid = serializedSettings === undefined || validateSettings(serializedSettings); + if (!isSettingsValid) { + console.warn(`Validation failed for ${this._moduleInstance.getName()}`, { + settingsErrors: validateSettings.errors, + }); + this._serializedState = null; + return; // Invalid state, do not serialize + } + } + + if (this._serializedStateSchema.view) { + const validateView = ajv.compile(this._serializedStateSchema.view); + const isViewValid = serializedView === undefined || validateView(serializedView); + + if (!isViewValid) { + console.warn(`Validation failed for ${this._moduleInstance.getName()}`, { + viewErrors: validateView.errors, + }); + this._serializedState = null; + return; // Invalid state, do not serialize + } + } + + const newSerializedState = { + settings: serializedSettings, + view: serializedView, + } as TSerializedState; + + const newHash = await hashJsonString(objectToJsonString(newSerializedState)); + + if (newHash !== this._lastSerializedHash) { + this._serializedState = newSerializedState; + this._lastSerializedHash = newHash; + this._debouncedNotifyChange?.(); + } + + this._serializedState = { + settings: serializedSettings, + view: serializedView, + } as TSerializedState; + } + + deserializeState(raw: StringifiedSerializedModuleComponentsState): void { + if (!this._serializedStateSchema) { + console.warn(`No serialized state schema defined for module instance ${this._moduleInstance.getName()}`); + return; // No schema defined, cannot deserialize + } + + if (!hasSerialization(this._serializationFunctions)) { + console.warn(`No serialization functions defined for module instance ${this._moduleInstance.getName()}`); + this._serializedState = null; + return; // No serialization functions, cannot deserialize + } + + let parsedSettings: unknown; + let parsedView: unknown; + try { + parsedSettings = raw.settings ? JSON.parse(raw.settings) : undefined; + parsedView = raw.view ? JSON.parse(raw.view) : undefined; + } catch (e) { + console.warn(`Invalid JSON in module state for instance ${this._moduleInstance.getName()}:`, e); + this._serializedState = null; + return; + } + + if (this._serializedStateSchema.settings) { + // If possible, compilation should only be performed once - as soon as the schema is available - move to constructor? + const validateSettings = ajv.compile(this._serializedStateSchema.settings); + const isSettingsValid = parsedSettings === undefined || validateSettings(parsedSettings); + if (!isSettingsValid) { + console.warn(`Validation failed for settings in ${this._moduleInstance.getName()}`, { + settingsErrors: validateSettings.errors, + }); + this._serializedState = null; + return; // Invalid settings, do not apply state + } + } + + if (this._serializedStateSchema.view) { + // If possible, compilation should only be performed once - as soon as the schema is available - move to constructor? + const validateView = ajv.compile(this._serializedStateSchema.view); + const isViewValid = parsedView === undefined || validateView(parsedView); + + if (!isViewValid) { + console.warn(`Validation failed for view in ${this._moduleInstance.getName()}`, { + viewErrors: validateView.errors, + }); + this._serializedState = null; + return; + } + } + + this._serializedState = { + settings: parsedSettings as TSerializedState["settings"], + view: parsedView as TSerializedState["view"], + } as TSerializedState; + + this.applyStateToAtoms(this._serializedState); + } + + applyTemplateState(templateState: PartialSerializedModuleState): void { + this.applyStateToAtoms( + { + settings: templateState.settings, + view: templateState.view, + }, + true, + ); + } + + private applyStateToAtoms( + state: PartialSerializedModuleState, + fromTemplate: boolean = false, + ): void { + if (!hasSerialization(this._serializationFunctions)) { + console.warn(`No serialization functions defined for module instance ${this._moduleInstance.getName()}`); + return; // No serialization functions, cannot apply state + } + + const atomStore = this._atomStore; + + const persistedSetter: Setter = (atom, ...args) => { + const [value] = args; + const isPersistable = isPersistableAtom(atom); + + let finalValue = value; + if (isPersistable) { + if (fromTemplate) { + finalValue = { value, _source: Source.TEMPLATE }; + } else { + finalValue = { value, _source: Source.PERSISTENCE }; + } + } + + return atomStore.set(atom as any, finalValue); + }; + + if (state.settings) { + this._serializationFunctions.deserializeStateFunctions.settings?.(state.settings, persistedSetter); + } + + if (state.view) { + this._serializationFunctions.deserializeStateFunctions.view?.(state.view, persistedSetter); + } + } +} + +function debounce(fn: () => void, delay: number) { + let timeout: ReturnType | null = null; + return () => { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(fn, delay); + }; +} diff --git a/frontend/src/framework/ModuleRegistry.ts b/frontend/src/framework/ModuleRegistry.ts index f32a717d4..e44ec8b10 100644 --- a/frontend/src/framework/ModuleRegistry.ts +++ b/frontend/src/framework/ModuleRegistry.ts @@ -2,10 +2,14 @@ import type { ChannelDefinition, ChannelReceiverDefinition } from "./DataChannel import { ModuleNotFoundPlaceholder } from "./internal/ModuleNotFoundPlaceholder"; import type { InterfaceEffects, + ModuleComponentsStateBase, ModuleCategory, ModuleDevState, ModuleInterfaceTypes, + NoModuleStateSchema, OnInstanceUnloadFunc, + ModuleStateSchema, + ModuleComponentSerializationFunctions, } from "./Module"; import { Module } from "./Module"; import type { ModuleDataTagId } from "./ModuleDataTags"; @@ -13,7 +17,7 @@ import type { DrawPreviewFunc } from "./Preview"; import type { SyncSettingKey } from "./SyncSettings"; import type { InterfaceInitialization } from "./UniDirectionalModuleComponentsInterface"; -export type RegisterModuleOptions = { +export type RegisterModuleOptions = { moduleName: string; category: ModuleCategory; devState: ModuleDevState; @@ -25,7 +29,25 @@ export type RegisterModuleOptions = { preview?: DrawPreviewFunc; description?: string; onInstanceUnload?: OnInstanceUnloadFunc; -}; +} & (TSerializedStateDef extends NoModuleStateSchema + ? { serializedStateSchema?: never } + : { + serializedStateSchema: ModuleStateSchema; + }); + +export type InitModuleOptions< + TInterfaceTypes extends ModuleInterfaceTypes, + TSerializedStateDef extends ModuleComponentsStateBase = NoModuleStateSchema, +> = { + settingsToViewInterfaceInitialization?: TInterfaceTypes["settingsToView"] extends undefined + ? undefined + : InterfaceInitialization>; + viewToSettingsInterfaceInitialization?: TInterfaceTypes["viewToSettings"] extends undefined + ? undefined + : InterfaceInitialization>; + viewToSettingsInterfaceEffects?: InterfaceEffects>; + settingsToViewInterfaceEffects?: InterfaceEffects>; +} & ModuleComponentSerializationFunctions; export class ModuleNotFoundError extends Error { readonly moduleName: string; @@ -38,15 +60,20 @@ export class ModuleNotFoundError extends Error { } export class ModuleRegistry { - private static _registeredModules: Record> = {}; - private static _moduleNotFoundPlaceholders: Record> = {}; + private static _registeredModules: Record> = {}; + private static _moduleNotFoundPlaceholders: Record> = {}; private constructor() {} - static registerModule( - options: RegisterModuleOptions, - ): Module { - const module = new Module({ + static registerModule< + TInterfaceTypes extends ModuleInterfaceTypes, + TSerializedStateDef extends ModuleComponentsStateBase = NoModuleStateSchema, + >(options: RegisterModuleOptions): Module { + if (this._registeredModules[options.moduleName]) { + throw new Error(`Module with name '${options.moduleName}' is already registered.`); + } + + const module = new Module({ name: options.moduleName, defaultTitle: options.defaultTitle, category: options.category, @@ -58,24 +85,21 @@ export class ModuleRegistry { drawPreviewFunc: options.preview, onInstanceUnloadFunc: options.onInstanceUnload, description: options.description, + serializedStateSchema: options.serializedStateSchema as unknown as + | ModuleStateSchema + | undefined, }); this._registeredModules[options.moduleName] = module; return module; } - static initModule( + static initModule< + TInterfaceTypes extends ModuleInterfaceTypes, + TSerializedStateDef extends ModuleComponentsStateBase = NoModuleStateSchema, + >( moduleName: string, - options: { - settingsToViewInterfaceInitialization?: TInterfaceTypes["settingsToView"] extends undefined - ? undefined - : InterfaceInitialization>; - viewToSettingsInterfaceInitialization?: TInterfaceTypes["viewToSettings"] extends undefined - ? undefined - : InterfaceInitialization>; - viewToSettingsInterfaceEffects?: InterfaceEffects>; - settingsToViewInterfaceEffects?: InterfaceEffects>; - }, - ): Module { + options: InitModuleOptions, + ): Module { const module = this._registeredModules[moduleName]; if (module) { if (options.settingsToViewInterfaceInitialization) { @@ -90,25 +114,31 @@ export class ModuleRegistry { if (options.settingsToViewInterfaceEffects) { module.setSettingsToViewInterfaceEffects(options.settingsToViewInterfaceEffects); } - return module as Module; + if (options.serializeStateFunctions && options.deserializeStateFunctions) { + module.setSerializationFunctions({ + serializeStateFunctions: options.serializeStateFunctions, + deserializeStateFunctions: options.deserializeStateFunctions, + }); + } + return module as Module; } throw new ModuleNotFoundError(moduleName); } - static getModule(moduleName: string): Module { + static getModule(moduleName: string): Module { const module = this._registeredModules[moduleName]; if (module) { - return module as Module; + return module as Module; } const placeholder = this._moduleNotFoundPlaceholders[moduleName]; if (placeholder) { - return placeholder as Module; + return placeholder as Module; } this._moduleNotFoundPlaceholders[moduleName] = new ModuleNotFoundPlaceholder(moduleName); - return this._moduleNotFoundPlaceholders[moduleName] as Module; + return this._moduleNotFoundPlaceholders[moduleName] as Module; } - static getRegisteredModules(): Record> { + static getRegisteredModules(): Record> { return this._registeredModules; } } diff --git a/frontend/src/framework/RegularEnsemble.ts b/frontend/src/framework/RegularEnsemble.ts index 6ac22a3bd..006edea00 100644 --- a/frontend/src/framework/RegularEnsemble.ts +++ b/frontend/src/framework/RegularEnsemble.ts @@ -1,5 +1,3 @@ -import type { EnsembleTimestamps_api } from "@api"; - import type { Parameter } from "./EnsembleParameters"; import { EnsembleParameters } from "./EnsembleParameters"; import type { Sensitivity } from "./EnsembleSensitivities"; @@ -16,7 +14,6 @@ export class RegularEnsemble { private _sensitivities: EnsembleSensitivities | null; private _color: string; private _customName: string | null; - private _timestamps: EnsembleTimestamps_api | null; constructor( fieldIdentifier: string, @@ -29,7 +26,6 @@ export class RegularEnsemble { sensitivityArray: Sensitivity[] | null, color: string, customName: string | null = null, - timestamps: EnsembleTimestamps_api | null = null, ) { this._ensembleIdent = new RegularEnsembleIdent(caseUuid, ensembleName); this._fieldIdentifier = fieldIdentifier; @@ -39,7 +35,6 @@ export class RegularEnsemble { this._parameters = new EnsembleParameters(parameterArray); this._color = color; this._customName = customName; - this._timestamps = timestamps; this._sensitivities = null; if (sensitivityArray && sensitivityArray.length > 0) { @@ -109,8 +104,4 @@ export class RegularEnsemble { getCustomName(): string | null { return this._customName; } - - getTimestamps(): EnsembleTimestamps_api | null { - return this._timestamps; - } } diff --git a/frontend/src/framework/StatusWriter.ts b/frontend/src/framework/StatusWriter.ts index 25697d087..573e0f5c4 100644 --- a/frontend/src/framework/StatusWriter.ts +++ b/frontend/src/framework/StatusWriter.ts @@ -60,7 +60,7 @@ export class SettingsStatusWriter { } } -export function useViewStatusWriter(viewContext: ViewContext): ViewStatusWriter { +export function useViewStatusWriter(viewContext: ViewContext): ViewStatusWriter { const statusController = viewContext.getStatusController(); const statusWriter = React.useRef(new ViewStatusWriter(statusController)); @@ -75,7 +75,7 @@ export function useViewStatusWriter(viewContext: ViewContext): ViewStatusWr return statusWriter.current; } -export function useSettingsStatusWriter(settingsContext: SettingsContext): SettingsStatusWriter { +export function useSettingsStatusWriter(settingsContext: SettingsContext): SettingsStatusWriter { const statusController = settingsContext.getStatusController(); const statusWriter = React.useRef(new SettingsStatusWriter(statusController)); diff --git a/frontend/src/framework/SyncSettings.ts b/frontend/src/framework/SyncSettings.ts index 319fbe42c..0c2f2a126 100644 --- a/frontend/src/framework/SyncSettings.ts +++ b/frontend/src/framework/SyncSettings.ts @@ -50,13 +50,13 @@ export const SyncSettingsMeta = { export class SyncSettingsHelper { private _workbenchServices: WorkbenchServices; - private _moduleContext: SettingsContext | ViewContext | null; + private _moduleContext: SettingsContext | ViewContext | null; private _activeSyncedKeys: SyncSettingKey[]; constructor( activeSyncedKeys: SyncSettingKey[], workbenchServices: WorkbenchServices, - moduleContext?: SettingsContext | ViewContext, + moduleContext?: SettingsContext | ViewContext, ) { this._activeSyncedKeys = activeSyncedKeys; this._workbenchServices = workbenchServices; diff --git a/frontend/src/framework/TemplateRegistry.ts b/frontend/src/framework/TemplateRegistry.ts index 1ccaec6fb..83fb40023 100644 --- a/frontend/src/framework/TemplateRegistry.ts +++ b/frontend/src/framework/TemplateRegistry.ts @@ -1,6 +1,8 @@ +import type { ModuleSerializedStateMap } from "@modules/ModuleSerializedStateMap"; + import type { KeyKind } from "./DataChannelTypes"; +import type { LayoutElement } from "./internal/WorkbenchSession/Dashboard"; import type { SyncSettingKey } from "./SyncSettings"; -import type { LayoutElement } from "./Workbench"; export type DataChannelTemplate = { listensToInstanceRef: string; @@ -10,36 +12,55 @@ export type DataChannelTemplate = { export type TemplateLayoutElement = Omit; +export type TemplateModuleInstance = { + instanceRef?: string; + moduleName: M; + layout: TemplateLayoutElement; + syncedSettings?: SyncSettingKey[]; + dataChannelsToInitialSettingsMapping?: Record; + initialState?: { + settings?: ModuleSerializedStateMap[M]["settings"]; + view?: ModuleSerializedStateMap[M]["view"]; + }; +}; + export type Template = { + name: string; description: string; - moduleInstances: { - instanceRef?: string; - moduleName: string; - layout: TemplateLayoutElement; - syncedSettings?: SyncSettingKey[]; - dataChannelsToInitialSettingsMapping?: Record; - initialSettings?: Record; - }[]; + moduleInstances: TemplateModuleInstance[]; }; +export function createTemplateModuleInstance( + moduleName: M, + options: Omit, "moduleName">, +): TemplateModuleInstance { + return { + moduleName, + ...options, + }; +} + export class TemplateRegistry { - private static _registeredTemplates: Record = {}; + private static _registeredTemplates: Template[] = []; private constructor() {} - static registerTemplate(name: string, template: Template): void { - this._registeredTemplates[name] = template; + static registerTemplate(template: Template): void { + if (this._registeredTemplates.find((t) => t.name === template.name)) { + throw new Error(`Template with name ${template.name} already registered.`); + } + this._registeredTemplates.push(template); } - static getRegisteredTemplates(): Record { + static getRegisteredTemplates(): Template[] { return this._registeredTemplates; } - static getTemplate(name: string): Template | undefined { - const template = this._registeredTemplates[name]; - if (template) { - return template; + static getTemplate(name: string): Template { + const template = this._registeredTemplates.find((t) => t.name === name); + if (!template) { + throw new Error(`Template with name ${name} not registered.`); } - throw new Error(`Template with name ${name} not registered.`); + return template; } } diff --git a/frontend/src/framework/UserCreatedItems.ts b/frontend/src/framework/UserCreatedItems.ts index 03f7e837c..78fa124b7 100644 --- a/frontend/src/framework/UserCreatedItems.ts +++ b/frontend/src/framework/UserCreatedItems.ts @@ -1,18 +1,29 @@ import React from "react"; import type { AtomStoreMaster } from "./AtomStoreMaster"; -import { IntersectionPolylines, IntersectionPolylinesEvent } from "./userCreatedItems/IntersectionPolylines"; +import { + INTERSECTION_POLYLINES_JTD_SCHEMA, + IntersectionPolylines, + IntersectionPolylinesEvent, + type SerializedIntersectionPolylines, +} from "./userCreatedItems/IntersectionPolylines"; import type { WorkbenchSession } from "./WorkbenchSession"; +import type { JTDSchemaType } from "ajv/dist/core"; -export interface UserCreatedItemSet { - serialize(): string; - populateFromData(data: string): void; -} +export type SerializedUserCreatedItems = { + intersectionPolylines: SerializedIntersectionPolylines; +}; export enum UserCreatedItemsEvent { INTERSECTION_POLYLINES_CHANGE = "IntersectionPolylinesChange", } +export const USER_CREATED_ITEMS_JTD_SCHEMA: JTDSchemaType = { + properties: { + intersectionPolylines: INTERSECTION_POLYLINES_JTD_SCHEMA, + }, +} as const; + export class UserCreatedItems { private _intersectionPolylines: IntersectionPolylines; private _subscribersMap: Map void>> = new Map(); @@ -24,6 +35,16 @@ export class UserCreatedItems { }); } + serializeState(): SerializedUserCreatedItems { + return { + intersectionPolylines: this._intersectionPolylines.serializeState(), + }; + } + + deserializeState(serializedState: SerializedUserCreatedItems): void { + this._intersectionPolylines.deserializeState(serializedState.intersectionPolylines); + } + subscribe(event: UserCreatedItemsEvent.INTERSECTION_POLYLINES_CHANGE, cb: () => void): () => void { const subscribersSet = this._subscribersMap.get(event) || new Set(); subscribersSet.add(cb); @@ -47,7 +68,7 @@ export class UserCreatedItems { } isEqual(other: UserCreatedItems): boolean { - return this._intersectionPolylines.serialize() === other._intersectionPolylines.serialize(); + return this._intersectionPolylines.serializeState() === other._intersectionPolylines.serializeState(); } } diff --git a/frontend/src/framework/Workbench.ts b/frontend/src/framework/Workbench.ts index 52ebfd7a0..92335717c 100644 --- a/frontend/src/framework/Workbench.ts +++ b/frontend/src/framework/Workbench.ts @@ -1,517 +1,593 @@ import type { QueryClient } from "@tanstack/react-query"; -import { postGetTimestampsForEnsemblesOptions, type EnsembleIdent_api, type EnsembleTimestamps_api } from "@api"; +import { getRecentSnapshotsQueryKey } from "@api"; +import { PublishSubscribeDelegate, type PublishSubscribe } from "@lib/utils/PublishSubscribeDelegate"; -import { AtomStoreMaster } from "./AtomStoreMaster"; -import { GuiMessageBroker, GuiState } from "./GuiMessageBroker"; -import { InitialSettings } from "./InitialSettings"; -import { loadMetadataFromBackendAndCreateEnsembleSet } from "./internal/EnsembleSetLoader"; +import { confirmationService } from "./ConfirmationService"; +import { GuiMessageBroker, GuiState, LeftDrawerContent, RightDrawerContent } from "./GuiMessageBroker"; +import { NavigationObserver } from "./internal/NavigationObserver"; import { PrivateWorkbenchServices } from "./internal/PrivateWorkbenchServices"; -import { PrivateWorkbenchSettings } from "./internal/PrivateWorkbenchSettings"; -import { WorkbenchSessionPrivate } from "./internal/WorkbenchSessionPrivate"; -import { ImportState } from "./Module"; -import type { ModuleInstance } from "./ModuleInstance"; -import { ModuleRegistry } from "./ModuleRegistry"; -import type { RegularEnsemble } from "./RegularEnsemble"; -import { RegularEnsembleIdent } from "./RegularEnsembleIdent"; -import type { Template } from "./TemplateRegistry"; -import { isEnsembleOutdated } from "./utils/ensembleTimestampUtils"; +import { EnsembleUpdateMonitor } from "./internal/WorkbenchSession/EnsembleUpdateMonitor"; +import { PrivateWorkbenchSession } from "./internal/WorkbenchSession/PrivateWorkbenchSession"; +import { + buildSessionUrl, + readSessionIdFromUrl, + removeSessionIdFromUrl, +} from "./internal/WorkbenchSession/SessionUrlService"; +import { loadSnapshotFromBackend } from "./internal/WorkbenchSession/SnapshotLoader"; +import { readSnapshotIdFromUrl, removeSnapshotIdFromUrl } from "./internal/WorkbenchSession/SnapshotUrlService"; +import { localStorageKeyForSessionId } from "./internal/WorkbenchSession/utils"; +import { + loadAllWorkbenchSessionsFromLocalStorage, + loadWorkbenchSessionFromBackend, + loadWorkbenchSessionFromLocalStorage, +} from "./internal/WorkbenchSession/WorkbenchSessionLoader"; +import { WorkbenchSessionPersistenceService } from "./internal/WorkbenchSession/WorkbenchSessionPersistenceService"; import type { WorkbenchServices } from "./WorkbenchServices"; +import type { Template } from "./TemplateRegistry"; +import { Dashboard } from "./internal/WorkbenchSession/Dashboard"; -export enum WorkbenchEvents { - LayoutChanged = "LayoutChanged", - ModuleInstancesChanged = "ModuleInstancesChanged", +export enum WorkbenchTopic { + HAS_ACTIVE_SESSION = "hasActiveSession", } -export type LayoutElement = { - moduleInstanceId?: string; - moduleName: string; - relX: number; - relY: number; - relHeight: number; - relWidth: number; - minimized?: boolean; - maximized?: boolean; -}; - -export type UserEnsembleSetting = { - ensembleIdent: RegularEnsembleIdent; - customName: string | null; - color: string; - timestamps: EnsembleTimestamps_api | null; -}; - -export type UserDeltaEnsembleSetting = { - comparisonEnsembleIdent: RegularEnsembleIdent; - referenceEnsembleIdent: RegularEnsembleIdent; - customName: string | null; - color: string; -}; - -export type StoredUserEnsembleSetting = { - ensembleIdent: string; - customName: string | null; - color: string; - timestamps: EnsembleTimestamps_api | null; -}; - -export type StoredUserDeltaEnsembleSetting = { - comparisonEnsembleIdent: string; - referenceEnsembleIdent: string; - customName: string | null; - color: string; +export type WorkbenchTopicPayloads = { + [WorkbenchTopic.HAS_ACTIVE_SESSION]: boolean; }; +export class Workbench implements PublishSubscribe { + private _publishSubscribeDelegate = new PublishSubscribeDelegate(); -function ensembleToUserSettings(ensemble: RegularEnsemble): UserEnsembleSetting { - return { - color: ensemble.getColor(), - customName: ensemble.getCustomName(), - ensembleIdent: ensemble.getIdent(), - timestamps: ensemble.getTimestamps(), - }; -} - -const ENSEMBLE_POLLING_INTERVAL = 60000; // 1 minute - -export class Workbench { - private _moduleInstances: ModuleInstance[]; - private _workbenchSession: WorkbenchSessionPrivate; + private _workbenchSession: PrivateWorkbenchSession | null = null; private _workbenchServices: PrivateWorkbenchServices; - private _workbenchSettings: PrivateWorkbenchSettings; private _guiMessageBroker: GuiMessageBroker; - private _subscribersMap: { [key: string]: Set<() => void> }; - private _layout: LayoutElement[]; - private _perModuleRunningInstanceNumber: Record; - private _atomStoreMaster: AtomStoreMaster; - - constructor() { - this._moduleInstances = []; - this._atomStoreMaster = new AtomStoreMaster(); - this._workbenchSession = new WorkbenchSessionPrivate(this._atomStoreMaster); + private _queryClient: QueryClient; + private _workbenchSessionPersistenceService: WorkbenchSessionPersistenceService; + private _navigationObserver: NavigationObserver; + private _ensembleUpdateMonitor: EnsembleUpdateMonitor; + private _isInitialized: boolean = false; + + constructor(queryClient: QueryClient) { + this._queryClient = queryClient; this._workbenchServices = new PrivateWorkbenchServices(this); - this._workbenchSettings = new PrivateWorkbenchSettings(); + this._workbenchSessionPersistenceService = new WorkbenchSessionPersistenceService(this); this._guiMessageBroker = new GuiMessageBroker(); - this._subscribersMap = {}; - this._layout = []; - this._perModuleRunningInstanceNumber = {}; - } - - loadLayoutFromLocalStorage(): boolean { - const layoutString = localStorage.getItem("layout"); - if (!layoutString) return false; - - const layout = JSON.parse(layoutString) as LayoutElement[]; - this.makeLayout(layout); - return true; - } - - getLayout(): LayoutElement[] { - return this._layout; - } - - getAtomStoreMaster(): AtomStoreMaster { - return this._atomStoreMaster; - } - - getWorkbenchSession(): WorkbenchSessionPrivate { - return this._workbenchSession; - } - - getWorkbenchServices(): WorkbenchServices { - return this._workbenchServices; + this._navigationObserver = new NavigationObserver({ + onBeforeUnload: () => this.isWorkbenchDirty(), + onNavigate: async () => this.handleNavigation(), + }); + this._ensembleUpdateMonitor = new EnsembleUpdateMonitor(queryClient, this); } - getWorkbenchSettings(): PrivateWorkbenchSettings { - return this._workbenchSettings; + getPublishSubscribeDelegate(): PublishSubscribeDelegate { + return this._publishSubscribeDelegate; } - getGuiMessageBroker(): GuiMessageBroker { - return this._guiMessageBroker; + makeSnapshotGetter(topic: T): () => WorkbenchTopicPayloads[T] { + const snapshotGetter = (): any => { + if (topic === WorkbenchTopic.HAS_ACTIVE_SESSION) { + return this._workbenchSession !== null; + } + throw new Error(`No snapshot getter implemented for topic ${topic}`); + }; + return snapshotGetter; } - private notifySubscribers(event: WorkbenchEvents): void { - const subscribers = this._subscribersMap[event]; - if (!subscribers) return; - - subscribers.forEach((subscriber) => { - subscriber(); - }); + getQueryClient(): QueryClient { + return this._queryClient; } - subscribe(event: WorkbenchEvents, cb: () => void) { - const subscribersSet = this._subscribersMap[event] || new Set(); - subscribersSet.add(cb); - this._subscribersMap[event] = subscribersSet; - return () => { - subscribersSet.delete(cb); - }; + getWorkbenchSessionPersistenceService(): WorkbenchSessionPersistenceService { + return this._workbenchSessionPersistenceService; } - getModuleInstances(): ModuleInstance[] { - return this._moduleInstances; - } + private isWorkbenchDirty(): boolean { + if (!this._workbenchSession) { + return false; // No active session, so nothing to save. + } - getModuleInstance(id: string): ModuleInstance | undefined { - return this._moduleInstances.find((moduleInstance) => moduleInstance.getId() === id); + return ( + (this._workbenchSessionPersistenceService.hasChanges() || !this._workbenchSession.getIsPersisted()) && + !this._workbenchSession.isSnapshot() + ); } - private getNextModuleInstanceNumber(moduleName: string): number { - if (moduleName in this._perModuleRunningInstanceNumber) { - this._perModuleRunningInstanceNumber[moduleName] += 1; - } else { - this._perModuleRunningInstanceNumber[moduleName] = 1; + async handleNavigation(): Promise { + // When the user navigates with forward/backward buttons, they might want to load a snapshot. In this case, + // we first have to check if a snapshot id is present in the URL. + // If it is, we have to check for unsaved changes and then load the snapshot. + const snapshotId = readSnapshotIdFromUrl(); + const sessionId = readSessionIdFromUrl(); + if (!snapshotId && !sessionId) { + // No snapshot/session id in URL, so we can proceed with the navigation - if a session is active, it will be closed. + if (this._workbenchSession) { + await this.maybeCloseCurrentSession(); + return true; // Proceed with the navigation. + } } - return this._perModuleRunningInstanceNumber[moduleName]; - } - makeLayout(layout: LayoutElement[]): void { - this._moduleInstances = []; - this.setLayout(layout); - layout.forEach((element, index: number) => { - const module = ModuleRegistry.getModule(element.moduleName); - if (!module) { - throw new Error(`Module ${element.moduleName} not found`); + if (this._workbenchSession) { + if (this._workbenchSession.isSnapshot()) { + // If the current session is a snapshot, we can just load the new entity. + if (snapshotId) { + await this.loadSnapshot(snapshotId); + } else if (sessionId) { + if (this._workbenchSession.getId() === sessionId) { + // If the session id is the same as the current session, we do not need to reload it. + return true; // Proceed with the navigation. + } + await this.openSession(sessionId); + } + return true; // Proceed with the navigation. } - module.setWorkbench(this); - const moduleInstance = module.makeInstance(this.getNextModuleInstanceNumber(module.getName())); - this._atomStoreMaster.makeAtomStoreForModuleInstance(moduleInstance.getId()); - this._moduleInstances.push(moduleInstance); - this._layout[index] = { ...this._layout[index], moduleInstanceId: moduleInstance.getId() }; - this.notifySubscribers(WorkbenchEvents.ModuleInstancesChanged); - this.notifySubscribers(WorkbenchEvents.LayoutChanged); - }); - } + // If the current session is not a snapshot, we have to check for unsaved changes. + if (this._workbenchSessionPersistenceService.hasChanges() || !this._workbenchSession.getIsPersisted()) { + // If there are unsaved changes, we show a dialog to the user to confirm if they want to discard the + // current session and load the new one. + const result = await confirmationService.confirm({ + title: "Unsaved Changes", + message: "You have unsaved changes in your current session. Do you want to save or discard them?", + actions: [ + { id: "save", label: "Save Changes", color: "success" }, + { id: "discard", label: "Discard Changes", color: "danger" }, + { id: "cancel", label: "Cancel" }, + ], + }); + + if (result === "cancel") { + // User chose to cancel the navigation, so we do nothing. + return false; + } + if (result === "save") { + // User chose to save the changes, so we save the current session and then load the new snapshot. + await this.saveCurrentSession(true); + if (snapshotId) { + await this.loadSnapshot(snapshotId); + } else if (sessionId) { + await this.openSession(sessionId); + } + return true; // Proceed with the navigation. + } + if (result === "discard") { + // User chose to discard the changes, so we discard the current session and load the new snapshot. + this.unloadCurrentSession(); + if (snapshotId) { + await this.loadSnapshot(snapshotId); + } else if (sessionId) { + await this.openSession(sessionId); + } + return true; // Proceed with the navigation. + } - resetModuleInstanceNumbers(): void { - this._perModuleRunningInstanceNumber = {}; - } + throw new Error(`Unexpected confirmation result: ${result}`); + } + } - clearLayout(): void { - for (const moduleInstance of this._moduleInstances) { - const manager = moduleInstance.getChannelManager(); - manager.unregisterAllChannels(); - manager.unregisterAllReceivers(); + // If there are no unsaved changes or no active session, we can just load the new snapshot or session. + if (snapshotId) { + await this.loadSnapshot(snapshotId); + } else if (sessionId) { + await this.openSession(sessionId); } - this._moduleInstances = []; - this._layout = []; - this.notifySubscribers(WorkbenchEvents.ModuleInstancesChanged); - this.notifySubscribers(WorkbenchEvents.LayoutChanged); + return true; // Proceed with the navigation. } - makeAndAddModuleInstance(moduleName: string, layout: LayoutElement): ModuleInstance { - const module = ModuleRegistry.getModule(moduleName); - if (!module) { - throw new Error(`Module ${moduleName} not found`); + async initialize() { + if (this._isInitialized) { + console.info( + "Workbench is already initialized. This might happen in strict mode due to useEffects being called multiple times.", + ); + return; } - module.setWorkbench(this); + this._isInitialized = true; - const moduleInstance = module.makeInstance(this.getNextModuleInstanceNumber(module.getName())); - this._atomStoreMaster.makeAtomStoreForModuleInstance(moduleInstance.getId()); - this._moduleInstances.push(moduleInstance); + // First, check if a snapshot id is provided in the URL + const snapshotId = readSnapshotIdFromUrl(); + const sessionId = readSessionIdFromUrl(); - this._layout.push({ ...layout, moduleInstanceId: moduleInstance.getId() }); - this.notifySubscribers(WorkbenchEvents.ModuleInstancesChanged); - this.notifySubscribers(WorkbenchEvents.LayoutChanged); - this.getGuiMessageBroker().setState(GuiState.ActiveModuleInstanceId, moduleInstance.getId()); - return moduleInstance; - } - - removeModuleInstance(moduleInstanceId: string): void { - const moduleInstance = this.getModuleInstance(moduleInstanceId); + const storedSessions = await loadAllWorkbenchSessionsFromLocalStorage(); - if (moduleInstance) { - const manager = moduleInstance.getChannelManager(); - - moduleInstance.unload(); - manager.unregisterAllChannels(); - manager.unregisterAllReceivers(); + if (snapshotId) { + this.loadSnapshot(snapshotId); + return; + } else if (sessionId) { + this.openSession(sessionId); + if (storedSessions.find((el) => el.id === sessionId)) { + this._guiMessageBroker.setState(GuiState.ActiveSessionRecoveryDialogOpen, true); + } + return; } - this._moduleInstances = this._moduleInstances.filter((el) => el.getId() !== moduleInstanceId); - - this._atomStoreMaster.removeAtomStoreForModuleInstance(moduleInstanceId); + if (storedSessions.length > 0) { + this._guiMessageBroker.setState(GuiState.MultiSessionsRecoveryDialogOpen, true); + } + } - const newLayout = this._layout.filter((el) => el.moduleInstanceId !== moduleInstanceId); - this.setLayout(newLayout); - const activeModuleInstanceId = this.getGuiMessageBroker().getState(GuiState.ActiveModuleInstanceId); - if (activeModuleInstanceId === moduleInstanceId) { - this.getGuiMessageBroker().setState(GuiState.ActiveModuleInstanceId, ""); + private async loadSnapshot(snapshotId: string): Promise { + try { + const snapshotData = await loadSnapshotFromBackend(this._queryClient, snapshotId); + const snapshot = await PrivateWorkbenchSession.fromDataContainer(this._queryClient, snapshotData); + await this.setWorkbenchSession(snapshot); + if (this.getGuiMessageBroker().getState(GuiState.LeftDrawerContent) !== LeftDrawerContent.ModuleSettings) { + this._guiMessageBroker.setState(GuiState.LeftDrawerContent, LeftDrawerContent.ModuleSettings); + } + if (this.getGuiMessageBroker().getState(GuiState.RightDrawerContent) === RightDrawerContent.ModulesList) { + this._guiMessageBroker.setState( + GuiState.RightDrawerContent, + RightDrawerContent.RealizationFilterSettings, + ); + this._guiMessageBroker.setState(GuiState.RightSettingsPanelWidthInPercent, 0); + } + return; + } catch (error) { + this._guiMessageBroker.setState(GuiState.IsLoadingSession, false); + console.error("Failed to load session from backend:", error); + const result = await confirmationService.confirm({ + title: "Could not load snapshot", + message: `Could not load snapshot with ID ${snapshotId}. The snapshot might not exist or you might not have access to it.`, + actions: [ + { + id: "cancel", + label: "Cancel", + }, + { + id: "retry", + label: "Retry", + }, + ], + }); + if (result === "retry") { + this._guiMessageBroker.setState(GuiState.IsLoadingSession, true); + // Retry loading the snapshot + await this.loadSnapshot(snapshotId); + } } - this.notifySubscribers(WorkbenchEvents.ModuleInstancesChanged); } - setLayout(layout: LayoutElement[]): void { - this._layout = layout; + makeSessionFromSnapshot(): void { + if (!this._workbenchSession) { + throw new Error("No active workbench session."); + } - const modifiedLayout = layout.map((el) => { - return { ...el, moduleInstanceId: undefined }; + this._workbenchSessionPersistenceService.removeWorkbenchSession(); + this._workbenchSession.setMetadata({ + title: "New Session from Snapshot", + description: undefined, + createdAt: Date.now(), + updatedAt: Date.now(), + lastModifiedMs: Date.now(), }); - localStorage.setItem("layout", JSON.stringify(modifiedLayout)); - this.notifySubscribers(WorkbenchEvents.LayoutChanged); + this._workbenchSession.setIsSnapshot(false); + this._workbenchSessionPersistenceService.setWorkbenchSession(this._workbenchSession); + removeSnapshotIdFromUrl(); } - maybeMakeFirstModuleInstanceActive(): void { - const activeModuleInstanceId = this.getGuiMessageBroker().getState(GuiState.ActiveModuleInstanceId); - if (!this._moduleInstances.some((el) => el.getId() === activeModuleInstanceId)) { - const newActiveModuleInstanceId = - this._moduleInstances - .filter((el) => el.getImportState() === ImportState.Imported) - .at(0) - ?.getId() || ""; - this.getGuiMessageBroker().setState(GuiState.ActiveModuleInstanceId, newActiveModuleInstanceId); + discardLocalStorageSession(snapshotId: string | null, unloadSession = true): void { + const key = localStorageKeyForSessionId(snapshotId); + localStorage.removeItem(key); + + if (!unloadSession) { + return; } + + this._guiMessageBroker.setState(GuiState.SessionHasUnsavedChanges, false); + this._guiMessageBroker.setState(GuiState.SaveSessionDialogOpen, false); + this._workbenchSessionPersistenceService.removeWorkbenchSession(); + this._workbenchSession = null; + this._publishSubscribeDelegate.notifySubscribers(WorkbenchTopic.HAS_ACTIVE_SESSION); } - async initWorkbenchFromLocalStorage(queryClient: QueryClient): Promise { - const storedUserEnsembleSettings = this.maybeLoadEnsembleSettingsFromLocalStorage(); - const storedUserDeltaEnsembleSettings = this.maybeLoadDeltaEnsembleSettingsFromLocalStorage(); + async openSessionFromLocalStorage(snapshotId: string | null, forceOpen = false): Promise { + if (this._workbenchSession && !forceOpen) { + console.warn("A workbench session is already active. Please close it before opening a new one."); + return; + } - if (!storedUserEnsembleSettings && !storedUserDeltaEnsembleSettings) { + const sessionData = await loadWorkbenchSessionFromLocalStorage(snapshotId); + if (!sessionData) { + console.warn("No workbench session found in local storage."); return; } - await this.loadAndSetupEnsembleSetInSession( - queryClient, - storedUserEnsembleSettings ?? [], - storedUserDeltaEnsembleSettings ?? [], - ); + const session = await PrivateWorkbenchSession.fromDataContainer(this._queryClient, sessionData); - this.beginEnsembleUpdatePolling(queryClient); + await this.setWorkbenchSession(session); + this._guiMessageBroker.setState(GuiState.MultiSessionsRecoveryDialogOpen, false); + this._guiMessageBroker.setState(GuiState.ActiveSessionRecoveryDialogOpen, false); } - async storeSettingsInLocalStorageAndLoadAndSetupEnsembleSetInSession( - queryClient: QueryClient, - userEnsembleSettings: UserEnsembleSetting[], - userDeltaEnsembleSettings: UserDeltaEnsembleSetting[], - ): Promise { - this.storeEnsembleSetInLocalStorage(userEnsembleSettings); - this.storeDeltaEnsembleSetInLocalStorage(userDeltaEnsembleSettings); + async openSession(sessionId: string): Promise { + if (this._workbenchSession) { + console.warn("A workbench session is already active. Please close it before opening a new one."); + return; + } - await this.loadAndSetupEnsembleSetInSession(queryClient, userEnsembleSettings, userDeltaEnsembleSettings); + this._guiMessageBroker.setState(GuiState.IsLoadingSession, true); + + const url = buildSessionUrl(sessionId); + window.history.pushState({}, "", url); + + try { + const sessionData = await loadWorkbenchSessionFromBackend(this._queryClient, sessionId); + const session = await PrivateWorkbenchSession.fromDataContainer(this._queryClient, sessionData); + await this.setWorkbenchSession(session); + } catch (error) { + console.error("Failed to load session from backend:", error); + this._guiMessageBroker.setState(GuiState.IsLoadingSession, false); + const result = await confirmationService.confirm({ + title: "Could not load session", + message: `Could not load session with ID ${sessionId}. The session might not exist or you might not have access to it.`, + actions: [ + { + id: "cancel", + label: "Cancel", + }, + { + id: "retry", + label: "Retry", + }, + ], + }); + if (result === "retry") { + // Retry loading the session + await this.openSession(sessionId); + } + } finally { + this._guiMessageBroker.setState(GuiState.IsLoadingSession, false); + } } - private async loadAndSetupEnsembleSetInSession( - queryClient: QueryClient, - userEnsembleSettings: UserEnsembleSetting[], - userDeltaEnsembleSettings: UserDeltaEnsembleSetting[], - ): Promise { - console.debug("loadAndSetupEnsembleSetInSession - starting load"); - this._workbenchSession.setEnsembleSetLoadingState(true); - const newEnsembleSet = await loadMetadataFromBackendAndCreateEnsembleSet( - queryClient, - userEnsembleSettings, - userDeltaEnsembleSettings, - ); - console.debug("loadAndSetupEnsembleSetInSession - loading done"); - console.debug("loadAndSetupEnsembleSetInSession - publishing"); - this._workbenchSession.setEnsembleSetLoadingState(false); - this._workbenchSession.setEnsembleSet(newEnsembleSet); - } + async makeSnapshot(title: string, description: string): Promise { + if (!this._workbenchSession) { + throw new Error("No active workbench session to make a snapshot."); + } - private _pollingEnabled = false; - private _waitingPollingRun?: NodeJS.Timeout; + this._guiMessageBroker.setState(GuiState.IsMakingSnapshot, true); - /** - * Starts a background polling routine, that repeats at a set interval. - * @param queryClient The QueryClient to use for fetching ensemble timestamps. - */ - beginEnsembleUpdatePolling(queryClient: QueryClient) { - if (this._pollingEnabled) return; + const snapshotId = await this._workbenchSessionPersistenceService.makeSnapshot(title, description); + this._guiMessageBroker.setState(GuiState.IsMakingSnapshot, false); - // This shouldn't happen, but we check just in case - if (this._waitingPollingRun) { - console.warn("Found a waiting polling call, even though polling was disabled"); - clearTimeout(this._waitingPollingRun); - } + // Reset this, so it'll fetch fresh copies + this._queryClient.resetQueries({ queryKey: getRecentSnapshotsQueryKey() }); - // Start polling - console.debug("checkForEnsembleUpdate - initializing..."); - this._pollingEnabled = true; - this.recursivelyQueueEnsemblePolling(queryClient); + return snapshotId; } - stopEnsembleUpdatePolling() { - clearTimeout(this._waitingPollingRun); - this._waitingPollingRun = undefined; - this._pollingEnabled = false; + async saveCurrentSession(forceSave = false): Promise { + if (!this._workbenchSession) { + throw new Error("No active workbench session to save."); + } + + if (this._workbenchSession.getIsPersisted() || forceSave) { + this._guiMessageBroker.setState(GuiState.IsSavingSession, true); + const wasPersisted = this._workbenchSession.getIsPersisted(); + const result = await this._workbenchSessionPersistenceService.persistSessionState(); + if (!result) { + this._guiMessageBroker.setState(GuiState.IsSavingSession, false); + this._guiMessageBroker.setState(GuiState.SaveSessionDialogOpen, true); + return false; + } + const id = this._workbenchSession.getId(); + if (!wasPersisted && id) { + const url = buildSessionUrl(id); + window.history.pushState({}, "", url); + } + this._guiMessageBroker.setState(GuiState.IsSavingSession, false); + this._guiMessageBroker.setState(GuiState.SaveSessionDialogOpen, false); + this._guiMessageBroker.setState(GuiState.SessionHasUnsavedChanges, false); + return true; + } + + this._guiMessageBroker.setState(GuiState.SessionHasUnsavedChanges, false); + this._guiMessageBroker.setState(GuiState.SaveSessionDialogOpen, true); + return false; } - private async recursivelyQueueEnsemblePolling(queryClient: QueryClient) { - if (!this._pollingEnabled) return; + private async setWorkbenchSession(session: PrivateWorkbenchSession): Promise { + if (this._workbenchSession) { + console.warn("A workbench session is already active."); + return; + } - await this.pollForEnsembleChange(queryClient); + try { + if (session.getEnsembleSet().getEnsembleArray().length === 0) { + this._guiMessageBroker.setState(GuiState.EnsembleDialogOpen, true); + } + + this._guiMessageBroker.setState(GuiState.LeftDrawerContent, LeftDrawerContent.ModuleSettings); - // Checking the variable again in case polling was disabled *during* the async call - if (!this._pollingEnabled) return; + if (session.getActiveDashboard().getLayout().length === 0) { + this._guiMessageBroker.setState(GuiState.RightDrawerContent, RightDrawerContent.ModulesList); + if (this._guiMessageBroker.getState(GuiState.RightSettingsPanelWidthInPercent) === 0) { + this._guiMessageBroker.setState(GuiState.RightSettingsPanelWidthInPercent, 20); + } + } - console.debug("checkForEnsembleUpdate - queuing next..."); - this._waitingPollingRun = setTimeout(async () => { - this.recursivelyQueueEnsemblePolling(queryClient); - }, ENSEMBLE_POLLING_INTERVAL); + this._workbenchSession = session; + await this._ensembleUpdateMonitor.pollImmediately(); + this._ensembleUpdateMonitor.startPolling(); + await this._workbenchSessionPersistenceService.setWorkbenchSession(session); + this._publishSubscribeDelegate.notifySubscribers(WorkbenchTopic.HAS_ACTIVE_SESSION); + } catch (error) { + console.error("Failed to hydrate workbench session:", error); + throw new Error("Could not load workbench session from data container."); + } finally { + this._guiMessageBroker.setState(GuiState.SessionHasUnsavedChanges, false); + this._guiMessageBroker.setState(GuiState.SaveSessionDialogOpen, false); + } } - private async pollForEnsembleChange(queryClient: QueryClient) { - console.debug("checkForEnsembleUpdate - fetching..."); + async startNewSession(): Promise { + if (this._workbenchSession) { + console.warn("A workbench session is already active. Please close it before starting a new one."); + return; + } - const regularEnsembleSet = this._workbenchSession.getEnsembleSet().getRegularEnsembleArray(); + const session = new PrivateWorkbenchSession(this._queryClient); + session.makeDefaultDashboard(); - const latestTimestamps = await this.getLatestEnsembleTimestamps(queryClient, regularEnsembleSet); + await this.setWorkbenchSession(session); + } - const newSettings = latestTimestamps.reduce((acc, [ens, ts]) => { - if (!isEnsembleOutdated(ens, ts)) return acc; + async maybeCloseCurrentSession(): Promise { + if (!this._workbenchSession) { + console.warn("No active workbench session to close."); + return true; + } - return acc.concat({ - ...ensembleToUserSettings(ens), - timestamps: ts, + if ( + (this._workbenchSessionPersistenceService.hasChanges() || !this._workbenchSession.getIsPersisted()) && + !this._workbenchSession.isSnapshot() + ) { + const result = await confirmationService.confirm({ + title: "Unsaved Changes", + message: "You have unsaved changes in your current session. Do you want to save them before closing?", + actions: [ + { id: "cancel", label: "Cancel" }, + { id: "discard", label: "Discard Changes", color: "danger" }, + { id: "save", label: "Save Changes", color: "success" }, + ], }); - }, [] as UserEnsembleSetting[]); - if (newSettings.length) { - this.updateExistingUserEnsembleSettings(queryClient, newSettings); + if (result === "cancel") { + // User chose to cancel the close operation, so we do nothing. + return false; + } + if (result === "save") { + // User chose to save the changes, so we save the current session and then close it + if (!this._workbenchSession.getIsPersisted()) { + this._guiMessageBroker.setState(GuiState.SaveSessionDialogOpen, true); + return false; // Do not proceed with the close operation, wait for the user to save + } + await this.saveCurrentSession(true); + this.closeCurrentSession(); + return true; // Proceed with the close operation. + } + if (result === "discard") { + // User chose to discard the changes, so we unload the current session and close it. + this.closeCurrentSession(); + return true; // Proceed with the close operation. + } + + throw new Error(`Unexpected confirmation result: ${result}`); } - console.debug("checkForEnsembleUpdate - done..."); + this.closeCurrentSession(); + return true; } - private async getLatestEnsembleTimestamps( - queryClient: QueryClient, - ensembles: readonly RegularEnsemble[], - ): Promise<[RegularEnsemble, EnsembleTimestamps_api][]> { - const idents = ensembles.map((ens) => ({ - caseUuid: ens.getCaseUuid(), - ensembleName: ens.getEnsembleName(), - })); - - const timestamps = await queryClient.fetchQuery({ - ...postGetTimestampsForEnsemblesOptions({ body: idents }), - staleTime: 0, - gcTime: 0, - }); + async maybeRefreshSession(): Promise { + if (!this._workbenchSession) { + console.warn("No active workbench session to refresh."); + return; + } - return ensembles.map((ens, i) => [ens, timestamps[i]]); - } + if (this._workbenchSession.isSnapshot() || !this._workbenchSession.getIsPersisted()) { + throw new Error("Cannot refresh a snapshot or non-persisted session."); + } - private async updateExistingUserEnsembleSettings(queryClient: QueryClient, newSettings: UserEnsembleSetting[]) { - if (newSettings.length === 0) return; + const sessionId = this._workbenchSession.getId(); - const existingEnsembleSettings = this.maybeLoadEnsembleSettingsFromLocalStorage() ?? []; - const existingDeltaEnsembleSettings = this.maybeLoadDeltaEnsembleSettingsFromLocalStorage() ?? []; + if (!sessionId) { + throw new Error("Cannot refresh session without a valid session ID."); + } - const newEnsembleSettings = existingEnsembleSettings.map((el) => { - const newSetting = newSettings.find((newEl) => newEl.ensembleIdent.equals(el.ensembleIdent)); - if (newSetting) return newSetting; - return el; - }); + if ( + (this._workbenchSessionPersistenceService.hasChanges() || !this._workbenchSession.getIsPersisted()) && + !this._workbenchSession.isSnapshot() + ) { + const result = await confirmationService.confirm({ + title: "Unsaved Changes", + message: + "You have unsaved changes in your current session. By refreshing, you will lose these changes. Do you want to proceed?", + actions: [ + { id: "cancel", label: "Cancel" }, + { id: "discard", label: "Discard & Refresh", color: "danger" }, + ], + }); - await this.storeSettingsInLocalStorageAndLoadAndSetupEnsembleSetInSession( - queryClient, - newEnsembleSettings, - existingDeltaEnsembleSettings, - ); - } + if (result === "cancel") { + // User chose to cancel the close operation, so we do nothing. + return; + } + if (result === "discard") { + // User chose to discard the changes, so we unload the current session and refresh it. + this.unloadCurrentSession(); + await this.openSession(sessionId); + return; + } - private storeEnsembleSetInLocalStorage(ensembleSettingsToStore: UserEnsembleSetting[]): void { - const ensembleSettingsArrayToStore: StoredUserEnsembleSetting[] = ensembleSettingsToStore.map((el) => ({ - ...el, - ensembleIdent: el.ensembleIdent.toString(), - })); - localStorage.setItem("userEnsembleSettings", JSON.stringify(ensembleSettingsArrayToStore)); - } + throw new Error(`Unexpected confirmation result: ${result}`); + } - private storeDeltaEnsembleSetInLocalStorage(deltaEnsembleSettingsToStore: UserDeltaEnsembleSetting[]): void { - const deltaEnsembleSettingsArrayToStore: StoredUserDeltaEnsembleSetting[] = deltaEnsembleSettingsToStore.map( - (el) => ({ - ...el, - comparisonEnsembleIdent: el.comparisonEnsembleIdent.toString(), - referenceEnsembleIdent: el.referenceEnsembleIdent.toString(), - }), - ); - localStorage.setItem("userDeltaEnsembleSettings", JSON.stringify(deltaEnsembleSettingsArrayToStore)); + this.unloadCurrentSession(); + await this.openSession(sessionId); } - maybeLoadEnsembleSettingsFromLocalStorage(): UserEnsembleSetting[] | null { - const ensembleSettingsString = localStorage.getItem("userEnsembleSettings"); - if (!ensembleSettingsString) return null; + private unloadCurrentSession(): void { + if (!this._workbenchSession) { + console.warn("No active workbench session to unload."); + return; + } - const ensembleSettingsArray = JSON.parse(ensembleSettingsString) as StoredUserEnsembleSetting[]; - const parsedEnsembleSettingsArray: UserEnsembleSetting[] = ensembleSettingsArray.map((el) => ({ - ...el, - ensembleIdent: RegularEnsembleIdent.fromString(el.ensembleIdent), - })); + this._ensembleUpdateMonitor.stopPolling(); - return parsedEnsembleSettingsArray; + this._workbenchSession.beforeDestroy(); + this._workbenchSessionPersistenceService.removeWorkbenchSession(); + this._workbenchSession = null; } - maybeLoadDeltaEnsembleSettingsFromLocalStorage(): UserDeltaEnsembleSetting[] | null { - const deltaEnsembleSettingsString = localStorage.getItem("userDeltaEnsembleSettings"); - if (!deltaEnsembleSettingsString) return null; + closeCurrentSession(): void { + if (!this._workbenchSession) { + console.warn("No active workbench session to close."); + return; + } - const deltaEnsembleSettingsArray = JSON.parse(deltaEnsembleSettingsString) as StoredUserDeltaEnsembleSetting[]; - const parsedDeltaEnsembleSettingsArray: UserDeltaEnsembleSetting[] = deltaEnsembleSettingsArray.map((el) => ({ - ...el, - comparisonEnsembleIdent: RegularEnsembleIdent.fromString(el.comparisonEnsembleIdent), - referenceEnsembleIdent: RegularEnsembleIdent.fromString(el.referenceEnsembleIdent), - })); + removeSnapshotIdFromUrl(); + removeSessionIdFromUrl(); + this.unloadCurrentSession(); - return parsedDeltaEnsembleSettingsArray; + this._publishSubscribeDelegate.notifySubscribers(WorkbenchTopic.HAS_ACTIVE_SESSION); } - applyTemplate(template: Template): void { - this.clearLayout(); - - const newLayout = template.moduleInstances.map((el) => { - return { ...el.layout, moduleName: el.moduleName }; - }); - - this.makeLayout(newLayout); - - for (let i = 0; i < this._moduleInstances.length; i++) { - const moduleInstance = this._moduleInstances[i]; - const templateModule = template.moduleInstances[i]; - if (templateModule.syncedSettings) { - for (const syncSettingKey of templateModule.syncedSettings) { - moduleInstance.addSyncedSetting(syncSettingKey); - } - } - - const initialSettings: Record = templateModule.initialSettings || {}; - - if (templateModule.dataChannelsToInitialSettingsMapping) { - for (const propName of Object.keys(templateModule.dataChannelsToInitialSettingsMapping)) { - const dataChannel = templateModule.dataChannelsToInitialSettingsMapping[propName]; + getWorkbenchSession(): PrivateWorkbenchSession { + if (!this._workbenchSession) { + throw new Error("Workbench session has not been started. Call startNewSession() first."); + } + return this._workbenchSession; + } - const moduleInstanceIndex = template.moduleInstances.findIndex( - (el) => el.instanceRef === dataChannel.listensToInstanceRef, - ); - if (moduleInstanceIndex === -1) { - throw new Error("Could not find module instance for data channel"); - } + getWorkbenchServices(): WorkbenchServices { + return this._workbenchServices; + } - const listensToModuleInstance = this._moduleInstances[moduleInstanceIndex]; - const channel = listensToModuleInstance.getChannelManager().getChannel(dataChannel.channelIdString); - if (!channel) { - throw new Error("Could not find channel"); - } + getGuiMessageBroker(): GuiMessageBroker { + return this._guiMessageBroker; + } - const receiver = moduleInstance.getChannelManager().getReceiver(propName); + beforeDestroy(): void { + this._navigationObserver.beforeDestroy(); + } - if (!receiver) { - throw new Error("Could not find receiver"); - } + clear(): void { + // this._workbenchSession.clear(); + } - receiver.subscribeToChannel(channel, "All"); - } - } + async makeSessionFromTemplate(template: Template): Promise { + if (this._workbenchSession) { + this._workbenchSession.clear(); + } - moduleInstance.setInitialSettings(new InitialSettings(initialSettings)); + await this.startNewSession(); - if (i === 0) { - this.getGuiMessageBroker().setState(GuiState.ActiveModuleInstanceId, moduleInstance.getId()); - } + if (!this._workbenchSession) { + throw new Error("No active workbench session to apply the template to."); } - this.notifySubscribers(WorkbenchEvents.ModuleInstancesChanged); + const dashboard = await Dashboard.fromTemplate(template, this._workbenchSession.getAtomStoreMaster()); + this._workbenchSession.setDashboards([dashboard]); + + this._publishSubscribeDelegate.notifySubscribers(WorkbenchTopic.HAS_ACTIVE_SESSION); } } diff --git a/frontend/src/framework/WorkbenchSession.ts b/frontend/src/framework/WorkbenchSession.ts index b67532c70..e1f18974b 100644 --- a/frontend/src/framework/WorkbenchSession.ts +++ b/frontend/src/framework/WorkbenchSession.ts @@ -1,82 +1,27 @@ import React from "react"; -import type { AtomStoreMaster } from "./AtomStoreMaster"; +import type { PublishSubscribe } from "@lib/utils/PublishSubscribeDelegate"; + import type { DeltaEnsembleIdent } from "./DeltaEnsembleIdent"; -import { EnsembleSet } from "./EnsembleSet"; -import { RealizationFilterSet } from "./RealizationFilterSet"; +import type { EnsembleSet } from "./EnsembleSet"; +import type { RealizationFilterSet } from "./RealizationFilterSet"; import type { RegularEnsembleIdent } from "./RegularEnsembleIdent"; -import { UserCreatedItems } from "./UserCreatedItems"; - -export type EnsembleRealizationFilterFunction = ( - ensembleIdent: RegularEnsembleIdent | DeltaEnsembleIdent, -) => readonly number[]; +import type { UserCreatedItems } from "./UserCreatedItems"; -export enum WorkbenchSessionEvent { - EnsembleSetChanged = "EnsembleSetChanged", - EnsembleSetLoadingStateChanged = "EnsembleSetLoadingStateChanged", - RealizationFilterSetChanged = "RealizationFilterSetChanged", +export enum WorkbenchSessionTopic { + EnsembleSet = "EnsembleSet", + RealizationFilterSet = "RealizationFilterSet", } -export type WorkbenchSessionPayloads = { - [WorkbenchSessionEvent.EnsembleSetLoadingStateChanged]: { - isLoading: boolean; - }; +export type WorkbenchSessionTopicPayloads = { + [WorkbenchSessionTopic.EnsembleSet]: EnsembleSet; + [WorkbenchSessionTopic.RealizationFilterSet]: RealizationFilterSet; }; -export class WorkbenchSession { - private _subscribersMap: Map void>> = new Map(); - protected _ensembleSet: EnsembleSet = new EnsembleSet([]); - protected _realizationFilterSet = new RealizationFilterSet(); - protected _userCreatedItems: UserCreatedItems; - - constructor(atomStoreMaster: AtomStoreMaster) { - this._userCreatedItems = new UserCreatedItems(atomStoreMaster); - } - - getEnsembleSet(): EnsembleSet { - return this._ensembleSet; - } - - getRealizationFilterSet(): RealizationFilterSet { - return this._realizationFilterSet; - } - - getUserCreatedItems(): UserCreatedItems { - return this._userCreatedItems; - } - - subscribe>( - event: T, - cb: () => void, - ): () => void; - subscribe( - event: T, - cb: (payload: WorkbenchSessionPayloads[T]) => void, - ): () => void; - subscribe(event: T, cb: (payload: any) => void) { - const subscribersSet = this._subscribersMap.get(event) || new Set(); - subscribersSet.add(cb); - this._subscribersMap.set(event, subscribersSet); - return () => { - subscribersSet.delete(cb); - }; - } - - protected notifySubscribers>( - event: T, - ): void; - protected notifySubscribers( - event: T, - payload: WorkbenchSessionPayloads[T], - ): void; - protected notifySubscribers(event: T, payload?: any): void { - const subscribersSet = this._subscribersMap.get(event); - if (!subscribersSet) return; - - for (const callbackFn of subscribersSet) { - callbackFn(payload); - } - } +export interface WorkbenchSession extends PublishSubscribe { + getEnsembleSet: () => EnsembleSet; + getRealizationFilterSet: () => RealizationFilterSet; + getUserCreatedItems: () => UserCreatedItems; } export function createEnsembleRealizationFilterFuncForWorkbenchSession(workbenchSession: WorkbenchSession) { @@ -90,6 +35,10 @@ export function createEnsembleRealizationFilterFuncForWorkbenchSession(workbench }; } +export type EnsembleRealizationFilterFunction = ( + ensembleIdent: RegularEnsembleIdent | DeltaEnsembleIdent, +) => readonly number[]; + export function useEnsembleRealizationFilterFunc( workbenchSession: WorkbenchSession, ): EnsembleRealizationFilterFunction { @@ -108,9 +57,10 @@ export function useEnsembleRealizationFilterFunc( ); } - const unsubscribeFunc = workbenchSession.subscribe( - WorkbenchSessionEvent.RealizationFilterSetChanged, - handleEnsembleRealizationFilterSetChanged, + const unsubscribeFunc = workbenchSession + .getPublishSubscribeDelegate() + .makeSubscriberFunction(WorkbenchSessionTopic.RealizationFilterSet)( + () => handleEnsembleRealizationFilterSetChanged, ); return unsubscribeFunc; }, @@ -119,47 +69,3 @@ export function useEnsembleRealizationFilterFunc( return storedEnsembleRealizationFilterFunc; } - -export function useEnsembleSet(workbenchSession: WorkbenchSession): EnsembleSet { - const [storedEnsembleSet, setStoredEnsembleSet] = React.useState(workbenchSession.getEnsembleSet()); - - React.useEffect( - function subscribeToEnsembleSetChanges() { - function handleEnsembleSetChanged() { - setStoredEnsembleSet(workbenchSession.getEnsembleSet()); - } - - const unsubFunc = workbenchSession.subscribe( - WorkbenchSessionEvent.EnsembleSetChanged, - handleEnsembleSetChanged, - ); - return unsubFunc; - }, - [workbenchSession], - ); - - return storedEnsembleSet; -} - -export function useIsEnsembleSetLoading(workbenchSession: WorkbenchSession): boolean { - const [isLoading, setIsLoading] = React.useState(false); - - React.useEffect( - function subscribeToEnsembleSetLoadingStateChanges() { - function handleEnsembleSetLoadingStateChanged( - payload: WorkbenchSessionPayloads[WorkbenchSessionEvent.EnsembleSetLoadingStateChanged], - ) { - setIsLoading(payload.isLoading); - } - - const unsubFunc = workbenchSession.subscribe( - WorkbenchSessionEvent.EnsembleSetLoadingStateChanged, - handleEnsembleSetLoadingStateChanged, - ); - return unsubFunc; - }, - [workbenchSession], - ); - - return isLoading; -} diff --git a/frontend/src/framework/WorkbenchSettings.ts b/frontend/src/framework/WorkbenchSettings.ts index 9bcb5474e..552fed35d 100644 --- a/frontend/src/framework/WorkbenchSettings.ts +++ b/frontend/src/framework/WorkbenchSettings.ts @@ -9,23 +9,23 @@ * and give additional context to the functions. */ -/* eslint-disable react-hooks/rules-of-hooks */ import React from "react"; import { isEqual } from "lodash"; -import { WorkbenchSettingsEvents } from "@framework/internal/PrivateWorkbenchSettings"; import type { ColorPalette } from "@lib/utils/ColorPalette"; import type { ColorScaleOptions } from "@lib/utils/ColorScale"; import { ColorScale, ColorScaleGradientType, ColorScaleType } from "@lib/utils/ColorScale"; import { ColorSet } from "@lib/utils/ColorSet"; +import { usePublishSubscribeTopicValue, type PublishSubscribe } from "@lib/utils/PublishSubscribeDelegate"; +export enum WorkbenchSettingsTopic { + SelectedColorPalettes = "SelectedColorPalettes", +} -import { - defaultColorPalettes, - defaultContinuousDivergingColorPalettes, - defaultContinuousSequentialColorPalettes, -} from "./utils/colorPalettes"; +export type WorkbenchSettingsTopicPayloads = { + [WorkbenchSettingsTopic.SelectedColorPalettes]: Record; +}; export enum ColorPaletteType { Categorical = "categorical", @@ -38,188 +38,146 @@ export enum ColorScaleDiscreteSteps { Diverging = "diverging", } -export class WorkbenchSettings { - protected _colorPalettes: Record; - protected _selectedColorPalettes: Record; - protected _steps: { +export interface WorkbenchSettings extends PublishSubscribe { + getSelectedColorPalette(type: ColorPaletteType): ColorPalette; + getColorPalettes(): Record; + getSteps(): { [ColorScaleDiscreteSteps.Sequential]: number; [ColorScaleDiscreteSteps.Diverging]: number; }; - protected _subscribersMap: { [key: string]: Set<() => void> }; - - constructor() { - this._subscribersMap = {}; - - this._colorPalettes = { - [ColorPaletteType.Categorical]: defaultColorPalettes, - [ColorPaletteType.ContinuousSequential]: defaultContinuousSequentialColorPalettes, - [ColorPaletteType.ContinuousDiverging]: defaultContinuousDivergingColorPalettes, - }; - this._selectedColorPalettes = { - [ColorPaletteType.Categorical]: defaultColorPalettes[0].getId(), - [ColorPaletteType.ContinuousSequential]: defaultContinuousSequentialColorPalettes[0].getId(), - [ColorPaletteType.ContinuousDiverging]: defaultContinuousDivergingColorPalettes[0].getId(), - }; - - this._steps = { - [ColorScaleDiscreteSteps.Sequential]: 10, - [ColorScaleDiscreteSteps.Diverging]: 10, - }; - } + makeColorSet(): ColorSet; + makeDiscreteColorScale(options: { gradientType: ColorScaleGradientType }): ColorScale; + makeContinuousColorScale(options: { gradientType: ColorScaleGradientType }): ColorScale; +} - getSelectedColorPalette(type: ColorPaletteType): ColorPalette { - const colorPalette = this._colorPalettes[type].find((el) => el.getId() === this._selectedColorPalettes[type]); - if (!colorPalette) { - throw new Error("Could not find selected color palette"); - } - return colorPalette; - } +export function useColorSet(workbenchSettings: WorkbenchSettings): ColorSet { + const selectedColorPalettes = usePublishSubscribeTopicValue( + workbenchSettings, + WorkbenchSettingsTopic.SelectedColorPalettes, + ); + const [colorSet, setColorSet] = React.useState( + new ColorSet(workbenchSettings.getSelectedColorPalette(ColorPaletteType.Categorical)), + ); + + React.useEffect( + function onColorPalettesChange() { + setColorSet(new ColorSet(workbenchSettings.getSelectedColorPalette(ColorPaletteType.Categorical))); + }, + [selectedColorPalettes, workbenchSettings], + ); + + return colorSet; +} - private subscribe(event: WorkbenchSettingsEvents, cb: () => void) { - const subscribersSet = this._subscribersMap[event] || new Set(); - subscribersSet.add(cb); - this._subscribersMap[event] = subscribersSet; - return () => { - subscribersSet.delete(cb); - }; - } +export function useDiscreteColorScale( + workbenchSettings: WorkbenchSettings, + options: { gradientType: ColorScaleGradientType }, +): ColorScale { + const selectedColorPalettes = usePublishSubscribeTopicValue( + workbenchSettings, + WorkbenchSettingsTopic.SelectedColorPalettes, + ); + + const optionsWithDefaults: ColorScaleOptions = { + type: ColorScaleType.Discrete, + colorPalette: workbenchSettings.getSelectedColorPalette( + options.gradientType === ColorScaleGradientType.Sequential + ? ColorPaletteType.ContinuousSequential + : ColorPaletteType.ContinuousDiverging, + ), + gradientType: options.gradientType, + steps: workbenchSettings.getSteps()[ + options.gradientType === ColorScaleGradientType.Sequential + ? ColorScaleDiscreteSteps.Sequential + : ColorScaleDiscreteSteps.Diverging + ], + }; - getColorPalettes(): Record { - return this._colorPalettes; - } + const divergingSteps = workbenchSettings.getSteps()[ColorScaleDiscreteSteps.Diverging]; + const sequentialSteps = workbenchSettings.getSteps()[ColorScaleDiscreteSteps.Sequential]; - useColorSet(): ColorSet { - const [colorSet, setColorSet] = React.useState( - new ColorSet(this.getSelectedColorPalette(ColorPaletteType.Categorical)), - ); + const [adjustedOptions, setAdjustedOptions] = React.useState(optionsWithDefaults); - React.useEffect(() => { + const [colorScale, setColorScale] = React.useState(new ColorScale(optionsWithDefaults)); + + if (!isEqual(optionsWithDefaults, adjustedOptions)) { + setAdjustedOptions({ ...optionsWithDefaults }); + } + + React.useEffect( + function onColorPalettesChange() { // Explicitly using arrow function to preserve the "this" context - const handleColorPalettesChanged = () => { - setColorSet(new ColorSet(this.getSelectedColorPalette(ColorPaletteType.Categorical))); - }; + const newColorScale = new ColorScale({ + ...adjustedOptions, + steps: options.gradientType === ColorScaleGradientType.Sequential ? sequentialSteps : divergingSteps, + colorPalette: workbenchSettings.getSelectedColorPalette( + options.gradientType === ColorScaleGradientType.Sequential + ? ColorPaletteType.ContinuousSequential + : ColorPaletteType.ContinuousDiverging, + ), + }); + setColorScale(newColorScale); + }, + [ + adjustedOptions, + divergingSteps, + sequentialSteps, + options.gradientType, + selectedColorPalettes, + workbenchSettings, + ], + ); + + return colorScale; +} - const unsubscribeFunc = this.subscribe( - WorkbenchSettingsEvents.ColorPalettesChanged, - handleColorPalettesChanged, - ); +export function useContinuousColorScale( + workbenchSettings: WorkbenchSettings, + options: { gradientType: ColorScaleGradientType }, +): ColorScale { + const selectedColorPalettes = usePublishSubscribeTopicValue( + workbenchSettings, + WorkbenchSettingsTopic.SelectedColorPalettes, + ); + + const optionsWithDefaults: ColorScaleOptions = { + type: ColorScaleType.Continuous, + colorPalette: workbenchSettings.getSelectedColorPalette( + options.gradientType === ColorScaleGradientType.Sequential + ? ColorPaletteType.ContinuousSequential + : ColorPaletteType.ContinuousDiverging, + ), + gradientType: options.gradientType, + steps: workbenchSettings.getSteps()[ + options.gradientType === ColorScaleGradientType.Sequential + ? ColorScaleDiscreteSteps.Sequential + : ColorScaleDiscreteSteps.Diverging + ], + }; - return () => { - unsubscribeFunc(); - }; - }, []); + const [adjustedOptions, setAdjustedOptions] = React.useState(optionsWithDefaults); - return colorSet; - } + const [colorScale, setColorScale] = React.useState(new ColorScale(optionsWithDefaults)); - useDiscreteColorScale(options: { gradientType: ColorScaleGradientType }): ColorScale { - const optionsWithDefaults: ColorScaleOptions = { - type: ColorScaleType.Discrete, - colorPalette: this.getSelectedColorPalette( - options.gradientType === ColorScaleGradientType.Sequential - ? ColorPaletteType.ContinuousSequential - : ColorPaletteType.ContinuousDiverging, - ), - gradientType: options.gradientType, - steps: this._steps[ - options.gradientType === ColorScaleGradientType.Sequential - ? ColorScaleDiscreteSteps.Sequential - : ColorScaleDiscreteSteps.Diverging - ], - }; - - const divergingSteps = this._steps[ColorScaleDiscreteSteps.Diverging]; - const sequentialSteps = this._steps[ColorScaleDiscreteSteps.Sequential]; - - const [adjustedOptions, setAdjustedOptions] = React.useState(optionsWithDefaults); - - const [colorScale, setColorScale] = React.useState(new ColorScale(optionsWithDefaults)); - - if (!isEqual(optionsWithDefaults, adjustedOptions)) { - setAdjustedOptions({ ...optionsWithDefaults }); - } - - React.useEffect(() => { - // Explicitly using arrow function to preserve the "this" context - const handleColorPalettesChanged = () => { - const newColorScale = new ColorScale({ - ...adjustedOptions, - steps: - options.gradientType === ColorScaleGradientType.Sequential ? sequentialSteps : divergingSteps, - colorPalette: this.getSelectedColorPalette( - options.gradientType === ColorScaleGradientType.Sequential - ? ColorPaletteType.ContinuousSequential - : ColorPaletteType.ContinuousDiverging, - ), - }); - setColorScale(newColorScale); - }; - - const unsubscribeFunc = this.subscribe( - WorkbenchSettingsEvents.ColorPalettesChanged, - handleColorPalettesChanged, - ); - - handleColorPalettesChanged(); - - return () => { - unsubscribeFunc(); - }; - }, [adjustedOptions, divergingSteps, sequentialSteps, options.gradientType]); - - return colorScale; + if (!isEqual(optionsWithDefaults, adjustedOptions)) { + setAdjustedOptions({ ...optionsWithDefaults }); } - useContinuousColorScale(options: { gradientType: ColorScaleGradientType }): ColorScale { - const optionsWithDefaults: ColorScaleOptions = { - type: ColorScaleType.Continuous, - colorPalette: this.getSelectedColorPalette( - options.gradientType === ColorScaleGradientType.Sequential - ? ColorPaletteType.ContinuousSequential - : ColorPaletteType.ContinuousDiverging, - ), - gradientType: options.gradientType, - steps: this._steps[ - options.gradientType === ColorScaleGradientType.Sequential - ? ColorScaleDiscreteSteps.Sequential - : ColorScaleDiscreteSteps.Diverging - ], - }; - - const [adjustedOptions, setAdjustedOptions] = React.useState(optionsWithDefaults); - - const [colorScale, setColorScale] = React.useState(new ColorScale(optionsWithDefaults)); - - if (!isEqual(optionsWithDefaults, adjustedOptions)) { - setAdjustedOptions({ ...optionsWithDefaults }); - } - - React.useEffect(() => { + React.useEffect( + function onColorPalettesChange() { // Explicitly using arrow function to preserve the "this" context - const handleColorPalettesChanged = () => { - const newColorScale = new ColorScale({ - ...adjustedOptions, - colorPalette: this.getSelectedColorPalette( - options.gradientType === ColorScaleGradientType.Sequential - ? ColorPaletteType.ContinuousSequential - : ColorPaletteType.ContinuousDiverging, - ), - }); - setColorScale(newColorScale); - }; - - const unsubscribeFunc = this.subscribe( - WorkbenchSettingsEvents.ColorPalettesChanged, - handleColorPalettesChanged, - ); - - handleColorPalettesChanged(); - - return () => { - unsubscribeFunc(); - }; - }, [adjustedOptions, options.gradientType]); - - return colorScale; - } + const newColorScale = new ColorScale({ + ...adjustedOptions, + colorPalette: workbenchSettings.getSelectedColorPalette( + options.gradientType === ColorScaleGradientType.Sequential + ? ColorPaletteType.ContinuousSequential + : ColorPaletteType.ContinuousDiverging, + ), + }); + setColorScale(newColorScale); + }, + [adjustedOptions, options.gradientType, selectedColorPalettes, workbenchSettings], + ); + + return colorScale; } diff --git a/frontend/src/framework/components/ColorScaleSelector/colorScaleSelector.tsx b/frontend/src/framework/components/ColorScaleSelector/colorScaleSelector.tsx index 4b22754b4..2ebd155ae 100644 --- a/frontend/src/framework/components/ColorScaleSelector/colorScaleSelector.tsx +++ b/frontend/src/framework/components/ColorScaleSelector/colorScaleSelector.tsx @@ -37,7 +37,7 @@ export function ColorScaleSelector(props: ColorScaleSelectorProps): React.ReactN const id = React.useId(); const [colorScaleSpecification, setColorScaleSpecification] = React.useState({ - colorScale: props.workbenchSettings.useContinuousColorScale({ + colorScale: props.workbenchSettings.makeContinuousColorScale({ gradientType: ColorScaleGradientType.Sequential, }), areBoundariesUserDefined: false, @@ -126,7 +126,7 @@ function ColorScaleSelectorDialog(props: ColorScaleSelectorProps): React.ReactNo const id = React.useId(); const [colorScaleSpecification, setColorScaleSpecification] = React.useState({ - colorScale: props.workbenchSettings.useContinuousColorScale({ + colorScale: props.workbenchSettings.makeContinuousColorScale({ gradientType: ColorScaleGradientType.Sequential, }), areBoundariesUserDefined: false, diff --git a/frontend/src/framework/components/LayoutBox/layoutBox.tsx b/frontend/src/framework/components/LayoutBox/layoutBox.tsx index 38cc2f733..e827acc2d 100644 --- a/frontend/src/framework/components/LayoutBox/layoutBox.tsx +++ b/frontend/src/framework/components/LayoutBox/layoutBox.tsx @@ -1,10 +1,10 @@ import type React from "react"; -import type { LayoutElement } from "@framework/Workbench"; import type { Rect2D, Size2D } from "@lib/utils/geometry"; import { outerRectContainsInnerRect, rectContainsPoint, rectsAreEqual } from "@lib/utils/geometry"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; import type { Vec2 } from "@lib/utils/vec2"; +import type { LayoutElement } from "@framework/internal/WorkbenchSession/Dashboard"; function layoutElementToRect(layoutElement: LayoutElement): Rect2D { return { @@ -917,9 +917,7 @@ export const LayoutBoxComponents: React.FC<{ height: rect.height, zIndex: props.zIndex, }} - > - Drag a module here -
+ >
); } return <>{flatBoxes.map((box) => makeBoxEdges(box))}; diff --git a/frontend/src/framework/internal/DataChannels/ChannelManager.ts b/frontend/src/framework/internal/DataChannels/ChannelManager.ts index 30294a3d3..70dcdff0f 100644 --- a/frontend/src/framework/internal/DataChannels/ChannelManager.ts +++ b/frontend/src/framework/internal/DataChannels/ChannelManager.ts @@ -1,3 +1,5 @@ +import type { Dashboard } from "../WorkbenchSession/Dashboard"; + import type { ChannelDefinition } from "./Channel"; import { Channel } from "./Channel"; import type { ChannelReceiverDefinition } from "./ChannelReceiver"; @@ -8,6 +10,13 @@ export enum ChannelManagerNotificationTopic { RECEIVERS_CHANGE = "receivers-change", } +export type DataChannelReceiverSubscription = { + idString: string; + listensToModuleInstanceId: string; + channelIdString: string; + contentIdStrings: string[]; +}; + export class ChannelManager { private readonly _moduleInstanceId: string; private _channels: Channel[] = []; @@ -86,6 +95,56 @@ export class ChannelManager { }; } + serialize(): DataChannelReceiverSubscription[] { + const subscriptions: DataChannelReceiverSubscription[] = []; + + for (const receiver of this._receivers) { + const channel = receiver.getChannel(); + if (!channel) { + continue; // Skip receivers that are not associated with a channel + } + + const subscription: DataChannelReceiverSubscription = { + idString: receiver.getIdString(), + listensToModuleInstanceId: channel.getManager().getModuleInstanceId(), + channelIdString: channel.getIdString(), + contentIdStrings: receiver.getContentIdStrings(), + }; + subscriptions.push(subscription); + } + + return subscriptions; + } + + deserialize(subscriptions: DataChannelReceiverSubscription[], dashboard: Dashboard): void { + for (const subscription of subscriptions) { + const listensToModuleInstance = dashboard.getModuleInstance(subscription.listensToModuleInstanceId); + if (!listensToModuleInstance) { + console.warn( + `ChannelManager.deserialize: Module instance with ID ${subscription.listensToModuleInstanceId} not found. Skipping subscription.`, + ); + continue; + } + const channel = listensToModuleInstance.getChannelManager().getChannel(subscription.channelIdString); + if (!channel) { + console.warn( + `ChannelManager.deserialize: Channel with ID ${subscription.channelIdString} not found. Skipping subscription.`, + ); + continue; + } + const receiver = this.getReceiver(subscription.idString); + if (!receiver) { + console.warn( + `ChannelManager.deserialize: Receiver with ID ${subscription.idString} not found. Skipping subscription.`, + ); + continue; + } + receiver.subscribeToChannel(channel, subscription.contentIdStrings); + } + + this.notifySubscribers(ChannelManagerNotificationTopic.RECEIVERS_CHANGE); + } + private notifySubscribers(topic: ChannelManagerNotificationTopic): void { const topicSubscribers = this._subscribersMap.get(topic); diff --git a/frontend/src/framework/internal/EnsembleSetLoader.ts b/frontend/src/framework/internal/EnsembleSetLoader.ts index b91c9f927..8d6bb5e86 100644 --- a/frontend/src/framework/internal/EnsembleSetLoader.ts +++ b/frontend/src/framework/internal/EnsembleSetLoader.ts @@ -3,7 +3,7 @@ import type { QueryClient } from "@tanstack/react-query"; import type { EnsembleDetails_api, EnsembleParameter_api, EnsembleSensitivity_api, EnsembleTimestamps_api } from "@api"; import { SensitivityType_api, getEnsembleDetailsOptions, getParametersOptions, getSensitivitiesOptions } from "@api"; import { DeltaEnsemble } from "@framework/DeltaEnsemble"; -import type { UserDeltaEnsembleSetting, UserEnsembleSetting } from "@framework/Workbench"; +import { type EnsembleTimestamps } from "@framework/EnsembleTimestampsStore"; import type { ContinuousParameter, DiscreteParameter, Parameter } from "../EnsembleParameters"; import { ParameterType } from "../EnsembleParameters"; @@ -24,18 +24,32 @@ type EnsembleIdentStringToEnsembleApiDataMap = { [ensembleIdentString: string]: EnsembleApiData; }; +export type UserEnsembleSetting = { + ensembleIdent: RegularEnsembleIdent; + customName: string | null; + color: string; + timestamps?: EnsembleTimestamps_api; +}; + +export type UserDeltaEnsembleSetting = { + comparisonEnsembleIdent: RegularEnsembleIdent; + referenceEnsembleIdent: RegularEnsembleIdent; + customName: string | null; + color: string; +}; + export async function loadMetadataFromBackendAndCreateEnsembleSet( queryClient: QueryClient, userEnsembleSettings: UserEnsembleSetting[], userDeltaEnsembleSettings: UserDeltaEnsembleSetting[], ): Promise { // Get ensemble idents to load - const ensembleTimestampMap = {} as Record; - const ensembleIdentsToLoad = [] as RegularEnsembleIdent[]; + const ensembleTimestampMap = new Map(); + const ensembleIdentsToLoad: RegularEnsembleIdent[] = []; for (const ensembleSetting of userEnsembleSettings) { if (ensembleSetting.timestamps) { - ensembleTimestampMap[ensembleSetting.ensembleIdent.toString()] = ensembleSetting.timestamps; + ensembleTimestampMap.set(ensembleSetting.ensembleIdent.toString(), ensembleSetting.timestamps); } ensembleIdentsToLoad.push(ensembleSetting.ensembleIdent); } @@ -80,7 +94,6 @@ export async function loadMetadataFromBackendAndCreateEnsembleSet( sensitivityArray, ensembleSetting.color, ensembleSetting.customName, - ensembleApiData.ensembleDetails.timestamps, ), ); } @@ -134,7 +147,6 @@ export async function loadMetadataFromBackendAndCreateEnsembleSet( nullSensitivityArray, emptyColor, comparisonEnsembleCustomName, - comparisonEnsembleApiData.ensembleDetails.timestamps, ); const referenceEnsemble = existingReferenceEnsemble @@ -150,7 +162,6 @@ export async function loadMetadataFromBackendAndCreateEnsembleSet( nullSensitivityArray, emptyColor, referenceEnsembleCustomName, - referenceEnsembleApiData.ensembleDetails.timestamps, ); outDeltaEnsembleArray.push( @@ -169,7 +180,7 @@ export async function loadMetadataFromBackendAndCreateEnsembleSet( async function loadEnsembleApiDataMapFromBackend( queryClient: QueryClient, ensembleIdents: RegularEnsembleIdent[], - ensembleTimestampMap: Record, + ensembleTimestampMap: Map, ): Promise { console.debug("loadEnsembleIdentStringToApiDataMapFromBackend", ensembleIdents); const STALE_TIME = tanstackDebugTimeOverride(5 * 60 * 1000); @@ -182,7 +193,7 @@ async function loadEnsembleApiDataMapFromBackend( for (const ensembleIdent of ensembleIdents) { const caseUuid = ensembleIdent.getCaseUuid(); const ensembleName = ensembleIdent.getEnsembleName(); - const timestamps = ensembleTimestampMap[ensembleIdent.toString()]; + const timestamps = ensembleTimestampMap.get(ensembleIdent.toString()); const ensembleDetailsPromise = queryClient.fetchQuery({ ...getEnsembleDetailsOptions({ diff --git a/frontend/src/framework/internal/ModuleNotFoundPlaceholder.tsx b/frontend/src/framework/internal/ModuleNotFoundPlaceholder.tsx index 3374dde0f..1593f6b30 100644 --- a/frontend/src/framework/internal/ModuleNotFoundPlaceholder.tsx +++ b/frontend/src/framework/internal/ModuleNotFoundPlaceholder.tsx @@ -1,11 +1,12 @@ import { BugReport, Forum, WebAssetOff } from "@mui/icons-material"; -import { ImportState, Module, ModuleCategory, ModuleDevState } from "@framework/Module"; +import type { AtomStoreMaster } from "@framework/AtomStoreMaster"; +import { ImportStatus, Module, ModuleCategory, ModuleDevState } from "@framework/Module"; import type { ModuleInstance } from "@framework/ModuleInstance"; import { Button } from "@lib/components/Button"; import { Tag } from "@lib/components/Tag"; -export class ModuleNotFoundPlaceholder extends Module { +export class ModuleNotFoundPlaceholder extends Module { constructor(moduleName: string) { super({ name: moduleName, @@ -13,11 +14,11 @@ export class ModuleNotFoundPlaceholder extends Module { category: ModuleCategory.MAIN, devState: ModuleDevState.PROD, }); - this._importState = ImportState.Imported; + this._importState = ImportStatus.Imported; } - makeInstance(instanceNumber: number): ModuleInstance { - const instance = super.makeInstance(instanceNumber); + makeInstance(id: string, atomStoreMaster: AtomStoreMaster): ModuleInstance { + const instance = super.makeInstance(id, atomStoreMaster); return instance; } diff --git a/frontend/src/framework/internal/NavigationObserver.ts b/frontend/src/framework/internal/NavigationObserver.ts new file mode 100644 index 000000000..45e62dd49 --- /dev/null +++ b/frontend/src/framework/internal/NavigationObserver.ts @@ -0,0 +1,57 @@ +export type NavigationObserverOptions = { + onBeforeUnload?: () => boolean; + onNavigate?: () => Promise; +}; + +export class NavigationObserver { + private readonly _options: NavigationObserverOptions; + + private _currentUrl: string = window.location.href; + private readonly handleBeforeUnloadBound = this.handleBeforeUnload.bind(this); + private readonly handlePopStateBound = this.handlePopState.bind(this); + + constructor(options: NavigationObserverOptions) { + this._options = options; + + window.addEventListener("beforeunload", this.handleBeforeUnloadBound); + window.addEventListener("popstate", this.handlePopStateBound); + } + + private async handleBeforeUnload(event: BeforeUnloadEvent) { + const { onBeforeUnload } = this._options; + if (!onBeforeUnload) { + return; + } + + const result = onBeforeUnload(); + + if (result) { + event.preventDefault(); + event.returnValue = ""; // This is necessary for the dialog to show in some browsers. + } + } + + private async handlePopState() { + const { onNavigate } = this._options; + if (!onNavigate) { + return; + } + + const previousUrl = this._currentUrl; + const result = await onNavigate(); + + if (!result) { + // If the navigation was not handled, we can prevent the default behavior. + // Note: This is a workaround, as popstate does not allow preventing default behavior. + // Instead, we can use a custom logic to handle the navigation. + window.history.pushState(null, "", previousUrl); + } else { + this._currentUrl = window.location.href; + } + } + + beforeDestroy(): void { + window.removeEventListener("beforeunload", this.handleBeforeUnloadBound); + window.removeEventListener("popstate", this.handlePopStateBound); + } +} diff --git a/frontend/src/framework/internal/PrivateWorkbenchSettings.ts b/frontend/src/framework/internal/PrivateWorkbenchSettings.ts index 4bf17eded..0ed8cf0a2 100644 --- a/frontend/src/framework/internal/PrivateWorkbenchSettings.ts +++ b/frontend/src/framework/internal/PrivateWorkbenchSettings.ts @@ -1,11 +1,19 @@ -import type { ColorPaletteType, ColorScaleDiscreteSteps } from "@framework/WorkbenchSettings"; -import { WorkbenchSettings } from "@framework/WorkbenchSettings"; +import { + defaultColorPalettes, + defaultContinuousDivergingColorPalettes, + defaultContinuousSequentialColorPalettes, +} from "@framework/utils/colorPalettes"; +import { + ColorPaletteType, + ColorScaleDiscreteSteps, + WorkbenchSettingsTopic, + type WorkbenchSettingsTopicPayloads, +} from "@framework/WorkbenchSettings"; import type { ColorPalette } from "@lib/utils/ColorPalette"; -import type { ColorScaleGradientType } from "@lib/utils/ColorScale"; - -export enum WorkbenchSettingsEvents { - ColorPalettesChanged = "ColorPalettesChanged", -} +import { ColorScale, ColorScaleGradientType, ColorScaleType, type ColorScaleOptions } from "@lib/utils/ColorScale"; +import { ColorSet } from "@lib/utils/ColorSet"; +import { PublishSubscribeDelegate, type PublishSubscribe } from "@lib/utils/PublishSubscribeDelegate"; +import type { JTDSchemaType } from "ajv/dist/core"; export type UseDiscreteColorScaleOptions = { gradientType: ColorScaleGradientType; @@ -15,14 +23,91 @@ export type UseContinuousColorScaleOptions = { gradientType: ColorScaleGradientType; }; -export class PrivateWorkbenchSettings extends WorkbenchSettings { +export type SerializedWorkbenchSettings = { + selectedColorPalettes: Record; + discreteColorScaleSteps: { + [ColorScaleDiscreteSteps.Sequential]: number; + [ColorScaleDiscreteSteps.Diverging]: number; + }; +}; + +export const WORKBENCH_SETTINGS_JTD_SCHEMA: JTDSchemaType = { + properties: { + selectedColorPalettes: { + properties: { + [ColorPaletteType.Categorical]: { type: "string" }, + [ColorPaletteType.ContinuousDiverging]: { type: "string" }, + [ColorPaletteType.ContinuousSequential]: { type: "string" }, + }, + }, + discreteColorScaleSteps: { + properties: { + [ColorScaleDiscreteSteps.Sequential]: { type: "int32" }, + [ColorScaleDiscreteSteps.Diverging]: { type: "int32" }, + }, + }, + }, +} as const; + +export class PrivateWorkbenchSettings implements PublishSubscribe { + private _publishSubscribeDelegate = new PublishSubscribeDelegate(); + + private _colorPalettes: Record; + private _selectedColorPalettes: Record; + private _steps: { + [ColorScaleDiscreteSteps.Sequential]: number; + [ColorScaleDiscreteSteps.Diverging]: number; + }; + constructor() { - super(); + this._colorPalettes = { + [ColorPaletteType.Categorical]: defaultColorPalettes, + [ColorPaletteType.ContinuousSequential]: defaultContinuousSequentialColorPalettes, + [ColorPaletteType.ContinuousDiverging]: defaultContinuousDivergingColorPalettes, + }; + this._selectedColorPalettes = { + [ColorPaletteType.Categorical]: defaultColorPalettes[0].getId(), + [ColorPaletteType.ContinuousSequential]: defaultContinuousSequentialColorPalettes[0].getId(), + [ColorPaletteType.ContinuousDiverging]: defaultContinuousDivergingColorPalettes[0].getId(), + }; + + this._steps = { + [ColorScaleDiscreteSteps.Sequential]: 10, + [ColorScaleDiscreteSteps.Diverging]: 10, + }; this.loadSelectedColorPaletteIdsFromLocalStorage(); this.loadStepsFromLocalStorage(); } + serializeState(): SerializedWorkbenchSettings { + return { + selectedColorPalettes: this._selectedColorPalettes, + discreteColorScaleSteps: this._steps, + }; + } + + deserializeState(serializedState: SerializedWorkbenchSettings): void { + this._selectedColorPalettes = serializedState.selectedColorPalettes; + this._steps = serializedState.discreteColorScaleSteps; + } + + getPublishSubscribeDelegate(): PublishSubscribeDelegate { + return this._publishSubscribeDelegate; + } + + makeSnapshotGetter(topic: T): () => WorkbenchSettingsTopicPayloads[T] { + const snapshotGetter = (): any => { + if (topic === WorkbenchSettingsTopic.SelectedColorPalettes) { + return this._selectedColorPalettes; + } + + throw new Error(`No snapshot getter for topic ${topic}`); + }; + + return snapshotGetter; + } + private loadSelectedColorPaletteIdsFromLocalStorage(): void { const selectedColorPalettesString = localStorage.getItem("selectedColorPalettes"); if (!selectedColorPalettesString) { @@ -41,7 +126,7 @@ export class PrivateWorkbenchSettings extends WorkbenchSettings { } } - this.notifySubscribers(WorkbenchSettingsEvents.ColorPalettesChanged); + this._publishSubscribeDelegate.notifySubscribers(WorkbenchSettingsTopic.SelectedColorPalettes); } private loadStepsFromLocalStorage(): void { @@ -55,7 +140,7 @@ export class PrivateWorkbenchSettings extends WorkbenchSettings { this._steps = steps; - this.notifySubscribers(WorkbenchSettingsEvents.ColorPalettesChanged); + this._publishSubscribeDelegate.notifySubscribers(WorkbenchSettingsTopic.SelectedColorPalettes); } private storeSelectedColorPaletteIdsToLocalStorage(): void { @@ -66,15 +151,6 @@ export class PrivateWorkbenchSettings extends WorkbenchSettings { localStorage.setItem("discreteColorScaleSteps", JSON.stringify(this._steps)); } - private notifySubscribers(event: WorkbenchSettingsEvents): void { - const subscribers = this._subscribersMap[event]; - if (!subscribers) return; - - subscribers.forEach((subscriber) => { - subscriber(); - }); - } - getSelectedColorPalette(type: ColorPaletteType): ColorPalette { const colorPalette = this._colorPalettes[type].find((el) => el.getId() === this._selectedColorPalettes[type]); if (!colorPalette) { @@ -83,6 +159,10 @@ export class PrivateWorkbenchSettings extends WorkbenchSettings { return colorPalette; } + getColorPalettes(): Record { + return this._colorPalettes; + } + getSelectedColorPaletteIds(): Record { return this._selectedColorPalettes; } @@ -90,7 +170,7 @@ export class PrivateWorkbenchSettings extends WorkbenchSettings { setSelectedColorPaletteId(type: ColorPaletteType, id: string): void { this._selectedColorPalettes[type] = id; this.storeSelectedColorPaletteIdsToLocalStorage(); - this.notifySubscribers(WorkbenchSettingsEvents.ColorPalettesChanged); + this._publishSubscribeDelegate.notifySubscribers(WorkbenchSettingsTopic.SelectedColorPalettes); } getSteps(): { @@ -106,7 +186,7 @@ export class PrivateWorkbenchSettings extends WorkbenchSettings { }): void { this._steps = steps; this.storeStepsToLocalStorage(); - this.notifySubscribers(WorkbenchSettingsEvents.ColorPalettesChanged); + this._publishSubscribeDelegate.notifySubscribers(WorkbenchSettingsTopic.SelectedColorPalettes); } getStepsForType(type: ColorScaleDiscreteSteps.Diverging | ColorScaleDiscreteSteps.Sequential): number { @@ -116,6 +196,48 @@ export class PrivateWorkbenchSettings extends WorkbenchSettings { setStepsForType(type: ColorScaleDiscreteSteps.Diverging | ColorScaleDiscreteSteps.Sequential, steps: number): void { this._steps[type] = steps; this.storeStepsToLocalStorage(); - this.notifySubscribers(WorkbenchSettingsEvents.ColorPalettesChanged); + this._publishSubscribeDelegate.notifySubscribers(WorkbenchSettingsTopic.SelectedColorPalettes); + } + + makeColorSet(): ColorSet { + return new ColorSet(this.getSelectedColorPalette(ColorPaletteType.Categorical)); + } + + makeDiscreteColorScale(options: { gradientType: ColorScaleGradientType }): ColorScale { + const optionsWithDefaults: ColorScaleOptions = { + type: ColorScaleType.Discrete, + colorPalette: this.getSelectedColorPalette( + options.gradientType === ColorScaleGradientType.Sequential + ? ColorPaletteType.ContinuousSequential + : ColorPaletteType.ContinuousDiverging, + ), + gradientType: options.gradientType, + steps: this.getSteps()[ + options.gradientType === ColorScaleGradientType.Sequential + ? ColorScaleDiscreteSteps.Sequential + : ColorScaleDiscreteSteps.Diverging + ], + }; + + return new ColorScale(optionsWithDefaults); + } + + makeContinuousColorScale(options: { gradientType: ColorScaleGradientType }): ColorScale { + const optionsWithDefaults: ColorScaleOptions = { + type: ColorScaleType.Continuous, + colorPalette: this.getSelectedColorPalette( + options.gradientType === ColorScaleGradientType.Sequential + ? ColorPaletteType.ContinuousSequential + : ColorPaletteType.ContinuousDiverging, + ), + gradientType: options.gradientType, + steps: this.getSteps()[ + options.gradientType === ColorScaleGradientType.Sequential + ? ColorScaleDiscreteSteps.Sequential + : ColorScaleDiscreteSteps.Diverging + ], + }; + + return new ColorScale(optionsWithDefaults); } } diff --git a/frontend/src/framework/internal/WorkbenchSession/Dashboard.ts b/frontend/src/framework/internal/WorkbenchSession/Dashboard.ts new file mode 100644 index 000000000..be7a7ff67 --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSession/Dashboard.ts @@ -0,0 +1,444 @@ +import type { JTDSchemaType } from "ajv/dist/core"; +import { v4 } from "uuid"; + +import { SyncSettingKey } from "@framework/SyncSettings"; +import type { Template } from "@framework/TemplateRegistry"; +import { PublishSubscribeDelegate, type PublishSubscribe } from "@lib/utils/PublishSubscribeDelegate"; + +import type { AtomStoreMaster } from "../../AtomStoreMaster"; +import type { ModuleInstance, ModuleInstanceSerializedState } from "../../ModuleInstance"; +import { ModuleRegistry } from "../../ModuleRegistry"; + +export type LayoutElement = { + moduleInstanceId?: string; + moduleName: string; + relX: number; + relY: number; + relHeight: number; + relWidth: number; + minimized?: boolean; + maximized?: boolean; +}; + +export type ModuleInstanceStateAndLayoutInfo = ModuleInstanceSerializedState & { + layoutInfo: Omit; +}; + +export type SerializedDashboard = { + id: string; + name: string; + description?: string; + activeModuleInstanceId: string | null; + moduleInstances: ModuleInstanceStateAndLayoutInfo[]; +}; + +const layoutElementSchema: JTDSchemaType> = { + properties: { + relX: { type: "float32" }, + relY: { type: "float32" }, + relHeight: { type: "float32" }, + relWidth: { type: "float32" }, + }, + optionalProperties: { + minimized: { type: "boolean" }, + maximized: { type: "boolean" }, + }, +} as const; + +const moduleInstanceSchema: JTDSchemaType = { + properties: { + id: { type: "string" }, + name: { type: "string" }, + serializedState: { + optionalProperties: { + view: { type: "string" }, + settings: { type: "string" }, + }, + nullable: true, + }, + syncedSettingKeys: { + elements: { + enum: Object.values(SyncSettingKey), + }, + }, + dataChannelReceiverSubscriptions: { + elements: { + properties: { + idString: { type: "string" }, + listensToModuleInstanceId: { type: "string" }, + channelIdString: { type: "string" }, + contentIdStrings: { + elements: { type: "string" }, + }, + }, + }, + }, + layoutInfo: layoutElementSchema, + }, +} as const; + +export const DASHBOARD_JTD_SCHEMA: JTDSchemaType = { + properties: { + id: { type: "string" }, + name: { type: "string" }, + activeModuleInstanceId: { type: "string", nullable: true }, + moduleInstances: { + elements: moduleInstanceSchema, + }, + }, + optionalProperties: { + description: { type: "string" }, + }, +} as const; + +export enum DashboardTopic { + Layout = "Layout", + ModuleInstances = "ModuleInstances", + ActiveModuleInstanceId = "ActiveModuleInstanceId", +} + +export type DashboardTopicPayloads = { + [DashboardTopic.Layout]: LayoutElement[]; + [DashboardTopic.ModuleInstances]: ModuleInstance[]; + [DashboardTopic.ActiveModuleInstanceId]: string | null; +}; + +export class Dashboard implements PublishSubscribe { + private _publishSubscribeDelegate = new PublishSubscribeDelegate(); + + private _id: string; + private _name: string; + private _description?: string; + private _layout: LayoutElement[] = []; + private _moduleInstances: ModuleInstance[] = []; + private _activeModuleInstanceId: string | null = null; + private _atomStoreMaster: AtomStoreMaster; + + constructor(atomStoreMaster: AtomStoreMaster) { + this._id = v4(); + this._name = "New Dashboard"; + this._atomStoreMaster = atomStoreMaster; + } + + getPublishSubscribeDelegate(): PublishSubscribeDelegate { + return this._publishSubscribeDelegate; + } + + makeSnapshotGetter(topic: T): () => DashboardTopicPayloads[T] { + const snapshotGetter = (): any => { + if (topic === DashboardTopic.Layout) { + return this._layout; + } + if (topic === DashboardTopic.ModuleInstances) { + return this._moduleInstances; + } + if (topic === DashboardTopic.ActiveModuleInstanceId) { + return this._activeModuleInstanceId; + } + + throw new Error(`No snapshot getter for topic ${topic}`); + }; + + return snapshotGetter; + } + + getId(): string { + return this._id; + } + + getName(): string { + return this._name; + } + + getLayout(): LayoutElement[] { + return this._layout; + } + + setLayout(layout: LayoutElement[]): void { + this._layout = layout; + this._publishSubscribeDelegate.notifySubscribers(DashboardTopic.Layout); + } + + getModuleInstances(): ModuleInstance[] { + return this._moduleInstances; + } + + serializeState(): SerializedDashboard { + const moduleInstances = this._moduleInstances.map((moduleInstance) => { + const moduleState = moduleInstance.serialize(); + + const layoutInfo = this._layout.find((el) => el.moduleInstanceId === moduleInstance.getId()); + + if (!layoutInfo) { + throw new Error(`Layout info for module instance ${moduleInstance.getId()} not found`); + } + + return { + ...moduleState, + layoutInfo: { + relX: layoutInfo.relX, + relY: layoutInfo.relY, + relHeight: layoutInfo.relHeight, + relWidth: layoutInfo.relWidth, + minimized: layoutInfo.minimized ?? false, + maximized: layoutInfo.maximized ?? false, + }, + }; + }); + + return { + id: this._id, + name: this._name, + description: this._description, + activeModuleInstanceId: this._activeModuleInstanceId, + moduleInstances, + }; + } + + async deserializeState(serializedDashboard: SerializedDashboard): Promise { + this._id = serializedDashboard.id; + this._name = serializedDashboard.name; + this._description = serializedDashboard.description; + + this.clearLayout(); + + for (const serializedInstance of serializedDashboard.moduleInstances) { + const { id, name } = serializedInstance; + + const module = ModuleRegistry.getModule(name); + if (!module) { + throw new Error(`Module ${name} not found`); + } + const moduleInstance = module.makeInstance(id, this._atomStoreMaster); + this.registerModuleInstance(moduleInstance); + } + + // Doing this after all module instances have been registered + // ensures that the module instances are available for data channel initialization. + for (const serializedInstance of serializedDashboard.moduleInstances) { + const { id, name, layoutInfo } = serializedInstance; + const moduleInstance = this.getModuleInstance(id); + if (!moduleInstance) { + throw new Error(`Module instance with ID ${id} not found`); + } + + moduleInstance.initiateDeserialization(serializedInstance, this); + + this._layout.push({ + moduleInstanceId: id, + moduleName: name, + relX: layoutInfo.relX, + relY: layoutInfo.relY, + relHeight: layoutInfo.relHeight, + relWidth: layoutInfo.relWidth, + minimized: layoutInfo.minimized, + maximized: layoutInfo.maximized, + }); + } + + this.setActiveModuleInstanceId(serializedDashboard.activeModuleInstanceId); + + this._publishSubscribeDelegate.notifySubscribers(DashboardTopic.Layout); + } + + clearLayout(): void { + for (const moduleInstance of this._moduleInstances) { + moduleInstance.beforeDestroy(); + } + this._moduleInstances = []; + this._layout = []; + this._publishSubscribeDelegate.notifySubscribers(DashboardTopic.Layout); + } + + registerModuleInstance(moduleInstance: ModuleInstance): void { + this._moduleInstances = [...this._moduleInstances, moduleInstance]; + this._publishSubscribeDelegate.notifySubscribers(DashboardTopic.ModuleInstances); + } + + makeAndAddModuleInstance(moduleName: string): ModuleInstance { + const module = ModuleRegistry.getModule(moduleName); + if (!module) { + throw new Error(`Module ${moduleName} not found`); + } + + const id = v4(); + this._atomStoreMaster.makeAtomStoreForModuleInstance(id); + const moduleInstance = module.makeInstance(id, this._atomStoreMaster); + this._moduleInstances = [...this._moduleInstances, moduleInstance]; + if (this._moduleInstances.length === 1) { + this._activeModuleInstanceId = moduleInstance.getId(); + } + + this._activeModuleInstanceId = moduleInstance.getId(); + this._publishSubscribeDelegate.notifySubscribers(DashboardTopic.ModuleInstances); + this._publishSubscribeDelegate.notifySubscribers(DashboardTopic.Layout); + return moduleInstance; + } + + removeModuleInstance(moduleInstanceId: string): void { + const moduleInstance = this.getModuleInstance(moduleInstanceId); + + if (moduleInstance) { + const manager = moduleInstance.getChannelManager(); + + moduleInstance.unload(); + manager.unregisterAllChannels(); + manager.unregisterAllReceivers(); + } + + this._moduleInstances = this._moduleInstances.filter((el) => el.getId() !== moduleInstanceId); + + this._atomStoreMaster.removeAtomStoreForModuleInstance(moduleInstanceId); + + if (this._activeModuleInstanceId === moduleInstanceId) { + this._activeModuleInstanceId = null; + } + this._publishSubscribeDelegate.notifySubscribers(DashboardTopic.ModuleInstances); + this._publishSubscribeDelegate.notifySubscribers(DashboardTopic.Layout); + } + + getModuleInstance(id: string): ModuleInstance | undefined { + return this._moduleInstances.find((moduleInstance) => moduleInstance.getId() === id); + } + + setActiveModuleInstanceId(moduleInstanceId: string | null): void { + if (moduleInstanceId !== null && !this.getModuleInstance(moduleInstanceId)) { + throw new Error(`Module instance with ID ${moduleInstanceId} not found`); + } + this._activeModuleInstanceId = moduleInstanceId; + this._publishSubscribeDelegate.notifySubscribers(DashboardTopic.ActiveModuleInstanceId); + } + + getActiveModuleInstanceId(): string | null { + return this._activeModuleInstanceId; + } + + static fromPersistedState(serializedDashboard: SerializedDashboard, atomStoreMaster: AtomStoreMaster): Dashboard { + const dashboard = new Dashboard(atomStoreMaster); + dashboard._id = serializedDashboard.id; + dashboard._name = serializedDashboard.name; + dashboard._description = serializedDashboard.description; + dashboard._activeModuleInstanceId = serializedDashboard.activeModuleInstanceId; + + const layout: LayoutElement[] = []; + + for (const serializedInstance of serializedDashboard.moduleInstances) { + const { id, name } = serializedInstance; + + const module = ModuleRegistry.getModule(name); + if (!module) { + throw new Error(`Module ${name} not found`); + } + const moduleInstance = module.makeInstance(id, atomStoreMaster); + dashboard.registerModuleInstance(moduleInstance); + } + + // Doing this after all module instances have been registered + // ensures that the module instances are available for data channel initialization. + for (const serializedInstance of serializedDashboard.moduleInstances) { + const { id, name, layoutInfo } = serializedInstance; + const moduleInstance = dashboard.getModuleInstance(id); + if (!moduleInstance) { + throw new Error(`Module instance with ID ${id} not found`); + } + + moduleInstance.initiateDeserialization(serializedInstance, dashboard); + + layout.push({ + moduleInstanceId: id, + moduleName: name, + relX: layoutInfo.relX, + relY: layoutInfo.relY, + relHeight: layoutInfo.relHeight, + relWidth: layoutInfo.relWidth, + minimized: layoutInfo.minimized, + maximized: layoutInfo.maximized, + }); + } + + dashboard.setLayout(layout); + + return dashboard; + } + + static async fromTemplate(template: Template, atomStoreMaster: AtomStoreMaster): Promise { + const dashboard = new Dashboard(atomStoreMaster); + dashboard._id = v4(); + dashboard._description = template.description; + + const layout: LayoutElement[] = []; + const moduleInstances: ModuleInstance[] = []; + const moduleInstanceRefMap: Record> = {}; + + for (const module of template.moduleInstances) { + const moduleInstance = await dashboard.makeAndAddModuleInstance(module.moduleName); + layout.push({ + moduleInstanceId: moduleInstance.getId(), + moduleName: module.moduleName, + relX: module.layout.relX, + relY: module.layout.relY, + relHeight: module.layout.relHeight, + relWidth: module.layout.relWidth, + minimized: module.layout.minimized, + maximized: module.layout.maximized, + }); + + if (module.syncedSettings) { + for (const syncedSetting of module.syncedSettings) { + moduleInstance.addSyncedSetting(syncedSetting); + } + } + + if (module.instanceRef) { + moduleInstanceRefMap[module.instanceRef] = moduleInstance; + } + + if (module.initialState) { + moduleInstance.initiateTemplateStateApplication(module.initialState); + } + + moduleInstances.push(moduleInstance); + } + + for (const [idx, module] of template.moduleInstances.entries()) { + const moduleInstance = moduleInstances[idx]; + if (!moduleInstance) { + throw new Error(`Module instance with reference ${module.instanceRef} not found`); + } + + if (module.dataChannelsToInitialSettingsMapping) { + for (const [key, dataChannelConfig] of Object.entries(module.dataChannelsToInitialSettingsMapping)) { + const listensToModuleInstance = moduleInstanceRefMap[dataChannelConfig.listensToInstanceRef]; + if (!listensToModuleInstance) { + throw new Error( + `Module instance with reference ${dataChannelConfig.listensToInstanceRef} not found`, + ); + } + + const channel = listensToModuleInstance + .getChannelManager() + .getChannel(dataChannelConfig.channelIdString); + + if (!channel) { + throw new Error( + `Channel with ID ${dataChannelConfig.channelIdString} not found in module instance ${moduleInstance.getId()}`, + ); + } + + const receiver = moduleInstance.getChannelManager().getReceiver(key); + if (!receiver) { + throw new Error( + `Receiver with ID ${key} not found in module instance ${moduleInstance.getId()}`, + ); + } + + receiver.subscribeToChannel(channel, "All"); + } + } + } + + dashboard.setLayout(layout); + + return dashboard; + } +} diff --git a/frontend/src/framework/internal/WorkbenchSession/EnsembleUpdateMonitor.ts b/frontend/src/framework/internal/WorkbenchSession/EnsembleUpdateMonitor.ts new file mode 100644 index 000000000..a1a175168 --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSession/EnsembleUpdateMonitor.ts @@ -0,0 +1,174 @@ +import { globalLog } from "@src/Log"; +import type { QueryClient } from "@tanstack/query-core"; + +import { postGetTimestampsForEnsemblesOptions, type EnsembleIdent_api } from "@api"; +import { EnsembleTimestampsStore, type EnsembleTimestamps } from "@framework/EnsembleTimestampsStore"; +import type { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; +import type { Workbench } from "@framework/Workbench"; + +const logger = globalLog.registerLogger("EnsembleUpdateMonitor"); + +const ENSEMBLE_POLLING_INTERVAL = 10000; // 60 seconds + +export class EnsembleUpdateMonitor { + private _queryClient: QueryClient; + private _pollingEnabled: boolean = false; + private _isRunning: boolean = false; + private _pollingTimeout: ReturnType | null = null; + private _lastPollTimestamp: number | null = null; + private _workbench: Workbench; + + constructor(queryClient: QueryClient, workbench: Workbench) { + this._queryClient = queryClient; + this._workbench = workbench; + } + + startPolling() { + if (this._pollingEnabled) { + return; // Already polling + } + + // This shouldn't happen, but we check just in case + if (this._pollingTimeout) { + console.warn("Found a waiting polling call, even though polling was disabled"); + clearTimeout(this._pollingTimeout); + } + + logger.console?.log("checkForEnsembleUpdate - initializing..."); + this._pollingEnabled = true; + this.recursivelyQueueEnsemblePolling(); + } + + stopPolling() { + if (!this._pollingEnabled) { + return; // Not currently polling + } + + logger.console?.log("checkForEnsembleUpdate - stopping..."); + this._pollingEnabled = false; + + if (this._pollingTimeout) { + clearTimeout(this._pollingTimeout); + this._pollingTimeout = null; + } + } + + async pollImmediately() { + await this.pollForUpdatedEnsembles(); + } + + private async recursivelyQueueEnsemblePolling() { + if (!this._pollingEnabled) { + return; // Stop if polling is disabled + } + + const now = Date.now(); + const elapsed = this._lastPollTimestamp ? now - this._lastPollTimestamp : Infinity; + + if (elapsed < ENSEMBLE_POLLING_INTERVAL) { + const wait = ENSEMBLE_POLLING_INTERVAL - elapsed; + logger.console?.log(`Polling skipped: only ${elapsed}ms elapsed since last poll. Waiting ${wait}ms...`); + this._pollingTimeout = setTimeout(() => { + this.recursivelyQueueEnsemblePolling(); + }, wait); + return; + } + + await this.pollForUpdatedEnsembles(); + + if (!this._pollingEnabled) { + return; // Stop if polling is disabled + } + + logger.console?.log("checkForEnsembleUpdate - queuing next..."); + + this._pollingTimeout = setTimeout(() => { + this.recursivelyQueueEnsemblePolling(); + }, ENSEMBLE_POLLING_INTERVAL); + } + + private async pollForUpdatedEnsembles() { + if (this._isRunning) { + console.warn("Ensemble polling is already running, skipping this cycle."); + return; // Prevent concurrent polling + } + this._isRunning = true; + + logger.console?.log(`checkForEnsembleUpdate - fetching...`); + + try { + const workbenchSession = this._workbench.getWorkbenchSession(); + if (!workbenchSession) { + console.warn(`No workbench session found, exiting...`); + return; + } + + const allRegularEnsembleIdents: Set = new Set( + workbenchSession + .getEnsembleSet() + .getRegularEnsembleArray() + .map((ens) => ens.getIdent()), + ); + + // Collect all delta ensembles' reference and comparison ensembles + const deltaEnsembles = workbenchSession.getEnsembleSet().getDeltaEnsembleArray(); + for (const deltaEnsemble of deltaEnsembles) { + allRegularEnsembleIdents.add(deltaEnsemble.getComparisonEnsembleIdent()); + allRegularEnsembleIdents.add(deltaEnsemble.getReferenceEnsembleIdent()); + } + + // If there are no ensembles to check, we can exit early + if (allRegularEnsembleIdents.size === 0) { + logger.console?.log(`checkForEnsembleUpdate - no ensembles to check, exiting...`); + return; + } + + // Fetch the latest timestamps for all ensembles + const latestTimestamps = await this.fetchLatestEnsembleTimestamps(Array.from(allRegularEnsembleIdents)); + + if (latestTimestamps.length !== allRegularEnsembleIdents.size) { + console.warn( + `Expected ${allRegularEnsembleIdents.size} timestamps, received ${latestTimestamps.length}.`, + ); + } + + const latestTimestampsMap = new Map(); + + // Update the ensemble timestamps map + for (const [ident, timestamps] of latestTimestamps) { + latestTimestampsMap.set(ident.toString(), timestamps); + } + + // Update the EnsembleTimestampsStore with the latest timestamps + EnsembleTimestampsStore.setAll(latestTimestampsMap); + + logger.console?.log(`checkForEnsembleUpdate - fetched and updated timestamps for ensembles.`); + } catch (error) { + console.error(`Error during ensemble polling:`, error); + } finally { + this._lastPollTimestamp = Date.now(); + this._isRunning = false; + } + } + + private async fetchLatestEnsembleTimestamps( + ensembleIdents: RegularEnsembleIdent[], + ): Promise<[RegularEnsembleIdent, EnsembleTimestamps][]> { + const idents = ensembleIdents.map((ens) => ({ + caseUuid: ens.getCaseUuid(), + ensembleName: ens.getEnsembleName(), + })); + + const timestamps = await this._queryClient.fetchQuery({ + ...postGetTimestampsForEnsemblesOptions({ body: idents }), + staleTime: 0, + gcTime: 0, + }); + + return ensembleIdents.map((ident, i) => [ident, timestamps[i]]); + } + + dispose() { + this.stopPolling(); + } +} diff --git a/frontend/src/framework/internal/WorkbenchSession/PrivateWorkbenchSession.ts b/frontend/src/framework/internal/WorkbenchSession/PrivateWorkbenchSession.ts new file mode 100644 index 000000000..d96fa3209 --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSession/PrivateWorkbenchSession.ts @@ -0,0 +1,361 @@ +import type { QueryClient } from "@tanstack/query-core"; + +import { AtomStoreMaster } from "@framework/AtomStoreMaster"; +import { EnsembleSet } from "@framework/EnsembleSet"; +import { EnsembleTimestampsStore } from "@framework/EnsembleTimestampsStore"; +import { EnsembleSetAtom, RealizationFilterSetAtom } from "@framework/GlobalAtoms"; +import { Dashboard, type SerializedDashboard } from "@framework/internal/WorkbenchSession/Dashboard"; +import { RealizationFilterSet } from "@framework/RealizationFilterSet"; +import { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; +import { UserCreatedItems, type SerializedUserCreatedItems } from "@framework/UserCreatedItems"; +import { PublishSubscribeDelegate, type PublishSubscribe } from "@lib/utils/PublishSubscribeDelegate"; + +import { + loadMetadataFromBackendAndCreateEnsembleSet, + type UserEnsembleSetting, + type UserDeltaEnsembleSetting, +} from "../EnsembleSetLoader"; +import { PrivateWorkbenchSettings, type SerializedWorkbenchSettings } from "../PrivateWorkbenchSettings"; + +import { isPersisted, isSnapshot, type WorkbenchSessionDataContainer } from "./WorkbenchSessionDataContainer"; + +export type SerializedRegularEnsemble = { + ensembleIdent: string; + name: string | null; + color: string; +}; + +export type SerializedDeltaEnsemble = { + comparisonEnsembleIdent: string; + referenceEnsembleIdent: string; + name: string | null; + color: string; +}; + +export type SerializedEnsembleSet = { + regularEnsembles: SerializedRegularEnsemble[]; + deltaEnsembles: SerializedDeltaEnsemble[]; +}; + +export type WorkbenchSessionContent = { + activeDashboardId: string | null; + dashboards: SerializedDashboard[]; + ensembleSet: SerializedEnsembleSet; + settings: SerializedWorkbenchSettings; + userCreatedItems: SerializedUserCreatedItems; +}; + +export type WorkbenchSessionMetadata = { + title: string; + description?: string; + updatedAt: number; // Timestamp of the last modification + createdAt: number; // Timestamp of creation + hash?: string; // Optional hash for content integrity + lastModifiedMs: number; // Last modified timestamp for internal use +}; + +export enum PrivateWorkbenchSessionTopic { + ENSEMBLE_SET = "EnsembleSet", + IS_ENSEMBLE_SET_LOADING = "EnsembleSetLoadingState", + REALIZATION_FILTER_SET = "RealizationFilterSet", + ACTIVE_DASHBOARD = "ActiveDashboard", + METADATA = "Metadata", + DASHBOARDS = "Dashboards", + IS_PERSISTED = "IsPersisted", + IS_SNAPSHOT = "IsSnapshot", +} + +export type PrivateWorkbenchSessionTopicPayloads = { + [PrivateWorkbenchSessionTopic.IS_ENSEMBLE_SET_LOADING]: boolean; + [PrivateWorkbenchSessionTopic.ENSEMBLE_SET]: EnsembleSet; + [PrivateWorkbenchSessionTopic.REALIZATION_FILTER_SET]: RealizationFilterSet; + [PrivateWorkbenchSessionTopic.ACTIVE_DASHBOARD]: Dashboard; + [PrivateWorkbenchSessionTopic.METADATA]: WorkbenchSessionMetadata; + [PrivateWorkbenchSessionTopic.DASHBOARDS]: Dashboard[]; + [PrivateWorkbenchSessionTopic.IS_PERSISTED]: boolean; + [PrivateWorkbenchSessionTopic.IS_SNAPSHOT]: boolean; +}; + +export class PrivateWorkbenchSession implements PublishSubscribe { + private _publishSubscribeDelegate = new PublishSubscribeDelegate(); + + private _id: string | null = null; + private _isPersisted: boolean = false; + private _isSnapshot: boolean; + private _atomStoreMaster: AtomStoreMaster; + private _queryClient: QueryClient; + private _dashboards: Dashboard[] = []; + private _activeDashboardId: string | null = null; + private _ensembleSet: EnsembleSet = new EnsembleSet([]); + private _realizationFilterSet = new RealizationFilterSet(); + private _userCreatedItems: UserCreatedItems; + private _metadata: WorkbenchSessionMetadata = { + title: "New Workbench Session", + createdAt: Date.now(), + updatedAt: Date.now(), + lastModifiedMs: Date.now(), + }; + private _isEnsembleSetLoading: boolean = false; + private _loadedFromLocalStorage: boolean = false; + private _settings: PrivateWorkbenchSettings = new PrivateWorkbenchSettings(); + + constructor(queryClient: QueryClient, isSnapshot = false) { + this._atomStoreMaster = new AtomStoreMaster(); + this._queryClient = queryClient; + this._userCreatedItems = new UserCreatedItems(this._atomStoreMaster); + this._atomStoreMaster.setAtomValue(RealizationFilterSetAtom, this._realizationFilterSet); + this._isSnapshot = isSnapshot; + } + + getIsLoadedFromLocalStorage(): boolean { + return this._loadedFromLocalStorage; + } + + setLoadedFromLocalStorage(loaded: boolean): void { + this._loadedFromLocalStorage = loaded; + } + + getWorkbenchSettings(): PrivateWorkbenchSettings { + return this._settings; + } + + getAtomStoreMaster(): AtomStoreMaster { + return this._atomStoreMaster; + } + + getId(): string | null { + return this._id; + } + + setId(id: string): void { + if (this._id) throw new Error("Session ID already set"); + this._id = id; + } + + isSnapshot(): boolean { + return this._isSnapshot; + } + + setIsSnapshot(isSnapshot: boolean): void { + this._isSnapshot = isSnapshot; + this._publishSubscribeDelegate.notifySubscribers(PrivateWorkbenchSessionTopic.IS_SNAPSHOT); + } + + getMetadata(): WorkbenchSessionMetadata { + return this._metadata; + } + + setMetadata(metadata: WorkbenchSessionMetadata): void { + this._metadata = metadata; + this._publishSubscribeDelegate.notifySubscribers(PrivateWorkbenchSessionTopic.METADATA); + } + + updateMetadata(update: Partial>, notify = true): void { + this._metadata = { ...this._metadata, ...update }; + + if (!notify) { + return; + } + + this._publishSubscribeDelegate.notifySubscribers(PrivateWorkbenchSessionTopic.METADATA); + } + + getContent(): WorkbenchSessionContent { + return { + activeDashboardId: this._activeDashboardId, + settings: this._settings.serializeState(), + userCreatedItems: this._userCreatedItems.serializeState(), + dashboards: this._dashboards.map((d) => d.serializeState()), + ensembleSet: { + regularEnsembles: this._ensembleSet.getRegularEnsembleArray().map( + (e): SerializedRegularEnsemble => ({ + ensembleIdent: e.getIdent().toString(), + name: e.getCustomName(), + color: e.getColor(), + }), + ), + deltaEnsembles: this._ensembleSet.getDeltaEnsembleArray().map( + (e): SerializedDeltaEnsemble => ({ + comparisonEnsembleIdent: e.getComparisonEnsembleIdent().toString(), + referenceEnsembleIdent: e.getReferenceEnsembleIdent().toString(), + name: e.getCustomName(), + color: e.getColor(), + }), + ), + }, + }; + } + + async loadContent(content: WorkbenchSessionContent): Promise { + this._isPersisted = this._id !== null; + this._activeDashboardId = content.activeDashboardId; + const dashboards: Dashboard[] = []; + for (const serializedDashboard of content.dashboards) { + const dashboard = Dashboard.fromPersistedState(serializedDashboard, this._atomStoreMaster); + dashboards.push(dashboard); + } + this.setDashboards(dashboards); + + this._settings.deserializeState(content.settings); + this._userCreatedItems.deserializeState(content.userCreatedItems); + + const userEnsembleSettings: UserEnsembleSetting[] = content.ensembleSet.regularEnsembles.map((e) => ({ + ensembleIdent: RegularEnsembleIdent.fromString(e.ensembleIdent), + customName: e.name, + color: e.color, + })); + + const userDeltaEnsembleSettings: UserDeltaEnsembleSetting[] = content.ensembleSet.deltaEnsembles.map((e) => ({ + comparisonEnsembleIdent: RegularEnsembleIdent.fromString(e.comparisonEnsembleIdent), + referenceEnsembleIdent: RegularEnsembleIdent.fromString(e.referenceEnsembleIdent), + customName: e.name, + color: e.color, + })); + + await this.loadAndSetupEnsembleSet(userEnsembleSettings, userDeltaEnsembleSettings); + } + + async loadAndSetupEnsembleSet( + regularEnsembleSettings: UserEnsembleSetting[], + deltaEnsembleSettings: UserDeltaEnsembleSetting[], + ): Promise { + this.setEnsembleSetLoading(true); + const newSet = await loadMetadataFromBackendAndCreateEnsembleSet( + this._queryClient, + regularEnsembleSettings, + deltaEnsembleSettings, + ); + await this.setEnsembleSet(newSet); + this.setEnsembleSetLoading(false); + } + + private setEnsembleSetLoading(isLoading: boolean) { + this._isEnsembleSetLoading = isLoading; + this._publishSubscribeDelegate.notifySubscribers(PrivateWorkbenchSessionTopic.IS_ENSEMBLE_SET_LOADING); + } + + private async setEnsembleSet(set: EnsembleSet) { + this._realizationFilterSet.synchronizeWithEnsembleSet(set); + this._ensembleSet = set; + // Await the update of the EnsembleTimestampsStore with the latest timestamps before notifying any subscribers + this._atomStoreMaster.setAtomValue(EnsembleSetAtom, set); + this._publishSubscribeDelegate.notifySubscribers(PrivateWorkbenchSessionTopic.ENSEMBLE_SET); + this._publishSubscribeDelegate.notifySubscribers(PrivateWorkbenchSessionTopic.REALIZATION_FILTER_SET); + } + + getPublishSubscribeDelegate() { + return this._publishSubscribeDelegate; + } + + makeSnapshotGetter( + topic: T, + ): () => PrivateWorkbenchSessionTopicPayloads[T] { + const snapshotGetter = (): any => { + switch (topic) { + case PrivateWorkbenchSessionTopic.IS_ENSEMBLE_SET_LOADING: + return this._isEnsembleSetLoading; + case PrivateWorkbenchSessionTopic.ENSEMBLE_SET: + return this._ensembleSet; + case PrivateWorkbenchSessionTopic.REALIZATION_FILTER_SET: + return this._realizationFilterSet; + case PrivateWorkbenchSessionTopic.ACTIVE_DASHBOARD: + return this.getActiveDashboard(); + case PrivateWorkbenchSessionTopic.METADATA: + return this._metadata; + case PrivateWorkbenchSessionTopic.DASHBOARDS: + return this._dashboards; + case PrivateWorkbenchSessionTopic.IS_PERSISTED: + return this._isPersisted; + case PrivateWorkbenchSessionTopic.IS_SNAPSHOT: + return this._isSnapshot; + default: + throw new Error(`No snapshot getter implemented for topic ${topic}`); + } + }; + return snapshotGetter; + } + + getActiveDashboard(): Dashboard { + if (!this._activeDashboardId && this._dashboards.length > 0) { + this._activeDashboardId = this._dashboards[0].getId(); + } + const found = this._dashboards.find((d) => d.getId() === this._activeDashboardId); + if (!found) throw new Error("Active dashboard not found"); + return found; + } + + getDashboards(): Dashboard[] { + return this._dashboards; + } + + setDashboards(dashboards: Dashboard[]): void { + this._dashboards = dashboards; + if (dashboards.length > 0) { + this._activeDashboardId = dashboards[0].getId(); + } else { + this._activeDashboardId = null; + } + this._publishSubscribeDelegate.notifySubscribers(PrivateWorkbenchSessionTopic.DASHBOARDS); + this._publishSubscribeDelegate.notifySubscribers(PrivateWorkbenchSessionTopic.ACTIVE_DASHBOARD); + } + + getIsPersisted(): boolean { + return this._isPersisted; + } + + setIsPersisted(val: boolean): void { + this._isPersisted = val; + this._publishSubscribeDelegate.notifySubscribers(PrivateWorkbenchSessionTopic.IS_PERSISTED); + } + + getEnsembleSet(): EnsembleSet { + return this._ensembleSet; + } + + getRealizationFilterSet(): RealizationFilterSet { + return this._realizationFilterSet; + } + + getUserCreatedItems(): UserCreatedItems { + return this._userCreatedItems; + } + + notifyAboutEnsembleRealizationFilterChange(): void { + this._publishSubscribeDelegate.notifySubscribers(PrivateWorkbenchSessionTopic.REALIZATION_FILTER_SET); + } + + makeDefaultDashboard(): void { + const d = new Dashboard(this._atomStoreMaster); + this._dashboards.push(d); + this._activeDashboardId = d.getId(); + this._publishSubscribeDelegate.notifySubscribers(PrivateWorkbenchSessionTopic.DASHBOARDS); + } + + clear(): void { + this._dashboards = []; + this._activeDashboardId = null; + this._ensembleSet = new EnsembleSet([]); + EnsembleTimestampsStore.clear(); + } + + beforeDestroy(): void { + this.clear(); + } + + static async fromDataContainer( + queryClient: QueryClient, + dataContainer: WorkbenchSessionDataContainer, + ): Promise { + const session = new PrivateWorkbenchSession(queryClient, isSnapshot(dataContainer)); + + if (isPersisted(dataContainer)) { + session.setId(dataContainer.id); + session.setIsPersisted(true); + } + + session.setMetadata(dataContainer.metadata); + await session.loadContent(dataContainer.content); + + return session; + } +} diff --git a/frontend/src/framework/internal/WorkbenchSession/SessionUrlService.ts b/frontend/src/framework/internal/WorkbenchSession/SessionUrlService.ts new file mode 100644 index 000000000..1fc67fa0a --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSession/SessionUrlService.ts @@ -0,0 +1,34 @@ +export function buildSessionUrl(sessionId: string): string { + const url = new URL(window.location.href); + + url.pathname = `/session/${sessionId}`; + url.search = ""; // Clear any existing query parameters + url.hash = ""; // Clear any existing hash + return url.toString(); +} + +export function readSessionIdFromUrl(): string | null { + const url = new URL(window.location.href); + const pathParts = url.pathname.split("/"); + const sessionId = pathParts.includes("session") ? pathParts[pathParts.indexOf("session") + 1] : null; + + if (sessionId && /^[a-zA-Z0-9_-]{8}$/.test(sessionId)) { + return sessionId; + } + return null; +} + +export function removeSessionIdFromUrl(): void { + const url = new URL(window.location.href); + const pathParts = url.pathname.split("/"); + const sessionIndex = pathParts.indexOf("session"); + + if (sessionIndex === -1) { + return; + } + + url.pathname = "/"; // Reset to root if no snapshot ID is present + url.search = ""; // Clear any existing query parameters + url.hash = ""; // Clear any existing hash + window.history.pushState({}, "", url.toString()); +} diff --git a/frontend/src/framework/internal/WorkbenchSession/SnapshotLoader.ts b/frontend/src/framework/internal/WorkbenchSession/SnapshotLoader.ts new file mode 100644 index 000000000..faf7bf857 --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSession/SnapshotLoader.ts @@ -0,0 +1,45 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { Ajv } from "ajv/dist/jtd"; + +import { getSnapshotOptions, type Snapshot_api } from "@api"; + +import { workbenchSessionContentSchema } from "./workbenchSession.jtd"; +import { WorkbenchSessionSource, type WorkbenchSessionDataContainer } from "./WorkbenchSessionDataContainer"; + +const ajv = new Ajv(); +const validateContent = ajv.compile(workbenchSessionContentSchema); + +export async function loadSnapshotFromBackend( + queryClient: QueryClient, + snapshotId: string, +): Promise { + const snapshotData = await queryClient.fetchQuery({ + ...getSnapshotOptions({ path: { snapshot_id: snapshotId } }), + }); + + return deserializeFromBackend(snapshotData); +} + +export function deserializeFromBackend(raw: Snapshot_api): WorkbenchSessionDataContainer { + const parsed = JSON.parse(raw.content); + if (!validateContent(parsed)) { + throw new Error(`Backend session validation failed ${validateContent.errors}`); + } + + const snapshot: WorkbenchSessionDataContainer = { + id: raw.id, + isSnapshot: true, + source: WorkbenchSessionSource.BACKEND, + metadata: { + title: raw.metadata.title, + description: raw.metadata.description ?? undefined, + createdAt: new Date(raw.metadata.createdAt).getTime(), + updatedAt: new Date(raw.metadata.updatedAt).getTime(), + hash: raw.metadata.hash, + lastModifiedMs: new Date().getTime(), // Fallback to now if not provided + }, + content: parsed, + }; + + return snapshot; +} diff --git a/frontend/src/framework/internal/WorkbenchSession/SnapshotUrlService.ts b/frontend/src/framework/internal/WorkbenchSession/SnapshotUrlService.ts new file mode 100644 index 000000000..a986c749d --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSession/SnapshotUrlService.ts @@ -0,0 +1,34 @@ +export function buildSnapshotUrl(snapshotId: string): string { + const url = new URL(window.location.href); + + url.pathname = `/snapshot/${snapshotId}`; + url.search = ""; // Clear any existing query parameters + url.hash = ""; // Clear any existing hash + return url.toString(); +} + +export function readSnapshotIdFromUrl(): string | null { + const url = new URL(window.location.href); + const pathParts = url.pathname.split("/"); + const snapshotId = pathParts.includes("snapshot") ? pathParts[pathParts.indexOf("snapshot") + 1] : null; + + if (snapshotId && /^[a-zA-Z0-9_-]{8}$/.test(snapshotId)) { + return snapshotId; + } + return null; +} + +export function removeSnapshotIdFromUrl(): void { + const url = new URL(window.location.href); + const pathParts = url.pathname.split("/"); + const snapshotIndex = pathParts.indexOf("snapshot"); + + if (snapshotIndex === -1) { + return; + } + + url.pathname = "/"; // Reset to root if no snapshot ID is present + url.search = ""; // Clear any existing query parameters + url.hash = ""; // Clear any existing hash + window.history.pushState({}, "", url.toString()); +} diff --git a/frontend/src/framework/internal/WorkbenchSession/WorkbenchSessionDataContainer.ts b/frontend/src/framework/internal/WorkbenchSession/WorkbenchSessionDataContainer.ts new file mode 100644 index 000000000..4da5cc4e8 --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSession/WorkbenchSessionDataContainer.ts @@ -0,0 +1,69 @@ +import type { LayoutElement } from "./Dashboard"; +import type { SerializedWorkbenchSession } from "./WorkbenchSessionSerializer"; + +export enum WorkbenchSessionSource { + LOCAL_STORAGE = "localStorage", + BACKEND = "backend", +} + +export type WorkbenchSessionDataContainer = SerializedWorkbenchSession & + ( + | { + id?: string; // Optional ID for the session (only when stored once), can be used to identify or restore the session + source: WorkbenchSessionSource.LOCAL_STORAGE; // Source of the session data + } + | { + id: string; // Required ID for the session (when stored multiple times), used to identify or restore the session + source: WorkbenchSessionSource.BACKEND; // Source of the session data + isSnapshot: boolean; // Indicates if this session is a snapshot + } + ); + +export function isPersisted( + session: WorkbenchSessionDataContainer, +): session is Extract { + return typeof session.id === "string" && session.id.length > 0; +} + +export function isFromBackend( + session: WorkbenchSessionDataContainer, +): session is Extract { + return session.source === WorkbenchSessionSource.BACKEND; +} + +export function isSnapshot( + session: WorkbenchSessionDataContainer, +): session is Extract { + // Check if the session has the isSnapshot property set to true + if (session.source !== WorkbenchSessionSource.BACKEND) { + return false; // Only backend sessions can be snapshots + } + return session.isSnapshot === true; +} + +export function extractLayout(session: WorkbenchSessionDataContainer): LayoutElement[] { + const activeDashboard = session.content.dashboards.find((d) => d.id === session.content.activeDashboardId); + + if (!activeDashboard) { + return []; + } + + const layout: LayoutElement[] = []; + + for (const serializedInstance of activeDashboard.moduleInstances) { + const { id, name, layoutInfo } = serializedInstance; + + layout.push({ + moduleInstanceId: id, + moduleName: name, + relX: layoutInfo.relX, + relY: layoutInfo.relY, + relHeight: layoutInfo.relHeight, + relWidth: layoutInfo.relWidth, + minimized: layoutInfo.minimized, + maximized: layoutInfo.maximized, + }); + } + + return layout; +} diff --git a/frontend/src/framework/internal/WorkbenchSession/WorkbenchSessionLoader.ts b/frontend/src/framework/internal/WorkbenchSession/WorkbenchSessionLoader.ts new file mode 100644 index 000000000..43e65bf03 --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSession/WorkbenchSessionLoader.ts @@ -0,0 +1,39 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { getSessionOptions } from "@api"; + +import { + localStorageKeyForSessionId, + WORKBENCH_SESSION_LOCAL_STORAGE_KEY_PREFIX, + WORKBENCH_SESSION_LOCAL_STORAGE_KEY_TEMP, +} from "./utils"; +import type { WorkbenchSessionDataContainer } from "./WorkbenchSessionDataContainer"; +import { deserializeFromBackend, deserializeFromLocalStorage } from "./WorkbenchSessionSerializer"; + +export async function loadWorkbenchSessionFromBackend( + queryClient: QueryClient, + sessionId: string, +): Promise { + const sessionData = await queryClient.fetchQuery({ + ...getSessionOptions({ path: { session_id: sessionId } }), + }); + + return deserializeFromBackend(sessionData); +} + +export async function loadWorkbenchSessionFromLocalStorage( + sessionId: string | null, +): Promise { + const key = localStorageKeyForSessionId(sessionId); + return deserializeFromLocalStorage(key); +} + +export async function loadAllWorkbenchSessionsFromLocalStorage(): Promise { + const keys = Object.keys(localStorage).filter( + (key) => + key.startsWith(WORKBENCH_SESSION_LOCAL_STORAGE_KEY_PREFIX) || + key === WORKBENCH_SESSION_LOCAL_STORAGE_KEY_TEMP, + ); + const sessions = await Promise.all(keys.map((key) => deserializeFromLocalStorage(key))); + return sessions.filter((session): session is WorkbenchSessionDataContainer => session !== null); +} diff --git a/frontend/src/framework/internal/WorkbenchSession/WorkbenchSessionPersistenceService.ts b/frontend/src/framework/internal/WorkbenchSession/WorkbenchSessionPersistenceService.ts new file mode 100644 index 000000000..18872bec9 --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSession/WorkbenchSessionPersistenceService.ts @@ -0,0 +1,512 @@ +import { toast } from "react-toastify"; + +import { getSessionMetadataOptions, getSessionsMetadataQueryKey } from "@api"; +import { DashboardTopic } from "@framework/internal/WorkbenchSession/Dashboard"; +import { + PrivateWorkbenchSessionTopic, + type PrivateWorkbenchSession, +} from "@framework/internal/WorkbenchSession/PrivateWorkbenchSession"; +import { ModuleInstanceTopic } from "@framework/ModuleInstance"; +import { UserCreatedItemsEvent } from "@framework/UserCreatedItems"; +import type { Workbench } from "@framework/Workbench"; +import { WorkbenchSettingsTopic } from "@framework/WorkbenchSettings"; +import { PublishSubscribeDelegate, type PublishSubscribe } from "@lib/utils/PublishSubscribeDelegate"; +import { UnsubscribeFunctionsManagerDelegate } from "@lib/utils/UnsubscribeFunctionsManagerDelegate"; + +import { + createSessionWithCacheUpdate, + createSnapshotWithCacheUpdate, + hashJsonString, + localStorageKeyForSessionId, + objectToJsonString, + updateSessionWithCacheUpdate, +} from "./utils"; +import { makeWorkbenchSessionStateString } from "./WorkbenchSessionSerializer"; + +export type WorkbenchSessionPersistenceInfo = { + lastModifiedMs: number; + hasChanges: boolean; + lastPersistedMs: number | null; + backendLastUpdatedMs: number | null; +}; + +export enum WorkbenchSessionPersistenceServiceTopic { + PERSISTENCE_INFO = "PersistenceInfo", +} + +export type WorkbenchSessionPersistenceServiceTopicPayloads = { + [WorkbenchSessionPersistenceServiceTopic.PERSISTENCE_INFO]: WorkbenchSessionPersistenceInfo; +}; + +export class WorkbenchSessionPersistenceService + implements PublishSubscribe +{ + private _publishSubscribeDelegate = new PublishSubscribeDelegate(); + private _unsubscribeFunctionsManagerDelegate = new UnsubscribeFunctionsManagerDelegate(); + private _workbench: Workbench; + private _workbenchSession: PrivateWorkbenchSession | null = null; + private _lastPersistedHash: string | null = null; + private _currentHash: string | null = null; + private _currentStateString: string | null = null; + private _persistenceInfo: WorkbenchSessionPersistenceInfo = { + lastModifiedMs: 0, + hasChanges: false, + lastPersistedMs: null, + backendLastUpdatedMs: null, + }; + private _fetchingInterval: ReturnType | null = null; + private _pullDebounceTimeout: ReturnType | null = null; + private _pullInProgress = false; + private _pullCounter = 0; + + private _lastPersistedMs: number | null = null; + private _lastModifiedMs: number = 0; + private _backendLastUpdatedMs: number | null = null; + + constructor(workbench: Workbench) { + this._workbench = workbench; + } + + async setWorkbenchSession(session: PrivateWorkbenchSession) { + this.unsubscribeFromSessionUpdates(); + + this._workbenchSession = session; + + if (session.isSnapshot()) { + return; // No need to persist snapshots + } + + this._currentStateString = makeWorkbenchSessionStateString(this._workbenchSession); + this._currentHash = await hashJsonString(this._currentStateString); + this._lastPersistedMs = session.getMetadata().updatedAt; + this._lastModifiedMs = session.getMetadata().lastModifiedMs; + + if (!session.getIsLoadedFromLocalStorage()) { + this._lastPersistedHash = this._currentHash; + } else { + this._lastPersistedHash = null; + } + + this.updatePersistenceInfo(); + + this.subscribeToSessionChanges(); + this.subscribeToDashboardUpdates(); + this.subscribeToModuleInstanceUpdates(); + + if (this._fetchingInterval) { + clearInterval(this._fetchingInterval); + } + this._fetchingInterval = setInterval(() => { + this.repeatedlyFetchSessionFromBackend(); + }, 10000); // Fetch every 10 seconds + } + + async repeatedlyFetchSessionFromBackend() { + const queryClient = this._workbench.getQueryClient(); + if (!this._workbenchSession) { + return; + } + + const sessionId = this._workbenchSession.getId(); + if (!sessionId || !this._workbenchSession.getIsPersisted()) { + return; + } + + try { + const sessionBackendMetadata = await queryClient.fetchQuery({ + ...getSessionMetadataOptions({ + path: { session_id: sessionId }, + }), + }); + + this._backendLastUpdatedMs = new Date(sessionBackendMetadata.updatedAt).getTime(); + this.updatePersistenceInfo(); + } catch (error) { + console.error("Failed to fetch session from backend:", error); + } + } + + removeWorkbenchSession() { + if (!this._workbenchSession) { + return; + } + + this.removeFromLocalStorage(); + this.unsubscribeFromSessionUpdates(); + this.resetInternalState(); + this._workbenchSession = null; + + if (this._fetchingInterval) { + clearInterval(this._fetchingInterval); + } + } + + removeFromLocalStorage(): void { + const key = this.makeLocalStorageKey(); + localStorage.removeItem(key); + } + + getWorkbenchSession(): PrivateWorkbenchSession | null { + return this._workbenchSession; + } + + private subscribeToSessionChanges() { + if (!this._workbenchSession) { + throw new Error("No active workbench session to subscribe to changes."); + } + + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "workbench-session", + this._workbenchSession + .getPublishSubscribeDelegate() + .makeSubscriberFunction(PrivateWorkbenchSessionTopic.DASHBOARDS)(() => { + this.schedulePullFullSessionState(); + this.subscribeToDashboardUpdates(); + }), + ); + + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "workbench-session", + this._workbenchSession + .getPublishSubscribeDelegate() + .makeSubscriberFunction(PrivateWorkbenchSessionTopic.ENSEMBLE_SET)(() => { + this.schedulePullFullSessionState(); + this.subscribeToModuleInstanceUpdates(); + }), + ); + + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "workbench-session", + this._workbenchSession + .getPublishSubscribeDelegate() + .makeSubscriberFunction(PrivateWorkbenchSessionTopic.REALIZATION_FILTER_SET)(() => { + this.schedulePullFullSessionState(); + }), + ); + + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "workbench-session", + this._workbenchSession + .getPublishSubscribeDelegate() + .makeSubscriberFunction(PrivateWorkbenchSessionTopic.METADATA)(() => { + this.schedulePullFullSessionState(); + }), + ); + + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "workbench-session", + this._workbenchSession + .getWorkbenchSettings() + .getPublishSubscribeDelegate() + .makeSubscriberFunction(WorkbenchSettingsTopic.SelectedColorPalettes)(() => { + this.schedulePullFullSessionState(); + }), + ); + + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "workbench-session", + this._workbenchSession + .getUserCreatedItems() + .subscribe(UserCreatedItemsEvent.INTERSECTION_POLYLINES_CHANGE, () => { + this.schedulePullFullSessionState(); + }), + ); + } + + hasChanges(): boolean { + return this._persistenceInfo.hasChanges; + } + + beforeDestroy(): void { + this._unsubscribeFunctionsManagerDelegate.unsubscribeAll(); + } + + resetInternalState(): void { + this._lastPersistedHash = null; + this._currentHash = null; + this._currentStateString = null; + this._persistenceInfo = { + lastModifiedMs: 0, + hasChanges: false, + lastPersistedMs: null, + backendLastUpdatedMs: null, + }; + this._lastPersistedMs = null; + this._lastModifiedMs = 0; + + if (this._pullDebounceTimeout) { + clearTimeout(this._pullDebounceTimeout); + this._pullDebounceTimeout = null; + } + this._pullCounter++; + this._pullInProgress = false; + + if (this._fetchingInterval) { + clearInterval(this._fetchingInterval); + this._fetchingInterval = null; + } + + this._publishSubscribeDelegate.notifySubscribers(WorkbenchSessionPersistenceServiceTopic.PERSISTENCE_INFO); + } + + getPublishSubscribeDelegate(): PublishSubscribeDelegate { + return this._publishSubscribeDelegate; + } + + makeSnapshotGetter( + topic: T, + ): () => WorkbenchSessionPersistenceServiceTopicPayloads[T] { + const snapshotGetter = (): any => { + if (topic === WorkbenchSessionPersistenceServiceTopic.PERSISTENCE_INFO) { + return this._persistenceInfo; + } + throw new Error(`Unknown topic: ${topic}`); + }; + + return snapshotGetter; + } + + private makeLocalStorageKey(): string { + if (!this._workbenchSession) { + throw new Error("Workbench is not set. Cannot create local storage key."); + } + + const sessionId = this._workbenchSession.getId(); + return localStorageKeyForSessionId(sessionId); + } + + private persistToLocalStorage() { + const key = this.makeLocalStorageKey(); + + if (this._currentStateString) { + localStorage.setItem(key, this._currentStateString); + } + } + + async makeSnapshot(title: string, description: string): Promise { + const queryClient = this._workbench.getQueryClient(); + + if (!this._workbenchSession) { + throw new Error("No active workbench session to make a snapshot of."); + } + + await this.pullFullSessionState({ immediate: true }); + if (!this._currentStateString) { + throw new Error("Current state string is not set. Cannot make a snapshot."); + } + const toastId = toast.loading("Creating snapshot..."); + + try { + const snapshotId = await createSnapshotWithCacheUpdate(queryClient, { + title, + description, + content: objectToJsonString(this._workbenchSession.getContent()), + }); + toast.dismiss(toastId); + toast.success("Snapshot successfully created."); + return snapshotId; + } catch (error) { + console.error("Failed to create snapshot:", error); + toast.dismiss(toastId); + toast.error("Failed to create snapshot. Please try again later."); + + return null; + } + } + + private schedulePullFullSessionState(delay: number = 200) { + if (this._pullDebounceTimeout) { + clearTimeout(this._pullDebounceTimeout); + } + + this._pullDebounceTimeout = setTimeout(() => { + this._pullDebounceTimeout = null; + this.pullFullSessionState(); + }, delay); + } + + private async pullFullSessionState({ immediate = false } = {}): Promise { + if (!this._workbenchSession) { + console.warn("No active workbench session to pull state from."); + return false; + } + + if (this._pullInProgress && !immediate) { + // Do not allow concurrent pulls – let debounce handle retries + return false; + } + + this._pullInProgress = true; + const localPullId = ++this._pullCounter; + + try { + const oldHash = this._currentHash; + const newStateString = makeWorkbenchSessionStateString(this._workbenchSession); + const newHash = await hashJsonString(newStateString); + + // Only apply if it's still the latest pull + if (localPullId !== this._pullCounter) { + return false; + } + + if (newHash !== oldHash) { + this._currentStateString = newStateString; + this._currentHash = newHash; + this._lastModifiedMs = Date.now(); + + this._workbenchSession.updateMetadata({ lastModifiedMs: this._lastModifiedMs }, false); + this.persistToLocalStorage(); + this.updatePersistenceInfo(); + + return true; + } + return false; // No changes detected + } catch (error) { + console.error("Failed to pull full session state:", error); + return false; + } finally { + this._pullInProgress = false; + } + } + + async persistSessionState(): Promise { + const queryClient = this._workbench.getQueryClient(); + + if (!this._workbenchSession) { + throw new Error("No active workbench session to persist."); + } + + if (!this._currentStateString) { + throw new Error("Current state string is not set. Cannot persist session state."); + } + + const metadata = this._workbenchSession.getMetadata(); + const id = this._workbenchSession.getId(); + const toastId = toast.loading("Persisting session state..."); + + if (this._currentHash === this._lastPersistedHash) { + toast.dismiss(toastId); + toast.info("No changes to persist."); + return false; + } + + try { + if (this._workbenchSession.getIsPersisted()) { + if (!id) { + throw new Error("Session ID is not set. Cannot update session state."); + } + await updateSessionWithCacheUpdate(queryClient, { + id, + content: objectToJsonString(this._workbenchSession.getContent()), + metadata: { + title: metadata.title, + description: metadata.description, + }, + }); + // On successful update, we can safely remove the local storage recovery entry + this.removeFromLocalStorage(); + + toast.dismiss(toastId); + toast.success("Session state updated successfully."); + } else { + const id = await createSessionWithCacheUpdate(queryClient, { + title: metadata.title, + description: metadata.description ?? null, + content: objectToJsonString(this._workbenchSession.getContent()), + }); + this._workbenchSession.setId(id); + toast.dismiss(toastId); + toast.success("Session successfully created and persisted."); + } + + // Reset queries to ensure the new session is fetched + queryClient.resetQueries({ queryKey: getSessionsMetadataQueryKey() }); + + this._lastPersistedMs = Date.now(); + this._lastPersistedHash = this._currentHash; + this._workbenchSession.setIsPersisted(true); + this.updatePersistenceInfo(); + return true; + } catch (error) { + console.error("Failed to persist session state:", error); + toast.dismiss(toastId); + toast.error("Failed to persist session state. Please try again later."); + return false; + } + } + + private updatePersistenceInfo() { + this._persistenceInfo = { + lastModifiedMs: this._lastModifiedMs, + hasChanges: this._currentHash !== this._lastPersistedHash, + lastPersistedMs: this._lastPersistedMs, + backendLastUpdatedMs: this._backendLastUpdatedMs, + }; + this._publishSubscribeDelegate.notifySubscribers(WorkbenchSessionPersistenceServiceTopic.PERSISTENCE_INFO); + } + + private unsubscribeFromSessionUpdates() { + this._unsubscribeFunctionsManagerDelegate.unsubscribeAll(); + } + + private subscribeToDashboardUpdates() { + if (!this._workbenchSession) { + throw new Error("No active workbench session to subscribe to dashboard updates."); + } + + this._unsubscribeFunctionsManagerDelegate.unsubscribe("dashboards"); + + const dashboards = this._workbenchSession.getDashboards(); + + for (const dashboard of dashboards) { + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "dashboards", + dashboard.getPublishSubscribeDelegate().makeSubscriberFunction(DashboardTopic.Layout)(() => { + this.schedulePullFullSessionState(); + }), + ); + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "dashboards", + dashboard.getPublishSubscribeDelegate().makeSubscriberFunction(DashboardTopic.ModuleInstances)(() => { + this.schedulePullFullSessionState(); + this.subscribeToModuleInstanceUpdates(); + }), + ); + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "dashboards", + dashboard.getPublishSubscribeDelegate().makeSubscriberFunction(DashboardTopic.ActiveModuleInstanceId)( + () => { + this.schedulePullFullSessionState(); + }, + ), + ); + } + } + + private subscribeToModuleInstanceUpdates() { + if (!this._workbenchSession) { + throw new Error("No active workbench session to subscribe to module instance updates."); + } + + this._unsubscribeFunctionsManagerDelegate.unsubscribe("module-instances"); + + const dashboards = this._workbenchSession.getDashboards(); + for (const dashboard of dashboards) { + const moduleInstances = dashboard.getModuleInstances(); + for (const moduleInstance of moduleInstances) { + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "module-instances", + moduleInstance.makeSubscriberFunction(ModuleInstanceTopic.SERIALIZED_STATE)(() => { + this.schedulePullFullSessionState(); + }), + ); + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( + "module-instances", + moduleInstance.makeSubscriberFunction(ModuleInstanceTopic.SYNCED_SETTINGS)(() => { + this.schedulePullFullSessionState(); + }), + ); + } + } + } +} diff --git a/frontend/src/framework/internal/WorkbenchSession/WorkbenchSessionSerializer.ts b/frontend/src/framework/internal/WorkbenchSession/WorkbenchSessionSerializer.ts new file mode 100644 index 000000000..f3e83c41a --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSession/WorkbenchSessionSerializer.ts @@ -0,0 +1,71 @@ +import { Ajv } from "ajv/dist/jtd"; + +import type { SessionDocument_api } from "@api"; + +import type { + PrivateWorkbenchSession, + WorkbenchSessionContent, + WorkbenchSessionMetadata, +} from "./PrivateWorkbenchSession"; +import { objectToJsonString, sessionIdFromLocalStorageKey } from "./utils"; +import { workbenchSessionContentSchema, workbenchSessionSchema } from "./workbenchSession.jtd"; +import { WorkbenchSessionSource, type WorkbenchSessionDataContainer } from "./WorkbenchSessionDataContainer"; + +export type SerializedWorkbenchSession = { + metadata: WorkbenchSessionMetadata; + content: WorkbenchSessionContent; +}; +const ajv = new Ajv(); +const validateContent = ajv.compile(workbenchSessionContentSchema); +const validateFull = ajv.compile(workbenchSessionSchema); + +export function deserializeFromLocalStorage(key: string): WorkbenchSessionDataContainer | null { + const json = localStorage.getItem(key); + if (!json) return null; + + const parsed = JSON.parse(json); + if (!validateFull(parsed)) { + console.warn("Invalid session from localStorage", validateFull.errors); + return null; + } + + const session: WorkbenchSessionDataContainer = { + metadata: parsed.metadata, + content: parsed.content, + id: sessionIdFromLocalStorageKey(key) ?? undefined, + source: WorkbenchSessionSource.LOCAL_STORAGE, + }; + + return session; +} + +export function deserializeFromBackend(raw: SessionDocument_api): WorkbenchSessionDataContainer { + const parsed = JSON.parse(raw.content); + if (!validateContent(parsed)) { + throw new Error(`Backend session validation failed ${validateContent.errors}`); + } + + const session: WorkbenchSessionDataContainer = { + metadata: { + title: raw.metadata.title, + description: raw.metadata.description ?? undefined, + createdAt: new Date(raw.metadata.createdAt).getTime(), + updatedAt: new Date(raw.metadata.updatedAt).getTime(), + hash: raw.metadata.hash, + lastModifiedMs: new Date(raw.metadata.updatedAt).getTime(), // Fallback to now if not provided + }, + content: parsed, + id: raw.id, + source: WorkbenchSessionSource.BACKEND, + isSnapshot: false, + }; + + return session; +} + +export function makeWorkbenchSessionStateString(session: PrivateWorkbenchSession): string { + return objectToJsonString({ + metadata: session.getMetadata(), + content: session.getContent(), + }); +} diff --git a/frontend/src/framework/internal/WorkbenchSession/WorkbenchSessionValidator.ts b/frontend/src/framework/internal/WorkbenchSession/WorkbenchSessionValidator.ts new file mode 100644 index 000000000..3dcd0ec00 --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSession/WorkbenchSessionValidator.ts @@ -0,0 +1,22 @@ +import { Ajv } from "ajv/dist/jtd"; + +import { workbenchSessionContentSchema } from "./workbenchSession.jtd"; +import type { SerializedWorkbenchSession } from "./WorkbenchSessionSerializer"; + +const ajv = new Ajv(); +const validateFn = ajv.compile(workbenchSessionContentSchema); + +/** + * Validates the structure of a workbench session JSON object. + */ +export function validateWorkbenchSessionJson(raw: unknown): raw is SerializedWorkbenchSession { + return validateFn(raw); +} + +/** + * Returns validation errors if present. + */ +export function getWorkbenchSessionValidationErrors(raw: unknown): string[] | null { + const isValid = validateFn(raw); + return isValid ? null : (validateFn.errors ?? []).map((e) => `${e.instancePath || "(root)"} ${e.message}`); +} diff --git a/frontend/src/framework/internal/WorkbenchSession/utils.ts b/frontend/src/framework/internal/WorkbenchSession/utils.ts new file mode 100644 index 000000000..802d899d2 --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSession/utils.ts @@ -0,0 +1,111 @@ +import type { QueryClient } from "@tanstack/query-core"; + +import { + createSession, + createSnapshot, + getSessionQueryKey, + getSessionsMetadataQueryKey, + getSnapshotsMetadataQueryKey, + updateSession, + type NewSession_api, + type SessionUpdate_api, +} from "@api"; + +export const WORKBENCH_SESSION_LOCAL_STORAGE_KEY_PREFIX = "workbench-session-"; +export const WORKBENCH_SESSION_LOCAL_STORAGE_KEY_TEMP = "temp-workbench-session"; + +export function localStorageKeyForSessionId(sessionId: string | null): string { + if (!sessionId) { + return WORKBENCH_SESSION_LOCAL_STORAGE_KEY_TEMP; + } + return `${WORKBENCH_SESSION_LOCAL_STORAGE_KEY_PREFIX}${sessionId}`; +} + +export function sessionIdFromLocalStorageKey(key: string): string | null { + if (key.startsWith(WORKBENCH_SESSION_LOCAL_STORAGE_KEY_PREFIX)) { + return key.slice(WORKBENCH_SESSION_LOCAL_STORAGE_KEY_PREFIX.length); + } + return null; +} + +export function objectToJsonString(obj: unknown): string { + try { + return JSON.stringify(obj, null, 2); + } catch (error) { + console.error("Failed to convert object to JSON string. Offending object:", obj, "Error:", error); + throw error; // or return fallback JSON string like '{}' + } +} + +export async function hashJsonString(jsonString: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(jsonString); + + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); + return hashHex; +} + +export async function createSessionWithCacheUpdate( + queryClient: QueryClient, + sessionData: NewSession_api, +): Promise { + const response = await createSession({ + throwOnError: true, + body: sessionData, + }); + + // Invalidate the cache for the session to ensure the new session is fetched + queryClient.invalidateQueries({ + queryKey: getSessionsMetadataQueryKey(), + }); + + return response.data; +} + +export async function updateSessionWithCacheUpdate( + queryClient: QueryClient, + sessionData: SessionUpdate_api, +): Promise { + await updateSession({ + throwOnError: true, + path: { + session_id: sessionData.id, + }, + body: sessionData, + }); + + // Invalidate the cache for the session to ensure the updated session is fetched + queryClient.invalidateQueries({ + queryKey: getSessionQueryKey({ path: { session_id: sessionData.id } }), + }); + queryClient.invalidateQueries({ + queryKey: getSessionsMetadataQueryKey(), + }); +} + +export function getIdFromLocalStorageKey(key: string): string | null { + const prefix = "workbench-session-"; + if (key.startsWith(prefix)) { + return key.slice(prefix.length); + } + return null; +} + +export async function createSnapshotWithCacheUpdate( + queryClient: QueryClient, + snapshotData: NewSession_api, +): Promise { + const response = await createSnapshot({ + throwOnError: true, + body: snapshotData, + }); + + // Invalidate the cache for the session to ensure the new session is fetched + queryClient.invalidateQueries({ + queryKey: getSnapshotsMetadataQueryKey(), + }); + + return response.data; +} diff --git a/frontend/src/framework/internal/WorkbenchSession/workbenchSession.jtd.ts b/frontend/src/framework/internal/WorkbenchSession/workbenchSession.jtd.ts new file mode 100644 index 000000000..f596c0260 --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSession/workbenchSession.jtd.ts @@ -0,0 +1,75 @@ +import type { JTDSchemaType } from "ajv/dist/jtd"; + +import { USER_CREATED_ITEMS_JTD_SCHEMA } from "@framework/UserCreatedItems"; + +import { WORKBENCH_SETTINGS_JTD_SCHEMA } from "../PrivateWorkbenchSettings"; + +import { DASHBOARD_JTD_SCHEMA } from "./Dashboard"; +import type { + SerializedDeltaEnsemble, + SerializedEnsembleSet, + SerializedRegularEnsemble, + WorkbenchSessionContent, + WorkbenchSessionMetadata, +} from "./PrivateWorkbenchSession"; +import type { SerializedWorkbenchSession } from "./WorkbenchSessionSerializer"; + +export const regularEnsembleSchema: JTDSchemaType = { + properties: { + ensembleIdent: { type: "string" }, + color: { type: "string" }, + name: { type: "string", nullable: true }, + }, +} as const; + +export const deltaEnsembleSchema: JTDSchemaType = { + properties: { + referenceEnsembleIdent: { type: "string" }, + comparisonEnsembleIdent: { type: "string" }, + color: { type: "string" }, + name: { type: "string", nullable: true }, + }, +} as const; + +export const ensembleSetSchema: JTDSchemaType = { + properties: { + regularEnsembles: { + elements: regularEnsembleSchema, + }, + deltaEnsembles: { + elements: deltaEnsembleSchema, + }, + }, +} as const; + +export const workbenchSessionContentSchema: JTDSchemaType = { + properties: { + activeDashboardId: { type: "string", nullable: true }, + dashboards: { + elements: DASHBOARD_JTD_SCHEMA, + }, + settings: WORKBENCH_SETTINGS_JTD_SCHEMA, + ensembleSet: ensembleSetSchema, + userCreatedItems: USER_CREATED_ITEMS_JTD_SCHEMA, + }, +} as const; + +export const workbenchSessionMetadataSchema: JTDSchemaType = { + properties: { + title: { type: "string" }, + createdAt: { type: "float64" }, + updatedAt: { type: "float64" }, + lastModifiedMs: { type: "float64" }, + }, + optionalProperties: { + description: { type: "string" }, + hash: { type: "string" }, + }, +} as const; + +export const workbenchSessionSchema: JTDSchemaType = { + properties: { + metadata: workbenchSessionMetadataSchema, + content: workbenchSessionContentSchema, + }, +} as const; diff --git a/frontend/src/framework/internal/WorkbenchSessionPrivate.ts b/frontend/src/framework/internal/WorkbenchSessionPrivate.ts deleted file mode 100644 index b6349b4b4..000000000 --- a/frontend/src/framework/internal/WorkbenchSessionPrivate.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { AtomStoreMaster } from "@framework/AtomStoreMaster"; - -import type { EnsembleSet } from "../EnsembleSet"; -import { EnsembleSetAtom, RealizationFilterSetAtom } from "../GlobalAtoms"; -import { WorkbenchSession, WorkbenchSessionEvent } from "../WorkbenchSession"; - -export class WorkbenchSessionPrivate extends WorkbenchSession { - private _atomStoreMaster: AtomStoreMaster; - - constructor(atomStoreMaster: AtomStoreMaster) { - super(atomStoreMaster); - this._atomStoreMaster = atomStoreMaster; - this._atomStoreMaster.setAtomValue(RealizationFilterSetAtom, this._realizationFilterSet); - } - - setEnsembleSetLoadingState(isLoading: boolean): void { - this.notifySubscribers(WorkbenchSessionEvent.EnsembleSetLoadingStateChanged, { isLoading }); - } - - setEnsembleSet(newEnsembleSet: EnsembleSet): void { - this._realizationFilterSet.synchronizeWithEnsembleSet(newEnsembleSet); - this._ensembleSet = newEnsembleSet; - this._atomStoreMaster.setAtomValue(EnsembleSetAtom, newEnsembleSet); - this.notifySubscribers(WorkbenchSessionEvent.EnsembleSetChanged); - this.notifySubscribers(WorkbenchSessionEvent.RealizationFilterSetChanged); - } - - notifyAboutEnsembleRealizationFilterChange(): void { - this._atomStoreMaster.setAtomValue(RealizationFilterSetAtom, { - filterSet: this._realizationFilterSet, - }); - this.notifySubscribers(WorkbenchSessionEvent.RealizationFilterSetChanged); - } -} diff --git a/frontend/src/framework/internal/components/ActiveSessionBoundary/activeSessionBoundary.tsx b/frontend/src/framework/internal/components/ActiveSessionBoundary/activeSessionBoundary.tsx new file mode 100644 index 000000000..8c20285ec --- /dev/null +++ b/frontend/src/framework/internal/components/ActiveSessionBoundary/activeSessionBoundary.tsx @@ -0,0 +1,17 @@ +import { WorkbenchTopic, type Workbench } from "@framework/Workbench"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; + +export type ActiveSessionBoundaryProps = { + children?: React.ReactNode; + workbench: Workbench; +}; + +export function ActiveSessionBoundary(props: ActiveSessionBoundaryProps): React.ReactNode { + const hasActiveSession = usePublishSubscribeTopicValue(props.workbench, WorkbenchTopic.HAS_ACTIVE_SESSION); + + if (!hasActiveSession) { + return null; + } + + return props.children; +} diff --git a/frontend/src/framework/internal/components/ActiveSessionBoundary/index.ts b/frontend/src/framework/internal/components/ActiveSessionBoundary/index.ts new file mode 100644 index 000000000..3c73665d2 --- /dev/null +++ b/frontend/src/framework/internal/components/ActiveSessionBoundary/index.ts @@ -0,0 +1,2 @@ +export { ActiveSessionBoundary } from "./activeSessionBoundary"; +export type { ActiveSessionBoundaryProps } from "./activeSessionBoundary"; diff --git a/frontend/src/framework/internal/components/ActiveSessionRecoveryDialog/activeSessionRecoveryDialog.tsx b/frontend/src/framework/internal/components/ActiveSessionRecoveryDialog/activeSessionRecoveryDialog.tsx new file mode 100644 index 000000000..da6f3dd04 --- /dev/null +++ b/frontend/src/framework/internal/components/ActiveSessionRecoveryDialog/activeSessionRecoveryDialog.tsx @@ -0,0 +1,101 @@ +import React from "react"; + +import { GuiState, useGuiState } from "@framework/GuiMessageBroker"; +import { + extractLayout, + type WorkbenchSessionDataContainer, +} from "@framework/internal/WorkbenchSession/WorkbenchSessionDataContainer"; +import { loadAllWorkbenchSessionsFromLocalStorage } from "@framework/internal/WorkbenchSession/WorkbenchSessionLoader"; +import type { Workbench } from "@framework/Workbench"; +import { Button } from "@lib/components/Button"; +import { Dialog } from "@lib/components/Dialog"; +import { timeAgo } from "@lib/utils/dates"; + +import { DashboardPreview } from "../DashboardPreview/dashboardPreview"; + +export type ActiveSessionRecoveryDialogProps = { + workbench: Workbench; +}; + +export function ActiveSessionRecoveryDialog(props: ActiveSessionRecoveryDialogProps): React.ReactNode { + const [isOpen, setIsOpen] = useGuiState( + props.workbench.getGuiMessageBroker(), + GuiState.ActiveSessionRecoveryDialogOpen, + ); + + const activeSession = props.workbench.getWorkbenchSession(); + + const [session, setSession] = React.useState(null); + + const loadSession = React.useCallback( + async function loadSession() { + const loadedSessions = await loadAllWorkbenchSessionsFromLocalStorage(); + + const storedSession = loadedSessions.find((s) => s.id === activeSession.getId()); + setSession(storedSession || null); + }, + [activeSession], + ); + + React.useEffect( + function loadSessionOnOpen() { + if (isOpen) { + loadSession(); + } + }, + [isOpen, loadSession], + ); + + if (!isOpen || !session) { + return null; + } + + function handleDiscard() { + props.workbench.discardLocalStorageSession(activeSession.getId(), false); + setIsOpen(false); + } + + function handleOpen() { + props.workbench.openSessionFromLocalStorage(activeSession.getId(), true); + } + + return ( + + + + + } + width={800} + > + We found an unsaved version of your current session in your local storage. You can either discard it or open + it to recover your work. +
+ +
+
+ Title + {session?.metadata.title} +
+
+ Last modified + {timeAgo(Date.now() - session.metadata.lastModifiedMs)} +
+
+ Last persisted + {timeAgo(Date.now() - activeSession.getMetadata().lastModifiedMs)} +
+
+
+
+ ); +} diff --git a/frontend/src/framework/internal/components/ActiveSessionRecoveryDialog/index.ts b/frontend/src/framework/internal/components/ActiveSessionRecoveryDialog/index.ts new file mode 100644 index 000000000..0de43f12d --- /dev/null +++ b/frontend/src/framework/internal/components/ActiveSessionRecoveryDialog/index.ts @@ -0,0 +1,2 @@ +export { ActiveSessionRecoveryDialog as UnsavedSessionChangesDialog } from "./activeSessionRecoveryDialog"; +export type { ActiveSessionRecoveryDialogProps as UnsavedSessionChangesDialogProps } from "./activeSessionRecoveryDialog"; diff --git a/frontend/src/framework/internal/components/ApplyInterfaceEffects/applyInterfaceEffects.tsx b/frontend/src/framework/internal/components/ApplyInterfaceEffects/applyInterfaceEffects.tsx index ba5f4dde8..daffcb0c0 100644 --- a/frontend/src/framework/internal/components/ApplyInterfaceEffects/applyInterfaceEffects.tsx +++ b/frontend/src/framework/internal/components/ApplyInterfaceEffects/applyInterfaceEffects.tsx @@ -5,9 +5,8 @@ import { useAtom } from "jotai"; import type { ModuleInterfaceTypes } from "@framework/Module"; import type { ModuleInstance } from "@framework/ModuleInstance"; - export type ApplyInterfaceEffectsProps = { - moduleInstance: ModuleInstance; + moduleInstance: ModuleInstance; children?: React.ReactNode; }; diff --git a/frontend/src/framework/internal/components/AuthenticationBoundary/authenticationBoundary.tsx b/frontend/src/framework/internal/components/AuthenticationBoundary/authenticationBoundary.tsx new file mode 100644 index 000000000..4f384d7bf --- /dev/null +++ b/frontend/src/framework/internal/components/AuthenticationBoundary/authenticationBoundary.tsx @@ -0,0 +1,69 @@ +import FmuLogo from "@assets/fmu.svg"; +import FmuLogoAnimated from "@assets/fmuAnimated.svg"; + +import { AuthState, useAuthProvider } from "@framework/internal/providers/AuthProvider"; +import { Button } from "@lib/components/Button"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; + +import { DataSharingLabel } from "./private-components/DataSharingLabel"; +import { DevLabel } from "./private-components/DevLabel"; + +export type AuthenticationBoundaryProps = { + children?: React.ReactNode; +}; + +export function AuthenticationBoundary(props: AuthenticationBoundaryProps) { + const { authState } = useAuthProvider(); + + function signIn() { + window.location.href = `/api/login?redirect_url_after_login=${btoa(window.location.pathname + window.location.search + window.location.hash)}`; + } + + let content: React.ReactNode = null; + if (authState === AuthState.NotLoggedIn) { + content = ( + <> + FMU Analysis logo +

FMU Analysis

+ +

Please sign in to continue.

+ + + + ); + } else if (authState === AuthState.Loading) { + content = ( + <> + FMU Analysis animated logo + Checking if user is signed in... + + ); + } + + return ( +
+
+ {content} +
+
+ {authState === AuthState.LoggedIn ? props.children : null} +
+
+ ); +} diff --git a/frontend/src/framework/internal/components/AuthenticationBoundary/index.ts b/frontend/src/framework/internal/components/AuthenticationBoundary/index.ts new file mode 100644 index 000000000..71e005b23 --- /dev/null +++ b/frontend/src/framework/internal/components/AuthenticationBoundary/index.ts @@ -0,0 +1,2 @@ +export { AuthenticationBoundary } from "./authenticationBoundary"; +export type { AuthenticationBoundaryProps } from "./authenticationBoundary"; diff --git a/frontend/src/framework/internal/components/AuthenticationBoundary/private-components/DataSharingLabel.tsx b/frontend/src/framework/internal/components/AuthenticationBoundary/private-components/DataSharingLabel.tsx new file mode 100644 index 000000000..bdb5c5259 --- /dev/null +++ b/frontend/src/framework/internal/components/AuthenticationBoundary/private-components/DataSharingLabel.tsx @@ -0,0 +1,15 @@ +export function DataSharingLabel() { + return ( +
+

+ Disclaimer: Webviz is a service provided by Equinor and is not a way of sharing + official data. Data should continue to be shared through L2S, FTP and/or Dasha. +

+

+ References to e.g. earlier models, model results and data should still be done through the mentioned + tools, and not Webviz. Since Webviz is currently under heavy development and not production ready, there + is no guarantee given as of now that calculations are error-free. +

+
+ ); +} diff --git a/frontend/src/framework/internal/components/AuthenticationBoundary/private-components/DevLabel.tsx b/frontend/src/framework/internal/components/AuthenticationBoundary/private-components/DevLabel.tsx new file mode 100644 index 000000000..ea5807afb --- /dev/null +++ b/frontend/src/framework/internal/components/AuthenticationBoundary/private-components/DevLabel.tsx @@ -0,0 +1,26 @@ +export function DevLabel() { + return ( +
+ NOTE: This application is still under heavy development; bugs and occasional downtime + should be expected. Please help us improve Webviz by reporting any undesired behaviour either on{" "} + + Slack + {" "} + or{" "} + + Yammer + + . +
+ ); +} diff --git a/frontend/src/framework/internal/components/Content/content.tsx b/frontend/src/framework/internal/components/Content/content.tsx index b0c6ba255..4e4e4519d 100644 --- a/frontend/src/framework/internal/components/Content/content.tsx +++ b/frontend/src/framework/internal/components/Content/content.tsx @@ -1,22 +1,20 @@ import type React from "react"; -import { GuiState, useGuiValue } from "@framework/GuiMessageBroker"; import type { Workbench } from "@framework/Workbench"; import { DataChannelVisualizationLayer } from "./private-components/DataChannelVisualizationLayer"; -import { Layout } from "./private-components/layout"; +import { Layout } from "@framework/internal/components/Layout"; type ContentProps = { workbench: Workbench; }; export const Content: React.FC = (props) => { - const activeModuleInstanceId = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.ActiveModuleInstanceId); return ( <> -
- +
+
); diff --git a/frontend/src/framework/internal/components/Content/private-components/DataChannelVisualizationLayer/dataChannelVisualizationLayer.tsx b/frontend/src/framework/internal/components/Content/private-components/DataChannelVisualizationLayer/dataChannelVisualizationLayer.tsx index d3a5c1f54..8aba142ac 100644 --- a/frontend/src/framework/internal/components/Content/private-components/DataChannelVisualizationLayer/dataChannelVisualizationLayer.tsx +++ b/frontend/src/framework/internal/components/Content/private-components/DataChannelVisualizationLayer/dataChannelVisualizationLayer.tsx @@ -2,9 +2,11 @@ import React from "react"; import type { GuiEventPayloads } from "@framework/GuiMessageBroker"; import { GuiEvent, GuiState, useGuiState } from "@framework/GuiMessageBroker"; +import { PrivateWorkbenchSessionTopic } from "@framework/internal/WorkbenchSession/PrivateWorkbenchSession"; import type { Workbench } from "@framework/Workbench"; import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect"; import { createPortal } from "@lib/utils/createPortal"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; import type { Vec2 } from "@lib/utils/vec2"; @@ -24,6 +26,10 @@ type DataChannelPath = { }; export const DataChannelVisualizationLayer: React.FC = (props) => { + const dashboard = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.ACTIVE_DASHBOARD, + ); const ref = React.useRef(null); const [visible, setVisible] = React.useState(false); const [originPoint, setOriginPoint] = React.useState({ x: 0, y: 0 }); @@ -69,7 +75,7 @@ export const DataChannelVisualizationLayer: React.FC = (props) => { const { onChannelConnect, onChannelConnectionDisconnect } = props; + const dashboard = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.ACTIVE_DASHBOARD, + ); + const ref = React.useRef(null); const removeButtonRef = React.useRef(null); const editButtonRef = React.useRef(null); @@ -45,14 +52,14 @@ export const ChannelReceiverNode: React.FC = (props) = let localConnectable = false; let localModuleInstanceId = ""; - const moduleInstance = props.workbench.getModuleInstance(props.moduleInstanceId); + const moduleInstance = dashboard.getModuleInstance(props.moduleInstanceId); function handleDataChannelOriginPointerDown(payload: GuiEventPayloads[GuiEvent.DataChannelOriginPointerDown]) { localConnectable = false; setConnectable(false); localModuleInstanceId = ""; - const originModuleInstance = props.workbench.getModuleInstance(payload.moduleInstanceId); + const originModuleInstance = dashboard.getModuleInstance(payload.moduleInstanceId); if (!originModuleInstance) { return; } @@ -120,7 +127,7 @@ export const ChannelReceiverNode: React.FC = (props) = } function checkIfConnection() { - const moduleInstance = props.workbench.getModuleInstance(props.moduleInstanceId); + const moduleInstance = dashboard.getModuleInstance(props.moduleInstanceId); if (!moduleInstance) { return; } @@ -163,7 +170,7 @@ export const ChannelReceiverNode: React.FC = (props) = }, [ onChannelConnect, onChannelConnectionDisconnect, - props.workbench, + dashboard, props.moduleInstanceId, props.idString, props.supportedKindsOfKeys, @@ -188,7 +195,7 @@ export const ChannelReceiverNode: React.FC = (props) = guiMessageBroker.publishEvent(GuiEvent.DataChannelNodeUnhover); } - const moduleInstance = props.workbench.getModuleInstance(props.moduleInstanceId); + const moduleInstance = dashboard.getModuleInstance(props.moduleInstanceId); const receiver = moduleInstance?.getChannelManager().getReceiver(props.idString); const channel = receiver?.getChannel(); diff --git a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/channelReceiverNodesWrapper.tsx b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/channelReceiverNodesWrapper.tsx index 204d07fc1..6c58ede22 100644 --- a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/channelReceiverNodesWrapper.tsx +++ b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/private-components/channelReceiverNodesWrapper.tsx @@ -3,10 +3,12 @@ import React from "react"; import type { GuiEventPayloads } from "@framework/GuiMessageBroker"; import { GuiEvent, GuiState, useGuiState } from "@framework/GuiMessageBroker"; import type { ChannelReceiver } from "@framework/internal/DataChannels/ChannelReceiver"; +import { PrivateWorkbenchSessionTopic } from "@framework/internal/WorkbenchSession/PrivateWorkbenchSession"; import type { ModuleInstance } from "@framework/ModuleInstance"; import type { Workbench } from "@framework/Workbench"; import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect"; import { createPortal } from "@lib/utils/createPortal"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; import type { Vec2 } from "@lib/utils/vec2"; @@ -16,11 +18,16 @@ import { ChannelReceiverNode } from "./channelReceiverNode"; export type ChannelReceiverNodesWrapperProps = { forwardedRef: React.RefObject; - moduleInstance: ModuleInstance; + moduleInstance: ModuleInstance; workbench: Workbench; }; export const ChannelReceiverNodesWrapper: React.FC = (props) => { + const dashboard = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.ACTIVE_DASHBOARD, + ); + const [visible, setVisible] = React.useState(false); const [currentReceiver, setCurrentReceiver] = React.useState(null); const [currentOriginModuleInstanceId, setCurrentOriginModuleInstanceId] = React.useState(null); @@ -109,7 +116,7 @@ export const ChannelReceiverNodesWrapper: React.FC; + moduleInstance: ModuleInstance; isDragged: boolean; onPointerDown?: (event: React.PointerEvent) => void; onReceiversClick?: (event: React.PointerEvent) => void; }; export const Header: React.FC = (props) => { - const moduleId = props.moduleInstance.getId(); + const isSnapshot = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.IS_SNAPSHOT, + ); + const dashboard = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.ACTIVE_DASHBOARD, + ); + const moduleInstanceId = props.moduleInstance.getId(); const guiMessageBroker = props.workbench.getGuiMessageBroker(); const dataChannelOriginRef = React.useRef(null); @@ -39,7 +49,6 @@ export const Header: React.FC = (props) => { ); const log = useStatusControllerStateValue(props.moduleInstance.getStatusController(), "log"); const [, setRightDrawerContent] = useGuiState(guiMessageBroker, GuiState.RightDrawerContent); - const [, setActiveModuleInstanceId] = useGuiState(guiMessageBroker, GuiState.ActiveModuleInstanceId); const [rightSettingsPanelWidth, setRightSettingsPanelWidth] = useGuiState( guiMessageBroker, GuiState.RightSettingsPanelWidthInPercent, @@ -48,38 +57,40 @@ export const Header: React.FC = (props) => { const handleMaximizeClick = React.useCallback( function handleMaximizeClick(e: React.PointerEvent) { - const currentLayout = props.workbench.getLayout(); - const tweakedLayout = currentLayout.map((l) => ({ ...l, maximized: l.moduleInstanceId === moduleId })); - props.workbench.setLayout(tweakedLayout); - - guiMessageBroker.setState(GuiState.ActiveModuleInstanceId, moduleId); + const currentLayout = dashboard.getLayout(); + const tweakedLayout = currentLayout.map((l) => ({ + ...l, + maximized: l.moduleInstanceId === moduleInstanceId, + })); + dashboard.setLayout(tweakedLayout); + dashboard.setActiveModuleInstanceId(moduleInstanceId); e.preventDefault(); e.stopPropagation(); }, - [moduleId, guiMessageBroker, props.workbench], + [moduleInstanceId, dashboard], ); const handleRestoreClick = React.useCallback( function handleRestoreClick(e: React.PointerEvent) { - const currentLayout = props.workbench.getLayout(); + const currentLayout = dashboard.getLayout(); const tweakedLayout = currentLayout.map((l) => ({ ...l, maximized: false })); - props.workbench.setLayout(tweakedLayout); + dashboard.setLayout(tweakedLayout); e.preventDefault(); e.stopPropagation(); }, - [props.workbench], + [dashboard], ); const handleRemoveClick = React.useCallback( function handleRemoveClick(e: React.PointerEvent) { - guiMessageBroker.publishEvent(GuiEvent.RemoveModuleInstanceRequest, { moduleInstanceId: moduleId }); + guiMessageBroker.publishEvent(GuiEvent.RemoveModuleInstanceRequest, { moduleInstanceId: moduleInstanceId }); e.preventDefault(); e.stopPropagation(); }, - [guiMessageBroker, moduleId], + [guiMessageBroker, moduleInstanceId], ); const ref = React.useRef(null); @@ -140,7 +151,7 @@ export const Header: React.FC = (props) => { setRightSettingsPanelWidth(15); } - setActiveModuleInstanceId(props.moduleInstance.getId()); + dashboard.setActiveModuleInstanceId(props.moduleInstance.getId()); setRightDrawerContent(RightDrawerContent.ModuleInstanceLog); setStatusMessagesVisible(false); @@ -341,15 +352,16 @@ export const Header: React.FC = (props) => {
)} - -
- -
+ {!isSnapshot && ( +
+ +
+ )} {statusMessagesVisible && createPortal(
; + moduleInstance: ModuleInstance; workbench: Workbench; }; export const ViewContent = React.memo((props: ViewContentProps) => { - const importState = useModuleInstanceTopicValue(props.moduleInstance, ModuleInstanceTopic.IMPORT_STATE); - const moduleInstanceState = useModuleInstanceTopicValue(props.moduleInstance, ModuleInstanceTopic.STATE); + const workbenchSession = props.workbench.getWorkbenchSession(); + const importState = useModuleInstanceTopicValue(props.moduleInstance, ModuleInstanceTopic.IMPORT_STATUS); + const moduleInstanceLifeCycleState = useModuleInstanceTopicValue( + props.moduleInstance, + ModuleInstanceTopic.LIFECYCLE_STATE, + ); - const atomStore = props.workbench.getAtomStoreMaster().getAtomStoreForModuleInstance(props.moduleInstance.getId()); + const atomStore = workbenchSession.getAtomStoreMaster().getAtomStoreForModuleInstance(props.moduleInstance.getId()); const handleModuleInstanceReload = React.useCallback( function handleModuleInstanceReload() { @@ -34,50 +42,58 @@ export const ViewContent = React.memo((props: ViewContentProps) => { [props.moduleInstance], ); - if (importState === ImportState.NotImported) { - return
Not imported
; - } + function makeStateRelatedContent(): React.ReactNode { + if (importState === ImportStatus.NotImported) { + return "Module not imported. Please check the spelling when registering and initializing the module."; + } - if (importState === ImportState.Importing) { - return ( -
- -
Importing...
-
- ); - } + if (importState === ImportStatus.Importing) { + return ( + <> + +
Importing...
+ + ); + } - if (!props.moduleInstance.isInitialized()) { - return ( -
- -
Initializing...
-
- ); - } + if (!props.moduleInstance.isInitialized()) { + return ( + <> + +
Initializing...
+ + ); + } - if (importState === ImportState.Failed) { - return ( -
- Module could not be imported. Please check the spelling when registering and initializing the module. -
- ); + if (importState === ImportStatus.Failed) { + return "Module could not be imported. Please check the spelling when registering and initializing the module."; + } + + if ( + moduleInstanceLifeCycleState === ModuleInstanceLifeCycleState.INITIALIZING || + moduleInstanceLifeCycleState === ModuleInstanceLifeCycleState.RESETTING + ) { + const text = + moduleInstanceLifeCycleState === ModuleInstanceLifeCycleState.INITIALIZING + ? "Initializing..." + : "Resetting..."; + return ( + <> + +
{text}
+ + ); + } + + return false; } - if ( - moduleInstanceState === ModuleInstanceState.INITIALIZING || - moduleInstanceState === ModuleInstanceState.RESETTING - ) { - const text = moduleInstanceState === ModuleInstanceState.INITIALIZING ? "Initializing..." : "Resetting..."; - return ( -
- -
{text}
-
- ); + const stateRelatedContent = makeStateRelatedContent(); + if (stateRelatedContent) { + return
{stateRelatedContent}
; } - if (moduleInstanceState === ModuleInstanceState.ERROR) { + if (moduleInstanceLifeCycleState === ModuleInstanceLifeCycleState.ERROR) { const errorObject = props.moduleInstance.getFatalError(); if (errorObject) { return ( @@ -106,9 +122,11 @@ export const ViewContent = React.memo((props: ViewContentProps) => { diff --git a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/viewWrapper.tsx b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/viewWrapper.tsx index 630638604..4847d7049 100644 --- a/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/viewWrapper.tsx +++ b/frontend/src/framework/internal/components/Content/private-components/ViewWrapper/viewWrapper.tsx @@ -1,9 +1,12 @@ import React from "react"; import { GuiEvent, GuiState, LeftDrawerContent, useGuiState, useGuiValue } from "@framework/GuiMessageBroker"; +import { DashboardTopic } from "@framework/internal/WorkbenchSession/Dashboard"; +import { PrivateWorkbenchSessionTopic } from "@framework/internal/WorkbenchSession/PrivateWorkbenchSession"; import type { ModuleInstance } from "@framework/ModuleInstance"; import type { Workbench } from "@framework/Workbench"; import { pointRelativeToDomRect } from "@lib/utils/geometry"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; import type { Vec2 } from "@lib/utils/vec2"; import { subtractVec2, vec2FromPointerEvent } from "@lib/utils/vec2"; @@ -17,8 +20,7 @@ import { ViewContent } from "./private-components/viewContent"; type ViewWrapperProps = { isMaximized?: boolean; isMinimized?: boolean; - isActive: boolean; - moduleInstance: ModuleInstance; + moduleInstance: ModuleInstance; workbench: Workbench; width: number; height: number; @@ -30,11 +32,18 @@ type ViewWrapperProps = { }; export const ViewWrapper: React.FC = (props) => { + const dashboard = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.ACTIVE_DASHBOARD, + ); const [prevWidth, setPrevWidth] = React.useState(props.width); const [prevHeight, setPrevHeight] = React.useState(props.height); const [prevX, setPrevX] = React.useState(props.x); const [prevY, setPrevY] = React.useState(props.y); + const activeModuleInstanceId = usePublishSubscribeTopicValue(dashboard, DashboardTopic.ActiveModuleInstanceId); + const isActive = props.moduleInstance.getId() === activeModuleInstanceId; + const ref = React.useRef(null); const [drawerContent, setDrawerContent] = useGuiState( props.workbench.getGuiMessageBroker(), @@ -99,8 +108,8 @@ export const ViewWrapper: React.FC = (props) => { if (drawerContent !== LeftDrawerContent.SyncSettings) { setDrawerContent(LeftDrawerContent.ModuleSettings); } - if (props.isActive) return; - props.workbench.getGuiMessageBroker().setState(GuiState.ActiveModuleInstanceId, props.moduleInstance.getId()); + if (isActive) return; + dashboard.setActiveModuleInstanceId(props.moduleInstance.getId()); } function handlePointerDown() { @@ -113,12 +122,6 @@ export const ViewWrapper: React.FC = (props) => { return; } pointerDown.current = false; - if (drawerContent === LeftDrawerContent.ModulesList) { - if (!timeRef.current || Date.now() - timeRef.current < 800) { - handleModuleClick(); - } - return; - } handleModuleClick(); } @@ -131,7 +134,7 @@ export const ViewWrapper: React.FC = (props) => { } const showAsActive = - props.isActive && [LeftDrawerContent.ModuleSettings, LeftDrawerContent.SyncSettings].includes(drawerContent); + isActive && [LeftDrawerContent.ModuleSettings, LeftDrawerContent.SyncSettings].includes(drawerContent); function makeHeader() { return ( @@ -154,7 +157,7 @@ export const ViewWrapper: React.FC = (props) => { )} - {/* ! Show a placeholder while dragging modules around, since resizing module content while dragging might be costly */} + {/* ! Show a placeholder while dragging modules around/resizing, since resizing module content while dragging might be costly */} {props.changingLayout && (
= (props) => { zIndex: props.isDragged ? 1 : 0, }} > -
+
{makeHeader()}
@@ -177,12 +180,13 @@ export const ViewWrapper: React.FC = (props) => { className={resolveClassNames("absolute box-border contain-content", { "p-0.5": !props.isMinimized, invisible: props.changingLayout, + "z-10": props.isMaximized, })} style={{ - width: prevWidth, - height: prevHeight, - left: prevX, - top: prevY, + width: props.isMaximized ? "100%" : prevWidth, + height: props.isMaximized ? "100%" : prevHeight, + left: props.isMaximized ? "0px" : prevX, + top: props.isMaximized ? "0px" : prevY, }} >
= (props) => { + const dashboard = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.ACTIVE_DASHBOARD, + ); const [draggedModuleInstanceId, setDraggedModuleInstanceId] = React.useState(null); const [position, setPosition] = React.useState({ x: 0, y: 0 }); const [pointer, setPointer] = React.useState({ x: -1, y: -1 }); @@ -42,12 +47,12 @@ export const Layout: React.FC = (props) => { const mainRef = React.useRef(null); const layoutDivSize = useElementSize(ref); const layoutBoxRef = React.useRef(null); - const moduleInstances = useModuleInstances(props.workbench); + const moduleInstances = usePublishSubscribeTopicValue(dashboard, DashboardTopic.ModuleInstances); const guiMessageBroker = props.workbench.getGuiMessageBroker(); // We use a temporary layout while dragging elements around const [tempLayout, setTempLayout] = React.useState(null); - const trueLayout = useModuleLayout(props.workbench); + const trueLayout = usePublishSubscribeTopicValue(dashboard, DashboardTopic.Layout); const layout = tempLayout ?? trueLayout; React.useEffect(() => { @@ -60,8 +65,8 @@ export const Layout: React.FC = (props) => { let dragging = false; let moduleInstanceId: string | null = null; let moduleName: string | null = null; - let originalLayout: LayoutElement[] = props.workbench.getLayout(); - let currentLayout: LayoutElement[] = props.workbench.getLayout(); + let originalLayout: LayoutElement[] = trueLayout; + let currentLayout: LayoutElement[] = trueLayout; let originalLayoutBox = makeLayoutBoxes(originalLayout); let currentLayoutBox = originalLayoutBox; layoutBoxRef.current = currentLayoutBox; @@ -113,7 +118,8 @@ export const Layout: React.FC = (props) => { if (isNewModule && moduleName) { const layoutElement = currentLayout.find((el) => el.moduleInstanceId === pointerDownElementId); if (layoutElement) { - const instance = props.workbench.makeAndAddModuleInstance(moduleName, layoutElement); + // This is not working yet as the older layout is not adjusted + const instance = dashboard.makeAndAddModuleInstance(moduleName); layoutElement.moduleInstanceId = instance.getId(); layoutElement.moduleName = instance.getName(); } @@ -126,7 +132,6 @@ export const Layout: React.FC = (props) => { originalLayoutBox = currentLayoutBox; layoutBoxRef.current = currentLayoutBox; setTempLayout(null); - props.workbench.setLayout(currentLayout); setPosition({ x: 0, y: 0 }); setPointer({ x: -1, y: -1 }); @@ -139,6 +144,7 @@ export const Layout: React.FC = (props) => { moduleInstanceId = null; dragging = false; originalLayout = currentLayout; + dashboard.setLayout(currentLayout); removeDraggingEventListeners(); } @@ -256,12 +262,12 @@ export const Layout: React.FC = (props) => { if (dragging) { return; } - props.workbench.removeModuleInstance(payload.moduleInstanceId); + dashboard.removeModuleInstance(payload.moduleInstanceId); currentLayoutBox.removeLayoutElement(payload.moduleInstanceId); currentLayout = currentLayoutBox.toLayout(); originalLayout = currentLayout; originalLayoutBox = currentLayoutBox; - props.workbench.setLayout(currentLayout); + dashboard.setLayout(currentLayout); } function addDraggingEventListeners() { @@ -323,7 +329,7 @@ export const Layout: React.FC = (props) => { clearTimeout(delayTimer); } }; - }, [layoutDivSize, moduleInstances, guiMessageBroker, props.workbench]); + }, [layoutDivSize, moduleInstances, guiMessageBroker, trueLayout, dashboard]); function makeTempViewWrapperPlaceholder() { if (!tempLayoutBoxId) { @@ -365,7 +371,7 @@ export const Layout: React.FC = (props) => { rows = Math.ceil(minimizedLayouts.length / elementsPerRow); } - function computeModuleLayoutProps(moduleInstance: ModuleInstance) { + function computeModuleLayoutProps(moduleInstance: ModuleInstance) { const moduleId = moduleInstance.getId(); const layoutElement = layout.find((element) => element.moduleInstanceId === moduleId); @@ -423,7 +429,6 @@ export const Layout: React.FC = (props) => { pointer={pointer} /> )} - {moduleInstances.map((instance) => { const layoutProps = computeModuleLayoutProps(instance); const isDragged = draggedModuleInstanceId === instance.getId(); @@ -435,7 +440,6 @@ export const Layout: React.FC = (props) => { key={instance.getId()} moduleInstance={instance} workbench={props.workbench} - isActive={props.activeModuleInstanceId === instance.getId()} isDragged={isDragged} dragPosition={position} changingLayout={draggedModuleInstanceId !== null} @@ -444,6 +448,10 @@ export const Layout: React.FC = (props) => { ); })} {makeTempViewWrapperPlaceholder()} +
+ + Drag modules here to add them to the layout +
); diff --git a/frontend/src/framework/internal/components/CreateSnapshotDialog/createSnapshotDialog.tsx b/frontend/src/framework/internal/components/CreateSnapshotDialog/createSnapshotDialog.tsx new file mode 100644 index 000000000..45cfb336d --- /dev/null +++ b/frontend/src/framework/internal/components/CreateSnapshotDialog/createSnapshotDialog.tsx @@ -0,0 +1,171 @@ +import React from "react"; + +import { AddLink } from "@mui/icons-material"; + +import { GuiState, useGuiState, useGuiValue } from "@framework/GuiMessageBroker"; +import { buildSnapshotUrl } from "@framework/internal/WorkbenchSession/SnapshotUrlService"; +import type { Workbench } from "@framework/Workbench"; +import { Button } from "@lib/components/Button"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { Dialog } from "@lib/components/Dialog"; +import { Input } from "@lib/components/Input"; +import { Label } from "@lib/components/Label"; + +import { DashboardPreview } from "../DashboardPreview/dashboardPreview"; + +export type MakeSnapshotDialogProps = { + workbench: Workbench; +}; + +type MakeSnapshotDialogInputFeedback = { + title?: string; + description?: string; +}; + +export function CreateSnapshotDialog(props: MakeSnapshotDialogProps): React.ReactNode { + const [isOpen, setIsOpen] = useGuiState(props.workbench.getGuiMessageBroker(), GuiState.MakeSnapshotDialogOpen); + + const isSaving = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.IsSavingSession); + + const [title, setTitle] = React.useState(""); + const [description, setDescription] = React.useState(""); + const [snapshotUrl, setSnapshotUrl] = React.useState(null); + const [inputFeedback, setInputFeedback] = React.useState({}); + const inputRef = React.useRef(null); + + function handleCreateSnapshot() { + if (title.trim() === "") { + setInputFeedback((prev) => ({ ...prev, title: "Title is required." })); + return; + } else { + setInputFeedback((prev) => ({ ...prev, title: undefined })); + } + + props.workbench + .makeSnapshot(title, description) + .then((snapshotId) => { + if (!snapshotId) { + return; + } + setTitle(""); + setDescription(""); + setInputFeedback({}); + if (!snapshotId) { + return; + } + setSnapshotUrl(buildSnapshotUrl(snapshotId)); + }) + .catch((error) => { + console.error("Failed to save session:", error); + }); + } + + function handleCancel() { + setIsOpen(false); + setTitle(""); + setDescription(""); + setInputFeedback({}); + setSnapshotUrl(null); + } + + const layout = props.workbench.getWorkbenchSession().getActiveDashboard()?.getLayout() || []; + + let content: React.ReactNode = null; + let actions: React.ReactNode = null; + + React.useEffect( + function focusInput() { + if (isOpen && inputRef.current && !snapshotUrl) { + inputRef.current.focus(); + } + }, + [isOpen, snapshotUrl], + ); + + if (!snapshotUrl) { + content = ( +
+ +
+ + +
+
+ ); + + actions = ( + <> + + + + ); + } else { + content = ( +
+
Snapshot created successfully!
+
+ By sharing the following link you can give others access to your snapshot.
+ You can find all your created and visited snapshots in the snapshots dialog. +
+ navigator.clipboard.writeText(snapshotUrl || "")}> + Copy + + } + /> +
+ ); + + actions = ( + <> + + + ); + } + + return ( + + {content} + + ); +} diff --git a/frontend/src/framework/internal/components/CreateSnapshotDialog/index.ts b/frontend/src/framework/internal/components/CreateSnapshotDialog/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/framework/internal/components/DashboardPreview/dashboardPreview.tsx b/frontend/src/framework/internal/components/DashboardPreview/dashboardPreview.tsx new file mode 100644 index 000000000..48c90f087 --- /dev/null +++ b/frontend/src/framework/internal/components/DashboardPreview/dashboardPreview.tsx @@ -0,0 +1,58 @@ +import type { LayoutElement } from "@framework/internal/WorkbenchSession/Dashboard"; +import { ModuleRegistry } from "@framework/ModuleRegistry"; + +export type DashboardPreviewProps = { + layout: LayoutElement[]; + width: number; + height: number; +}; + +export function DashboardPreview(props: DashboardPreviewProps): React.ReactNode { + const { layout, width, height } = props; + return ( + + {layout.map((element, idx) => { + const w = element.relWidth * width; + const h = element.relHeight * height; + const x = element.relX * width; + const y = element.relY * height; + const strokeWidth = 2; + const headerHeight = 10; + const module = ModuleRegistry.getModule(element.moduleName); + const drawFunc = module.getDrawPreviewFunc(); + return ( + + + + + {element.moduleName} + + + {drawFunc && drawFunc(w - 4 * strokeWidth, h - headerHeight - 4 * strokeWidth)} + + + ); + })} + + ); +} diff --git a/frontend/src/framework/internal/components/ErrorBoundary/errorBoundary.tsx b/frontend/src/framework/internal/components/ErrorBoundary/errorBoundary.tsx index 7da323dcc..1fa9687e4 100644 --- a/frontend/src/framework/internal/components/ErrorBoundary/errorBoundary.tsx +++ b/frontend/src/framework/internal/components/ErrorBoundary/errorBoundary.tsx @@ -3,7 +3,7 @@ import React from "react"; import type { ModuleInstance } from "@framework/ModuleInstance"; export type Props = { - moduleInstance: ModuleInstance; + moduleInstance: ModuleInstance; children?: React.ReactNode; }; diff --git a/frontend/src/framework/internal/components/GlobalConfirmationDialog/globalConfirmationDialog.tsx b/frontend/src/framework/internal/components/GlobalConfirmationDialog/globalConfirmationDialog.tsx new file mode 100644 index 000000000..6ba3676ab --- /dev/null +++ b/frontend/src/framework/internal/components/GlobalConfirmationDialog/globalConfirmationDialog.tsx @@ -0,0 +1,50 @@ +import React from "react"; + +import { type ConfirmOptions, confirmationService } from "@framework/ConfirmationService"; +import { Button } from "@lib/components/Button"; +import { Dialog } from "@lib/components/Dialog"; + +export function GlobalConfirmationDialog(): React.ReactNode { + const [visible, setVisible] = React.useState(false); + const [options, setOptions] = React.useState>(); + + React.useEffect(function onMount() { + confirmationService.setShowDialogCallback((options) => { + setOptions(options); + setVisible(true); + }); + }, []); + + function handleAction(actionId: string) { + confirmationService.resolve(actionId); + setVisible(false); + } + + if (!visible || !options) { + return null; + } + + return ( + + {options.actions.map((action) => ( + + ))} + + } + > + {options.message} + + ); +} diff --git a/frontend/src/framework/internal/components/GlobalConfirmationDialog/index.ts b/frontend/src/framework/internal/components/GlobalConfirmationDialog/index.ts new file mode 100644 index 000000000..208f905c4 --- /dev/null +++ b/frontend/src/framework/internal/components/GlobalConfirmationDialog/index.ts @@ -0,0 +1 @@ +export { GlobalConfirmationDialog } from "./globalConfirmationDialog"; diff --git a/frontend/src/framework/internal/components/Layout/Layout.tsx b/frontend/src/framework/internal/components/Layout/Layout.tsx new file mode 100644 index 000000000..731a6b8a9 --- /dev/null +++ b/frontend/src/framework/internal/components/Layout/Layout.tsx @@ -0,0 +1,398 @@ +import React, { type CSSProperties } from "react"; + +import { GuiEvent } from "@framework/GuiMessageBroker"; +import type { LayoutElement } from "@framework/internal/WorkbenchSession/Dashboard"; +import { DashboardTopic } from "@framework/internal/WorkbenchSession/Dashboard"; +import type { ModuleInstance } from "@framework/ModuleInstance"; +import type { Workbench } from "@framework/Workbench"; +import { useElementSize } from "@lib/hooks/useElementSize"; +import type { Size2D } from "@lib/utils/geometry"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; +import type { Vec2 } from "@lib/utils/vec2"; +import { WebAsset } from "@mui/icons-material"; +import { v4 } from "uuid"; + +import { ViewWrapper } from "../Content/private-components/ViewWrapper"; +import { ViewWrapperPlaceholder } from "../Content/private-components/viewWrapperPlaceholder"; + +import { LayoutOverlay } from "./components/LayoutOverlay"; +import { QuickSwitchDock } from "./components/QuickSwitchDock"; +import { + LayoutController, + DragSourceKind, + type DragSource, + type ResizeSource, + type LayoutControllerBindings, +} from "./LayoutController"; +import type { LayoutNode } from "./LayoutNode"; +import { makeLayoutNodes } from "./LayoutNode"; + +export type LayoutProps = { workbench: Workbench }; + +export const Layout: React.FC = (props: LayoutProps) => { + const guiMessageBroker = props.workbench.getGuiMessageBroker(); + const dashboard = props.workbench.getWorkbenchSession().getActiveDashboard(); + + // DOM refs / size + const containerRef = React.useRef(null); + const rootRef = React.useRef(null); + const viewportSize = useElementSize(containerRef); + + // Dashboard topics + const moduleInstances = usePublishSubscribeTopicValue(dashboard, DashboardTopic.ModuleInstances); + const trueLayout = usePublishSubscribeTopicValue(dashboard, DashboardTopic.Layout); + + // Temp layout for preview (controller drives this) + const [previewLayout, setPreviewLayout] = React.useState(null); + + const [rootNode, setRootNode] = React.useState(() => makeLayoutNodes(trueLayout)); + + // Drag overlay visuals + const [draggingModuleId, setDraggingModuleId] = React.useState(null); + const [dragPosition, setDragPosition] = React.useState(null); + const [pointerPos, setPointerPos] = React.useState(null); + const [tempPlaceholderId, setTempPlaceholderId] = React.useState(null); + const [cursor, setCursor] = React.useState("default"); + + // Build LayoutNode tree (true or temp) + const layoutElements = previewLayout ?? trueLayout; + + // Expose to controller via ref (stable reference) + const rootNodeRef = React.useRef(null); + rootNodeRef.current = rootNode; + + const isPreviewing = + !!previewLayout || // preview tree exists (drag/resize) + !!draggingModuleId || // dragging existing/new module + !!tempPlaceholderId; + + const bindings = React.useMemo( + function makeBindings() { + const bindings: LayoutControllerBindings = { + // Getters + getViewportSize: () => viewportSize as Size2D, + + // Effects + setRootNode: (node: LayoutNode) => { + setRootNode(node); + }, + setTempLayout: (next: LayoutElement[] | null) => setPreviewLayout(next), + setDragAndClientPosition: (dragPos: Vec2 | null, pointer: Vec2 | null) => { + setDragPosition(dragPos); + setPointerPos(pointer); + }, + setDraggingModuleId: (id: string | null) => setDraggingModuleId(id), + setTempPlaceholderId: (id: string | null) => setTempPlaceholderId(id), + setCursor: (c: CSSProperties["cursor"]) => setCursor(c), + + // Commits + commitLayout: (next: LayoutElement[]) => { + dashboard.setLayout(next); + }, + createModuleAndCommit: (moduleName: string, next: LayoutElement[], tempId: string) => { + // Show the preview layout immediately so the user sees a tile + setPreviewLayout(next); + + // Atomic create + tempId swap + single setLayout + const instance = dashboard.makeAndAddModuleInstance(moduleName); + const realId = instance.getId(); + + // replace tempId → realId in `next` + const patched = next.map((el) => + el.moduleInstanceId === tempId + ? { ...el, moduleInstanceId: realId, moduleName: instance.getName() } + : el, + ); + + dashboard.setLayout(patched); + + // Clear temp layout after commit + setPreviewLayout(null); + }, + + // Utilities + toLocalPx: (clientPos: Vec2) => { + const rect = containerRef.current?.getBoundingClientRect(); + return rect ? { x: clientPos.x - rect.left, y: clientPos.y - rect.top } : clientPos; + }, + scheduleFrame: (cb: FrameRequestCallback) => window.requestAnimationFrame(cb), + cancelFrame: (id: number) => window.cancelAnimationFrame(id), + }; + + return bindings; + }, + [viewportSize, dashboard], + ); + + const controllerRef = React.useRef(null); + if (!controllerRef.current) { + controllerRef.current = new LayoutController(bindings); + } + const controller = controllerRef.current; + + const anyModuleMaximized = React.useMemo(() => layoutElements.some((el) => el.maximized), [layoutElements]); + + React.useEffect( + function updateLayout() { + controller.setCommittedLayout(trueLayout); + + if (!isPreviewing) { + setRootNode(makeLayoutNodes(trueLayout)); + } + }, + [trueLayout, controller, isPreviewing], + ); + + // keep the single instance's bindings fresh + React.useEffect( + function updateBindings() { + controller.updateBindings(bindings); + }, + [controller, bindings], + ); + + React.useEffect(() => { + controller.attach(); + return () => controller.detach(); + }, [controller]); + + React.useEffect( + function makeGuiSubscriptions() { + const unsubHeader = guiMessageBroker.subscribeToEvent( + GuiEvent.ModuleHeaderPointerDown, + (payload: { + moduleInstanceId: string; + elementPosition: Vec2; // client coords + elementSize: Size2D; + pointerPosition: Vec2; // client coords + }) => { + controller.startDrag({ + kind: DragSourceKind.EXISTING, + id: payload.moduleInstanceId, + elementPos: payload.elementPosition, + elementSize: payload.elementSize, + pointerDownClientPos: payload.pointerPosition, + } as DragSource); + }, + ); + + const unsubNew = guiMessageBroker.subscribeToEvent( + GuiEvent.NewModulePointerDown, + (payload: { + moduleName: string; + elementPosition: Vec2; // client coords (tray item) + elementSize: Size2D; + pointerPosition: Vec2; // client coords + }) => { + const tempId = v4(); + controller.startDrag({ + kind: DragSourceKind.NEW, + id: tempId, + moduleName: payload.moduleName, + elementPos: payload.elementPosition, + elementSize: payload.elementSize, + pointerDownClientPos: payload.pointerPosition, + } as DragSource); + }, + ); + + const unsubRemove = guiMessageBroker.subscribeToEvent( + GuiEvent.RemoveModuleInstanceRequest, + (payload: { moduleInstanceId: string }) => { + const current = (previewLayout ?? trueLayout) as LayoutElement[]; + + // 2) Remove the element and repack to fill 100% + const remaining = current.filter((el) => el.moduleInstanceId !== payload.moduleInstanceId); + const adjusted = makeLayoutNodes(remaining).toLayout(); + + // 3) Optimistic preview to avoid flicker (optional but nice) + setPreviewLayout(adjusted); + + // 4) Do the actual cleanup (channels, stores, etc.) + dashboard.removeModuleInstance(payload.moduleInstanceId); + + // 5) Override Dashboard's naive filtered layout with our adjusted one + dashboard.setLayout(adjusted); + + // 6) Clear temp — trueLayout now equals `adjusted` + setPreviewLayout(null); + }, + ); + + return function removeGuiSubscriptions() { + unsubHeader(); + unsubNew(); + unsubRemove(); + }; + }, + [guiMessageBroker, controller, dashboard, previewLayout, trueLayout], + ); + + const onContainerPointerMove = React.useCallback( + function onContainerPointerMove(e: React.PointerEvent) { + if (!rootNodeRef.current || isPreviewing) return; + const local = bindings.toLocalPx({ x: e.clientX, y: e.clientY }); + const hit = rootNodeRef.current.hitTestDivider(local, viewportSize); + const next = !hit ? "default" : hit.axis === "vertical" ? "ew-resize" : "ns-resize"; + if (cursor !== next) { + setCursor(next); + } + }, + [bindings, viewportSize, cursor, isPreviewing], + ); + + const onContainerPointerDown = React.useCallback( + function onContainerPointerDown(e: React.PointerEvent) { + if (anyModuleMaximized || isPreviewing) return; + if (!rootNodeRef.current) return; + const clientPos = { x: e.clientX, y: e.clientY }; + const localPos = bindings.toLocalPx(clientPos); + const hit = rootNodeRef.current.hitTestDivider(localPos, viewportSize); + if (!hit) { + return; + } + + controller.startResize({ + axis: hit.axis, + containerPath: hit.containerPath, + index: hit.index, + pointerDownClientPos: clientPos, + } as ResizeSource); + + // prevent text selection/scroll during resize + e.preventDefault(); + e.stopPropagation(); + }, + [controller, bindings, viewportSize, anyModuleMaximized, isPreviewing], + ); + + const convertLayoutRectToRealRect = React.useCallback(function convertLayoutRectToRealRect( + el: LayoutElement, + size: Size2D, + ) { + return { + x: el.relX * size.width, + y: el.relY * size.height, + width: el.relWidth * size.width, + height: el.relHeight * size.height, + }; + }, []); + + const computeModuleLayoutProps = React.useCallback( + (instance: ModuleInstance) => { + const el = layoutElements.find((le) => le.moduleInstanceId === instance.getId()); + if (!el) return null; + const rect = convertLayoutRectToRealRect(el, viewportSize); + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + isMaximized: el.maximized, + isMinimized: el.minimized, + }; + }, + [layoutElements, viewportSize, convertLayoutRectToRealRect], + ); + + const onContainerPointerLeave = React.useCallback(function onContainerPointerLeave() { + setCursor("default"); + }, []); + + const handleFullscreenModuleChange = React.useCallback( + function handleFullscreenModuleChange(moduleInstanceId: string) { + const newLayout = layoutElements.map((el) => { + if (el.moduleInstanceId === moduleInstanceId) { + return { ...el, maximized: true }; + } else if (el.maximized) { + return { ...el, maximized: false }; + } + return el; + }); + + dashboard.setActiveModuleInstanceId(moduleInstanceId); + + dashboard.setLayout(newLayout); + }, + [dashboard, layoutElements], + ); + + return ( +
+
+ {/* Edges / sashes hover highlights */} + {rootNode && ( + + )} + + {/* Modules */} + {moduleInstances.map((instance) => { + const layoutProps = computeModuleLayoutProps(instance); + if (!layoutProps) return null; + + const isDragged = draggingModuleId === instance.getId(); + return ( + + ); + })} + + {/* Quick switch dock */} + { + const el = moduleInstances.find((mi) => mi.getId() === moduleInstanceId); + return el ? el.getTitle() : undefined; + }} + /> + + {/* Placeholder for NEW module while dragging */} + {tempPlaceholderId && + (() => { + const el = layoutElements.find((le) => le.moduleInstanceId === tempPlaceholderId); + if (!el) return null; + const r = convertLayoutRectToRealRect(el, viewportSize); + return ( + + ); + })()} + + {/* Empty-state hint */} + {moduleInstances.length === 0 && ( +
+ + Drag modules here to add them to the layout +
+ )} +
+
+ ); +}; diff --git a/frontend/src/framework/internal/components/Layout/LayoutController.ts b/frontend/src/framework/internal/components/Layout/LayoutController.ts new file mode 100644 index 000000000..cfa048917 --- /dev/null +++ b/frontend/src/framework/internal/components/Layout/LayoutController.ts @@ -0,0 +1,755 @@ +import type { CSSProperties } from "react"; + +import type { LayoutElement } from "@framework/internal/WorkbenchSession/Dashboard"; +import { MANHATTAN_LENGTH, type Size2D } from "@lib/utils/geometry"; +import { point2Distance, type Vec2 } from "@lib/utils/vec2"; +import { globalLog } from "@src/Log"; +import { isEqual } from "lodash"; + +import { LayoutNode, LayoutAxis, type LayoutNodeEdge, makeLayoutNodes } from "./LayoutNode"; + +export type LayoutControllerBindings = { + // State getters + getViewportSize: () => Size2D; + + // React-side effects + setRootNode: (rootNode: LayoutNode) => void; + setTempLayout: (tempLayout: LayoutElement[] | null) => void; + setDragAndClientPosition: (dragPosition: Vec2 | null, clientPos: Vec2 | null) => void; + setDraggingModuleId: (id: string | null) => void; + setTempPlaceholderId: (id: string | null) => void; + setCursor: (cursor: CSSProperties["cursor"]) => void; + + // Dashboard ops + commitLayout: (next: LayoutElement[]) => void; + createModuleAndCommit: (moduleName: string, next: LayoutElement[], tempId: string) => void; + + // Utilities + toLocalPx: (clientPos: Vec2) => Vec2; + scheduleFrame: (callback: FrameRequestCallback) => number; + cancelFrame: (id: number) => void; +}; + +export enum DragSourceKind { + EXISTING = "EXISTING", + NEW = "NEW", +} + +enum ModeKind { + IDLE = "idle", + DRAG_POINTER_DOWN = "drag-pointer-down", + DRAG = "drag", + RESIZE = "resize", +} + +export type DragSource = { + id: string; + elementPos: Vec2; + elementSize: Size2D; + pointerDownClientPos: Vec2; +} & ({ kind: DragSourceKind.EXISTING } | { kind: DragSourceKind.NEW; moduleName: string }); + +function isDragSourceKindNew(src: DragSource): src is DragSource & { kind: DragSourceKind.NEW; moduleName: string } { + return src.kind === DragSourceKind.NEW; +} + +enum Phase { + IDLE = "idle", // No interaction + HOVER = "hover", // Showing where the user is going to add the module + PREVIEW = "preview", // Showing a preview of the new layout +} + +export type ResizeSource = { + axis: LayoutAxis; + containerPath: number[]; + index: number; + pointerDownClientPos: Vec2; +}; + +type Mode = + | { kind: ModeKind.IDLE } + | { + kind: ModeKind.DRAG_POINTER_DOWN; + source: DragSource; + pointerOffset: { x: number; y: number }; + lastClientPos: Vec2; + } + | { + kind: ModeKind.DRAG; + source: DragSource; + lastClientPos: Vec2; + pointerOffset: { x: number; y: number }; + } + | { kind: ModeKind.RESIZE; src: ResizeSource; lastClientPos: Vec2 }; + +type HoverTarget = { + containerPath: number[]; + edge: LayoutNodeEdge; +}; + +const HOVER_DWELL_MS = 500; // wait this long on same edge before previewing +const EXIT_DWELL_MS = 500; // wait this long after leaving edge before clearing preview +const PREVIEW_STICKY_PAD_PX = 10; // px padding around the preview to keep it visible when moving away from the edge +const RESIZE_SNAP_STEP = 0.01; // 1% steps (tune to taste) +const RESIZE_MIN_TILE_SIZE: Size2D = { width: 200, height: 200 }; // minimum tile size in px + +const logger = globalLog.registerLogger("LayoutController"); + +export class LayoutController { + // Bindings to the React side + private _bindings: LayoutControllerBindings; + + // The current interaction mode, either idle, dragging, or resizing + private _mode: Mode = { kind: ModeKind.IDLE }; + + // the current requestAnimationFrame ID, or null if not scheduled + private _rafId: number | null = null; + + // Last local position of the pointer in px, used for scheduling updates + private _lastLocalPos: Vec2 | null = null; + + // The current hover target, if any + // This is the edge and container path where the pointer is hovering + // Used to determine when to show the hover arrows and when to promote to preview + // null if not hovering over any edge + private _hoverTarget: HoverTarget | null = null; + + private _activeRootNode: LayoutNode | null = null; + private _previewRootNode: LayoutNode | null = null; + private _isPreviewing: boolean = false; + + private _hoverLocalPos: Vec2 | null = null; + private _hoverTimerId: ReturnType | null = null; + private _cancelTimerId: ReturnType | null = null; + private _phase: Phase = Phase.IDLE; + + // Bound handler refs (single stable references for add/remove) + private _boundOnPointerMove: (e: PointerEvent) => void; + private _boundOnPointerUp: (e: PointerEvent) => void; + private _boundOnPointerCancel: (e: PointerEvent) => void; + private _boundOnKeyDown: (e: KeyboardEvent) => void; + + constructor(bindings: LayoutControllerBindings) { + this._bindings = bindings; + + this._boundOnPointerMove = this.onPointerMove.bind(this); + this._boundOnPointerUp = this.onPointerUp.bind(this); + this._boundOnPointerCancel = this.onPointerCancel.bind(this); + this._boundOnKeyDown = this.onKeyDown.bind(this); + + logger.console?.log("Initialized"); + } + + attach() { + logger.console?.log("Attaching event listeners"); + document.addEventListener("pointermove", this._boundOnPointerMove, { passive: false }); + document.addEventListener("pointerup", this._boundOnPointerUp, { passive: false }); + document.addEventListener("pointercancel", this._boundOnPointerCancel, { passive: false }); + document.addEventListener("keydown", this._boundOnKeyDown); + } + + detach() { + logger.console?.log("Detaching event listeners"); + document.removeEventListener("pointermove", this._boundOnPointerMove); + document.removeEventListener("pointerup", this._boundOnPointerUp); + document.removeEventListener("pointercancel", this._boundOnPointerCancel); + document.removeEventListener("keydown", this._boundOnKeyDown); + + this.cancelInteraction(); + } + + setCommittedLayout(elements: LayoutElement[]) { + this._activeRootNode = makeLayoutNodes(elements); + if (!this._isPreviewing) { + this._bindings.setRootNode(this._activeRootNode); + this._bindings.setTempLayout(null); + } + } + + private currentRootNode(): LayoutNode | null { + return this._previewRootNode ?? this._activeRootNode; + } + + private pushPreviewRootNode(): void { + if (!this._previewRootNode) return; + this._bindings.setRootNode(this._previewRootNode.clone()); + this._bindings.setTempLayout(this._previewRootNode.toLayout()); + } + + /** Ensure we have a frozen preview root. Push an initial snapshot to the view. */ + private ensurePreviewRootNode(): LayoutNode | null { + if (!this._previewRootNode) { + this._previewRootNode = this._activeRootNode ? this._activeRootNode.clone() : null; + if (this._previewRootNode) { + // Push first preview snapshot + this._bindings.setRootNode(this._previewRootNode); + this._bindings.setTempLayout(this._previewRootNode.toLayout()); + } + } + this.setIsPreviewing(true); + return this._previewRootNode; + } + + startDrag(dragSource: DragSource) { + this.clearHoverTimer(); + this.clearCancelTimer(); + this._hoverTarget = null; + this._hoverLocalPos = null; + this._lastLocalPos = null; + this.setPhase(Phase.IDLE); + + logger.console?.log("Starting drag", dragSource); + + // Ensure we have a preview root node + this.ensurePreviewRootNode(); + + // Compute offset so the card doesn't jump + const localPointerDown = this._bindings.toLocalPx(dragSource.pointerDownClientPos); + const localElementTopLeft = this._bindings.toLocalPx(dragSource.elementPos); + const pointerOffset = { + x: localPointerDown.x - localElementTopLeft.x, + y: localPointerDown.y - localElementTopLeft.y, + }; + + this._mode = { + kind: ModeKind.DRAG_POINTER_DOWN, + source: dragSource, + pointerOffset, + lastClientPos: dragSource.pointerDownClientPos, + }; + } + + private setIsPreviewing(isPreviewing: boolean) { + if (this._isPreviewing === isPreviewing) return; + this._isPreviewing = isPreviewing; + logger.console?.log("Setting isPreviewing to", isPreviewing); + } + + startResize(src: ResizeSource) { + logger.console?.log("Starting resize", src); + this._mode = { + kind: ModeKind.RESIZE, + src, + lastClientPos: src.pointerDownClientPos, + }; + + // lock the appropriate resize cursor for the duration of the resize + this._bindings.setCursor(src.axis === LayoutAxis.VERTICAL ? "ew-resize" : "ns-resize"); + + this.ensurePreviewRootNode(); + + // first preview immediately + const local = this._bindings.toLocalPx(src.pointerDownClientPos); + this.queueResizePreview(local); + } + + updateBindings(next: LayoutControllerBindings) { + logger.console?.log("Updating bindings"); + this._bindings = next; + } + + cancelInteraction() { + logger.console?.log("Cancelling interaction"); + + this.cancelAnyScheduledFrame(); + this.clearHoverTimer(); + this.clearCancelTimer(); + this.setPhase(Phase.IDLE); + this._mode = { kind: ModeKind.IDLE }; + this._hoverTarget = null; + this._hoverLocalPos = null; + this._bindings.setTempLayout(null); + this._bindings.setDragAndClientPosition(null, null); + this._bindings.setDraggingModuleId(null); + this._bindings.setTempPlaceholderId(null); + this._bindings.setCursor("default"); + this.setIsPreviewing(false); + } + + private setPhase(phase: Phase) { + logger.console?.log("Setting phase", phase); + if (this._phase === phase) { + return; // no change + } + this._phase = phase; + } + + private isOutOfViewport(local: Vec2): boolean { + const vp = this._bindings.getViewportSize(); + const M = 25; // px margin + return local.x < -M || local.y < -M || local.x > vp.width + M || local.y > vp.height + M; + } + + private onPointerMove(e: PointerEvent) { + if (this._mode.kind === ModeKind.IDLE) return; + + // suppress scroll/selection during interactions + e.preventDefault(); + e.stopPropagation(); + + this._mode.lastClientPos = { x: e.clientX, y: e.clientY }; + const local = this._bindings.toLocalPx(this._mode.lastClientPos); + + if (this._mode.kind === ModeKind.DRAG_POINTER_DOWN) { + const distance = point2Distance(this._mode.source.pointerDownClientPos, this._mode.lastClientPos); + + if (distance < MANHATTAN_LENGTH) { + return; // not far enough to start dragging + } + + logger.console?.log("Transitioning to drag mode"); + + // Transition to drag mode + this._mode = { + kind: ModeKind.DRAG, + source: this._mode.source, + lastClientPos: this._mode.lastClientPos, + pointerOffset: this._mode.pointerOffset, + }; + this._bindings.setDraggingModuleId(this._mode.source.id); + if (isDragSourceKindNew(this._mode.source)) { + this._bindings.setTempPlaceholderId(this._mode.source.id); + } + this._bindings.setCursor("grabbing"); + const dragPos = { + x: local.x - this._mode.pointerOffset.x, + y: local.y - this._mode.pointerOffset.y, + }; + this._bindings.setDragAndClientPosition(dragPos, local); + this.queueDragPreview(local); + this.setPhase(Phase.HOVER); + this.setIsPreviewing(true); + return; + } + + if (this._mode.kind === ModeKind.DRAG) { + if (this.isOutOfViewport(local)) { + logger.console?.log("Pointer moved out of viewport, cancelling interaction"); + + this._bindings.setTempLayout(null); + this._bindings.setDragAndClientPosition(null, null); + this._hoverTarget = null; + this._hoverLocalPos = null; + this.clearHoverTimer(); + this.clearCancelTimer(); + this._previewRootNode = null; + + return; + } + + this.updateDragPosition(); + this.queueDragPreview(local); + } else if (this._mode.kind === ModeKind.RESIZE) { + this.queueResizePreview(local); + } + } + + private updateDragPosition(): void { + if (this._mode.kind !== ModeKind.DRAG) return; + + const localClientPos = this._bindings.toLocalPx(this._mode.lastClientPos); + + const draggedId = this._mode.source.id; + const draggedSize = this.calcModuleInstanceSize(draggedId); + if (!draggedSize) { + return; + } + + const relativePointerOffset: Vec2 = { + x: this._mode.pointerOffset.x / this._mode.source.elementSize.width, + y: this._mode.pointerOffset.y / this._mode.source.elementSize.height, + }; + + const newAbsolutePointerOffset: Vec2 = { + x: localClientPos.x - relativePointerOffset.x * draggedSize.width, + y: localClientPos.y - this._mode.pointerOffset.y, + }; + + this._bindings.setDragAndClientPosition(newAbsolutePointerOffset, localClientPos); + } + + private calcModuleInstanceSize(moduleInstanceId: string): Size2D | null { + if (this._mode.kind !== ModeKind.DRAG) { + return null; // not in drag mode + } + const layoutElements = this._previewRootNode?.toLayout(); + if (!layoutElements) { + return this._mode.source.elementSize; // if no temp layout, use the original size + } + + const element = layoutElements.find((el) => el.moduleInstanceId === moduleInstanceId); + if (!element) return null; + + const viewportSize = this._bindings.getViewportSize(); + return { + width: element.relWidth * viewportSize.width, + height: element.relHeight * viewportSize.height, + }; + } + + private synthesizeHoverPreviewLayout(): LayoutElement[] | null { + logger.console?.log("Synthesizing hover preview layout"); + if (this._mode.kind !== ModeKind.DRAG || !this._hoverTarget || !this._hoverLocalPos) { + logger.console?.log("No hover target or local position, returning null"); + return null; + } + + const root = this.currentRootNode(); + if (!root) { + logger.console?.log("No root node, returning null"); + return null; + } + + const isNew = this._mode.source.kind === DragSourceKind.NEW; + const pre = root.previewLayout( + this._hoverLocalPos, + this._bindings.getViewportSize(), + this._mode.source.id, + isNew, + ); + + return pre ? pre.toLayout() : null; + } + + private onPointerUp() { + if (this._mode.kind === ModeKind.IDLE) return; + + // 1) Unlock cursor immediately (visual feedback) + this._bindings.setCursor("default"); + + if (this._mode.kind === ModeKind.DRAG) { + const source = this._mode.source; + + // --- Decide what to commit BEFORE clearing hover/temp state --- + let toCommit: LayoutElement[] | null = this._previewRootNode?.toLayout() ?? null; + + // If no preview yet (never promoted) but we're in HOVER, + // synthesize a one-off preview at the last hover position. + if (!toCommit && this._phase === Phase.HOVER) { + toCommit = this.synthesizeHoverPreviewLayout(); + } + + /* + + // NEW module empty-dashboard fallback (unchanged) + if (isDragSourceKindNew(source) && (!toCommit || toCommit.length === 0) && this._hoverTarget) { + const empty = !this._activeRootNode || this._activeRootNode.getChildren().length === 0; + if (empty) { + toCommit = [ + { + relX: 0, + relY: 0, + relWidth: 1, + relHeight: 1, + moduleInstanceId: source.id, + moduleName: source.moduleName, + }, + ]; + } + } + */ + + // --- Now clear UI state --- + this.clearHoverTimer(); + this.clearCancelTimer(); + // (DON'T null _hoverLocalPos before synthesize; we did synthesis above) + this._hoverTarget = null; + this._hoverLocalPos = null; + + this._bindings.setDragAndClientPosition(null, null); + this._bindings.setDraggingModuleId(null); + this._bindings.setTempPlaceholderId(null); + + // --- Commit --- + if (isDragSourceKindNew(source)) { + if (toCommit && toCommit.length) { + this._bindings.createModuleAndCommit(source.moduleName, toCommit, source.id); + } else { + this._bindings.setTempLayout(null); // cancel add + } + } else { + if (toCommit && toCommit.length) { + this._bindings.commitLayout(toCommit); + } + this._bindings.setTempLayout(null); + } + } + + if (this._mode.kind === ModeKind.RESIZE) { + // Commit resized layout from the preview root; cancel if none + const previewElements = this._previewRootNode?.toLayout() ?? null; + if (previewElements && previewElements.length) { + this._bindings.commitLayout(previewElements); + } + this._bindings.setTempLayout(null); + } + + // 5) Exit preview mode (single place) + this._previewRootNode = null; + this.setIsPreviewing(false); + + // 6) Controller housekeeping + this.cancelAnyScheduledFrame(); + this._mode = { kind: ModeKind.IDLE }; + this.setPhase(Phase.IDLE); + } + + private onPointerCancel() { + logger.console?.log("Pointer interaction cancelled"); + this.cancelInteraction(); + } + + private onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + this.cancelInteraction(); + } + } + + private scheduleFrame(fn: () => void) { + if (this._rafId != null) { + this.cancelAnyScheduledFrame(); + } + logger.console?.log("Scheduling frame for drag/resize update"); + this._rafId = this._bindings.scheduleFrame(() => { + this._rafId = null; + fn(); + }); + } + + private cancelAnyScheduledFrame() { + if (this._rafId != null) { + logger.console?.log("Cancelling scheduled frame"); + this._bindings.cancelFrame(this._rafId); + this._rafId = null; + } + } + + private queueDragPreview(localPos: Vec2) { + this._lastLocalPos = localPos; + this.scheduleFrame(() => this.updateDragPreview()); + } + + private queueResizePreview(localPos: Vec2) { + this._lastLocalPos = localPos; + this.scheduleFrame(() => this.updateResizePreview()); + } + + private updateDragPreview() { + if (this._mode.kind !== ModeKind.DRAG) { + logger.console?.log("Not in drag mode, skipping drag preview update"); + return; + } + + const root = this.currentRootNode(); + if (!root) { + logger.console?.log("No root node, skipping drag preview update"); + return; + } + + const local = this._lastLocalPos ?? this._bindings.toLocalPx(this._mode.lastClientPos); + const viewportSize = this._bindings.getViewportSize(); + + // empty layout: promote immediately + if (this._mode.source.kind === DragSourceKind.NEW && root.getChildren().length === 0) { + logger.console?.log("Empty layout, promoting to preview immediately"); + const pre = root.previewLayout(local, viewportSize, this._mode.source.id, true); + if (pre) { + this._previewRootNode = pre; + this.pushPreviewRootNode(); + } + return; + } + + // edge hover detection (arrows phase) + const container = root.findBoxContainingPoint(local, viewportSize); + if (!container) { + logger.console?.log("Pointer not over any container, clearing hover and cancel timers"); + this._hoverTarget = null; + this._hoverLocalPos = null; + this.clearHoverTimer(); + if (this._phase === Phase.PREVIEW) { + if (this.isOverPreviewPlaceholder(local)) { + this.clearCancelTimer(); + } else { + this.armCancelTimer(); + } + } + return; + } + + const edge = container.findEdgeContainingPoint(local, viewportSize, this._mode.source.id); + if (!edge) { + logger.console?.log("Pointer not over any edge, clearing hover and cancel timers"); + this._hoverTarget = null; + this._hoverLocalPos = null; + this.clearHoverTimer(); + if (this._phase === Phase.PREVIEW) { + if (this.isOverPreviewPlaceholder(local)) { + logger.console?.log("Pointer over preview placeholder, clearing cancel timer"); + this.clearCancelTimer(); + } else { + logger.console?.log("Pointer over preview placeholder, arming cancel timer"); + this.armCancelTimer(); + } + } + return; + } + + logger.console?.log("Pointer over edge", edge, "in container", container.pathFromRoot()); + + // Over a valid edge + // this.clearHoverTimer(); + // this.clearCancelTimer(); + + const key = { containerPath: container.pathFromRoot(), edge: edge }; + const same = + this._hoverTarget && + isEqual(key.edge, this._hoverTarget.edge) && + key.containerPath.length === this._hoverTarget.containerPath.length && + key.containerPath.every((v, i) => v === this._hoverTarget!.containerPath[i]); + + if (!same) { + logger.console?.log("Pointer moved to a new edge, updating hover target"); + this.clearCancelTimer(); + // new edge under pointer -> start dwell timer + this._hoverTarget = key; + this._hoverLocalPos = local; + this.armHoverTimer(); + return; // show arrows only until timer fires + } + } + + private updateResizePreview() { + if (this._mode.kind !== ModeKind.RESIZE) return; + + const root = this._previewRootNode; + if (!root) return; + + const local = this._lastLocalPos ?? this._bindings.toLocalPx(this._mode.lastClientPos); + const viewport = this._bindings.getViewportSize(); + + const clone = root.clone(); + + const container = LayoutNode.findByPath(clone, this._mode.src.containerPath); + if (!container) { + this._bindings.setTempLayout(null); + return; + } + + const abs = container.getAbsoluteRect(); // in 0..1 relative to root + + // map local px to container-relative [0..1] along the axis + const pos01 = + this._mode.src.axis === LayoutAxis.VERTICAL + ? (local.x / viewport.width - abs.x) / abs.width + : (local.y / viewport.height - abs.y) / abs.height; + + // clamp and snap + const clamp01 = (v: number) => Math.max(0, Math.min(1, v)); + const snap = (v: number, step: number) => Math.round(v / step) * step; + + const resizeMinTileFractions: Size2D = { + width: RESIZE_MIN_TILE_SIZE.width / viewport.width, + height: RESIZE_MIN_TILE_SIZE.height / viewport.height, + }; + + container.resizeAtDivider( + this._mode.src.index, + this._mode.src.axis, + snap(clamp01(pos01), RESIZE_SNAP_STEP), + resizeMinTileFractions, + ); + + this._previewRootNode = clone; + this.pushPreviewRootNode(); + } + + private clearHoverTimer() { + if (this._hoverTimerId) { + clearTimeout(this._hoverTimerId); + this._hoverTimerId = null; + } + } + + private clearCancelTimer() { + if (this._cancelTimerId) { + clearTimeout(this._cancelTimerId); + this._cancelTimerId = null; + } + } + + private armCancelTimer() { + this.clearCancelTimer(); + this._cancelTimerId = setTimeout(() => { + this.cancelPreviewForHoverExit(); + }, EXIT_DWELL_MS); + } + + private armHoverTimer() { + this.clearHoverTimer(); + this._hoverTimerId = setTimeout(() => { + this.promoteHoverToPreview(); + }, HOVER_DWELL_MS); + } + + private promoteHoverToPreview() { + this.clearCancelTimer(); + if (this._mode.kind !== ModeKind.DRAG || !this._hoverTarget || !this._hoverLocalPos) { + logger.console?.log("Not in drag mode or no hover target, skipping preview promotion"); + return; + } + + const root = this.currentRootNode(); + if (!root) { + logger.console?.log("No root node, cannot promote hover to preview"); + return; + } + + const isNew = this._mode.source.kind === DragSourceKind.NEW; + const pre = root.previewLayout( + this._hoverLocalPos, + this._bindings.getViewportSize(), + this._mode.source.id, + isNew, + ); + if (!pre) { + logger.console?.log("No preview layout created"); + return; + } + + this._previewRootNode = pre; + this.updateDragPosition(); + this.pushPreviewRootNode(); + } + + private cancelPreviewForHoverExit() { + this._hoverTarget = null; + this._hoverLocalPos = null; + this.clearHoverTimer(); + this._bindings.setTempLayout(null); + this.setPhase(Phase.HOVER); + } + + private isOverPreviewPlaceholder(local: Vec2): boolean { + if (this._mode.kind !== ModeKind.DRAG || !this._previewRootNode) { + return false; + } + + const layout: LayoutElement[] | null = this._previewRootNode.toLayout(); + + if (!layout) return false; + + const draggedId = this._mode.source.id; + const element = layout.find((le) => le.moduleInstanceId === draggedId); + if (!element) return false; + + const viewportSize = this._bindings.getViewportSize(); + const x = element.relX * viewportSize.width; + const y = element.relY * viewportSize.height; + const width = element.relWidth * viewportSize.width; + const height = element.relHeight * viewportSize.height; + + const pad = PREVIEW_STICKY_PAD_PX; // padding around the placeholder + return local.x >= x - pad && local.x <= x + width + pad && local.y >= y - pad && local.y <= y + height + pad; + } +} diff --git a/frontend/src/framework/internal/components/Layout/LayoutNode.ts b/frontend/src/framework/internal/components/Layout/LayoutNode.ts new file mode 100644 index 000000000..0f620e7e2 --- /dev/null +++ b/frontend/src/framework/internal/components/Layout/LayoutNode.ts @@ -0,0 +1,1186 @@ +import type { LayoutElement } from "@framework/internal/WorkbenchSession/Dashboard"; +import type { Rect2D, Size2D } from "@lib/utils/geometry"; +import { outerRectContainsInnerRect, rectContainsPoint } from "@lib/utils/geometry"; +import type { Vec2 } from "@lib/utils/vec2"; + +function layoutElementToRect(layoutElement: LayoutElement): Rect2D { + return { + x: layoutElement.relX, + y: layoutElement.relY, + width: layoutElement.relWidth, + height: layoutElement.relHeight, + }; +} + +export const LAYOUT_BOX_DROP_MARGIN = 25; +export const LAYOUT_BOX_RESIZE_MARGIN = 5; +export const EDGE_DROP_WEIGHT = 50; +export const EDGE_RESIZE_WEIGHT = 5; +export const MIN_FRAME_PX = 4; // minimal inner frame to keep overlays visible +export const MIN_EDGE_PX = 6; // minimal thickness for drop/resize edges + +const EPSILON = 1e-6; + +function nearlyEqual(a: number, b: number, epsilon: number = EPSILON): boolean { + return Math.abs(a - b) <= epsilon; +} + +function dedupeWithEpsilon(sortedNumbers: number[], epsilon: number = EPSILON): number[] { + const result: number[] = []; + for (let i = 0; i < sortedNumbers.length; i++) { + const value = sortedNumbers[i]; + if (i === 0 || !nearlyEqual(value, sortedNumbers[i - 1], epsilon)) { + result.push(value); + } + } + return result; +} + +export enum LayoutNodeEdgeType { + TOP = "top", + BOTTOM = "bottom", + LEFT = "left", + RIGHT = "right", + HORIZONTAL = "horizontal", + VERTICAL = "vertical", +} + +export enum LayoutDirection { + HORIZONTAL = "horizontal", + VERTICAL = "vertical", + SINGLE = "single", + MAIN = "main", +} + +export enum LayoutAxis { + HORIZONTAL = "horizontal", + VERTICAL = "vertical", +} + +export type LayoutNodeEdge = + | { + edge: Exclude; + rect: Rect2D; + } + | { + position: number; + edge: LayoutNodeEdgeType.HORIZONTAL | LayoutNodeEdgeType.VERTICAL; + rect: Rect2D; + }; + +export class LayoutNode { + private _rectRelativeToParent: Rect2D; + private _children: LayoutNode[]; + private _level: number; + private _moduleInstanceId: string | undefined; + private _moduleName: string; + private _isWrapper: boolean; + private _parent: LayoutNode | null; + private _layoutDirection: LayoutDirection; + private _isNewInParent: boolean = false; + + constructor( + rect: Rect2D, + direction: LayoutDirection, + parent: LayoutNode | null = null, + level = 0, + children: LayoutNode[] = [], + ) { + this._rectRelativeToParent = rect; + this._children = children; + this._level = level; + this._isWrapper = true; + + this._moduleInstanceId = ""; + this._moduleName = ""; + this._parent = parent; + this._layoutDirection = direction; + } + + getRect(): Rect2D { + return this._rectRelativeToParent; + } + + getModuleInstanceId(): string | undefined { + return this._moduleInstanceId; + } + + getRectWithMargin(realSizeFactor: Size2D): Rect2D { + const absoluteRect = this.getAbsoluteRect(); + + const absoluteWidth = absoluteRect.width * realSizeFactor.width; + const absoluteHeight = absoluteRect.height * realSizeFactor.height; + + const lvl = this._level; + const parentLvl = this._parent?._level ?? lvl; + + let mxLvl = parentLvl; + if (this._parent === null || this._parent._layoutDirection === LayoutDirection.HORIZONTAL) { + mxLvl = lvl; + } + + let myLvl = parentLvl; + if (this._parent === null || this._parent._layoutDirection !== LayoutDirection.HORIZONTAL) { + myLvl = lvl; + } + + // raw margins in px + const rawMx = LAYOUT_BOX_DROP_MARGIN * mxLvl; + const rawMy = LAYOUT_BOX_DROP_MARGIN * myLvl; + + // clamp so we never invert the rect; also cap to a fraction to stay sane + const marginX = Math.min(rawMx, Math.max(0, (absoluteWidth - MIN_FRAME_PX) / 2)); + const marginY = Math.min(rawMy, Math.max(0, (absoluteHeight - MIN_FRAME_PX) / 2)); + + return { + x: absoluteRect.x * realSizeFactor.width + marginX, + y: absoluteRect.y * realSizeFactor.height + marginY, + width: Math.max(MIN_FRAME_PX, absoluteWidth - 2 * marginX), + height: Math.max(MIN_FRAME_PX, absoluteHeight - 2 * marginY), + }; + } + + toString(): string { + const parts: string[] = []; + + /* eslint-disable-next-line @typescript-eslint/no-this-alias */ + let current: LayoutNode | null = this; + + while (current) { + const r = current._rectRelativeToParent; + parts.push(`LayoutNode(${r.x}, ${r.y}, ${r.width}, ${r.height})`); + current = current._parent; + } + return parts.join(" > "); + } + + log(): string { + let text = ""; + for (let i = 0; i < this._level; i++) { + text += " "; + } + text += `${this._rectRelativeToParent.x}, ${this._rectRelativeToParent.y}, ${this._rectRelativeToParent.width}, ${this._rectRelativeToParent.height}\n`; + for (const child of this._children) { + text += child.log(); + } + return text; + } + + transformRectToRelative(rect: Rect2D): Rect2D { + let newRect = { + x: (rect.x - this._rectRelativeToParent.x) / this._rectRelativeToParent.width, + y: (rect.y - this._rectRelativeToParent.y) / this._rectRelativeToParent.height, + width: rect.width / this._rectRelativeToParent.width, + height: rect.height / this._rectRelativeToParent.height, + }; + + if (this._parent !== null) { + newRect = this._parent.transformRectToRelative(newRect); + } + + return newRect; + } + + getAbsoluteRect(): Rect2D { + if (this._parent === null) { + return this._rectRelativeToParent; + } + return this._parent.transformRectToAbsolute(this._rectRelativeToParent); + } + + private transformRectToAbsolute(rect: Rect2D): Rect2D { + let newRect = { + x: rect.x * this._rectRelativeToParent.width + this._rectRelativeToParent.x, + y: rect.y * this._rectRelativeToParent.height + this._rectRelativeToParent.y, + width: rect.width * this._rectRelativeToParent.width, + height: rect.height * this._rectRelativeToParent.height, + }; + + if (this._parent !== null) { + newRect = this._parent.transformRectToAbsolute(newRect); + } + return newRect; + } + + private findVerticalCuts(elements: LayoutElement[], parentBox: Rect2D): number[] { + // collect all candidate x positions (tile edges + parent edges) + const candidateEdges: number[] = []; + candidateEdges.push(parentBox.x); + candidateEdges.push(parentBox.x + parentBox.width); + + for (const element of elements) { + const left = element.relX; + const right = element.relX + element.relWidth; + candidateEdges.push(left); + candidateEdges.push(right); + } + + candidateEdges.sort((a, b) => a - b); + const uniqueEdges: number[] = dedupeWithEpsilon(candidateEdges); + + const cuts: number[] = []; + for (const xCut of uniqueEdges) { + let crosses = false; + for (const el of elements) { + const left = el.relX; + const right = el.relX + el.relWidth; + if (left + EPSILON < xCut && xCut < right - EPSILON) { + crosses = true; + break; + } + } + if (!crosses) cuts.push(xCut); + } + + return cuts; + } + + private findHorizontalCuts(elements: LayoutElement[], parentBox: Rect2D): number[] { + const candidateEdges: number[] = []; + candidateEdges.push(parentBox.y); + candidateEdges.push(parentBox.y + parentBox.height); + + for (const element of elements) { + const top = element.relY; + const bottom = element.relY + element.relHeight; + candidateEdges.push(top); + candidateEdges.push(bottom); + } + + candidateEdges.sort((a, b) => a - b); + const uniqueEdges: number[] = dedupeWithEpsilon(candidateEdges); + + const cuts: number[] = []; + for (const yCut of uniqueEdges) { + let crosses = false; + for (const el of elements) { + const top = el.relY; + const bottom = el.relY + el.relHeight; + if (top + EPSILON < yCut && yCut < bottom - EPSILON) { + crosses = true; + break; + } + } + if (!crosses) cuts.push(yCut); + } + + return cuts; + } + + private buildVerticalSegments( + elements: LayoutElement[], + parent: Rect2D, + verticalCuts: number[], + ): { rect: Rect2D; elements: LayoutElement[] }[] { + if (verticalCuts.length === 0) { + return []; + } + + const sortedCuts: number[] = [...verticalCuts].sort((a, b) => a - b); + const boundaries: number[] = [parent.x, ...sortedCuts, parent.x + parent.width]; + + const segments: { rect: Rect2D; elements: LayoutElement[] }[] = []; + + for (let i = 0; i < boundaries.length - 1; i++) { + const left = boundaries[i]; + const right = boundaries[i + 1]; + const width = right - left; + if (width <= EPSILON) { + continue; + } + + const segmentRect: Rect2D = { + x: left, + y: parent.y, + width: width, + height: parent.height, + }; + + const elementsInSegment: LayoutElement[] = []; + for (const element of elements) { + const elLeft = element.relX; + const elRight = element.relX + element.relWidth; + const elTop = element.relY; + const elBottom = element.relY + element.relHeight; + + const insideHorizontally = elLeft >= left - EPSILON && elRight <= right + EPSILON; + const insideVertically = elTop >= parent.y - EPSILON && elBottom <= parent.y + parent.height + EPSILON; + + if (insideHorizontally && insideVertically) { + elementsInSegment.push(element); + } + } + + if (elementsInSegment.length > 0) { + segments.push({ rect: segmentRect, elements: elementsInSegment }); + } + } + + return segments; + } + + private buildHorizontalSegments( + elements: LayoutElement[], + parent: Rect2D, + horizontalCuts: number[], + ): { rect: Rect2D; elements: LayoutElement[] }[] { + if (horizontalCuts.length === 0) { + return []; + } + + const sortedCuts: number[] = [...horizontalCuts].sort((a, b) => a - b); + const boundaries: number[] = [parent.y, ...sortedCuts, parent.y + parent.height]; + + const segments: { rect: Rect2D; elements: LayoutElement[] }[] = []; + + for (let i = 0; i < boundaries.length - 1; i++) { + const top = boundaries[i]; + const bottom = boundaries[i + 1]; + const height = bottom - top; + if (height <= EPSILON) { + continue; + } + + const segmentRect: Rect2D = { + x: parent.x, + y: top, + width: parent.width, + height: height, + }; + + const elementsInSegment: LayoutElement[] = []; + for (const element of elements) { + const elLeft = element.relX; + const elRight = element.relX + element.relWidth; + const elTop = element.relY; + const elBottom = element.relY + element.relHeight; + + const insideHorizontally = elLeft >= parent.x - EPSILON && elRight <= parent.x + parent.width + EPSILON; + const insideVertically = elTop >= top - EPSILON && elBottom <= bottom + EPSILON; + + if (insideHorizontally && insideVertically) { + elementsInSegment.push(element); + } + } + + if (elementsInSegment.length > 0) { + segments.push({ rect: segmentRect, elements: elementsInSegment }); + } + } + + return segments; + } + + makeChildren(containedElements: LayoutElement[]) { + if (containedElements.length === 0) { + return; + } + if (containedElements.length === 1) { + if (this._layoutDirection === LayoutDirection.MAIN) { + const elementsInRect = containedElements.filter((layoutElement) => + outerRectContainsInnerRect(this.getAbsoluteRect(), layoutElementToRect(layoutElement)), + ); + const childBox = new LayoutNode( + this._rectRelativeToParent, + LayoutDirection.SINGLE, + this, + this._level + 1, + ); + childBox._isWrapper = true; + childBox.makeChildren(elementsInRect); + childBox.reorderChildren(); + this._children.push(childBox); + return; + } + this._isWrapper = false; + if (containedElements.length === 1) { + this._moduleInstanceId = containedElements[0].moduleInstanceId; + this._moduleName = containedElements[0].moduleName; + this._layoutDirection = LayoutDirection.SINGLE; + } + return; + } + + const parentRect = this.getAbsoluteRect(); + const verticalCuts: number[] = this.findVerticalCuts(containedElements, parentRect); + const horizontalCuts: number[] = this.findHorizontalCuts(containedElements, parentRect); + + const chooseVertical = verticalCuts.length > horizontalCuts.length; + const chooseHorizontal = horizontalCuts.length > verticalCuts.length; + + const tie = verticalCuts.length === horizontalCuts.length; + + if (chooseVertical || (tie && verticalCuts.length > 0)) { + const segments = this.buildVerticalSegments(containedElements, parentRect, verticalCuts); + if (segments.length > 1) { + const children: LayoutNode[] = []; + + for (const segment of segments) { + const childRect = this.transformRectToRelative(segment.rect); + + const child = new LayoutNode(childRect, LayoutDirection.VERTICAL, this, this._level + 1); + + child.makeChildren(segment.elements); + child.reorderChildren(); + children.push(child); + } + + this._children = children; + + if (this._layoutDirection === LayoutDirection.MAIN) { + this._layoutDirection = LayoutDirection.VERTICAL; + const wrapper = new LayoutNode( + this._rectRelativeToParent, + LayoutDirection.HORIZONTAL, + this, + 1, + this._children, + ); + this._children.forEach((child) => (child._parent = wrapper)); + this._level = 0; + this.setChildren([wrapper]); + } + } + } + + if (chooseHorizontal || (tie && horizontalCuts.length > 0)) { + const segments = this.buildHorizontalSegments(containedElements, parentRect, horizontalCuts); + if (segments.length > 1) { + const children: LayoutNode[] = []; + + for (const segment of segments) { + const childRect = this.transformRectToRelative(segment.rect); + + const child = new LayoutNode(childRect, LayoutDirection.HORIZONTAL, this, this._level + 1); + + child.makeChildren(segment.elements); + child.reorderChildren(); + children.push(child); + } + + this._children = children; + + if (this._layoutDirection === LayoutDirection.MAIN) { + this._layoutDirection = LayoutDirection.HORIZONTAL; + const wrapper = new LayoutNode( + this._rectRelativeToParent, + LayoutDirection.VERTICAL, + this, + 1, + this._children, + ); + this._children.forEach((child) => (child._parent = wrapper)); + this._level = 0; + this.setChildren([wrapper]); + } + } + } + } + + private reorderChildren() { + if (this._children.length === 0) return; + + const isHorizontalSplit = this._layoutDirection === LayoutDirection.HORIZONTAL; + + const existingChildren = this._children.filter((child) => !child._isNewInParent); + const numExistingChildren = existingChildren.length; + const newChildren = this._children.filter((child) => child._isNewInParent); + const nTotal = existingChildren.length + newChildren.length; + + const evenSharePerChild = 1.0 / nTotal; + + const currentSizeOfExistingChildren = existingChildren.reduce( + (acc, child) => + acc + (isHorizontalSplit ? child._rectRelativeToParent.width : child._rectRelativeToParent.height), + 0, + ); + const spaceAvailableForExistingChildren = numExistingChildren * evenSharePerChild; + + const scaleExistingChildren = + numExistingChildren > 0 && currentSizeOfExistingChildren > 0 + ? spaceAvailableForExistingChildren / currentSizeOfExistingChildren + : 0; + + let cumulativelyAssignedSize = 0; + + if (this._layoutDirection === LayoutDirection.HORIZONTAL) { + let currentX = 0; + this._children.forEach((child, index) => { + let newWidth = child._rectRelativeToParent.width * scaleExistingChildren; + if (child._isNewInParent) { + newWidth = evenSharePerChild; + } + if (index === this._children.length - 1) { + newWidth = 1 - cumulativelyAssignedSize; // Ensure the last child takes up the remaining space + } + cumulativelyAssignedSize += newWidth; + child._rectRelativeToParent = { + x: currentX, + y: 0, + width: newWidth, + height: 1, + }; + child._level = this._level + 1; + currentX += newWidth; + }); + } else { + let currentY = 0; + this._children.forEach((child, index) => { + let newHeight = child._rectRelativeToParent.height * scaleExistingChildren; + if (child._isNewInParent) { + newHeight = evenSharePerChild; + } + if (index === this._children.length - 1) { + newHeight = 1 - cumulativelyAssignedSize; // Ensure the last child takes up the remaining space + } + cumulativelyAssignedSize += newHeight; + child._rectRelativeToParent = { + x: 0, + y: currentY, + width: 1, + height: newHeight, + }; + child._level = this._level + 1; + currentY += newHeight; + }); + } + + for (const child of this._children) { + child._isNewInParent = false; + } + } + + prependChild(child: LayoutNode) { + this._children.unshift(child); + this.reorderChildren(); + } + + appendChild(child: LayoutNode) { + this._children.push(child); + this.reorderChildren(); + } + + insertChildAt(child: LayoutNode, index: number) { + this._children.splice(index, 0, child); + this.reorderChildren(); + } + + removeChild(child: LayoutNode) { + this._children = this._children.filter((c) => c !== child); + if (this._children.length > 0) { + this.reorderChildren(); + if (this._children.length === 1 && this._children[0]._children.length === 0) { + this.convertWrapperToSingleLayout(); + } + } else if (this._parent) { + this._parent.removeChild(this); + } + } + + setChildren(children: LayoutNode[]): void { + this._children = children; + } + + getChildren(): LayoutNode[] { + return this._children; + } + + findBoxContainingPoint(point: Vec2, realSize: Size2D): LayoutNode | null { + if (!rectContainsPoint(this.getRectWithMargin(realSize), point)) { + return null; + } + + let found: LayoutNode | null = null; + this._children.every((child) => { + found = child.findBoxContainingPoint(point, realSize); + if (found) { + return false; + } + return true; + }); + + return found || this; + } + + findBoxContainingModuleInstance(moduleInstanceId: string): LayoutNode | null { + if (this._moduleInstanceId === moduleInstanceId) { + return this; + } + + let found: LayoutNode | null = null; + this._children.every((child) => { + found = child.findBoxContainingModuleInstance(moduleInstanceId); + if (found) { + return false; + } + return true; + }); + + return found; + } + + getEdgeRects(realSize: Size2D, edgeWeight: number, edgeMargin: number): LayoutNodeEdge[] { + const rect = this.getRectWithMargin(realSize); + const edges: LayoutNodeEdge[] = []; + + const clampThickness = (t: number, axis: "x" | "y") => + Math.max(MIN_EDGE_PX, Math.min(t, axis === "x" ? rect.width : rect.height)); + + // SINGLE child: fractional bands; clamp those too + if (this._layoutDirection === LayoutDirection.SINGLE && this._parent) { + if ( + this._parent._layoutDirection === LayoutDirection.HORIZONTAL || + this._parent._layoutDirection === LayoutDirection.MAIN + ) { + const th = clampThickness(rect.height * 0.25, "y"); + edges.push({ + rect: { x: rect.x, y: rect.y, width: rect.width, height: th }, + edge: LayoutNodeEdgeType.TOP, + }); + edges.push({ + rect: { x: rect.x, y: rect.y + rect.height - th, width: rect.width, height: th }, + edge: LayoutNodeEdgeType.BOTTOM, + }); + } + if ( + this._parent._layoutDirection === LayoutDirection.VERTICAL || + this._parent._layoutDirection === LayoutDirection.MAIN + ) { + const tw = clampThickness(rect.width * 0.25, "x"); + edges.push({ + rect: { x: rect.x, y: rect.y, width: tw, height: rect.height }, + edge: LayoutNodeEdgeType.LEFT, + }); + edges.push({ + rect: { x: rect.x + rect.width - tw, y: rect.y, width: tw, height: rect.height }, + edge: LayoutNodeEdgeType.RIGHT, + }); + } + } + + // Container edges + if (this._layoutDirection === LayoutDirection.HORIZONTAL) { + const t = clampThickness(edgeWeight, "x"); + edges.push( + { + rect: { x: rect.x + rect.width - t, y: rect.y, width: t, height: rect.height }, + edge: LayoutNodeEdgeType.RIGHT, + }, + { rect: { x: rect.x, y: rect.y, width: t, height: rect.height }, edge: LayoutNodeEdgeType.LEFT }, + ); + } + if (this._layoutDirection === LayoutDirection.VERTICAL) { + const t = clampThickness(edgeWeight, "y"); + edges.push( + { rect: { x: rect.x, y: rect.y, width: rect.width, height: t }, edge: LayoutNodeEdgeType.TOP }, + { + rect: { x: rect.x, y: rect.y + rect.height - t, width: rect.width, height: t }, + edge: LayoutNodeEdgeType.BOTTOM, + }, + ); + } + + // Sashes between children — base their span on our safe rect, + // and trim a bit but never below MIN_EDGE_PX + const trimY = Math.min(edgeMargin * this._level, Math.max(0, rect.height / 4)); + const trimX = Math.min(edgeMargin * this._level, Math.max(0, rect.width / 4)); + + if (this._layoutDirection === LayoutDirection.HORIZONTAL) { + const t = clampThickness(edgeWeight, "x"); + for (let i = 1; i < this._children.length; i++) { + const child = this._children[i]; + const abs = child.getAbsoluteRect(); + edges.push({ + rect: { + x: abs.x * realSize.width - t / 2, + y: rect.y + trimY, + width: t, + height: Math.max(MIN_EDGE_PX, rect.height - 2 * trimY), + }, + edge: LayoutNodeEdgeType.VERTICAL, + position: abs.x, + }); + } + } + + if (this._layoutDirection === LayoutDirection.VERTICAL) { + const t = clampThickness(edgeWeight, "y"); + for (let i = 1; i < this._children.length; i++) { + const child = this._children[i]; + const abs = child.getAbsoluteRect(); + edges.push({ + rect: { + x: rect.x + trimX, + y: abs.y * realSize.height - t / 2, + width: Math.max(MIN_EDGE_PX, rect.width - 2 * trimX), + height: t, + }, + edge: LayoutNodeEdgeType.HORIZONTAL, + position: abs.y, + }); + } + } + + return edges; + } + + findEdgeContainingPoint(point: Vec2, realSize: Size2D, draggedModuleInstanceId: string): LayoutNodeEdge | null { + const edgeRects = this.getEdgeRects(realSize, EDGE_DROP_WEIGHT, LAYOUT_BOX_DROP_MARGIN); + const edge = edgeRects.find((edgeRect) => rectContainsPoint(edgeRect.rect, point)); + if (!edge) { + return null; + } + + if ( + [LayoutNodeEdgeType.LEFT, LayoutNodeEdgeType.TOP].includes(edge.edge) && + this._children.at(0)?.getModuleInstanceId() === draggedModuleInstanceId + ) { + return null; + } + if ( + [LayoutNodeEdgeType.RIGHT, LayoutNodeEdgeType.BOTTOM].includes(edge.edge) && + this._children.at(this._children.length - 1)?.getModuleInstanceId() === draggedModuleInstanceId + ) { + return null; + } + + if ( + edge.edge === LayoutNodeEdgeType.RIGHT && + this._parent && + this._parent._layoutDirection === LayoutDirection.HORIZONTAL + ) { + if (this._parent._children.at(0)?.getModuleInstanceId() === draggedModuleInstanceId) { + return null; + } + } + + if (edge.edge === LayoutNodeEdgeType.VERTICAL) { + const reversedLayoutBoxChildren = [...this._children].reverse(); + const prevChild = reversedLayoutBoxChildren.find((child) => child.getAbsoluteRect().x < edge.position); + if (prevChild?.getModuleInstanceId() === draggedModuleInstanceId) { + return null; + } + const nextChild = this._children.find((child) => child.getAbsoluteRect().x >= edge.position); + if (nextChild?.getModuleInstanceId() === draggedModuleInstanceId) { + return null; + } + } + + if (edge.edge === LayoutNodeEdgeType.HORIZONTAL) { + const reversedLayoutBoxChildren = [...this._children].reverse(); + const prevChild = reversedLayoutBoxChildren.find((child) => child.getAbsoluteRect().y < edge.position); + if (prevChild?.getModuleInstanceId() === draggedModuleInstanceId) { + return null; + } + const nextChild = this._children.find((child) => child.getAbsoluteRect().y >= edge.position); + if (nextChild?.getModuleInstanceId() === draggedModuleInstanceId) { + return null; + } + } + return edge || null; + } + + clone(parent: LayoutNode | null = null): LayoutNode { + const rect = this._rectRelativeToParent; + const clone = new LayoutNode( + { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + this._layoutDirection, + parent, + this._level, + ); + clone._moduleInstanceId = this._moduleInstanceId; + clone._moduleName = this._moduleName; + clone._children = this._children.map((child) => child.clone(clone)); + clone._isWrapper = this._isWrapper; + return clone; + } + + previewLayout( + pointerLocalPx: Vec2, + viewport: Size2D, + draggedModuleInstanceId: string, + isNewModule: boolean, + ): LayoutNode | null { + if (this._parent) return null; // only root orchestrates preview + + const preview = this.clone(); + + // If empty and it's a new module, fill full area + if (isNewModule && preview._children.length === 0) { + const dragged = new LayoutNode( + { x: 0, y: 0, width: 1, height: 1 }, + LayoutDirection.SINGLE, + preview, + preview._level + 1, + ); + dragged._moduleInstanceId = draggedModuleInstanceId; + dragged._isWrapper = false; + preview._children.push(dragged); + preview._isWrapper = true; + return preview; + } + + // Find node under pointer (existing code used findBoxContainingPoint) + const layoutBox = preview.findBoxContainingPoint(pointerLocalPx, viewport); + if (!layoutBox) return null; + + // Edge/sash hit test (existing code: findEdgeContainingPoint) + const edge = layoutBox.findEdgeContainingPoint(pointerLocalPx, viewport, draggedModuleInstanceId); + if (!edge) return null; + + // Move existing or add new (existing code: moveLayoutElement / addLayoutElement) + let dragged = preview.findBoxContainingModuleInstance(draggedModuleInstanceId); + if (!dragged) { + if (!isNewModule) return null; + dragged = new LayoutNode( + { x: 0, y: 0, width: 1, height: 1 }, + LayoutDirection.SINGLE, + preview, + preview._level + 1, + ); + dragged._moduleInstanceId = draggedModuleInstanceId; + dragged._isWrapper = false; + preview.addLayoutElement(dragged, layoutBox, edge); + } else { + preview.moveLayoutElement(dragged, layoutBox, edge); + } + + return preview; + } + + private convertSingleLayoutToWrapper(convertTo: LayoutDirection.HORIZONTAL | LayoutDirection.VERTICAL): void { + if (this._layoutDirection === convertTo) { + return; + } + + const newLayoutBox = new LayoutNode( + { x: 0, y: 0, width: 1, height: 1 }, + LayoutDirection.SINGLE, + this, + this._level + 1, + ); + + newLayoutBox._moduleInstanceId = this._moduleInstanceId; + newLayoutBox._isWrapper = false; + newLayoutBox._moduleName = this._moduleName; + + this.setChildren([newLayoutBox]); + this._moduleInstanceId = ""; + this._moduleName = ""; + this._isWrapper = true; + this._layoutDirection = convertTo; + } + + private convertWrapperToSingleLayout(): void { + if (this._layoutDirection === LayoutDirection.SINGLE) { + return; + } + + if (this._children.length !== 1) { + return; + } + + const child = this._children[0]; + this._moduleInstanceId = child._moduleInstanceId; + this._moduleName = child._moduleName; + this._isWrapper = false; + this._layoutDirection = LayoutDirection.SINGLE; + this._children = []; + } + + private positionToIndex(position: number, ignoreBoxes: LayoutNode[]): number { + if (this._layoutDirection === LayoutDirection.HORIZONTAL) { + const elementsBeforePosition = this._children.filter( + (child) => !ignoreBoxes.includes(child) && child._rectRelativeToParent.x < position, + ); + return elementsBeforePosition.length; + } + if (this._layoutDirection === LayoutDirection.VERTICAL) { + const elementsBeforePosition = this._children.filter( + (child) => !ignoreBoxes.includes(child) && child._rectRelativeToParent.y < position, + ); + return elementsBeforePosition.length; + } + + return position; + } + + moveLayoutElement(source: LayoutNode, destination: LayoutNode, edge: LayoutNodeEdge): void { + if (source === destination) { + return; + } + + if (edge.edge === LayoutNodeEdgeType.LEFT || edge.edge === LayoutNodeEdgeType.TOP) { + if (destination._layoutDirection === LayoutDirection.SINGLE) { + const layoutType = + edge.edge === LayoutNodeEdgeType.LEFT ? LayoutDirection.HORIZONTAL : LayoutDirection.VERTICAL; + destination.convertSingleLayoutToWrapper(layoutType); + } + if (source._parent !== destination) { + source._parent?.removeChild(source); + source._isNewInParent = true; + destination.prependChild(source); + source._parent = destination; + } else { + destination._children = [source, ...destination._children.filter((child) => child !== source)]; + destination.reorderChildren(); + } + return; + } + + if (edge.edge === LayoutNodeEdgeType.RIGHT || edge.edge === LayoutNodeEdgeType.BOTTOM) { + if (destination._layoutDirection === LayoutDirection.SINGLE) { + const layoutType = + edge.edge === LayoutNodeEdgeType.RIGHT ? LayoutDirection.HORIZONTAL : LayoutDirection.VERTICAL; + destination.convertSingleLayoutToWrapper(layoutType); + } + if (source._parent !== destination) { + source._parent?.removeChild(source); + source._isNewInParent = true; + destination.appendChild(source); + source._parent = destination; + } else { + destination._children = [...destination._children.filter((child) => child !== source), source]; + destination.reorderChildren(); + } + return; + } + + if (edge.edge === LayoutNodeEdgeType.VERTICAL || edge.edge === LayoutNodeEdgeType.HORIZONTAL) { + const index = destination.positionToIndex(edge.position, [source]); + if (source._parent !== destination) { + source._parent?.removeChild(source); + source._isNewInParent = true; + destination.insertChildAt(source, index); + source._parent = destination; + } else { + const otherElements = destination._children.filter((child) => child !== source); + const elementsBefore = otherElements.slice(0, index); + const elementsAfter = otherElements.slice(index); + destination._children = [...elementsBefore, source, ...elementsAfter]; + destination.reorderChildren(); + } + return; + } + } + + addLayoutElement(newBox: LayoutNode, destination: LayoutNode, edge: LayoutNodeEdge): void { + if (edge.edge === LayoutNodeEdgeType.LEFT || edge.edge === LayoutNodeEdgeType.TOP) { + if (destination._layoutDirection === LayoutDirection.SINGLE) { + const layoutType = + edge.edge === LayoutNodeEdgeType.LEFT ? LayoutDirection.HORIZONTAL : LayoutDirection.VERTICAL; + destination.convertSingleLayoutToWrapper(layoutType); + } + newBox._isNewInParent = true; + destination.prependChild(newBox); + newBox._parent = destination; + return; + } + + if (edge.edge === LayoutNodeEdgeType.RIGHT || edge.edge === LayoutNodeEdgeType.BOTTOM) { + if (destination._layoutDirection === LayoutDirection.SINGLE) { + const layoutType = + edge.edge === LayoutNodeEdgeType.RIGHT ? LayoutDirection.HORIZONTAL : LayoutDirection.VERTICAL; + destination.convertSingleLayoutToWrapper(layoutType); + } + newBox._isNewInParent = true; + destination.appendChild(newBox); + newBox._parent = destination; + return; + } + + if (edge.edge === LayoutNodeEdgeType.VERTICAL || edge.edge === LayoutNodeEdgeType.HORIZONTAL) { + const index = destination.positionToIndex(edge.position, []); + newBox._isNewInParent = true; + destination.insertChildAt(newBox, index); + newBox._parent = destination; + return; + } + } + + removeLayoutElement(moduleInstanceId: string): void { + if (this._isWrapper) { + this._children.forEach((child) => child.removeLayoutElement(moduleInstanceId)); + } else if (this._moduleInstanceId === moduleInstanceId) { + if (this._parent) { + this._parent.removeChild(this); + } + } + } + + toLayout(): LayoutElement[] { + const layout: LayoutElement[] = []; + + if (this._isWrapper) { + this._children.forEach((child) => { + layout.push(...child.toLayout()); + }); + } else { + const absoluteRect = this.getAbsoluteRect(); + layout.push({ + relX: absoluteRect.x, + relY: absoluteRect.y, + relWidth: absoluteRect.width, + relHeight: absoluteRect.height, + moduleInstanceId: this._moduleInstanceId || undefined, + moduleName: this._moduleName, + }); + } + + return layout; + } + + pathFromRoot(): number[] { + const path: number[] = []; + /* eslint-disable @typescript-eslint/no-this-alias */ + let node: LayoutNode | null = this; + while (node && node._parent) { + const idx = node._parent._children.indexOf(node); + path.unshift(idx); + node = node._parent; + } + return path; + } + + static findByPath(root: LayoutNode, path: number[]): LayoutNode | null { + let cur: LayoutNode | null = root; + for (const idx of path) { + if (!cur) return null; + cur = cur._children[idx] ?? null; + } + return cur; + } + + resizeAtDivider(index: number, axis: "vertical" | "horizontal", pos01: number, minFractions: Size2D): void { + // Only valid on split containers of the right axis + const isVerticalSplit = this._layoutDirection === LayoutDirection.HORIZONTAL; // vertical sash between columns + const isHorizontalSplit = this._layoutDirection === LayoutDirection.VERTICAL; // horizontal sash between rows + + if ((axis === "vertical" && !isVerticalSplit) || (axis === "horizontal" && !isHorizontalSplit)) { + return; + } + if (index <= 0 || index >= this._children.length) return; + + // Normalize children rects to 0..1 within this container + const totalSpan = + axis === "vertical" + ? this._children.reduce((acc, ch) => acc + ch._rectRelativeToParent.width, 0) + : this._children.reduce((acc, ch) => acc + ch._rectRelativeToParent.height, 0); + + // Guard: if someone left non-normalized, renormalize first + if (Math.abs(totalSpan - 1) > 1e-6) { + if (axis === "vertical") { + let sum = 0; + for (const ch of this._children) sum += ch._rectRelativeToParent.width; + for (const ch of this._children) ch._rectRelativeToParent.width /= sum || 1; + // recompute x + let x = 0; + for (const ch of this._children) { + ch._rectRelativeToParent.x = x; + x += ch._rectRelativeToParent.width; + } + } else { + let sum = 0; + for (const ch of this._children) sum += ch._rectRelativeToParent.height; + for (const ch of this._children) ch._rectRelativeToParent.height /= sum || 1; + // recompute y + let y = 0; + for (const ch of this._children) { + ch._rectRelativeToParent.y = y; + y += ch._rectRelativeToParent.height; + } + } + } + + // Compute cumulative spans to get neighbor sizes + if (axis === "vertical") { + // reposition sash between index-1 and index to pos01 in [0..1] + const left = this._children[index - 1]; + const right = this._children[index]; + + const leftStart = left._rectRelativeToParent.x; + const rightEnd = right._rectRelativeToParent.x + right._rectRelativeToParent.width; + + // Clamp pos01 within [leftStart + min, rightEnd - min] + const min = minFractions.width; + const newPos = Math.max(leftStart + min, Math.min(rightEnd - min, pos01)); + + const newLeftWidth = newPos - leftStart; + const newRightWidth = rightEnd - newPos; + + left._rectRelativeToParent.width = newLeftWidth; + right._rectRelativeToParent.x = newPos; + right._rectRelativeToParent.width = newRightWidth; + } else { + // axis === "horizontal" + const top = this._children[index - 1]; + const bottom = this._children[index]; + + const topStart = top._rectRelativeToParent.y; + const bottomEnd = bottom._rectRelativeToParent.y + bottom._rectRelativeToParent.height; + + const min = minFractions.height; + const newPos = Math.max(topStart + min, Math.min(bottomEnd - min, pos01)); + + const newTopHeight = newPos - topStart; + const newBottomHeight = bottomEnd - newPos; + + top._rectRelativeToParent.height = newTopHeight; + bottom._rectRelativeToParent.y = newPos; + bottom._rectRelativeToParent.height = newBottomHeight; + } + } + + /** Hit-test for a sash (divider) at a pointer in local px. Returns the owning container and divider index. */ + hitTestDivider( + pointLocalPx: Vec2, + viewport: Size2D, + ): { containerPath: number[]; axis: LayoutAxis; index: number } | null { + // Only containers (not SINGLE leaves) can own dividers. + // We recurse so deepest container wins (most specific). + if ( + this._layoutDirection === LayoutDirection.HORIZONTAL || + this._layoutDirection === LayoutDirection.VERTICAL + ) { + // test children first + for (let i = 0; i < this._children.length; i++) { + const child = this._children[i]; + const hit = child.hitTestDivider(pointLocalPx, viewport); + if (hit) return hit; + } + + // then test our own dividers + const edges = this.getEdgeRects(viewport, EDGE_RESIZE_WEIGHT, LAYOUT_BOX_RESIZE_MARGIN); + + // vertical dividers exist when we're HORIZONTAL (columns) + if (this._layoutDirection === LayoutDirection.HORIZONTAL) { + const verticals = edges.filter((e) => e.edge === LayoutNodeEdgeType.VERTICAL); + for (let idx = 0; idx < verticals.length; idx++) { + const r = verticals[idx].rect; + if (rectContainsPoint(r, pointLocalPx)) { + return { containerPath: this.pathFromRoot(), axis: LayoutAxis.VERTICAL, index: idx + 1 }; + } + } + } + + // horizontal dividers exist when we're VERTICAL (rows) + if (this._layoutDirection === LayoutDirection.VERTICAL) { + const horizontals = edges.filter((e) => e.edge === LayoutNodeEdgeType.HORIZONTAL); + for (let idx = 0; idx < horizontals.length; idx++) { + const r = horizontals[idx].rect; + if (rectContainsPoint(r, pointLocalPx)) { + return { containerPath: this.pathFromRoot(), axis: LayoutAxis.HORIZONTAL, index: idx + 1 }; + } + } + } + } + + return null; + } +} + +export function makeLayoutNodes(layoutElements: LayoutElement[]): LayoutNode { + const root = new LayoutNode({ x: 0, y: 0, width: 1, height: 1 }, LayoutDirection.MAIN, null, 1); + root.makeChildren(layoutElements); + return root; +} diff --git a/frontend/src/framework/internal/components/Layout/components/LayoutOverlay.tsx b/frontend/src/framework/internal/components/Layout/components/LayoutOverlay.tsx new file mode 100644 index 000000000..342542f72 --- /dev/null +++ b/frontend/src/framework/internal/components/Layout/components/LayoutOverlay.tsx @@ -0,0 +1,139 @@ +import React from "react"; + +import { rectContainsPoint, type Rect2D, type Size2D } from "@lib/utils/geometry"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import type { Vec2 } from "@lib/utils/vec2"; + +import { LayoutNodeEdgeType, type LayoutNode } from "../LayoutNode"; + +function flatten(node: LayoutNode): LayoutNode[] { + return [node, ...node.getChildren().flatMap(flatten)]; +} + +export type LayoutOverlayProps = { + root: LayoutNode; + active: string | null; + realSize: Size2D; + zIndex: number; + pointer: Vec2 | null; +}; + +export function LayoutOverlay(props: LayoutOverlayProps) { + const flat = React.useMemo(() => flatten(props.root), [props.root]); + + const activeBox = React.useMemo( + () => (props.pointer ? props.root.findBoxContainingPoint(props.pointer, props.realSize) : null), + [props.root, props.pointer, props.realSize], + ); + + // If there’s only one box, show a simple hover highlight for the whole area. + if (flat.length === 1) { + const rect = flat[0].getRectWithMargin(props.realSize); + const hovered = props.pointer ? rectContainsPoint(rect, props.pointer) : false; + + return ( +
+ ); + } + + // only show an edge when we have a drag source (active) + const shouldShowEdge = + !!props.pointer && !!props.active && !!activeBox && activeBox.getModuleInstanceId?.() !== props.active; + + if (!shouldShowEdge) { + return null; + } + + // Compute hovered edge and rect once for the active box. + const hovered = activeBox.findEdgeContainingPoint(props.pointer!, props.realSize, props.active!); + const edgeType = hovered?.edge; + const rect: Rect2D | undefined = hovered?.rect; + + if (!edgeType || !rect) { + return null; + } + + const isRowDirection = [LayoutNodeEdgeType.LEFT, LayoutNodeEdgeType.RIGHT, LayoutNodeEdgeType.VERTICAL].includes( + edgeType, + ); + + const minDim = Math.min(hovered.rect.width, hovered.rect.height); + const ARROW_PX = Math.max(8, Math.min(20, Math.floor(minDim * 0.35))); + + return ( +
+ {edgeType === LayoutNodeEdgeType.LEFT && } + {edgeType === LayoutNodeEdgeType.TOP && } + {edgeType === LayoutNodeEdgeType.RIGHT && } + {edgeType === LayoutNodeEdgeType.BOTTOM && } + {edgeType === LayoutNodeEdgeType.VERTICAL && ( + <> + + + + )} + {edgeType === LayoutNodeEdgeType.HORIZONTAL && ( + <> + + + + )} +
+ ); +} + +type ArrowProps = { + direction: "left" | "right" | "top" | "bottom"; + size: number; +}; + +function Arrow(props: ArrowProps) { + const arrowStyle = { + width: props.size, + height: props.size, + borderColor: "black", + } as const; + + const borderWidth = Math.max(1, Math.floor(props.size / 4)); + + return ( +
+ ); +} diff --git a/frontend/src/framework/internal/components/Layout/components/QuickSwitchDock.tsx b/frontend/src/framework/internal/components/Layout/components/QuickSwitchDock.tsx new file mode 100644 index 000000000..65fd99fd7 --- /dev/null +++ b/frontend/src/framework/internal/components/Layout/components/QuickSwitchDock.tsx @@ -0,0 +1,140 @@ +import type { LayoutElement } from "@framework/internal/WorkbenchSession/Dashboard"; +import { Button } from "@lib/components/Button"; +import { IconButton } from "@lib/components/IconButton"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { ArrowLeftRounded, ArrowRightRounded, WebAsset } from "@mui/icons-material"; +import React from "react"; + +export type QuickSwitchDockProps = { + isOpen: boolean; + layoutElements: LayoutElement[]; + getModuleInstanceName: (moduleInstanceId: string) => string | undefined; + onActiveModuleChange: (moduleInstanceId: string) => void; +}; + +export function QuickSwitchDock(props: QuickSwitchDockProps) { + const [visible, setVisible] = React.useState(false); + + const scrollerRef = React.useRef(null); + const chipRefs = React.useRef>({}); + + const activeModuleInstanceIndex = React.useMemo(() => { + return props.layoutElements.findIndex((el) => el.maximized); + }, [props.layoutElements]); + + const activeModuleInstanceId = React.useMemo(() => { + return props.layoutElements[activeModuleInstanceIndex]?.moduleInstanceId; + }, [activeModuleInstanceIndex, props.layoutElements]); + + const handlePreviousModuleInstanceClick = React.useCallback( + function handlePreviousModuleInstanceClick() { + let newIndex = activeModuleInstanceIndex - 1; + if (newIndex < 0) { + newIndex = props.layoutElements.length - 1; + } + const id = props.layoutElements[newIndex].moduleInstanceId; + if (!id) { + return; + } + props.onActiveModuleChange(id); + }, + [activeModuleInstanceIndex, props.layoutElements, props.onActiveModuleChange], + ); + + const handleNextModuleInstanceClick = React.useCallback( + function handleNextModuleInstanceClick() { + let newIndex = activeModuleInstanceIndex + 1; + if (newIndex >= props.layoutElements.length) { + newIndex = 0; + } + const id = props.layoutElements[newIndex].moduleInstanceId; + if (!id) { + return; + } + props.onActiveModuleChange(id); + }, + [activeModuleInstanceIndex, props.layoutElements, props.onActiveModuleChange], + ); + + const handleModuleInstanceClick = React.useCallback( + function handleModuleInstanceClick(moduleInstanceId: string) { + props.onActiveModuleChange(moduleInstanceId); + }, + [props.onActiveModuleChange], + ); + + function handleToggleVisibilityClick() { + setVisible((prev) => !prev); + } + + React.useEffect( + function scrollIntoView() { + const btn = activeModuleInstanceId ? chipRefs.current[activeModuleInstanceId] : null; + btn?.scrollIntoView({ inline: "center", behavior: "smooth", block: "nearest" }); + }, + [activeModuleInstanceId], + ); + + if (!props.isOpen || props.layoutElements.length === 0) { + return null; + } + + return ( +
+
+ + + +
+
+ + + + +
+ {/* +
+
+ */} +
+ {props.layoutElements + .filter((el) => el.moduleInstanceId) + .map((el, index) => { + const tabIndex = index === activeModuleInstanceIndex ? 0 : -1; + + return ( + + ); + })} +
+
+ + + +
+
+ ); +} diff --git a/frontend/src/framework/internal/components/Layout/index.ts b/frontend/src/framework/internal/components/Layout/index.ts new file mode 100644 index 000000000..3147e7697 --- /dev/null +++ b/frontend/src/framework/internal/components/Layout/index.ts @@ -0,0 +1,2 @@ +export { Layout } from "./Layout"; +export type { LayoutProps } from "./Layout"; diff --git a/frontend/src/framework/internal/components/LeftSettingsPanel/leftSettingsPanel.tsx b/frontend/src/framework/internal/components/LeftSettingsPanel/leftSettingsPanel.tsx index a1253561a..c3516d6ff 100644 --- a/frontend/src/framework/internal/components/LeftSettingsPanel/leftSettingsPanel.tsx +++ b/frontend/src/framework/internal/components/LeftSettingsPanel/leftSettingsPanel.tsx @@ -1,26 +1,28 @@ import React from "react"; -import { Settings as SettingsIcon } from "@mui/icons-material"; +import { Tune } from "@mui/icons-material"; import { GuiState, LeftDrawerContent, useGuiValue } from "@framework/GuiMessageBroker"; -import { useModuleInstances } from "@framework/internal/hooks/workbenchHooks"; +import { DashboardTopic } from "@framework/internal/WorkbenchSession/Dashboard"; +import { PrivateWorkbenchSessionTopic } from "@framework/internal/WorkbenchSession/PrivateWorkbenchSession"; import type { Workbench } from "@framework/Workbench"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; import { ColorPaletteSettings } from "./private-components/colorPaletteSettings"; import { ModuleSettings } from "./private-components/moduleSettings"; -import { ModulesList } from "./private-components/modulesList"; import { SyncSettings } from "./private-components/syncSettings"; -import { TemplatesList } from "./private-components/templatesList"; type LeftSettingsPanelProps = { workbench: Workbench; }; export const LeftSettingsPanel: React.FC = (props) => { - const moduleInstances = useModuleInstances(props.workbench); - const activeModuleInstanceId = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.ActiveModuleInstanceId); - + const dashboard = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.ACTIVE_DASHBOARD, + ); + const moduleInstances = usePublishSubscribeTopicValue(dashboard, DashboardTopic.ModuleInstances); const drawerContent = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.LeftDrawerContent); const mainRef = React.useRef(null); @@ -31,8 +33,6 @@ export const LeftSettingsPanel: React.FC = (props) => { className={resolveClassNames("bg-white", "h-full")} style={{ boxShadow: "4px 0px 4px 1px rgba(0, 0, 0, 0.05)" }} > - -
= (props) => { )} > {moduleInstances.map((instance) => ( - + ))} {moduleInstances.length === 0 && (
- +
)}
diff --git a/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/colorPaletteSettings.tsx b/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/colorPaletteSettings.tsx index c124eb01a..af0054caa 100644 --- a/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/colorPaletteSettings.tsx +++ b/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/colorPaletteSettings.tsx @@ -17,17 +17,20 @@ export type ColorPaletteSettingsProps = { }; export const ColorPaletteSettings: React.FC = (props) => { - const colorPalettes = props.workbench.getWorkbenchSettings().getColorPalettes(); + const colorPalettes = props.workbench.getWorkbenchSession().getWorkbenchSettings().getColorPalettes(); const drawerContent = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.LeftDrawerContent); const [selectedColorPaletteIds, setSelectedColorPaletteIds] = React.useState>( - props.workbench.getWorkbenchSettings().getSelectedColorPaletteIds(), + props.workbench.getWorkbenchSession().getWorkbenchSettings().getSelectedColorPaletteIds(), ); const [steps, setSteps] = React.useState>( - props.workbench.getWorkbenchSettings().getSteps(), + props.workbench.getWorkbenchSession().getWorkbenchSettings().getSteps(), ); function handleColorPaletteSelected(colorPalette: ColorPalette, type: ColorPaletteType) { - props.workbench.getWorkbenchSettings().setSelectedColorPaletteId(type, colorPalette.getId()); + props.workbench + .getWorkbenchSession() + .getWorkbenchSettings() + .setSelectedColorPaletteId(type, colorPalette.getId()); setSelectedColorPaletteIds({ ...selectedColorPaletteIds, [type]: colorPalette.getId(), @@ -35,7 +38,7 @@ export const ColorPaletteSettings: React.FC = (props) } function handleColorPaletteStepsChanged(newSteps: number, type: ColorScaleDiscreteSteps) { - props.workbench.getWorkbenchSettings().setStepsForType(type, newSteps); + props.workbench.getWorkbenchSession().getWorkbenchSettings().setStepsForType(type, newSteps); setSteps({ ...steps, [type]: newSteps, diff --git a/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/moduleSettings.tsx b/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/moduleSettings.tsx index 4e2df9e59..fd2c0f8ae 100644 --- a/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/moduleSettings.tsx +++ b/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/moduleSettings.tsx @@ -4,39 +4,56 @@ import { Settings as SettingsIcon } from "@mui/icons-material"; import { Provider } from "jotai"; import { ErrorBoundary } from "@framework/internal/components/ErrorBoundary"; -import { ImportState } from "@framework/Module"; +import { DashboardTopic } from "@framework/internal/WorkbenchSession/Dashboard"; +import { PrivateWorkbenchSessionTopic } from "@framework/internal/WorkbenchSession/PrivateWorkbenchSession"; +import { ImportStatus } from "@framework/Module"; import type { ModuleInstance } from "@framework/ModuleInstance"; -import { ModuleInstanceState, ModuleInstanceTopic, useModuleInstanceTopicValue } from "@framework/ModuleInstance"; +import { + ModuleInstanceTopic, + ModuleInstanceLifeCycleState, + useModuleInstanceTopicValue, +} from "@framework/ModuleInstance"; import { StatusSource } from "@framework/ModuleInstanceStatusController"; import type { Workbench } from "@framework/Workbench"; +import type { WorkbenchSession } from "@framework/WorkbenchSession"; import { CircularProgress } from "@lib/components/CircularProgress"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; - import { ApplyInterfaceEffectsToSettings } from "../../ApplyInterfaceEffects/applyInterfaceEffects"; import { DebugProfiler } from "../../DebugProfiler"; import { HydrateQueryClientAtom } from "../../HydrateQueryClientAtom"; type ModuleSettingsProps = { - moduleInstance: ModuleInstance; - activeModuleInstanceId: string; + moduleInstance: ModuleInstance; workbench: Workbench; }; export const ModuleSettings: React.FC = (props) => { - const importState = useModuleInstanceTopicValue(props.moduleInstance, ModuleInstanceTopic.IMPORT_STATE); - const moduleInstanceState = useModuleInstanceTopicValue(props.moduleInstance, ModuleInstanceTopic.STATE); - const atomStore = props.workbench.getAtomStoreMaster().getAtomStoreForModuleInstance(props.moduleInstance.getId()); + const workbenchSession = props.workbench.getWorkbenchSession(); + const importState = useModuleInstanceTopicValue(props.moduleInstance, ModuleInstanceTopic.IMPORT_STATUS); + const dashboard = usePublishSubscribeTopicValue(workbenchSession, PrivateWorkbenchSessionTopic.ACTIVE_DASHBOARD); + + const activeModuleInstanceId = usePublishSubscribeTopicValue(dashboard, DashboardTopic.ActiveModuleInstanceId); + + const moduleInstanceLifecycleState = useModuleInstanceTopicValue( + props.moduleInstance, + ModuleInstanceTopic.LIFECYCLE_STATE, + ); + const atomStore = workbenchSession.getAtomStoreMaster().getAtomStoreForModuleInstance(props.moduleInstance.getId()); - if (importState !== ImportState.Imported || !props.moduleInstance.isInitialized()) { + if (importState !== ImportStatus.Imported || !props.moduleInstance.isInitialized()) { return null; } if ( - moduleInstanceState === ModuleInstanceState.INITIALIZING || - moduleInstanceState === ModuleInstanceState.RESETTING + moduleInstanceLifecycleState === ModuleInstanceLifeCycleState.INITIALIZING || + moduleInstanceLifecycleState === ModuleInstanceLifeCycleState.RESETTING ) { - const text = moduleInstanceState === ModuleInstanceState.INITIALIZING ? "Initializing..." : "Resetting..."; + const text = + moduleInstanceLifecycleState === ModuleInstanceLifeCycleState.INITIALIZING + ? "Initializing..." + : "Resetting..."; return (
@@ -45,14 +62,14 @@ export const ModuleSettings: React.FC = (props) => { ); } - if (moduleInstanceState === ModuleInstanceState.ERROR) { + if (moduleInstanceLifecycleState === ModuleInstanceLifeCycleState.ERROR) { const errorObject = props.moduleInstance.getFatalError(); if (errorObject) { return (
This module instance has encountered an error. Please see its view for more details. @@ -66,7 +83,7 @@ export const ModuleSettings: React.FC = (props) => {
= (props) => { diff --git a/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/syncSettings.tsx b/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/syncSettings.tsx index 5017b453d..3940a55e0 100644 --- a/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/syncSettings.tsx +++ b/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/syncSettings.tsx @@ -2,23 +2,32 @@ import React from "react"; import { Link, PinDrop, Public } from "@mui/icons-material"; +import { DashboardTopic } from "@framework/internal/WorkbenchSession/Dashboard"; import { GuiState, LeftDrawerContent, useGuiValue } from "@framework/GuiMessageBroker"; import { Drawer } from "@framework/internal/components/Drawer"; +import { PrivateWorkbenchSessionTopic } from "@framework/internal/WorkbenchSession/PrivateWorkbenchSession"; import type { SyncSettingKey } from "@framework/SyncSettings"; import { SyncSettingsMeta } from "@framework/SyncSettings"; import type { Workbench } from "@framework/Workbench"; import { Checkbox } from "@lib/components/Checkbox"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; type ModulesListProps = { workbench: Workbench; }; export const SyncSettings: React.FC = (props) => { + const dashboard = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.ACTIVE_DASHBOARD, + ); + const moduleInstances = usePublishSubscribeTopicValue(dashboard, DashboardTopic.ModuleInstances); + const activeModuleInstanceId = usePublishSubscribeTopicValue(dashboard, DashboardTopic.ActiveModuleInstanceId); + const forceRerender = React.useReducer((x) => x + 1, 0)[1]; const drawerContent = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.LeftDrawerContent); - const activeModuleInstanceId = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.ActiveModuleInstanceId); - const activeModuleInstance = props.workbench.getModuleInstance(activeModuleInstanceId); + const activeModuleInstance = moduleInstances.find((instance) => instance.getId() === activeModuleInstanceId); function handleSyncSettingChange(setting: SyncSettingKey, value: boolean) { if (activeModuleInstance === undefined) { @@ -37,8 +46,6 @@ export const SyncSettings: React.FC = (props) => { } function handleGlobalSyncSettingChange(setting: SyncSettingKey, value: boolean) { - const moduleInstances = props.workbench.getModuleInstances(); - // @rmt: This has to be changed as soon as we support multiple pages for (const moduleInstance of moduleInstances) { if (moduleInstance.getModule().hasSyncableSettingKey(setting)) { @@ -56,8 +63,6 @@ export const SyncSettings: React.FC = (props) => { } function isGlobalSyncSetting(setting: SyncSettingKey): boolean { - const moduleInstances = props.workbench.getModuleInstances(); - // @rmt: This has to be changed as soon as we support multiple pages for (const moduleInstance of moduleInstances) { if (moduleInstance.getModule().hasSyncableSettingKey(setting)) { diff --git a/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/templatesList.tsx b/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/templatesList.tsx deleted file mode 100644 index a876ea77c..000000000 --- a/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/templatesList.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import React from "react"; - -import { GridView } from "@mui/icons-material"; - -import { GuiState, LeftDrawerContent, useGuiState } from "@framework/GuiMessageBroker"; -import { Drawer } from "@framework/internal/components/Drawer"; -import { ModuleRegistry } from "@framework/ModuleRegistry"; -import type { Template } from "@framework/TemplateRegistry"; -import { TemplateRegistry } from "@framework/TemplateRegistry"; -import type { Workbench } from "@framework/Workbench"; - -function drawTemplatePreview(template: Template, width: number, height: number): React.ReactNode { - return ( - - {template.moduleInstances.map((element, idx) => { - const w = element.layout.relWidth * width; - const h = element.layout.relHeight * height; - const x = element.layout.relX * width; - const y = element.layout.relY * height; - const strokeWidth = 2; - const headerHeight = 10; - const module = ModuleRegistry.getModule(element.moduleName); - const drawFunc = module.getDrawPreviewFunc(); - return ( - - - - - {element.moduleName} - - - {drawFunc && drawFunc(w - 4 * strokeWidth, h - headerHeight - 4 * strokeWidth)} - - - ); - })} - - ); -} - -type TemplatesListItemProps = { - templateName: string; - onClick: () => void; -}; - -const TemplatesListItem: React.FC = (props) => { - const ref = React.useRef(null); - const mainRef = React.useRef(null); - - const template = TemplateRegistry.getTemplate(props.templateName); - - return ( - <> -
-
- {template && drawTemplatePreview(template, 100, 100)} -
-
-
{props.templateName}
-
- {template?.description} -
-
-
- - ); -}; - -type TemplatesListProps = { - workbench: Workbench; -}; - -/* - @rmt: This component does probably need virtualization and therefore refactoring. - As this includes a lot more implementation, - I will skip it for now and come back to it when it becomes a problem. -*/ -export const TemplatesList: React.FC = (props) => { - const [drawerContent, setDrawerContent] = useGuiState( - props.workbench.getGuiMessageBroker(), - GuiState.LeftDrawerContent, - ); - const [searchQuery, setSearchQuery] = React.useState(""); - - const handleSearchQueryChange = (e: React.ChangeEvent) => { - setSearchQuery(e.target.value); - }; - - const handleTemplateClick = (templateName: string) => { - const template = TemplateRegistry.getTemplate(templateName); - if (!template) { - return; - } - props.workbench.applyTemplate(template); - setDrawerContent(LeftDrawerContent.ModuleSettings); - }; - - return ( - } - visible={drawerContent === LeftDrawerContent.TemplatesList} - > - {Object.keys(TemplateRegistry.getRegisteredTemplates()) - .filter((templName) => templName.toLowerCase().includes(searchQuery.toLowerCase())) - .map((templName) => ( - handleTemplateClick(templName)} - key={templName} - templateName={templName} - /> - ))} - - ); -}; diff --git a/frontend/src/framework/internal/components/LoadingOverlay/loadingOverlay.tsx b/frontend/src/framework/internal/components/LoadingOverlay/loadingOverlay.tsx index e6cb84375..7782d277a 100644 --- a/frontend/src/framework/internal/components/LoadingOverlay/loadingOverlay.tsx +++ b/frontend/src/framework/internal/components/LoadingOverlay/loadingOverlay.tsx @@ -1,11 +1,15 @@ import FmuLogoAnimated from "@assets/fmuAnimated.svg"; -export function LoadingOverlay(): JSX.Element { +export type LoadingOverlayProps = { + text: string; +}; + +export function LoadingOverlay(props: LoadingOverlayProps): JSX.Element { return (
FMU Analysis animated logo - Loading ensembles... + {props.text}
); diff --git a/frontend/src/framework/internal/components/LoginButton/loginButton.tsx b/frontend/src/framework/internal/components/LoginButton/loginButton.tsx index a90a17c20..23706e1f5 100644 --- a/frontend/src/framework/internal/components/LoginButton/loginButton.tsx +++ b/frontend/src/framework/internal/components/LoginButton/loginButton.tsx @@ -1,5 +1,6 @@ import React from "react"; +import { Tooltip } from "@equinor/eds-core-react"; import { Dropdown, MenuButton } from "@mui/base"; import { AccountCircle, Login, Logout } from "@mui/icons-material"; @@ -7,7 +8,9 @@ import { postLogout } from "@api"; import { AuthState, useAuthProvider } from "@framework/internal/providers/AuthProvider"; import { CircularProgress } from "@lib/components/CircularProgress"; import { Menu } from "@lib/components/Menu"; +import { MenuDivider } from "@lib/components/MenuDivider"; import { MenuItem } from "@lib/components/MenuItem"; +import { MenuText } from "@lib/components/MenuText/menuText"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; import { getTextWidthWithFont } from "@lib/utils/textSize"; @@ -39,7 +42,7 @@ export const LoginButton: React.FC = (props) => { console.debug("Logging out..."); await postLogout(); console.debug("Redirecting to login screen..."); - window.location.href = "/"; + window.location.reload(); } function makeIcon() { @@ -97,20 +100,19 @@ export const LoginButton: React.FC = (props) => { return ( - - + + {makeIcon()} - - {props.showText && text} - - - + + + {text} + Sign out diff --git a/frontend/src/framework/internal/components/ModulesList/index.ts b/frontend/src/framework/internal/components/ModulesList/index.ts new file mode 100644 index 000000000..d617c9cb3 --- /dev/null +++ b/frontend/src/framework/internal/components/ModulesList/index.ts @@ -0,0 +1,2 @@ +export { ModulesList } from "./modulesList"; +export type { ModulesListProps } from "./modulesList"; diff --git a/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/modulesList.tsx b/frontend/src/framework/internal/components/ModulesList/modulesList.tsx similarity index 91% rename from frontend/src/framework/internal/components/LeftSettingsPanel/private-components/modulesList.tsx rename to frontend/src/framework/internal/components/ModulesList/modulesList.tsx index 15ee374fc..060be28b5 100644 --- a/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/modulesList.tsx +++ b/frontend/src/framework/internal/components/ModulesList/modulesList.tsx @@ -12,9 +12,8 @@ import { } from "@mui/icons-material"; import type { GuiMessageBroker } from "@framework/GuiMessageBroker"; -import { GuiEvent, GuiState, LeftDrawerContent, useGuiValue } from "@framework/GuiMessageBroker"; +import { GuiEvent, GuiState, RightDrawerContent, useGuiValue } from "@framework/GuiMessageBroker"; import { Drawer } from "@framework/internal/components/Drawer"; -import { useModuleInstances } from "@framework/internal/hooks/workbenchHooks"; import type { Module } from "@framework/Module"; import { ModuleCategory, ModuleDevState } from "@framework/Module"; import { ModuleDataTags } from "@framework/ModuleDataTags"; @@ -56,7 +55,6 @@ type ModulesListItemProps = { displayName: string; description: string | null; drawPreviewFunc: DrawPreviewFunc | null; - relContainer: HTMLDivElement | null; guiMessageBroker: GuiMessageBroker; onShowDetails: (moduleName: string, yPos: number) => void; onHover: (moduleName: string, yPos: number) => void; @@ -129,10 +127,7 @@ const ModulesListItem: React.FC = (props) => { } if (dragging) { - const rect = props.relContainer?.getBoundingClientRect(); - if (rect) { - setDragPosition(subtractVec2(subtractVec2(vec2FromPointerEvent(e), rect), pointerToElementDiff)); - } + setDragPosition(subtractVec2(vec2FromPointerEvent(e), pointerToElementDiff)); } }; @@ -160,7 +155,7 @@ const ModulesListItem: React.FC = (props) => { } removeDraggingEventListeners(); }; - }, [props.relContainer, props.guiMessageBroker, props.name, onDraggingStart]); + }, [props.guiMessageBroker, props.name, onDraggingStart]); function handleShowDetails(e: React.MouseEvent) { e.stopPropagation(); @@ -192,9 +187,8 @@ const ModulesListItem: React.FC = (props) => { return
; } - return ( - <> - {isDragged &&
} + function makeItem() { + return (
= (props) => { style={makeStyle(isDragged, dragSize, dragPosition)} onMouseOver={handleHover} > -
+
{makePreviewImage()}
{props.displayName} = (props) => {
- - ); + ); + } + + if (isDragged) { + return ( + <> +
+ {createPortal(makeItem())} + + ); + } + return makeItem(); }; type DevStatesFilterProps = { @@ -234,7 +238,7 @@ type DevStatesFilterProps = { }; function DevStatesFilter(props: DevStatesFilterProps): React.ReactNode { - const [expanded, setExpanded] = React.useState(false); + const [expanded, setExpanded] = React.useState(true); const [devStates, setDevStates] = React.useState(props.initialDevStates); function toggleExpanded() { @@ -357,7 +361,7 @@ function makeDevStateIcon(devState: ModuleDevState): React.ReactNode { } type DetailsPopupProps = { - module: Module; + module: Module; left: number; top: number; onClose: () => void; @@ -437,9 +441,9 @@ function DetailsPopup(props: DetailsPopupProps): React.ReactNode { ); } -type ModulesListProps = { +export type ModulesListProps = { workbench: Workbench; - relContainer: HTMLDivElement | null; + onClose: () => void; }; const MODULE_CATEGORIES: { category: ModuleCategory; label: string }[] = [ @@ -458,8 +462,7 @@ if (isDevMode()) { } export const ModulesList: React.FC = (props) => { - const drawerContent = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.LeftDrawerContent); - const moduleInstances = useModuleInstances(props.workbench); + const drawerContent = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.RightDrawerContent); const ref = React.useRef(null); const boundingClientRect = useElementBoundingRect(ref); @@ -500,14 +503,14 @@ export const ModulesList: React.FC = (props) => { setDetailsPosY(yPos); } - function handleNotificationClick() { - props.workbench.getGuiMessageBroker().setState(GuiState.LeftDrawerContent, LeftDrawerContent.ModuleSettings); - } - const handleDraggingStart = React.useCallback(function handleDraggingStart() { setShowDetailsForModule(null); }, []); + function handleClose() { + props.onClose(); + } + const filteredModules = Object.values(ModuleRegistry.getRegisteredModules()) .filter((mod) => devStates.includes(mod.getDevState())) .filter((mod) => mod.getDefaultTitle().toLowerCase().includes(searchQuery.toLowerCase())); @@ -517,12 +520,13 @@ export const ModulesList: React.FC = (props) => { left = boundingClientRect.left + boundingClientRect.width + 10; } - const visible = drawerContent === LeftDrawerContent.ModulesList; + const visible = drawerContent === RightDrawerContent.ModulesList; return ( -
+
} showFilter @@ -530,7 +534,6 @@ export const ModulesList: React.FC = (props) => { onFilterChange={handleSearchQueryChange} headerChildren={ <> - 0} /> } @@ -542,7 +545,6 @@ export const ModulesList: React.FC = (props) => { .filter((mod) => mod.getCategory() === el.category) .map((mod) => ( = (props) => {
); }; - -type NotificationProps = { - visible: boolean; - onClick: () => void; -}; - -function Notification(props: NotificationProps): React.ReactNode { - if (!props.visible) { - return null; - } - return ( -
-

Done editing?

- Click here or on a module header to start using your dashboard. -
- ); -} diff --git a/frontend/src/framework/internal/components/MultiSessionsRecoveryDialog/index.ts b/frontend/src/framework/internal/components/MultiSessionsRecoveryDialog/index.ts new file mode 100644 index 000000000..c6bc07e9a --- /dev/null +++ b/frontend/src/framework/internal/components/MultiSessionsRecoveryDialog/index.ts @@ -0,0 +1,2 @@ +export { MultiSessionsRecoveryDialog } from "./multiSessionsRecoveryDialog"; +export type { MultiSessionsRecoveryDialogProps } from "./multiSessionsRecoveryDialog"; diff --git a/frontend/src/framework/internal/components/MultiSessionsRecoveryDialog/multiSessionsRecoveryDialog.tsx b/frontend/src/framework/internal/components/MultiSessionsRecoveryDialog/multiSessionsRecoveryDialog.tsx new file mode 100644 index 000000000..bed98cfd2 --- /dev/null +++ b/frontend/src/framework/internal/components/MultiSessionsRecoveryDialog/multiSessionsRecoveryDialog.tsx @@ -0,0 +1,93 @@ +import React from "react"; + +import { GuiState, useGuiState } from "@framework/GuiMessageBroker"; +import type { WorkbenchSessionDataContainer } from "@framework/internal/WorkbenchSession/WorkbenchSessionDataContainer"; +import { loadAllWorkbenchSessionsFromLocalStorage } from "@framework/internal/WorkbenchSession/WorkbenchSessionLoader"; +import type { Workbench } from "@framework/Workbench"; +import { Button } from "@lib/components/Button"; +import { Dialog } from "@lib/components/Dialog"; + +import { SessionRow } from "./private-components/sessionRow"; + +export type MultiSessionsRecoveryDialogProps = { + workbench: Workbench; +}; + +export function MultiSessionsRecoveryDialog(props: MultiSessionsRecoveryDialogProps): React.ReactNode { + const [isOpen, setIsOpen] = useGuiState( + props.workbench.getGuiMessageBroker(), + GuiState.MultiSessionsRecoveryDialogOpen, + ); + const [sessions, setSessions] = React.useState([]); + + const loadSessions = React.useCallback( + async function loadSessions() { + const loadedSessions = await loadAllWorkbenchSessionsFromLocalStorage(); + + setSessions(loadedSessions); + }, + [props.workbench], + ); + + React.useEffect( + function loadSessionOnOpen() { + if (isOpen) { + loadSessions(); + } + }, + [isOpen, loadSessions], + ); + + if (!isOpen) { + return null; + } + + function handleCancel() { + setIsOpen(false); + } + + function handleDiscard(sessionId: string | null) { + props.workbench.discardLocalStorageSession(sessionId); + loadSessions(); + } + + function handleOpen(sessionId: string | null) { + props.workbench.openSessionFromLocalStorage(sessionId); + } + + return ( + + + + } + width={800} + > + We found one or more previous sessions with unsaved changes. You can either discard them or open one of the + sessions below to recover your work. + + + + + + + + + + + + {sessions.map((session) => ( + + ))} + +
NameCreated AtUpdated atLast persistedActions
+
+ ); +} diff --git a/frontend/src/framework/internal/components/MultiSessionsRecoveryDialog/private-components/sessionRow.tsx b/frontend/src/framework/internal/components/MultiSessionsRecoveryDialog/private-components/sessionRow.tsx new file mode 100644 index 000000000..78ac1e0eb --- /dev/null +++ b/frontend/src/framework/internal/components/MultiSessionsRecoveryDialog/private-components/sessionRow.tsx @@ -0,0 +1,77 @@ +import type React from "react"; + +import { Delete, FileOpen } from "@mui/icons-material"; +import { useQuery } from "@tanstack/react-query"; + +import { getSessionMetadataOptions } from "@api"; +import { + isPersisted, + type WorkbenchSessionDataContainer, +} from "@framework/internal/WorkbenchSession/WorkbenchSessionDataContainer"; +import { Button } from "@lib/components/Button"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; + +export type SessionRowProps = { + session: WorkbenchSessionDataContainer; + onOpen: (sessionId: string | null) => void; + onDiscard: (sessionId: string | null) => void; +}; + +export function SessionRow(props: SessionRowProps): React.ReactNode { + const backendSession = useQuery({ + ...getSessionMetadataOptions({ + path: { session_id: props.session.id ?? "" }, + }), + enabled: Boolean(props.session.id) && isPersisted(props.session), + gcTime: 0, + staleTime: 0, + }); + + let lastPersisted: React.ReactNode = "Never"; + if (props.session.id) { + if (backendSession.isSuccess) { + const updatedAt = backendSession.data.updatedAt; + if (updatedAt) { + lastPersisted = new Date(updatedAt).toLocaleString(); + } + } else if (backendSession.isError) { + lastPersisted = "Failed to fetch metadata"; + } else if (backendSession.isFetching) { + lastPersisted = ( + + + Fetching from server... + + ); + } + } + + return ( + + + {props.session.metadata.title} + + {new Date(props.session.metadata.createdAt).toLocaleString()} + + {new Date(props.session.metadata.lastModifiedMs).toLocaleString()} + + {lastPersisted} + + + + + + ); +} diff --git a/frontend/src/framework/internal/components/NavBar/leftNavBar.tsx b/frontend/src/framework/internal/components/NavBar/leftNavBar.tsx index e1c7893a0..a0d5d1617 100644 --- a/frontend/src/framework/internal/components/NavBar/leftNavBar.tsx +++ b/frontend/src/framework/internal/components/NavBar/leftNavBar.tsx @@ -1,44 +1,34 @@ -import React from "react"; +import type React from "react"; -import { ChevronLeft, ChevronRight, GridView, Link, List, Palette, Settings, WebAsset } from "@mui/icons-material"; -import { useQueryClient } from "@tanstack/react-query"; +import { GridView, Link, List, Palette, Tune } from "@mui/icons-material"; -import FmuLogo from "@assets/fmu.svg"; - -import { GuiState, LeftDrawerContent, useGuiState, useGuiValue } from "@framework/GuiMessageBroker"; -import { LoginButton } from "@framework/internal/components/LoginButton"; -import { SelectEnsemblesDialog } from "@framework/internal/components/SelectEnsemblesDialog"; -import type { - BaseEnsembleItem, - DeltaEnsembleItem, - RegularEnsembleItem, -} from "@framework/internal/components/SelectEnsemblesDialog"; -import { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; -import type { UserDeltaEnsembleSetting, UserEnsembleSetting, Workbench } from "@framework/Workbench"; -import { WorkbenchEvents } from "@framework/Workbench"; -import { useEnsembleSet, useIsEnsembleSetLoading } from "@framework/WorkbenchSession"; +import { GuiState, LeftDrawerContent, useGuiState } from "@framework/GuiMessageBroker"; +import { DashboardTopic } from "@framework/internal/WorkbenchSession/Dashboard"; +import { PrivateWorkbenchSessionTopic } from "@framework/internal/WorkbenchSession/PrivateWorkbenchSession"; +import type { Workbench } from "@framework/Workbench"; import { Badge } from "@lib/components/Badge"; -import { Button } from "@lib/components/Button"; import { CircularProgress } from "@lib/components/CircularProgress"; import { NavBarButton, NavBarDivider } from "@lib/components/NavBarComponents"; -import { isDevMode } from "@lib/utils/devMode"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; -import { UserSessionState } from "./private-components/UserSessionState"; - type LeftNavBarProps = { workbench: Workbench; }; export const LeftNavBar: React.FC = (props) => { - const ensembleSet = useEnsembleSet(props.workbench.getWorkbenchSession()); - const [ensembleDialogOpen, setEnsembleDialogOpen] = React.useState(false); - const [newSelectedEnsembles, setNewSelectedEnsembles] = React.useState([]); - const [newCreatedDeltaEnsembles, setNewCreatedDeltaEnsembles] = React.useState([]); - const [layoutEmpty, setLayoutEmpty] = React.useState(props.workbench.getLayout().length === 0); - const [collapsed, setCollapsed] = React.useState(localStorage.getItem("navBarCollapsed") === "true"); - const [prevIsAppInitialized, setPrevIsAppInitialized] = React.useState(false); - const loadingEnsembleSet = useIsEnsembleSetLoading(props.workbench.getWorkbenchSession()); + const workbenchSession = props.workbench.getWorkbenchSession(); + const ensembleSet = usePublishSubscribeTopicValue(workbenchSession, PrivateWorkbenchSessionTopic.ENSEMBLE_SET); + const dashboard = usePublishSubscribeTopicValue(workbenchSession, PrivateWorkbenchSessionTopic.ACTIVE_DASHBOARD); + const layout = usePublishSubscribeTopicValue(dashboard, DashboardTopic.Layout); + const [ensembleDialogOpen, setEnsembleDialogOpen] = useGuiState( + props.workbench.getGuiMessageBroker(), + GuiState.EnsembleDialogOpen, + ); + const loadingEnsembleSet = usePublishSubscribeTopicValue( + workbenchSession, + PrivateWorkbenchSessionTopic.IS_ENSEMBLE_SET_LOADING, + ); const [drawerContent, setDrawerContent] = useGuiState( props.workbench.getGuiMessageBroker(), GuiState.LeftDrawerContent, @@ -47,36 +37,8 @@ export const LeftNavBar: React.FC = (props) => { props.workbench.getGuiMessageBroker(), GuiState.LeftSettingsPanelWidthInPercent, ); - const isAppInitialized = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.AppInitialized); - - if (isAppInitialized !== prevIsAppInitialized && !loadingEnsembleSet) { - setEnsembleDialogOpen(ensembleSet.getEnsembleArray().length === 0); - setPrevIsAppInitialized(isAppInitialized); - } - - const queryClient = useQueryClient(); - const colorSet = props.workbench.getWorkbenchSettings().useColorSet(); - - React.useEffect( - function reactToModuleInstancesChanged() { - function listener() { - if ( - props.workbench.getLayout().length === 0 && - [LeftDrawerContent.ModuleSettings, LeftDrawerContent.SyncSettings].includes(drawerContent) - ) { - setDrawerContent(LeftDrawerContent.ModulesList); - } - setLayoutEmpty(props.workbench.getLayout().length === 0); - } - - const unsubscribeFunc = props.workbench.subscribe(WorkbenchEvents.ModuleInstancesChanged, listener); - - return () => { - unsubscribeFunc(); - }; - }, - [drawerContent, props.workbench, setDrawerContent], - ); + const [, setTemplatesDialogOpen] = useGuiState(props.workbench.getGuiMessageBroker(), GuiState.TemplatesDialogOpen); + const isSnapshot = usePublishSubscribeTopicValue(workbenchSession, PrivateWorkbenchSessionTopic.IS_SNAPSHOT); function ensureSettingsPanelIsVisible() { if (leftSettingsPanelWidth <= 5) { @@ -93,14 +55,8 @@ export const LeftNavBar: React.FC = (props) => { setDrawerContent(LeftDrawerContent.ModuleSettings); } - function handleModulesListClick() { - ensureSettingsPanelIsVisible(); - setDrawerContent(LeftDrawerContent.ModulesList); - } - - function handleTemplatesListClick() { - ensureSettingsPanelIsVisible(); - setDrawerContent(LeftDrawerContent.TemplatesList); + function handleTemplatesDialogClick() { + setTemplatesDialogOpen(true); } function handleSyncSettingsClick() { @@ -113,130 +69,29 @@ export const LeftNavBar: React.FC = (props) => { setDrawerContent(LeftDrawerContent.ColorPaletteSettings); } - function handleEnsembleDialogClose() { - setEnsembleDialogOpen(false); - } - - function handleCollapseOrExpand() { - setCollapsed(!collapsed); - localStorage.setItem("navBarCollapsed", (!collapsed).toString()); - } - - function loadAndSetupEnsembles( - ensembleItems: RegularEnsembleItem[], - createdDeltaEnsembles: DeltaEnsembleItem[], - ): Promise { - setNewSelectedEnsembles(ensembleItems); - setNewCreatedDeltaEnsembles(createdDeltaEnsembles); - const ensembleSettings: UserEnsembleSetting[] = ensembleItems.map((ens) => ({ - ensembleIdent: new RegularEnsembleIdent(ens.caseUuid, ens.ensembleName), - customName: ens.customName, - color: ens.color, - timestamps: ens.timestamps, - })); - const deltaEnsembleSettings: UserDeltaEnsembleSetting[] = createdDeltaEnsembles.map((deltaEns) => ({ - comparisonEnsembleIdent: new RegularEnsembleIdent( - deltaEns.comparisonEnsemble.caseUuid, - deltaEns.comparisonEnsemble.ensembleName, - ), - referenceEnsembleIdent: new RegularEnsembleIdent( - deltaEns.referenceEnsemble.caseUuid, - deltaEns.referenceEnsemble.ensembleName, - ), - customName: deltaEns.customName, - color: deltaEns.color, - })); - return props.workbench.storeSettingsInLocalStorageAndLoadAndSetupEnsembleSetInSession( - queryClient, - ensembleSettings, - deltaEnsembleSettings, - ); - } - - const selectedEnsembles: RegularEnsembleItem[] = ensembleSet.getRegularEnsembleArray().map((ens) => ({ - caseUuid: ens.getCaseUuid(), - caseName: ens.getCaseName(), - ensembleName: ens.getEnsembleName(), - color: ens.getColor(), - customName: ens.getCustomName(), - timestamps: ens.getTimestamps(), - })); - - let fixedSelectedEnsembles = selectedEnsembles; - if (loadingEnsembleSet) { - fixedSelectedEnsembles = newSelectedEnsembles; - } - - const createdDeltaEnsembles: DeltaEnsembleItem[] = ensembleSet.getDeltaEnsembleArray().map((deltaEns) => { - const comparisonEnsemble: BaseEnsembleItem = { - caseUuid: deltaEns.getComparisonEnsembleIdent().getCaseUuid(), - ensembleName: deltaEns.getComparisonEnsembleIdent().getEnsembleName(), - }; - const referenceEnsemble: BaseEnsembleItem = { - caseUuid: deltaEns.getReferenceEnsembleIdent().getCaseUuid(), - ensembleName: deltaEns.getReferenceEnsembleIdent().getEnsembleName(), - }; - - const deltaEnsembleItem: DeltaEnsembleItem = { - comparisonEnsemble: comparisonEnsemble, - referenceEnsemble: referenceEnsemble, - color: deltaEns.getColor(), - customName: deltaEns.getCustomName(), - }; - return deltaEnsembleItem; - }); - let fixedCreatedDeltaEnsembles = createdDeltaEnsembles; - if (loadingEnsembleSet) { - fixedCreatedDeltaEnsembles = newCreatedDeltaEnsembles; - } + const layoutEmpty = layout.length === 0; return (
-
- FMU Analysis logo - {collapsed ? null :

FMU Analysis

} -
-
- BETA -
-
- -
- - ) : ( - selectedEnsembles.length + createdDeltaEnsembles.length + ensembleSet.getEnsembleArray().length ) } > @@ -250,72 +105,40 @@ export const LeftNavBar: React.FC = (props) => { } + icon={} onClick={handleModuleSettingsClick} disabled={layoutEmpty} /> } onClick={handleSyncSettingsClick} /> - } - onClick={handleModulesListClick} - /> - } - onClick={handleTemplatesListClick} + onClick={handleTemplatesDialogClick} + disabled={isSnapshot} /> } onClick={handleColorPaletteSettingsClick} /> - - - -
-
- - -
- {ensembleDialogOpen && ( - - )}
); }; diff --git a/frontend/src/framework/internal/components/NavBar/rightNavBar.tsx b/frontend/src/framework/internal/components/NavBar/rightNavBar.tsx index 2e0a70d3e..e846678ee 100644 --- a/frontend/src/framework/internal/components/NavBar/rightNavBar.tsx +++ b/frontend/src/framework/internal/components/NavBar/rightNavBar.tsx @@ -1,12 +1,14 @@ import type React from "react"; -import { FilterAlt, Fullscreen, FullscreenExit, History } from "@mui/icons-material"; +import { FilterAlt, Fullscreen, FullscreenExit, History, WebAsset } from "@mui/icons-material"; import { GuiState, RightDrawerContent, useGuiState } from "@framework/GuiMessageBroker"; import { useBrowserFullscreen } from "@framework/internal/hooks/useBrowserFullscreen"; +import { PrivateWorkbenchSessionTopic } from "@framework/internal/WorkbenchSession/PrivateWorkbenchSession"; import type { Workbench } from "@framework/Workbench"; import { Badge } from "@lib/components/Badge"; import { NavBarButton, NavBarDivider } from "@lib/components/NavBarComponents"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; type RightNavBarProps = { @@ -14,6 +16,7 @@ type RightNavBarProps = { }; export const RightNavBar: React.FC = (props) => { + const workbenchSession = props.workbench.getWorkbenchSession(); const guiMessageBroker = props.workbench.getGuiMessageBroker(); const [isFullscreen, toggleFullScreen] = useBrowserFullscreen(); @@ -26,6 +29,7 @@ export const RightNavBar: React.FC = (props) => { guiMessageBroker, GuiState.RightSettingsPanelWidthInPercent, ); + const isSnapshot = usePublishSubscribeTopicValue(workbenchSession, PrivateWorkbenchSessionTopic.IS_SNAPSHOT); function ensureSettingsPanelIsVisible() { if (rightSettingsPanelWidth <= 5) { @@ -46,6 +50,11 @@ export const RightNavBar: React.FC = (props) => { } } + function handleModulesListClick() { + ensureSettingsPanelIsVisible(); + setDrawerContent(RightDrawerContent.ModulesList); + } + function handleRealizationFilterClick() { togglePanelContent(RightDrawerContent.RealizationFilterSettings); } @@ -56,11 +65,18 @@ export const RightNavBar: React.FC = (props) => { return (
+ } + onClick={handleModulesListClick} + disabled={isSnapshot} + /> + = (props) => { title="Open module history" onClick={handleModuleInstanceLogClick} /> - - } diff --git a/frontend/src/framework/internal/components/RightSettingsPanel/private-components/ModuleInstanceLog/moduleInstanceLog.tsx b/frontend/src/framework/internal/components/RightSettingsPanel/private-components/ModuleInstanceLog/moduleInstanceLog.tsx index 25eeb7d27..f1b7ee116 100644 --- a/frontend/src/framework/internal/components/RightSettingsPanel/private-components/ModuleInstanceLog/moduleInstanceLog.tsx +++ b/frontend/src/framework/internal/components/RightSettingsPanel/private-components/ModuleInstanceLog/moduleInstanceLog.tsx @@ -2,6 +2,7 @@ import React from "react"; import { CheckCircle, ClearAll, CloudDone, CloudDownload, Error, History, Warning } from "@mui/icons-material"; +import { DashboardTopic } from "@framework/internal/WorkbenchSession/Dashboard"; import { GuiState, RightDrawerContent, useGuiValue } from "@framework/GuiMessageBroker"; import { Drawer } from "@framework/internal/components/Drawer"; import type { LogEntry } from "@framework/internal/ModuleInstanceStatusControllerInternal"; @@ -9,11 +10,13 @@ import { LogEntryType, useStatusControllerStateValue, } from "@framework/internal/ModuleInstanceStatusControllerInternal"; +import { PrivateWorkbenchSessionTopic } from "@framework/internal/WorkbenchSession/PrivateWorkbenchSession"; import type { ModuleInstance } from "@framework/ModuleInstance"; import { StatusMessageType } from "@framework/ModuleInstanceStatusController"; import type { Workbench } from "@framework/Workbench"; import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect"; import { createPortal } from "@lib/utils/createPortal"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; import { convertRemToPixels } from "@lib/utils/screenUnitConversions"; @@ -23,18 +26,24 @@ export type ModuleInstanceLogProps = { }; export function ModuleInstanceLog(props: ModuleInstanceLogProps): React.ReactNode { + const dashboard = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.ACTIVE_DASHBOARD, + ); const [details, setDetails] = React.useState | null>(null); const [detailsPosY, setDetailsPosY] = React.useState(0); const [pointerOverDetails, setPointerOverDetails] = React.useState(false); const drawerContent = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.RightDrawerContent); - const activeModuleInstanceId = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.ActiveModuleInstanceId); + const activeModuleInstanceId = usePublishSubscribeTopicValue(dashboard, DashboardTopic.ActiveModuleInstanceId); const ref = React.useRef(null); const boundingClientRect = useElementBoundingRect(ref); const timeoutRef = React.useRef | null>(null); - const moduleInstance = props.workbench.getModuleInstance(activeModuleInstanceId); + const moduleInstance = usePublishSubscribeTopicValue(dashboard, DashboardTopic.ModuleInstances).find( + (instance) => instance.getId() === activeModuleInstanceId, + ); React.useEffect(function handleMount() { const currentTimeoutRef = timeoutRef.current; @@ -165,7 +174,7 @@ export function ModuleInstanceLog(props: ModuleInstanceLogProps): React.ReactNod type LogListProps = { onShowDetails: (details: Record, yPos: number) => void; onHideDetails: () => void; - moduleInstance: ModuleInstance; + moduleInstance: ModuleInstance; }; function LogList(props: LogListProps): React.ReactNode { diff --git a/frontend/src/framework/internal/components/RightSettingsPanel/private-components/RealizationFilterSettings/realizationFilterSettings.tsx b/frontend/src/framework/internal/components/RightSettingsPanel/private-components/RealizationFilterSettings/realizationFilterSettings.tsx index 139e7fc63..04f305b9e 100644 --- a/frontend/src/framework/internal/components/RightSettingsPanel/private-components/RealizationFilterSettings/realizationFilterSettings.tsx +++ b/frontend/src/framework/internal/components/RightSettingsPanel/private-components/RealizationFilterSettings/realizationFilterSettings.tsx @@ -9,6 +9,7 @@ import { GuiEvent, GuiState, RightDrawerContent, useGuiState, useGuiValue } from import { Drawer } from "@framework/internal/components/Drawer"; import type { EnsembleRealizationFilterSelections } from "@framework/internal/components/EnsembleRealizationFilter"; import { EnsembleRealizationFilter } from "@framework/internal/components/EnsembleRealizationFilter"; +import { PrivateWorkbenchSessionTopic } from "@framework/internal/WorkbenchSession/PrivateWorkbenchSession"; import type { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; import { UnsavedChangesAction } from "@framework/types/unsavedChangesAction"; import { areUnsortedArraysEqual } from "@framework/utils/arrayUtils"; @@ -16,8 +17,7 @@ import { getEnsembleIdentFromString } from "@framework/utils/ensembleIdentUtils" import { countTrueValues } from "@framework/utils/objectUtils"; import { areParameterIdentStringToValueSelectionMapCandidatesEqual } from "@framework/utils/realizationFilterTypesUtils"; import type { Workbench } from "@framework/Workbench"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; - +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; export type RealizationFilterSettingsProps = { workbench: Workbench; onClose: () => void }; @@ -25,7 +25,10 @@ export const RealizationFilterSettings: React.FC const guiMessageBroker = props.workbench.getGuiMessageBroker(); const drawerContent = useGuiValue(guiMessageBroker, GuiState.RightDrawerContent); const rightSettingsPanelWidth = useGuiValue(guiMessageBroker, GuiState.RightSettingsPanelWidthInPercent); - const ensembleSet = useEnsembleSet(props.workbench.getWorkbenchSession()); + const ensembleSet = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.ENSEMBLE_SET, + ); const realizationFilterSet = props.workbench.getWorkbenchSession().getRealizationFilterSet(); const [, setNumberOfUnsavedRealizationFilters] = useGuiState( guiMessageBroker, diff --git a/frontend/src/framework/internal/components/RightSettingsPanel/rightSettingsPanel.tsx b/frontend/src/framework/internal/components/RightSettingsPanel/rightSettingsPanel.tsx index ab4f14c51..3e462d3ae 100644 --- a/frontend/src/framework/internal/components/RightSettingsPanel/rightSettingsPanel.tsx +++ b/frontend/src/framework/internal/components/RightSettingsPanel/rightSettingsPanel.tsx @@ -8,12 +8,14 @@ import { Dialog } from "@lib/components/Dialog"; import { ModuleInstanceLog } from "./private-components/ModuleInstanceLog/moduleInstanceLog"; import { RealizationFilterSettings } from "./private-components/RealizationFilterSettings"; +import { ModulesList } from "../ModulesList"; type RightSettingsPanelProps = { workbench: Workbench }; export const RightSettingsPanel: React.FC = (props) => { const guiMessageBroker = props.workbench.getGuiMessageBroker(); const [dialogOpen, setDialogOpen] = React.useState(false); + const mainRef = React.useRef(null); const [, setRightDrawerContent] = useGuiState(guiMessageBroker, GuiState.RightDrawerContent); const [, setRightSettingsPanelWidth] = useGuiState(guiMessageBroker, GuiState.RightSettingsPanelWidthInPercent); @@ -56,7 +58,8 @@ export const RightSettingsPanel: React.FC = (props) => } return ( -
+
+ (""); + const [description, setDescription] = React.useState(""); + const [inputFeedback, setInputFeedback] = React.useState({}); + const inputRef = React.useRef(null); + + React.useEffect( + function focusInput() { + if (saveSessionDialogOpen && inputRef.current) { + inputRef.current.focus(); + } + }, + [saveSessionDialogOpen], + ); + + function handleSave() { + if (title.trim() === "") { + setInputFeedback((prev) => ({ ...prev, title: "Title is required." })); + return; + } else { + setInputFeedback((prev) => ({ ...prev, title: undefined })); + } + + props.workbench.getWorkbenchSession().updateMetadata({ title, description }); + props.workbench + .saveCurrentSession(true) + .then((result) => { + if (!result) { + return; // Save was not successful, do not close dialog + } + setTitle(""); + setDescription(""); + setInputFeedback({}); + }) + .catch((error) => { + console.error("Failed to save session:", error); + }); + } + + function handleCancel() { + setSaveSessionDialogOpen(false); + setTitle(""); + setDescription(""); + setInputFeedback({}); + } + + const layout = props.workbench.getWorkbenchSession().getActiveDashboard()?.getLayout() || []; + + return ( + + + + + } + > +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/framework/internal/components/SelectEnsemblesDialog/_utils.ts b/frontend/src/framework/internal/components/SelectEnsemblesDialog/_utils.ts index 2ae48bc41..b236ecd60 100644 --- a/frontend/src/framework/internal/components/SelectEnsemblesDialog/_utils.ts +++ b/frontend/src/framework/internal/components/SelectEnsemblesDialog/_utils.ts @@ -1,7 +1,57 @@ -import type { BaseEnsembleItem } from "./types"; +import { v4 } from "uuid"; -export function isSameEnsembleItem(first: BaseEnsembleItem | null, second: BaseEnsembleItem | null): boolean { - if (!first || !second) return false; +import type { EnsembleSet } from "@framework/EnsembleSet"; - return first.caseUuid === second.caseUuid && first.ensembleName === second.ensembleName; +import type { InternalDeltaEnsembleSetting, InternalRegularEnsembleSetting } from "./types"; + +export function makeRegularEnsembleSettingsFromEnsembleSet(ensembleSet: EnsembleSet): InternalRegularEnsembleSetting[] { + const items: InternalRegularEnsembleSetting[] = []; + + for (const ensemble of ensembleSet.getRegularEnsembleArray()) { + items.push({ + ensembleIdent: ensemble.getIdent(), + caseName: ensemble.getCaseName(), + customName: ensemble.getCustomName(), + color: ensemble.getColor(), + }); + } + + return items; +} + +export function makeDeltaEnsembleSettingsFromEnsembleSet(ensembleSet: EnsembleSet): InternalDeltaEnsembleSetting[] { + const items: InternalDeltaEnsembleSetting[] = []; + + for (const ensemble of ensembleSet.getDeltaEnsembleArray()) { + items.push({ + comparisonEnsembleIdent: ensemble.getComparisonEnsembleIdent(), + referenceEnsembleIdent: ensemble.getReferenceEnsembleIdent(), + uuid: v4(), + color: ensemble.getColor(), + customName: ensemble.getCustomName(), + }); + } + + return items; +} + +export function makeHashFromSelectedEnsembles( + selectedRegularEnsembles: InternalRegularEnsembleSetting[], + selectedDeltaEnsembles: InternalDeltaEnsembleSetting[], +): string { + const regularHash = selectedRegularEnsembles + .map((item) => item.ensembleIdent.toString()) + .sort() + .join(","); + + const deltaHash = selectedDeltaEnsembles + .map((item) => makeHashFromDeltaEnsemble(item)) + .sort() + .join(","); + + return `${regularHash}|${deltaHash}`; +} + +export function makeHashFromDeltaEnsemble(deltaEnsemble: InternalDeltaEnsembleSetting): string { + return `${deltaEnsemble.comparisonEnsembleIdent.toString()}~&&~${deltaEnsemble.referenceEnsembleIdent.toString()}`; } diff --git a/frontend/src/framework/internal/components/SelectEnsemblesDialog/index.ts b/frontend/src/framework/internal/components/SelectEnsemblesDialog/index.ts index 7c2cc8daa..109dd356f 100644 --- a/frontend/src/framework/internal/components/SelectEnsemblesDialog/index.ts +++ b/frontend/src/framework/internal/components/SelectEnsemblesDialog/index.ts @@ -1,2 +1,2 @@ export { SelectEnsemblesDialog } from "./selectEnsemblesDialog"; -export type { BaseEnsembleItem, DeltaEnsembleItem, RegularEnsembleItem } from "./types"; +export type { SelectEnsemblesDialogProps } from "./selectEnsemblesDialog"; diff --git a/frontend/src/framework/internal/components/SelectEnsemblesDialog/private-components/DeltaEnsembleRow.tsx b/frontend/src/framework/internal/components/SelectEnsemblesDialog/private-components/DeltaEnsembleRow.tsx index 19d09083c..f80dc7202 100644 --- a/frontend/src/framework/internal/components/SelectEnsemblesDialog/private-components/DeltaEnsembleRow.tsx +++ b/frontend/src/framework/internal/components/SelectEnsemblesDialog/private-components/DeltaEnsembleRow.tsx @@ -2,6 +2,7 @@ import type React from "react"; import { Remove } from "@mui/icons-material"; +import { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; import { ColorSelect } from "@lib/components/ColorSelect"; import type { DropdownOption } from "@lib/components/Dropdown"; import { Dropdown } from "@lib/components/Dropdown"; @@ -9,56 +10,49 @@ import { IconButton } from "@lib/components/IconButton"; import { Input } from "@lib/components/Input"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; -import type { BaseEnsembleItem, InternalDeltaEnsembleItem, RegularEnsembleItem } from "../types"; +import type { InternalDeltaEnsembleSetting, InternalRegularEnsembleSetting } from "../types"; export type DeltaEnsembleRowProps = { - deltaEnsembleItem: InternalDeltaEnsembleItem; - availableRegularEnsembles: RegularEnsembleItem[]; + deltaEnsembleSetting: InternalDeltaEnsembleSetting; + availableRegularEnsembleSettings: InternalRegularEnsembleSetting[]; isDuplicate: boolean; isValid: boolean; - onUpdate: (newItem: InternalDeltaEnsembleItem) => void; - onDelete: (item: InternalDeltaEnsembleItem) => void; + onUpdate: (newItem: InternalDeltaEnsembleSetting) => void; + onDelete: (item: InternalDeltaEnsembleSetting) => void; }; -const CASE_UUID_ENSEMBLE_NAME_SEPARATOR = "~@@~"; - -function createEnsembleOptionValue(ensembleItem: BaseEnsembleItem): string { - return `${ensembleItem.caseUuid}${CASE_UUID_ENSEMBLE_NAME_SEPARATOR}${ensembleItem.ensembleName}`; +function createEnsembleOptionValue(ensembleIdent: RegularEnsembleIdent): string { + return ensembleIdent.toString(); } -function getEnsembleFromDropdownValue(value: string): BaseEnsembleItem { - const [caseUuid, ensembleName] = value.split(CASE_UUID_ENSEMBLE_NAME_SEPARATOR); - if (!caseUuid || !ensembleName) { - throw new Error("Invalid caseUuidAndEnsembleNameString"); - } - - return { caseUuid, ensembleName }; +function getEnsembleFromDropdownValue(value: string): RegularEnsembleIdent { + return RegularEnsembleIdent.fromString(value); } -function makeEnsembleDropdownOptions(ensembleItems: RegularEnsembleItem[]): DropdownOption[] { +function makeEnsembleDropdownOptions(ensembleItems: InternalRegularEnsembleSetting[]): DropdownOption[] { return ensembleItems.map((ens) => ({ - value: createEnsembleOptionValue(ens), - label: ens.customName ?? `${ens.ensembleName} (${ens.caseName})`, + value: createEnsembleOptionValue(ens.ensembleIdent), + label: ens.customName ?? `${ens.ensembleIdent.getEnsembleName()} (${ens.caseName})`, })); } export function DeltaEnsembleRow(props: DeltaEnsembleRowProps): React.ReactNode { - const { comparisonEnsemble, referenceEnsemble } = props.deltaEnsembleItem; + const { comparisonEnsembleIdent, referenceEnsembleIdent } = props.deltaEnsembleSetting; - const ensembleDropdownItems = makeEnsembleDropdownOptions(props.availableRegularEnsembles); - const comparisonEnsValue = comparisonEnsemble ? createEnsembleOptionValue(comparisonEnsemble) : undefined; - const referenceEnsValue = referenceEnsemble ? createEnsembleOptionValue(referenceEnsemble) : undefined; + const ensembleDropdownItems = makeEnsembleDropdownOptions(props.availableRegularEnsembleSettings); + const comparisonEnsValue = comparisonEnsembleIdent ? createEnsembleOptionValue(comparisonEnsembleIdent) : undefined; + const referenceEnsValue = referenceEnsembleIdent ? createEnsembleOptionValue(referenceEnsembleIdent) : undefined; function onColorChange(newColor: string) { props.onUpdate({ - ...props.deltaEnsembleItem, + ...props.deltaEnsembleSetting, color: newColor, }); } function onNameChange(newName: string) { props.onUpdate({ - ...props.deltaEnsembleItem, + ...props.deltaEnsembleSetting, customName: newName || null, }); } @@ -67,8 +61,8 @@ export function DeltaEnsembleRow(props: DeltaEnsembleRowProps): React.ReactNode const ens = getEnsembleFromDropdownValue(value); props.onUpdate({ - ...props.deltaEnsembleItem, - comparisonEnsemble: ens, + ...props.deltaEnsembleSetting, + comparisonEnsembleIdent: ens, }); } @@ -76,13 +70,13 @@ export function DeltaEnsembleRow(props: DeltaEnsembleRowProps): React.ReactNode const ens = getEnsembleFromDropdownValue(value); props.onUpdate({ - ...props.deltaEnsembleItem, - referenceEnsemble: ens, + ...props.deltaEnsembleSetting, + referenceEnsembleIdent: ens, }); } function onDelete() { - props.onDelete(props.deltaEnsembleItem); + props.onDelete(props.deltaEnsembleSetting); } return ( @@ -94,11 +88,11 @@ export function DeltaEnsembleRow(props: DeltaEnsembleRowProps): React.ReactNode })} > - + diff --git a/frontend/src/framework/internal/components/SelectEnsemblesDialog/private-components/EnsemblePicker.tsx b/frontend/src/framework/internal/components/SelectEnsemblesDialog/private-components/EnsemblePicker.tsx index 3cd83b432..012e331d7 100644 --- a/frontend/src/framework/internal/components/SelectEnsemblesDialog/private-components/EnsemblePicker.tsx +++ b/frontend/src/framework/internal/components/SelectEnsemblesDialog/private-components/EnsemblePicker.tsx @@ -5,8 +5,10 @@ import { useQuery } from "@tanstack/react-query"; import type { CaseInfo_api } from "@api"; import { getCasesOptions, getEnsemblesOptions, getFieldsOptions } from "@api"; +import type { UserEnsembleSetting } from "@framework/internal/EnsembleSetLoader"; import { useAuthProvider } from "@framework/internal/providers/AuthProvider"; import { tanstackDebugTimeOverride } from "@framework/internal/utils/debug"; +import { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; import { Button } from "@lib/components/Button"; import { CircularProgress } from "@lib/components/CircularProgress"; import { Dropdown } from "@lib/components/Dropdown"; @@ -18,15 +20,14 @@ import type { TableSelectOption } from "@lib/components/TableSelect"; import { TableSelect } from "@lib/components/TableSelect"; import { useValidState } from "@lib/hooks/useValidState"; -import { isSameEnsembleItem } from "../_utils"; -import type { BaseEnsembleItem, RegularEnsembleItem } from "../types"; +import type { InternalRegularEnsembleSetting } from "../types"; import { UserAvatar } from "./userAvatar"; export type EnsemblePickerProps = { nextEnsembleColor: string; - selectedEnsembles: RegularEnsembleItem[]; - onAddEnsemble: (newEnsemble: RegularEnsembleItem) => void; + selectedEnsembles: UserEnsembleSetting[]; + onAddEnsemble: (newEnsemble: InternalRegularEnsembleSetting) => void; }; const STALE_TIME = tanstackDebugTimeOverride(0); @@ -115,7 +116,7 @@ export function EnsemblePicker(props: EnsemblePickerProps): React.ReactNode { ], })) ?? []; - const [selectedCaseId, setSelectedCaseId] = useValidState({ + const [selectedCaseUuid, setSelectedCaseId] = useValidState({ initialState: "", validStates: filterCases(casesQuery.data)?.map((item) => item.uuid) ?? [], keepStateWhenInvalid: true, @@ -123,15 +124,15 @@ export function EnsemblePicker(props: EnsemblePickerProps): React.ReactNode { const selectedCase = React.useMemo(() => { const cases = casesQuery.data ?? []; - return cases.find((c) => c.uuid === selectedCaseId); - }, [casesQuery.data, selectedCaseId]); + return cases.find((c) => c.uuid === selectedCaseUuid); + }, [casesQuery.data, selectedCaseUuid]); // Ensemble select const ensemblesQuery = useQuery({ ...getEnsemblesOptions({ query: { t: selectedCase?.updatedAtUtcMs }, path: { - case_uuid: selectedCaseId, + case_uuid: selectedCaseUuid, }, }), enabled: casesQuery.isSuccess && !!selectedCase, @@ -156,17 +157,16 @@ export function EnsemblePicker(props: EnsemblePickerProps): React.ReactNode { value: e.name, })) ?? []; - const selectedEnsembleItem = React.useMemo(() => { - return { caseUuid: selectedCaseId, ensembleName: selectedEnsembleName }; - }, [selectedCaseId, selectedEnsembleName]); - - const ensembleAlreadySelected = React.useMemo(() => { - return ( - selectedCaseId && - selectedEnsembleName && - props.selectedEnsembles.some((e) => isSameEnsembleItem(e, selectedEnsembleItem)) - ); - }, [props.selectedEnsembles, selectedCaseId, selectedEnsembleItem, selectedEnsembleName]); + let selectedEnsembleIdent: RegularEnsembleIdent | null = null; + try { + selectedEnsembleIdent = new RegularEnsembleIdent(selectedCaseUuid, selectedEnsembleName); + } catch (_e) { + selectedEnsembleIdent = null; + } + const ensembleAlreadySelected = + selectedCaseUuid && + selectedEnsembleName && + props.selectedEnsembles.some((el) => el.ensembleIdent.equals(selectedEnsembleIdent)); function handleFieldChanged(fieldIdentifier: string) { storeStateInLocalStorage("selectedField", fieldIdentifier); @@ -185,12 +185,11 @@ export function EnsemblePicker(props: EnsemblePickerProps): React.ReactNode { if (ensembleAlreadySelected) return; if (!selectedEnsemble) return; - const caseName = casesQuery.data?.find((c) => c.uuid === selectedCaseId)?.name ?? "UNKNOWN"; + const caseName = casesQuery.data?.find((c) => c.uuid === selectedCaseUuid)?.name ?? "UNKNOWN"; props.onAddEnsemble({ - caseUuid: selectedCaseId, + ensembleIdent: new RegularEnsembleIdent(selectedCaseUuid, selectedEnsembleName), caseName: caseName, - ensembleName: selectedEnsembleName, color: props.nextEnsembleColor, customName: null, timestamps: selectedEnsemble.timestamps, @@ -231,7 +230,7 @@ export function EnsemblePicker(props: EnsemblePickerProps): React.ReactNode { void; - onRemoveRegularEnsemble: (removedEnsemble: RegularEnsembleItem) => void; + onUpdateRegularEnsemble: (updatedEnsemble: InternalRegularEnsembleSetting) => void; + onRemoveRegularEnsemble: (removedEnsemble: InternalRegularEnsembleSetting) => void; - onAddDeltaEnsemble: (newEnsemble: InternalDeltaEnsembleItem) => void; - onUpdateDeltaEnsemble: (updatedEnsemble: InternalDeltaEnsembleItem) => void; - onRemoveDeltaEnsemble: (removedEnsemble: InternalDeltaEnsembleItem) => void; + onAddDeltaEnsemble: (newEnsemble: InternalDeltaEnsembleSetting) => void; + onUpdateDeltaEnsemble: (updatedEnsemble: InternalDeltaEnsembleSetting) => void; + onRemoveDeltaEnsemble: (removedEnsemble: InternalDeltaEnsembleSetting) => void; }; export function EnsembleTables(props: EnsembleTablesProps): React.ReactNode { - function isDuplicateDelta(deltaEnsemble: InternalDeltaEnsembleItem) { - const { uuid, referenceEnsemble, comparisonEnsemble } = deltaEnsemble; + function isDuplicateDelta(deltaEnsemble: InternalDeltaEnsembleSetting) { + const { uuid, referenceEnsembleIdent, comparisonEnsembleIdent } = deltaEnsemble; return props.deltaEnsembles.some((other) => { if (other.uuid === uuid) return false; return ( - isSameEnsembleItem(other.comparisonEnsemble, comparisonEnsemble) && - isSameEnsembleItem(other.referenceEnsemble, referenceEnsemble) + other.comparisonEnsembleIdent.equals(comparisonEnsembleIdent) && + other.referenceEnsembleIdent.equals(referenceEnsembleIdent) ); }); } - function isValidDelta(deltaEnsemble: InternalDeltaEnsembleItem) { - return !!deltaEnsemble.comparisonEnsemble && !!deltaEnsemble.referenceEnsemble; + function isValidDelta(deltaEnsemble: InternalDeltaEnsembleSetting) { + return !!deltaEnsemble.comparisonEnsembleIdent && !!deltaEnsemble.referenceEnsembleIdent; } function createNewDeltaEnsemble() { if (!props.regularEnsembles.length) return; - const comparisonEns = props.regularEnsembles[0]; - const referenceEns = props.regularEnsembles[1] ?? props.regularEnsembles[0]; + const comparisonEns = props.regularEnsembles[0].ensembleIdent; + const referenceEns = props.regularEnsembles[1]?.ensembleIdent ?? props.regularEnsembles[0].ensembleIdent; props.onAddDeltaEnsemble({ uuid: v4(), color: props.nextEnsembleColor, - comparisonEnsemble: comparisonEns, - referenceEnsemble: referenceEns, + comparisonEnsembleIdent: comparisonEns, + referenceEnsembleIdent: referenceEns, customName: null, }); } @@ -76,8 +75,8 @@ export function EnsembleTables(props: EnsembleTablesProps): React.ReactNode { {props.regularEnsembles.map((item) => ( @@ -123,8 +122,8 @@ export function EnsembleTables(props: EnsembleTablesProps): React.ReactNode { return ( void; - onDelete: (item: RegularEnsembleItem) => void; + ensembleSetting: InternalRegularEnsembleSetting; + onUpdate: (newItem: InternalRegularEnsembleSetting) => void; + onDelete: (item: InternalRegularEnsembleSetting) => void; }; export function RegularEnsembleRow(props: RegularEnsembleRowProps): React.ReactNode { function onColorChange(newColor: string) { props.onUpdate({ - ...props.ensembleItem, + ...props.ensembleSetting, color: newColor, }); } function onNameChange(newName: string) { props.onUpdate({ - ...props.ensembleItem, + ...props.ensembleSetting, customName: newName || null, }); } function onDelete() { - props.onDelete(props.ensembleItem); + props.onDelete(props.ensembleSetting); } return ( - + -
- {props.ensembleItem.caseName} +
+ {props.ensembleSetting.caseName}
- {props.ensembleItem.ensembleName} + {props.ensembleSetting.ensembleIdent.getEnsembleName()}
diff --git a/frontend/src/framework/internal/components/SelectEnsemblesDialog/selectEnsemblesDialog.tsx b/frontend/src/framework/internal/components/SelectEnsemblesDialog/selectEnsemblesDialog.tsx index 99a71a8dd..720105fad 100644 --- a/frontend/src/framework/internal/components/SelectEnsemblesDialog/selectEnsemblesDialog.tsx +++ b/frontend/src/framework/internal/components/SelectEnsemblesDialog/selectEnsemblesDialog.tsx @@ -2,59 +2,72 @@ import React from "react"; import { Check } from "@mui/icons-material"; import { isEqual } from "lodash"; -import { v4 } from "uuid"; +import type { EnsembleSet } from "@framework/EnsembleSet"; +import { GuiState, useGuiState } from "@framework/GuiMessageBroker"; +import type { UserDeltaEnsembleSetting, UserEnsembleSetting } from "@framework/internal/EnsembleSetLoader"; +import { PrivateWorkbenchSessionTopic } from "@framework/internal/WorkbenchSession/PrivateWorkbenchSession"; +import type { Workbench } from "@framework/Workbench"; +import { useColorSet } from "@framework/WorkbenchSettings"; import { Button } from "@lib/components/Button"; import { CircularProgress } from "@lib/components/CircularProgress"; import { Dialog } from "@lib/components/Dialog"; -import type { ColorSet } from "@lib/utils/ColorSet"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { LoadingOverlay } from "../LoadingOverlay"; -import { isSameEnsembleItem } from "./_utils"; +import { + makeDeltaEnsembleSettingsFromEnsembleSet, + makeHashFromDeltaEnsemble, + makeHashFromSelectedEnsembles, + makeRegularEnsembleSettingsFromEnsembleSet, +} from "./_utils"; import { EnsemblePicker } from "./private-components/EnsemblePicker"; import { EnsembleTables } from "./private-components/EnsembleTables"; -import type { DeltaEnsembleItem, InternalDeltaEnsembleItem, RegularEnsembleItem } from "./types"; +import type { InternalDeltaEnsembleSetting, InternalRegularEnsembleSetting } from "./types"; export type SelectEnsemblesDialogProps = { - loadAndSetupEnsembles: ( - selectedRegularEnsembles: RegularEnsembleItem[], - createdDeltaEnsembles: DeltaEnsembleItem[], - ) => Promise; - onClose: () => void; - selectedRegularEnsembles: RegularEnsembleItem[]; - createdDeltaEnsembles: DeltaEnsembleItem[]; - colorSet: ColorSet; + workbench: Workbench; }; export const SelectEnsemblesDialog: React.FC = (props) => { - const [isLoadingEnsembles, setIsLoadingEnsembles] = React.useState(false); + const [prevEnsembleSet, setPrevEnsembleSet] = React.useState(null); + const [hash, setHash] = React.useState(""); + const [isOpen, setIsOpen] = useGuiState(props.workbench.getGuiMessageBroker(), GuiState.EnsembleDialogOpen); const [confirmCancel, setConfirmCancel] = React.useState(false); - const [newlySelectedRegularEnsembles, setNewlySelectedRegularEnsembles] = React.useState([]); - const [deltaEnsembles, setDeltaEnsembles] = React.useState([]); - - React.useLayoutEffect(() => { - setNewlySelectedRegularEnsembles(props.selectedRegularEnsembles); - }, [props.selectedRegularEnsembles]); - - React.useLayoutEffect(() => { - setDeltaEnsembles( - props.createdDeltaEnsembles.map((elm) => ({ - comparisonEnsemble: elm.comparisonEnsemble, - referenceEnsemble: elm.referenceEnsemble, - uuid: v4(), - color: elm.color, - customName: elm.customName, - })), - ); - }, [props.createdDeltaEnsembles]); + const [selectedRegularEnsembles, setSelectedRegularEnsembles] = React.useState( + [], + ); + const [selectedDeltaEnsembles, setSelectedDeltaEnsembles] = React.useState([]); + + const workbenchSession = props.workbench.getWorkbenchSession(); + + const ensembleSet = usePublishSubscribeTopicValue(workbenchSession, PrivateWorkbenchSessionTopic.ENSEMBLE_SET); + const isEnsembleSetLoading = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.IS_ENSEMBLE_SET_LOADING, + ); + + const colorSet = useColorSet(props.workbench.getWorkbenchSession().getWorkbenchSettings()); + const currentHash = makeHashFromSelectedEnsembles(selectedRegularEnsembles, selectedDeltaEnsembles); + + if (!isEqual(prevEnsembleSet, ensembleSet)) { + setPrevEnsembleSet(ensembleSet); + + const regularEnsembles = makeRegularEnsembleSettingsFromEnsembleSet(ensembleSet); + const deltaEnsembles = makeDeltaEnsembleSettingsFromEnsembleSet(ensembleSet); + + setSelectedRegularEnsembles(regularEnsembles); + setSelectedDeltaEnsembles(deltaEnsembles); + setHash(makeHashFromSelectedEnsembles(regularEnsembles, deltaEnsembles)); + } const nextEnsembleColor = React.useMemo(() => { - const usedColors = [...newlySelectedRegularEnsembles, ...deltaEnsembles].map((ens) => ens.color); + const usedColors = [...selectedRegularEnsembles, ...selectedDeltaEnsembles].map((ens) => ens.color); - for (let i = 0; i < props.colorSet.getColorArray().length; i++) { - const candidateColor = props.colorSet.getColor(i); + for (let i = 0; i < colorSet.getColorArray().length; i++) { + const candidateColor = colorSet.getColor(i); if (!usedColors.includes(candidateColor)) { return candidateColor; @@ -62,16 +75,16 @@ export const SelectEnsemblesDialog: React.FC = (prop } // Default to an existing color (looping) - return props.colorSet.getColor(usedColors.length % props.colorSet.getColorArray().length); - }, [deltaEnsembles, newlySelectedRegularEnsembles, props.colorSet]); + return colorSet.getColor(usedColors.length % colorSet.getColorArray().length); + }, [selectedDeltaEnsembles, selectedRegularEnsembles, colorSet]); function handleClose() { setConfirmCancel(false); - props.onClose(); + setIsOpen(false); } function handleCancel() { - if (isEqual(props.selectedRegularEnsembles, newlySelectedRegularEnsembles)) { + if (currentHash === hash) { handleClose(); return; } @@ -79,13 +92,14 @@ export const SelectEnsemblesDialog: React.FC = (prop } function handleApplyEnsembleSelection() { - if (deltaEnsembles.some((elm) => !elm.comparisonEnsemble || !elm.referenceEnsemble)) { + // Highlight invalid delta ensembles? + if (selectedDeltaEnsembles.some((elm) => !elm.comparisonEnsembleIdent || !elm.referenceEnsembleIdent)) { return; } - const validDeltaEnsembles: DeltaEnsembleItem[] = []; - for (const deltaEnsemble of deltaEnsembles) { - if (!deltaEnsemble.comparisonEnsemble || !deltaEnsemble.referenceEnsemble) { + const validDeltaEnsembles: UserDeltaEnsembleSetting[] = []; + for (const deltaEnsemble of selectedDeltaEnsembles) { + if (!deltaEnsemble.comparisonEnsembleIdent || !deltaEnsemble.referenceEnsembleIdent) { continue; } @@ -93,60 +107,37 @@ export const SelectEnsemblesDialog: React.FC = (prop if ( validDeltaEnsembles.some( (elm) => - isEqual(elm.comparisonEnsemble, deltaEnsemble.comparisonEnsemble) && - isEqual(elm.referenceEnsemble, deltaEnsemble.referenceEnsemble), + isEqual(elm.comparisonEnsembleIdent, deltaEnsemble.comparisonEnsembleIdent) && + isEqual(elm.referenceEnsembleIdent, deltaEnsemble.referenceEnsembleIdent), ) ) { continue; } validDeltaEnsembles.push({ - comparisonEnsemble: deltaEnsemble.comparisonEnsemble, - referenceEnsemble: deltaEnsemble.referenceEnsemble, + comparisonEnsembleIdent: deltaEnsemble.comparisonEnsembleIdent, + referenceEnsembleIdent: deltaEnsemble.referenceEnsembleIdent, color: deltaEnsemble.color, customName: deltaEnsemble.customName, }); } - setIsLoadingEnsembles(true); - props - .loadAndSetupEnsembles(newlySelectedRegularEnsembles, validDeltaEnsembles) - .then(() => { - handleClose(); - }) - .finally(() => { - setIsLoadingEnsembles(false); - }); - } - - function hasAnyDeltaEnsemblesChanged(): boolean { - if (props.createdDeltaEnsembles.length !== deltaEnsembles.length) { - return true; - } - - const isContentEqual = props.createdDeltaEnsembles.every((elm, idx) => { - const internalDeltaEnsemble = deltaEnsembles[idx]; - return ( - elm.color === internalDeltaEnsemble.color && - elm.customName === internalDeltaEnsemble.customName && - isEqual(elm.comparisonEnsemble, internalDeltaEnsemble.comparisonEnsemble) && - isEqual(elm.referenceEnsemble, internalDeltaEnsemble.referenceEnsemble) - ); + workbenchSession.loadAndSetupEnsembleSet(selectedRegularEnsembles, validDeltaEnsembles).then(() => { + setIsOpen(false); }); - return !isContentEqual; } function areAnyDeltaEnsemblesInvalid(): boolean { - return deltaEnsembles.some((elm) => !elm.comparisonEnsemble || !elm.referenceEnsemble); + return selectedDeltaEnsembles.some((el) => !el.comparisonEnsembleIdent || !el.referenceEnsembleIdent); } function hasDuplicateDeltaEnsembles(): boolean { const uniqueDeltaEnsembles = new Set(); - for (const elm of deltaEnsembles) { - if (!elm.comparisonEnsemble || !elm.referenceEnsemble) { + for (const el of selectedDeltaEnsembles) { + if (!el.comparisonEnsembleIdent || !el.referenceEnsembleIdent) { continue; } - const key = `${elm.comparisonEnsemble.caseUuid}~&&~${elm.comparisonEnsemble.ensembleName}~&&~${elm.referenceEnsemble.caseUuid}~&&~${elm.referenceEnsemble.ensembleName}`; + const key = makeHashFromDeltaEnsemble(el); if (uniqueDeltaEnsembles.has(key)) { return true; } @@ -155,36 +146,34 @@ export const SelectEnsemblesDialog: React.FC = (prop return false; } - function hasAnyRegularEnsembleChanged(): boolean { - return !isEqual(props.selectedRegularEnsembles, newlySelectedRegularEnsembles); - } - - function handleAddRegularEnsemble(newItem: RegularEnsembleItem) { - if (newlySelectedRegularEnsembles.some((i) => isSameEnsembleItem(i, newItem))) return; + function handleAddRegularEnsemble(newItem: InternalRegularEnsembleSetting) { + if (selectedRegularEnsembles.some((el) => el.ensembleIdent.equals(newItem.ensembleIdent))) { + return; + } - setNewlySelectedRegularEnsembles((prev) => [...prev, newItem]); + setSelectedRegularEnsembles((prev) => [...prev, newItem]); } - function handleUpdateRegularEnsemble(updatedItem: RegularEnsembleItem) { - setNewlySelectedRegularEnsembles((prev) => { - return prev.map((item) => (isSameEnsembleItem(item, updatedItem) ? updatedItem : item)); + function handleUpdateRegularEnsemble(updatedItem: InternalRegularEnsembleSetting) { + setSelectedRegularEnsembles((prev) => { + return prev.map((el) => (el.ensembleIdent.equals(updatedItem) ? updatedItem : el)); }); } - function handleRemoveRegularEnsemble(removedItem: RegularEnsembleItem) { - setNewlySelectedRegularEnsembles((prev) => prev.filter((i) => !isSameEnsembleItem(i, removedItem))); + function handleRemoveRegularEnsemble(removedItem: UserEnsembleSetting) { + setSelectedRegularEnsembles((prev) => prev.filter((el) => !el.ensembleIdent.equals(removedItem.ensembleIdent))); removeEnsembleFromDeltaEnsembles(removedItem); } - function removeEnsembleFromDeltaEnsembles(removedEnsemble: RegularEnsembleItem) { - setDeltaEnsembles((prev) => { + function removeEnsembleFromDeltaEnsembles(removedEnsemble: UserEnsembleSetting) { + setSelectedDeltaEnsembles((prev) => { return prev.map((deltaEnsemble) => { - const { comparisonEnsemble, referenceEnsemble } = deltaEnsemble; - if (comparisonEnsemble && isSameEnsembleItem(comparisonEnsemble, removedEnsemble)) { + const { comparisonEnsembleIdent, referenceEnsembleIdent } = deltaEnsemble; + if (comparisonEnsembleIdent && comparisonEnsembleIdent.equals(removedEnsemble.ensembleIdent)) { return { ...deltaEnsemble, comparisonEnsemble: null }; } - if (referenceEnsemble && isSameEnsembleItem(referenceEnsemble, removedEnsemble)) { + if (referenceEnsembleIdent && referenceEnsembleIdent.equals(removedEnsemble.ensembleIdent)) { return { ...deltaEnsemble, referenceEnsemble: null }; } return deltaEnsemble; @@ -192,31 +181,33 @@ export const SelectEnsemblesDialog: React.FC = (prop }); } - function handleAddDeltaEnsemble(newItem: InternalDeltaEnsembleItem) { - setDeltaEnsembles((prev) => [...prev, newItem]); + function handleAddDeltaEnsemble(newItem: InternalDeltaEnsembleSetting) { + setSelectedDeltaEnsembles((prev) => [...prev, newItem]); } - function handleUpdateDeltaEnsemble(updatedItem: InternalDeltaEnsembleItem) { - setDeltaEnsembles((prev) => { + function handleUpdateDeltaEnsemble(updatedItem: InternalDeltaEnsembleSetting) { + setSelectedDeltaEnsembles((prev) => { return prev.map((ens) => (ens.uuid === updatedItem.uuid ? updatedItem : ens)); }); } - function handleRemoveDeltaEnsemble(removedItem: InternalDeltaEnsembleItem) { - setDeltaEnsembles((prev) => prev.filter((i) => i.uuid !== removedItem.uuid)); + function handleRemoveDeltaEnsemble(removedItem: InternalDeltaEnsembleSetting) { + setSelectedDeltaEnsembles((prev) => prev.filter((i) => i.uuid !== removedItem.uuid)); } function makeApplyButtonStartIcon() { - if (isLoadingEnsembles) { + if (isEnsembleSetLoading) { return ; } return ; } + const hasAnyChanges = hash !== currentHash; + return ( <> = (prop height={"75"} actions={
-
@@ -255,14 +240,14 @@ export const SelectEnsemblesDialog: React.FC = (prop
= (prop />
- {isLoadingEnsembles && } + {isEnsembleSetLoading && }
+
+
+
+ Start + + + + + + + + + +
+
+ Resources + + + Webviz on GitHub + + +
+
+
+ Recent +
+ Sessions + +
+
+ Snapshots + +
+
+
+
+ ); +} diff --git a/frontend/src/framework/internal/components/StartPage/index.ts b/frontend/src/framework/internal/components/StartPage/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/framework/internal/components/StartPage/private-components/recentSessions.tsx b/frontend/src/framework/internal/components/StartPage/private-components/recentSessions.tsx new file mode 100644 index 000000000..07c36f9b7 --- /dev/null +++ b/frontend/src/framework/internal/components/StartPage/private-components/recentSessions.tsx @@ -0,0 +1,87 @@ +import React from "react"; + +import { useQuery } from "@tanstack/react-query"; + +import { + getSessionsMetadataOptions, + PrimaryServicesDatabaseAccessSessionAccessTypesSortBy_api, + SortDirection_api, +} from "@api"; +import type { Workbench } from "@framework/Workbench"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { timeAgo } from "@lib/utils/dates"; + +export type RecentSessionsProps = { + workbench: Workbench; +}; + +export function RecentSessions(props: RecentSessionsProps) { + const [state, setState] = React.useState["status"]>("pending"); + + function handleSessionClick(e: React.MouseEvent, sessionId: string) { + e.preventDefault(); + props.workbench.openSession(sessionId); + } + + const sessionsQuery = useQuery({ + ...getSessionsMetadataOptions({ + query: { + sort_by: PrimaryServicesDatabaseAccessSessionAccessTypesSortBy_api.UPDATED_AT, + sort_direction: SortDirection_api.DESC, + limit: 5, + }, + }), + refetchInterval: 10000, + }); + + if (!sessionsQuery.isFetching) { + if (sessionsQuery.isError) { + if (state !== "error") { + setState("error"); + } + } else if (sessionsQuery.isSuccess) { + if (state !== "success") { + setState("success"); + } + } + } + + function makeContent() { + if (state === "pending") { + return ( + + Loading recent sessions... + + ); + } + + if (state === "error") { + return Could not fetch recent sessions...; + } + + if (state === "success" && sessionsQuery.data && sessionsQuery.data.length > 0) { + return ( + + ); + } + + return
No recent sessions found.
; + } + + return
{makeContent()}
; +} diff --git a/frontend/src/framework/internal/components/StartPage/private-components/recentSnapshots.tsx b/frontend/src/framework/internal/components/StartPage/private-components/recentSnapshots.tsx new file mode 100644 index 000000000..0b29f6b47 --- /dev/null +++ b/frontend/src/framework/internal/components/StartPage/private-components/recentSnapshots.tsx @@ -0,0 +1,71 @@ +import type React from "react"; + +import { useQuery } from "@tanstack/react-query"; + +import { getRecentSnapshotsOptions, getRecentSnapshotsQueryKey } from "@api"; +import { GuiState } from "@framework/GuiMessageBroker"; +import type { Workbench } from "@framework/Workbench"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { timeAgo } from "@lib/utils/dates"; + +export type RecentSnapshotsProps = { + workbench: Workbench; +}; + +export function RecentSnapshots(props: RecentSnapshotsProps): React.ReactNode { + const recentSnapshotsQuery = useQuery({ + ...getRecentSnapshotsOptions(), + refetchInterval: 10000, + }); + + async function handleSnapshotClick(e: React.MouseEvent) { + e.preventDefault(); + + // Load the selected snapshot + // TODO: Make this to a workbench method + props.workbench.getGuiMessageBroker().setState(GuiState.IsLoadingSession, true); + + history.pushState(null, "", e.currentTarget.href); + await props.workbench.handleNavigation(); + + props.workbench.getGuiMessageBroker().setState(GuiState.IsLoadingSession, false); + // Reset query so that fresh snapshots are fetched when we return to the start page + props.workbench.getQueryClient().resetQueries({ queryKey: getRecentSnapshotsQueryKey() }); + } + + if (recentSnapshotsQuery.isPending) { + return ( + + Loading recent snapshots... + + ); + } + + if (recentSnapshotsQuery.isError) { + return Could not fetch recent snapshots...; + } + + if (!recentSnapshotsQuery.data.length) { + return No recently visited snapshots.; + } + + return ( + + ); +} diff --git a/frontend/src/framework/internal/components/TemplatesDialog/index.ts b/frontend/src/framework/internal/components/TemplatesDialog/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/framework/internal/components/TemplatesDialog/templatesDialog.tsx b/frontend/src/framework/internal/components/TemplatesDialog/templatesDialog.tsx new file mode 100644 index 000000000..e77bfd5a9 --- /dev/null +++ b/frontend/src/framework/internal/components/TemplatesDialog/templatesDialog.tsx @@ -0,0 +1,250 @@ +import React from "react"; + +import { GuiState, useGuiState } from "@framework/GuiMessageBroker"; +import { ModuleDataTags, type ModuleDataTagId } from "@framework/ModuleDataTags"; +import { ModuleRegistry } from "@framework/ModuleRegistry"; +import { TemplateRegistry, type Template } from "@framework/TemplateRegistry"; +import type { Workbench } from "@framework/Workbench"; +import { Dialog } from "@lib/components/Dialog"; +import { Input } from "@lib/components/Input"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; +import { Button } from "@lib/components/Button"; + +export type TemplatesDialogProps = { + workbench: Workbench; +}; + +export function TemplatesDialog(props: TemplatesDialogProps): React.ReactNode { + const [isOpen, setIsOpen] = useGuiState(props.workbench.getGuiMessageBroker(), GuiState.TemplatesDialogOpen); + + const [searchQuery, setSearchQuery] = React.useState(""); + const [template, setTemplate] = React.useState