diff --git a/backend_py/primary/poetry.lock b/backend_py/primary/poetry.lock index 08f4dfa53..1f2395a65 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 = "d33fe14399685017201e1a181796597f34d61eea5af3ffb9e19f8308172a48f8" +content-hash = "982ccf2afc51348f6573db6552b7f7ddc33b6cccbdea0242f2bc57a0e226c906" 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/general.py b/backend_py/primary/primary/routers/general.py index a43159165..281cc6daa 100644 --- a/backend_py/primary/primary/routers/general.py +++ b/backend_py/primary/primary/routers/general.py @@ -15,6 +15,7 @@ class UserInfo(BaseModel): + user_id: str username: str display_name: str | None = None avatar_b64str: str | None = None @@ -63,6 +64,7 @@ async def get_logged_in_user( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No user is logged in") user_info = UserInfo( + user_id=authenticated_user.get_user_id(), username=authenticated_user.get_username(), avatar_b64str=None, display_name=None, diff --git a/backend_py/primary/primary/routers/graph/router.py b/backend_py/primary/primary/routers/graph/router.py index f4e35bd2c..f84745ca6 100644 --- a/backend_py/primary/primary/routers/graph/router.py +++ b/backend_py/primary/primary/routers/graph/router.py @@ -1,36 +1,71 @@ import logging import httpx -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Path from primary.auth.auth_helper import AuthHelper from primary.services.utils.authenticated_user import AuthenticatedUser from primary.services.graph_access.graph_access import GraphApiAccess +from primary.services.service_exceptions import Service, AuthorizationError, ServiceRequestError -from .schemas import GraphUserPhoto + +from . import schemas LOGGER = logging.getLogger(__name__) router = APIRouter() +@router.get("/user_info/{user_id_or_email}") +async def get_user_info( + authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), + user_id_or_email: str = Path(description="User email, id or 'me' for the authenticated user"), +) -> schemas.GraphUser | None: + if not authenticated_user.has_graph_access_token(): + raise AuthorizationError("User can't access Graph API", Service.GENERAL) + + graph_api_access = GraphApiAccess(authenticated_user.get_graph_access_token()) + + try: + user_info = await graph_api_access.get_user_info(user_id_or_email) + + if not user_info: + return None + + return schemas.GraphUser( + id=user_info["id"], + display_name=user_info["displayName"], + principal_name=user_info["userPrincipalName"], + email=user_info["mail"], + ) + + except httpx.HTTPError as exc: + raise ServiceRequestError( + "Error while fetching user from Microsoft Graph API (HTTP error)", Service.GENERAL + ) from exc + except httpx.InvalidURL as exc: + raise ServiceRequestError( + "Error while fetching user from Microsoft Graph API (HTTP error)", Service.GENERAL + ) from exc + + @router.get("/user_photo/") async def get_user_photo( # fmt:off authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), - user_email: str = Query(description="User email or 'me' for the authenticated user"), + user_id_or_email: str = Query(description="User email or 'me' for the authenticated user"), # fmt:on -) -> GraphUserPhoto: +) -> schemas.GraphUserPhoto: """Get username, display name and avatar from Microsoft Graph API for a given user email""" - user_photo = GraphUserPhoto( + user_photo = schemas.GraphUserPhoto( avatar_b64str=None, ) if authenticated_user.has_graph_access_token(): graph_api_access = GraphApiAccess(authenticated_user.get_graph_access_token()) try: - avatar_b64str = await graph_api_access.get_user_profile_photo(user_email) + avatar_b64str = await graph_api_access.get_user_profile_photo(user_id_or_email) user_photo.avatar_b64str = avatar_b64str except httpx.HTTPError as exc: diff --git a/backend_py/primary/primary/routers/graph/schemas.py b/backend_py/primary/primary/routers/graph/schemas.py index 01946540a..65c1fa1da 100644 --- a/backend_py/primary/primary/routers/graph/schemas.py +++ b/backend_py/primary/primary/routers/graph/schemas.py @@ -3,3 +3,10 @@ class GraphUserPhoto(BaseModel): avatar_b64str: str | None = None + + +class GraphUser(BaseModel): + id: str + principal_name: str + display_name: str + email: str diff --git a/backend_py/primary/primary/routers/persistence/sessions/__init__.py b/backend_py/primary/primary/routers/persistence/sessions/__init__.py new file mode 100644 index 000000000..e69de29bb 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..00b9a5efc --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/sessions/converters.py @@ -0,0 +1,44 @@ +from primary.services.database_access.session_access.model import SessionDocument +from primary.services.database_access.session_access.types import SessionMetadata +from . import schemas + + +def to_api_session_metadata_summary(session: SessionDocument) -> schemas.SessionMetadataWithId: + return schemas.SessionMetadataWithId( + id=session.id, + title=session.metadata.title, + description=session.metadata.description, + createdAt=session.metadata.created_at.isoformat(), + updatedAt=session.metadata.updated_at.isoformat(), + version=session.metadata.version, + hash=session.metadata.hash, + ) + + +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, + ) + + +def to_api_session_index_page( + sessions: list[SessionDocument], continuation_token: str | None +) -> schemas.SessionIndexPage: + return schemas.SessionIndexPage( + continuation_token=continuation_token, + items=[to_api_session_metadata_summary(session) for session in sessions], + ) 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..e2089229d --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/sessions/router.py @@ -0,0 +1,107 @@ +import logging +from typing import 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.services.database_access.query_collation_options import SortDirection +from primary.auth.auth_helper import AuthHelper, AuthenticatedUser +from primary.services.database_access.session_access.types import NewSession, SessionUpdate, SessionSortBy + +from . import schemas +from .converters import ( + to_api_session_metadata, + to_api_session_record, + to_api_session_index_page, +) + + +LOGGER = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/sessions", response_model=schemas.SessionIndexPage) +@no_cache +async def get_sessions_metadata( + user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), + # ! Must be named "cursor" or "page" to make hey-api generate infinite-queries + # ! When we've updated to the latest hey-api version, we can change this to something custom + cursor: None | str = Query(None), + sort_by: Optional[SessionSortBy] = Query(None, description="Sort the result by"), + sort_direction: Optional[SortDirection] = Query(SortDirection.ASC, description="Sort direction: 'asc' or 'desc'"), + limit: int = Query(10, ge=1, le=100, description="Limit the number of results"), + # ? Is this becoming too many args? Should we make a post-search endpoint instead? + filter_title: Optional[str] = Query(None, description="Filter results by title (case insensitive)"), + filter_updated_from: Optional[str] = Query(None, description="Filter results by date"), + filter_updated_to: Optional[str] = Query(None, description="Filter results by date"), +) -> schemas.SessionIndexPage: + access = SessionAccess.create(user.get_user_id()) + + async with access: + (items, cont_token) = await access.get_user_sessions_by_page_async( + continuation_token=cursor, + page_size=limit, + sort_by=sort_by, + sort_direction=sort_direction, + filter_title=filter_title, + filter_updated_from=filter_updated_from, + filter_updated_to=filter_updated_to, + ) + + return to_api_session_index_page(items, cont_token) + + +@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) +) -> schemas.SessionDocument: + 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) +) -> schemas.SessionMetadata: + 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) +) -> str: + 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}", description="Updates a session object. Allows for partial update objects") +async def update_session( + session_id: str, + session_update: SessionUpdate, + user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), +) -> schemas.SessionDocument: + access = SessionAccess.create(user.get_user_id()) + async with access: + updated_session = await access.update_session_async(session_id, session_update) + return to_api_session_record(updated_session) + + +@router.delete("/sessions/{session_id}") +async def delete_session(session_id: str, user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user)) -> None: + 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..abe554aaf --- /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 SessionMetadata(BaseModel): + title: str + description: Optional[str] + createdAt: str + updatedAt: str + version: int + hash: str + + +class SessionMetadataWithId(SessionMetadata): + id: str + + +class SessionDocument(BaseModel): + id: str + ownerId: str + metadata: SessionMetadata + content: str + + +class SessionIndexPage(BaseModel): + items: list[SessionMetadataWithId] + continuation_token: str | None 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/__init__.py b/backend_py/primary/primary/routers/persistence/snapshots/__init__.py new file mode 100644 index 000000000..e69de29bb 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..984a947bd --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/snapshots/converters.py @@ -0,0 +1,56 @@ +from primary.services.database_access.snapshot_access.models import SnapshotAccessLogDocument, SnapshotDocument +from primary.services.database_access.snapshot_access.types import 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: SnapshotDocument) -> 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: SnapshotAccessLogDocument) -> 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, + snapshotDeleted=access_log.snapshot_deleted, + snapshotMetadata=to_api_snapshot_metadata(access_log.snapshot_metadata), + ) + + +def to_api_access_log_index_page( + access_logs: list[SnapshotAccessLogDocument], continuation_token: str | None +) -> schemas.SnapshotAccessLogIndexPage: + return schemas.SnapshotAccessLogIndexPage( + continuation_token=continuation_token, + items=[to_api_snapshot_access_log(log) for log in access_logs], + ) 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..d5c98f97d --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/snapshots/router.py @@ -0,0 +1,133 @@ +import logging +from typing import List, Optional + +from fastapi import APIRouter, BackgroundTasks, Depends, Query + +from primary.services.database_access.snapshot_access.types import ( + NewSnapshot, + SnapshotSortBy, + SnapshotAccessLogSortBy, +) +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_log_access import SnapshotLogAccess +from primary.services.database_access.query_collation_options import SortDirection +from primary.services.database_access.workers.mark_logs_deleted import mark_logs_deleted_worker + + +from primary.auth.auth_helper import AuthHelper, AuthenticatedUser + + +from . import schemas +from .converters import ( + to_api_snapshot, + to_api_snapshot_metadata, + to_api_snapshot_metadata_summary, + to_api_access_log_index_page, +) + + +LOGGER = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/visited_snapshots", response_model=schemas.SnapshotAccessLogIndexPage) +async def get_visited_snapshots( + user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), + # ! Must be named "cursor" or "page" to make hey-api generate infinite-queries + # ! When we've updated to the latest hey-api version, we can change this to something custom + cursor: Optional[str] = Query(None, description="Continuation token for pagination"), + limit: Optional[int] = Query(10, ge=1, le=100, description="Limit the number of results"), + sort_by: Optional[SnapshotAccessLogSortBy] = Query(None, description="Sort the result by"), + sort_direction: Optional[SortDirection] = Query(None, description="Sort direction: 'asc' or 'desc'"), + # ? Is this becoming too many args? Should we make a post-search endpoint instead? + filter_title: Optional[str] = Query(None, description="Filter results by title (case insensitive)"), + filter_updated_from: Optional[str] = Query(None, description="Filter results by date"), + filter_updated_to: Optional[str] = Query(None, description="Filter results by date"), +) -> schemas.SnapshotAccessLogIndexPage: + async with SnapshotLogAccess.create(user.get_user_id()) as log_access: + (items, cont_token) = await log_access.get_user_access_log_by_page_async( + continuation_token=cursor, + page_size=limit, + sort_by=sort_by, + sort_direction=sort_direction, + filter_title=filter_title, + filter_updated_from=filter_updated_from, + filter_updated_to=filter_updated_to, + ) + + return to_api_access_log_index_page(items, cont_token) + + +@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[SnapshotSortBy] = Query(SnapshotSortBy.UPDATED_AT, 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, offset=0 + ) + 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: + snapshot_access = SnapshotAccess.create(user.get_user_id()) + log_access = SnapshotLogAccess.create(user_id=user.get_user_id()) + + async with snapshot_access, log_access: + snapshot = await snapshot_access.get_snapshot_by_id_async(snapshot_id) + # Should we clear the log if a snapshot was not found? This could mean that the snapshot was + # deleted but deletion of logs has failed + await log_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) +) -> schemas.SnapshotMetadata: + access = SnapshotAccess.create(user.get_user_id()) + async with access: + metadata = await access.get_snapshot_metadata_async(snapshot_id) + 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: + snapshot_access = SnapshotAccess.create(user.get_user_id()) + log_access = SnapshotLogAccess.create(user.get_user_id()) + + async with snapshot_access, log_access: + snapshot_id = await snapshot_access.insert_snapshot_async(session) + + # We count snapshot creation as implicit visit. This also makes it so we can get recently created ones alongside other shared screenshots + await log_access.log_snapshot_visit_async(snapshot_id=snapshot_id, snapshot_owner_id=user.get_user_id()) + return snapshot_id + + +@router.delete("/snapshots/{snapshot_id}") +async def delete_snapshot( + snapshot_id: str, + background_tasks: BackgroundTasks, + user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), +) -> None: + snapshot_access = SnapshotAccess.create(user.get_user_id()) + async with snapshot_access: + await snapshot_access.delete_snapshot_async(snapshot_id) + + # This is the fastest solution for the moment. As we are expecting <= 150 logs per snapshot + # and consistency is not critical, we can afford to do this in the background and without + # a safety net. We can later consider adding this to a queue for better reliability. + background_tasks.add_task(mark_logs_deleted_worker, snapshot_id=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..bcf60335c --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/snapshots/schemas.py @@ -0,0 +1,46 @@ +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 + snapshotDeleted: bool + + 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) + + +class SnapshotAccessLogIndexPage(BaseModel): + items: list[SnapshotAccessLog] + continuation_token: str | None 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..f5f944738 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/_utils.py @@ -0,0 +1,37 @@ +import hashlib +from typing import Any, cast, TypeVar + +from azure.core.async_paging import AsyncPageIterator, AsyncItemPaged + +T = TypeVar("T") + + +# 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 + + +def cast_query_params(params: list[dict[str, Any]]) -> list[dict[str, object]]: + return cast(list[dict[str, object]], params) + + +def query_by_page(query_iterable: AsyncItemPaged[T], page_token: str | None) -> AsyncPageIterator[T]: + """ + Cosmosdb's `by_page` returns a more narrow subtype than anticipated. This makes + extra's like `.continuation_token` not show up in returned value's type. + + This util function correctly casts the return value to the expected type + """ + pager = query_iterable.by_page(page_token) + + if not isinstance(pager, AsyncPageIterator): + raise TypeError("Expected AsyncPageIterator from query_items_by_page_token_async") + + return cast(AsyncPageIterator[T], pager) 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..6681ba86c --- /dev/null +++ b/backend_py/primary/primary/services/database_access/container_access.py @@ -0,0 +1,242 @@ +import logging +from typing import Any, Dict, Generic, List, Optional, Sequence, Type, TypeVar, NoReturn +from azure.cosmos.aio import ContainerProxy +from azure.cosmos import exceptions +from pydantic import BaseModel, ValidationError + +from primary.services.database_access.database_access_exceptions import ( + DatabaseAccessNotFoundError, + DatabaseAccessConflictError, + DatabaseAccessPreconditionFailedError, + DatabaseAccessPermissionError, + DatabaseAccessThrottledError, + DatabaseAccessTransportError, +) + +from ._utils import query_by_page +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) -> "ContainerAccess": + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object | None + ) -> None: + await self.close_async() + + def _raise_exception(self, operation: str, exc: exceptions.CosmosHttpResponseError) -> NoReturn: + """Map Cosmos error to a data-access exception with rich context and re-raise.""" + headers = getattr(exc, "headers", {}) or {} + status = getattr(exc, "status_code", None) + # Cosmos uses x-ms-substatus for more detail (e.g., 1002) + substatus_raw = headers.get("x-ms-substatus") + try: + substatus = int(substatus_raw) if substatus_raw is not None else None + except ValueError: + substatus = None + activity_id = headers.get("x-ms-activity-id") + + msg = ( + f"[{operation}] Cosmos error on {self._database_name}/{self._container_name}: " + f"{getattr(exc, 'message', None) or str(exc)} " + f"(status={status}, substatus={substatus}, activity_id={activity_id})" + ) + + # Log with stack trace + logger.exception( + "[ContainerAccess] %s", + msg, + extra={ + "database": self._database_name, + "container": self._container_name, + "operation": operation, + "status_code": status, + "sub_status": substatus, + "activity_id": activity_id, + }, + ) + + if status == 404: + raise DatabaseAccessNotFoundError( + msg, status_code=status, sub_status=substatus, activity_id=activity_id + ) from exc + if status == 409: + raise DatabaseAccessConflictError( + msg, status_code=status, sub_status=substatus, activity_id=activity_id + ) from exc + if status == 412: + raise DatabaseAccessPreconditionFailedError( + msg, status_code=status, sub_status=substatus, activity_id=activity_id + ) from exc + if status in (401, 403): + raise DatabaseAccessPermissionError( + msg, status_code=status, sub_status=substatus, activity_id=activity_id + ) from exc + if status in (429, 503): + # Typically retryable + raise DatabaseAccessThrottledError( + msg, status_code=status, sub_status=substatus, activity_id=activity_id + ) from exc + + # Fallback + raise DatabaseAccessTransportError( + msg, status_code=status, sub_status=substatus, activity_id=activity_id + ) from exc + + 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("query_items_async", error) + + async def query_items_by_page_token_async( + self, + query: str, + page_token: str | None, + parameters: Optional[List[Dict[str, object]]] = None, + page_size: Optional[int] = None, + ) -> tuple[list[T], str | None]: + query_iterable = self._container.query_items(query=query, parameters=parameters, max_item_count=page_size) + + pager = query_by_page(query_iterable, page_token) + page = await anext(pager) + + token = pager.continuation_token + + items = [self._validation_model.model_validate(item) async for item in page] + + return (items, token) + + 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("get_item_async", error) + + async def insert_item_async(self, item: T) -> str: + try: + validated_item = self._validation_model.model_validate(item) + dumped_item = validated_item.model_dump(by_alias=True, mode="json") + result = await self._container.upsert_item(dumped_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("insert_item_async", error) + + async def delete_item_async(self, item_id: str, partition_key: str) -> None: + 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("delete_item_async", error) + + async def update_item_async(self, item_id: str, updated_item: T) -> None: + 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("update_item_async", error) + + async def patch_item_async( + self, + item_id: str, + partition_key: str, + patch_operations: Sequence[Dict[str, object]], + *, + filter_predicate: str | None = None, + ) -> None: + try: + await self._container.patch_item( + item=item_id, + partition_key=partition_key, + patch_operations=list(patch_operations), + filter_predicate=filter_predicate, + no_response=True, + ) + logger.debug("[ContainerAccess] Patched item '%s' in '%s'", item_id, self._container_name) + except exceptions.CosmosHttpResponseError as error: + self._raise_exception("patch_item_async", error) + + async def query_projection_async( + self, + query: str, + parameters: Optional[List[Dict[str, object]]] = None, + ) -> List[Dict[str, Any]]: + """ + Run a query that returns raw dicts (no Pydantic validation), useful for + projections like SELECT c.id, c.partitionKey. + """ + try: + items_iterable = self._container.query_items( + query=query, + parameters=parameters or [], + ) + return [item async for item in items_iterable] + except exceptions.CosmosHttpResponseError as error: + self._raise_exception("query_items_async", error) + + async def close_async(self) -> None: + """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() + + # These should never be accessed anymore. We'll ignore the + # typing, and unset them to crash on further access attempts + self._database_access = None # type: ignore[assignment] + self._container = None # type: ignore[assignment] 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/database_access_exceptions.py b/backend_py/primary/primary/services/database_access/database_access_exceptions.py new file mode 100644 index 000000000..d5a291b4c --- /dev/null +++ b/backend_py/primary/primary/services/database_access/database_access_exceptions.py @@ -0,0 +1,37 @@ +class DatabaseAccessError(RuntimeError): + def __init__( + self, + message: str, + *, + status_code: int | None = None, + sub_status: int | None = None, + activity_id: str | None = None + ): + super().__init__(message) + self.status_code = status_code + self.sub_status = sub_status + self.activity_id = activity_id + + +class DatabaseAccessNotFoundError(DatabaseAccessError): + """Resource not found (404).""" + + +class DatabaseAccessConflictError(DatabaseAccessError): + """Conflict (409).""" + + +class DatabaseAccessPreconditionFailedError(DatabaseAccessError): + """Precondition failed / ETag mismatch (412).""" + + +class DatabaseAccessPermissionError(DatabaseAccessError): + """Auth/permission denied (401/403).""" + + +class DatabaseAccessThrottledError(DatabaseAccessError): + """Throttled / transient (429/503).""" + + +class DatabaseAccessTransportError(DatabaseAccessError): + """Other transport / HTTP errors.""" diff --git a/backend_py/primary/primary/services/database_access/error_converter.py b/backend_py/primary/primary/services/database_access/error_converter.py new file mode 100644 index 000000000..1b5397d9c --- /dev/null +++ b/backend_py/primary/primary/services/database_access/error_converter.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from typing import Dict, NoReturn, Optional, Type + +from primary.services.service_exceptions import Service, ServiceRequestError + +from primary.services.database_access.database_access_exceptions import ( + DatabaseAccessError, + DatabaseAccessNotFoundError, + DatabaseAccessConflictError, + DatabaseAccessPreconditionFailedError, + DatabaseAccessPermissionError, + DatabaseAccessThrottledError, + DatabaseAccessTransportError, +) + +_DEFAULT_MESSAGES: Dict[Type[DatabaseAccessError], str] = { + DatabaseAccessNotFoundError: "Resource not found.", + DatabaseAccessConflictError: "Conflict while writing resource.", + DatabaseAccessPreconditionFailedError: "Precondition failed (ETag mismatch).", + DatabaseAccessPermissionError: "Permission denied for database operation.", + DatabaseAccessThrottledError: "Database is throttling requests; please retry later.", + DatabaseAccessTransportError: "Database transport error.", + DatabaseAccessError: "Database error.", +} + + +def convert_data_access_error_to_service_error( + err: DatabaseAccessError, + *, + context: Optional[str] = None, + messages: Optional[Dict[Type[DatabaseAccessError], str]] = None, +) -> ServiceRequestError: + """ + Convert a DatabaseAccess* error to a ServiceRequestError (without raising). + You can customize messages per exception type via the 'messages' dict. + """ + msgs = {**_DEFAULT_MESSAGES, **(messages or {})} + + # Find the most specific message for the concrete type + msg = None + for err_type, text in msgs.items(): + if isinstance(err, err_type): + msg = text + break + if msg is None: + msg = msgs[DatabaseAccessError] + + # Append context and technical details (status/substatus/activity_id) if available + details = [] + if getattr(err, "status_code", None) is not None: + details.append(f"status={err.status_code}") + if getattr(err, "sub_status", None) is not None: + details.append(f"substatus={err.sub_status}") + if getattr(err, "activity_id", None): + details.append(f"activity_id={err.activity_id}") + + prefix = f"{context}: " if context else "" + suffix = f" ({', '.join(details)})" if details else "" + message = f"{prefix}{msg}{suffix}" + + # Chain the original exception for traceback preservation + return ServiceRequestError(message, Service.DATABASE) + + +def raise_service_error_from_database_access( + err: DatabaseAccessError, + *, + context: Optional[str] = None, + messages: Optional[Dict[Type[DatabaseAccessError], str]] = None, +) -> NoReturn: + """ + Convert and raise immediately, chaining the original error. + """ + service_err = convert_data_access_error_to_service_error(err, context=context, messages=messages) + raise service_err from err diff --git a/backend_py/primary/primary/services/database_access/query_collation_options.py b/backend_py/primary/primary/services/database_access/query_collation_options.py new file mode 100644 index 000000000..763ff4886 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/query_collation_options.py @@ -0,0 +1,81 @@ +from enum import Enum +from dataclasses import dataclass +from typing import Literal + + +class SortDirection(str, Enum): + ASC = "asc" + DESC = "desc" + + +LOWER_CASE_PREFIX = "__lower" +FilterOperator = Literal["CONTAINS", "EQUAL", "LESS", "MORE"] + + +@dataclass +class Filter: + field: str + value: str | float + operator: FilterOperator = "EQUAL" + prefix: str = "" + + @property + def prop_name(self) -> str: + return self.field.replace(".", "") + self.prefix + + +@dataclass +class QueryCollationOptions: + """Helper class for defining NoSQL collation options""" + + sort_by: str | None = None + sort_dir: SortDirection | None = None # "asc" or "desc" + sort_lowercase: bool | None = None + limit: int | None = None + offset: int | None = 0 + + filters: list[Filter] | None = None + + def is_any_collation(self) -> bool: + return self.sort_by is not None and self.limit is not None + + def make_query_params(self) -> list[dict[str, object]]: + if not self.filters: + return [] + + return [{"name": f"@{q_filter.prop_name}", "value": q_filter.value} for q_filter in self.filters] + + def to_sql_query_string(self, variable_name: str = "c") -> str | None: + tokens = [] + + if self.filters: + for idx, query_filter in enumerate(self.filters): + tokens.append("WHERE" if (idx == 0) else "AND") + + if query_filter.operator == "EQUAL": + tokens.append(f"{variable_name}.{query_filter.field} = @{query_filter.prop_name}") + elif query_filter.operator == "LESS": + tokens.append(f"{variable_name}.{query_filter.field} <= @{query_filter.prop_name}") + elif query_filter.operator == "MORE": + tokens.append(f"{variable_name}.{query_filter.field} >= @{query_filter.prop_name}") + elif query_filter.operator == "CONTAINS": + tokens.append(f"CONTAINS({variable_name}.{query_filter.field}, @{query_filter.prop_name})") + else: + raise NotImplementedError(f"{query_filter.operator} has not been implemented yet") + + if self.sort_by: + sort_by_field = self.sort_by + (LOWER_CASE_PREFIX if self.sort_lowercase else "") + + tokens.append(f"ORDER BY {variable_name}.{sort_by_field}") + + 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 or 0} LIMIT {self.limit}") + + if tokens: + return " ".join(tokens) + + return 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..ed6d86311 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/session_access/model.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, ConfigDict +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..5b56cb882 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/session_access/session_access.py @@ -0,0 +1,225 @@ +from typing import Any, List +from datetime import datetime, timezone +from nanoid import generate + +from primary.services.database_access.session_access.model import SessionDocument +from primary.services.database_access._utils import hash_json_string, cast_query_params +from primary.services.service_exceptions import Service, ServiceRequestError +from primary.services.database_access.container_access import ContainerAccess +from primary.services.database_access.query_collation_options import QueryCollationOptions, SortDirection, Filter +from primary.services.database_access.session_access.types import ( + NewSession, + SessionMetadataWithId, + SessionMetadata, + SessionUpdate, + SessionSortBy, +) +from primary.services.database_access.database_access_exceptions import ( + DatabaseAccessError, +) +from primary.services.database_access.error_converter import raise_service_error_from_database_access + +# Util dict to handle case insensitive collation +LOWERCASED_FIELDS = [SessionSortBy.TITLE] + + +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) -> "SessionAccess": + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object | None + ) -> None: + await self.session_container_access.close_async() + + @classmethod + def create(cls, user_id: str) -> "SessionAccess": + 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: + try: + document = await self.session_container_access.get_item_async( + item_id=session_id, partition_key=self.user_id + ) + return document + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def get_all_sessions_metadata_for_user_async(self) -> List[SessionMetadataWithId]: + try: + query = "SELECT * FROM c WHERE c.owner_id = @owner_id" + params = cast_query_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] + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def get_user_sessions_by_page_async( + self, + continuation_token: str | None = None, + page_size: int | None = None, + sort_by: SessionSortBy | None = None, + sort_direction: SortDirection | None = None, + filter_title: str | None = None, + filter_updated_from: str | None = None, + filter_updated_to: str | None = None, + ) -> tuple[list[SessionDocument], str | None]: + sort_by_field = sort_by.value if sort_by else None + sort_by_lowercase = sort_by in LOWERCASED_FIELDS + + filters: list[Filter] = [Filter("owner_id", self.user_id)] + + if filter_title: + filters.append(Filter("metadata.title__lower", filter_title.lower(), "CONTAINS")) + if filter_updated_from: + filters.append(Filter("metadata.updated_at", filter_updated_from, "MORE", "_from")) + if filter_updated_to: + filters.append(Filter("metadata.updated_at", filter_updated_to, "LESS", "_to")) + + collation_options = QueryCollationOptions( + sort_lowercase=sort_by_lowercase, + sort_dir=sort_direction, + sort_by=sort_by_field, + filters=filters, + ) + + query = "SELECT * from c" + params = collation_options.make_query_params() + search_options = collation_options.to_sql_query_string() + + if search_options: + query = f"{query} {search_options}" + + return await self.session_container_access.query_items_by_page_token_async( + query=query, parameters=params, page_size=page_size, page_token=continuation_token + ) + + async def get_filtered_sessions_metadata_for_user_async( + self, + sort_by: SessionSortBy | None, + sort_direction: SortDirection | None, + limit: int | None, + offset: int | None, + filter_title: str | None, + filter_updated_from: str | None, + filter_updated_to: str | None, + ) -> List[SessionMetadataWithId]: + try: + sort_by_field = sort_by.value if sort_by else None + sort_by_lowercase = sort_by in LOWERCASED_FIELDS + + filters: list[Filter] = [Filter("owner_id", self.user_id)] + + if filter_title: + filters.append(Filter("metadata.title__lower", filter_title.lower(), "CONTAINS")) + if filter_updated_from: + filters.append(Filter("metadata.updated_at", filter_updated_from, "MORE", "_from")) + if filter_updated_to: + filters.append(Filter("metadata.updated_at", filter_updated_to, "LESS", "_to")) + + collation_options = QueryCollationOptions( + sort_lowercase=sort_by_lowercase, + sort_dir=sort_direction, + sort_by=sort_by_field, + offset=offset, + limit=limit, + filters=filters, + ) + + query = "SELECT * from c" + params = collation_options.make_query_params() + search_options = collation_options.to_sql_query_string() + + if search_options: + query = f"{query} {search_options}" + + items = await self.session_container_access.query_items_async(query=query, parameters=params) + + return [self._to_metadata_summary(item) for item in items] + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def get_session_metadata_async(self, session_id: str) -> SessionMetadata: + try: + document = await self._assert_ownership_async(session_id) + return document.metadata + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def insert_session_async(self, new_session: NewSession) -> str: + try: + 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) + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def delete_session_async(self, session_id: str) -> None: + 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) -> SessionDocument: + try: + existing = await self._assert_ownership_async(session_id) + + # Get all explicitly defined changes + document_update_dict = session_update.model_dump(exclude_unset=True, exclude=set(["id"])) + metadata_update_dict: dict[str, Any] = document_update_dict.get("metadata", {}) + + # Early return if there are no changes + if not document_update_dict and not metadata_update_dict: + return existing + + # Inject computed fields + metadata_update_dict.update({"updated_at": datetime.now(timezone.utc)}) + metadata_update_dict.update({"version": existing.metadata.version + 1}) + + if session_update.content: + metadata_update_dict.update({"hash": hash_json_string(session_update.content)}) + + updated_metadata = existing.metadata.model_copy(update=metadata_update_dict) + document_update_dict.update({"metadata": updated_metadata}) + + updated_session = existing.model_copy(update=document_update_dict) + + await self.session_container_access.update_item_async(session_id, updated_session) + + return updated_session + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + 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 DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + 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..ee864382c --- /dev/null +++ b/backend_py/primary/primary/services/database_access/session_access/types.py @@ -0,0 +1,70 @@ +from datetime import datetime +from enum import Enum +from typing import Optional + + +from pydantic import BaseModel, computed_field +from pydantic.json_schema import SkipJsonSchema + + +class SessionUserEditableMetadata(BaseModel): + title: str + description: Optional[str] = None + + # Computed lowercase fields for case-insensitive collation + @computed_field # type: ignore[prop-decorator] + @property + def title__lower(self) -> str: + return self.title.lower() + + @computed_field # type: ignore[prop-decorator] + @property + def description__lower(self) -> str | None: + if self.description is None: + return None + + return self.description.lower() + + +class SessionMetadataInternal(BaseModel): + created_at: datetime + updated_at: datetime + hash: str + version: int + + +class SessionMetadata(SessionUserEditableMetadata, SessionMetadataInternal): + pass + + +class SessionMetadataWithId(SessionMetadata): + id: str + + +# SkipJsonSchema is so the field is optional, but not nullable +class SessionMetadataUpdate(BaseModel): + title: str | SkipJsonSchema[None] = None + description: str | None = None + + +class SessionUpdate(BaseModel): + id: str + metadata: SessionMetadataUpdate | SkipJsonSchema[None] = None + content: str | SkipJsonSchema[None] = None + + +class NewSession(BaseModel): + title: str + description: Optional[str] + content: str + + +class SessionSortBy(str, Enum): + CREATED_AT = "metadata.created_at" + UPDATED_AT = "metadata.updated_at" + TITLE = "metadata.title" + + +class SessionSortDirection(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..6bd18fed3 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/setup_local_database.py @@ -0,0 +1,107 @@ +""" +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", "partition_key": "/id"}, + {"id": "snapshot_access_logs", "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..8832a001e --- /dev/null +++ b/backend_py/primary/primary/services/database_access/snapshot_access/models.py @@ -0,0 +1,40 @@ +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 SnapshotDocument(BaseModel): + id: str # Partition key + owner_id: str + metadata: SnapshotMetadata + content: str + + model_config = ConfigDict(extra="ignore") + + +class SnapshotAccessLogDocument(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 + snapshot_deleted: bool = False + snapshot_deleted_at: datetime | None = None + + snapshot_metadata: SnapshotMetadata + + # 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/snapshot_access.py b/backend_py/primary/primary/services/database_access/snapshot_access/snapshot_access.py new file mode 100644 index 000000000..d058aa400 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/snapshot_access/snapshot_access.py @@ -0,0 +1,156 @@ +from typing import Optional, List +from datetime import datetime, timezone +from nanoid import generate + +from primary.services.database_access.snapshot_access.models import SnapshotDocument +from primary.services.database_access._utils import hash_json_string, cast_query_params +from primary.services.service_exceptions import Service, ServiceRequestError +from primary.services.database_access.query_collation_options import QueryCollationOptions, SortDirection +from primary.services.database_access.container_access import ContainerAccess +from primary.services.database_access.database_access_exceptions import DatabaseAccessError, DatabaseAccessNotFoundError +from primary.services.database_access.error_converter import raise_service_error_from_database_access + +from .types import ( + NewSnapshot, + SnapshotMetadata, + SnapshotMetadataWithId, + SnapshotSortBy, +) + + +# Util dict to handle case insensitive collation +CASING_FIELD_LOOKUP: dict[SnapshotSortBy | None, SnapshotSortBy] = {SnapshotSortBy.TITLE_LOWER: SnapshotSortBy.TITLE} + + +class SnapshotAccess: + CONTAINER_NAME = "snapshots" + DATABASE_NAME = "persistence" + + def __init__( + self, + user_id: str, + container_access: ContainerAccess[SnapshotDocument], + ): + self.user_id = user_id + self.container_access = 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.container_access.close_async() + + @classmethod + def create(cls, user_id: str) -> "SnapshotAccess": + container_access = ContainerAccess.create(cls.DATABASE_NAME, cls.CONTAINER_NAME, SnapshotDocument) + return cls(user_id, container_access) + + async def get_snapshot_by_id_async(self, snapshot_id: str) -> SnapshotDocument: + try: + document = await self.container_access.get_item_async(item_id=snapshot_id, partition_key=snapshot_id) + + return document + except DatabaseAccessNotFoundError as err: + raise ServiceRequestError( + f"Snapshot with id '{snapshot_id}' not found. It might have been deleted.", Service.DATABASE + ) from err + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def get_all_snapshots_metadata_for_user_async(self) -> List[SnapshotMetadataWithId]: + try: + query = "SELECT * FROM c WHERE c.owner_id = @owner_id" + params = cast_query_params([{"name": "@owner_id", "value": self.user_id}]) + items = await self.container_access.query_items_async(query=query, parameters=params) + return [self._to_metadata_summary(item) for item in items] + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def get_filtered_snapshots_metadata_for_user_async( + self, + sort_by: SnapshotSortBy | None, + sort_direction: SortDirection | None, + limit: int | None, + offset: int | None, + ) -> List[SnapshotMetadataWithId]: + try: + # pylint: disable=consider-iterating-dictionary + sort_by_lowercase = sort_by in CASING_FIELD_LOOKUP.keys() + sort_by = CASING_FIELD_LOOKUP.get(sort_by, sort_by) + + collation_options = QueryCollationOptions( + sort_lowercase=sort_by_lowercase, + sort_dir=sort_direction, + sort_by=sort_by, + offset=offset, + limit=limit, + ) + + query = "SELECT * FROM c WHERE c.owner_id = @owner_id" + params = cast_query_params([{"name": "@owner_id", "value": self.user_id}]) + search_options = collation_options.to_sql_query_string("c.metadata") + + if search_options: + query = f"{query} {search_options}" + + items = await self.container_access.query_items_async(query=query, parameters=params) + + return [self._to_metadata_summary(item) for item in items] + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def get_snapshot_metadata_async(self, snapshot_id: str, _owner_id: Optional[str] = None) -> SnapshotMetadata: + try: + document = await self.container_access.get_item_async(snapshot_id, partition_key=snapshot_id) + return document.metadata + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def insert_snapshot_async(self, new_snapshot: NewSnapshot) -> str: + try: + now = datetime.now(timezone.utc) + snapshot_id = generate(size=8) + + snapshot = SnapshotDocument( + id=snapshot_id, + owner_id=self.user_id, + 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), + ), + content=new_snapshot.content, + ) + + return await self.container_access.insert_item_async(snapshot) + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def delete_snapshot_async(self, snapshot_id: str) -> None: + await self._assert_ownership_async(snapshot_id) + await self.container_access.delete_item_async(snapshot_id, partition_key=snapshot_id) + + async def _assert_ownership_async(self, snapshot_id: str) -> SnapshotDocument: + """Assert that the user owns the snapshot with the given ID.""" + try: + document = await self.container_access.get_item_async(item_id=snapshot_id, partition_key=snapshot_id) + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + # 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 document.owner_id != self.user_id: + raise ServiceRequestError( + f"You do not have permission to access snapshot '{snapshot_id}'.", Service.DATABASE + ) + + return document + + @staticmethod + def _to_metadata_summary(doc: SnapshotDocument) -> SnapshotMetadataWithId: + return SnapshotMetadataWithId(**doc.metadata.model_dump(), id=doc.id) diff --git a/backend_py/primary/primary/services/database_access/snapshot_access/snapshot_log_access.py b/backend_py/primary/primary/services/database_access/snapshot_access/snapshot_log_access.py new file mode 100644 index 000000000..a256d8a6a --- /dev/null +++ b/backend_py/primary/primary/services/database_access/snapshot_access/snapshot_log_access.py @@ -0,0 +1,149 @@ +import logging +from datetime import datetime, timezone +from typing import Any + + +from primary.services.database_access.container_access import ContainerAccess +from primary.services.database_access.database_access_exceptions import DatabaseAccessError, DatabaseAccessNotFoundError +from primary.services.service_exceptions import Service, ServiceRequestError + +from ..query_collation_options import QueryCollationOptions, SortDirection, Filter +from .types import SnapshotAccessLogSortBy +from .models import SnapshotAccessLogDocument +from .util import make_access_log_item_id + +from .snapshot_access import SnapshotAccess + + +LOGGER = logging.getLogger(__name__) + +LOWERCASED_FIELDS = [SnapshotAccessLogSortBy.TITLE] + + +class SnapshotLogAccess: + DATABASE_NAME = "persistence" + CONTAINER_NAME = "snapshot_access_logs" + + def __init__(self, user_id: str, container_access: ContainerAccess[SnapshotAccessLogDocument]): + self._user_id = user_id + self._container_access = container_access + + @classmethod + def create(cls, user_id: str) -> "SnapshotLogAccess": + container_access = ContainerAccess.create(cls.DATABASE_NAME, cls.CONTAINER_NAME, SnapshotAccessLogDocument) + + return cls(user_id, container_access) + + async def __aenter__(self) -> "SnapshotLogAccess": + 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 update_log_async(self, snapshot_id: str, changes: dict[str, Any]) -> None: + try: + existing = await self.get_access_log_async(snapshot_id) + + updated_item = existing.model_copy(update=changes) + + await self._container_access.update_item_async(snapshot_id, updated_item) + except DatabaseAccessError as err: + raise ServiceRequestError(f"Failed to update access log: {str(err)}", Service.DATABASE) from err + + async def get_user_access_log_by_page_async( + self, + continuation_token: str | None, + page_size: int | None, + sort_by: SnapshotAccessLogSortBy | None, + sort_direction: SortDirection | None, + filter_title: str | None, + filter_updated_from: str | None, + filter_updated_to: str | None, + ) -> tuple[list[SnapshotAccessLogDocument], str | None]: + sort_by_field = sort_by.value if sort_by else None + sort_by_lowercase = sort_by in LOWERCASED_FIELDS + + filters: list[Filter] = [Filter("visitor_id", self._user_id)] + + if filter_title: + filters.append(Filter("snapshot_metadata.title__lower", filter_title.lower(), "CONTAINS")) + if filter_updated_from: + filters.append(Filter("snapshot_metadata.updated_at", filter_updated_from, "MORE", "_from")) + if filter_updated_to: + filters.append(Filter("snapshot_metadata.updated_at", filter_updated_to, "LESS", "_to")) + + collation_options = QueryCollationOptions( + sort_lowercase=sort_by_lowercase, + sort_dir=sort_direction, + sort_by=sort_by_field, + filters=filters, + ) + + query = "SELECT * from c" + params = collation_options.make_query_params() + search_options = collation_options.to_sql_query_string() + + if search_options: + query = f"{query} {search_options}" + + return await self._container_access.query_items_by_page_token_async( + query=query, parameters=params, page_size=page_size, page_token=continuation_token + ) + + async def create_access_log_async(self, snapshot_id: str, snapshot_owner_id: str) -> SnapshotAccessLogDocument: + try: + snapshots = SnapshotAccess.create(self._user_id) + + snapshot_meta = await snapshots.get_snapshot_metadata_async(snapshot_id, snapshot_owner_id) + + new_log = SnapshotAccessLogDocument( + visitor_id=self._user_id, + snapshot_id=snapshot_id, + snapshot_owner_id=snapshot_owner_id, + snapshot_metadata=snapshot_meta, + ) + + _inserted_id = await self._container_access.insert_item_async(new_log) + + return new_log + except DatabaseAccessError as err: + raise ServiceRequestError(f"Failed to create access log: {str(err)}", Service.DATABASE) from err + + async def get_access_log_async(self, snapshot_id: str) -> SnapshotAccessLogDocument: + 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 + ) -> SnapshotAccessLogDocument: + """ + 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 DatabaseAccessNotFoundError: + return await self.create_access_log_async(snapshot_id=snapshot_id, snapshot_owner_id=snapshot_owner_id) + except DatabaseAccessError as err: + raise ServiceRequestError(f"Failed to get or create access log: {str(err)}", Service.DATABASE) from err + + async def log_snapshot_visit_async(self, snapshot_id: str, snapshot_owner_id: str) -> SnapshotAccessLogDocument: + timestamp = datetime.now(timezone.utc) + try: + 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 + except DatabaseAccessError as err: + raise ServiceRequestError(f"Failed to log snapshot visit: {str(err)}", Service.DATABASE) from err 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..4131c5215 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/snapshot_access/types.py @@ -0,0 +1,65 @@ +from typing import Optional +from datetime import datetime +from enum import Enum +from pydantic import BaseModel, computed_field + + +class SnapshotUserEditableMetadata(BaseModel): + title: str + description: Optional[str] = None + + # Computed lowercase fields for case-insensitive collation + @computed_field # type: ignore[prop-decorator] + @property + def title__lower(self) -> str: + return self.title.lower() + + @computed_field # type: ignore[prop-decorator] + @property + def description__lower(self) -> str | None: + if self.description is None: + return None + + return self.description.lower() + + +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 NewSnapshot(BaseModel): + title: str + description: Optional[str] + content: str + + +class SnapshotSortBy(str, Enum): + CREATED_AT = "created_at" + UPDATED_AT = "updated_at" + TITLE = "title" + TITLE_LOWER = "title_lower" + + +class SnapshotAccessLogSortBy(str, Enum): + VISITS = "visits" + LAST_VISIT = "last_visited_at" + TITLE = "snapshot_metadata.title" + CREATED_AT = "snapshot_metadata.created_at" 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/database_access/workers/mark_logs_deleted.py b/backend_py/primary/primary/services/database_access/workers/mark_logs_deleted.py new file mode 100644 index 000000000..6f4af6680 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/workers/mark_logs_deleted.py @@ -0,0 +1,79 @@ +import asyncio +from datetime import datetime, timezone +import logging +from typing import Any, Dict, List + +from primary.services.database_access.container_access import ContainerAccess +from primary.services.database_access.snapshot_access.models import SnapshotAccessLogDocument +from primary.services.service_exceptions import ServiceRequestError + +LOGGER = logging.getLogger(__name__) + +DATABASE_NAME = "persistence" +CONTAINER_NAME = "snapshot_access_logs" + + +async def mark_logs_deleted_worker(snapshot_id: str) -> None: + """ + Marks all access-log docs for the given snapshot_id as deleted (PATCH /snapshot_deleted = true). + Runs with bounded concurrency and is idempotent/safe to re-run. + """ + container_access: ContainerAccess[SnapshotAccessLogDocument] = ContainerAccess.create( + DATABASE_NAME, CONTAINER_NAME, SnapshotAccessLogDocument + ) + + try: + query = ( + "SELECT c.id, c.visitor_id " + "FROM c " + "WHERE c.snapshot_id = @sid " + "AND (NOT IS_DEFINED(c.snapshot_deleted) OR c.snapshot_deleted != true)" + ) + params = [{"name": "@sid", "value": snapshot_id}] + + rows: List[Dict[str, Any]] = await container_access.query_projection_async(query, params) + + if not rows: + LOGGER.info("No snapshot_access_logs to update for snapshot '%s'.", snapshot_id) + return + + deleted_at = datetime.now(timezone.utc).isoformat() + + operations = [ + {"op": "set", "path": "/snapshot_deleted", "value": True}, + {"op": "set", "path": "/snapshot_deleted_at", "value": deleted_at}, + ] + + # Limit concurrency to avoid RU spikes/throttling + sem = asyncio.Semaphore(32) + + async def _patch_one(rec: Dict[str, Any]) -> bool: + async with sem: + try: + await container_access.patch_item_async( + item_id=rec["id"], + partition_key=rec["visitor_id"], # /visitor_id is the PK + patch_operations=operations, + ) + return True + except asyncio.CancelledError: + # always re-raise cancellation so shutdowns are graceful + raise + except ServiceRequestError as e: + LOGGER.warning("PATCH failed for log id=%s pk=%s: %s", rec.get("id"), rec.get("visitor_id"), e) + # Do not re-raise - we want to continue with other items + return False + + results = await asyncio.gather(*(_patch_one(r) for r in rows)) + success = sum(1 for ok in results if ok) + fail = len(rows) - success + LOGGER.info( + "Marked %d/%d access-log docs deleted for snapshot '%s' (failures=%d).", + success, + len(rows), + snapshot_id, + fail, + ) + + finally: + await container_access.close_async() diff --git a/backend_py/primary/primary/services/graph_access/graph_access.py b/backend_py/primary/primary/services/graph_access/graph_access.py index e8937c251..4d9017baf 100644 --- a/backend_py/primary/primary/services/graph_access/graph_access.py +++ b/backend_py/primary/primary/services/graph_access/graph_access.py @@ -23,10 +23,10 @@ async def _request(self, url: str) -> httpx.Response: return response - async def get_user_profile_photo(self, user_email: str) -> str | None: + async def get_user_profile_photo(self, user_id_email: str) -> str | None: request_url = urljoin( self.base_url, - "me/photo/$value" if user_email == "me" else f"users/{user_email}/photo/$value", + "me/photo/$value" if user_id_email == "me" else f"users/{user_id_email}/photo/$value", ) response = await self._request(request_url) 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 1c5dcf0c6..80f7d17d9 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 b0af0ee69..c44487f36 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,13 +14,14 @@ "@hey-api/client-axios": "^0.4.0", "@mui/base": "^5.0.0-beta.3", "@mui/icons-material": "^5.14.9", - "@tanstack/query-core": "^5.62.16", - "@tanstack/react-query": "^5.63", - "@tanstack/react-query-devtools": "^5.63", + "@tanstack/query-core": "^5.83.0", + "@tanstack/react-query": "^5.83.0", + "@tanstack/react-query-devtools": "^5.83.0", "@webviz/group-tree-plot": "^1.5.3", "@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", "uuid": "^9.0.0", "vite-plugin-node-polyfills": "^0.24.0", @@ -4647,29 +4649,29 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.64.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.64.2.tgz", - "integrity": "sha512-hdO8SZpWXoADNTWXV9We8CwTkXU88OVWRBcsiFrk7xJQnhm6WRlweDzMD+uH+GnuieTBVSML6xFa17C2cNV8+g==", + "version": "5.85.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.5.tgz", + "integrity": "sha512-KO0WTob4JEApv69iYp1eGvfMSUkgw//IpMnq+//cORBzXf0smyRwPLrUvEe5qtAEGjwZTXrjxg+oJNP/C00t6w==", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tanstack/query-devtools": { - "version": "5.64.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.64.2.tgz", - "integrity": "sha512-3DautR5UpVZdk/qNIhioZVF7g8fdQZ1U98sBEEk4Tzz3tihSBNMPgwlP40nzgbPEDBIrn/j/oyyvNBVSo083Vw==", + "version": "5.84.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.84.0.tgz", + "integrity": "sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ==", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tanstack/react-query": { - "version": "5.64.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.64.2.tgz", - "integrity": "sha512-3pakNscZNm8KJkxmovvtZ4RaXLyiYYobwleTMvpIGUoKRa8j8VlrQKNl5W8VUEfVfZKkikvXVddLuWMbcSCA1Q==", + "version": "5.85.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.5.tgz", + "integrity": "sha512-/X4EFNcnPiSs8wM2v+b6DqS5mmGeuJQvxBglmDxl6ZQb5V26ouD2SJYAcC3VjbNwqhY2zjxVD15rDA5nGbMn3A==", "dependencies": { - "@tanstack/query-core": "5.64.2" + "@tanstack/query-core": "5.85.5" }, "funding": { "type": "github", @@ -4680,18 +4682,18 @@ } }, "node_modules/@tanstack/react-query-devtools": { - "version": "5.64.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.64.2.tgz", - "integrity": "sha512-+ZjJVnPzc8BUV/Eklu2k9T/IAyAyvwoCHqOaOrk2sbU33LFhM52BpX4eyENXn0bx5LwV3DJZgEQlIzucoemfGQ==", + "version": "5.85.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.85.5.tgz", + "integrity": "sha512-6Ol6Q+LxrCZlQR4NoI5181r+ptTwnlPG2t7H9Sp3klxTBhYGunONqcgBn2YKRPsaKiYM8pItpKMdMXMEINntMQ==", "dependencies": { - "@tanstack/query-devtools": "5.64.2" + "@tanstack/query-devtools": "5.84.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "^5.64.2", + "@tanstack/react-query": "^5.85.5", "react": "^18 || ^19" } }, @@ -6381,7 +6383,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", @@ -7288,7 +7291,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" @@ -12768,23 +12773,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", @@ -13920,6 +13908,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", @@ -14267,6 +14272,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", @@ -15484,6 +15502,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 334425196..c9fee3394 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,13 +29,14 @@ "@hey-api/client-axios": "^0.4.0", "@mui/base": "^5.0.0-beta.3", "@mui/icons-material": "^5.14.9", - "@tanstack/query-core": "^5.62.16", - "@tanstack/react-query": "^5.63", - "@tanstack/react-query-devtools": "^5.63", + "@tanstack/query-core": "^5.83.0", + "@tanstack/react-query": "^5.83.0", + "@tanstack/react-query-devtools": "^5.83.0", "@webviz/group-tree-plot": "^1.5.3", "@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", @@ -48,6 +49,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", "uuid": "^9.0.0", "vite-plugin-node-polyfills": "^0.24.0", 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..a744b8d67 --- /dev/null +++ b/frontend/src/Log.ts @@ -0,0 +1,198 @@ +/* eslint-disable no-console */ +import { getDebugSetting, setDebugSetting } from "@framework/internal/utils/debug"; +import { formatHex } from "culori"; +import { isArray } from "lodash"; + +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.`; + }; + + 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..f0aea36cb --- /dev/null +++ b/frontend/src/WorkbenchWrapper.tsx @@ -0,0 +1,79 @@ +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 { EditSessionDialog } from "@framework/internal/components/EditSessionDialog"; +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"; + +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..2e706107e 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 { @@ -63,12 +69,26 @@ import { postGetSeismicFence, getPolygonsDirectory, getPolygonsData, + getUserInfo, getUserPhoto, getObservations, getTableDefinition, getRealizationData, getVfpTableNames, getVfpTable, + getSessionsMetadata, + createSession, + deleteSession, + getSession, + updateSession, + getSessionMetadata, + getVisitedSnapshots, + getSnapshotsMetadata, + createSnapshot, + deleteSnapshot, + getSnapshot, + getSnapshotMetadata, + snapshotPreview, loginRoute, authorizedCallbackRoute, getAlive, @@ -151,12 +171,38 @@ import type { PostGetSeismicFenceResponse_api, GetPolygonsDirectoryData_api, GetPolygonsDataData_api, + GetUserInfoData_api, GetUserPhotoData_api, GetObservationsData_api, GetTableDefinitionData_api, GetRealizationDataData_api, GetVfpTableNamesData_api, GetVfpTableData_api, + GetSessionsMetadataData_api, + GetSessionsMetadataError_api, + GetSessionsMetadataResponse_api, + CreateSessionData_api, + CreateSessionError_api, + CreateSessionResponse_api, + DeleteSessionData_api, + DeleteSessionError_api, + GetSessionData_api, + UpdateSessionData_api, + UpdateSessionError_api, + UpdateSessionResponse_api, + GetSessionMetadataData_api, + GetVisitedSnapshotsData_api, + GetVisitedSnapshotsError_api, + GetVisitedSnapshotsResponse_api, + GetSnapshotsMetadataData_api, + CreateSnapshotData_api, + CreateSnapshotError_api, + CreateSnapshotResponse_api, + DeleteSnapshotData_api, + DeleteSnapshotError_api, + GetSnapshotData_api, + GetSnapshotMetadataData_api, + SnapshotPreviewData_api, LoginRouteData_api, AuthorizedCallbackRouteData_api, GetAliveData_api, @@ -1439,6 +1485,23 @@ export const getPolygonsDataOptions = (options: Options }); }; +export const getUserInfoQueryKey = (options: Options) => [createQueryKey("getUserInfo", options)]; + +export const getUserInfoOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getUserInfo({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getUserInfoQueryKey(options), + }); +}; + export const getUserPhotoQueryKey = (options: Options) => [createQueryKey("getUserPhoto", options)]; export const getUserPhotoOptions = (options: Options) => { @@ -1549,6 +1612,382 @@ 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), + }); +}; + +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 getSessionsMetadataInfiniteQueryKey = ( + options?: Options, +): QueryKey> => [createQueryKey("getSessionsMetadata", options, true)]; + +export const getSessionsMetadataInfiniteOptions = (options?: Options) => { + return infiniteQueryOptions< + GetSessionsMetadataResponse_api, + AxiosError, + InfiniteData, + QueryKey>, + string | 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: { + cursor: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await getSessionsMetadata({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getSessionsMetadataInfiniteQueryKey(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< + UpdateSessionResponse_api, + AxiosError, + 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 getVisitedSnapshotsQueryKey = (options?: Options) => [ + createQueryKey("getVisitedSnapshots", options), +]; + +export const getVisitedSnapshotsOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getVisitedSnapshots({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getVisitedSnapshotsQueryKey(options), + }); +}; + +export const getVisitedSnapshotsInfiniteQueryKey = ( + options?: Options, +): QueryKey> => [createQueryKey("getVisitedSnapshots", options, true)]; + +export const getVisitedSnapshotsInfiniteOptions = (options?: Options) => { + return infiniteQueryOptions< + GetVisitedSnapshotsResponse_api, + AxiosError, + InfiniteData, + QueryKey>, + string | 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: { + cursor: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await getVisitedSnapshots({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getVisitedSnapshotsInfiniteQueryKey(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 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..f7c86c55e 100644 --- a/frontend/src/api/autogen/sdk.gen.ts +++ b/frontend/src/api/autogen/sdk.gen.ts @@ -176,6 +176,9 @@ import type { GetPolygonsDataData_api, GetPolygonsDataResponse_api, GetPolygonsDataError_api, + GetUserInfoData_api, + GetUserInfoResponse_api, + GetUserInfoError_api, GetUserPhotoData_api, GetUserPhotoResponse_api, GetUserPhotoError_api, @@ -194,6 +197,43 @@ 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, + UpdateSessionResponse_api, + UpdateSessionError_api, + GetSessionMetadataData_api, + GetSessionMetadataResponse_api, + GetSessionMetadataError_api, + GetVisitedSnapshotsData_api, + GetVisitedSnapshotsResponse_api, + GetVisitedSnapshotsError_api, + GetSnapshotsMetadataData_api, + GetSnapshotsMetadataResponse_api, + GetSnapshotsMetadataError_api, + CreateSnapshotData_api, + CreateSnapshotResponse_api, + CreateSnapshotError_api, + DeleteSnapshotData_api, + DeleteSnapshotError_api, + GetSnapshotData_api, + GetSnapshotResponse_api, + GetSnapshotError_api, + GetSnapshotMetadataData_api, + GetSnapshotMetadataResponse_api, + GetSnapshotMetadataError_api, + SnapshotPreviewData_api, + SnapshotPreviewResponse_api, + SnapshotPreviewError_api, LoginRouteData_api, LoginRouteError_api, AuthorizedCallbackRouteData_api, @@ -1146,6 +1186,16 @@ export const getPolygonsData = ( }); }; +/** + * Get User Info + */ +export const getUserInfo = (options: Options) => { + return (options?.client ?? client).get({ + ...options, + url: "/graph/user_info/{user_id_or_email}", + }); +}; + /** * Get User Photo * Get username, display name and avatar from Microsoft Graph API for a given user email @@ -1218,6 +1268,172 @@ 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 + * Updates a session object. Allows for partial update objects + */ +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 Visited Snapshots + */ +export const getVisitedSnapshots = ( + options?: Options, +) => { + return (options?.client ?? client).get({ + ...options, + url: "/snapshots/visited_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}", + }); +}; + +/** + * 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..55ae74b3a 100644 --- a/frontend/src/api/autogen/types.gen.ts +++ b/frontend/src/api/autogen/types.gen.ts @@ -238,6 +238,13 @@ export enum Gfr_api { MMW = "MMW", } +export type GraphUser_api = { + id: string; + principal_name: string; + display_name: string; + email: string; +}; + export type GraphUserPhoto_api = { avatar_b64str?: string | null; }; @@ -391,6 +398,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 +695,103 @@ export enum SensitivityType_api { SCENARIO = "scenario", } +export type SessionDocument_api = { + id: string; + ownerId: string; + metadata: SessionMetadata_api; + content: string; +}; + +export type SessionIndexPage_api = { + items: Array; + continuation_token: string | null; +}; + +export type SessionMetadata_api = { + title: string; + description: string | null; + createdAt: string; + updatedAt: string; + version: number; + hash: string; +}; + +export type SessionMetadataUpdate_api = { + title?: string; + description?: string | null; +}; + +export type SessionMetadataWithId_api = { + title: string; + description: string | null; + createdAt: string; + updatedAt: string; + version: number; + hash: string; + id: string; +}; + +export enum SessionSortBy_api { + METADATA_CREATED_AT = "metadata.created_at", + METADATA_UPDATED_AT = "metadata.updated_at", + METADATA_TITLE = "metadata.title", +} + +export type SessionUpdate_api = { + id: string; + metadata?: SessionMetadataUpdate_api; + content?: string; +}; + +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; + snapshotDeleted: boolean; + snapshotMetadata: SnapshotMetadata_api; +}; + +export type SnapshotAccessLogIndexPage_api = { + items: Array; + continuation_token: string | null; +}; + +export enum SnapshotAccessLogSortBy_api { + VISITS = "visits", + LAST_VISITED_AT = "last_visited_at", + SNAPSHOT_METADATA_TITLE = "snapshot_metadata.title", + SNAPSHOT_METADATA_CREATED_AT = "snapshot_metadata.created_at", +} + +export type SnapshotMetadata_api = { + ownerId: string; + title: string; + description: string | null; + createdAt: string; + updatedAt: string; + hash: string; +}; + +export enum SnapshotSortBy_api { + CREATED_AT = "created_at", + UPDATED_AT = "updated_at", + TITLE = "title", + TITLE_LOWER = "title_lower", +} + +export enum SortDirection_api { + ASC = "asc", + DESC = "desc", +} + export enum StatisticFunction_api { MEAN = "MEAN", MIN = "MIN", @@ -907,6 +1023,7 @@ export enum UnitType_api { } export type UserInfo_api = { + user_id: string; username: string; display_name?: string | null; avatar_b64str?: string | null; @@ -3598,6 +3715,38 @@ export type GetPolygonsDataResponses_api = { export type GetPolygonsDataResponse_api = GetPolygonsDataResponses_api[keyof GetPolygonsDataResponses_api]; +export type GetUserInfoData_api = { + body?: never; + path: { + /** + * User email, id or 'me' for the authenticated user + */ + user_id_or_email: string; + }; + query?: { + t?: number; + }; + url: "/graph/user_info/{user_id_or_email}"; +}; + +export type GetUserInfoErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type GetUserInfoError_api = GetUserInfoErrors_api[keyof GetUserInfoErrors_api]; + +export type GetUserInfoResponses_api = { + /** + * Successful Response + */ + 200: GraphUser_api | null; +}; + +export type GetUserInfoResponse_api = GetUserInfoResponses_api[keyof GetUserInfoResponses_api]; + export type GetUserPhotoData_api = { body?: never; path?: never; @@ -3605,7 +3754,7 @@ export type GetUserPhotoData_api = { /** * User email or 'me' for the authenticated user */ - user_email: string; + user_id_or_email: string; t?: number; }; url: "/graph/user_photo/"; @@ -3828,6 +3977,434 @@ export type GetVfpTableResponses_api = { export type GetVfpTableResponse_api = GetVfpTableResponses_api[keyof GetVfpTableResponses_api]; +export type GetSessionsMetadataData_api = { + body?: never; + path?: never; + query?: { + cursor?: string | null; + /** + * Sort the result by + */ + sort_by?: SessionSortBy_api | null; + /** + * Sort direction: 'asc' or 'desc' + */ + sort_direction?: SortDirection_api | null; + /** + * Limit the number of results + */ + limit?: number; + /** + * Filter results by title (case insensitive) + */ + filter_title?: string | null; + /** + * Filter results by date + */ + filter_updated_from?: string | null; + /** + * Filter results by date + */ + filter_updated_to?: string | 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: SessionIndexPage_api; +}; + +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: SessionDocument_api; +}; + +export type UpdateSessionResponse_api = UpdateSessionResponses_api[keyof UpdateSessionResponses_api]; + +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 GetVisitedSnapshotsData_api = { + body?: never; + path?: never; + query?: { + /** + * Continuation token for pagination + */ + cursor?: string | null; + /** + * Limit the number of results + */ + limit?: number | null; + /** + * Sort the result by + */ + sort_by?: SnapshotAccessLogSortBy_api | null; + /** + * Sort direction: 'asc' or 'desc' + */ + sort_direction?: SortDirection_api | null; + /** + * Filter results by title (case insensitive) + */ + filter_title?: string | null; + /** + * Filter results by date + */ + filter_updated_from?: string | null; + /** + * Filter results by date + */ + filter_updated_to?: string | null; + t?: number; + }; + url: "/snapshots/visited_snapshots"; +}; + +export type GetVisitedSnapshotsErrors_api = { + /** + * Validation Error + */ + 422: HttpValidationError_api; +}; + +export type GetVisitedSnapshotsError_api = GetVisitedSnapshotsErrors_api[keyof GetVisitedSnapshotsErrors_api]; + +export type GetVisitedSnapshotsResponses_api = { + /** + * Successful Response + */ + 200: SnapshotAccessLogIndexPage_api; +}; + +export type GetVisitedSnapshotsResponse_api = GetVisitedSnapshotsResponses_api[keyof GetVisitedSnapshotsResponses_api]; + +export type GetSnapshotsMetadataData_api = { + body?: never; + path?: never; + query?: { + /** + * Sort the result by + */ + sort_by?: SnapshotSortBy_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 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..d25f886e7 100644 --- a/frontend/src/framework/GuiMessageBroker.ts +++ b/frontend/src/framework/GuiMessageBroker.ts @@ -8,7 +8,6 @@ import type { UnsavedChangesAction } from "./types/unsavedChangesAction"; export enum LeftDrawerContent { ModuleSettings = "ModuleSettings", - ModulesList = "ModulesList", TemplatesList = "TemplatesList", SyncSettings = "SyncSettings", ColorPaletteSettings = "ColorPaletteSettings", @@ -17,19 +16,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", + EditSessionDialogOpen = "editSessionDialogOpen", + SessionHasUnsavedChanges = "sessionHasUnsavedChanges", + IsSavingSession = "isSavingSession", + IsMakingSnapshot = "isMakingSnapshot", + IsLoadingSession = "isLoadingSession", + EnsembleDialogOpen = "ensembleDialogOpen", + MultiSessionsRecoveryDialogOpen = "multiSessionsRecoveryDialogOpen", + ActiveSessionRecoveryDialogOpen = "activeSessionRecoveryDialogOpen", + MakeSnapshotDialogOpen = "makeSnapshotDialogOpen", } export enum GuiEvent { @@ -88,25 +97,43 @@ 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.EditSessionDialogOpen]: 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; }; 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); const persistentStates: GuiState[] = [ GuiState.LeftSettingsPanelWidthInPercent, diff --git a/frontend/src/framework/Module.tsx b/frontend/src/framework/Module.tsx index 53ede2286..17b08a3f2 100644 --- a/frontend/src/framework/Module.tsx +++ b/frontend/src/framework/Module.tsx @@ -1,5 +1,6 @@ import type React from "react"; +import type { JTDDataType } from "ajv/dist/core"; import type { Getter, Setter } from "jotai"; import type { ChannelDefinition, ChannelReceiverDefinition } from "./DataChannelTypes"; @@ -10,7 +11,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,12 +50,14 @@ export type ModuleSettingsProps< settingsToView: Record; viewToSettings: Record; }, + TSerializedStateDef extends ModuleStateBaseSchema = NoModuleStateSchema, > = { - settingsContext: SettingsContext; + settingsContext: SettingsContext; workbenchSession: WorkbenchSession; workbenchServices: WorkbenchServices; workbenchSettings: WorkbenchSettings; initialSettings?: InitialSettings; + persistence: ModulePersistence; }; export type ModuleViewProps< @@ -63,12 +65,19 @@ export type ModuleViewProps< settingsToView: Record; viewToSettings: Record; }, + TSerializedStateDef extends ModuleStateBaseSchema = NoModuleStateSchema, > = { - viewContext: ViewContext; + viewContext: ViewContext; workbenchSession: WorkbenchSession; workbenchServices: WorkbenchServices; workbenchSettings: WorkbenchSettings; initialSettings?: InitialSettings; + persistence: ModulePersistence; +}; + +export type ModulePersistence = { + serializedState: JTDDataType; + serializeState: (state: JTDDataType) => void; }; export type InterfaceEffects = (( @@ -77,28 +86,51 @@ export type InterfaceEffects = (( getAtomValue: Getter, ) => void)[]; +export type JTDBaseType = Record; + +export type ModuleStateBaseSchema = { + settings: JTDBaseType; + view: JTDBaseType; +}; + +export type NoModuleStateSchema = { + settings: Record; + view: Record; +}; + +export type SerializedModuleState = { + view: JTDDataType; + settings: JTDDataType; +}; + +export type MakeReadonly = { + readonly [P in keyof T]: T[P]; +}; + export type ModuleSettings< TInterfaceTypes extends ModuleInterfaceTypes = { settingsToView: Record; viewToSettings: Record; }, -> = React.FC>; + TSerializedStateDef extends ModuleStateBaseSchema = NoModuleStateSchema, +> = React.FC>; export type ModuleView< TInterfaceTypes extends ModuleInterfaceTypes = { settingsToView: Record; viewToSettings: Record; }, -> = React.FC>; + TSerializedStateDef extends ModuleStateBaseSchema = NoModuleStateSchema, +> = React.FC>; -export enum ImportState { +export enum ImportStatus { NotImported = "NotImported", Importing = "Importing", Imported = "Imported", Failed = "Failed", } -export interface ModuleOptions { +export interface ModuleOptions { name: string; defaultTitle: string; category: ModuleCategory; @@ -110,15 +142,19 @@ export interface ModuleOptions { channelDefinitions?: ChannelDefinition[]; channelReceiverDefinitions?: ChannelReceiverDefinition[]; onInstanceUnloadFunc?: OnInstanceUnloadFunc; + serializedStateSchema?: TSerializedStateSchema; } -export class Module { +export class Module< + TInterfaceTypes extends ModuleInterfaceTypes, + TSerializedStateSchema extends ModuleStateBaseSchema, +> { 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 +165,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 +174,9 @@ export class Module { private _category: ModuleCategory; private _devState: ModuleDevState; private _dataTagIds: ModuleDataTagId[]; + private _serializedStateSchema: TSerializedStateSchema | null; - constructor(options: ModuleOptions) { + constructor(options: ModuleOptions) { this._name = options.name; this._defaultTitle = options.defaultTitle; this._category = options.category; @@ -154,13 +190,18 @@ export class Module { this._channelDefinitions = options.channelDefinitions ?? null; this._channelReceiverDefinitions = options.channelReceiverDefinitions ?? null; this._dataTagIds = options.dataTagIds ?? []; + this._serializedStateSchema = options.serializedStateSchema ?? null; + } + + getSerializedStateSchema(): TSerializedStateSchema | null { + return this._serializedStateSchema; } getDrawPreviewFunc(): DrawPreviewFunc | null { return this._drawPreviewFunc; } - getImportState(): ImportState { + getImportState(): ImportStatus { return this._importState; } @@ -188,10 +229,6 @@ export class Module { return this._description; } - setWorkbench(workbench: Workbench): void { - this._workbench = workbench; - } - setSettingsToViewInterfaceInitialization( interfaceInitialization: InterfaceInitialization>, ): void { @@ -232,15 +269,10 @@ 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): ModuleInstance { + const instance = new ModuleInstance({ module: this, - workbench: this._workbench, - instanceNumber, + id, channelDefinitions: this._channelDefinitions, channelReceiverDefinitions: this._channelReceiverDefinitions, }); @@ -253,18 +285,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 { + private initializeModuleInstance(instance: ModuleInstance): void { instance.initialize(); if (this._settingsToViewInterfaceInitialization) { instance.makeSettingsToViewInterface(this._settingsToViewInterfaceInitialization); @@ -277,8 +305,8 @@ export class Module { } private maybeImportSelf(): void { - if (this._importState !== ImportState.NotImported) { - if (this._importState === ImportState.Imported) { + if (this._importState !== ImportStatus.NotImported) { + if (this._importState === ImportStatus.Imported) { this._moduleInstances.forEach((instance) => { if (instance.isInitialized()) { return; @@ -289,27 +317,27 @@ 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.setImportState(ImportStatus.Imported); this._moduleInstances.forEach((instance) => { this.initializeModuleInstance(instance); }); }) .catch((e) => { console.error(`Failed to import module ${this._name}`, e); - this.setImportState(ImportState.Failed); + this.setImportState(ImportStatus.Failed); }); } } diff --git a/frontend/src/framework/ModuleContext.ts b/frontend/src/framework/ModuleContext.ts index 1b86e29cf..e0ce8963d 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 { ModuleStateBaseSchema, 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 ModuleStateBaseSchema, +> { + 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 ModuleStateBaseSchema = NoModuleStateSchema, +> = Omit< + ModuleContext, "useViewToSettingsInterfaceValue" | "useSettingsAtom" | "useSetSettingsAtom" | "useSettingsAtomValue" >; -export type SettingsContext = Omit< - ModuleContext, +export type SettingsContext< + TInterfaceType extends InterfaceBaseType, + TSerializedStateDef extends ModuleStateBaseSchema = NoModuleStateSchema, +> = Omit< + ModuleContext, "useSettingsToViewInterfaceValue" | "useViewAtom" | "useViewAtomValue" | "useSetViewAtom" >; diff --git a/frontend/src/framework/ModuleInstance.ts b/frontend/src/framework/ModuleInstance.ts index 96e85f6ab..4935fdce4 100644 --- a/frontend/src/framework/ModuleInstance.ts +++ b/frontend/src/framework/ModuleInstance.ts @@ -1,6 +1,8 @@ import type { ErrorInfo } from "react"; import React from "react"; +import type { JTDDataType } from "ajv/dist/core"; +import { Ajv } from "ajv/dist/jtd"; import type { Atom } from "jotai"; import { atom } from "jotai"; import { atomEffect } from "jotai-effect"; @@ -9,14 +11,23 @@ import type { ChannelDefinition, ChannelReceiverDefinition } from "./DataChannel import type { InitialSettings } from "./InitialSettings"; import { ChannelManager } from "./internal/DataChannels/ChannelManager"; import { ModuleInstanceStatusControllerInternal } from "./internal/ModuleInstanceStatusControllerInternal"; -import type { ImportState, Module, ModuleInterfaceTypes, ModuleSettings, ModuleView } from "./Module"; +import type { + ImportStatus, + Module, + ModuleStateBaseSchema, + ModuleInterfaceTypes, + ModuleSettings, + SerializedModuleState, + ModuleView, +} from "./Module"; import { ModuleContext } from "./ModuleContext"; import type { SyncSettingKey } from "./SyncSettings"; import type { InterfaceInitialization } from "./UniDirectionalModuleComponentsInterface"; import { UniDirectionalModuleComponentsInterface } from "./UniDirectionalModuleComponentsInterface"; -import type { Workbench } from "./Workbench"; -export enum ModuleInstanceState { +const ajv = new Ajv(); + +export enum ModuleInstanceLifeCycleState { INITIALIZING, OK, ERROR, @@ -26,34 +37,59 @@ 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]: ModuleStateBaseSchema; }; -export interface ModuleInstanceOptions { - module: Module; - workbench: Workbench; - instanceNumber: number; +export interface ModuleInstanceOptions< + TInterfaceTypes extends ModuleInterfaceTypes, + TSerializedStateSchema extends ModuleStateBaseSchema, +> { + module: Module; + id: string; channelDefinitions: ChannelDefinition[] | null; channelReceiverDefinitions: ChannelReceiverDefinition[] | null; } -export class ModuleInstance { +export type ModuleInstanceFullState = { + id: string; + name: string; + dataChannelReceiverSubscriptions: { + idString: string; + listensToModuleInstanceId: string; + channelIdString: string; + contentIdStrings: string[]; + }[]; + syncedSettingKeys: SyncSettingKey[]; + serializedState: StringifiedSerializedModuleState | null; +}; + +type StringifiedSerializedModuleState = { + settings?: string; + view?: string; +}; + +export class ModuleInstance< + TInterfaceTypes extends ModuleInterfaceTypes, + TSerializedStateSchema extends ModuleStateBaseSchema, +> { 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,9 +102,10 @@ export class ModuleInstance { > | null = null; private _settingsToViewInterfaceEffectsAtom: Atom | null = null; private _viewToSettingsInterfaceEffectsAtom: Atom | null = null; + private _serializedState: SerializedModuleState | null = null; - constructor(options: ModuleInstanceOptions) { - this._id = `${options.module.getName()}-${options.instanceNumber}`; + constructor(options: ModuleInstanceOptions) { + this._id = options.id; this._title = options.module.getDefaultTitle(); this._module = options.module; @@ -88,6 +125,100 @@ export class ModuleInstance { } } + setSerializedState(serializedState: SerializedModuleState): void { + this._serializedState = serializedState; + } + + getSerializedState(): SerializedModuleState | null { + return this._serializedState; + } + + deserializeSerializedState(raw: StringifiedSerializedModuleState): void { + const schema = this._module.getSerializedStateSchema(); + if (!schema) { + return; // No schema defined, 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 ${this._module.getName()}:`, e); + this._serializedState = null; + return; + } + + const validateSettings = ajv.compile(schema.settings); + const validateView = ajv.compile(schema.view); + + const isSettingsValid = parsedSettings === undefined || validateSettings(parsedSettings); + const isViewValid = parsedView === undefined || validateView(parsedView); + + if (!isSettingsValid || !isViewValid) { + console.warn(`Validation failed for ${this._module.getName()}`, { + settingsErrors: validateSettings.errors, + viewErrors: validateView.errors, + }); + this._serializedState = null; + return; + } + + this._serializedState = { + settings: parsedSettings as JTDDataType, + view: parsedView as JTDDataType, + }; + } + + setFullState(fullState: ModuleInstanceFullState): void { + this._syncedSettingKeys = fullState.syncedSettingKeys; + + this._id = fullState.id; + this._title = fullState.name; + + if (fullState.serializedState) { + this.deserializeSerializedState(fullState.serializedState); + } + } + + getFullState(): ModuleInstanceFullState { + return { + id: this._id, + name: this._module.getName(), + 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: { + settings: JSON.stringify(this._serializedState?.settings ?? ""), + view: JSON.stringify(this._serializedState?.view ?? ""), + }, + }; + } + + serializeSettingsState(state: JTDDataType): void { + this._serializedState = { + ...(this._serializedState ?? ({} as SerializedModuleState)), + settings: state, + }; + this.notifySubscribers(ModuleInstanceTopic.SERIALIZED_STATE); + } + + serializeViewState(state: JTDDataType): void { + this._serializedState = { + ...(this._serializedState ?? ({} as SerializedModuleState)), + view: state, + }; + this.notifySubscribers(ModuleInstanceTopic.SERIALIZED_STATE); + } + getUniDirectionalSettingsToViewInterface(): UniDirectionalModuleComponentsInterface< Exclude > { @@ -111,9 +242,9 @@ 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); } makeSettingsToViewInterface( @@ -220,19 +351,19 @@ 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}.'?`; } @@ -288,18 +419,21 @@ 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) { + return this.getSerializedState(); + } }; return snapshotGetter; } - getModule(): Module { + getModule(): Module { return this._module; } @@ -307,17 +441,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 +466,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 +487,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/ModuleRegistry.ts b/frontend/src/framework/ModuleRegistry.ts index f32a717d4..28f1e1629 100644 --- a/frontend/src/framework/ModuleRegistry.ts +++ b/frontend/src/framework/ModuleRegistry.ts @@ -2,9 +2,13 @@ import type { ChannelDefinition, ChannelReceiverDefinition } from "./DataChannel import { ModuleNotFoundPlaceholder } from "./internal/ModuleNotFoundPlaceholder"; import type { InterfaceEffects, + JTDBaseType, + MakeReadonly, + ModuleStateBaseSchema, ModuleCategory, ModuleDevState, ModuleInterfaceTypes, + NoModuleStateSchema, OnInstanceUnloadFunc, } from "./Module"; import { Module } from "./Module"; @@ -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,6 +29,7 @@ export type RegisterModuleOptions = { preview?: DrawPreviewFunc; description?: string; onInstanceUnload?: OnInstanceUnloadFunc; + serializedStateSchema?: MakeReadonly; }; export class ModuleNotFoundError extends Error { @@ -38,15 +43,16 @@ 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 ModuleStateBaseSchema = NoModuleStateSchema, + >(options: RegisterModuleOptions): Module { + const module = new Module({ name: options.moduleName, defaultTitle: options.defaultTitle, category: options.category, @@ -58,12 +64,16 @@ export class ModuleRegistry { drawPreviewFunc: options.preview, onInstanceUnloadFunc: options.onInstanceUnload, description: options.description, + serializedStateSchema: options.serializedStateSchema, }); this._registeredModules[options.moduleName] = module; return module; } - static initModule( + static initModule< + TInterfaceTypes extends ModuleInterfaceTypes, + TSerializedStateDef extends ModuleStateBaseSchema = NoModuleStateSchema, + >( moduleName: string, options: { settingsToViewInterfaceInitialization?: TInterfaceTypes["settingsToView"] extends undefined @@ -75,7 +85,7 @@ export class ModuleRegistry { viewToSettingsInterfaceEffects?: InterfaceEffects>; settingsToViewInterfaceEffects?: InterfaceEffects>; }, - ): Module { + ): Module { const module = this._registeredModules[moduleName]; if (module) { if (options.settingsToViewInterfaceInitialization) { @@ -90,25 +100,25 @@ export class ModuleRegistry { if (options.settingsToViewInterfaceEffects) { module.setSettingsToViewInterfaceEffects(options.settingsToViewInterfaceEffects); } - return module as Module; + 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..4c3b91311 100644 --- a/frontend/src/framework/TemplateRegistry.ts +++ b/frontend/src/framework/TemplateRegistry.ts @@ -1,6 +1,6 @@ +import type { LayoutElement } from "./internal/WorkbenchSession/Dashboard"; import type { KeyKind } from "./DataChannelTypes"; import type { SyncSettingKey } from "./SyncSettings"; -import type { LayoutElement } from "./Workbench"; export type DataChannelTemplate = { listensToInstanceRef: string; 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..6a4b39c1e 100644 --- a/frontend/src/framework/Workbench.ts +++ b/frontend/src/framework/Workbench.ts @@ -1,463 +1,659 @@ -import type { QueryClient } from "@tanstack/react-query"; - -import { postGetTimestampsForEnsemblesOptions, type EnsembleIdent_api, type EnsembleTimestamps_api } from "@api"; +import type { QueryClient, InfiniteData } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; + +import type { SessionDocument_api, SessionIndexPage_api, SessionUpdate_api } from "@api"; +import { + deleteSessionMutation, + getSessionMetadataQueryKey, + getSessionQueryKey, + getSessionsMetadataQueryKey, + getVisitedSnapshotsQueryKey, + updateSessionMutation, +} 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 { + buildSnapshotUrl, + 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 { ApiErrorHelper } from "./utils/ApiErrorHelper"; +import { FilterLevel, makeTanstackQueryFilters } from "./utils/reactQuery"; import type { WorkbenchServices } from "./WorkbenchServices"; -export enum WorkbenchEvents { - LayoutChanged = "LayoutChanged", - ModuleInstancesChanged = "ModuleInstancesChanged", +export enum WorkbenchTopic { + ACTIVE_SESSION = "activeSession", + 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 WorkbenchTopicPayloads = { + [WorkbenchTopic.ACTIVE_SESSION]: PrivateWorkbenchSession | null; + [WorkbenchTopic.HAS_ACTIVE_SESSION]: boolean; }; +export class Workbench implements PublishSubscribe { + private _publishSubscribeDelegate = new PublishSubscribeDelegate(); -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; -}; - -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 = []; + private _queryClient: QueryClient; + private _workbenchSessionPersistenceService: WorkbenchSessionPersistenceService; + private _navigationObserver: NavigationObserver; + private _ensembleUpdateMonitor: EnsembleUpdateMonitor; + private _isInitialized: boolean = false; + + constructor(queryClient: QueryClient) { + this._queryClient = queryClient; this._atomStoreMaster = new AtomStoreMaster(); - this._workbenchSession = new WorkbenchSessionPrivate(this._atomStoreMaster); 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; + this._navigationObserver = new NavigationObserver({ + onBeforeUnload: () => this.isWorkbenchDirty(), + onNavigate: async () => this.handleNavigation(), + }); + this._ensembleUpdateMonitor = new EnsembleUpdateMonitor(queryClient, this); } - getLayout(): LayoutElement[] { - return this._layout; + getPublishSubscribeDelegate(): PublishSubscribeDelegate { + return this._publishSubscribeDelegate; } - getAtomStoreMaster(): AtomStoreMaster { - return this._atomStoreMaster; + makeSnapshotGetter(topic: T): () => WorkbenchTopicPayloads[T] { + const snapshotGetter = (): any => { + if (topic === WorkbenchTopic.ACTIVE_SESSION) { + return this._workbenchSession; + } + if (topic === WorkbenchTopic.HAS_ACTIVE_SESSION) { + return this._workbenchSession !== null; + } + throw new Error(`No snapshot getter implemented for topic ${topic}`); + }; + return snapshotGetter; } - getWorkbenchSession(): WorkbenchSessionPrivate { - return this._workbenchSession; + getQueryClient(): QueryClient { + return this._queryClient; } - getWorkbenchServices(): WorkbenchServices { - return this._workbenchServices; + getWorkbenchSessionPersistenceService(): WorkbenchSessionPersistenceService { + return this._workbenchSessionPersistenceService; } - getWorkbenchSettings(): PrivateWorkbenchSettings { - return this._workbenchSettings; - } + private isWorkbenchDirty(): boolean { + if (!this._workbenchSession) { + return false; // No active session, so nothing to save. + } - getGuiMessageBroker(): GuiMessageBroker { - return this._guiMessageBroker; + return ( + (this._workbenchSessionPersistenceService.hasChanges() || !this._workbenchSession.getIsPersisted()) && + !this._workbenchSession.isSnapshot() + ); } - private notifySubscribers(event: WorkbenchEvents): void { - const subscribers = this._subscribersMap[event]; - if (!subscribers) return; + 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. + } + } - subscribers.forEach((subscriber) => { - subscriber(); - }); - } + 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.openSnapshot(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. + } - subscribe(event: WorkbenchEvents, cb: () => void) { - const subscribersSet = this._subscribersMap[event] || new Set(); - subscribersSet.add(cb); - this._subscribersMap[event] = subscribersSet; - return () => { - subscribersSet.delete(cb); - }; - } + // 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.openSnapshot(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.openSnapshot(snapshotId); + } else if (sessionId) { + await this.openSession(sessionId); + } + return true; // Proceed with the navigation. + } - getModuleInstances(): ModuleInstance[] { - return this._moduleInstances; - } + throw new Error(`Unexpected confirmation result: ${result}`); + } + } - getModuleInstance(id: string): ModuleInstance | undefined { - return this._moduleInstances.find((moduleInstance) => moduleInstance.getId() === id); + // If there are no unsaved changes or no active session, we can just load the new snapshot or session. + if (snapshotId) { + await this.openSnapshot(snapshotId); + } else if (sessionId) { + await this.openSession(sessionId); + } + return true; // Proceed with the navigation. } - private getNextModuleInstanceNumber(moduleName: string): number { - if (moduleName in this._perModuleRunningInstanceNumber) { - this._perModuleRunningInstanceNumber[moduleName] += 1; - } else { - this._perModuleRunningInstanceNumber[moduleName] = 1; + 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; } - 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`); + this._isInitialized = true; + + // First, check if a snapshot id is provided in the URL + const snapshotId = readSnapshotIdFromUrl(); + const sessionId = readSessionIdFromUrl(); + + const storedSessions = await loadAllWorkbenchSessionsFromLocalStorage(); + + if (snapshotId) { + this.openSnapshot(snapshotId); + return; + } else if (sessionId) { + this.openSession(sessionId); + if (storedSessions.find((el) => el.id === sessionId)) { + this._guiMessageBroker.setState(GuiState.ActiveSessionRecoveryDialogOpen, true); } + return; + } - 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 (storedSessions.length > 0) { + this._guiMessageBroker.setState(GuiState.MultiSessionsRecoveryDialogOpen, true); + } } - resetModuleInstanceNumbers(): void { - this._perModuleRunningInstanceNumber = {}; - } + async openSnapshot(snapshotId: string): Promise { + try { + const url = buildSnapshotUrl(snapshotId); + window.history.pushState({}, "", url); + + this._guiMessageBroker.setState(GuiState.IsLoadingSession, true); + const snapshotData = await loadSnapshotFromBackend(this._queryClient, snapshotId); + const snapshot = await PrivateWorkbenchSession.fromDataContainer( + this._atomStoreMaster, + 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); + } + this._guiMessageBroker.setState(GuiState.IsLoadingSession, false); + return; + } catch (error: any) { + this._guiMessageBroker.setState(GuiState.IsLoadingSession, false); + console.error("Failed to load snapshot from backend:", error); - clearLayout(): void { - for (const moduleInstance of this._moduleInstances) { - const manager = moduleInstance.getChannelManager(); - manager.unregisterAllChannels(); - manager.unregisterAllReceivers(); + if (isAxiosError(error)) { + // Handle Axios error specifically + console.error("Axios error details:", error.response?.data); + } + + const apiError = ApiErrorHelper.fromError(error); + + const result = await confirmationService.confirm({ + title: "Could not load snapshot", + message: apiError?.getMessage() ?? "Could not open snapshot", + actions: [ + { + id: "cancel", + label: "Cancel", + }, + { + id: "retry", + label: "Retry", + }, + ], + }); + if (result === "retry") { + this._guiMessageBroker.setState(GuiState.IsLoadingSession, true); + // Retry loading the snapshot + await this.openSnapshot(snapshotId); + } } - this._moduleInstances = []; - this._layout = []; - this.notifySubscribers(WorkbenchEvents.ModuleInstancesChanged); - this.notifySubscribers(WorkbenchEvents.LayoutChanged); } - makeAndAddModuleInstance(moduleName: string, layout: LayoutElement): ModuleInstance { - const module = ModuleRegistry.getModule(moduleName); - if (!module) { - throw new Error(`Module ${moduleName} not found`); + makeSessionFromSnapshot(): void { + if (!this._workbenchSession) { + throw new Error("No active workbench session."); } - module.setWorkbench(this); + this._workbenchSessionPersistenceService.removeWorkbenchSession(); + this._workbenchSession.setMetadata({ + title: "New Session from Snapshot", + description: undefined, + createdAt: Date.now(), + updatedAt: Date.now(), + lastModifiedMs: Date.now(), + }); + this._workbenchSession.setIsSnapshot(false); + this._workbenchSessionPersistenceService.setWorkbenchSession(this._workbenchSession); + removeSnapshotIdFromUrl(); + } - const moduleInstance = module.makeInstance(this.getNextModuleInstanceNumber(module.getName())); - this._atomStoreMaster.makeAtomStoreForModuleInstance(moduleInstance.getId()); - this._moduleInstances.push(moduleInstance); + discardLocalStorageSession(snapshotId: string | null, unloadSession = true): void { + const key = localStorageKeyForSessionId(snapshotId); + localStorage.removeItem(key); - this._layout.push({ ...layout, moduleInstanceId: moduleInstance.getId() }); - this.notifySubscribers(WorkbenchEvents.ModuleInstancesChanged); - this.notifySubscribers(WorkbenchEvents.LayoutChanged); - this.getGuiMessageBroker().setState(GuiState.ActiveModuleInstanceId, moduleInstance.getId()); - return moduleInstance; + 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); } - removeModuleInstance(moduleInstanceId: string): void { - const moduleInstance = this.getModuleInstance(moduleInstanceId); + 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 (moduleInstance) { - const manager = moduleInstance.getChannelManager(); + this._guiMessageBroker.setState(GuiState.IsLoadingSession, true); - moduleInstance.unload(); - manager.unregisterAllChannels(); - manager.unregisterAllReceivers(); + const sessionData = await loadWorkbenchSessionFromLocalStorage(snapshotId); + if (!sessionData) { + console.warn("No workbench session found in local storage."); + return; } - this._moduleInstances = this._moduleInstances.filter((el) => el.getId() !== moduleInstanceId); - - this._atomStoreMaster.removeAtomStoreForModuleInstance(moduleInstanceId); + const session = await PrivateWorkbenchSession.fromDataContainer( + this._atomStoreMaster, + this._queryClient, + sessionData, + ); - 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, ""); - } - this.notifySubscribers(WorkbenchEvents.ModuleInstancesChanged); + await this.setWorkbenchSession(session); + this._guiMessageBroker.setState(GuiState.MultiSessionsRecoveryDialogOpen, false); + this._guiMessageBroker.setState(GuiState.ActiveSessionRecoveryDialogOpen, false); + this._guiMessageBroker.setState(GuiState.IsLoadingSession, false); } - setLayout(layout: LayoutElement[]): void { - this._layout = layout; + 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; + } - const modifiedLayout = layout.map((el) => { - return { ...el, moduleInstanceId: undefined }; - }); - localStorage.setItem("layout", JSON.stringify(modifiedLayout)); - this.notifySubscribers(WorkbenchEvents.LayoutChanged); + 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._atomStoreMaster, + 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); + } } - 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); + async makeSnapshot(title: string, description: string): Promise { + if (!this._workbenchSession) { + throw new Error("No active workbench session to make a snapshot."); } - } - async initWorkbenchFromLocalStorage(queryClient: QueryClient): Promise { - const storedUserEnsembleSettings = this.maybeLoadEnsembleSettingsFromLocalStorage(); - const storedUserDeltaEnsembleSettings = this.maybeLoadDeltaEnsembleSettingsFromLocalStorage(); + this._guiMessageBroker.setState(GuiState.IsMakingSnapshot, true); - if (!storedUserEnsembleSettings && !storedUserDeltaEnsembleSettings) { - return; - } + const snapshotId = await this._workbenchSessionPersistenceService.makeSnapshot(title, description); + this._guiMessageBroker.setState(GuiState.IsMakingSnapshot, false); - await this.loadAndSetupEnsembleSetInSession( - queryClient, - storedUserEnsembleSettings ?? [], - storedUserDeltaEnsembleSettings ?? [], - ); + // Reset this, so it'll fetch fresh copies + this._queryClient.resetQueries({ queryKey: getVisitedSnapshotsQueryKey() }); - this.beginEnsembleUpdatePolling(queryClient); + return snapshotId; } - async storeSettingsInLocalStorageAndLoadAndSetupEnsembleSetInSession( - queryClient: QueryClient, - userEnsembleSettings: UserEnsembleSetting[], - userDeltaEnsembleSettings: UserDeltaEnsembleSetting[], - ): Promise { - this.storeEnsembleSetInLocalStorage(userEnsembleSettings); - this.storeDeltaEnsembleSetInLocalStorage(userDeltaEnsembleSettings); + async saveCurrentSession(forceSave = false): Promise { + if (!this._workbenchSession) { + throw new Error("No active workbench session to save."); + } - await this.loadAndSetupEnsembleSetInSession(queryClient, userEnsembleSettings, userDeltaEnsembleSettings); - } + if (this._workbenchSession.getIsPersisted() || forceSave) { + this._guiMessageBroker.setState(GuiState.IsSavingSession, true); + const wasPersisted = this._workbenchSession.getIsPersisted(); + await this._workbenchSessionPersistenceService.persistSessionState(); + 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.EditSessionDialogOpen, false); + this._guiMessageBroker.setState(GuiState.SessionHasUnsavedChanges, false); + return; + } - 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); + this._guiMessageBroker.setState(GuiState.SessionHasUnsavedChanges, false); + this._guiMessageBroker.setState(GuiState.SaveSessionDialogOpen, true); } - private _pollingEnabled = false; - private _waitingPollingRun?: NodeJS.Timeout; + private async setWorkbenchSession(session: PrivateWorkbenchSession): Promise { + try { + if (session.getEnsembleSet().getEnsembleArray().length === 0) { + this._guiMessageBroker.setState(GuiState.EnsembleDialogOpen, 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; + this._guiMessageBroker.setState(GuiState.LeftDrawerContent, LeftDrawerContent.ModuleSettings); - // 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); - } + 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); + } + } - // Start polling - console.debug("checkForEnsembleUpdate - initializing..."); - this._pollingEnabled = true; - this.recursivelyQueueEnsemblePolling(queryClient); + this._workbenchSession = session; + await this._ensembleUpdateMonitor.pollImmediately(); + this._ensembleUpdateMonitor.startPolling(); + await this._workbenchSessionPersistenceService.setWorkbenchSession(session); + this._publishSubscribeDelegate.notifySubscribers(WorkbenchTopic.HAS_ACTIVE_SESSION); + this._publishSubscribeDelegate.notifySubscribers(WorkbenchTopic.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); + } } - stopEnsembleUpdatePolling() { - clearTimeout(this._waitingPollingRun); - this._waitingPollingRun = undefined; - this._pollingEnabled = false; + async startNewSession(): Promise { + if (this._workbenchSession) { + console.warn("A workbench session is already active. Please close it before starting a new one."); + return; + } + + const session = new PrivateWorkbenchSession(this._atomStoreMaster, this._queryClient); + session.makeDefaultDashboard(); + + await this.setWorkbenchSession(session); } - private async recursivelyQueueEnsemblePolling(queryClient: QueryClient) { - if (!this._pollingEnabled) return; + async maybeCloseCurrentSession(): Promise { + if (!this._workbenchSession) { + console.warn("No active workbench session to close."); + return true; + } + + 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" }, + ], + }); - await this.pollForEnsembleChange(queryClient); + 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. + } - // Checking the variable again in case polling was disabled *during* the async call - if (!this._pollingEnabled) return; + throw new Error(`Unexpected confirmation result: ${result}`); + } - console.debug("checkForEnsembleUpdate - queuing next..."); - this._waitingPollingRun = setTimeout(async () => { - this.recursivelyQueueEnsemblePolling(queryClient); - }, ENSEMBLE_POLLING_INTERVAL); + this.closeCurrentSession(); + return true; } - private async pollForEnsembleChange(queryClient: QueryClient) { - console.debug("checkForEnsembleUpdate - fetching..."); + async maybeRefreshSession(): Promise { + if (!this._workbenchSession) { + console.warn("No active workbench session to refresh."); + return; + } - const regularEnsembleSet = this._workbenchSession.getEnsembleSet().getRegularEnsembleArray(); + if (this._workbenchSession.isSnapshot() || !this._workbenchSession.getIsPersisted()) { + throw new Error("Cannot refresh a snapshot or non-persisted session."); + } - const latestTimestamps = await this.getLatestEnsembleTimestamps(queryClient, regularEnsembleSet); + const sessionId = this._workbenchSession.getId(); - const newSettings = latestTimestamps.reduce((acc, [ens, ts]) => { - if (!isEnsembleOutdated(ens, ts)) return acc; + if (!sessionId) { + throw new Error("Cannot refresh session without a valid session ID."); + } - 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. By refreshing, you will lose these changes. Do you want to proceed?", + actions: [ + { id: "cancel", label: "Cancel" }, + { id: "discard", label: "Discard & Refresh", color: "danger" }, + ], }); - }, [] as UserEnsembleSetting[]); - if (newSettings.length) { - this.updateExistingUserEnsembleSettings(queryClient, newSettings); + 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; + } + + throw new Error(`Unexpected confirmation result: ${result}`); } - console.debug("checkForEnsembleUpdate - done..."); + this.unloadCurrentSession(); + await this.openSession(sessionId); } - 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, - }); + private unloadCurrentSession(): void { + if (!this._workbenchSession) { + console.warn("No active workbench session to unload."); + return; + } + + this._ensembleUpdateMonitor.stopPolling(); - return ensembles.map((ens, i) => [ens, timestamps[i]]); + this._workbenchSession.beforeDestroy(); + this._workbenchSessionPersistenceService.removeWorkbenchSession(); + this._workbenchSession = null; } - private async updateExistingUserEnsembleSettings(queryClient: QueryClient, newSettings: UserEnsembleSetting[]) { - if (newSettings.length === 0) return; + closeCurrentSession(): void { + if (!this._workbenchSession) { + console.warn("No active workbench session to close."); + return; + } + + removeSnapshotIdFromUrl(); + removeSessionIdFromUrl(); + this.unloadCurrentSession(); - const existingEnsembleSettings = this.maybeLoadEnsembleSettingsFromLocalStorage() ?? []; - const existingDeltaEnsembleSettings = this.maybeLoadDeltaEnsembleSettingsFromLocalStorage() ?? []; + this._publishSubscribeDelegate.notifySubscribers(WorkbenchTopic.HAS_ACTIVE_SESSION); + } - const newEnsembleSettings = existingEnsembleSettings.map((el) => { - const newSetting = newSettings.find((newEl) => newEl.ensembleIdent.equals(el.ensembleIdent)); - if (newSetting) return newSetting; - return el; + async deleteSession(sessionId: string): Promise { + const result = await confirmationService.confirm({ + title: "Are you sure?", + message: + "This session will be deleted. This action can not be reversed. Note that any snapshots made from this session will still be available", + actions: [ + { id: "cancel", label: "Cancel" }, + { id: "delete", label: "Delete", color: "danger" }, + ], }); - await this.storeSettingsInLocalStorageAndLoadAndSetupEnsembleSetInSession( - queryClient, - newEnsembleSettings, - existingDeltaEnsembleSettings, - ); - } + if (result !== "delete") return; + + await this._queryClient + .getMutationCache() + .build(this._queryClient, deleteSessionMutation()) + .execute({ path: { session_id: sessionId } }); - private storeEnsembleSetInLocalStorage(ensembleSettingsToStore: UserEnsembleSetting[]): void { - const ensembleSettingsArrayToStore: StoredUserEnsembleSetting[] = ensembleSettingsToStore.map((el) => ({ - ...el, - ensembleIdent: el.ensembleIdent.toString(), - })); - localStorage.setItem("userEnsembleSettings", JSON.stringify(ensembleSettingsArrayToStore)); + removeSessionQueryData(this._queryClient, sessionId); } - 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)); + async updateSession(updatedSession: SessionUpdate_api) { + const queryClient = this._queryClient; + + await queryClient + .getMutationCache() + .build(queryClient, { + ...updateSessionMutation(), + onSuccess(data) { + replaceSessionQueryData(queryClient, data); + }, + }) + .execute({ path: { session_id: updatedSession.id }, body: updatedSession }); } - maybeLoadEnsembleSettingsFromLocalStorage(): UserEnsembleSetting[] | null { - const ensembleSettingsString = localStorage.getItem("userEnsembleSettings"); - if (!ensembleSettingsString) return null; + getAtomStoreMaster(): AtomStoreMaster { + return this._atomStoreMaster; + } - const ensembleSettingsArray = JSON.parse(ensembleSettingsString) as StoredUserEnsembleSetting[]; - const parsedEnsembleSettingsArray: UserEnsembleSetting[] = ensembleSettingsArray.map((el) => ({ - ...el, - ensembleIdent: RegularEnsembleIdent.fromString(el.ensembleIdent), - })); + getWorkbenchSession(): PrivateWorkbenchSession { + if (!this._workbenchSession) { + throw new Error("Workbench session has not been started. Call startNewSession() first."); + } + return this._workbenchSession; + } - return parsedEnsembleSettingsArray; + getWorkbenchServices(): WorkbenchServices { + return this._workbenchServices; } - maybeLoadDeltaEnsembleSettingsFromLocalStorage(): UserDeltaEnsembleSetting[] | null { - const deltaEnsembleSettingsString = localStorage.getItem("userDeltaEnsembleSettings"); - if (!deltaEnsembleSettingsString) return null; + getGuiMessageBroker(): GuiMessageBroker { + return this._guiMessageBroker; + } - const deltaEnsembleSettingsArray = JSON.parse(deltaEnsembleSettingsString) as StoredUserDeltaEnsembleSetting[]; - const parsedDeltaEnsembleSettingsArray: UserDeltaEnsembleSetting[] = deltaEnsembleSettingsArray.map((el) => ({ - ...el, - comparisonEnsembleIdent: RegularEnsembleIdent.fromString(el.comparisonEnsembleIdent), - referenceEnsembleIdent: RegularEnsembleIdent.fromString(el.referenceEnsembleIdent), - })); + beforeDestroy(): void { + this._navigationObserver.beforeDestroy(); + } - return parsedDeltaEnsembleSettingsArray; + clear(): void { + // this._workbenchSession.clear(); } + /* applyTemplate(template: Template): void { this.clearLayout(); @@ -514,4 +710,79 @@ export class Workbench { this.notifySubscribers(WorkbenchEvents.ModuleInstancesChanged); } + */ +} + +function removeSessionQueryData(queryClient: QueryClient, deletedSessionId: string) { + const sessionsListFilter = makeTanstackQueryFilters([getSessionsMetadataQueryKey()]); + const sessionsInfiniteListFilter = { queryKey: ["getSessionsMetadata", "infinite"] }; + + queryClient.setQueriesData(sessionsListFilter, function dropSessionFromList(page: SessionIndexPage_api) { + if (!page) return undefined; + + const { continuation_token, items } = page; + let dropped = false; + + const newItems = items.filter((session) => { + if (session.id !== deletedSessionId) return true; + + dropped = true; + return false; + }); + + if (dropped) return { continuation_token, items: newItems }; + return undefined; + }); + queryClient.setQueriesData( + sessionsInfiniteListFilter, + function dropSessionFromList(oldData: InfiniteData) { + if (!oldData) return undefined; + + const pageParams = oldData.pageParams; + const existingPages = oldData.pages; + + let dropped = false; + + const newPages = existingPages.map((page) => { + const { continuation_token, items } = page; + + const newItems = items.filter((session) => { + if (session.id !== deletedSessionId) return true; + + dropped = true; + return false; + }); + + return { continuation_token, items: newItems }; + }); + + if (dropped) return { pageParams, pages: newPages }; + return undefined; + }, + ); +} + +function replaceSessionQueryData(queryClient: QueryClient, newSession: SessionDocument_api) { + const sessionMetadataQueryKey = getSessionMetadataQueryKey({ path: { session_id: newSession.id } }); + const sessionQueryKey = getSessionQueryKey({ path: { session_id: newSession.id } }); + + const sessionsMetadataQueryKey = getSessionsMetadataQueryKey(); + // ! Something breaks when using hey-api's generated infinite query options: setQueriesData is unable to get the + // ! correct cache entry, and instead makes a new entry. getQueriesData still works as expected... + const sessionsMetadataInfiniteQueryKey = ["getSessionsMetadata", "infinite"]; + + // Replace query data that directly refers to this session + queryClient.setQueriesData(makeTanstackQueryFilters([sessionQueryKey], FilterLevel.PATH), newSession); + queryClient.setQueriesData( + makeTanstackQueryFilters([sessionMetadataQueryKey], FilterLevel.PATH), + newSession.metadata, + ); + + // Replace list data entries + // ! We sort these queries server-side; updating entries in these lists *might* result in an an incorrectly + // ! sorted state. We could theoretically make a filter that matches only unsorted lists, but the easier option + // ! is to just refetch all of them... + + queryClient.invalidateQueries(makeTanstackQueryFilters([sessionMetadataQueryKey, sessionsMetadataQueryKey])); + queryClient.invalidateQueries({ queryKey: sessionsMetadataInfiniteQueryKey }); } 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..a203bde61 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 { 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..96ddc50e5 100644 --- a/frontend/src/framework/internal/ModuleNotFoundPlaceholder.tsx +++ b/frontend/src/framework/internal/ModuleNotFoundPlaceholder.tsx @@ -1,11 +1,11 @@ import { BugReport, Forum, WebAssetOff } from "@mui/icons-material"; -import { ImportState, Module, ModuleCategory, ModuleDevState } from "@framework/Module"; +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 +13,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): ModuleInstance { + const instance = super.makeInstance(id); 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..a8b5e346f --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSession/Dashboard.ts @@ -0,0 +1,338 @@ +import type { JTDSchemaType } from "ajv/dist/core"; +import { v4 } from "uuid"; + +import { SyncSettingKey } from "@framework/SyncSettings"; +import { PublishSubscribeDelegate, type PublishSubscribe } from "@lib/utils/PublishSubscribeDelegate"; + +import type { AtomStoreMaster } from "../../AtomStoreMaster"; +import type { ModuleInstance, ModuleInstanceFullState } 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 = ModuleInstanceFullState & { + 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 fullState = moduleInstance.getFullState(); + + 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 { + ...fullState, + 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, + }; + } + + deserializeState(serializedDashboard: SerializedDashboard): void { + this._id = serializedDashboard.id; + this._name = serializedDashboard.name; + this._description = serializedDashboard.description; + + this.clearLayout(); + + for (const serializedInstance of serializedDashboard.moduleInstances) { + const { id, name, layoutInfo } = serializedInstance; + + const module = ModuleRegistry.getModule(name); + if (!module) { + throw new Error(`Module ${name} not found`); + } + const moduleInstance = module.makeInstance(id); + moduleInstance.setFullState(serializedInstance); + this.registerModuleInstance(moduleInstance); + + 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._atomStoreMaster.makeAtomStoreForModuleInstance(moduleInstance.getId()); + this._publishSubscribeDelegate.notifySubscribers(DashboardTopic.ModuleInstances); + } + + makeAndAddModuleInstance(moduleName: string, layout: LayoutElement): ModuleInstance { + const module = ModuleRegistry.getModule(moduleName); + if (!module) { + throw new Error(`Module ${moduleName} not found`); + } + + const moduleInstance = module.makeInstance(v4()); + this._atomStoreMaster.makeAtomStoreForModuleInstance(moduleInstance.getId()); + this._moduleInstances = [...this._moduleInstances, moduleInstance]; + + this._layout = [...this._layout, { ...layout, moduleInstanceId: 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); + + const newLayout = this._layout.filter((el) => el.moduleInstanceId !== moduleInstanceId); + this.setLayout(newLayout); + if (this._activeModuleInstanceId === moduleInstanceId) { + this._activeModuleInstanceId = null; + } + this._publishSubscribeDelegate.notifySubscribers(DashboardTopic.ModuleInstances); + } + + 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; + + const layout: LayoutElement[] = []; + + for (const serializedInstance of serializedDashboard.moduleInstances) { + const { id, name, layoutInfo } = serializedInstance; + + const module = ModuleRegistry.getModule(name); + if (!module) { + throw new Error(`Module ${name} not found`); + } + const moduleInstance = module.makeInstance(id); + moduleInstance.setFullState(serializedInstance); + dashboard.registerModuleInstance(moduleInstance); + + 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; + } +} 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..f31e1a290 --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSession/PrivateWorkbenchSession.ts @@ -0,0 +1,346 @@ +import type { QueryClient } from "@tanstack/query-core"; + +import type { 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(atomStoreMaster: AtomStoreMaster, queryClient: QueryClient, isSnapshot = false) { + this._atomStoreMaster = atomStoreMaster; + this._queryClient = queryClient; + this._userCreatedItems = new UserCreatedItems(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; + } + + 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; + this._dashboards = content.dashboards.map((s) => { + const d = new Dashboard(this._atomStoreMaster); + d.deserializeState(s); + return d; + }); + + 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; + } + + 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( + atomStoreMaster: AtomStoreMaster, + queryClient: QueryClient, + dataContainer: WorkbenchSessionDataContainer, + ): Promise { + const session = new PrivateWorkbenchSession(atomStoreMaster, 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..5085c9dd4 --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSession/WorkbenchSessionPersistenceService.ts @@ -0,0 +1,518 @@ +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 { makeWorkbenchSessionLocalStorageString, 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; + + this.maybeClearPullDebounceTimeout(); + + 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._workbenchSession) { + localStorage.setItem(key, makeWorkbenchSessionLocalStorageString(this._workbenchSession)); + } + } + + 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 }); + 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 maybeClearPullDebounceTimeout() { + if (this._pullDebounceTimeout) { + clearTimeout(this._pullDebounceTimeout); + this._pullDebounceTimeout = null; + } + } + + private schedulePullFullSessionState(delay: number = 200) { + this.maybeClearPullDebounceTimeout(); + + 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() { + 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."); + } + + // Make sure we pull the latest session before we save + this.maybeClearPullDebounceTimeout(); + await this.pullFullSessionState(); + + 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; + } + + 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()), + }); + + // ! Make sure you remove the localStorage backup BEFORE you store the new session id + this.removeFromLocalStorage(); + + 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(); + } catch (error) { + console.error("Failed to persist session state:", error); + toast.dismiss(toastId); + toast.error("Failed to persist session state. Please try again later."); + } + } + + 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..5bfab2663 --- /dev/null +++ b/frontend/src/framework/internal/WorkbenchSession/WorkbenchSessionSerializer.ts @@ -0,0 +1,81 @@ +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: { + title: session.getMetadata().title, + description: session.getMetadata().description, + }, + content: session.getContent(), + }); +} + +export function makeWorkbenchSessionLocalStorageString(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..ede6c580e --- /dev/null +++ b/frontend/src/framework/internal/components/ActiveSessionRecoveryDialog/activeSessionRecoveryDialog.tsx @@ -0,0 +1,104 @@ +import React from "react"; + +import { GuiState, useGuiState, useGuiValue } 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 { CircularProgress } from "@lib/components/CircularProgress"; +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 isLoading = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.IsLoadingSession); + + 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..3bf5e39d7 100644 --- a/frontend/src/framework/internal/components/Content/content.tsx +++ b/frontend/src/framework/internal/components/Content/content.tsx @@ -1,6 +1,5 @@ import type React from "react"; -import { GuiState, useGuiValue } from "@framework/GuiMessageBroker"; import type { Workbench } from "@framework/Workbench"; import { DataChannelVisualizationLayer } from "./private-components/DataChannelVisualizationLayer"; @@ -11,12 +10,11 @@ type ContentProps = { }; 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 importState = useModuleInstanceTopicValue(props.moduleInstance, ModuleInstanceTopic.IMPORT_STATUS); + const moduleInstanceLifeCycleState = useModuleInstanceTopicValue( + props.moduleInstance, + ModuleInstanceTopic.LIFECYCLE_STATE, + ); const atomStore = props.workbench.getAtomStoreMaster().getAtomStoreForModuleInstance(props.moduleInstance.getId()); @@ -34,50 +41,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,10 +121,19 @@ 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..e2962026e 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 { DashboardTopic } from "@framework/internal/WorkbenchSession/Dashboard"; import { GuiEvent, GuiState, LeftDrawerContent, useGuiState, useGuiValue } from "@framework/GuiMessageBroker"; +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 ( diff --git a/frontend/src/framework/internal/components/Content/private-components/layout.tsx b/frontend/src/framework/internal/components/Content/private-components/layout.tsx index 4b8748488..afd9ee6c7 100644 --- a/frontend/src/framework/internal/components/Content/private-components/layout.tsx +++ b/frontend/src/framework/internal/components/Content/private-components/layout.tsx @@ -4,14 +4,15 @@ import { v4 } from "uuid"; import type { LayoutBox } from "@framework/components/LayoutBox"; import { LayoutBoxComponents, makeLayoutBoxes } from "@framework/components/LayoutBox"; -import type { GuiEventPayloads } from "@framework/GuiMessageBroker"; -import { GuiEvent } from "@framework/GuiMessageBroker"; -import { useModuleInstances, useModuleLayout } from "@framework/internal/hooks/workbenchHooks"; +import { GuiEvent, type GuiEventPayloads } from "@framework/GuiMessageBroker"; +import { DashboardTopic, type LayoutElement } from "@framework/internal/WorkbenchSession/Dashboard"; +import { PrivateWorkbenchSessionTopic } from "@framework/internal/WorkbenchSession/PrivateWorkbenchSession"; import type { ModuleInstance } from "@framework/ModuleInstance"; -import type { LayoutElement, Workbench } from "@framework/Workbench"; +import { WorkbenchTopic, type Workbench } from "@framework/Workbench"; import { useElementSize } from "@lib/hooks/useElementSize"; import type { Rect2D, Size2D } from "@lib/utils/geometry"; import { MANHATTAN_LENGTH, addMarginToRect, pointRelativeToDomRect, rectContainsPoint } from "@lib/utils/geometry"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { convertRemToPixels } from "@lib/utils/screenUnitConversions"; import type { Vec2 } from "@lib/utils/vec2"; import { multiplyVec2, point2Distance, scaleVec2NonUniform, subtractVec2, vec2FromPointerEvent } from "@lib/utils/vec2"; @@ -21,7 +22,6 @@ import { ViewWrapperPlaceholder } from "./viewWrapperPlaceholder"; type LayoutProps = { workbench: Workbench; - activeModuleInstanceId: string | null; }; function convertLayoutRectToRealRect(element: LayoutElement, size: Size2D): Rect2D { @@ -34,6 +34,8 @@ function convertLayoutRectToRealRect(element: LayoutElement, size: Size2D): Rect } export const Layout: React.FC = (props) => { + const activeSession = usePublishSubscribeTopicValue(props.workbench, WorkbenchTopic.ACTIVE_SESSION); + const dashboard = usePublishSubscribeTopicValue(activeSession!, 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 +44,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 +62,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[] = dashboard.getLayout(); + let currentLayout: LayoutElement[] = dashboard.getLayout(); let originalLayoutBox = makeLayoutBoxes(originalLayout); let currentLayoutBox = originalLayoutBox; layoutBoxRef.current = currentLayoutBox; @@ -113,7 +115,7 @@ 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); + const instance = dashboard.makeAndAddModuleInstance(moduleName, layoutElement); layoutElement.moduleInstanceId = instance.getId(); layoutElement.moduleName = instance.getName(); } @@ -126,7 +128,7 @@ export const Layout: React.FC = (props) => { originalLayoutBox = currentLayoutBox; layoutBoxRef.current = currentLayoutBox; setTempLayout(null); - props.workbench.setLayout(currentLayout); + dashboard.setLayout(currentLayout); setPosition({ x: 0, y: 0 }); setPointer({ x: -1, y: -1 }); @@ -256,12 +258,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 +325,7 @@ export const Layout: React.FC = (props) => { clearTimeout(delayTimer); } }; - }, [layoutDivSize, moduleInstances, guiMessageBroker, props.workbench]); + }, [layoutDivSize, moduleInstances, guiMessageBroker, dashboard]); function makeTempViewWrapperPlaceholder() { if (!tempLayoutBoxId) { @@ -365,7 +367,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); @@ -435,7 +437,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} 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..f3bee3f8e --- /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.IsMakingSnapshot); + + 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/EditSessionDialog/editSessionDialog.tsx b/frontend/src/framework/internal/components/EditSessionDialog/editSessionDialog.tsx new file mode 100644 index 000000000..b8a3fdbf1 --- /dev/null +++ b/frontend/src/framework/internal/components/EditSessionDialog/editSessionDialog.tsx @@ -0,0 +1,128 @@ +import React from "react"; + +import { isEqual } from "lodash"; + +import { GuiState, useGuiState, useGuiValue } from "@framework/GuiMessageBroker"; +import type { WorkbenchSessionMetadata } from "@framework/internal/WorkbenchSession/PrivateWorkbenchSession"; +import { PrivateWorkbenchSessionTopic } from "@framework/internal/WorkbenchSession/PrivateWorkbenchSession"; +import { WorkbenchTopic, 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 { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; + +import { DashboardPreview } from "../DashboardPreview/dashboardPreview"; + +export type EditSessionDialogProps = { + workbench: Workbench; +}; + +type EditSessionDialogInputFeedback = { + title?: string; + description?: string; +}; + +export function EditSessionDialog(props: EditSessionDialogProps): React.ReactNode { + const [editSessionDialogOpen, setEditSessionDialogOpen] = useGuiState( + props.workbench.getGuiMessageBroker(), + GuiState.EditSessionDialogOpen, + ); + + const activeSession = usePublishSubscribeTopicValue(props.workbench, WorkbenchTopic.ACTIVE_SESSION); + const metadata = usePublishSubscribeTopicValue(activeSession!, PrivateWorkbenchSessionTopic.METADATA); + const isSaving = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.IsSavingSession); + + const [prevMetadata, setPrevMetadata] = React.useState(metadata); + const [title, setTitle] = React.useState(metadata.title); + const [description, setDescription] = React.useState(metadata.description ?? ""); + const [inputFeedback, setInputFeedback] = React.useState({}); + + if (!isEqual(prevMetadata, metadata)) { + setPrevMetadata(metadata); + setTitle(metadata.title); + setDescription(metadata.description ?? ""); + } + + 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() + .then(() => { + setInputFeedback({}); + }) + .catch((error) => { + console.error("Failed to save session:", error); + }); + } + + function handleCancel() { + setEditSessionDialogOpen(false); + setInputFeedback({}); + } + + const layout = props.workbench.getWorkbenchSession().getActiveDashboard()?.getLayout() || []; + + return ( + + + + + } + > +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/framework/internal/components/EditSessionDialog/index.ts b/frontend/src/framework/internal/components/EditSessionDialog/index.ts new file mode 100644 index 000000000..06c87488c --- /dev/null +++ b/frontend/src/framework/internal/components/EditSessionDialog/index.ts @@ -0,0 +1,2 @@ +export { EditSessionDialog } from "./editSessionDialog"; +export type { EditSessionDialogProps } from "./editSessionDialog"; diff --git a/frontend/src/framework/internal/components/EditSessionMetadataDialog/editSessionMetadataDialog.tsx b/frontend/src/framework/internal/components/EditSessionMetadataDialog/editSessionMetadataDialog.tsx new file mode 100644 index 000000000..5464d6536 --- /dev/null +++ b/frontend/src/framework/internal/components/EditSessionMetadataDialog/editSessionMetadataDialog.tsx @@ -0,0 +1,132 @@ +import React from "react"; + +import type { SessionMetadata_api } from "@api"; +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 EditSessionMetadataDialogProps = { + open: boolean; + sessionId: string; + sessionMetadata: SessionMetadata_api; + workbench: Workbench; + onSaved?: () => void; + onClose?: () => void; +}; + +type EditingInputFeedback = { + title?: string; + description?: string; +}; + +export function EditSessionMetadataDialog(props: EditSessionMetadataDialogProps): React.ReactNode { + const [prevOpen, setPrevOpen] = React.useState(props.open); + const [title, setTitle] = React.useState(""); + const [description, setDescription] = React.useState(""); + + const [inputFeedback, setInputFeedback] = React.useState({}); + const [isSaving, setIsSaving] = React.useState(false); + + if (prevOpen !== props.open) { + setPrevOpen(props.open); + if (props.open) { + setTitle(props.sessionMetadata.title); + setDescription(props.sessionMetadata.description ?? ""); + } + } + + // const [savePending, setSavePending] = React.useState(false); + + // const isSaving = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.IsSavingSession); + + async function handleSave() { + if (title.trim() === "") { + setInputFeedback((prev) => ({ ...prev, title: "Title is required." })); + return; + } else { + setInputFeedback((prev) => ({ ...prev, title: undefined })); + } + + setIsSaving(true); + + await props.workbench.updateSession({ + id: props.sessionId, + metadata: { + title: title, + description: description === "" ? null : description, + }, + }); + + setIsSaving(false); + + props.onSaved?.(); + handleClose(); + } + + function handleClose() { + setTitle(""); + setDescription(""); + setInputFeedback({}); + props.onClose?.(); + } + + return ( + + + + + } + > +
+ {/* */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/framework/internal/components/EditSessionMetadataDialog/index.ts b/frontend/src/framework/internal/components/EditSessionMetadataDialog/index.ts new file mode 100644 index 000000000..4b484a1df --- /dev/null +++ b/frontend/src/framework/internal/components/EditSessionMetadataDialog/index.ts @@ -0,0 +1 @@ +export { EditSessionMetadataDialog } from "./editSessionMetadataDialog"; 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/LeftSettingsPanel/leftSettingsPanel.tsx b/frontend/src/framework/internal/components/LeftSettingsPanel/leftSettingsPanel.tsx index a1253561a..39a119240 100644 --- a/frontend/src/framework/internal/components/LeftSettingsPanel/leftSettingsPanel.tsx +++ b/frontend/src/framework/internal/components/LeftSettingsPanel/leftSettingsPanel.tsx @@ -1,15 +1,16 @@ 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"; @@ -18,9 +19,11 @@ type LeftSettingsPanelProps = { }; 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,7 +34,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)" }} > - @@ -43,16 +45,11 @@ export const LeftSettingsPanel: React.FC = (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..34e96aa4f 100644 --- a/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/moduleSettings.tsx +++ b/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/moduleSettings.tsx @@ -3,40 +3,59 @@ import type React from "react"; import { Settings as SettingsIcon } from "@mui/icons-material"; import { Provider } from "jotai"; +import { DashboardTopic } from "@framework/internal/WorkbenchSession/Dashboard"; import { ErrorBoundary } from "@framework/internal/components/ErrorBoundary"; -import { ImportState } from "@framework/Module"; +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 importState = useModuleInstanceTopicValue(props.moduleInstance, ModuleInstanceTopic.IMPORT_STATUS); + const dashboard = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.ACTIVE_DASHBOARD, + ); + + const activeModuleInstanceId = usePublishSubscribeTopicValue(dashboard, DashboardTopic.ActiveModuleInstanceId); + + const moduleInstanceLifecycleState = useModuleInstanceTopicValue( + props.moduleInstance, + ModuleInstanceTopic.LIFECYCLE_STATE, + ); const atomStore = props.workbench.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 +64,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 +85,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 index a876ea77c..00fa1c410 100644 --- a/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/templatesList.tsx +++ b/frontend/src/framework/internal/components/LeftSettingsPanel/private-components/templatesList.tsx @@ -110,7 +110,7 @@ export const TemplatesList: React.FC = (props) => { if (!template) { return; } - props.workbench.applyTemplate(template); + // props.workbench.applyTemplate(template); setDrawerContent(LeftDrawerContent.ModuleSettings); }; 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 93% rename from frontend/src/framework/internal/components/LeftSettingsPanel/private-components/modulesList.tsx rename to frontend/src/framework/internal/components/ModulesList/modulesList.tsx index 15ee374fc..2af7b0da3 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) => ( ([]); + + async function loadSessions() { + const loadedSessions = await loadAllWorkbenchSessionsFromLocalStorage(); + + setSessions(loadedSessions); + } + + React.useEffect( + function loadSessionOnOpen() { + if (isOpen) { + loadSessions(); + } + }, + [isOpen], + ); + + 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..f00dbfe6c 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,7 @@ 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 isSnapshot = usePublishSubscribeTopicValue(workbenchSession, PrivateWorkbenchSessionTopic.IS_SNAPSHOT); function ensureSettingsPanelIsVisible() { if (leftSettingsPanelWidth <= 5) { @@ -93,11 +54,6 @@ export const LeftNavBar: React.FC = (props) => { setDrawerContent(LeftDrawerContent.ModuleSettings); } - function handleModulesListClick() { - ensureSettingsPanelIsVisible(); - setDrawerContent(LeftDrawerContent.ModulesList); - } - function handleTemplatesListClick() { ensureSettingsPanelIsVisible(); setDrawerContent(LeftDrawerContent.TemplatesList); @@ -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,41 @@ export const LeftNavBar: React.FC = (props) => { } + icon={} onClick={handleModuleSettingsClick} disabled={layoutEmpty} /> } onClick={handleSyncSettingsClick} /> - - } - onClick={handleModulesListClick} - /> } onClick={handleTemplatesListClick} + 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({}); + + if (prevSaveSessionDialogOpen !== saveSessionDialogOpen) { + setPrevSaveSessionDialogOpen(saveSessionDialogOpen); + if (saveSessionDialogOpen) { + setTitle(metadata.title); + setDescription(metadata.description ?? ""); + } + } + + 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(() => { + 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..d0060fd93 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,13 @@ 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 { UserAvatar } from "./userAvatar"; +import { UserAvatar } from "../../UserAvatar"; +import type { InternalRegularEnsembleSetting } from "../types"; export type EnsemblePickerProps = { nextEnsembleColor: string; - selectedEnsembles: RegularEnsembleItem[]; - onAddEnsemble: (newEnsemble: RegularEnsembleItem) => void; + selectedEnsembles: UserEnsembleSetting[]; + onAddEnsemble: (newEnsemble: InternalRegularEnsembleSetting) => void; }; const STALE_TIME = tanstackDebugTimeOverride(0); @@ -115,7 +115,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 +123,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 +156,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 +184,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 +229,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 && }
= [ + { + _type: "data", + columnId: "title", + label: "Title", + sizeInPercent: 20, + filter: false, + }, + { + _type: "data", + columnId: "description", + label: "Description", + sizeInPercent: 50, + filter: false, + sortable: false, + renderData(value) { + return value || N/A; + }, + }, + + // TODO - Future work: Could be nice to show/filter on modules used, but need backend changes and virtual table columns to support that + // { + // _type: "virtual", + // columnId: "modules" as keyof SessionMetadataWithId_api, + // label: "Modules", + // sizeInPercent: 15, + // filter: false, + // }, + { + _type: "data", + columnId: "updatedAt", + label: "Updated at", + sizeInPercent: 15, + filter: false, + formatValue: (value) => { + return formatDate(new Date(value)); + }, + }, + { + _type: "data", + columnId: "createdAt", + label: "Created at", + sizeInPercent: 15, + filter: false, + formatValue: (value) => { + return formatDate(new Date(value)); + }, + }, +]; + +function columnIdToApiSortField(columnId: string): SessionSortBy_api { + switch (columnId) { + case "title": + return SessionSortBy_api.METADATA_TITLE; + case "updatedAt": + return SessionSortBy_api.METADATA_UPDATED_AT; + case "createdAt": + return SessionSortBy_api.METADATA_CREATED_AT; + + default: + throw new Error(`Unknown columnId: ${columnId}`); + } +} + +function tableSortDirToApiSortDir(sort: TableSortDirection): SortDirection_api { + return sort as unknown as SortDirection_api; +} + +function useInfiniteSessionMetadataQuery(querySortParams: Options["query"]) { + // ! We need to manually write out the query because hey-api generates keys in a way that messes with Tanstack's + // ! ability to set query data (which we use after mutating metadata). + // ! You'd think this would work, but if I try this; the data never loads, because it tries to get the query + // ! params from the key... + // return useInfiniteQuery({ + // ...getSessionsMetadataInfiniteOptions({ + // query: { + // ...querySortParams, + // limit: QUERY_PAGE_SIZE, + // // // TODO: Rename `cursor` to `continuation_token` once we update to latest hey-api version + // // cursor: pageParam, + // }, + // }), + // queryKey: [ + // // @ts-expect-error -- Ignore expected tanstack key type + // "getSessionsMetadata", + // "infinite", + // querySortParams?.filter_title, + // querySortParams?.filter_updated_from, + // querySortParams?.filter_updated_to, + // querySortParams?.sort_by, + // querySortParams?.sort_direction, + // ], + // initialPageParam: null, + // refetchInterval: 20000, + // getNextPageParam(lastPage) { + // return lastPage.continuation_token; + // }, + // }); + + return useInfiniteQuery< + GetSessionsMetadataResponse_api, + AxiosError, + InfiniteData, + readonly unknown[], + string | null + >({ + queryKey: [ + "getSessionsMetadata", + "infinite", + querySortParams?.filter_title, + querySortParams?.filter_updated_from, + querySortParams?.filter_updated_to, + querySortParams?.sort_by, + querySortParams?.sort_direction, + ], + initialPageParam: null, + refetchInterval: 20000, + getNextPageParam(lastPage) { + return lastPage.continuation_token; + }, + async queryFn({ pageParam, signal }) { + const { data } = await getSessionsMetadata({ + signal, + throwOnError: true, + query: { + ...querySortParams, + limit: QUERY_PAGE_SIZE, + // TODO: Rename `cursor` to `continuation_token` once we update to latest hey-api version + cursor: pageParam, + }, + }); + + return data; + }, + }); +} + +export type SessionOverviewContentProps = { + selectedSession: string | null; + workbench: Workbench; + editOpen: boolean; + onSelectSession: (sessionId: string | null) => void; + onEditClose: () => void; +}; + +export function SessionOverviewContent(props: SessionOverviewContentProps): React.ReactNode { + // ? Should this be opened via gui-events? + + const [visibleRowRange, setVisibleRowRange] = React.useState<{ start: number; end: number } | null>(null); + const [tableFilter, setTableFilter] = React.useState({}); + const [tableSortState, setTableSortState] = React.useState([ + { columnId: "updatedAt", direction: TableSortDirection.DESC }, + ]); + + const querySortParams = React.useMemo["query"]>(() => { + if (!tableSortState?.length) return undefined; + + const sortBy = columnIdToApiSortField(tableSortState[0].columnId); + const SortDirection = tableSortDirToApiSortDir(tableSortState[0].direction); + + return { + sort_by: sortBy, + sort_direction: SortDirection, + filter_title: tableFilter.title, + filter_updated_from: tableFilter.updatedAt?.from, + filter_updated_to: tableFilter.updatedAt?.to, + }; + }, [tableFilter, tableSortState]); + + const sessionsQuery = useInfiniteSessionMetadataQuery(querySortParams); + + const tableData = React.useMemo(() => { + if (!sessionsQuery.data) return []; + + return sessionsQuery.data.pages?.flatMap(({ items }) => items); + }, [sessionsQuery.data]); + + const onTableScrollIndexChange = React.useCallback((start: number, end: number) => { + setVisibleRowRange({ start, end }); + }, []); + + React.useEffect( + function maybeRefetchNextPageEffect() { + if (!visibleRowRange || visibleRowRange.end === -1) return; + if (!sessionsQuery.hasNextPage) return; + if (sessionsQuery.isFetchingNextPage) return; + if (tableData.length - visibleRowRange?.end <= NEXT_PAGE_THRESHOLD) { + sessionsQuery.fetchNextPage(); + } + }, + [sessionsQuery, tableData.length, visibleRowRange], + ); + + const selectedSession = React.useMemo(() => { + if (!props.selectedSession) return null; + return tableData.find((session) => session.id === props.selectedSession) || null; + }, [tableData, props.selectedSession]); + + function onFilterRangeChange(newRange: null | EdsFilterRange) { + setTableFilter((prev) => { + return { + ...prev, + updatedAt: edsRangeChoiceToFilterRange(newRange), + }; + }); + } + + function handleTitleFilterValueChange(newValue: string) { + setTableFilter((prev) => { + return { + ...prev, + title: newValue || undefined, + }; + }); + } + + return ( + <> +
+ + + +
+ props.onSelectSession(selection[0])} + onVisibleRowRangeChange={onTableScrollIndexChange} + /> + + {selectedSession && ( + + )} + + ); +} diff --git a/frontend/src/framework/internal/components/SessionOverviewDialog/sessionOverviewDialog.tsx b/frontend/src/framework/internal/components/SessionOverviewDialog/sessionOverviewDialog.tsx new file mode 100644 index 000000000..58d464d4b --- /dev/null +++ b/frontend/src/framework/internal/components/SessionOverviewDialog/sessionOverviewDialog.tsx @@ -0,0 +1,160 @@ +import React from "react"; + +import { Add, Delete, Edit, FileOpen, Link } from "@mui/icons-material"; +import { toast } from "react-toastify"; + +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 type { DialogProps } from "@lib/components/Dialog/dialog"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; + +import { SessionOverviewContent } from "./sessionOverviewContent"; +import { SnapshotOverviewContent } from "./snapshotOverviewContent"; + +export type SessionOverviewDialogProps = { + workbench: Workbench; + contentMode: ModalContentMode; + onNewSession?: () => void; + onChangeModalMode?: (newMode: ModalContentMode) => void; +} & Pick; + +export type ModalContentMode = "sessions" | "snapshots"; + +export function SessionOverviewDialog(props: SessionOverviewDialogProps): React.ReactNode { + const [prevMode, setPrevMode] = React.useState(props.contentMode); + const [selectedEntryId, setSelectedEntryId] = React.useState(null); + + const [deletePending, setDeletePending] = React.useState(false); + const [entryEditOpen, setEntryEditOpen] = React.useState(false); + + if (props.contentMode !== prevMode) { + setPrevMode(props.contentMode); + setEntryEditOpen(false); + setSelectedEntryId(null); + } + + function editSelectedEntry() { + if (!selectedEntryId) return; + setEntryEditOpen(true); + } + + function goToSelectedEntry() { + if (!selectedEntryId) return; + if (props.contentMode === "sessions") { + props.workbench.openSession(selectedEntryId); + } else { + props.workbench.openSnapshot(selectedEntryId); + } + } + + function copySelectedUrl() { + if (!selectedEntryId) return; + if (props.contentMode === "snapshots") { + navigator.clipboard.writeText(buildSnapshotUrl(selectedEntryId)); + toast.info("Url copied"); + } + } + + async function deleteSelectedEntry() { + if (!selectedEntryId || props.contentMode !== "sessions") return; + + setDeletePending(true); + + await props.workbench.deleteSession(selectedEntryId); + + setSelectedEntryId(null); + setDeletePending(false); + } + + const actions = + props.contentMode === "sessions" ? ( + <> + + + + + + + + ) : ( + <> + + + + ); + + return ( + <> + + + + + } + modal + {...props} + actions={actions} + width={1500} + showCloseCross + > + {props.contentMode === "sessions" && ( + setEntryEditOpen(false)} + /> + )} + + {props.contentMode === "snapshots" && ( + setEntryEditOpen(false)} + /> + )} + + + ); +} diff --git a/frontend/src/framework/internal/components/SessionOverviewDialog/snapshotOverviewContent.tsx b/frontend/src/framework/internal/components/SessionOverviewDialog/snapshotOverviewContent.tsx new file mode 100644 index 000000000..9427ca412 --- /dev/null +++ b/frontend/src/framework/internal/components/SessionOverviewDialog/snapshotOverviewContent.tsx @@ -0,0 +1,281 @@ +import React from "react"; + +import { DateRangePicker } from "@equinor/eds-core-react"; +import type { Options } from "@hey-api/client-axios"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { omit } from "lodash"; +import { toast } from "react-toastify"; + +import type { GetVisitedSnapshotsData_api, SnapshotAccessLog_api, SortDirection_api } from "@api"; +import { getVisitedSnapshotsInfiniteOptions, SnapshotAccessLogSortBy_api } from "@api"; +import { buildSnapshotUrl } from "@framework/internal/WorkbenchSession/SnapshotUrlService"; +import type { Workbench } from "@framework/Workbench"; +import { Input } from "@lib/components/Input"; +import { Label } from "@lib/components/Label"; +import { Table } from "@lib/components/Table"; +import type { TableColumns, TableSorting } from "@lib/components/Table/types"; +import { SortDirection as TableSortDirection } from "@lib/components/Table/types"; +import { formatDate } from "@lib/utils/dates"; + +import { UserAvatar } from "../UserAvatar"; + +import { edsRangeChoiceToFilterRange, type EdsFilterRange, type FilterRange } from "./_utils"; +import { + HEADER_HEIGHT, + NEXT_PAGE_THRESHOLD, + QUERY_PAGE_SIZE, + ROW_HEIGHT, + TABLE_HEIGHT, + USE_ALTERNATING_COLUMN_COLORS, +} from "./constants"; + +// The table comp doesn't support nested object key paths, so we transform the data into a flattened object +type FlattenedSnapshotAccessLog_api = Omit & { + [K in keyof SnapshotAccessLog_api["snapshotMetadata"] as `snapshotMetadata.${Extract}`]: SnapshotAccessLog_api["snapshotMetadata"][K]; +}; + +type TableFilter = { + title?: string; + visitedAt?: FilterRange; +}; + +const TABLE_COLUMNS: TableColumns = [ + { + _type: "data", + label: "Visits", + sizeInPercent: 7, + columnId: "visits", + sortable: false, // The sorting adornments require too much space, so the table looks off + filter: false, + formatStyle: () => ({ textAlign: "center", paddingRight: "0.5rem" }), + }, + { + _type: "data", + label: "Title", + sizeInPercent: 24, + columnId: "snapshotMetadata.title", + filter: false, + }, + { + _type: "data", + columnId: "snapshotMetadata.description", + label: "Description", + sizeInPercent: 26, + filter: false, + sortable: false, + renderData(value) { + return value || N/A; + }, + }, + { + // TODO: This too could be a "virtual" column + _type: "data", + columnId: "snapshotId", + label: "Url", + sortable: false, + filter: false, + sizeInPercent: 12, + renderData(snapshotId) { + const url = buildSnapshotUrl(snapshotId); + return ( + { + evt.preventDefault(); + navigator.clipboard.writeText(url); + toast.info("Url copied"); + }} + >{`/${snapshotId}`} + ); + }, + }, + { + _type: "data", + columnId: "snapshotMetadata.ownerId", + label: "Owner", + sortable: false, + filter: false, + sizeInPercent: 11, + renderData() { + // TODO: Need new backend oid + return ( + <> +
+ + anhun +
+ + ); + }, + }, + { + _type: "data", + label: "Last visited at", + sizeInPercent: 20, + columnId: "lastVisitedAt", + filter: false, + renderData(value) { + if (!value) return N/A; + return formatDate(new Date(value)); + }, + }, +]; + +function columnIdToApiSortField(columnId: string): SnapshotAccessLogSortBy_api { + switch (columnId as keyof FlattenedSnapshotAccessLog_api) { + case "visits": + return SnapshotAccessLogSortBy_api.VISITS; + case "snapshotMetadata.title": + return SnapshotAccessLogSortBy_api.SNAPSHOT_METADATA_TITLE; + case "lastVisitedAt": + return SnapshotAccessLogSortBy_api.LAST_VISITED_AT; + case "snapshotMetadata.createdAt": + return SnapshotAccessLogSortBy_api.SNAPSHOT_METADATA_CREATED_AT; + + default: + throw new Error(`Unknown columnId: ${columnId}`); + } +} + +function tableSortDirToApiSortDir(sort: TableSortDirection): SortDirection_api { + return sort as unknown as SortDirection_api; +} + +export function flattenSnapshotAccessLogEntry(logEntry: SnapshotAccessLog_api): FlattenedSnapshotAccessLog_api { + const ret = omit(logEntry, ["snapshotMetadata"]) as Record; + + Object.entries(logEntry.snapshotMetadata).forEach(([k, v]) => { + const flattenedKey = `snapshotMetadata.${k}`; + ret[flattenedKey] = v; + }); + + return ret as FlattenedSnapshotAccessLog_api; +} + +export type SnapshotOverviewContentProps = { + selectedSession: string | null; + workbench: Workbench; + editOpen: boolean; + onSelectSession: (sessionId: string | null) => void; + onEditClose: () => void; +}; + +export function SnapshotOverviewContent(props: SnapshotOverviewContentProps): React.ReactNode { + // ? Should this be opened via gui-events? + + const [visibleRowRange, setVisibleRowRange] = React.useState<{ start: number; end: number } | null>(null); + const [tableFilter, setTableFilter] = React.useState({}); + const [tableSortState, setTableSortState] = React.useState([ + { columnId: "lastVisitedAt", direction: TableSortDirection.DESC }, + ]); + + const querySortParams = React.useMemo["query"]>(() => { + if (!tableSortState?.length) return undefined; + + const sortBy = columnIdToApiSortField(tableSortState[0].columnId); + const SortDirection = tableSortDirToApiSortDir(tableSortState[0].direction); + + return { + sort_by: sortBy, + sort_direction: SortDirection, + filter_title: tableFilter.title, + filter_updated_from: tableFilter.visitedAt?.from, + filter_updated_to: tableFilter.visitedAt?.to, + }; + }, [tableFilter, tableSortState]); + + const sessionsQuery = useInfiniteQuery({ + ...getVisitedSnapshotsInfiniteOptions({ + query: { ...querySortParams, limit: QUERY_PAGE_SIZE }, + }), + // Tanstack requires initialPageParam. The correct option would be `null`, but that causes an + // undefined-error in `getVisitedSnapshotsInfiniteOptions(...)` since it thinks it's an object. + initialPageParam: "", + refetchInterval: 10000, + getNextPageParam(lastPage) { + return lastPage.continuation_token; + }, + }); + + function onFilterRangeChange(newRange: null | EdsFilterRange) { + setTableFilter((prev) => { + return { + ...prev, + visitedAt: edsRangeChoiceToFilterRange(newRange), + }; + }); + } + + function handleTitleFilterValueChange(newValue: string) { + setTableFilter((prev) => { + return { + ...prev, + title: newValue || undefined, + }; + }); + } + + const tableData = React.useMemo(() => { + if (!sessionsQuery.data) return []; + + return sessionsQuery.data?.pages?.flatMap(({ items }) => { + return items.map(flattenSnapshotAccessLogEntry); + }); + }, [sessionsQuery.data]); + + const onTableScrollIndexChange = React.useCallback((start: number, end: number) => { + setVisibleRowRange({ start, end }); + }, []); + + React.useEffect( + function maybeRefetchNextPageEffect() { + if (!visibleRowRange || visibleRowRange.end === -1) return; + if (!sessionsQuery.hasNextPage) return; + if (sessionsQuery.isFetchingNextPage) return; + if (tableData.length - visibleRowRange?.end <= NEXT_PAGE_THRESHOLD) { + sessionsQuery.fetchNextPage(); + } + }, + [sessionsQuery, tableData.length, visibleRowRange], + ); + + return ( + <> +
+ + + {/* TODO: Allow the user to filter on owner. Awaiting fixed picker comp, which I think is being done on the ensemble dialog */} + {/* */} + + +
+ +
props.onSelectSession(selection[0])} + onVisibleRowRangeChange={onTableScrollIndexChange} + /> + + ); +} diff --git a/frontend/src/framework/internal/components/StartPage/StartPage.tsx b/frontend/src/framework/internal/components/StartPage/StartPage.tsx new file mode 100644 index 000000000..a47ac939e --- /dev/null +++ b/frontend/src/framework/internal/components/StartPage/StartPage.tsx @@ -0,0 +1,100 @@ +import React from "react"; + +import { Icon, Tooltip, Typography } from "@equinor/eds-core-react"; +import { category, dashboard, folder_open, github, external_link } from "@equinor/eds-icons"; + +import type { Workbench } from "@framework/Workbench"; +import { Button } from "@lib/components/Button"; + +import { SessionOverviewDialog } from "../SessionOverviewDialog"; +import type { ModalContentMode } from "../SessionOverviewDialog/sessionOverviewDialog"; + +import { RecentSessions } from "./private-components/recentSessions"; +import { RecentSnapshots } from "./private-components/recentSnapshots"; + +Icon.add({ dashboard, category, folder_open, github, external_link }); + +export type StartPageProps = { + workbench: Workbench; +}; + +export function StartPage(props: StartPageProps) { + function handleNewSession() { + props.workbench.startNewSession(); + } + + const [showOverviewDialog, setShowOverviewDialog] = React.useState(false); + const [overviewContentMode, setOverviewContentMode] = React.useState("sessions"); + + function closeOverviewDialog() { + setShowOverviewDialog(false); + } + + function openOverviewDialogOnSessions() { + setShowOverviewDialog(true); + setOverviewContentMode("sessions"); + } + + function openOverviewDialogOnSnapshots() { + setShowOverviewDialog(true); + setOverviewContentMode("snapshots"); + } + + return ( +
+
+
+ Start + + + + + + + + + +
+ + + +
+ Resources + + + Webviz on GitHub + + +
+ + +
+ + +
+ ); +} 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/constants.ts b/frontend/src/framework/internal/components/StartPage/private-components/constants.ts new file mode 100644 index 000000000..282b2d24c --- /dev/null +++ b/frontend/src/framework/internal/components/StartPage/private-components/constants.ts @@ -0,0 +1 @@ +export const RECENT_CARDS_LIST_LENGTH = 5; 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..bf3ca51d2 --- /dev/null +++ b/frontend/src/framework/internal/components/StartPage/private-components/recentSessions.tsx @@ -0,0 +1,113 @@ +import React from "react"; + +import { Typography } from "@equinor/eds-core-react"; +import { Refresh } from "@mui/icons-material"; +import { useQuery } from "@tanstack/react-query"; + +import { getSessionsMetadataOptions, SessionSortBy_api, SortDirection_api } from "@api"; +import { buildSessionUrl } from "@framework/internal/WorkbenchSession/SessionUrlService"; +import type { Workbench } from "@framework/Workbench"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { IconButton } from "@lib/components/IconButton"; +import { timeAgo } from "@lib/utils/dates"; + +import { RECENT_CARDS_LIST_LENGTH } from "./constants"; +import { SessionCard } from "./sessionCard"; + +export type RecentSessionsProps = { + workbench: Workbench; + onOpenSessionDialog: () => void; +}; + +export function RecentSessions(props: RecentSessionsProps) { + const [state, setState] = React.useState["status"]>("pending"); + + function handleSessionClick(sessionId: string, evt: React.MouseEvent) { + evt.preventDefault(); + props.workbench.openSession(sessionId); + } + + const sessionsQuery = useQuery({ + ...getSessionsMetadataOptions({ + query: { + sort_by: SessionSortBy_api.METADATA_UPDATED_AT, + sort_direction: SortDirection_api.DESC, + limit: RECENT_CARDS_LIST_LENGTH, + }, + }), + refetchInterval: 10000, + }); + + const sessions = sessionsQuery.data?.items ?? []; + const hasMoreSessions = !!sessionsQuery.data?.continuation_token; + + 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 && sessions.length > 0) { + return ( +
    + {sessions.map((session) => ( + + ))} +
+ ); + } + + return
No recent sessions found.
; + } + + return ( +
+ + Recent sessions + sessionsQuery.refetch()}> + + + +
{makeContent()}
+ {hasMoreSessions && ( + + )} +
+ ); +} 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..f4265a9cc --- /dev/null +++ b/frontend/src/framework/internal/components/StartPage/private-components/recentSnapshots.tsx @@ -0,0 +1,112 @@ +import type React from "react"; + +import { Typography } from "@equinor/eds-core-react"; +import { Refresh } from "@mui/icons-material"; +import { useQuery } from "@tanstack/react-query"; + +import { + getVisitedSnapshotsOptions, + getVisitedSnapshotsQueryKey, + SnapshotAccessLogSortBy_api, + SortDirection_api, +} from "@api"; +import type { Workbench } from "@framework/Workbench"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { IconButton } from "@lib/components/IconButton"; +import { timeAgo } from "@lib/utils/dates"; + +import { RECENT_CARDS_LIST_LENGTH } from "./constants"; +import { SessionCard } from "./sessionCard"; + +export type RecentSnapshotsProps = { + workbench: Workbench; + onOpenSessionDialog: () => void; +}; + +export function RecentSnapshots(props: RecentSnapshotsProps): React.ReactNode { + const recentSnapshotsQuery = useQuery({ + ...getVisitedSnapshotsOptions({ + query: { + sort_by: SnapshotAccessLogSortBy_api.LAST_VISITED_AT, + sort_direction: SortDirection_api.DESC, + limit: RECENT_CARDS_LIST_LENGTH, + }, + }), + refetchInterval: 10000, + }); + + const snapshots = recentSnapshotsQuery.data?.items ?? []; + const hasMoreSnapshots = !!recentSnapshotsQuery.data?.continuation_token; + + async function handleSnapshotClick(snapshotId: string, e: React.MouseEvent) { + e.preventDefault(); + + // Load the selected snapshot + props.workbench.openSnapshot(snapshotId); + + // Reset query so that fresh snapshots are fetched when we return to the start page + props.workbench.getQueryClient().resetQueries({ queryKey: getVisitedSnapshotsQueryKey() }); + } + + function makeContent() { + if (recentSnapshotsQuery.isPending) { + return ( + + Loading recent snapshots... + + ); + } + + if (recentSnapshotsQuery.isError) { + return Could not fetch recent snapshots...; + } + + if (!snapshots.length) { + return No recently visited snapshots.; + } + + return ( +
    + {snapshots.map((snapshot) => ( + + ))} +
+ ); + } + + return ( +
+ + Recent snapshots + recentSnapshotsQuery.refetch()}> + + + + {makeContent()} + {hasMoreSnapshots && ( + + )} +
+ ); +} diff --git a/frontend/src/framework/internal/components/StartPage/private-components/sessionCard.tsx b/frontend/src/framework/internal/components/StartPage/private-components/sessionCard.tsx new file mode 100644 index 000000000..f0401e3a7 --- /dev/null +++ b/frontend/src/framework/internal/components/StartPage/private-components/sessionCard.tsx @@ -0,0 +1,111 @@ +import React from "react"; + +import { Tooltip } from "@equinor/eds-core-react"; +import { useQuery } from "@tanstack/react-query"; + +import type { GraphUser_api } from "@api"; +import { getUserInfoOptions } from "@api"; +import { useAuthProvider } from "@framework/internal/providers/AuthProvider"; +import { timeAgo } from "@lib/utils/dates"; + +import { UserAvatar } from "../../UserAvatar"; + +export type SessionCardProps = { + id: string; + title: string; + timestamp: string; + description: string | null; + href: string; + + ownerId?: string; + + tooltipInfo?: Record; + + onClick?: (id: string, evt: React.MouseEvent) => void; +}; + +export function SessionCard(props: SessionCardProps): React.ReactNode { + const showOwnerRow = props.ownerId; + + const ownerInfo = useUserGraphInfo(props.ownerId); + + const allTooltipInfo = React.useMemo(() => { + if (!ownerInfo) return props.tooltipInfo; + + return { + "Created by": ownerInfo?.principal_name, + ...props.tooltipInfo, + }; + }, [ownerInfo, props.tooltipInfo]); + + return ( +
  • + } + placement="right" + > + props.onClick?.(props.id, evt)} + > +
    + {props.title} + {showOwnerRow && } +
    + + ~ {timeAgo(Date.now() - new Date(props.timestamp).getTime())} + +
    +
    +
  • + ); +} + +function OwnerLine(props: { owner: GraphUser_api | null }): React.ReactNode { + const { userInfo: activeUserInfo } = useAuthProvider(); + + const name = props.owner?.principal_name?.split("@")?.[0].toLocaleLowerCase(); + const isSelf = activeUserInfo && props.owner?.id === activeUserInfo.user_id; + + return ( +
    + + {name} + {isSelf && (You)} +
    + ); +} + +function useUserGraphInfo(ownerId: string | undefined): GraphUser_api | null { + const userInfoQuery = useQuery({ + ...getUserInfoOptions({ path: { user_id_or_email: ownerId ?? "" } }), + enabled: Boolean(ownerId), + }); + + return userInfoQuery.data ?? null; +} + +// TODO: Show preview image here? +function TooltipContent( + props: { owner: GraphUser_api | null; tooltipInfo?: Record } & SessionCardProps, +): React.ReactNode { + return ( +
    +

    {props.title}

    + +
    + {props.description &&

    {props.description}

    } + + {props.tooltipInfo && ( +
      + {Object.entries(props.tooltipInfo).map(([k, v]) => ( +
    • + {k}: {v} +
    • + ))} +
    + )} +
    + ); +} diff --git a/frontend/src/framework/internal/components/TopBar/index.ts b/frontend/src/framework/internal/components/TopBar/index.ts new file mode 100644 index 000000000..d8e572ec8 --- /dev/null +++ b/frontend/src/framework/internal/components/TopBar/index.ts @@ -0,0 +1,2 @@ +export { TopBar } from "./topBar"; +export type { TopBarProps } from "./topBar"; diff --git a/frontend/src/framework/internal/components/TopBar/topBar.tsx b/frontend/src/framework/internal/components/TopBar/topBar.tsx new file mode 100644 index 000000000..2940595c8 --- /dev/null +++ b/frontend/src/framework/internal/components/TopBar/topBar.tsx @@ -0,0 +1,376 @@ +import React from "react"; + +import { Icon, Tooltip, Typography } from "@equinor/eds-core-react"; +import { category } from "@equinor/eds-icons"; +import { AddLink, Category, Close, Edit, Link, Lock, Refresh, Save } from "@mui/icons-material"; + +import FmuLogo from "@assets/fmu.svg"; + +import { GuiState, useGuiState, useGuiValue } from "@framework/GuiMessageBroker"; +import { PrivateWorkbenchSessionTopic } from "@framework/internal/WorkbenchSession/PrivateWorkbenchSession"; +import { WorkbenchSessionPersistenceServiceTopic } from "@framework/internal/WorkbenchSession/WorkbenchSessionPersistenceService"; +import { WorkbenchTopic, type Workbench } from "@framework/Workbench"; +import { Button } from "@lib/components/Button"; +import type { ButtonProps } from "@lib/components/Button/button"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; + +import { LoginButton } from "../LoginButton"; + +export type TopBarProps = { + workbench: Workbench; +}; + +Icon.add({ category }); + +export function TopBar(props: TopBarProps): React.ReactNode { + const hasActiveSession = usePublishSubscribeTopicValue(props.workbench, WorkbenchTopic.HAS_ACTIVE_SESSION); + + return ( + <> +
    + +
    + {hasActiveSession ? ( + <> + + + + ) : ( +
    + )} + + +
    +
    + + ); +} + +type TopBarButtonsProps = { + workbench: Workbench; +}; + +function TopBarButtons(props: TopBarButtonsProps): React.ReactNode { + const isSnapshot = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.IS_SNAPSHOT, + ); + + function handleCloseSessionClick() { + props.workbench.maybeCloseCurrentSession(); + } + + const closeButtonTitle = isSnapshot ? "Close snapshot" : "Close session"; + + return ( + <> + {isSnapshot ? ( + + ) : ( + <> + + + + + + + + )} + + + + + + + ); +} + +function LogoWithText(): React.ReactNode { + return ( +
    + FMU Analysis logo +

    FMU Analysis

    +
    + BETA +
    +
    + ); +} + +type EditSessionButtonProps = { + workbench: Workbench; +}; + +function EditSessionButton(props: EditSessionButtonProps): React.ReactNode { + const isPersisted = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.IS_PERSISTED, + ); + + const isSnapshot = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.IS_SNAPSHOT, + ); + + const [, setEditSessionDialogOpen] = useGuiState( + props.workbench.getGuiMessageBroker(), + GuiState.EditSessionDialogOpen, + ); + + function handleEditTitleClick() { + setEditSessionDialogOpen(true); + } + + if (isSnapshot || !isPersisted) { + return null; + } + + return ( + + + + ); +} + +type SessionTitleProps = { + workbench: Workbench; +}; + +function SessionTitle(props: SessionTitleProps): React.ReactNode { + const metadata = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.METADATA, + ); + + const isPersisted = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.IS_PERSISTED, + ); + + const isSnapshot = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.IS_SNAPSHOT, + ); + + function handleEditTitleClick() { + // props.workbench.editSession(); + } + + function handleCloseSessionClick() { + props.workbench.maybeCloseCurrentSession(); + } + + function makeContent() { + let content: React.ReactNode = null; + if (isSnapshot) { + content = ( + <> + + +
    {metadata.title}
    +

    + + {new Date(metadata.createdAt).toLocaleString()} + +

    +

    {metadata.description ?? "No description provided."}

    + + } + placement="bottom" + > + + {metadata.title} + +
    + + (snapshot) + + + + + + ); + } else { + content = ( + <> + + + {metadata.title} + + + ); + } + + return <>{content}; + } + + return
    {makeContent()}
    ; +} + +type SessionFromSnapshotButtonProps = { + workbench: Workbench; +}; + +function SessionFromSnapshotButton(props: SessionFromSnapshotButtonProps): React.ReactNode { + const handleClick = () => { + props.workbench.makeSessionFromSnapshot(); + }; + + return ( +
    + + Create new session from snapshot + +
    + ); +} + +type SnapshotButtonProps = { + workbench: Workbench; +}; + +function SnapshotButton(props: SnapshotButtonProps): React.ReactNode { + const [, setIsOpen] = useGuiState(props.workbench.getGuiMessageBroker(), GuiState.MakeSnapshotDialogOpen); + + const handleClick = () => { + setIsOpen(true); + }; + + return ( +
    + + + +
    + ); +} + +type SessionSaveButtonProps = { + workbench: Workbench; +}; + +function SessionSaveButton(props: SessionSaveButtonProps): React.ReactNode { + const persistenceInfo = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSessionPersistenceService(), + WorkbenchSessionPersistenceServiceTopic.PERSISTENCE_INFO, + ); + + const isPersisted = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSession(), + PrivateWorkbenchSessionTopic.IS_PERSISTED, + ); + + const isSaving = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.IsSavingSession); + + const handleSaveClick = () => { + props.workbench.saveCurrentSession(); + }; + + const enabled = persistenceInfo.hasChanges || !isPersisted; + + return ( +
    + {isSaving ? ( + + ) : ( + + + + )} +
    + ); +} + +type TopBarButtonProps = { + children?: React.ReactNode; + active?: boolean; + title: string; + onClick?: () => void; + disabled?: boolean; +} & ButtonProps; + +function TopBarButtonComponent(props: TopBarButtonProps, ref: React.ForwardedRef): React.ReactNode { + const { active, title, onClick, disabled, ...baseProps } = props; + return ( + + + + ); +} + +const TopBarButton = React.forwardRef(TopBarButtonComponent); + +type RefreshSessionButtonProps = { + workbench: Workbench; +}; + +function RefreshSessionButton(props: RefreshSessionButtonProps): React.ReactNode { + const persistenceInfo = usePublishSubscribeTopicValue( + props.workbench.getWorkbenchSessionPersistenceService(), + WorkbenchSessionPersistenceServiceTopic.PERSISTENCE_INFO, + ); + + function handleRefreshClick() { + props.workbench.maybeRefreshSession(); + } + + if ( + persistenceInfo.backendLastUpdatedMs === null || + persistenceInfo.backendLastUpdatedMs <= (persistenceInfo.lastPersistedMs ?? 0) + ) { + return null; + } + + return ( +
    + Out of sync with server. + + + +
    + ); +} + +function TopBarDivider(): React.ReactNode { + return
    ; +} diff --git a/frontend/src/framework/internal/components/UserAvatar/index.ts b/frontend/src/framework/internal/components/UserAvatar/index.ts new file mode 100644 index 000000000..f6eb37652 --- /dev/null +++ b/frontend/src/framework/internal/components/UserAvatar/index.ts @@ -0,0 +1,2 @@ +export type { UserAvatarProps } from "./userAvatar"; +export { UserAvatar } from "./userAvatar"; diff --git a/frontend/src/framework/internal/components/SelectEnsemblesDialog/private-components/userAvatar.tsx b/frontend/src/framework/internal/components/UserAvatar/userAvatar.tsx similarity index 51% rename from frontend/src/framework/internal/components/SelectEnsemblesDialog/private-components/userAvatar.tsx rename to frontend/src/framework/internal/components/UserAvatar/userAvatar.tsx index 007363778..cd77a5de2 100644 --- a/frontend/src/framework/internal/components/SelectEnsemblesDialog/private-components/userAvatar.tsx +++ b/frontend/src/framework/internal/components/UserAvatar/userAvatar.tsx @@ -6,17 +6,22 @@ import { useQuery } from "@tanstack/react-query"; import type { GraphUserPhoto_api } from "@api"; import { getUserPhotoOptions } from "@api"; +import type { SizeName } from "@framework/utils/getNamedSizeClass"; +import { getSizeClass } from "@framework/utils/getNamedSizeClass"; import { CircularProgress } from "@lib/components/CircularProgress"; +import { resolveClassNames } from "@lib/utils/resolveClassNames"; export type UserAvatarProps = { userEmail: string; + size?: SizeName; + className?: React.HTMLAttributes["className"]; }; function useUserInfoQuery(userEmail: string): UseQueryResult { return useQuery({ ...getUserPhotoOptions({ query: { - user_email: userEmail, + user_id_or_email: userEmail, }, }), enabled: userEmail !== "", @@ -25,24 +30,27 @@ function useUserInfoQuery(userEmail: string): UseQueryResult export const UserAvatar: React.FC = (props) => { const userInfo = useUserInfoQuery(props.userEmail); + const sizeOrDefault = props.size ?? "medium-small"; + const sizeClass = getSizeClass(sizeOrDefault); if (userInfo.isFetching) { - return ; + return ; } if (userInfo.data?.avatar_b64str) { return ( Avatar ); } return ( - - + + {/* size-auto ensures the icon follows the wrapper size-class */} + ); }; diff --git a/frontend/src/framework/internal/hooks/workbenchHooks.ts b/frontend/src/framework/internal/hooks/workbenchHooks.ts deleted file mode 100644 index 73473c474..000000000 --- a/frontend/src/framework/internal/hooks/workbenchHooks.ts +++ /dev/null @@ -1,41 +0,0 @@ -import React from "react"; - -import type { ModuleInstance } from "@framework/ModuleInstance"; -import type { LayoutElement, Workbench } from "@framework/Workbench"; -import { WorkbenchEvents } from "@framework/Workbench"; - -export function useModuleInstances(workbench: Workbench): ModuleInstance[] { - const [moduleInstances, setModuleInstances] = React.useState[]>([]); - - React.useEffect(() => { - function handleModuleInstancesChange() { - setModuleInstances(workbench.getModuleInstances()); - } - - const unsubscribeFunc = workbench.subscribe( - WorkbenchEvents.ModuleInstancesChanged, - handleModuleInstancesChange, - ); - - return unsubscribeFunc; - }, [workbench]); - - return moduleInstances; -} - -export function useModuleLayout(workbench: Workbench): LayoutElement[] { - // ? Shouldn't these hooks use React.syncExternalStore? - const [layout, setLayout] = React.useState([]); - - React.useEffect(() => { - function handleModuleInstancesChange() { - setLayout(workbench.getLayout()); - } - - const unsubscribeFunc = workbench.subscribe(WorkbenchEvents.LayoutChanged, handleModuleInstancesChange); - - return unsubscribeFunc; - }, [workbench]); - - return layout; -} diff --git a/frontend/src/framework/internal/utils/debug.ts b/frontend/src/framework/internal/utils/debug.ts index 18ae92f5a..102405ed3 100644 --- a/frontend/src/framework/internal/utils/debug.ts +++ b/frontend/src/framework/internal/utils/debug.ts @@ -12,6 +12,30 @@ export function debugFlagIsEnabled(flag: string) { return ["true", "1"].includes(storedFlag.toLowerCase()); } +/** + * Gets a stored debug setting value + * @param setting a debug setting key + * @returns the value of the debug setting, or null if not set + */ +export function getDebugSetting(setting: string): string | null { + const storedSetting = localStorage.getItem(DEBUG_FLAG_PREFIX + setting); + if (storedSetting === null) return null; + return storedSetting; +} + +/** + * Sets a debug setting value + * @param setting a debug setting key + * @param value the value to set, or null to remove the setting + */ +export function setDebugSetting(setting: string, value: string | null) { + if (value === null) { + localStorage.removeItem(DEBUG_FLAG_PREFIX + setting); + } else { + localStorage.setItem(DEBUG_FLAG_PREFIX + setting, value); + } +} + /** * Passes a given time, unless the `disable-tanstack-cache` debug flag is true * @param time time (ms) that the cache is valid for diff --git a/frontend/src/framework/test.ts b/frontend/src/framework/test.ts new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/framework/userCreatedItems/IntersectionPolylines.ts b/frontend/src/framework/userCreatedItems/IntersectionPolylines.ts index 1a4ece1a9..efb1954ed 100644 --- a/frontend/src/framework/userCreatedItems/IntersectionPolylines.ts +++ b/frontend/src/framework/userCreatedItems/IntersectionPolylines.ts @@ -3,7 +3,30 @@ import { cloneDeep } from "lodash"; import { v4 } from "uuid"; import type { AtomStoreMaster } from "@framework/AtomStoreMaster"; -import type { UserCreatedItemSet } from "@framework/UserCreatedItems"; +import type { JTDSchemaType } from "ajv/dist/core"; + +export type SerializedIntersectionPolylines = { + intersectionPolylines: IntersectionPolyline[]; +}; + +export const INTERSECTION_POLYLINES_JTD_SCHEMA: JTDSchemaType = { + properties: { + intersectionPolylines: { + elements: { + properties: { + id: { type: "string" }, + name: { type: "string" }, + path: { + elements: { + elements: { type: "float64" }, + }, + }, + fieldId: { type: "string" }, + }, + }, + }, + }, +} as const; export type IntersectionPolyline = { id: string; @@ -18,7 +41,7 @@ export enum IntersectionPolylinesEvent { CHANGE = "IntersectionPolylinesChange", } -export class IntersectionPolylines implements UserCreatedItemSet { +export class IntersectionPolylines { private _atomStoreMaster: AtomStoreMaster; private _polylines: IntersectionPolyline[] = []; private _subscribersMap: Map void>> = new Map(); @@ -27,12 +50,16 @@ export class IntersectionPolylines implements UserCreatedItemSet { this._atomStoreMaster = atomStoreMaster; } - serialize(): string { - return JSON.stringify(this._polylines); + serializeState(): SerializedIntersectionPolylines { + return { + intersectionPolylines: this._polylines, + }; } - populateFromData(data: string): void { - this._polylines = JSON.parse(data); + deserializeState(data: SerializedIntersectionPolylines): void { + this._polylines = data.intersectionPolylines.map((polyline) => ({ + ...polyline, + })); this.notifySubscribers(IntersectionPolylinesEvent.CHANGE); } diff --git a/frontend/src/framework/utils/ApiErrorHelper.ts b/frontend/src/framework/utils/ApiErrorHelper.ts index 324dac530..53687f943 100644 --- a/frontend/src/framework/utils/ApiErrorHelper.ts +++ b/frontend/src/framework/utils/ApiErrorHelper.ts @@ -73,9 +73,6 @@ export class ApiErrorHelper { if (!("type" in error) || !("message" in error)) { return; } - - this._type = JSON.stringify(error.type); - this._message = JSON.stringify(error.message); } hasError(): boolean { diff --git a/frontend/src/framework/utils/ensembleTimestampUtils.ts b/frontend/src/framework/utils/ensembleTimestampUtils.ts deleted file mode 100644 index 39f53fcc2..000000000 --- a/frontend/src/framework/utils/ensembleTimestampUtils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { EnsembleTimestamps_api } from "@api"; -import type { RegularEnsemble } from "@framework/RegularEnsemble"; - -/** - * Checks if the ensemble is outdated based on the provided timestamps. - * Note: if the ensemble doesn't have a timestamp, it's assumed to be outdated - * @param ensemble The ensemble to check - * @param timestamp The timestamps to compare against - * @returns {boolean} True if either timestamp is outdated, false otherwise - */ -export function isEnsembleOutdated(ensemble: RegularEnsemble, timestamp: EnsembleTimestamps_api) { - const currentTimestamp = ensemble.getTimestamps(); - if (!currentTimestamp) { - return true; - } - - const { caseUpdatedAtUtcMs, dataUpdatedAtUtcMs } = currentTimestamp; - return timestamp.caseUpdatedAtUtcMs > caseUpdatedAtUtcMs || timestamp.dataUpdatedAtUtcMs > dataUpdatedAtUtcMs; -} diff --git a/frontend/src/framework/utils/getNamedSizeClass.ts b/frontend/src/framework/utils/getNamedSizeClass.ts new file mode 100644 index 000000000..fab5be6b5 --- /dev/null +++ b/frontend/src/framework/utils/getNamedSizeClass.ts @@ -0,0 +1,16 @@ +export type SizeName = "extra-small" | "small" | "medium-small" | "medium" | "large"; + +export function getSizeClass(size: SizeName) { + switch (size) { + case "extra-small": + return "size-3"; + case "small": + return "size-4"; + case "medium-small": + return "size-5"; + case "medium": + return "size-8"; + case "large": + return "size-12"; + } +} diff --git a/frontend/src/framework/utils/queryUtils.ts b/frontend/src/framework/utils/queryUtils.ts new file mode 100644 index 000000000..845cba658 --- /dev/null +++ b/frontend/src/framework/utils/queryUtils.ts @@ -0,0 +1,15 @@ +import { EnsembleTimestampsStore } from "@framework/EnsembleTimestampsStore"; +import type { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; + +export function makeTimestampQueryParam(...ensembleIdents: RegularEnsembleIdent[]): { t?: number } { + // If no ensembles are provided, return an empty object + if (ensembleIdents.length === 0) { + return {}; + } + + // Get the ensemble timestamps from the EnsembleTimestampsStore + const ensembleTimestamps = EnsembleTimestampsStore.getLatestTimestamps(...ensembleIdents); + + // Return the maximum timestamp as a query parameter + return { t: Math.max(ensembleTimestamps.dataUpdatedAtUtcMs, ensembleTimestamps.caseUpdatedAtUtcMs) }; +} diff --git a/frontend/src/framework/utils/reactQuery.ts b/frontend/src/framework/utils/reactQuery.ts new file mode 100644 index 000000000..3df829b22 --- /dev/null +++ b/frontend/src/framework/utils/reactQuery.ts @@ -0,0 +1,91 @@ +import type { Options } from "@hey-api/client-axios"; +import type { QueryFilters, QueryKey } from "@tanstack/query-core"; +import { isEqual } from "lodash"; + +// ! copy of hey-api's internal generated key type +type HeyApiQueryKey = [ + Pick & { + _id: string; + _infinite?: boolean; + }, +]; + +export enum FilterLevel { + /** + * Match only by the _id (and _infinite boolean) + */ + ID = 1, + + /** + * Match by _id and path + */ + PATH = 2, + + /** + * Match by _id, path, and headers + */ + HEADER = 3, + + /** + * Match by _id, path, headers, and query/body parameters + */ + FULL = 4, +} + +export function makeTanstackQueryFilters( + // ! For some strange reason, hey-api's key generators does NOT return their keys as constant arrays... + // ! To avoid typing issues for implementers, we need to accept key arrays of any length + queryKeys: HeyApiQueryKey[0][][], + level: FilterLevel = FilterLevel.ID, +): QueryFilters { + // Technically, this should always validate + validateQueryKeysParam(queryKeys); + + return { + predicate(query) { + const queryKey = query.queryKey; + + if (!isHeyApiQueryKey(queryKey)) return false; + + return queryKeys.some((key) => compareKeys(key, queryKey, level)); + }, + }; +} + +function isHeyApiQueryKey(key: QueryKey): key is HeyApiQueryKey { + if (key.length !== 1) return false; + if (!key[0]) return false; + if (typeof key[0] !== "object") return false; + + return "_id" in key[0] && typeof key[0]._id === "string"; +} + +function compareKeys( + firstKey: HeyApiQueryKey, + secondKey: HeyApiQueryKey, + comparisonLevel: FilterLevel, +): boolean { + if (firstKey[0]._id !== secondKey[0]._id) return false; + if (!!firstKey[0]._infinite !== !!secondKey[0]._infinite) return false; + + if (comparisonLevel < FilterLevel.PATH) return true; + if (!isEqual(firstKey[0].path, secondKey[0].path)) return false; + + if (comparisonLevel < FilterLevel.HEADER) return true; + if (!isEqual(firstKey[0].headers, secondKey[0].headers)) return false; + + if (comparisonLevel < FilterLevel.FULL) return true; + if (!isEqual(firstKey[0].query, secondKey[0].query)) return false; + if (!isEqual(firstKey[0].body, secondKey[0].body)) return false; + + return true; +} + +function validateQueryKeysParam( + queryKeys: HeyApiQueryKey[0][][], +): asserts queryKeys is HeyApiQueryKey[] { + // We only care to check the first one + if (queryKeys[0].length !== 1) { + throw Error(`Expected hey-api query key of length 1, instead got ${queryKeys[0].length}`); + } +} diff --git a/frontend/src/lib/components/Button/button.tsx b/frontend/src/lib/components/Button/button.tsx index 76d096d4b..27c46b13b 100644 --- a/frontend/src/lib/components/Button/button.tsx +++ b/frontend/src/lib/components/Button/button.tsx @@ -57,7 +57,7 @@ function ButtonComponent(props: ButtonProps, ref: React.ForwardedRef = { }; export const CircularProgress = withDefaults()(defaultProps, (props) => { + const sizeClass = getSizeClass(props.size!); + return ( -
    +
    -

    {props.title}

    + {typeof props.title === "string" ? ( +

    {props.title}

    + ) : ( + props.title + )} {props.showCloseCross && (
    void; }; -function InputComponent(props: InputProps, ref: React.ForwardedRef) { +function InputComponent(props: InputProps, ref: React.ForwardedRef): React.ReactNode { const { startAdornment, endAdornment, diff --git a/frontend/src/lib/components/MenuText/index.ts b/frontend/src/lib/components/MenuText/index.ts new file mode 100644 index 000000000..30bd22a7b --- /dev/null +++ b/frontend/src/lib/components/MenuText/index.ts @@ -0,0 +1,2 @@ +export { MenuText as MenuHeading } from "./menuText"; +export type { MenuTextProps as MenuHeadingProps } from "./menuText"; diff --git a/frontend/src/lib/components/MenuText/menuText.tsx b/frontend/src/lib/components/MenuText/menuText.tsx new file mode 100644 index 000000000..e29d9750b --- /dev/null +++ b/frontend/src/lib/components/MenuText/menuText.tsx @@ -0,0 +1,18 @@ +import { resolveClassNames } from "@lib/utils/resolveClassNames"; + +export type MenuTextProps = { + classNames?: string; + style?: React.CSSProperties; + children: React.ReactNode; +}; + +export function MenuText(props: MenuTextProps): React.ReactNode { + return ( +
    + {props.children} +
    + ); +} diff --git a/frontend/src/lib/components/NavBarComponents/navBarButton.tsx b/frontend/src/lib/components/NavBarComponents/navBarButton.tsx index ae9814ab9..9c752aad0 100644 --- a/frontend/src/lib/components/NavBarComponents/navBarButton.tsx +++ b/frontend/src/lib/components/NavBarComponents/navBarButton.tsx @@ -1,5 +1,7 @@ import React from "react"; +import { Tooltip } from "@equinor/eds-core-react"; + import { resolveClassNames } from "@lib/utils/resolveClassNames"; import { Button } from "../Button"; @@ -14,29 +16,25 @@ export type NavBarButtonProps = { * An alternate icon to use when the button is in it's "active" state */ activeIcon?: React.ReactNode; - /** - * Collapses the button (rendering only the icon) - */ - collapsed?: boolean; /** * Renders the button in it's active state */ active?: boolean; - /** - * Text shown next to the button - */ - text?: string; /** * Tooltip text */ title?: string; + /** + * Tooltip text when disabled + */ + disabledTitle?: string; }; function NavBarButtonComponent( props: NavBarButtonProps & ButtonProps, ref: React.ForwardedRef, ): React.ReactNode { - const { icon, activeIcon, text, collapsed, active, ...baseProps } = props; + const { icon, activeIcon, active, disabledTitle, ...baseProps } = props; let buttonIcon: React.ReactNode; @@ -44,14 +42,21 @@ function NavBarButtonComponent( else buttonIcon = icon; return ( - + + {/* Using a span to ensure the tooltip has a child with enabled pointer-events */} + + + + ); } diff --git a/frontend/src/lib/utils/UnsubscribeFunctionsManagerDelegate.ts b/frontend/src/lib/utils/UnsubscribeFunctionsManagerDelegate.ts new file mode 100644 index 000000000..49a603714 --- /dev/null +++ b/frontend/src/lib/utils/UnsubscribeFunctionsManagerDelegate.ts @@ -0,0 +1,36 @@ +/* + * This class is used to manage unsubscribe functions. + * + * It provides a method for registering unsubscribe functions for a given + * category and two methods for unsubscribing from a given category or from all topics, respectively. + */ +export class UnsubscribeFunctionsManagerDelegate { + private _categoryToUnsubscribeFunctionsMap: Map void>> = new Map(); + + registerUnsubscribeFunction(topic: string, callback: () => void): void { + let subscriptionsSet = this._categoryToUnsubscribeFunctionsMap.get(topic); + if (!subscriptionsSet) { + subscriptionsSet = new Set(); + this._categoryToUnsubscribeFunctionsMap.set(topic, subscriptionsSet); + } + subscriptionsSet.add(callback); + } + + unsubscribe(topic: string): void { + const subscriptionsSet = this._categoryToUnsubscribeFunctionsMap.get(topic); + if (subscriptionsSet) { + for (const unsubscribeFunc of subscriptionsSet) { + unsubscribeFunc(); + } + this._categoryToUnsubscribeFunctionsMap.delete(topic); + } + } + + unsubscribeAll(): void { + for (const subscriptionsSet of this._categoryToUnsubscribeFunctionsMap.values()) { + for (const unsubscribeFunc of subscriptionsSet) { + unsubscribeFunc(); + } + } + } +} diff --git a/frontend/src/lib/utils/dates.ts b/frontend/src/lib/utils/dates.ts new file mode 100644 index 000000000..1e0449158 --- /dev/null +++ b/frontend/src/lib/utils/dates.ts @@ -0,0 +1,38 @@ +export function timeAgo(msDiff: number): string { + const seconds = Math.floor(msDiff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const weeks = Math.floor(days / 7); + const months = Math.floor(days / 30); + const years = Math.floor(days / 365); + + if (seconds < 5) return "just now"; + if (seconds < 60) return `${seconds} seconds ago`; + if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; + if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; + if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`; + if (weeks < 5) return `${weeks} week${weeks === 1 ? "" : "s"} ago`; + if (months < 12) return `${months} month${months === 1 ? "" : "s"} ago`; + return `${years} year${years === 1 ? "" : "s"} ago`; +} + +/** + * Format a timestamp with day, month, and year. + * @param timestamp - Date object or timestamp in milliseconds + * @returns Formatted date string like "24 Dec 2023" + */ +export function formatDate(timestamp: Date | number, formatOptions?: Intl.DateTimeFormatOptions): string { + const date = timestamp instanceof Date ? timestamp : new Date(timestamp); + + const options: Intl.DateTimeFormatOptions = formatOptions ?? { + // weekday: "long", + year: "numeric", + month: "2-digit", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }; + + return date.toLocaleDateString(undefined, options); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 1abcb511d..d10182fae 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,6 +8,7 @@ import { CustomQueryClientProvider } from "@framework/internal/providers/QueryCl import App from "./App"; import { GlobalErrorBoundary } from "./GlobalErrorBoundary"; +import { ToastContainer } from "react-toastify"; /* If the `cleanStart` query parameter is given, @@ -50,9 +51,12 @@ root.render( - + <> + + + - + , ); diff --git a/frontend/src/modules/2DViewer/loadModule.tsx b/frontend/src/modules/2DViewer/loadModule.tsx index 605d777fe..b087b374d 100644 --- a/frontend/src/modules/2DViewer/loadModule.tsx +++ b/frontend/src/modules/2DViewer/loadModule.tsx @@ -5,8 +5,9 @@ import { settingsToViewInterfaceInitialization } from "./interfaces"; import { MODULE_NAME } from "./registerModule"; import { Settings } from "./settings/settings"; import { View } from "./view/view"; +import type { SerializedState } from "./persistedState"; -const module = ModuleRegistry.initModule(MODULE_NAME, { +const module = ModuleRegistry.initModule(MODULE_NAME, { settingsToViewInterfaceInitialization, }); diff --git a/frontend/src/modules/2DViewer/persistedState.ts b/frontend/src/modules/2DViewer/persistedState.ts new file mode 100644 index 000000000..d0ddfb35b --- /dev/null +++ b/frontend/src/modules/2DViewer/persistedState.ts @@ -0,0 +1,12 @@ +export const SERIALIZED_STATE = { + settings: { + properties: { + dataProviderData: { + type: "string", + }, + }, + }, + view: {}, +} as const; + +export type SerializedState = typeof SERIALIZED_STATE; diff --git a/frontend/src/modules/2DViewer/registerModule.ts b/frontend/src/modules/2DViewer/registerModule.ts index b0aee07d8..8b3490f73 100644 --- a/frontend/src/modules/2DViewer/registerModule.ts +++ b/frontend/src/modules/2DViewer/registerModule.ts @@ -3,11 +3,12 @@ import { ModuleDataTagId } from "@framework/ModuleDataTags"; import { ModuleRegistry } from "@framework/ModuleRegistry"; import type { Interfaces } from "./interfaces"; +import { SERIALIZED_STATE, type SerializedState } from "./persistedState"; import { preview } from "./preview"; export const MODULE_NAME: string = "2DViewer"; -ModuleRegistry.registerModule({ +ModuleRegistry.registerModule({ moduleName: MODULE_NAME, category: ModuleCategory.MAIN, devState: ModuleDevState.DEV, @@ -24,4 +25,5 @@ ModuleRegistry.registerModule({ onInstanceUnload: (instanceId) => { window.localStorage.removeItem(`${instanceId}-settings`); }, + serializedStateSchema: SERIALIZED_STATE, }); diff --git a/frontend/src/modules/2DViewer/settings/components/dataProviderManagerWrapper.tsx b/frontend/src/modules/2DViewer/settings/components/dataProviderManagerWrapper.tsx index 866e44da8..b5123d506 100644 --- a/frontend/src/modules/2DViewer/settings/components/dataProviderManagerWrapper.tsx +++ b/frontend/src/modules/2DViewer/settings/components/dataProviderManagerWrapper.tsx @@ -14,7 +14,7 @@ import { import { useAtom } from "jotai"; import type { WorkbenchSession } from "@framework/WorkbenchSession"; -import type { WorkbenchSettings } from "@framework/WorkbenchSettings"; +import { useColorSet, type WorkbenchSettings } from "@framework/WorkbenchSettings"; import { Menu } from "@lib/components/Menu"; import { MenuButton } from "@lib/components/MenuButton"; import { MenuHeading } from "@lib/components/MenuHeading"; @@ -52,7 +52,7 @@ export type LayerManagerComponentWrapperProps = { }; export function DataProviderManagerWrapper(props: LayerManagerComponentWrapperProps): React.ReactNode { - const colorSet = props.workbenchSettings.useColorSet(); + const colorSet = useColorSet(props.workbenchSettings); const [preferredViewLayout, setPreferredViewLayout] = useAtom(preferredViewLayoutAtom); const groupDelegate = props.dataProviderManager.getGroupDelegate(); diff --git a/frontend/src/modules/2DViewer/settings/settings.tsx b/frontend/src/modules/2DViewer/settings/settings.tsx index 16c0ccfca..d6d23640f 100644 --- a/frontend/src/modules/2DViewer/settings/settings.tsx +++ b/frontend/src/modules/2DViewer/settings/settings.tsx @@ -5,21 +5,23 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { FieldDropdown } from "@framework/components/FieldDropdown"; import type { ModuleSettingsProps } from "@framework/Module"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSessionTopic } from "@framework/WorkbenchSession"; import { CollapsibleGroup } from "@lib/components/CollapsibleGroup"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { GroupDelegateTopic } from "@modules/_shared/DataProviderFramework/delegates/GroupDelegate"; import { DataProviderManager, DataProviderManagerTopic, } from "../../_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager"; +import type { SerializedState } from "../persistedState"; import { dataProviderManagerAtom, preferredViewLayoutAtom, userSelectedFieldIdentifierAtom } from "./atoms/baseAtoms"; import { selectedFieldIdentifierAtom } from "./atoms/derivedAtoms"; import { DataProviderManagerWrapper } from "./components/dataProviderManagerWrapper"; -export function Settings(props: ModuleSettingsProps): React.ReactNode { - const ensembleSet = useEnsembleSet(props.workbenchSession); +export function Settings(props: ModuleSettingsProps): React.ReactNode { + const ensembleSet = usePublishSubscribeTopicValue(props.workbenchSession, WorkbenchSessionTopic.EnsembleSet); const queryClient = useQueryClient(); const [dataProviderManager, setDataProviderManager] = useAtom(dataProviderManagerAtom); @@ -39,19 +41,27 @@ export function Settings(props: ModuleSettingsProps): React.ReactNode { fieldIdentifier, preferredViewLayout, }; + /* window.localStorage.setItem( `${props.settingsContext.getInstanceIdString()}-settings`, JSON.stringify(serializedState), ); + */ + props.persistence.serializeState({ + dataProviderData: JSON.stringify(serializedState), + }); }, - [dataProviderManager, fieldIdentifier, preferredViewLayout, props.settingsContext], + [dataProviderManager, fieldIdentifier, preferredViewLayout, props.persistence], ); const applyPersistedState = React.useCallback( function applyPersistedState(layerManager: DataProviderManager) { - const serializedState = window.localStorage.getItem( + /*const serializedState = window.localStorage.getItem( `${props.settingsContext.getInstanceIdString()}-settings`, - ); + );*/ + + const serializedState = props.persistence.serializedState?.dataProviderData; + if (!serializedState) { return; } @@ -72,7 +82,7 @@ export function Settings(props: ModuleSettingsProps): React.ReactNode { layerManager.deserializeState(parsedState.layerManager); } }, - [setFieldIdentifier, setPreferredViewLayout, props.settingsContext], + [setFieldIdentifier, setPreferredViewLayout, props.persistence], ); React.useEffect( diff --git a/frontend/src/modules/3DViewer/settings/settings.tsx b/frontend/src/modules/3DViewer/settings/settings.tsx index 1b1f4ecfe..d83b4ff6f 100644 --- a/frontend/src/modules/3DViewer/settings/settings.tsx +++ b/frontend/src/modules/3DViewer/settings/settings.tsx @@ -16,7 +16,7 @@ import type { Intersection } from "@framework/types/intersection"; import { IntersectionType } from "@framework/types/intersection"; import { useIntersectionPolylines } from "@framework/UserCreatedItems"; import type { IntersectionPolyline } from "@framework/userCreatedItems/IntersectionPolylines"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSessionTopic } from "@framework/WorkbenchSession"; import { CollapsibleGroup } from "@lib/components/CollapsibleGroup"; import { Dropdown } from "@lib/components/Dropdown"; import { Input } from "@lib/components/Input"; @@ -28,6 +28,7 @@ import { Select } from "@lib/components/Select"; import { Switch } from "@lib/components/Switch"; import type { TableSelectOption } from "@lib/components/TableSelect"; import { TableSelect } from "@lib/components/TableSelect"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; import { usePropagateApiErrorToStatusWriter } from "@modules/_shared/hooks/usePropagateApiErrorToStatusWriter"; import { isoIntervalStringToDateLabel, isoStringToDateLabel } from "@modules/_shared/utils/isoDatetimeStringFormatting"; @@ -71,7 +72,7 @@ import { GridCellIndexFilter } from "./components/gridCellIndexFilter"; import { WellboreSelector } from "./components/wellboreSelector"; export function Settings(props: ModuleSettingsProps): JSX.Element { - const ensembleSet = useEnsembleSet(props.workbenchSession); + const ensembleSet = usePublishSubscribeTopicValue(props.workbenchSession, WorkbenchSessionTopic.EnsembleSet); const statusWriter = useSettingsStatusWriter(props.settingsContext); const [showGridLines, setShowGridLines] = useAtom(showGridlinesAtom); diff --git a/frontend/src/modules/3DViewer/view/components/HoverUpdateWrapper.tsx b/frontend/src/modules/3DViewer/view/components/HoverUpdateWrapper.tsx index 00c927b42..99fcc6b4f 100644 --- a/frontend/src/modules/3DViewer/view/components/HoverUpdateWrapper.tsx +++ b/frontend/src/modules/3DViewer/view/components/HoverUpdateWrapper.tsx @@ -8,7 +8,6 @@ import type { ViewContext } from "@framework/ModuleContext"; import type { GlobalTopicDefinitions, WorkbenchServices } from "@framework/WorkbenchServices"; import { useSubscribedValue } from "@framework/WorkbenchServices"; - import type { SubsurfaceViewerWrapperProps } from "./SubsurfaceViewerWrapper"; import { SubsurfaceViewerWrapper } from "./SubsurfaceViewerWrapper"; @@ -16,7 +15,7 @@ export type HoverUpdateWrapperProps = { wellboreUuid: string | null; intersectionReferenceSystem?: IntersectionReferenceSystem; workbenchServices: WorkbenchServices; - viewContext: ViewContext; + viewContext: ViewContext; } & SubsurfaceViewerWrapperProps; export function HoverUpdateWrapper(props: HoverUpdateWrapperProps): React.ReactNode { diff --git a/frontend/src/modules/3DViewer/view/components/SyncedSettingsUpdateWrapper.tsx b/frontend/src/modules/3DViewer/view/components/SyncedSettingsUpdateWrapper.tsx index 992db2224..cabccbeaa 100644 --- a/frontend/src/modules/3DViewer/view/components/SyncedSettingsUpdateWrapper.tsx +++ b/frontend/src/modules/3DViewer/view/components/SyncedSettingsUpdateWrapper.tsx @@ -9,7 +9,7 @@ import { HoverUpdateWrapper } from "./HoverUpdateWrapper"; export type SyncedSettingsUpdateWrapperProps = { workbenchServices: WorkbenchServices; - viewContext: ViewContext; + viewContext: ViewContext; } & HoverUpdateWrapperProps; export function SyncedSettingsUpdateWrapper(props: SyncedSettingsUpdateWrapperProps): React.ReactNode { diff --git a/frontend/src/modules/3DViewer/view/view.tsx b/frontend/src/modules/3DViewer/view/view.tsx index 2572cfbea..ed67c473f 100644 --- a/frontend/src/modules/3DViewer/view/view.tsx +++ b/frontend/src/modules/3DViewer/view/view.tsx @@ -15,8 +15,10 @@ import type { IntersectionPolyline, IntersectionPolylineWithoutId, } from "@framework/userCreatedItems/IntersectionPolylines"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSessionTopic } from "@framework/WorkbenchSession"; +import { useContinuousColorScale } from "@framework/WorkbenchSettings"; import { ColorScaleGradientType } from "@lib/utils/ColorScale"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { usePropagateApiErrorToStatusWriter } from "@modules/_shared/hooks/usePropagateApiErrorToStatusWriter"; import { ColorScaleWithName } from "@modules/_shared/utils/ColorScaleWithName"; import { calcExtendedSimplifiedWellboreTrajectoryInXYPlane } from "@modules/_shared/utils/wellbore"; @@ -38,7 +40,7 @@ export function View(props: ModuleViewProps): React.ReactNode { const syncHelper = new SyncSettingsHelper(syncedSettingKeys, props.workbenchServices); let colorScale = props.viewContext.useSettingsToViewInterfaceValue("colorScale"); - const defaultColorScale = props.workbenchSettings.useContinuousColorScale({ + const defaultColorScale = useContinuousColorScale(props.workbenchSettings, { gradientType: ColorScaleGradientType.Sequential, }); if (!colorScale) { @@ -68,7 +70,7 @@ export function View(props: ModuleViewProps): React.ReactNode { const intersectionType = props.viewContext.useSettingsToViewInterfaceValue("intersectionType"); const setIntersectionType = useSetAtom(intersectionTypeAtom); - const ensembleSet = useEnsembleSet(props.workbenchSession); + const ensembleSet = usePublishSubscribeTopicValue(props.workbenchSession, WorkbenchSessionTopic.EnsembleSet); const fieldId = ensembleIdent ? (ensembleSet.getEnsemble(ensembleIdent)?.getFieldIdentifier() ?? null) : null; diff --git a/frontend/src/modules/DbgWorkbenchSpy/implementation.tsx b/frontend/src/modules/DbgWorkbenchSpy/implementation.tsx index 4d3e42bf6..8e65d4fc5 100644 --- a/frontend/src/modules/DbgWorkbenchSpy/implementation.tsx +++ b/frontend/src/modules/DbgWorkbenchSpy/implementation.tsx @@ -6,9 +6,9 @@ import type { EnsembleSet } from "@framework/EnsembleSet"; import type { ModuleViewProps } from "@framework/Module"; import { timestampUtcMsToIsoString } from "@framework/utils/timestampUtils"; import type { AllTopicDefinitions, WorkbenchServices } from "@framework/WorkbenchServices"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSessionTopic } from "@framework/WorkbenchSession"; import { Button } from "@lib/components/Button"; - +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import type { Interfaces } from "./interfaces"; @@ -26,7 +26,7 @@ export function WorkbenchSpySettings() { //----------------------------------------------------------------------------------------------------------- export function WorkbenchSpyView(props: ModuleViewProps) { - const ensembleSet = useEnsembleSet(props.workbenchSession); + const ensembleSet = usePublishSubscribeTopicValue(props.workbenchSession, WorkbenchSessionTopic.EnsembleSet); const [hoverRealization, hoverRealization_TS] = useServiceValueWithTS( "global.hoverRealization", props.workbenchServices, diff --git a/frontend/src/modules/DistributionPlot/view.tsx b/frontend/src/modules/DistributionPlot/view.tsx index 2fb6d814e..8cfc66ff8 100644 --- a/frontend/src/modules/DistributionPlot/view.tsx +++ b/frontend/src/modules/DistributionPlot/view.tsx @@ -7,6 +7,7 @@ import type { ChannelReceiverChannelContent } from "@framework/DataChannelTypes" import { KeyKind } from "@framework/DataChannelTypes"; import type { ModuleViewProps } from "@framework/Module"; import { useViewStatusWriter } from "@framework/StatusWriter"; +import { useColorSet, useContinuousColorScale } from "@framework/WorkbenchSettings"; import { Tag } from "@lib/components/Tag"; import { useElementSize } from "@lib/hooks/useElementSize"; import { ColorScaleGradientType } from "@lib/utils/ColorScale"; @@ -53,8 +54,8 @@ export const View = ({ viewContext, workbenchSettings }: ModuleViewProps) { - const ensembleSet = useEnsembleSet(workbenchSession); + const ensembleSet = usePublishSubscribeTopicValue(workbenchSession, WorkbenchSessionTopic.EnsembleSet); const statusWriter = useSettingsStatusWriter(settingsContext); const availableDateTimes = useAtomValue(availableDateTimesAtom); diff --git a/frontend/src/modules/InplaceVolumesPlot/settings/settings.tsx b/frontend/src/modules/InplaceVolumesPlot/settings/settings.tsx index 78befe0d8..014397a28 100644 --- a/frontend/src/modules/InplaceVolumesPlot/settings/settings.tsx +++ b/frontend/src/modules/InplaceVolumesPlot/settings/settings.tsx @@ -5,7 +5,6 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useApplyInitialSettingsToState } from "@framework/InitialSettings"; import type { ModuleSettingsProps } from "@framework/Module"; import type { InplaceVolumesFilterSettings } from "@framework/types/inplaceVolumesFilterSettings"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; import { CollapsibleGroup } from "@lib/components/CollapsibleGroup"; import type { DropdownOption } from "@lib/components/Dropdown"; import { Dropdown } from "@lib/components/Dropdown"; @@ -42,9 +41,11 @@ import { } from "./atoms/derivedAtoms"; import { tableDefinitionsQueryAtom } from "./atoms/queryAtoms"; import { makeColorByOptions, makeSubplotByOptions } from "./utils/plotDimensionUtils"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; +import { WorkbenchSessionTopic } from "@framework/WorkbenchSession"; export function Settings(props: ModuleSettingsProps): React.ReactNode { - const ensembleSet = useEnsembleSet(props.workbenchSession); + const ensembleSet = usePublishSubscribeTopicValue(props.workbenchSession, WorkbenchSessionTopic.EnsembleSet); const tableDefinitionsQueryResult = useAtomValue(tableDefinitionsQueryAtom); const tableDefinitionsAccessor = useAtomValue(tableDefinitionsAccessorAtom); diff --git a/frontend/src/modules/InplaceVolumesPlot/view/view.tsx b/frontend/src/modules/InplaceVolumesPlot/view/view.tsx index 81af2bde5..04b8886be 100644 --- a/frontend/src/modules/InplaceVolumesPlot/view/view.tsx +++ b/frontend/src/modules/InplaceVolumesPlot/view/view.tsx @@ -5,9 +5,11 @@ import { useAtomValue } from "jotai"; import type { ModuleViewProps } from "@framework/Module"; import { useViewStatusWriter } from "@framework/StatusWriter"; import { useSubscribedValue } from "@framework/WorkbenchServices"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSessionTopic } from "@framework/WorkbenchSession"; +import { useColorSet } from "@framework/WorkbenchSettings"; import { PendingWrapper } from "@lib/components/PendingWrapper"; import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import type { Interfaces } from "../interfaces"; @@ -19,9 +21,9 @@ import { useBuildPlotAndTable } from "./hooks/usePlotBuilder"; import { usePublishToDataChannels } from "./hooks/usePublishToDataChannels"; export function View(props: ModuleViewProps): React.ReactNode { - const ensembleSet = useEnsembleSet(props.workbenchSession); + const ensembleSet = usePublishSubscribeTopicValue(props.workbenchSession, WorkbenchSessionTopic.EnsembleSet); const statusWriter = useViewStatusWriter(props.viewContext); - const colorSet = props.workbenchSettings.useColorSet(); + const colorSet = useColorSet(props.workbenchSettings); const hoveredRegion = useSubscribedValue("global.hoverRegion", props.workbenchServices); const hoveredZone = useSubscribedValue("global.hoverZone", props.workbenchServices); diff --git a/frontend/src/modules/InplaceVolumesTable/settings/settings.tsx b/frontend/src/modules/InplaceVolumesTable/settings/settings.tsx index 5f460a357..a154823b7 100644 --- a/frontend/src/modules/InplaceVolumesTable/settings/settings.tsx +++ b/frontend/src/modules/InplaceVolumesTable/settings/settings.tsx @@ -6,7 +6,7 @@ import { InplaceVolumesStatistic_api } from "@api"; import { useApplyInitialSettingsToState } from "@framework/InitialSettings"; import type { ModuleSettingsProps } from "@framework/Module"; import type { InplaceVolumesFilterSettings } from "@framework/types/inplaceVolumesFilterSettings"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSessionTopic } from "@framework/WorkbenchSession"; import { CollapsibleGroup } from "@lib/components/CollapsibleGroup"; import { Dropdown } from "@lib/components/Dropdown"; import { Label } from "@lib/components/Label"; @@ -44,9 +44,10 @@ import { tableDefinitionsAccessorAtom, } from "./atoms/derivedAtoms"; import { tableDefinitionsQueryAtom } from "./atoms/queryAtoms"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; export function Settings(props: ModuleSettingsProps): React.ReactNode { - const ensembleSet = useEnsembleSet(props.workbenchSession); + const ensembleSet = usePublishSubscribeTopicValue(props.workbenchSession, WorkbenchSessionTopic.EnsembleSet); const tableDefinitionsQueryResult = useAtomValue(tableDefinitionsQueryAtom); const tableDefinitionsAccessor = useAtomValue(tableDefinitionsAccessorAtom); diff --git a/frontend/src/modules/InplaceVolumesTable/view/view.tsx b/frontend/src/modules/InplaceVolumesTable/view/view.tsx index 000e17c58..1ea4c81c1 100644 --- a/frontend/src/modules/InplaceVolumesTable/view/view.tsx +++ b/frontend/src/modules/InplaceVolumesTable/view/view.tsx @@ -4,11 +4,12 @@ import { useAtomValue } from "jotai"; import type { ModuleViewProps } from "@framework/Module"; import { useViewStatusWriter } from "@framework/StatusWriter"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSessionTopic } from "@framework/WorkbenchSession"; import { PendingWrapper } from "@lib/components/PendingWrapper"; import { TableDeprecated as TableComponent } from "@lib/components/TableDeprecated"; import type { TableHeading, TableRow } from "@lib/components/TableDeprecated/table"; import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import type { Interfaces } from "../interfaces"; @@ -17,7 +18,7 @@ import { useMakeViewStatusWriterMessages } from "./hooks/useMakeViewStatusWriter import { useTableBuilder } from "./hooks/useTableBuilder"; export function View(props: ModuleViewProps): React.ReactNode { - const ensembleSet = useEnsembleSet(props.workbenchSession); + const ensembleSet = usePublishSubscribeTopicValue(props.workbenchSession, WorkbenchSessionTopic.EnsembleSet); const statusWriter = useViewStatusWriter(props.viewContext); const divRef = React.useRef(null); diff --git a/frontend/src/modules/Intersection/settings/components/dataProviderManagerWrapper.tsx b/frontend/src/modules/Intersection/settings/components/dataProviderManagerWrapper.tsx index d33e948da..a95cef844 100644 --- a/frontend/src/modules/Intersection/settings/components/dataProviderManagerWrapper.tsx +++ b/frontend/src/modules/Intersection/settings/components/dataProviderManagerWrapper.tsx @@ -12,7 +12,7 @@ import { import { useAtom } from "jotai"; import type { WorkbenchSession } from "@framework/WorkbenchSession"; -import type { WorkbenchSettings } from "@framework/WorkbenchSettings"; +import { useColorSet, type WorkbenchSettings } from "@framework/WorkbenchSettings"; import { Menu } from "@lib/components/Menu"; import { MenuButton } from "@lib/components/MenuButton"; import { MenuHeading } from "@lib/components/MenuHeading"; @@ -44,7 +44,7 @@ export type DataProviderManagerWrapperProps = { }; export function DataProviderManagerWrapper(props: DataProviderManagerWrapperProps) { - const colorSet = props.workbenchSettings.useColorSet(); + const colorSet = useColorSet(props.workbenchSettings); const [preferredViewLayout, setPreferredViewLayout] = useAtom(preferredViewLayoutAtom); diff --git a/frontend/src/modules/Intersection/settings/settings.tsx b/frontend/src/modules/Intersection/settings/settings.tsx index 5d46fcb58..446765e76 100644 --- a/frontend/src/modules/Intersection/settings/settings.tsx +++ b/frontend/src/modules/Intersection/settings/settings.tsx @@ -5,8 +5,10 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { FieldDropdown } from "@framework/components/FieldDropdown"; import type { ModuleSettingsProps } from "@framework/Module"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSessionTopic } from "@framework/WorkbenchSession"; +import { useColorSet } from "@framework/WorkbenchSettings"; import { CollapsibleGroup } from "@lib/components/CollapsibleGroup"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { GroupDelegateTopic } from "@modules/_shared/DataProviderFramework/delegates/GroupDelegate"; import { DataProviderManager, @@ -23,9 +25,9 @@ import { selectedFieldIdentifierAtom } from "./atoms/derivedAtoms"; import { DataProviderManagerWrapper } from "./components/dataProviderManagerWrapper"; export function Settings(props: ModuleSettingsProps): JSX.Element { - const ensembleSet = useEnsembleSet(props.workbenchSession); + const ensembleSet = usePublishSubscribeTopicValue(props.workbenchSession, WorkbenchSessionTopic.EnsembleSet); const queryClient = useQueryClient(); - const colorSet = props.workbenchSettings.useColorSet(); + const colorSet = useColorSet(props.workbenchSettings); const [dataProviderManager, setDataProviderManager] = useAtom(dataProviderManagerAtom); diff --git a/frontend/src/modules/Intersection/view/view.tsx b/frontend/src/modules/Intersection/view/view.tsx index 18c0964ad..4adbb4f1d 100644 --- a/frontend/src/modules/Intersection/view/view.tsx +++ b/frontend/src/modules/Intersection/view/view.tsx @@ -6,7 +6,6 @@ import type { Interfaces } from "../interfaces"; import { DataProvidersWrapper } from "./components/DataProvidersWrapper"; - export function View(props: ModuleViewProps): React.ReactNode { const preferredViewLayout = props.viewContext.useSettingsToViewInterfaceValue("preferredViewLayout"); const dataProviderManager = props.viewContext.useSettingsToViewInterfaceValue("dataProviderManager"); diff --git a/frontend/src/modules/Map/settings/settings.tsx b/frontend/src/modules/Map/settings/settings.tsx index 40a6da3b5..3f85037ea 100644 --- a/frontend/src/modules/Map/settings/settings.tsx +++ b/frontend/src/modules/Map/settings/settings.tsx @@ -9,7 +9,7 @@ import type { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; import { useSettingsStatusWriter } from "@framework/StatusWriter"; import { SyncSettingKey, SyncSettingsHelper } from "@framework/SyncSettings"; import { fixupRegularEnsembleIdent, maybeAssignFirstSyncedEnsemble } from "@framework/utils/ensembleUiHelpers"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSessionTopic } from "@framework/WorkbenchSession"; import { Checkbox } from "@lib/components/Checkbox"; import { CircularProgress } from "@lib/components/CircularProgress"; import { Input } from "@lib/components/Input"; @@ -18,6 +18,7 @@ import { QueryStateWrapper } from "@lib/components/QueryStateWrapper"; import { RadioGroup } from "@lib/components/RadioGroup"; import type { SelectOption } from "@lib/components/Select"; import { Select } from "@lib/components/Select"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { usePropagateApiErrorToStatusWriter } from "@modules/_shared/hooks/usePropagateApiErrorToStatusWriter"; import type { FullSurfaceAddress } from "@modules/_shared/Surface"; import { @@ -28,13 +29,11 @@ import { useRealizationSurfacesMetadataQuery, } from "@modules/_shared/Surface"; - import type { Interfaces } from "../interfaces"; import { AggregationDropdown } from "../UiComponents"; import { surfaceAddressAtom } from "./atoms/baseAtoms"; - const SurfaceTimeTypeEnumToStringMapping = { [SurfaceTimeType.None]: "Static", [SurfaceTimeType.TimePoint]: "Time point", @@ -42,7 +41,7 @@ const SurfaceTimeTypeEnumToStringMapping = { }; //----------------------------------------------------------------------------------------------------------- export function MapSettings(props: ModuleSettingsProps) { - const ensembleSet = useEnsembleSet(props.workbenchSession); + const ensembleSet = usePublishSubscribeTopicValue(props.workbenchSession, WorkbenchSessionTopic.EnsembleSet); const [selectedEnsembleIdent, setSelectedEnsembleIdent] = React.useState(null); const [timeType, setTimeType] = React.useState(SurfaceTimeType.None); diff --git a/frontend/src/modules/MyModule/loadModule.tsx b/frontend/src/modules/MyModule/loadModule.tsx index f3b31ad21..66f53348f 100644 --- a/frontend/src/modules/MyModule/loadModule.tsx +++ b/frontend/src/modules/MyModule/loadModule.tsx @@ -4,8 +4,11 @@ import type { Interfaces } from "./interfaces"; import { settingsToViewInterfaceInitialization } from "./interfaces"; import { Settings } from "./settings/settings"; import { View } from "./view"; +import type { SerializedState } from "./persistedState"; -const module = ModuleRegistry.initModule("MyModule", { settingsToViewInterfaceInitialization }); +const module = ModuleRegistry.initModule("MyModule", { + settingsToViewInterfaceInitialization, +}); module.viewFC = View; module.settingsFC = Settings; diff --git a/frontend/src/modules/MyModule/persistedState.ts b/frontend/src/modules/MyModule/persistedState.ts new file mode 100644 index 000000000..a3e410c05 --- /dev/null +++ b/frontend/src/modules/MyModule/persistedState.ts @@ -0,0 +1,12 @@ +export const SERIALIZED_STATE = { + settings: { + properties: { + myData: { + type: "string", + }, + }, + }, + view: {}, +} as const; + +export type SerializedState = typeof SERIALIZED_STATE; diff --git a/frontend/src/modules/MyModule/registerModule.ts b/frontend/src/modules/MyModule/registerModule.ts index c9d147943..84c5ad33c 100644 --- a/frontend/src/modules/MyModule/registerModule.ts +++ b/frontend/src/modules/MyModule/registerModule.ts @@ -1,12 +1,14 @@ import { ModuleCategory, ModuleDevState } from "@framework/Module"; import { ModuleRegistry } from "@framework/ModuleRegistry"; -import type { Interfaces } from "./interfaces"; +import { SERIALIZED_STATE, type SerializedState } from "./persistedState"; +import type { InterfaceTypes } from "@modules/WellLogViewer/interfaces"; -ModuleRegistry.registerModule({ +ModuleRegistry.registerModule({ moduleName: "MyModule", defaultTitle: "My Module", category: ModuleCategory.DEBUG, devState: ModuleDevState.DEV, description: "My module description", + serializedStateSchema: SERIALIZED_STATE, }); diff --git a/frontend/src/modules/MyModule/settings/settings.tsx b/frontend/src/modules/MyModule/settings/settings.tsx index e62bd1377..6896dd59b 100644 --- a/frontend/src/modules/MyModule/settings/settings.tsx +++ b/frontend/src/modules/MyModule/settings/settings.tsx @@ -3,19 +3,19 @@ import type React from "react"; import { useAtom } from "jotai"; import type { ModuleSettingsProps } from "@framework/Module"; +import { useContinuousColorScale, useDiscreteColorScale } from "@framework/WorkbenchSettings"; import { ColorGradient } from "@lib/components/ColorGradient/colorGradient"; import { Input } from "@lib/components/Input"; import { Label } from "@lib/components/Label"; import { RadioGroup } from "@lib/components/RadioGroup"; import { ColorScaleGradientType, ColorScaleType } from "@lib/utils/ColorScale"; - import type { Interfaces } from "../interfaces"; +import type { SerializedState } from "../persistedState"; import { divMidPointAtom, gradientTypeAtom, maxAtom, minAtom, typeAtom } from "./atoms/baseAtoms"; - -export function Settings(props: ModuleSettingsProps): React.ReactNode { +export function Settings(props: ModuleSettingsProps): React.ReactNode { const [type, setType] = useAtom(typeAtom); const [gradientType, setGradientType] = useAtom(gradientTypeAtom); const [min, setMin] = useAtom(minAtom); @@ -28,16 +28,15 @@ export function Settings(props: ModuleSettingsProps): React.ReactNod function handleGradientTypeChange(e: React.ChangeEvent) { setGradientType(e.target.value as ColorScaleGradientType); + props.persistence.serializeState({ + myData: e.target.value, + }); } - const colorScale = - type === ColorScaleType.Continuous - ? props.workbenchSettings.useContinuousColorScale({ - gradientType, - }) - : props.workbenchSettings.useDiscreteColorScale({ - gradientType, - }); + const continuousColorScale = useContinuousColorScale(props.workbenchSettings, { gradientType }); + const discreteColorScale = useDiscreteColorScale(props.workbenchSettings, { gradientType }); + + const colorScale = type === ColorScaleType.Continuous ? continuousColorScale : discreteColorScale; return (
    diff --git a/frontend/src/modules/MyModule/view.tsx b/frontend/src/modules/MyModule/view.tsx index aa4f2b139..c690b2a17 100644 --- a/frontend/src/modules/MyModule/view.tsx +++ b/frontend/src/modules/MyModule/view.tsx @@ -9,439 +9,8 @@ import { Plot } from "@modules/_shared/components/Plot"; import type { Interfaces } from "./interfaces"; -const countryData = [ - "Belarus", - 17.5, - "Moldova", - 16.8, - "Lithuania", - 15.4, - "Russia", - 15.1, - "Romania", - 14.4, - "Ukraine", - 13.9, - "Andorra", - 13.8, - "Hungary", - 13.3, - "Czech Republic", - 13, - "Slovakia", - 13, - "Portugal", - 12.9, - "Serbia", - 12.6, - "Grenada", - 12.5, - "Poland", - 12.5, - "Latvia", - 12.3, - "Finland", - 12.3, - "South Korea", - 12.3, - "France", - 12.2, - "Australia", - 12.2, - "Croatia", - 12.2, - "Ireland", - 11.9, - "Luxembourg", - 11.9, - "Germany", - 11.8, - "Slovenia", - 11.6, - "United Kingdom", - 11.6, - "Denmark", - 11.4, - "Bulgaria", - 11.4, - "Spain", - 11.2, - "Belgium", - 11, - "South Africa", - 11, - "New Zealand", - 10.9, - "Gabon", - 10.9, - "Namibia", - 10.8, - "Switzerland", - 10.7, - "Saint Lucia", - 10.4, - "Austria", - 10.3, - "Estonia", - 10.3, - "Greece", - 10.3, - "Kazakhstan", - 10.3, - "Canada", - 10.2, - "Nigeria", - 10.1, - "Netherlands", - 9.9, - "Uganda", - 9.8, - "Rwanda", - 9.8, - "Chile", - 9.6, - "Argentina", - 9.3, - "Burundi", - 9.3, - "United States", - 9.2, - "Cyprus", - 9.2, - "Sweden", - 9.2, - "Venezuela", - 8.9, - "Paraguay", - 8.8, - "Brazil", - 8.7, - "Sierra Leone", - 8.7, - "Montenegro", - 8.7, - "Belize", - 8.5, - "Cameroon", - 8.4, - "Botswana", - 8.4, - "Saint Kitts and Nevis", - 8.2, - "Guyana", - 8.1, - "Peru", - 8.1, - "Panama", - 8, - "Niue", - 8, - "Palau", - 7.9, - "Norway", - 7.7, - "Tanzania", - 7.7, - "Georgia", - 7.7, - "Uruguay", - 7.6, - "Angola", - 7.5, - "Laos", - 7.3, - "Japan", - 7.2, - "Mexico", - 7.2, - "Ecuador", - 7.2, - "Dominica", - 7.1, - "Iceland", - 7.1, - "Thailand", - 7.1, - "Bosnia and Herzegovina", - 7.1, - "Sao Tome and Principe", - 7.1, - "Malta", - 7, - "Albania", - 7, - "Bahamas", - 6.9, - "Dominican Republic", - 6.9, - "Mongolia", - 6.9, - "Cape Verde", - 6.9, - "Barbados", - 6.8, - "Burkina Faso", - 6.8, - "Italy", - 6.7, - "Trinidad and Tobago", - 6.7, - "China", - 6.7, - "Macedonia", - 6.7, - "Saint Vincent and the Grenadines", - 6.6, - "Equatorial Guinea", - 6.6, - "Suriname", - 6.6, - "Vietnam", - 6.6, - "Lesotho", - 6.5, - "Haiti", - 6.4, - "Cook Islands", - 6.4, - "Colombia", - 6.2, - "Ivory Coast", - 6, - "Bolivia", - 5.9, - "Swaziland", - 5.7, - "Zimbabwe", - 5.7, - "Seychelles", - 5.6, - "Cambodia", - 5.5, - "Puerto Rico", - 5.4, - "Netherlands Antilles", - 5.4, - "Philippines", - 5.4, - "Costa Rica", - 5.4, - "Armenia", - 5.3, - "Cuba", - 5.2, - "Nicaragua", - 5, - "Jamaica", - 4.9, - "Ghana", - 4.8, - "Liberia", - 4.7, - "Uzbekistan", - 4.6, - "Chad", - 4.4, - "United Arab Emirates", - 4.3, - "Kyrgyzstan", - 4.3, - "India", - 4.3, - "Turkmenistan", - 4.3, - "Kenya", - 4.3, - "Ethiopia", - 4.2, - "Honduras", - 4, - "Guinea-Bissau", - 4, - "Zambia", - 4, - "Republic of the Congo", - 3.9, - "Guatemala", - 3.8, - "Central African Republic", - 3.8, - "North Korea", - 3.7, - "Sri Lanka", - 3.7, - "Mauritius", - 3.6, - "Samoa", - 3.6, - "Democratic Republic of the Congo", - 3.6, - "Nauru", - 3.5, - "Gambia", - 3.4, - "Federated States of Micronesia", - 3.3, - "El Salvador", - 3.2, - "Fiji", - 3, - "Papua New Guinea", - 3, - "Kiribati", - 3, - "Tajikistan", - 2.8, - "Israel", - 2.8, - "Sudan", - 2.7, - "Malawi", - 2.5, - "Lebanon", - 2.4, - "Azerbaijan", - 2.3, - "Mozambique", - 2.3, - "Togo", - 2.3, - "Nepal", - 2.2, - "Brunei", - 2.1, - "Benin", - 2.1, - "Singapore", - 2, - "Turkey", - 2, - "Madagascar", - 1.8, - "Solomon Islands", - 1.7, - "Tonga", - 1.6, - "Tunisia", - 1.5, - "Tuvalu", - 1.5, - "Qatar", - 1.5, - "Vanuatu", - 1.4, - "Djibouti", - 1.3, - "Malaysia", - 1.3, - "Syria", - 1.2, - "Maldives", - 1.2, - "Mali", - 1.1, - "Eritrea", - 1.1, - "Algeria", - 1, - "Iran", - 1, - "Oman", - 0.9, - "Brunei", - 0.9, - "Morocco", - 0.9, - "Jordan", - 0.7, - "Bhutan", - 0.7, - "Guinea", - 0.7, - "Burma", - 0.7, - "Afghanistan", - 0.7, - "Senegal", - 0.6, - "Indonesia", - 0.6, - "Timor-Leste", - 0.6, - "Iraq", - 0.5, - "Somalia", - 0.5, - "Egypt", - 0.4, - "Niger", - 0.3, - "Yemen", - 0.3, - "Comoros", - 0.2, - "Saudi Arabia", - 0.2, - "Bangladesh", - 0.2, - "Kuwait", - 0.1, - "Libya", - 0.1, - "Mauritania", - 0.1, - "Pakistan", - 0.1, -]; - -const countries: string[] = []; -const alcConsumption: number[] = []; - -for (let i = 0; i < countryData.length; i += 2) { - countries.push(countryData[i] as string); - alcConsumption.push(countryData[i + 1] as number); -} - export function View(props: ModuleViewProps): React.ReactNode { - const type = props.viewContext.useSettingsToViewInterfaceValue("type"); - const gradientType = props.viewContext.useSettingsToViewInterfaceValue("gradientType"); - const min = props.viewContext.useSettingsToViewInterfaceValue("min"); - const max = props.viewContext.useSettingsToViewInterfaceValue("max"); - const divMidPoint = props.viewContext.useSettingsToViewInterfaceValue("divMidPoint"); - const ref = React.useRef(null); - const size = useElementSize(ref); - - const colorScale = - type === ColorScaleType.Continuous - ? props.workbenchSettings.useContinuousColorScale({ - gradientType, - }) - : props.workbenchSettings.useDiscreteColorScale({ - gradientType, - }); - - colorScale.setRangeAndMidPoint(min, max, divMidPoint); - - const data: Partial = { - ...colorScale.getAsPlotlyColorScaleMapObject(), - type: "choropleth", - locationmode: "country names", - locations: countries, - z: alcConsumption, - }; - - const layout = { - mapbox: { style: "dark", center: { lon: -110, lat: 50 }, zoom: 0.8 }, - width: size.width, - height: size.height, - margin: { t: 0, b: 0 }, - }; - - return ( -
    - -
    - ); + return
    ; } diff --git a/frontend/src/modules/ParameterDistributionMatrix/settings/settings.tsx b/frontend/src/modules/ParameterDistributionMatrix/settings/settings.tsx index 65b638f47..92dd7bbd3 100644 --- a/frontend/src/modules/ParameterDistributionMatrix/settings/settings.tsx +++ b/frontend/src/modules/ParameterDistributionMatrix/settings/settings.tsx @@ -4,13 +4,13 @@ import { EnsembleSelect } from "@framework/components/EnsembleSelect"; import { ParameterIdent } from "@framework/EnsembleParameters"; import type { ModuleSettingsProps } from "@framework/Module"; import type { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSessionTopic } from "@framework/WorkbenchSession"; import { Checkbox } from "@lib/components/Checkbox"; import { CollapsibleGroup } from "@lib/components/CollapsibleGroup"; import { RadioGroup } from "@lib/components/RadioGroup"; import type { SelectOption } from "@lib/components/Select"; import { Select } from "@lib/components/Select"; - +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import type { Interfaces } from "../interfaces"; import { @@ -33,9 +33,8 @@ import { selectedParameterIdentsAtom, } from "./atoms/derivedAtoms"; - -export function Settings({ workbenchSession }: ModuleSettingsProps) { - const ensembleSet = useEnsembleSet(workbenchSession); +export function Settings({ workbenchSession: workbenchSession }: ModuleSettingsProps) { + const ensembleSet = usePublishSubscribeTopicValue(workbenchSession, WorkbenchSessionTopic.EnsembleSet); const selectedEnsembleIdents = useAtomValue(selectedEnsembleIdentsAtom); const setSelectedEnsembleIdents = useSetAtom(userSelectedEnsembleIdentsAtom); diff --git a/frontend/src/modules/ParameterDistributionMatrix/view/view.tsx b/frontend/src/modules/ParameterDistributionMatrix/view/view.tsx index 6d4fd0626..463ad0842 100644 --- a/frontend/src/modules/ParameterDistributionMatrix/view/view.tsx +++ b/frontend/src/modules/ParameterDistributionMatrix/view/view.tsx @@ -9,7 +9,6 @@ import type { EnsembleRealizationFilterFunction } from "@framework/WorkbenchSess import { useEnsembleRealizationFilterFunc } from "@framework/WorkbenchSession"; import { useElementSize } from "@lib/hooks/useElementSize"; - import type { Interfaces } from "../interfaces"; import type { ParameterDataArr } from "../typesAndEnums"; diff --git a/frontend/src/modules/ParameterResponseCorrelationBarPlot/view/view.tsx b/frontend/src/modules/ParameterResponseCorrelationBarPlot/view/view.tsx index 7e7b42c24..09f92bcf0 100644 --- a/frontend/src/modules/ParameterResponseCorrelationBarPlot/view/view.tsx +++ b/frontend/src/modules/ParameterResponseCorrelationBarPlot/view/view.tsx @@ -34,7 +34,11 @@ function MaxNumberPlotsExceededMessage() { ); } -export function View({ viewContext, workbenchSession, workbenchServices }: ModuleViewProps) { +export function View({ + viewContext, + workbenchSession: workbenchSession, + workbenchServices, +}: ModuleViewProps) { const [isPending, startTransition] = React.useTransition(); const [content, setContent] = React.useState(null); const [revNumberResponse, setRevNumberResponse] = React.useState(0); diff --git a/frontend/src/modules/ParameterResponseCorrelationMatrixPlot/view/view.tsx b/frontend/src/modules/ParameterResponseCorrelationMatrixPlot/view/view.tsx index 6d33a24fd..97caee337 100644 --- a/frontend/src/modules/ParameterResponseCorrelationMatrixPlot/view/view.tsx +++ b/frontend/src/modules/ParameterResponseCorrelationMatrixPlot/view/view.tsx @@ -30,6 +30,7 @@ import { PlotType, type CorrelationSettings } from "../typesAndEnums"; import { ParameterCorrelationMatrixFigure } from "./utils/parameterCorrelationMatrixFigure"; import { createResponseParameterCorrelationMatrix } from "./utils/parameterCorrelationMatrixUtils"; +import { useContinuousColorScale } from "@framework/WorkbenchSettings"; const MAX_NUM_PLOTS = 12; @@ -71,11 +72,9 @@ export function View({ viewContext, workbenchSession, workbenchSettings }: Modul const wrapperDivRef = React.useRef(null); const wrapperDivSize = useElementSize(wrapperDivRef); - const colorScaleWithGradient = workbenchSettings - .useContinuousColorScale({ - gradientType: ColorScaleGradientType.Diverging, - }) - .getPlotlyColorScale(); + const colorScaleWithGradient = useContinuousColorScale(workbenchSettings, { + gradientType: ColorScaleGradientType.Diverging, + }).getPlotlyColorScale(); const receiverResponses = [ viewContext.useChannelReceiver({ diff --git a/frontend/src/modules/ParameterResponseCorrelationParallelCoordsPlot/view/view.tsx b/frontend/src/modules/ParameterResponseCorrelationParallelCoordsPlot/view/view.tsx index b3ced349d..3d7fef109 100644 --- a/frontend/src/modules/ParameterResponseCorrelationParallelCoordsPlot/view/view.tsx +++ b/frontend/src/modules/ParameterResponseCorrelationParallelCoordsPlot/view/view.tsx @@ -21,7 +21,7 @@ import type { Interfaces } from "../interfaces"; import { ParallelCoordinatesFigure } from "./utils/parallelCoordinatesFigure"; -export function View({ viewContext, workbenchSession }: ModuleViewProps) { +export function View({ viewContext, workbenchSession: workbenchSession }: ModuleViewProps) { const [isPending, startTransition] = React.useTransition(); const [content, setContent] = React.useState(null); const [revNumberResponse, setRevNumberResponse] = React.useState(0); diff --git a/frontend/src/modules/ParameterResponseCrossPlot/settings/settings.tsx b/frontend/src/modules/ParameterResponseCrossPlot/settings/settings.tsx index 35644c71f..7c3b0a7aa 100644 --- a/frontend/src/modules/ParameterResponseCrossPlot/settings/settings.tsx +++ b/frontend/src/modules/ParameterResponseCrossPlot/settings/settings.tsx @@ -27,7 +27,7 @@ const plotTypes = [{ value: PlotType.ParameterResponseCrossPlot, label: "Paramet export function Settings({ initialSettings, settingsContext, - workbenchSession, + workbenchSession: workbenchSession, workbenchServices, }: ModuleSettingsProps) { const [plotType, setPlotType] = useAtom(plotTypeAtom); diff --git a/frontend/src/modules/ParameterResponseCrossPlot/view/view.tsx b/frontend/src/modules/ParameterResponseCrossPlot/view/view.tsx index 482c16040..22b99e7bc 100644 --- a/frontend/src/modules/ParameterResponseCrossPlot/view/view.tsx +++ b/frontend/src/modules/ParameterResponseCrossPlot/view/view.tsx @@ -33,7 +33,7 @@ function MaxNumberPlotsExceededMessage() { ); } -export function View({ viewContext, workbenchSession }: ModuleViewProps) { +export function View({ viewContext, workbenchSession: workbenchSession }: ModuleViewProps) { const [content, setContent] = React.useState(null); const [revNumberResponse, setRevNumberResponse] = React.useState(0); const [prevPlotType, setPrevPlotType] = React.useState(null); diff --git a/frontend/src/modules/Pvt/settings/settings.tsx b/frontend/src/modules/Pvt/settings/settings.tsx index 76a658d84..e57091320 100644 --- a/frontend/src/modules/Pvt/settings/settings.tsx +++ b/frontend/src/modules/Pvt/settings/settings.tsx @@ -5,14 +5,14 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { EnsembleSelect } from "@framework/components/EnsembleSelect"; import type { ModuleSettingsProps } from "@framework/Module"; import type { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; -import { useEnsembleRealizationFilterFunc, useEnsembleSet } from "@framework/WorkbenchSession"; +import { useEnsembleRealizationFilterFunc, WorkbenchSessionTopic } from "@framework/WorkbenchSession"; import { CollapsibleGroup } from "@lib/components/CollapsibleGroup"; import { Dropdown } from "@lib/components/Dropdown"; import { PendingWrapper } from "@lib/components/PendingWrapper"; import { RadioGroup } from "@lib/components/RadioGroup"; import type { SelectOption } from "@lib/components/Select"; import { Select } from "@lib/components/Select"; - +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import type { Interfaces } from "../interfaces"; import { @@ -41,9 +41,8 @@ import { import { pvtDataQueriesAtom } from "./atoms/queryAtoms"; import { DependentVariableSelector } from "./components/DependentVariableSelector/dependentVariableSelector"; - -export function Settings({ workbenchSession }: ModuleSettingsProps) { - const ensembleSet = useEnsembleSet(workbenchSession); +export function Settings({ workbenchSession: workbenchSession }: ModuleSettingsProps) { + const ensembleSet = usePublishSubscribeTopicValue(workbenchSession, WorkbenchSessionTopic.EnsembleSet); const filterEnsembleRealizationsFunc = useEnsembleRealizationFilterFunc(workbenchSession); const selectedEnsembleIdents = useAtomValue(selectedEnsembleIdentsAtom); diff --git a/frontend/src/modules/Pvt/view.tsx b/frontend/src/modules/Pvt/view.tsx index 836554206..415c10244 100644 --- a/frontend/src/modules/Pvt/view.tsx +++ b/frontend/src/modules/Pvt/view.tsx @@ -5,9 +5,11 @@ import type { RegularEnsemble } from "@framework/RegularEnsemble"; import type { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; import { useViewStatusWriter } from "@framework/StatusWriter"; import { ApiErrorHelper } from "@framework/utils/ApiErrorHelper"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSessionTopic } from "@framework/WorkbenchSession"; +import { useColorSet } from "@framework/WorkbenchSettings"; import { CircularProgress } from "@lib/components/CircularProgress"; import { useElementSize } from "@lib/hooks/useElementSize"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { ContentMessage, ContentMessageType } from "@modules/_shared/components/ContentMessage/contentMessage"; import { Plot } from "@modules/_shared/components/Plot"; import { makeDistinguishableEnsembleDisplayName } from "@modules/_shared/ensembleNameUtils"; @@ -17,9 +19,9 @@ import { PvtDataAccessor } from "./utils/PvtDataAccessor"; import { PvtPlotBuilder } from "./utils/PvtPlotBuilder"; export function View({ viewContext, workbenchSettings, workbenchSession }: ModuleViewProps) { - const colorSet = workbenchSettings.useColorSet(); + const colorSet = useColorSet(workbenchSettings); const statusWriter = useViewStatusWriter(viewContext); - const ensembleSet = useEnsembleSet(workbenchSession); + const ensembleSet = usePublishSubscribeTopicValue(workbenchSession, WorkbenchSessionTopic.EnsembleSet); const selectedEnsembleIdents = viewContext.useSettingsToViewInterfaceValue("selectedEnsembleIdents"); const selectedPvtNums = viewContext.useSettingsToViewInterfaceValue("selectedPvtNums"); diff --git a/frontend/src/modules/Rft/settings.tsx b/frontend/src/modules/Rft/settings.tsx index e9a1e9198..976f67bb7 100644 --- a/frontend/src/modules/Rft/settings.tsx +++ b/frontend/src/modules/Rft/settings.tsx @@ -5,14 +5,14 @@ import type { ModuleSettingsProps } from "@framework/Module"; import type { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; import { useSettingsStatusWriter } from "@framework/StatusWriter"; import { timestampUtcMsToCompactIsoString } from "@framework/utils/timestampUtils"; -import { useEnsembleRealizationFilterFunc, useEnsembleSet } from "@framework/WorkbenchSession"; +import { useEnsembleRealizationFilterFunc, WorkbenchSessionTopic } from "@framework/WorkbenchSession"; import { CollapsibleGroup } from "@lib/components/CollapsibleGroup"; import { PendingWrapper } from "@lib/components/PendingWrapper"; import type { SelectOption } from "@lib/components/Select"; import { Select } from "@lib/components/Select"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { usePropagateApiErrorToStatusWriter } from "@modules/_shared/hooks/usePropagateApiErrorToStatusWriter"; - import type { Interfaces } from "./interfaces"; import { userSelectedEnsembleIdentAtom, @@ -43,8 +43,8 @@ const timepointOptions = (timePoints: number[]): SelectOption[] => { })); }; -export function Settings({ settingsContext, workbenchSession }: ModuleSettingsProps) { - const ensembleSet = useEnsembleSet(workbenchSession); +export function Settings({ settingsContext, workbenchSession: workbenchSession }: ModuleSettingsProps) { + const ensembleSet = usePublishSubscribeTopicValue(workbenchSession, WorkbenchSessionTopic.EnsembleSet); const statusWriter = useSettingsStatusWriter(settingsContext); const selectedEnsembleIdent = useAtomValue(selectedEnsembleIdentAtom); diff --git a/frontend/src/modules/SimulationTimeSeries/settings/atoms/queryAtoms.ts b/frontend/src/modules/SimulationTimeSeries/settings/atoms/queryAtoms.ts index 0b22cf669..59fc3ac53 100644 --- a/frontend/src/modules/SimulationTimeSeries/settings/atoms/queryAtoms.ts +++ b/frontend/src/modules/SimulationTimeSeries/settings/atoms/queryAtoms.ts @@ -4,9 +4,12 @@ import { EnsembleSetAtom } from "@framework/GlobalAtoms"; import { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; import { atomWithQueries } from "@framework/utils/atomUtils"; import { isEnsembleIdentOfType } from "@framework/utils/ensembleIdentUtils"; +import { makeTimestampQueryParam } from "@framework/utils/queryUtils"; import { selectedEnsembleIdentsAtom } from "./derivedAtoms"; +type T = (query: K) => K; + export const vectorListQueriesAtom = atomWithQueries((get) => { const ensembleSet = get(EnsembleSetAtom); const selectedEnsembleIdents = get(selectedEnsembleIdentsAtom); @@ -19,6 +22,7 @@ export const vectorListQueriesAtom = atomWithQueries((get) => { queryFn: async () => { const { data } = await getVectorList({ query: { + ...makeTimestampQueryParam(ensembleIdent), case_uuid: ensembleIdent.getCaseUuid(), ensemble_name: ensembleIdent.getEnsembleName(), include_derived_vectors: true, diff --git a/frontend/src/modules/SimulationTimeSeries/settings/settings.tsx b/frontend/src/modules/SimulationTimeSeries/settings/settings.tsx index 0b318202c..0efe9d8c2 100644 --- a/frontend/src/modules/SimulationTimeSeries/settings/settings.tsx +++ b/frontend/src/modules/SimulationTimeSeries/settings/settings.tsx @@ -14,7 +14,7 @@ import type { ModuleSettingsProps } from "@framework/Module"; import type { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; import { useSettingsStatusWriter } from "@framework/StatusWriter"; import { SyncSettingKey, SyncSettingsHelper } from "@framework/SyncSettings"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSessionTopic } from "@framework/WorkbenchSession"; import { Checkbox } from "@lib/components/Checkbox"; import { CircularProgress } from "@lib/components/CircularProgress"; import { CollapsibleGroup } from "@lib/components/CollapsibleGroup"; @@ -27,6 +27,7 @@ import { RadioGroup } from "@lib/components/RadioGroup"; import { Select } from "@lib/components/Select"; import type { SmartNodeSelectorSelection } from "@lib/components/SmartNodeSelector"; import { Switch } from "@lib/components/Switch"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; import { VectorSelector } from "@modules/_shared/components/VectorSelector"; @@ -79,7 +80,7 @@ export function Settings({ workbenchSession, workbenchServices, }: ModuleSettingsProps) { - const ensembleSet = useEnsembleSet(workbenchSession); + const ensembleSet = usePublishSubscribeTopicValue(workbenchSession, WorkbenchSessionTopic.EnsembleSet); const statusWriter = useSettingsStatusWriter(settingsContext); const [selectedVectorTags, setSelectedVectorTags] = React.useState([]); @@ -370,7 +371,7 @@ export function Settings({ })} onChange={(_, value) => handleVisualizationModeChange(value)} /> -
    +
    ) { - const ensembleSet = useEnsembleSet(workbenchSession); + const ensembleSet = usePublishSubscribeTopicValue(workbenchSession, WorkbenchSessionTopic.EnsembleSet); const setSyncedRegularEnsembleIdents = useSetAtom(syncedRegularEnsembleIdentsAtom); const setSyncedVectorName = useSetAtom(syncedVectorNameAtom); diff --git a/frontend/src/modules/SimulationTimeSeriesSensitivity/view/view.tsx b/frontend/src/modules/SimulationTimeSeriesSensitivity/view/view.tsx index 98f900923..ce3cc3cd1 100644 --- a/frontend/src/modules/SimulationTimeSeriesSensitivity/view/view.tsx +++ b/frontend/src/modules/SimulationTimeSeriesSensitivity/view/view.tsx @@ -5,9 +5,9 @@ import { useAtomValue, useSetAtom } from "jotai"; import type { ModuleViewProps } from "@framework/Module"; import { useViewStatusWriter } from "@framework/StatusWriter"; import { useSubscribedValue } from "@framework/WorkbenchServices"; +import { useColorSet } from "@framework/WorkbenchSettings"; import { useElementSize } from "@lib/hooks/useElementSize"; - import type { Interfaces } from "../interfaces"; import { userSelectedActiveTimestampUtcMsAtom, vectorSpecificationAtom } from "./atoms/baseAtoms"; @@ -18,7 +18,6 @@ import { useMakeViewStatusWriterMessages } from "./hooks/useMakeViewStatusWriter import { usePublishToDataChannels } from "./hooks/usePublishToDataChannels"; import { useTimeSeriesChartTracesDataArrayBuilder } from "./hooks/useTimeSeriesChartTracesDataArrayBuilder"; - export const View = ({ viewContext, workbenchSettings, workbenchServices }: ModuleViewProps) => { const wrapperDivRef = React.useRef(null); const wrapperDivSize = useElementSize(wrapperDivRef); @@ -34,7 +33,7 @@ export const View = ({ viewContext, workbenchSettings, workbenchServices }: Modu useMakeViewStatusWriterMessages(statusWriter); usePublishToDataChannels(viewContext); - const colorSet = workbenchSettings.useColorSet(); + const colorSet = useColorSet(workbenchSettings); const traceDataArr = useTimeSeriesChartTracesDataArrayBuilder(colorSet); function handleHoverInChart(hoverInfo: TimeSeriesChartHoverInfo | null) { diff --git a/frontend/src/modules/SubsurfaceMap/components/SyncedSubsurfaceViewer/syncedSubsurfaceViewer.tsx b/frontend/src/modules/SubsurfaceMap/components/SyncedSubsurfaceViewer/syncedSubsurfaceViewer.tsx index 476d8dada..da163081f 100644 --- a/frontend/src/modules/SubsurfaceMap/components/SyncedSubsurfaceViewer/syncedSubsurfaceViewer.tsx +++ b/frontend/src/modules/SubsurfaceMap/components/SyncedSubsurfaceViewer/syncedSubsurfaceViewer.tsx @@ -9,7 +9,7 @@ import { SyncSettingKey, SyncSettingsHelper } from "@framework/SyncSettings"; import type { WorkbenchServices } from "@framework/WorkbenchServices"; export type SyncedSubsurfaceViewerProps = { - viewContext: ViewContext; + viewContext: ViewContext; workbenchServices: WorkbenchServices; } & SubsurfaceViewerProps; diff --git a/frontend/src/modules/SubsurfaceMap/settings/settings.tsx b/frontend/src/modules/SubsurfaceMap/settings/settings.tsx index 1cbdb05ab..22dc4b008 100644 --- a/frontend/src/modules/SubsurfaceMap/settings/settings.tsx +++ b/frontend/src/modules/SubsurfaceMap/settings/settings.tsx @@ -9,7 +9,7 @@ import type { ModuleSettingsProps } from "@framework/Module"; import { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; import { SyncSettingKey, SyncSettingsHelper } from "@framework/SyncSettings"; import { fixupRegularEnsembleIdent, maybeAssignFirstSyncedEnsemble } from "@framework/utils/ensembleUiHelpers"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSessionTopic } from "@framework/WorkbenchSession"; import { Button } from "@lib/components/Button"; import { Checkbox } from "@lib/components/Checkbox"; import { CircularProgress } from "@lib/components/CircularProgress"; @@ -20,6 +20,7 @@ import { QueryStateWrapper } from "@lib/components/QueryStateWrapper"; import { RadioGroup } from "@lib/components/RadioGroup"; import type { SelectOption } from "@lib/components/Select"; import { Select } from "@lib/components/Select"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import type { PolygonsAddress } from "@modules/_shared/Polygons"; import { PolygonsDirectory, usePolygonsDirectoryQuery } from "@modules/_shared/Polygons"; import { @@ -32,7 +33,6 @@ import { } from "@modules/_shared/Surface"; import { useDrilledWellboreHeadersQuery } from "@modules/_shared/WellBore/queryHooks"; - import type { Interfaces } from "../interfaces"; import { @@ -45,7 +45,6 @@ import { } from "./atoms/baseAtoms"; import { AggregationSelector } from "./components/aggregationSelector"; - //----------------------------------------------------------------------------------------------------------- type LabelledCheckboxProps = { label: string; @@ -74,7 +73,7 @@ export function Settings({ settingsContext, workbenchSession, workbenchServices const myInstanceIdStr = settingsContext.getInstanceIdString(); console.debug(`${myInstanceIdStr} -- render TopographicMap settings`); - const ensembleSet = useEnsembleSet(workbenchSession); + const ensembleSet = usePublishSubscribeTopicValue(workbenchSession, WorkbenchSessionTopic.EnsembleSet); const [selectedEnsembleIdent, setSelectedEnsembleIdent] = React.useState(null); const [selectedMeshSurfaceName, setSelectedMeshSurfaceName] = React.useState(null); const [selectedMeshSurfaceAttribute, setSelectedMeshSurfaceAttribute] = React.useState(null); diff --git a/frontend/src/modules/SubsurfaceMap/view.tsx b/frontend/src/modules/SubsurfaceMap/view.tsx index efb14e396..32b8a123f 100644 --- a/frontend/src/modules/SubsurfaceMap/view.tsx +++ b/frontend/src/modules/SubsurfaceMap/view.tsx @@ -8,15 +8,16 @@ import type { ModuleViewProps } from "@framework/Module"; import { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; import { SyncSettingKey, SyncSettingsHelper } from "@framework/SyncSettings"; import type { Wellbore } from "@framework/types/wellbore"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSessionTopic } from "@framework/WorkbenchSession"; +import { useContinuousColorScale } from "@framework/WorkbenchSettings"; import { Button } from "@lib/components/Button"; import { CircularProgress } from "@lib/components/CircularProgress"; import { ColorScaleGradientType } from "@lib/utils/ColorScale"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { usePolygonsDataQueryByAddress } from "@modules/_shared/Polygons"; import { useFieldWellboreTrajectoriesQuery } from "@modules/_shared/WellBore/queryHooks"; import { useSurfaceDataQueryByAddress } from "@modules_shared/Surface"; - import { createAxesLayer, createContinuousColorScaleForMap, @@ -74,7 +75,7 @@ export function View({ annotation3D: `${myInstanceIdStr} -- annotation3D`, }; - const ensembleSet = useEnsembleSet(workbenchSession); + const ensembleSet = usePublishSubscribeTopicValue(workbenchSession, WorkbenchSessionTopic.EnsembleSet); const meshSurfAddr = viewContext.useSettingsToViewInterfaceValue("meshSurfaceAddress"); const propertySurfAddr = viewContext.useSettingsToViewInterfaceValue("propertySurfaceAddress"); @@ -87,7 +88,7 @@ export function View({ const [viewportBounds, setviewPortBounds] = React.useState<[number, number, number, number] | undefined>(undefined); const syncedSettingKeys = viewContext.useSyncedSettingKeys(); const syncHelper = new SyncSettingsHelper(syncedSettingKeys, workbenchServices); - const surfaceColorScale = workbenchSettings.useContinuousColorScale({ + const surfaceColorScale = useContinuousColorScale(workbenchSettings, { gradientType: ColorScaleGradientType.Sequential, }); const colorTables = createContinuousColorScaleForMap(surfaceColorScale); diff --git a/frontend/src/modules/TornadoChart/settings/settings.tsx b/frontend/src/modules/TornadoChart/settings/settings.tsx index 8d64dc159..c2c09aad8 100644 --- a/frontend/src/modules/TornadoChart/settings/settings.tsx +++ b/frontend/src/modules/TornadoChart/settings/settings.tsx @@ -25,7 +25,7 @@ import { export function Settings({ initialSettings, settingsContext, - workbenchSession, + workbenchSession: workbenchSession, }: ModuleSettingsProps): React.ReactNode { const [displayComponentType, setDisplayComponentType] = useAtom(displayComponentTypeAtom); const [hideZeroY, setHideZeroY] = useAtom(hideZeroYAtom); diff --git a/frontend/src/modules/TornadoChart/view/view.tsx b/frontend/src/modules/TornadoChart/view/view.tsx index 0ef2c5b20..be634554c 100644 --- a/frontend/src/modules/TornadoChart/view/view.tsx +++ b/frontend/src/modules/TornadoChart/view/view.tsx @@ -7,9 +7,11 @@ import { KeyKind } from "@framework/DataChannelTypes"; import type { ModuleViewProps } from "@framework/Module"; import type { RegularEnsemble } from "@framework/RegularEnsemble"; import { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSessionTopic } from "@framework/WorkbenchSession"; +import { useColorSet } from "@framework/WorkbenchSettings"; import { Tag } from "@lib/components/Tag"; import { useElementSize } from "@lib/hooks/useElementSize"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { ContentInfo } from "@modules/_shared/components/ContentMessage/contentMessage"; import { createSensitivityColorMap } from "../../_shared/sensitivityColors"; @@ -30,7 +32,7 @@ export const View = ({ viewContext, workbenchSession, workbenchSettings }: Modul const referenceSensitivityName = viewContext.useSettingsToViewInterfaceValue("referenceSensitivityName"); const wrapperDivRef = React.useRef(null); const wrapperDivSize = useElementSize(wrapperDivRef); - const ensembleSet = useEnsembleSet(workbenchSession); + const ensembleSet = usePublishSubscribeTopicValue(workbenchSession, WorkbenchSessionTopic.EnsembleSet); const responseReceiver = viewContext.useChannelReceiver({ receiverIdString: "response", @@ -60,7 +62,7 @@ export const View = ({ viewContext, workbenchSession, workbenchSettings }: Modul } const sensitivities = channelEnsemble?.getSensitivities(); - const colorSet = workbenchSettings.useColorSet(); + const colorSet = useColorSet(workbenchSettings); const sensitivitiesColorMap = createSensitivityColorMap( sensitivities?.getSensitivityNames().sort() ?? [], colorSet, diff --git a/frontend/src/modules/Vfp/settings/settings.tsx b/frontend/src/modules/Vfp/settings/settings.tsx index 8b83219da..868fe7f86 100644 --- a/frontend/src/modules/Vfp/settings/settings.tsx +++ b/frontend/src/modules/Vfp/settings/settings.tsx @@ -4,16 +4,16 @@ import { EnsembleDropdown } from "@framework/components/EnsembleDropdown"; import type { ModuleSettingsProps } from "@framework/Module"; import type { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; import { useSettingsStatusWriter } from "@framework/StatusWriter"; -import { useEnsembleRealizationFilterFunc, useEnsembleSet } from "@framework/WorkbenchSession"; +import { useEnsembleRealizationFilterFunc, WorkbenchSessionTopic } from "@framework/WorkbenchSession"; import { CollapsibleGroup } from "@lib/components/CollapsibleGroup"; import { Dropdown } from "@lib/components/Dropdown"; import { Label } from "@lib/components/Label"; import { RadioGroup } from "@lib/components/RadioGroup"; import type { SelectOption } from "@lib/components/Select"; import { Select } from "@lib/components/Select"; +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import { usePropagateApiErrorToStatusWriter } from "@modules/_shared/hooks/usePropagateApiErrorToStatusWriter"; - import type { Interfaces } from "../interfaces"; import { PressureOption, VfpParam, VfpType } from "../types"; import { VfpDataAccessor } from "../utils/vfpDataAccessor"; @@ -44,10 +44,9 @@ import { } from "./atoms/derivedAtoms"; import { vfpTableQueryAtom } from "./atoms/queryAtoms"; - -export function Settings({ workbenchSession, settingsContext }: ModuleSettingsProps) { +export function Settings({ workbenchSession: workbenchSession, settingsContext }: ModuleSettingsProps) { const statusWriter = useSettingsStatusWriter(settingsContext); - const ensembleSet = useEnsembleSet(workbenchSession); + const ensembleSet = usePublishSubscribeTopicValue(workbenchSession, WorkbenchSessionTopic.EnsembleSet); const vfpTableQuery = useAtomValue(vfpTableQueryAtom); diff --git a/frontend/src/modules/Vfp/view.tsx b/frontend/src/modules/Vfp/view.tsx index 21f373df5..f8cb760f6 100644 --- a/frontend/src/modules/Vfp/view.tsx +++ b/frontend/src/modules/Vfp/view.tsx @@ -4,6 +4,7 @@ import type { PlotData } from "plotly.js"; import type { ModuleViewProps } from "@framework/Module"; import { useViewStatusWriter } from "@framework/StatusWriter"; +import { useContinuousColorScale } from "@framework/WorkbenchSettings"; import { CircularProgress } from "@lib/components/CircularProgress"; import { useElementSize } from "@lib/hooks/useElementSize"; import { ColorScaleGradientType } from "@lib/utils/ColorScale"; @@ -16,7 +17,7 @@ import { VfpDataAccessor } from "./utils/vfpDataAccessor"; import { VfpPlotBuilder } from "./utils/vfpPlotBuilder"; export function View({ viewContext, workbenchSettings }: ModuleViewProps) { - const colorScale = workbenchSettings.useContinuousColorScale({ gradientType: ColorScaleGradientType.Sequential }); + const colorScale = useContinuousColorScale(workbenchSettings, { gradientType: ColorScaleGradientType.Sequential }); const vfpDataQuery = viewContext.useSettingsToViewInterfaceValue("vfpDataQuery"); const selectedThpIndices = viewContext.useSettingsToViewInterfaceValue("selectedThpIndices"); diff --git a/frontend/src/modules/WellCompletions/settings/settings.tsx b/frontend/src/modules/WellCompletions/settings/settings.tsx index 565b70a57..c2260f053 100644 --- a/frontend/src/modules/WellCompletions/settings/settings.tsx +++ b/frontend/src/modules/WellCompletions/settings/settings.tsx @@ -10,7 +10,8 @@ import type { ModuleSettingsProps } from "@framework/Module"; import type { RegularEnsembleIdent } from "@framework/RegularEnsembleIdent"; import { useSettingsStatusWriter } from "@framework/StatusWriter"; import { SyncSettingKey, SyncSettingsHelper } from "@framework/SyncSettings"; -import { useEnsembleSet } from "@framework/WorkbenchSession"; +import { WorkbenchSessionTopic } from "@framework/WorkbenchSession"; +import { useColorSet } from "@framework/WorkbenchSettings"; import { Button } from "@lib/components/Button"; import { CollapsibleGroup } from "@lib/components/CollapsibleGroup"; import { DiscreteSlider } from "@lib/components/DiscreteSlider"; @@ -21,7 +22,7 @@ import { PendingWrapper } from "@lib/components/PendingWrapper"; import { RadioGroup } from "@lib/components/RadioGroup"; import { Switch } from "@lib/components/Switch"; import type { ColorSet } from "@lib/utils/ColorSet"; - +import { usePublishSubscribeTopicValue } from "@lib/utils/PublishSubscribeDelegate"; import type { Interfaces } from "../interfaces"; import { @@ -58,16 +59,15 @@ import { } from "./atoms/derivedAtoms"; import { useMakeSettingsStatusWriterMessages } from "./hooks/useMakeSettingsStatusWriterMessages"; - export const Settings = ({ settingsContext, workbenchSession, workbenchServices, workbenchSettings, }: ModuleSettingsProps) => { - const ensembleSet = useEnsembleSet(workbenchSession); + const ensembleSet = usePublishSubscribeTopicValue(workbenchSession, WorkbenchSessionTopic.EnsembleSet); const statusWriter = useSettingsStatusWriter(settingsContext); - const stratigraphyColorSet = workbenchSettings.useColorSet(); + const stratigraphyColorSet = useColorSet(workbenchSettings); const setSyncedEnsembleIdents = useSetAtom(syncedEnsembleIdentsAtom); const setSelectedStratigraphyColorSet = useSetAtom(selectedStratigraphyColorSetAtom); diff --git a/frontend/src/modules/_shared/DataProviderFramework/delegates/GroupDelegate.ts b/frontend/src/modules/_shared/DataProviderFramework/delegates/GroupDelegate.ts index 3f8bd2ebb..376e5e102 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/delegates/GroupDelegate.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/delegates/GroupDelegate.ts @@ -1,5 +1,6 @@ import type { PublishSubscribe } from "@lib/utils/PublishSubscribeDelegate"; import { PublishSubscribeDelegate } from "@lib/utils/PublishSubscribeDelegate"; +import { UnsubscribeFunctionsManagerDelegate } from "@lib/utils/UnsubscribeFunctionsManagerDelegate"; import { DataProvider } from "../framework/DataProvider/DataProvider"; import { DeserializationAssistant } from "../framework/utils/DeserializationAssistant"; @@ -7,7 +8,6 @@ import { instanceofItemGroup, type Item } from "../interfacesAndTypes/entities"; import type { SerializedItem } from "../interfacesAndTypes/serialization"; import { ItemDelegateTopic } from "./ItemDelegate"; -import { UnsubscribeHandlerDelegate } from "./UnsubscribeHandlerDelegate"; export enum GroupDelegateTopic { CHILDREN = "CHILDREN", @@ -35,7 +35,7 @@ export class GroupDelegate implements PublishSubscribe(); - private _unsubscribeHandlerDelegate = new UnsubscribeHandlerDelegate(); + private _unsubscribeFunctionsManagerDelegate = new UnsubscribeFunctionsManagerDelegate(); private _treeRevisionNumber: number = 0; private _deserializing = false; @@ -232,10 +232,10 @@ export class GroupDelegate implements PublishSubscribe; }; private _publishSubscribeDelegate = new PublishSubscribeDelegate(); - private _unsubscribeHandler: UnsubscribeHandlerDelegate = new UnsubscribeHandlerDelegate(); + private _unsubscribeFunctionsManagerDelegate: UnsubscribeFunctionsManagerDelegate = + new UnsubscribeFunctionsManagerDelegate(); private _status: SettingsContextStatus = SettingsContextStatus.LOADING; private _storedData: NullableStoredData = {} as NullableStoredData; private _storedDataLoadingStatus: { [K in TStoredDataKey]: boolean } = {} as { @@ -93,7 +94,7 @@ export class SettingsContextDelegate< this._settings = settings; - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "dependencies", this.getDataProviderManager() .getPublishSubscribeDelegate() @@ -103,13 +104,13 @@ export class SettingsContextDelegate< ); for (const key in this._settings) { - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "settings", this._settings[key].getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.VALUE)(() => { this.handleSettingChanged(); }), ); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "settings", this._settings[key].getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.IS_LOADING)( () => { @@ -285,7 +286,7 @@ export class SettingsContextDelegate< } createDependencies(): void { - this._unsubscribeHandler.unsubscribe("dependencies"); + this._unsubscribeFunctionsManagerDelegate.unsubscribe("dependencies"); this._dependencies = []; @@ -294,14 +295,14 @@ export class SettingsContextDelegate< const setting = this._settings[key]; handler(setting.getValue() as unknown as TSettingTypes[K]); }; - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "dependencies", this._settings[key].getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.VALUE)( handleChange, ), ); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "dependencies", this._settings[key].getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.IS_LOADING)( () => { @@ -312,14 +313,14 @@ export class SettingsContextDelegate< ), ); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "dependencies", this._settings[key].getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.IS_PERSISTED)( handleChange, ), ); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "dependencies", this._settings[key].getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.IS_INITIALIZED)( handleChange, @@ -336,7 +337,7 @@ export class SettingsContextDelegate< const handleChange = (): void => { handler(this.getDataProviderManager.bind(this)().getGlobalSetting(key)); }; - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "dependencies", this.getDataProviderManager() .getPublishSubscribeDelegate() @@ -498,7 +499,7 @@ export class SettingsContextDelegate< } beforeDestroy(): void { - this._unsubscribeHandler.unsubscribeAll(); + this._unsubscribeFunctionsManagerDelegate.unsubscribeAll(); for (const dependency of this._dependencies) { dependency.beforeDestroy(); } diff --git a/frontend/src/modules/_shared/DataProviderFramework/delegates/SharedSettingsDelegate.ts b/frontend/src/modules/_shared/DataProviderFramework/delegates/SharedSettingsDelegate.ts index b13dc6fef..ab903d673 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/delegates/SharedSettingsDelegate.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/delegates/SharedSettingsDelegate.ts @@ -1,3 +1,5 @@ +import { UnsubscribeFunctionsManagerDelegate } from "@lib/utils/UnsubscribeFunctionsManagerDelegate"; + import { DataProviderManagerTopic, type GlobalSettings } from "../framework/DataProviderManager/DataProviderManager"; import { ExternalSettingController } from "../framework/ExternalSettingController/ExternalSettingController"; import { SettingTopic, type SettingManager } from "../framework/SettingManager/SettingManager"; @@ -11,7 +13,6 @@ import type { SettingsKeysFromTuple } from "../interfacesAndTypes/utils"; import type { MakeSettingTypesMap, SettingTypes, Settings } from "../settings/settingsDefinitions"; import { Dependency } from "./_utils/Dependency"; -import { UnsubscribeHandlerDelegate } from "./UnsubscribeHandlerDelegate"; export class SharedSettingsDelegate< TSettings extends Settings, @@ -24,7 +25,8 @@ export class SharedSettingsDelegate< private _wrappedSettings: { [K in TSettingKey]: SettingManager } = {} as { [K in TSettingKey]: SettingManager; }; - private _unsubscribeHandler: UnsubscribeHandlerDelegate = new UnsubscribeHandlerDelegate(); + private _unsubscribeFunctionsManagerDelegate: UnsubscribeFunctionsManagerDelegate = + new UnsubscribeFunctionsManagerDelegate(); private _dependencies: Dependency[] = []; private _parentItem: Item; private _customDependenciesDefinition: @@ -61,11 +63,11 @@ export class SharedSettingsDelegate< } unsubscribeAll(): void { - this._unsubscribeHandler.unsubscribeAll(); + this._unsubscribeFunctionsManagerDelegate.unsubscribeAll(); } beforeDestroy(): void { - this._unsubscribeHandler.unsubscribeAll(); + this._unsubscribeFunctionsManagerDelegate.unsubscribeAll(); for (const key in this._externalSettingControllers) { const externalSettingController = this._externalSettingControllers[key]; externalSettingController.beforeDestroy(); @@ -81,7 +83,7 @@ export class SharedSettingsDelegate< } createDependencies(): void { - this._unsubscribeHandler.unsubscribe("dependencies"); + this._unsubscribeFunctionsManagerDelegate.unsubscribe("dependencies"); this._dependencies = []; @@ -90,14 +92,14 @@ export class SharedSettingsDelegate< const setting = this._wrappedSettings[key]; handler(setting.getValue() as unknown as TSettingTypes[K]); }; - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "dependencies", this._wrappedSettings[key].getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.VALUE)( handleChange, ), ); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "dependencies", this._wrappedSettings[key] .getPublishSubscribeDelegate() @@ -108,14 +110,14 @@ export class SharedSettingsDelegate< }), ); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "dependencies", this._wrappedSettings[key] .getPublishSubscribeDelegate() .makeSubscriberFunction(SettingTopic.IS_PERSISTED)(handleChange), ); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "dependencies", this._wrappedSettings[key] .getPublishSubscribeDelegate() @@ -132,7 +134,7 @@ export class SharedSettingsDelegate< const handleChange = (): void => { handler(this._parentItem.getItemDelegate().getDataProviderManager().getGlobalSetting(key)); }; - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "dependencies", this._parentItem .getItemDelegate() diff --git a/frontend/src/modules/_shared/DataProviderFramework/delegates/UnsubscribeHandlerDelegate.ts b/frontend/src/modules/_shared/DataProviderFramework/delegates/UnsubscribeHandlerDelegate.ts deleted file mode 100644 index c8aca62c5..000000000 --- a/frontend/src/modules/_shared/DataProviderFramework/delegates/UnsubscribeHandlerDelegate.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * This class is used to manage the unsubscribe functions.. - * - * It provides a method for registering one ore more unsubscribe function for a specific - * topic and two methods for unsubscribing from a specific topic or from all topics, respectively. - */ -export class UnsubscribeHandlerDelegate { - private _subscriptions: Map void>> = new Map(); - - registerUnsubscribeFunction(topic: string, callback: () => void): void { - let subscriptionsSet = this._subscriptions.get(topic); - if (!subscriptionsSet) { - subscriptionsSet = new Set(); - this._subscriptions.set(topic, subscriptionsSet); - } - subscriptionsSet.add(callback); - } - - unsubscribe(topic: string): void { - const subscriptionsSet = this._subscriptions.get(topic); - if (subscriptionsSet) { - for (const unsubscribeFunc of subscriptionsSet) { - unsubscribeFunc(); - } - this._subscriptions.delete(topic); - } - } - - unsubscribeAll(): void { - for (const subscriptionsSet of this._subscriptions.values()) { - for (const unsubscribeFunc of subscriptionsSet) { - unsubscribeFunc(); - } - } - } -} diff --git a/frontend/src/modules/_shared/DataProviderFramework/framework/DataProvider/DataProvider.ts b/frontend/src/modules/_shared/DataProviderFramework/framework/DataProvider/DataProvider.ts index 091b08a5d..c93c51ffd 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/framework/DataProvider/DataProvider.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/framework/DataProvider/DataProvider.ts @@ -7,6 +7,7 @@ import { ApiErrorHelper } from "@framework/utils/ApiErrorHelper"; import { isDevMode } from "@lib/utils/devMode"; import type { PublishSubscribe } from "@lib/utils/PublishSubscribeDelegate"; import { PublishSubscribeDelegate } from "@lib/utils/PublishSubscribeDelegate"; +import { UnsubscribeFunctionsManagerDelegate } from "@lib/utils/UnsubscribeFunctionsManagerDelegate"; import { ScopedQueryController } from "@lib/utils/ScopedQueryController"; import { ItemDelegate } from "../../delegates/ItemDelegate"; @@ -15,7 +16,6 @@ import { SettingsContextDelegateTopic, SettingsContextStatus, } from "../../delegates/SettingsContextDelegate"; -import { UnsubscribeHandlerDelegate } from "../../delegates/UnsubscribeHandlerDelegate"; import type { CustomDataProviderImplementation, DataProviderInformationAccessors, @@ -114,7 +114,8 @@ export class DataProvider< private _settingsContextDelegate: SettingsContextDelegate; private _itemDelegate: ItemDelegate; private _dataProviderManager: DataProviderManager; - private _unsubscribeHandler: UnsubscribeHandlerDelegate = new UnsubscribeHandlerDelegate(); + private _unsubscribeFunctionsManagerDelegate: UnsubscribeFunctionsManagerDelegate = + new UnsubscribeFunctionsManagerDelegate(); private _publishSubscribeDelegate = new PublishSubscribeDelegate>(); private _status: DataProviderStatus = DataProviderStatus.IDLE; private _data: TData | null = null; @@ -155,7 +156,7 @@ export class DataProvider< dataProviderManager, ); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "settings-context", this._settingsContextDelegate .getPublishSubscribeDelegate() @@ -164,7 +165,7 @@ export class DataProvider< }), ); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "settings-context", this._settingsContextDelegate .getPublishSubscribeDelegate() @@ -452,7 +453,7 @@ export class DataProvider< beforeDestroy(): void { this._settingsContextDelegate.beforeDestroy(); - this._unsubscribeHandler.unsubscribeAll(); + this._unsubscribeFunctionsManagerDelegate.unsubscribeAll(); this._scopedQueryController.cancelActiveFetch(); if (this._debounceTimeout) { clearTimeout(this._debounceTimeout); diff --git a/frontend/src/modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager.ts b/frontend/src/modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager.ts index 517240a19..bb658852e 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/framework/DataProviderManager/DataProviderManager.ts @@ -6,17 +6,17 @@ import type { IntersectionPolyline } from "@framework/userCreatedItems/Intersect import { IntersectionPolylinesEvent } from "@framework/userCreatedItems/IntersectionPolylines"; import type { EnsembleRealizationFilterFunction, WorkbenchSession } from "@framework/WorkbenchSession"; import { - WorkbenchSessionEvent, + WorkbenchSessionTopic, createEnsembleRealizationFilterFuncForWorkbenchSession, } from "@framework/WorkbenchSession"; import type { WorkbenchSettings } from "@framework/WorkbenchSettings"; import { ColorPaletteType } from "@framework/WorkbenchSettings"; import type { PublishSubscribe } from "@lib/utils/PublishSubscribeDelegate"; import { PublishSubscribeDelegate } from "@lib/utils/PublishSubscribeDelegate"; +import { UnsubscribeFunctionsManagerDelegate } from "@lib/utils/UnsubscribeFunctionsManagerDelegate"; import { GroupDelegate, GroupDelegateTopic } from "../../delegates/GroupDelegate"; import { ItemDelegate } from "../../delegates/ItemDelegate"; -import { UnsubscribeHandlerDelegate } from "../../delegates/UnsubscribeHandlerDelegate"; import type { Item, ItemGroup } from "../../interfacesAndTypes/entities"; import { type SerializedDataProviderManager, SerializedType } from "../../interfacesAndTypes/serialization"; @@ -60,7 +60,7 @@ export class DataProviderManager implements ItemGroup, PublishSubscribe; - private _subscriptionsHandler = new UnsubscribeHandlerDelegate(); + private _unsubscribeFunctionsManagerDelegate = new UnsubscribeFunctionsManagerDelegate(); private _deserializing = false; private _groupColorGenerator: Generator; @@ -73,28 +73,30 @@ export class DataProviderManager implements ItemGroup, PublishSubscribe this.handleEnsembleSetChanged.bind(this), ), ); - this._subscriptionsHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "workbenchSession", - this._workbenchSession.subscribe( - WorkbenchSessionEvent.RealizationFilterSetChanged, + this._workbenchSession + .getPublishSubscribeDelegate() + .makeSubscriberFunction(WorkbenchSessionTopic.RealizationFilterSet)(() => this.handleRealizationFilterSetChanged.bind(this), ), ); - this._subscriptionsHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "workbenchSession", this._workbenchSession .getUserCreatedItems() .getIntersectionPolylines() .subscribe(IntersectionPolylinesEvent.CHANGE, this.handleIntersectionPolylinesChanged.bind(this)), ); - this._subscriptionsHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "groupDelegate", this._groupDelegate .getPublishSubscribeDelegate() @@ -102,7 +104,7 @@ export class DataProviderManager implements ItemGroup, PublishSubscribe> = new Set(); constructor(name: string, dataProviderManager: DataProviderManager) { this._groupDelegate = new GroupDelegate(this); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "children", this._groupDelegate.getPublishSubscribeDelegate().makeSubscriberFunction(GroupDelegateTopic.CHILDREN)( () => { @@ -31,7 +33,7 @@ export class DeltaSurface implements ItemGroup { } private handleChildrenChange(): void { - this._unsubscribeHandler.unsubscribe("providers"); + this._unsubscribeFunctionsManagerDelegate.unsubscribe("providers"); for (const provider of this._childrenDataProviderSet) { provider.setIsSubordinated(false); @@ -44,7 +46,7 @@ export class DeltaSurface implements ItemGroup { child.setIsSubordinated(true); this._childrenDataProviderSet.add(child); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "providers", child .getSettingsContextDelegate() diff --git a/frontend/src/modules/_shared/DataProviderFramework/framework/ExternalSettingController/ExternalSettingController.ts b/frontend/src/modules/_shared/DataProviderFramework/framework/ExternalSettingController/ExternalSettingController.ts index 77b6632ef..1bf119974 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/framework/ExternalSettingController/ExternalSettingController.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/framework/ExternalSettingController/ExternalSettingController.ts @@ -1,5 +1,6 @@ +import { UnsubscribeFunctionsManagerDelegate } from "@lib/utils/UnsubscribeFunctionsManagerDelegate"; + import type { GroupDelegate } from "../../delegates/GroupDelegate"; -import { UnsubscribeHandlerDelegate } from "../../delegates/UnsubscribeHandlerDelegate"; import { instanceofItemGroup, type Item } from "../../interfacesAndTypes/entities"; import type { AvailableValuesType } from "../../interfacesAndTypes/utils"; import { @@ -23,14 +24,15 @@ export class ExternalSettingController< private _setting: SettingManager; private _controlledSettings: Map> = new Map(); private _availableValuesMap: Map> = new Map(); - private _unsubscribeHandler: UnsubscribeHandlerDelegate = new UnsubscribeHandlerDelegate(); + private _unsubscribeFunctionsManagerDelegate: UnsubscribeFunctionsManagerDelegate = + new UnsubscribeFunctionsManagerDelegate(); constructor(parentItem: Item, setting: SettingManager) { this._parentItem = parentItem; this._setting = setting; const dataProviderManager = parentItem.getItemDelegate().getDataProviderManager(); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "data-provider-manager", dataProviderManager .getPublishSubscribeDelegate() @@ -38,7 +40,7 @@ export class ExternalSettingController< this.unregisterAllControlledSettings(); }), ); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "data-provider-manager", dataProviderManager.getPublishSubscribeDelegate().makeSubscriberFunction(DataProviderManagerTopic.ITEMS)( () => { @@ -49,7 +51,7 @@ export class ExternalSettingController< } beforeDestroy(): void { - this._unsubscribeHandler.unsubscribeAll(); + this._unsubscribeFunctionsManagerDelegate.unsubscribeAll(); this.unregisterAllControlledSettings(); } diff --git a/frontend/src/modules/_shared/DataProviderFramework/framework/SettingManager/SettingManager.ts b/frontend/src/modules/_shared/DataProviderFramework/framework/SettingManager/SettingManager.ts index 0f9a931af..84a58069f 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/framework/SettingManager/SettingManager.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/framework/SettingManager/SettingManager.ts @@ -5,8 +5,8 @@ import type { WorkbenchSession } from "@framework/WorkbenchSession"; import type { WorkbenchSettings } from "@framework/WorkbenchSettings"; import type { PublishSubscribe } from "@lib/utils/PublishSubscribeDelegate"; import { PublishSubscribeDelegate } from "@lib/utils/PublishSubscribeDelegate"; +import { UnsubscribeFunctionsManagerDelegate } from "@lib/utils/UnsubscribeFunctionsManagerDelegate"; -import { UnsubscribeHandlerDelegate } from "../../delegates/UnsubscribeHandlerDelegate"; import type { CustomSettingImplementation } from "../../interfacesAndTypes/customSettingImplementation"; import type { SettingAttributes } from "../../interfacesAndTypes/customSettingsHandler"; import type { AvailableValuesType, MakeAvailableValuesTypeBasedOnCategory } from "../../interfacesAndTypes/utils"; @@ -89,7 +89,8 @@ export class SettingManager< visible: true, }; private _externalController: ExternalSettingController | null = null; - private _unsubscribeHandler: UnsubscribeHandlerDelegate = new UnsubscribeHandlerDelegate(); + private _unsubscribeFunctionsManagerDelegate: UnsubscribeFunctionsManagerDelegate = + new UnsubscribeFunctionsManagerDelegate(); constructor({ type, @@ -115,7 +116,7 @@ export class SettingManager< ): void { this._externalController = externalController; this._value = externalController.getSetting().getValue(); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "external-setting-controller", externalController.getSetting().getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.VALUE)( () => { @@ -124,7 +125,7 @@ export class SettingManager< }, ), ); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "external-setting-controller", externalController.getSetting().getPublishSubscribeDelegate().makeSubscriberFunction(SettingTopic.IS_VALID)( () => { @@ -132,7 +133,7 @@ export class SettingManager< }, ), ); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "external-setting-controller", externalController .getSetting() @@ -141,7 +142,7 @@ export class SettingManager< this._publishSubscribeDelegate.notifySubscribers(SettingTopic.IS_LOADING); }), ); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "external-setting-controller", externalController .getSetting() @@ -150,7 +151,7 @@ export class SettingManager< this._publishSubscribeDelegate.notifySubscribers(SettingTopic.ATTRIBUTES); }), ); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "external-setting-controller", externalController .getSetting() @@ -159,7 +160,7 @@ export class SettingManager< this._publishSubscribeDelegate.notifySubscribers(SettingTopic.VALUE_ABOUT_TO_BE_CHANGED); }), ); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "external-setting-controller", externalController .getSetting() @@ -168,7 +169,7 @@ export class SettingManager< this._publishSubscribeDelegate.notifySubscribers(SettingTopic.IS_INITIALIZED); }), ); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "external-setting-controller", externalController .getSetting() @@ -177,7 +178,7 @@ export class SettingManager< this._publishSubscribeDelegate.notifySubscribers(SettingTopic.IS_PERSISTED); }), ); - this._unsubscribeHandler.registerUnsubscribeFunction( + this._unsubscribeFunctionsManagerDelegate.registerUnsubscribeFunction( "external-setting-controller", externalController .getSetting() @@ -191,12 +192,12 @@ export class SettingManager< unregisterExternalSettingController(): void { this._value = this._externalController?.getSetting().getValue() ?? this._value; this._externalController = null; - this._unsubscribeHandler.unsubscribe("external-setting-controller"); + this._unsubscribeFunctionsManagerDelegate.unsubscribe("external-setting-controller"); this.applyAvailableValues(); } beforeDestroy(): void { - this._unsubscribeHandler.unsubscribeAll(); + this._unsubscribeFunctionsManagerDelegate.unsubscribeAll(); } getId(): string { diff --git a/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customSettingImplementation.ts b/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customSettingImplementation.ts index 917b1be91..0a04f031a 100644 --- a/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customSettingImplementation.ts +++ b/frontend/src/modules/_shared/DataProviderFramework/interfacesAndTypes/customSettingImplementation.ts @@ -1,7 +1,6 @@ import type { WorkbenchSession } from "@framework/WorkbenchSession"; import type { WorkbenchSettings } from "@framework/WorkbenchSettings"; - import type { GlobalSettings } from "../framework/DataProviderManager/DataProviderManager"; import type { SettingCategory } from "../settings/settingsDefinitions"; diff --git a/frontend/src/modules/_shared/components/InplaceVolumesFilterComponent/inplaceVolumesFilterComponent.tsx b/frontend/src/modules/_shared/components/InplaceVolumesFilterComponent/inplaceVolumesFilterComponent.tsx index 369e78cac..4649f59c6 100644 --- a/frontend/src/modules/_shared/components/InplaceVolumesFilterComponent/inplaceVolumesFilterComponent.tsx +++ b/frontend/src/modules/_shared/components/InplaceVolumesFilterComponent/inplaceVolumesFilterComponent.tsx @@ -18,7 +18,7 @@ import { Select } from "@lib/components/Select"; export type InplaceVolumesFilterComponentProps = { ensembleSet: EnsembleSet; - settingsContext: SettingsContext; + settingsContext: SettingsContext; workbenchServices: WorkbenchServices; availableTableNames: string[]; availableIndicesWithValues: InplaceVolumesIndexWithValues_api[]; diff --git a/frontend/tests/unit/DeltaEnsemble.test.ts b/frontend/tests/unit/DeltaEnsemble.test.ts index bcaeca4b2..51b38351f 100644 --- a/frontend/tests/unit/DeltaEnsemble.test.ts +++ b/frontend/tests/unit/DeltaEnsemble.test.ts @@ -5,7 +5,6 @@ import { DeltaEnsembleIdent } from "@framework/DeltaEnsembleIdent"; import { EnsembleParameters, ParameterType } from "@framework/EnsembleParameters"; import { SensitivityType } from "@framework/EnsembleSensitivities"; import { RegularEnsemble } from "@framework/RegularEnsemble"; -import { makeEnsembleTimeStamp } from "tests/utils/ensemble"; describe("DeltaEnsemble", () => { const COMPARISON_ENSEMBLE = new RegularEnsemble( @@ -30,7 +29,6 @@ describe("DeltaEnsemble", () => { [{ name: "sens1", type: SensitivityType.MONTECARLO, cases: [] }], "blue", "Custom Name First Ensemble", - makeEnsembleTimeStamp(), ); const REFERENCE_ENSEMBLE = new RegularEnsemble( @@ -55,7 +53,6 @@ describe("DeltaEnsemble", () => { [{ name: "sens1", type: SensitivityType.MONTECARLO, cases: [] }], "green", "Custom Name Second Ensemble", - makeEnsembleTimeStamp(), ); const DELTA_ENSEMBLE = new DeltaEnsemble( diff --git a/frontend/tests/unit/RegularEnsemble.test.ts b/frontend/tests/unit/RegularEnsemble.test.ts index 7a168393e..97d17fbc7 100644 --- a/frontend/tests/unit/RegularEnsemble.test.ts +++ b/frontend/tests/unit/RegularEnsemble.test.ts @@ -5,7 +5,6 @@ import { EnsembleParameters, ParameterType } from "@framework/EnsembleParameters import type { Sensitivity } from "@framework/EnsembleSensitivities"; import { EnsembleSensitivities, SensitivityType } from "@framework/EnsembleSensitivities"; import { RegularEnsemble } from "@framework/RegularEnsemble"; -import { makeEnsembleTimeStamp } from "tests/utils/ensemble"; describe("RegularEnsemble", () => { const fieldIdentifier = "field1"; @@ -41,7 +40,6 @@ describe("RegularEnsemble", () => { sensitivityArray, color, customName, - makeEnsembleTimeStamp(), ); test("should create an instance of RegularEnsemble", () => { diff --git a/frontend/tests/utils/ensemble.ts b/frontend/tests/utils/ensemble.ts deleted file mode 100644 index 76ca41f74..000000000 --- a/frontend/tests/utils/ensemble.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { EnsembleTimestamps_api } from "@api"; - -export function makeEnsembleTimeStamp(): EnsembleTimestamps_api { - return { - caseUpdatedAtUtcMs: Date.now(), - dataUpdatedAtUtcMs: Date.now(), - }; -} diff --git a/nginx.conf b/nginx.conf index f49a60de3..ce3a0172b 100644 --- a/nginx.conf +++ b/nginx.conf @@ -59,6 +59,11 @@ http { location / { proxy_http_version 1.1; root /usr/share/nginx/dist; + # Always serve index.html for SPA routing if the requested file is not found + # This allows the frontend to handle routing instead of nginx + index index.html; + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache"; # At the moment we need the "connect-src 'self' data:"" entry in order to use PNG images as data format add_header Content-Security-Policy "default-src 'self'; connect-src 'self' data:; style-src 'self' 'unsafe-inline' https://cdn.eds.equinor.com; script-src 'self' 'unsafe-eval' blob:; font-src https://cdn.eds.equinor.com; img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none';"; @@ -68,5 +73,13 @@ http { add_header X-XSS-Protection "1; mode=block"; add_header Referrer-Policy "no-referrer"; } + + location ~ ^/snapshot/(?[^/]+)$ { + if ($http_user_agent ~* (Slackbot|Discordbot|facebookexternalhit|Twitterbot|LinkedInBot|Teams|MicrosoftOffice)) { + proxy_pass http://backend-primary:5000/api/snapshot-preview/$snapshot_id; + break; + } + try_files $uri $uri/ /index.html; + } } }