diff --git a/buf.lock b/buf.lock index 3982955..df9621c 100644 --- a/buf.lock +++ b/buf.lock @@ -2,8 +2,8 @@ version: v2 deps: - name: buf.build/agynio/api - commit: 0294a1d83c944622abc5a9ea4ddbfac9 - digest: b5:9704a81fd9038e9432107a7d6814ed737836dcd95eac2f96ad05b74b6cedc8748eb92ef043c3d447999a4e568a56c5ebeb08cdfa323c8d72a0087e138f70df8a + commit: c93d50fb004044178f3a64bc851f27c0 + digest: b5:6b6e4567cc72c54a2acbdbd0608a623ab0f17d9a80cc8a175b2b9bc6ec661fa3945a93b1a2dd3c3b27c19123d77aacd1f32fd47cb32144bcc01647a1614119dd - name: buf.build/opentelemetry/opentelemetry commit: 5f2c7d4f740541589805e0816dad4bb0 digest: b5:935626c896cfe0f5efda467869c65df49843190c1d16ad24957d62ae840aef5f5da2474ec5c766e730b2727c6a24b39b5a2a073da70cf3f2611ce7d3934def86 diff --git a/package-lock.json b/package-lock.json index 3c272a3..d01bd87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "tw-animate-css": "^1.4.0" }, "devDependencies": { - "@bufbuild/buf": "latest", + "@bufbuild/buf": "1.67.0", "@bufbuild/protoc-gen-es": "^2.0.0", "@connectrpc/connect-node": "^2.1.1", "@eslint/js": "^9.35.0", @@ -50,90 +50,6 @@ "vitest": "3.2.4" } }, - "node_modules/@argos-ci/api-client": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@argos-ci/api-client/-/api-client-0.17.0.tgz", - "integrity": "sha512-z74ZdqT46+sirGlJUXXn1vhZZshUU50bRLfeVCeHilEf585N8GG7demF4vtY+dp226jGqSf+CkgBfg/O48AxAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "openapi-fetch": "^0.17.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@argos-ci/browser": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@argos-ci/browser/-/browser-5.1.3.tgz", - "integrity": "sha512-2acfqcb7A2VNqm43bBk90PleBV+qCwO68FNmoGn28zs3d10G3W3/ZkqtoYUY3ycWclWQ/KYxH8SdHQfz4iB3ow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@argos-ci/core": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@argos-ci/core/-/core-5.2.0.tgz", - "integrity": "sha512-DUTcNf8VFtlZj2h2qH1GP7lvDiaX4v+rZs/SQA2uAQp4J6Oapl40D90O4Hum8XZt1kaKC5BrxeDAohomH/plkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@argos-ci/api-client": "0.17.0", - "@argos-ci/util": "3.4.0", - "convict": "^6.2.5", - "debug": "^4.4.3", - "fast-glob": "^3.3.3", - "mime-types": "^3.0.2", - "sharp": "^0.34.5", - "tmp": "^0.2.5" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@argos-ci/playwright": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/@argos-ci/playwright/-/playwright-6.6.1.tgz", - "integrity": "sha512-GdNeCDjBX0vozgDg7jYzBY0iSQobjmNgOXjCpHQl8k8ZuwgAQ+oklfWx45uu4L1Sa6aZ8NmR6UCABTPCESXSJA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@argos-ci/browser": "5.1.3", - "@argos-ci/core": "5.2.0", - "@argos-ci/util": "3.4.0", - "chalk": "^5.6.2", - "debug": "^4.4.3" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@argos-ci/playwright/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@argos-ci/util": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@argos-ci/util/-/util-3.4.0.tgz", - "integrity": "sha512-rPa8xu6k0TkK1V4CIapcx2x/ncB7dvGa0adXCkflQf+rZZvZaVI3jCVXR57bCZn+S5AWndj4+A/7pX5xV9krvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -796,17 +712,6 @@ "node": ">=18" } }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -1456,508 +1361,18 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], + "license": "Apache-2.0", "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=18.18" }, "funding": { - "url": "https://opencollective.com/libvips" + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, "node_modules/@jridgewell/gen-mapping": { @@ -2005,60 +1420,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@playwright/test": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", - "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.59.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -5165,19 +4526,6 @@ "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -5412,20 +4760,6 @@ "dev": true, "license": "MIT" }, - "node_modules/convict": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/convict/-/convict-6.2.5.tgz", - "integrity": "sha512-JtXpxqDqJ8P0UwEHwhxLzCIXQy97vlYBZR222Sbzb1q1Erex9ASrztJ29SyhWFQjod1AeFBaPzEEC8YvtZMIYg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "lodash.clonedeep": "^4.5.0", - "yargs-parser": "^20.2.7" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -6054,36 +5388,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -6098,16 +5402,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -6138,19 +5432,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -6497,16 +5778,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -6947,13 +6218,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7170,16 +6434,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -7622,60 +6876,6 @@ ], "license": "MIT" }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -7746,23 +6946,6 @@ "node": ">=18" } }, - "node_modules/openapi-fetch": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", - "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", - "dev": true, - "license": "MIT", - "dependencies": { - "openapi-typescript-helpers": "^0.1.0" - } - }, - "node_modules/openapi-typescript-helpers": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", - "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", - "dev": true, - "license": "MIT" - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7919,53 +7102,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/playwright": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", - "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.59.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -8054,27 +7190,6 @@ "node": ">=6" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/radix-ui": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", @@ -8454,17 +7569,6 @@ "node": ">=4" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -8516,30 +7620,6 @@ "dev": true, "license": "MIT" }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -8582,64 +7662,6 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/sharp/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -8913,29 +7935,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/tough-cookie": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", @@ -9618,16 +8617,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 9bc9488..79b508a 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "tw-animate-css": "^1.4.0" }, "devDependencies": { - "@bufbuild/buf": "latest", + "@bufbuild/buf": "1.67.0", "@bufbuild/protoc-gen-es": "^2.0.0", "@connectrpc/connect-node": "^2.1.1", "@eslint/js": "^9.35.0", diff --git a/src/App.tsx b/src/App.tsx index 2140c19..895860c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,7 @@ import { UserDetailPage } from '@/pages/UserDetailPage'; import { RunnersListPage } from '@/pages/RunnersListPage'; import { RunnerDetailPage } from '@/pages/RunnerDetailPage'; import { WorkloadDetailPage } from '@/pages/WorkloadDetailPage'; +import { VolumeDetailPage } from '@/pages/VolumeDetailPage'; import { SettingsPage } from '@/pages/SettingsPage'; import { AppsPage } from '@/pages/AppsPage'; import { ApiTokensPage } from '@/pages/ApiTokensPage'; @@ -61,6 +62,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/__tests__/organization-context.test.tsx b/src/__tests__/organization-context.test.tsx index b208c11..b284351 100644 --- a/src/__tests__/organization-context.test.tsx +++ b/src/__tests__/organization-context.test.tsx @@ -14,15 +14,17 @@ import { type UserContextValue = ReturnType; -const { listMyMemberships, listAccessibleOrganizations } = vi.hoisted(() => ({ +const { listMyMemberships, listOrganizations, getOrganization } = vi.hoisted(() => ({ listMyMemberships: vi.fn(), - listAccessibleOrganizations: vi.fn(), + listOrganizations: vi.fn(), + getOrganization: vi.fn(), })); vi.mock('@/api/client', () => ({ organizationsClient: { listMyMemberships, - listAccessibleOrganizations, + listOrganizations, + getOrganization, }, })); @@ -75,6 +77,21 @@ function mockMemberships(active: Membership[], pending: Membership[] = []) { }); } +function mockOrganizationLookup(organizations: Array<{ id: string; name: string }>) { + const lookup = new Map(organizations.map((org) => [org.id, org])); + getOrganization.mockImplementation(({ id }: { id: string }) => { + const org = lookup.get(id); + return Promise.resolve({ organization: org ? create(OrganizationSchema, org) : undefined }); + }); +} + +function mockOrganizationList(organizations: Array<{ id: string; name: string }>) { + listOrganizations.mockResolvedValue({ + organizations: organizations.map((org) => create(OrganizationSchema, org)), + nextPageToken: '', + }); +} + describe('OrganizationContext', () => { afterEach(() => { cleanup(); @@ -93,7 +110,8 @@ describe('OrganizationContext', () => { window.localStorage.clear(); listMyMemberships.mockReset(); - listAccessibleOrganizations.mockReset(); + listOrganizations.mockReset(); + getOrganization.mockReset(); }); it('migrates legacy selections into context mode storage', async () => { @@ -108,13 +126,10 @@ describe('OrganizationContext', () => { status: MembershipStatus.ACTIVE, }), ]); - - listAccessibleOrganizations.mockResolvedValue({ - organizations: [ - create(OrganizationSchema, { id: 'org-1', name: 'Org One' }), - create(OrganizationSchema, { id: 'org-2', name: 'Org Two' }), - ], - }); + mockOrganizationLookup([ + { id: 'org-1', name: 'Org One' }, + { id: 'org-2', name: 'Org Two' }, + ]); renderWithProviders(); @@ -138,13 +153,10 @@ describe('OrganizationContext', () => { status: MembershipStatus.ACTIVE, }), ]); - - listAccessibleOrganizations.mockResolvedValue({ - organizations: [ - create(OrganizationSchema, { id: 'org-2', name: 'Zeta Org' }), - create(OrganizationSchema, { id: 'org-1', name: 'Alpha Org' }), - ], - }); + mockOrganizationLookup([ + { id: 'org-2', name: 'Zeta Org' }, + { id: 'org-1', name: 'Alpha Org' }, + ]); renderWithProviders(); @@ -166,10 +178,7 @@ describe('OrganizationContext', () => { status: MembershipStatus.ACTIVE, }), ]); - - listAccessibleOrganizations.mockResolvedValue({ - organizations: [create(OrganizationSchema, { id: 'org-1', name: 'Org One' })], - }); + mockOrganizationList([{ id: 'org-1', name: 'Org One' }]); renderWithProviders(); @@ -182,7 +191,7 @@ describe('OrganizationContext', () => { userContext.isClusterAdmin = true; mockMemberships([]); - listAccessibleOrganizations.mockResolvedValue({ organizations: [] }); + mockOrganizationList([]); renderWithProviders(); @@ -208,13 +217,10 @@ describe('OrganizationContext', () => { status: MembershipStatus.ACTIVE, }), ]); - - listAccessibleOrganizations.mockResolvedValue({ - organizations: [ - create(OrganizationSchema, { id: 'org-1', name: 'Org One' }), - create(OrganizationSchema, { id: 'org-2', name: 'Org Two' }), - ], - }); + mockOrganizationLookup([ + { id: 'org-1', name: 'Org One' }, + { id: 'org-2', name: 'Org Two' }, + ]); renderWithProviders(); @@ -227,12 +233,10 @@ describe('OrganizationContext', () => { userContext.isClusterAdmin = true; mockMemberships([]); - listAccessibleOrganizations.mockResolvedValue({ - organizations: [ - create(OrganizationSchema, { id: 'org-1', name: 'Org One' }), - create(OrganizationSchema, { id: 'org-2', name: 'Org Two' }), - ], - }); + mockOrganizationList([ + { id: 'org-1', name: 'Org One' }, + { id: 'org-2', name: 'Org Two' }, + ]); renderWithProviders(); @@ -258,8 +262,7 @@ describe('OrganizationContext', () => { status: MembershipStatus.PENDING, }), ]); - - listAccessibleOrganizations.mockResolvedValue({ organizations: [] }); + mockOrganizationLookup([]); renderWithProviders(); @@ -278,8 +281,7 @@ describe('OrganizationContext', () => { status: MembershipStatus.PENDING, }), ]); - - listAccessibleOrganizations.mockResolvedValue({ organizations: [] }); + mockOrganizationLookup([]); renderWithProviders(); @@ -298,10 +300,7 @@ describe('OrganizationContext', () => { status: MembershipStatus.ACTIVE, }), ]); - - listAccessibleOrganizations.mockResolvedValue({ - organizations: [create(OrganizationSchema, { id: 'org-1', name: 'Org One' })], - }); + mockOrganizationLookup([{ id: 'org-1', name: 'Org One' }]); renderWithProviders(); @@ -327,8 +326,7 @@ describe('OrganizationContext', () => { status: MembershipStatus.PENDING, }), ]); - - listAccessibleOrganizations.mockResolvedValue({ organizations: [] }); + mockOrganizationLookup([]); renderWithProviders(); diff --git a/src/components/MultiSelectFilter.tsx b/src/components/MultiSelectFilter.tsx new file mode 100644 index 0000000..76d5be3 --- /dev/null +++ b/src/components/MultiSelectFilter.tsx @@ -0,0 +1,90 @@ +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +export type MultiSelectOption = { + value: string; + label: string; + secondary?: string; +}; + +type MultiSelectFilterProps = { + label: string; + options: MultiSelectOption[]; + selectedValues: string[]; + onChange: (values: string[]) => void; + testId?: string; + emptyLabel?: string; +}; + +export function MultiSelectFilter({ + label, + options, + selectedValues, + onChange, + testId, + emptyLabel = 'No options available', +}: MultiSelectFilterProps) { + const selected = new Set(selectedValues); + const selectedCount = selectedValues.length; + const triggerLabel = selectedCount > 0 ? `${label} (${selectedCount})` : label; + + const applySelection = (nextSelected: Set) => { + const ordered = options.filter((option) => nextSelected.has(option.value)).map((option) => option.value); + onChange(ordered); + }; + + return ( + + + + + + {options.length === 0 ? ( + {emptyLabel} + ) : ( + options.map((option) => ( + { + const nextSelected = new Set(selected); + if (checked) { + nextSelected.add(option.value); + } else { + nextSelected.delete(option.value); + } + applySelection(nextSelected); + }} + > +
+ {option.label} + {option.secondary ? {option.secondary} : null} +
+
+ )) + )} + {selectedCount > 0 ? ( + <> + + { + onChange([]); + }} + > + Clear selections + + + ) : null} +
+
+ ); +} diff --git a/src/components/WorkloadsTable.tsx b/src/components/WorkloadsTable.tsx index 0a214b6..37f26e9 100644 --- a/src/components/WorkloadsTable.tsx +++ b/src/components/WorkloadsTable.tsx @@ -1,5 +1,6 @@ +import type { ReactNode } from 'react'; import type { InfiniteData, UseInfiniteQueryResult } from '@tanstack/react-query'; -import { NavLink, useLocation } from 'react-router-dom'; +import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { SortableHeader } from '@/components/SortableHeader'; import { LoadMoreButton } from '@/components/LoadMoreButton'; import { Badge } from '@/components/ui/badge'; @@ -8,14 +9,45 @@ import { Card, CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import type { ListWorkloadsResponse, Workload } from '@/gen/agynio/api/runners/v1/runners_pb'; import { WorkloadStatus } from '@/gen/agynio/api/runners/v1/runners_pb'; -import { useListControls } from '@/hooks/useListControls'; -import { formatTimestamp, formatWorkloadStatus, summarizeContainers, timestampToMillis } from '@/lib/format'; +import { type SortDirection, useListControls } from '@/hooks/useListControls'; +import { + EMPTY_PLACEHOLDER, + formatDurationBetween, + formatTimestamp, + formatWorkloadStatus, + summarizeContainers, + timestampToMillis, +} from '@/lib/format'; + +export type WorkloadSortKey = 'agentId' | 'runnerId' | 'threadId' | 'status' | 'started' | 'duration'; + +type WorkloadsTableControls = { + searchTerm?: string; + onSearchTermChange?: (value: string) => void; + sortKey: WorkloadSortKey; + sortDirection: SortDirection; + onSort: (key: WorkloadSortKey) => void; +}; type WorkloadsTableProps = { workloads: Workload[]; query: UseInfiniteQueryResult, Error>; showRunnerColumn?: boolean; + showDuration?: boolean; + showSearch?: boolean; getWorkloadLink?: (workload: Workload) => string | null; + getAgentName?: (workload: Workload) => string | undefined; + getRunnerName?: (workload: Workload) => string | undefined; + getAgentLink?: (workload: Workload) => string | null; + getRunnerLink?: (workload: Workload) => string | null; + agentLabel?: string; + runnerLabel?: string; + rowLinkMode?: 'row' | 'action'; + actionLabel?: string; + controls?: WorkloadsTableControls; + filterBar?: ReactNode; + searchPlaceholder?: string; + hasActiveFilters?: boolean; testIdPrefix: string; }; @@ -23,26 +55,64 @@ export function WorkloadsTable({ workloads, query, showRunnerColumn = false, + showDuration = false, + showSearch = true, getWorkloadLink, + getAgentName, + getRunnerName, + getAgentLink, + getRunnerLink, + agentLabel = 'Agent', + runnerLabel = 'Runner', + rowLinkMode = 'action', + actionLabel, + controls, + filterBar, + searchPlaceholder = 'Search workloads...', + hasActiveFilters, testIdPrefix, }: WorkloadsTableProps) { const location = useLocation(); + const navigate = useNavigate(); + const resolveAgentName = (workload: Workload) => { + const name = getAgentName ? getAgentName(workload) : workload.agentName; + return name?.trim() || ''; + }; + const resolveRunnerName = (workload: Workload) => { + const name = getRunnerName ? getRunnerName(workload) : workload.runnerName; + return name?.trim() || ''; + }; + const resolveAgentSortKey = (workload: Workload) => resolveAgentName(workload) || workload.agentId; + const resolveRunnerSortKey = (workload: Workload) => resolveRunnerName(workload) || workload.runnerId; + const resolveDurationEnd = (workload: Workload) => + workload.removedAt ?? + (workload.status === WorkloadStatus.STOPPED || workload.status === WorkloadStatus.FAILED + ? workload.lastActivityAt + : undefined); + const resolveDurationMillis = (workload: Workload) => { + const startMillis = timestampToMillis(workload.meta?.createdAt); + if (!startMillis) return 0; + const endTimestamp = resolveDurationEnd(workload); + const endMillis = endTimestamp ? timestampToMillis(endTimestamp) : Date.now(); + return Math.max(0, endMillis - startMillis); + }; const searchFields = [ - (workload: Workload) => workload.agentId, - ...(showRunnerColumn ? [(workload: Workload) => workload.runnerId] : []), + (workload: Workload) => resolveAgentName(workload) || workload.agentId, + ...(showRunnerColumn ? [(workload: Workload) => resolveRunnerName(workload) || workload.runnerId] : []), (workload: Workload) => workload.threadId, (workload: Workload) => formatWorkloadStatus(workload.status), ]; const sortOptions: Record string | number> = { - agentId: (workload) => workload.agentId, + agentId: (workload) => resolveAgentSortKey(workload), threadId: (workload) => workload.threadId, status: (workload) => formatWorkloadStatus(workload.status), started: (workload) => timestampToMillis(workload.meta?.createdAt), + duration: (workload) => resolveDurationMillis(workload), }; if (showRunnerColumn) { - sortOptions.runnerId = (workload) => workload.runnerId; + sortOptions.runnerId = (workload) => resolveRunnerSortKey(workload); } const listControls = useListControls({ @@ -53,9 +123,17 @@ export function WorkloadsTable({ defaultSortDirection: 'desc', }); - const visibleWorkloads = listControls.filteredItems; - const hasSearch = listControls.searchTerm.trim().length > 0; - const hasAction = Boolean(getWorkloadLink); + const searchTerm = controls?.searchTerm ?? listControls.searchTerm; + const handleSearchChange = controls?.onSearchTermChange ?? listControls.setSearchTerm; + const sortKey = controls?.sortKey ?? (listControls.sortKey as WorkloadSortKey); + const sortDirection = controls?.sortDirection ?? listControls.sortDirection; + const handleSort = controls?.onSort ?? listControls.handleSort; + + const visibleWorkloads = controls ? workloads : listControls.filteredItems; + const hasSearch = showSearch && searchTerm.trim().length > 0; + const hasFilters = controls ? (hasActiveFilters ?? hasSearch) : hasSearch; + const actionLabelText = actionLabel?.trim() || 'View'; + const hasAction = rowLinkMode === 'action' && Boolean(getWorkloadLink); const getStatusVariant = (status: WorkloadStatus) => { if (status === WorkloadStatus.RUNNING) return 'default'; @@ -65,25 +143,36 @@ export function WorkloadsTable({ return 'outline'; }; - const gridClass = showRunnerColumn - ? hasAction - ? 'md:grid-cols-[1.4fr_1.4fr_1.4fr_140px_200px_170px_120px]' - : 'md:grid-cols-[1.4fr_1.4fr_1.4fr_140px_200px_170px]' - : hasAction - ? 'md:grid-cols-[1.6fr_1.6fr_140px_200px_170px_120px]' - : 'md:grid-cols-[1.6fr_1.6fr_140px_200px_170px]'; + const gridColumns: string[] = []; + gridColumns.push(showRunnerColumn ? '1.4fr' : '1.6fr'); + if (showRunnerColumn) gridColumns.push('1.4fr'); + gridColumns.push(showRunnerColumn ? '1.4fr' : '1.6fr'); + gridColumns.push('140px'); + gridColumns.push('200px'); + gridColumns.push('170px'); + if (showDuration) gridColumns.push('140px'); + if (hasAction) { + const actionWidth = actionLabelText.length > 6 ? '160px' : '120px'; + gridColumns.push(actionWidth); + } + const gridClass = `md:grid-cols-[${gridColumns.join('_')}]`; const emptyMessage = showRunnerColumn ? 'No workloads found.' : 'No workloads on this runner.'; return (
-
- listControls.setSearchTerm(event.target.value)} - data-testid={`${testIdPrefix}-search`} - /> +
+ {showSearch ? ( +
+ handleSearchChange(event.target.value)} + data-testid={`${testIdPrefix}-search`} + /> +
+ ) : null} + {filterBar}
{query.isPending ?
Loading workloads...
: null} {query.isError ?
Failed to load workloads.
: null} @@ -94,49 +183,62 @@ export function WorkloadsTable({ data-testid={`${testIdPrefix}-header`} > {showRunnerColumn ? ( ) : null} - + {controls ? ( + Thread ID + ) : ( + + )} Containers + {showDuration ? ( + + ) : null} {hasAction ? Action : null}
{visibleWorkloads.length === 0 ? (
- {hasSearch ? 'No results found.' : emptyMessage} + {hasFilters ? 'No results found.' : emptyMessage}
) : ( visibleWorkloads.map((workload) => { @@ -146,22 +248,49 @@ export function WorkloadsTable({ ? `${workload.runnerId}:${workload.threadId}:${workload.agentId}` : `${workload.threadId}:${workload.agentId}`); const workloadLink = getWorkloadLink ? getWorkloadLink(workload) : null; - return ( -
- - {workload.agentId || '—'} - + const agentName = resolveAgentName(workload); + const runnerName = resolveRunnerName(workload); + const agentLabel = agentName || EMPTY_PLACEHOLDER; + const runnerLabelText = runnerName || EMPTY_PLACEHOLDER; + const agentLink = agentLabel !== EMPTY_PLACEHOLDER ? getAgentLink?.(workload) ?? null : null; + const runnerLink = runnerLabelText !== EMPTY_PLACEHOLDER ? getRunnerLink?.(workload) ?? null : null; + const durationEnd = resolveDurationEnd(workload); + const durationLabel = showDuration + ? formatDurationBetween(workload.meta?.createdAt, durationEnd) + : EMPTY_PLACEHOLDER; + + const rowContent = ( + <> +
+ {agentLink ? ( + event.stopPropagation()} + > + {agentLabel} + + ) : ( + {agentLabel} + )} +
{showRunnerColumn ? ( - - {workload.runnerId || '—'} - +
+ {runnerLink ? ( + event.stopPropagation()} + > + {runnerLabelText} + + ) : ( + {runnerLabelText} + )} +
) : null} - {workload.threadId || '—'} + {workload.threadId || EMPTY_PLACEHOLDER} {formatWorkloadStatus(workload.status)} @@ -172,6 +301,11 @@ export function WorkloadsTable({ {formatTimestamp(workload.meta?.createdAt)} + {showDuration ? ( + + {durationLabel} + + ) : null} {hasAction ? (
{workloadLink ? ( @@ -181,16 +315,52 @@ export function WorkloadsTable({ state={{ from: location.pathname }} data-testid={`${testIdPrefix}-view`} > - View + {actionLabelText} ) : ( )}
) : null} + + ); + + if (rowLinkMode === 'row' && workloadLink) { + return ( +
{ + const target = event.target; + if (target instanceof Element && target.closest('a, button')) return; + navigate(workloadLink, { state: { from: location.pathname } }); + }} + onKeyDown={(event) => { + if (event.currentTarget !== event.target) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + navigate(workloadLink, { state: { from: location.pathname } }); + } + }} + > + {rowContent} +
+ ); + } + + return ( +
+ {rowContent}
); }) diff --git a/src/context/OrganizationContext.tsx b/src/context/OrganizationContext.tsx index 6239d53..6981749 100644 --- a/src/context/OrganizationContext.tsx +++ b/src/context/OrganizationContext.tsx @@ -133,15 +133,6 @@ export function OrganizationProvider({ children }: { children: ReactNode }) { refetchOnWindowFocus: false, }); - // Accessible orgs provide metadata even when memberships are filtered by status. - const organizationsQuery = useQuery({ - queryKey: ['organizations', 'accessible', identityId], - queryFn: () => organizationsClient.listAccessibleOrganizations({ identityId: identityId ?? '' }), - enabled: userStatus === 'ready' && Boolean(identityId), - staleTime: 60 * 1000, - refetchOnWindowFocus: false, - }); - const memberships = useMemo( () => membershipsQuery.data?.memberships ?? [], [membershipsQuery.data?.memberships], @@ -150,14 +141,56 @@ export function OrganizationProvider({ children }: { children: ReactNode }) { () => pendingMembershipsQuery.data?.memberships ?? [], [pendingMembershipsQuery.data?.memberships], ); - const accessibleOrganizations = useMemo( + const organizationIds = useMemo(() => { + const ids = new Set(); + memberships.forEach((membership) => { + if (membership.organizationId) ids.add(membership.organizationId); + }); + return Array.from(ids).sort((a, b) => a.localeCompare(b)); + }, [memberships]); + + const organizationsQuery = useQuery({ + queryKey: isClusterAdmin + ? ['organizations', 'list'] + : ['organizations', 'by-membership', organizationIds], + queryFn: async () => { + if (isClusterAdmin) { + const organizations: Organization[] = []; + let pageToken = ''; + do { + const response = await organizationsClient.listOrganizations({ + pageSize: MAX_PAGE_SIZE, + pageToken, + }); + organizations.push(...response.organizations); + pageToken = response.nextPageToken; + } while (pageToken); + return { organizations }; + } + if (organizationIds.length === 0) { + return { organizations: [] }; + } + const responses = await Promise.all( + organizationIds.map((id) => organizationsClient.getOrganization({ id })), + ); + const organizations = responses.flatMap((response) => + response.organization ? [response.organization] : [], + ); + return { organizations }; + }, + enabled: userStatus === 'ready' && Boolean(identityId) && (isClusterAdmin || membershipsQuery.isSuccess), + staleTime: 60 * 1000, + refetchOnWindowFocus: false, + }); + + const listedOrganizations = useMemo( () => organizationsQuery.data?.organizations ?? [], [organizationsQuery.data?.organizations], ); const mappedOrganizations = useMemo( - () => mapOrganizations(accessibleOrganizations, memberships), - [accessibleOrganizations, memberships], + () => mapOrganizations(listedOrganizations, memberships), + [listedOrganizations, memberships], ); const visibleOrganizations = useMemo(() => { diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 63d7d93..8590f80 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -1,43 +1,49 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { notificationsClient } from '@/api/client'; +import type { NotificationEnvelope } from '@/gen/agynio/api/notifications/v1/notifications_pb'; type UseNotificationsOptions = { events: string[]; - invalidateKeys: string[][]; rooms: string[]; enabled?: boolean; + invalidateKeys?: string[][]; + onEvent?: (envelope: NotificationEnvelope) => void; }; export function useNotifications(options: UseNotificationsOptions): void { - const { events, invalidateKeys, rooms, enabled = true } = options; + const { events, rooms, enabled = true, invalidateKeys = [], onEvent } = options; const queryClient = useQueryClient(); const eventsRef = useRef(events); const keysRef = useRef(invalidateKeys); - const roomsRef = useRef(rooms); - const roomsKey = rooms.join('|'); - const hasRooms = rooms.length > 0; + const onEventRef = useRef(onEvent); + const roomsRef = useRef([]); eventsRef.current = events; keysRef.current = invalidateKeys; - roomsRef.current = rooms; + onEventRef.current = onEvent; + const normalizedRooms = useMemo( + () => rooms.map((room) => room.trim()).filter((room) => room.length > 0), + [rooms], + ); + roomsRef.current = normalizedRooms; + const roomsKey = normalizedRooms.join('|'); useEffect(() => { - if (!enabled) return; - if (!hasRooms) { - console.error('[useNotifications] rooms are required to subscribe'); - return; - } + if (!enabled || roomsRef.current.length === 0) return; const controller = new AbortController(); - const requestRooms = roomsRef.current; (async () => { try { - for await (const response of notificationsClient.subscribe({ rooms: requestRooms }, { signal: controller.signal })) { + for await (const response of notificationsClient.subscribe( + { rooms: roomsRef.current }, + { signal: controller.signal }, + )) { const envelope = response.envelope; if (!envelope) continue; if (!eventsRef.current.includes(envelope.event)) continue; + onEventRef.current?.(envelope); for (const key of keysRef.current) { void queryClient.invalidateQueries({ queryKey: key }); } @@ -52,5 +58,5 @@ export function useNotifications(options: UseNotificationsOptions): void { return () => { controller.abort(); }; - }, [enabled, hasRooms, queryClient, roomsKey]); + }, [enabled, queryClient, roomsKey]); } diff --git a/src/lib/format.ts b/src/lib/format.ts index 1682fbe..41cae60 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -34,6 +34,39 @@ export function formatDateOnly(timestamp?: Timestamp | null): string { return formatTimestamp(timestamp, { dateStyle: 'medium' }); } +export function formatDuration(milliseconds: number): string { + if (!Number.isFinite(milliseconds) || milliseconds <= 0) return EMPTY_PLACEHOLDER; + let remainingSeconds = Math.max(1, Math.floor(milliseconds / 1000)); + const units = [ + { label: 'd', seconds: 86_400 }, + { label: 'h', seconds: 3_600 }, + { label: 'm', seconds: 60 }, + { label: 's', seconds: 1 }, + ]; + const parts: string[] = []; + + for (const unit of units) { + if (parts.length >= 2) break; + if (remainingSeconds < unit.seconds && unit.label !== 's') continue; + const value = Math.floor(remainingSeconds / unit.seconds); + if (value <= 0) continue; + parts.push(`${value}${unit.label}`); + remainingSeconds -= value * unit.seconds; + } + + return parts.length > 0 ? parts.join(' ') : EMPTY_PLACEHOLDER; +} + +export function formatDurationBetween(start?: Timestamp | null, end?: Timestamp | null): string { + if (!start) return EMPTY_PLACEHOLDER; + const startMillis = timestampToMillis(start); + if (!startMillis) return EMPTY_PLACEHOLDER; + const endMillis = end ? timestampToMillis(end) : Date.now(); + if (!endMillis) return EMPTY_PLACEHOLDER; + const duration = Math.max(0, endMillis - startMillis); + return formatDuration(duration); +} + export function formatLabelPairs(labels: Record): string { const entries = Object.entries(labels); if (entries.length === 0) return EMPTY_PLACEHOLDER; diff --git a/src/pages/OrganizationActivityStorageTab.tsx b/src/pages/OrganizationActivityStorageTab.tsx index 233dfba..ccec2e1 100644 --- a/src/pages/OrganizationActivityStorageTab.tsx +++ b/src/pages/OrganizationActivityStorageTab.tsx @@ -1,34 +1,129 @@ -import { useMemo } from 'react'; -import { useParams } from 'react-router-dom'; -import { useInfiniteQuery, useQueries } from '@tanstack/react-query'; -import { agentsClient, runnersClient } from '@/api/client'; +import { useMemo, useState } from 'react'; +import { NavLink, useLocation, useParams } from 'react-router-dom'; +import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from '@tanstack/react-query'; +import { runnersClient } from '@/api/client'; import { LoadMoreButton } from '@/components/LoadMoreButton'; +import { MultiSelectFilter } from '@/components/MultiSelectFilter'; import { SortableHeader } from '@/components/SortableHeader'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; -import type { VolumeAttachment } from '@/gen/agynio/api/agents/v1/agents_pb'; -import { VolumeStatus, type Volume } from '@/gen/agynio/api/runners/v1/runners_pb'; +import { + AttachmentKind, + ListVolumesSortField, + SortDirection as VolumesSortDirection, + VolumeAttachmentFilterKind, + VolumeStatus, + type Attachment, + type Volume, +} from '@/gen/agynio/api/runners/v1/runners_pb'; +import type { NotificationEnvelope } from '@/gen/agynio/api/notifications/v1/notifications_pb'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; -import { useListControls } from '@/hooks/useListControls'; -import { EMPTY_PLACEHOLDER, formatVolumeStatus, truncate } from '@/lib/format'; +import { useNotifications } from '@/hooks/useNotifications'; +import { type SortDirection } from '@/hooks/useListControls'; +import { EMPTY_PLACEHOLDER, formatDateOnly, formatVolumeStatus, truncate } from '@/lib/format'; import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from '@/lib/pagination'; const UNATTACHED_LABEL = 'Unattached'; -const getVolumeName = (volume: Volume) => volume.meta?.id || volume.instanceId || volume.volumeId || ''; -const getVolumeSize = (volume: Volume) => { - const parsed = Number(volume.sizeGb); - return Number.isFinite(parsed) ? parsed : 0; +type VolumeSortKey = 'name' | 'size' | 'status' | 'created'; + +const VOLUME_STATUS_OPTIONS = [ + VolumeStatus.PROVISIONING, + VolumeStatus.ACTIVE, + VolumeStatus.DEPROVISIONING, + VolumeStatus.DELETED, + VolumeStatus.FAILED, +]; + +const VOLUME_ATTACHMENT_OPTIONS = [ + { value: String(VolumeAttachmentFilterKind.AGENT), label: 'Agent' }, + { value: String(VolumeAttachmentFilterKind.MCP), label: 'MCP' }, + { value: String(VolumeAttachmentFilterKind.HOOK), label: 'Hook' }, + { value: String(VolumeAttachmentFilterKind.UNATTACHED), label: 'Unattached' }, +]; + +const ATTACHMENT_KIND_LABELS: Record = { + [AttachmentKind.UNSPECIFIED]: 'Attachment', + [AttachmentKind.AGENT]: 'Agent', + [AttachmentKind.MCP]: 'MCP', + [AttachmentKind.HOOK]: 'Hook', +}; + +const getVolumeName = (volume: Volume) => volume.volumeName?.trim() || ''; + +const formatAttachmentLabel = (attachment: Attachment) => { + const name = attachment.name?.trim() || attachment.id || ''; + if (!name) return EMPTY_PLACEHOLDER; + const kindLabel = ATTACHMENT_KIND_LABELS[attachment.kind] ?? 'Attachment'; + return kindLabel === 'Attachment' ? name : `${kindLabel} ${name}`; +}; + +const resolveAttachmentSortKey = (attachment: Attachment) => attachment.name?.trim() || attachment.id || ''; + +const summarizeAttachments = (attachments: Attachment[]) => { + if (attachments.length === 0) return UNATTACHED_LABEL; + const labels = [...attachments] + .sort((left, right) => resolveAttachmentSortKey(left).localeCompare(resolveAttachmentSortKey(right))) + .map((attachment) => formatAttachmentLabel(attachment)) + .filter((label) => label !== EMPTY_PLACEHOLDER); + if (labels.length === 0) return UNATTACHED_LABEL; + if (labels.length === 1) return labels[0]; + return `${labels[0]} +${labels.length - 1} more`; +}; + +const extractVolumeId = (payload?: NotificationEnvelope['payload']): string | null => { + if (!payload) return null; + const resolveString = (value: unknown): string | null => + typeof value === 'string' && value.trim().length > 0 ? value : null; + const direct = resolveString(payload.volumeId ?? payload.volume_id ?? payload.id); + if (direct) return direct; + const volume = payload.volume; + if (!volume || typeof volume !== 'object' || Array.isArray(volume)) return null; + const volumeRecord = volume as Record; + const nested = resolveString(volumeRecord.volumeId ?? volumeRecord.volume_id ?? volumeRecord.id); + if (nested) return nested; + const meta = volumeRecord.meta; + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null; + return resolveString((meta as Record).id); }; -const formatAttachmentTarget = (attachment: VolumeAttachment) => { - const targetId = attachment.target.value; - if (!targetId) return EMPTY_PLACEHOLDER; - if (attachment.target.case === 'agentId') return `Agent ${truncate(targetId, 18)}`; - if (attachment.target.case === 'mcpId') return `MCP ${truncate(targetId, 18)}`; - if (attachment.target.case === 'hookId') return `Hook ${truncate(targetId, 18)}`; - return EMPTY_PLACEHOLDER; +const resetPagination = ( + _data: InfiniteData | undefined, + firstPage: TPage, +): InfiniteData => ({ pages: [firstPage], pageParams: [''] }); + +const upsertVolume = ( + data: InfiniteData>, unknown> | undefined, + volume: Volume, +): InfiniteData>, unknown> | undefined => { + if (!data) return data; + const volumeId = volume.volumeId || volume.meta?.id; + if (!volumeId) return data; + + let found = false; + const nextPages = data.pages.map((page) => { + const nextVolumes = page.volumes.map((item) => { + const itemId = item.volumeId || item.meta?.id; + if (itemId && itemId === volumeId) { + found = true; + return volume; + } + return item; + }); + return { ...page, volumes: nextVolumes }; + }); + + if (!found && nextPages.length > 0) { + const firstPage = nextPages[0]; + const withoutDuplicate = firstPage.volumes.filter((item) => (item.volumeId || item.meta?.id) !== volumeId); + const nextVolumes = [volume, ...withoutDuplicate] + .sort((left, right) => getVolumeName(left).localeCompare(getVolumeName(right))) + .slice(0, DEFAULT_PAGE_SIZE); + nextPages[0] = { ...firstPage, volumes: nextVolumes }; + } + + return { ...data, pages: nextPages }; }; export function OrganizationActivityStorageTab() { @@ -36,15 +131,98 @@ export function OrganizationActivityStorageTab() { const { id } = useParams(); const organizationId = id ?? ''; + const location = useLocation(); + const queryClient = useQueryClient(); + const [searchTerm, setSearchTerm] = useState(''); + const [runnerFilter, setRunnerFilter] = useState([]); + const [attachedKindFilter, setAttachedKindFilter] = useState([]); + const [statusFilter, setStatusFilter] = useState([]); + const [sortKey, setSortKey] = useState('name'); + const [sortDirection, setSortDirection] = useState('asc'); + + const runnersQuery = useQuery({ + queryKey: ['runners', organizationId, 'list', 'options'], + queryFn: () => runnersClient.listRunners({ organizationId, pageSize: MAX_PAGE_SIZE, pageToken: '' }), + enabled: Boolean(organizationId), + staleTime: 60_000, + refetchOnWindowFocus: false, + }); + + const runnerOptions = useMemo(() => { + const runners = runnersQuery.data?.runners ?? []; + return runners + .map((runner) => { + const runnerId = runner.meta?.id ?? ''; + if (!runnerId) return null; + const name = runner.name?.trim(); + if (!name) return null; + return { + value: runnerId, + label: name, + }; + }) + .filter((option): option is NonNullable => option !== null) + .sort((left, right) => left.label.localeCompare(right.label)); + }, [runnersQuery.data?.runners]); + + const statusOptions = useMemo( + () => + VOLUME_STATUS_OPTIONS.map((status) => ({ + value: String(status), + label: formatVolumeStatus(status), + })), + [], + ); + + const notificationRooms = useMemo( + () => (organizationId ? [`organization:${organizationId}`] : []), + [organizationId], + ); + + const normalizedSearch = searchTerm.trim(); + const filterKey = useMemo( + () => ({ search: normalizedSearch, runner: runnerFilter, attached: attachedKindFilter, status: statusFilter }), + [normalizedSearch, runnerFilter, attachedKindFilter, statusFilter], + ); + const sortSpec = useMemo(() => { + const fieldMap: Record = { + name: ListVolumesSortField.NAME, + size: ListVolumesSortField.SIZE, + status: ListVolumesSortField.STATUS, + created: ListVolumesSortField.CREATED, + }; + return { + field: fieldMap[sortKey], + direction: sortDirection === 'asc' ? VolumesSortDirection.ASC : VolumesSortDirection.DESC, + }; + }, [sortDirection, sortKey]); + const filterSpec = useMemo(() => { + const statusValues = statusFilter.map((value) => Number(value) as VolumeStatus).filter((value) => value > 0); + const attachedKinds = attachedKindFilter + .map((value) => Number(value) as VolumeAttachmentFilterKind) + .filter((value) => value > 0); + return { + volumeNameSubstring: normalizedSearch || undefined, + statusIn: statusValues, + runnerIdIn: runnerFilter, + attachedToKindIn: attachedKinds, + }; + }, [normalizedSearch, statusFilter, runnerFilter, attachedKindFilter]); + + const volumesQueryKey = useMemo( + () => ['runners', organizationId, 'volumes', 'list', filterKey, sortSpec] as const, + [filterKey, organizationId, sortSpec], + ); const volumesQuery = useInfiniteQuery({ - queryKey: ['runners', organizationId, 'volumes', 'list'], + queryKey: volumesQueryKey, queryFn: ({ pageParam }) => runnersClient.listVolumes({ organizationId, pageSize: DEFAULT_PAGE_SIZE, pageToken: pageParam, - statuses: [], + filter: filterSpec, + sort: sortSpec, }), initialPageParam: '', getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined, @@ -57,64 +235,21 @@ export function OrganizationActivityStorageTab() { () => volumesQuery.data?.pages.flatMap((page) => page.volumes) ?? [], [volumesQuery.data?.pages], ); - const attachmentVolumes = useMemo(() => volumes.filter((volume) => volume.volumeId), [volumes]); - const attachmentQueries = useQueries({ - queries: attachmentVolumes.map((volume) => ({ - queryKey: ['volumeAttachments', organizationId, volume.volumeId], - queryFn: () => - agentsClient.listVolumeAttachments({ - volumeId: volume.volumeId, - agentId: '', - mcpId: '', - hookId: '', - pageSize: MAX_PAGE_SIZE, - pageToken: '', - }), - enabled: Boolean(volume.volumeId), - staleTime: 60_000, - refetchOnWindowFocus: false, - })), - }); - - const attachmentsByVolume = useMemo(() => { - const map = new Map(); - attachmentVolumes.forEach((volume, index) => { - const attachments = attachmentQueries[index]?.data?.volumeAttachments ?? []; - map.set(volume.volumeId, attachments); - }); - return map; - }, [attachmentQueries, attachmentVolumes]); - - const getAttachedLabel = (volume: Volume) => { - const attachments = volume.volumeId ? attachmentsByVolume.get(volume.volumeId) ?? [] : []; - if (attachments.length === 0) return UNATTACHED_LABEL; - const labels = attachments - .map((attachment) => formatAttachmentTarget(attachment)) - .filter((label) => label !== EMPTY_PLACEHOLDER); - return labels.length > 0 ? labels.join(', ') : UNATTACHED_LABEL; + const hasActiveFilters = + normalizedSearch.length > 0 || + runnerFilter.length > 0 || + attachedKindFilter.length > 0 || + statusFilter.length > 0; + const handleSort = (key: VolumeSortKey) => { + if (key === sortKey) { + setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); + return; + } + setSortKey(key); + setSortDirection('asc'); }; - const listControls = useListControls({ - items: volumes, - searchFields: [ - (volume) => getVolumeName(volume), - (volume) => volume.volumeId, - (volume) => volume.sizeGb, - (volume) => getAttachedLabel(volume), - (volume) => formatVolumeStatus(volume.status), - ], - sortOptions: { - name: (volume) => getVolumeName(volume), - size: (volume) => getVolumeSize(volume), - used: () => '', - attached: (volume) => getAttachedLabel(volume), - status: (volume) => formatVolumeStatus(volume.status), - }, - defaultSortKey: 'name', - }); - - const visibleVolumes = listControls.filteredItems; - const hasSearch = listControls.searchTerm.trim().length > 0; + const hasActiveControls = hasActiveFilters || sortKey !== 'name' || sortDirection !== 'asc'; const getStatusVariant = (status: VolumeStatus) => { if (status === VolumeStatus.ACTIVE) return 'default'; @@ -123,6 +258,50 @@ export function OrganizationActivityStorageTab() { return 'outline'; }; + useNotifications({ + events: ['volume.updated'], + rooms: notificationRooms, + enabled: Boolean(organizationId) && notificationRooms.length > 0, + onEvent: (envelope) => { + if (hasActiveControls) { + void (async () => { + try { + const firstPage = await runnersClient.listVolumes({ + organizationId, + pageSize: DEFAULT_PAGE_SIZE, + pageToken: '', + filter: filterSpec, + sort: sortSpec, + }); + queryClient.setQueryData>, unknown>>( + volumesQueryKey, + (data) => resetPagination(data, firstPage), + ); + } catch (error) { + console.error('[useNotifications] volume refetch error:', error); + } + })(); + return; + } + + const volumeId = extractVolumeId(envelope.payload); + if (!volumeId) return; + void (async () => { + try { + const response = await runnersClient.getVolume({ id: volumeId }); + const volume = response.volume; + if (!volume || volume.organizationId !== organizationId) return; + queryClient.setQueryData>, unknown>>( + volumesQueryKey, + (data) => upsertVolume(data, volume), + ); + } catch (error) { + console.error('[useNotifications] volume update error:', error); + } + })(); + }, + }); + return (
@@ -131,20 +310,49 @@ export function OrganizationActivityStorageTab() { Real-time view of persistent volumes in use across the organization.

-
- listControls.setSearchTerm(event.target.value)} - data-testid="organization-storage-search" - /> +
+
+ setSearchTerm(event.target.value)} + data-testid="organization-storage-search" + /> +
+
+ +
+
+ +
+
+ +
{volumesQuery.isPending ?
Loading storage volumes...
: null} {volumesQuery.isError ?
Failed to load storage.
: null} {volumes.length === 0 && !volumesQuery.isPending ? ( - No storage volumes yet. + {hasActiveFilters ? 'No results found.' : 'No storage volumes yet.'} ) : null} @@ -152,85 +360,84 @@ export function OrganizationActivityStorageTab() {
- + Attached to
- {visibleVolumes.length === 0 ? ( -
- {hasSearch ? 'No results found.' : 'No storage volumes yet.'} -
- ) : ( - visibleVolumes.map((volume) => { - const name = getVolumeName(volume) || EMPTY_PLACEHOLDER; - const sizeLabel = volume.sizeGb ? `${volume.sizeGb} GB` : EMPTY_PLACEHOLDER; - const attachedLabel = getAttachedLabel(volume); - return ( -
-
-
- {truncate(name, 24)} -
-
- {name} -
+ {volumes.map((volume) => { + const name = volume.volumeName?.trim() || EMPTY_PLACEHOLDER; + const volumeId = volume.volumeId || volume.meta?.id || ''; + const volumeLink = volumeId ? `/organizations/${organizationId}/volumes/${volumeId}` : null; + const sizeLabel = volume.sizeGb ? `${volume.sizeGb} GB` : EMPTY_PLACEHOLDER; + const attachedLabel = summarizeAttachments(volume.attachments ?? []); + const createdLabel = formatDateOnly(volume.meta?.createdAt); + return ( +
+
+
+ {volumeLink ? ( + + {truncate(name, 24)} + + ) : ( + truncate(name, 24) + )}
- - {sizeLabel} - - - {EMPTY_PLACEHOLDER} - - - {attachedLabel} - - - {formatVolumeStatus(volume.status)} -
- ); - }) - )} + + {sizeLabel} + + + {createdLabel} + + + {attachedLabel} + + + {formatVolumeStatus(volume.status)} + +
+ ); + })}
diff --git a/src/pages/OrganizationActivityWorkloadsTab.tsx b/src/pages/OrganizationActivityWorkloadsTab.tsx index e9d58cf..cae476e 100644 --- a/src/pages/OrganizationActivityWorkloadsTab.tsx +++ b/src/pages/OrganizationActivityWorkloadsTab.tsx @@ -1,32 +1,244 @@ +import { useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { useInfiniteQuery } from '@tanstack/react-query'; -import { runnersClient } from '@/api/client'; -import { WorkloadsTable } from '@/components/WorkloadsTable'; +import { create } from '@bufbuild/protobuf'; +import { TimestampSchema, type Timestamp } from '@bufbuild/protobuf/wkt'; +import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from '@tanstack/react-query'; +import { agentsClient, runnersClient } from '@/api/client'; +import { MultiSelectFilter } from '@/components/MultiSelectFilter'; +import { WorkloadsTable, type WorkloadSortKey } from '@/components/WorkloadsTable'; +import { Input } from '@/components/ui/input'; +import { + ListWorkloadsSortField, + SortDirection as WorkloadsSortDirection, + type Workload, + WorkloadStatus, +} from '@/gen/agynio/api/runners/v1/runners_pb'; +import type { NotificationEnvelope } from '@/gen/agynio/api/notifications/v1/notifications_pb'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useNotifications } from '@/hooks/useNotifications'; -import { DEFAULT_PAGE_SIZE } from '@/lib/pagination'; +import { type SortDirection } from '@/hooks/useListControls'; +import { formatWorkloadStatus } from '@/lib/format'; +import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from '@/lib/pagination'; + +const WORKLOAD_STATUS_OPTIONS = [ + WorkloadStatus.STARTING, + WorkloadStatus.RUNNING, + WorkloadStatus.STOPPING, + WorkloadStatus.STOPPED, + WorkloadStatus.FAILED, +]; + +type ActivityWorkloadSortKey = Exclude; + +const parseDateInput = (value: string, isEnd = false): Date | null => { + if (!value) return null; + const [year, month, day] = value.split('-').map((segment) => Number(segment)); + if (!year || !month || !day) return null; + const date = new Date(year, month - 1, day); + if (isEnd) { + date.setHours(23, 59, 59, 999); + } else { + date.setHours(0, 0, 0, 0); + } + return date; +}; + +const toTimestamp = (date: Date): Timestamp => + create(TimestampSchema, { + seconds: BigInt(Math.floor(date.getTime() / 1000)), + nanos: 0, + }); + +const extractWorkloadId = (payload?: NotificationEnvelope['payload']): string | null => { + if (!payload) return null; + const resolveString = (value: unknown): string | null => + typeof value === 'string' && value.trim().length > 0 ? value : null; + + const direct = resolveString(payload.workloadId ?? payload.workload_id ?? payload.id); + if (direct) return direct; + + const workload = payload.workload; + if (!workload || typeof workload !== 'object' || Array.isArray(workload)) return null; + const workloadRecord = workload as Record; + const nested = resolveString(workloadRecord.workloadId ?? workloadRecord.workload_id ?? workloadRecord.id); + if (nested) return nested; + const meta = workloadRecord.meta; + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null; + return resolveString((meta as Record).id); +}; + +const resetPagination = ( + _data: InfiniteData | undefined, + firstPage: TPage, +): InfiniteData => ({ pages: [firstPage], pageParams: [''] }); + +const upsertWorkload = ( + data: InfiniteData>, unknown> | undefined, + workload: Workload, +): InfiniteData>, unknown> | undefined => { + if (!data) return data; + const workloadId = workload.meta?.id; + if (!workloadId) return data; + + let found = false; + const nextPages = data.pages.map((page) => { + const nextWorkloads = page.workloads.map((item) => { + if (item.meta?.id === workloadId) { + found = true; + return workload; + } + return item; + }); + return { ...page, workloads: nextWorkloads }; + }); + + if (!found && nextPages.length > 0) { + const firstPage = nextPages[0]; + const withoutDuplicate = firstPage.workloads.filter((item) => item.meta?.id !== workloadId); + const nextWorkloads = [workload, ...withoutDuplicate].slice(0, DEFAULT_PAGE_SIZE); + nextPages[0] = { ...firstPage, workloads: nextWorkloads }; + } + + return { ...data, pages: nextPages }; +}; export function OrganizationActivityWorkloadsTab() { useDocumentTitle('Workloads'); const { id } = useParams(); const organizationId = id ?? ''; + const queryClient = useQueryClient(); + const [agentIdFilter, setAgentIdFilter] = useState([]); + const [runnerIdFilter, setRunnerIdFilter] = useState([]); + const [statusFilter, setStatusFilter] = useState([]); + const [startedAfter, setStartedAfter] = useState(''); + const [startedBefore, setStartedBefore] = useState(''); + const [sortKey, setSortKey] = useState('started'); + const [sortDirection, setSortDirection] = useState('desc'); - useNotifications({ - rooms: organizationId ? [`organization:${organizationId}`] : [], - events: ['workload.updated'], - invalidateKeys: [['workloads', organizationId, 'list']], + const agentsQuery = useQuery({ + queryKey: ['agents', organizationId, 'list', 'options'], + queryFn: () => agentsClient.listAgents({ organizationId, pageSize: MAX_PAGE_SIZE, pageToken: '' }), enabled: Boolean(organizationId), + staleTime: 60_000, + refetchOnWindowFocus: false, }); + const runnersQuery = useQuery({ + queryKey: ['runners', organizationId, 'list', 'options'], + queryFn: () => runnersClient.listRunners({ organizationId, pageSize: MAX_PAGE_SIZE, pageToken: '' }), + enabled: Boolean(organizationId), + staleTime: 60_000, + refetchOnWindowFocus: false, + }); + + const agentOptions = useMemo(() => { + const agents = agentsQuery.data?.agents ?? []; + return agents + .map((agent) => { + const agentId = agent.meta?.id ?? ''; + if (!agentId) return null; + const name = agent.name?.trim(); + if (!name) return null; + return { + value: agentId, + label: name, + }; + }) + .filter((option): option is NonNullable => option !== null) + .sort((left, right) => left.label.localeCompare(right.label)); + }, [agentsQuery.data?.agents]); + + const runnerOptions = useMemo(() => { + const runners = runnersQuery.data?.runners ?? []; + return runners + .map((runner) => { + const runnerId = runner.meta?.id ?? ''; + if (!runnerId) return null; + const name = runner.name?.trim(); + if (!name) return null; + return { + value: runnerId, + label: name, + }; + }) + .filter((option): option is NonNullable => option !== null) + .sort((left, right) => left.label.localeCompare(right.label)); + }, [runnersQuery.data?.runners]); + + const statusOptions = useMemo( + () => + WORKLOAD_STATUS_OPTIONS.map((status) => ({ + value: String(status), + label: formatWorkloadStatus(status), + })), + [], + ); + + const notificationRooms = useMemo( + () => (organizationId ? [`organization:${organizationId}`] : []), + [organizationId], + ); + + const { rangeError, startDate, endDate } = useMemo(() => { + const parsedStart = parseDateInput(startedAfter, false); + const parsedEnd = parseDateInput(startedBefore, true); + if (parsedStart && parsedEnd && parsedStart > parsedEnd) { + return { rangeError: 'Start date must be before end date.', startDate: parsedStart, endDate: parsedEnd }; + } + return { rangeError: '', startDate: parsedStart, endDate: parsedEnd }; + }, [startedAfter, startedBefore]); + + const filterKey = useMemo( + () => ({ + agents: agentIdFilter, + runners: runnerIdFilter, + status: statusFilter, + startedAfter, + startedBefore, + }), + [agentIdFilter, runnerIdFilter, statusFilter, startedAfter, startedBefore], + ); + + const sortSpec = useMemo(() => { + const fieldMap: Record = { + agentId: ListWorkloadsSortField.AGENT, + runnerId: ListWorkloadsSortField.RUNNER, + status: ListWorkloadsSortField.STATUS, + started: ListWorkloadsSortField.STARTED, + duration: ListWorkloadsSortField.DURATION, + }; + return { + field: fieldMap[sortKey], + direction: sortDirection === 'asc' ? WorkloadsSortDirection.ASC : WorkloadsSortDirection.DESC, + }; + }, [sortDirection, sortKey]); + + const filterSpec = useMemo(() => { + const statusValues = statusFilter.map((value) => Number(value) as WorkloadStatus).filter((value) => value > 0); + return { + agentIdIn: agentIdFilter, + runnerIdIn: runnerIdFilter, + statusIn: statusValues, + startedAfter: rangeError ? undefined : startDate ? toTimestamp(startDate) : undefined, + startedBefore: rangeError ? undefined : endDate ? toTimestamp(endDate) : undefined, + }; + }, [agentIdFilter, runnerIdFilter, statusFilter, startDate, endDate, rangeError]); + + const workloadsQueryKey = useMemo( + () => ['workloads', organizationId, 'list', filterKey, sortSpec] as const, + [filterKey, organizationId, sortSpec], + ); + const workloadsQuery = useInfiniteQuery({ - queryKey: ['workloads', organizationId, 'list'], + queryKey: workloadsQueryKey, queryFn: ({ pageParam }) => runnersClient.listWorkloads({ organizationId, pageSize: DEFAULT_PAGE_SIZE, pageToken: pageParam, - statuses: [], + filter: filterSpec, + sort: sortSpec, }), initialPageParam: '', getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined, @@ -37,6 +249,68 @@ export function OrganizationActivityWorkloadsTab() { const workloads = workloadsQuery.data?.pages.flatMap((page) => page.workloads) ?? []; + const handleSort = (key: WorkloadSortKey) => { + if (key === 'threadId') return; + if (key === sortKey) { + setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); + return; + } + setSortKey(key); + setSortDirection('asc'); + }; + + const hasActiveFilters = + agentIdFilter.length > 0 || + runnerIdFilter.length > 0 || + statusFilter.length > 0 || + startedAfter.length > 0 || + startedBefore.length > 0; + const hasActiveControls = hasActiveFilters || sortKey !== 'started' || sortDirection !== 'desc'; + + useNotifications({ + events: ['workload.updated'], + rooms: notificationRooms, + enabled: Boolean(organizationId) && notificationRooms.length > 0, + onEvent: (envelope) => { + if (hasActiveControls) { + void (async () => { + try { + const firstPage = await runnersClient.listWorkloads({ + organizationId, + pageSize: DEFAULT_PAGE_SIZE, + pageToken: '', + filter: filterSpec, + sort: sortSpec, + }); + queryClient.setQueryData>, unknown>>( + workloadsQueryKey, + (data) => resetPagination(data, firstPage), + ); + } catch (error) { + console.error('[useNotifications] workload refetch error:', error); + } + })(); + return; + } + + const workloadId = extractWorkloadId(envelope.payload); + if (!workloadId) return; + void (async () => { + try { + const response = await runnersClient.getWorkload({ id: workloadId }); + const workload = response.workload; + if (!workload || workload.organizationId !== organizationId) return; + queryClient.setQueryData>, unknown>>( + workloadsQueryKey, + (data) => upsertWorkload(data, workload), + ); + } catch (error) { + console.error('[useNotifications] workload update error:', error); + } + })(); + }, + }); + return (
@@ -49,8 +323,84 @@ export function OrganizationActivityWorkloadsTab() { workloads={workloads} query={workloadsQuery} showRunnerColumn + showDuration + showSearch={false} + rowLinkMode="row" + getWorkloadLink={(workload) => { + const workloadId = workload.meta?.id; + if (!workloadId) return null; + return `/organizations/${organizationId}/workloads/${workloadId}`; + }} + getAgentName={(workload) => workload.agentName || ''} + getRunnerName={(workload) => workload.runnerName || ''} + getAgentLink={(workload) => + workload.agentId ? `/organizations/${organizationId}/agents/${workload.agentId}` : null + } + getRunnerLink={(workload) => + workload.runnerId ? `/organizations/${organizationId}/runners/${workload.runnerId}` : null + } + agentLabel="Agent" + runnerLabel="Runner" + controls={{ + sortKey, + sortDirection, + onSort: handleSort, + }} + filterBar={ + <> +
+ +
+
+ +
+
+ +
+
+
+ Started after + setStartedAfter(event.target.value)} + data-testid="organization-workloads-started-after" + /> +
+
+ Started before + setStartedBefore(event.target.value)} + data-testid="organization-workloads-started-before" + /> +
+
+ + } + hasActiveFilters={hasActiveFilters} testIdPrefix="organization-workloads" /> + {rangeError ?
{rangeError}
: null}
); } diff --git a/src/pages/OrganizationOverviewTab.tsx b/src/pages/OrganizationOverviewTab.tsx index cf70fcc..2a981ef 100644 --- a/src/pages/OrganizationOverviewTab.tsx +++ b/src/pages/OrganizationOverviewTab.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { NavLink, useParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { agentsClient, appsClient, organizationsClient, runnersClient, secretsClient } from '@/api/client'; @@ -13,12 +14,16 @@ export function OrganizationOverviewTab() { const { id } = useParams(); const organizationId = id ?? ''; + const notificationRooms = useMemo( + () => (organizationId ? [`organization:${organizationId}`] : []), + [organizationId], + ); useNotifications({ - rooms: organizationId ? [`organization:${organizationId}`] : [], events: ['workload.updated'], invalidateKeys: [['workloads', organizationId, 'overview']], - enabled: Boolean(organizationId), + rooms: notificationRooms, + enabled: Boolean(organizationId) && notificationRooms.length > 0, }); const membersQuery = useQuery({ diff --git a/src/pages/OrganizationThreadsTab.tsx b/src/pages/OrganizationThreadsTab.tsx index 7a1ccf4..4c860e7 100644 --- a/src/pages/OrganizationThreadsTab.tsx +++ b/src/pages/OrganizationThreadsTab.tsx @@ -1,32 +1,193 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { NavLink, useParams } from 'react-router-dom'; import { Code, ConnectError } from '@connectrpc/connect'; -import { useInfiniteQuery } from '@tanstack/react-query'; +import { create } from '@bufbuild/protobuf'; +import { TimestampSchema, type Timestamp } from '@bufbuild/protobuf/wkt'; +import { useInfiniteQuery, useQueryClient, type InfiniteData } from '@tanstack/react-query'; import { threadsClient } from '@/api/client'; import { LoadMoreButton } from '@/components/LoadMoreButton'; +import { MultiSelectFilter } from '@/components/MultiSelectFilter'; +import { SortableHeader } from '@/components/SortableHeader'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; -import { ThreadStatus } from '@/gen/agynio/api/threads/v1/threads_pb'; +import { Input } from '@/components/ui/input'; +import { + ListOrganizationThreadsSortField, + SortDirection as ThreadsSortDirection, + type Thread, + ThreadStatus, +} from '@/gen/agynio/api/threads/v1/threads_pb'; +import type { NotificationEnvelope } from '@/gen/agynio/api/notifications/v1/notifications_pb'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; -import { useIdentityHandles } from '@/hooks/useIdentityHandles'; +import { useNotifications } from '@/hooks/useNotifications'; +import { type SortDirection } from '@/hooks/useListControls'; +import { useUserContext } from '@/context/UserContext'; import { EMPTY_PLACEHOLDER, formatDateOnly, formatThreadStatus, truncate } from '@/lib/format'; import { DEFAULT_PAGE_SIZE } from '@/lib/pagination'; +type ThreadSortKey = 'status' | 'messages' | 'created' | 'updated'; + +const THREAD_STATUS_OPTIONS = [ThreadStatus.ACTIVE, ThreadStatus.ARCHIVED, ThreadStatus.DEGRADED]; + +type ThreadsPage = { + threads: Thread[]; + nextPageToken?: string; +}; + +const parseDateInput = (value: string, isEnd = false): Date | null => { + if (!value) return null; + const [year, month, day] = value.split('-').map((segment) => Number(segment)); + if (!year || !month || !day) return null; + const date = new Date(year, month - 1, day); + if (isEnd) { + date.setHours(23, 59, 59, 999); + } else { + date.setHours(0, 0, 0, 0); + } + return date; +}; + +const toTimestamp = (date: Date): Timestamp => + create(TimestampSchema, { + seconds: BigInt(Math.floor(date.getTime() / 1000)), + nanos: 0, + }); + +const formatNickname = (nickname?: string) => { + const trimmed = nickname?.trim(); + if (!trimmed) return ''; + return trimmed.startsWith('@') ? trimmed : `@${trimmed}`; +}; + +const extractThreadId = (payload?: NotificationEnvelope['payload']): string | null => { + if (!payload) return null; + const resolveString = (value: unknown): string | null => + typeof value === 'string' && value.trim().length > 0 ? value : null; + const direct = resolveString(payload.threadId ?? payload.thread_id ?? payload.id); + if (direct) return direct; + const thread = payload.thread; + if (!thread || typeof thread !== 'object' || Array.isArray(thread)) return null; + const threadRecord = thread as Record; + return resolveString(threadRecord.threadId ?? threadRecord.thread_id ?? threadRecord.id); +}; + +const resetPagination = ( + _data: InfiniteData | undefined, + firstPage: TPage, +): InfiniteData => ({ pages: [firstPage], pageParams: [''] }); + +const upsertThread = ( + data: InfiniteData | undefined, + thread: Thread, +): InfiniteData | undefined => { + if (!data) return data; + if (!thread.id) return data; + + let found = false; + const nextPages = data.pages.map((page) => { + const nextThreads = page.threads.map((item) => { + if (item.id === thread.id) { + found = true; + return thread; + } + return item; + }); + return { ...page, threads: nextThreads }; + }); + + if (!found && nextPages.length > 0) { + const firstPage = nextPages[0]; + const withoutDuplicate = firstPage.threads.filter((item) => item.id !== thread.id); + const nextThreads = [thread, ...withoutDuplicate].slice(0, DEFAULT_PAGE_SIZE); + nextPages[0] = { ...firstPage, threads: nextThreads }; + } + + return { ...data, pages: nextPages }; +}; + export function OrganizationThreadsTab() { useDocumentTitle('Threads'); const { id } = useParams(); const organizationId = id ?? ''; + const { identityId } = useUserContext(); + const queryClient = useQueryClient(); + const [participantFilter, setParticipantFilter] = useState([]); + const [statusFilter, setStatusFilter] = useState([]); + const [createdAfter, setCreatedAfter] = useState(''); + const [createdBefore, setCreatedBefore] = useState(''); + const [sortKey, setSortKey] = useState('created'); + const [sortDirection, setSortDirection] = useState('desc'); + + const notificationRooms = useMemo( + () => (identityId ? [`thread_participant:${identityId}`] : []), + [identityId], + ); + + const { rangeError, startDate, endDate } = useMemo(() => { + const parsedStart = parseDateInput(createdAfter, false); + const parsedEnd = parseDateInput(createdBefore, true); + if (parsedStart && parsedEnd && parsedStart > parsedEnd) { + return { rangeError: 'Start date must be before end date.', startDate: parsedStart, endDate: parsedEnd }; + } + return { rangeError: '', startDate: parsedStart, endDate: parsedEnd }; + }, [createdAfter, createdBefore]); - const threadsQuery = useInfiniteQuery({ - queryKey: ['threads', organizationId, 'list'], - queryFn: ({ pageParam }) => - threadsClient.getOrganizationThreads({ - organizationId, - pageSize: DEFAULT_PAGE_SIZE, - pageToken: pageParam, - status: ThreadStatus.UNSPECIFIED, - }), + const filterKey = useMemo( + () => ({ participants: participantFilter, status: statusFilter, createdAfter, createdBefore }), + [participantFilter, statusFilter, createdAfter, createdBefore], + ); + const sortSpec = useMemo(() => { + const fieldMap: Record = { + status: ListOrganizationThreadsSortField.STATUS, + messages: ListOrganizationThreadsSortField.MESSAGE_COUNT, + created: ListOrganizationThreadsSortField.CREATED, + updated: ListOrganizationThreadsSortField.UPDATED, + }; + return { + field: fieldMap[sortKey], + direction: sortDirection === 'asc' ? ThreadsSortDirection.ASC : ThreadsSortDirection.DESC, + }; + }, [sortDirection, sortKey]); + const statusValues = useMemo( + () => statusFilter.map((value) => Number(value) as ThreadStatus).filter((value) => value > 0), + [statusFilter], + ); + + const filterSpec = useMemo(() => { + const createdAfterValue = rangeError ? undefined : startDate ? toTimestamp(startDate) : undefined; + const createdBeforeValue = rangeError ? undefined : endDate ? toTimestamp(endDate) : undefined; + const hasFilters = + participantFilter.length > 0 || + statusValues.length > 0 || + createdAfterValue !== undefined || + createdBeforeValue !== undefined; + if (!hasFilters) return undefined; + return { + participantIdIn: participantFilter, + statusIn: statusValues, + createdAfter: createdAfterValue, + createdBefore: createdBeforeValue, + }; + }, [participantFilter, statusValues, startDate, endDate, rangeError]); + + const listThreadsQueryKey = useMemo( + () => ['threads', organizationId, 'list', filterKey, sortSpec] as const, + [filterKey, organizationId, sortSpec], + ); + + const fetchListThreadsPage = (pageToken: string): Promise => + threadsClient.listOrganizationThreads({ + organizationId, + pageSize: DEFAULT_PAGE_SIZE, + pageToken, + filter: filterSpec, + sort: sortSpec, + }); + + const listThreadsQuery = useInfiniteQuery({ + queryKey: listThreadsQueryKey, + queryFn: ({ pageParam }) => fetchListThreadsPage(pageParam), initialPageParam: '', getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined, enabled: Boolean(organizationId), @@ -35,68 +196,199 @@ export function OrganizationThreadsTab() { }); const threads = useMemo( - () => threadsQuery.data?.pages.flatMap((page) => page.threads) ?? [], - [threadsQuery.data?.pages], + () => listThreadsQuery.data?.pages.flatMap((page) => page.threads) ?? [], + [listThreadsQuery.data?.pages], ); - const isLoading = threadsQuery.isPending; - const isError = threadsQuery.isError; + const visibleThreads = threads; + const isLoading = listThreadsQuery.isPending; + const isError = listThreadsQuery.isError; const isPermissionDenied = - threadsQuery.error instanceof ConnectError && threadsQuery.error.code === Code.PermissionDenied; + listThreadsQuery.error instanceof ConnectError && listThreadsQuery.error.code === Code.PermissionDenied; - const identityIds = useMemo(() => { - const ids = new Set(); + const participantOptions = useMemo(() => { + const participantMap = new Map(); threads.forEach((thread) => { thread.participants.forEach((participant) => { - if (participant.id) ids.add(participant.id); + if (!participant.id) return; + const nickname = formatNickname(participant.nickname); + if (!nickname) return; + const label = nickname; + participantMap.set(participant.id, { + value: participant.id, + label, + }); }); }); - return Array.from(ids); + return Array.from(participantMap.values()).sort((left, right) => left.label.localeCompare(right.label)); }, [threads]); - const { formatHandle } = useIdentityHandles(identityIds); + const statusOptions = useMemo( + () => + THREAD_STATUS_OPTIONS.map((status) => ({ + value: String(status), + label: formatThreadStatus(status), + })), + [], + ); + + const hasActiveFilters = + participantFilter.length > 0 || + statusFilter.length > 0 || + createdAfter.length > 0 || + createdBefore.length > 0; + const hasActiveControls = hasActiveFilters || sortKey !== 'created' || sortDirection !== 'desc'; + const handleSort = (key: ThreadSortKey) => { + if (key === sortKey) { + setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); + return; + } + setSortKey(key); + setSortDirection('asc'); + }; + + useNotifications({ + events: ['message.created'], + rooms: notificationRooms, + enabled: Boolean(organizationId) && notificationRooms.length > 0, + onEvent: (envelope) => { + if (hasActiveControls) { + void (async () => { + try { + const firstPage = await fetchListThreadsPage(''); + queryClient.setQueryData>(listThreadsQueryKey, (data) => + resetPagination(data, firstPage), + ); + } catch (error) { + console.error('[useNotifications] thread refetch error:', error); + } + })(); + return; + } + + const threadId = extractThreadId(envelope.payload); + if (!threadId) return; + void (async () => { + try { + const response = await threadsClient.getThread({ threadId }); + const thread = response.thread; + if (!thread) return; + queryClient.setQueryData>(listThreadsQueryKey, (data) => + upsertThread(data, thread), + ); + } catch (error) { + console.error('[useNotifications] thread update error:', error); + } + })(); + }, + }); return (
+
+
+ +
+
+ +
+
+
+ Created after + setCreatedAfter(event.target.value)} + data-testid="organization-threads-created-after" + /> +
+
+ Created before + setCreatedBefore(event.target.value)} + data-testid="organization-threads-created-before" + /> +
+
+
+ {rangeError ?
{rangeError}
: null} {isLoading ?
Loading threads...
: null} {isError ? (
{isPermissionDenied ? 'You do not have permission to view threads.' : 'Failed to load threads.'}
) : null} - {threads.length === 0 && !isLoading && !isError ? ( + {visibleThreads.length === 0 && !isLoading && !isError ? ( - No threads yet. + {hasActiveFilters ? 'No results found.' : 'No threads yet.'} ) : null} - {threads.length > 0 ? ( + {visibleThreads.length > 0 ? ( -
+
Thread Participants - Status - Messages - Created + + + +
- {threads.map((thread) => { + {visibleThreads.map((thread) => { const threadId = thread.id; const messageCount = thread.messageCount ?? 0; const participantHandles = thread.participants - .map((participant) => formatHandle(participant.id)) - .filter((handle) => handle !== EMPTY_PLACEHOLDER); - const participantsLabel = - participantHandles.length > 0 - ? truncate(participantHandles.join(', '), 60) - : EMPTY_PLACEHOLDER; + .map((participant) => formatNickname(participant.nickname) || participant.id) + .filter((handle) => handle); + const participantsLabel = participantHandles.length > 0 + ? truncate(participantHandles.join(', '), 60) + : EMPTY_PLACEHOLDER; return (
@@ -120,6 +412,9 @@ export function OrganizationThreadsTab() { {formatDateOnly(thread.createdAt)} + + {formatDateOnly(thread.updatedAt)} + ); })} @@ -128,9 +423,11 @@ export function OrganizationThreadsTab() { ) : null} threadsQuery.fetchNextPage()} + hasMore={listThreadsQuery.hasNextPage} + isLoading={listThreadsQuery.isFetchingNextPage} + onClick={() => { + void listThreadsQuery.fetchNextPage(); + }} />
); diff --git a/src/pages/RunnerDetailPage.tsx b/src/pages/RunnerDetailPage.tsx index 409811f..5f9423e 100644 --- a/src/pages/RunnerDetailPage.tsx +++ b/src/pages/RunnerDetailPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { runnersClient } from '@/api/client'; @@ -38,15 +38,6 @@ export function RunnerDetailPage() { const [labelEntries, setLabelEntries] = useState([]); const [runnerName, setRunnerName] = useState(''); const [runnerNameError, setRunnerNameError] = useState(''); - const notificationRooms = organizationId ? [`organization:${organizationId}`] : []; - - useNotifications({ - rooms: notificationRooms, - events: ['workload.updated'], - invalidateKeys: [['workloads', 'runner', runnerId]], - enabled: Boolean(runnerId && organizationId), - }); - const runnerQuery = useQuery({ queryKey: ['runners', runnerId], queryFn: () => runnersClient.getRunner({ id: runnerId }), @@ -56,6 +47,19 @@ export function RunnerDetailPage() { }); const runner = runnerQuery.data?.runner; + const notificationRooms = useMemo(() => { + const rooms = new Set(); + if (organizationId) rooms.add(`organization:${organizationId}`); + if (runner?.organizationId) rooms.add(`organization:${runner.organizationId}`); + return Array.from(rooms); + }, [organizationId, runner?.organizationId]); + + useNotifications({ + events: ['workload.updated'], + invalidateKeys: [['workloads', 'runner', runnerId]], + rooms: notificationRooms, + enabled: Boolean(runnerId) && notificationRooms.length > 0, + }); const isOrgRunner = Boolean(organizationId) && runner?.organizationId === organizationId; const canManageRunner = !isOrgContext || isOrgRunner; diff --git a/src/pages/VolumeDetailPage.tsx b/src/pages/VolumeDetailPage.tsx new file mode 100644 index 0000000..a7dae53 --- /dev/null +++ b/src/pages/VolumeDetailPage.tsx @@ -0,0 +1,214 @@ +import { useMemo } from 'react'; +import { NavLink, useLocation, useParams } from 'react-router-dom'; +import { Code, ConnectError } from '@connectrpc/connect'; +import { useQuery } from '@tanstack/react-query'; +import { runnersClient } from '@/api/client'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { AttachmentKind, type Attachment, VolumeStatus } from '@/gen/agynio/api/runners/v1/runners_pb'; +import { useDocumentTitle } from '@/hooks/useDocumentTitle'; +import { useNotifications } from '@/hooks/useNotifications'; +import { EMPTY_PLACEHOLDER, formatTimestamp, formatVolumeStatus, truncate } from '@/lib/format'; + +const ATTACHMENT_KIND_LABELS: Record = { + [AttachmentKind.UNSPECIFIED]: 'Attachment', + [AttachmentKind.AGENT]: 'Agent', + [AttachmentKind.MCP]: 'MCP', + [AttachmentKind.HOOK]: 'Hook', +}; + +const formatAttachmentLabel = (attachment: Attachment) => { + const name = attachment.name?.trim() || attachment.id || ''; + if (!name) return EMPTY_PLACEHOLDER; + const kindLabel = ATTACHMENT_KIND_LABELS[attachment.kind] ?? 'Attachment'; + return kindLabel === 'Attachment' ? name : `${kindLabel} ${name}`; +}; + +const getStatusVariant = (status: VolumeStatus) => { + if (status === VolumeStatus.ACTIVE) return 'default'; + if (status === VolumeStatus.PROVISIONING) return 'secondary'; + if (status === VolumeStatus.FAILED) return 'destructive'; + return 'outline'; +}; + +export function VolumeDetailPage() { + const { id: organizationIdParam, volumeId: volumeIdParam } = useParams(); + const organizationId = organizationIdParam ?? ''; + const volumeId = volumeIdParam ?? ''; + const location = useLocation(); + + const notificationRooms = useMemo(() => { + const rooms: string[] = []; + if (organizationId) rooms.push(`organization:${organizationId}`); + if (volumeId) rooms.push(`volume:${volumeId}`); + return rooms; + }, [organizationId, volumeId]); + + useNotifications({ + events: ['volume.updated'], + invalidateKeys: [['volumes', volumeId, 'detail']], + rooms: notificationRooms, + enabled: Boolean(volumeId) && notificationRooms.length > 0, + }); + + const volumeQuery = useQuery({ + queryKey: ['volumes', volumeId, 'detail'], + queryFn: () => runnersClient.getVolume({ id: volumeId }), + enabled: Boolean(volumeId), + staleTime: 30_000, + refetchOnWindowFocus: false, + }); + + const volume = volumeQuery.data?.volume ?? null; + const isNotFoundError = volumeQuery.error instanceof ConnectError && volumeQuery.error.code === Code.NotFound; + const isOrgMismatch = Boolean(volume && organizationId && volume.organizationId !== organizationId); + const isMissing = !volume && !volumeQuery.isPending && !volumeQuery.isError; + const showNotFound = isNotFoundError || isOrgMismatch || isMissing; + const showError = volumeQuery.isError && !isNotFoundError; + + const volumeTitle = volume?.volumeName || volume?.volumeId ? `Volume ${truncate(volume.volumeName || volume.volumeId, 18)}` : 'Volume'; + useDocumentTitle(volumeTitle); + + const fromState = + typeof location.state === 'object' && + location.state !== null && + 'from' in location.state && + typeof (location.state as { from?: unknown }).from === 'string' + ? (location.state as { from: string }).from + : undefined; + const fallbackBack = organizationId ? `/organizations/${organizationId}/activity/storage` : '/organizations'; + const backHref = fromState || fallbackBack; + const backLabel = fromState ? '← Back' : organizationId ? '← Back to Storage' : '← Back'; + + const volumeName = volume?.volumeName || volume?.volumeId || volume?.meta?.id || EMPTY_PLACEHOLDER; + const volumeIdLabel = volume?.volumeId || volume?.meta?.id || EMPTY_PLACEHOLDER; + const sizeLabel = volume?.sizeGb ? `${volume.sizeGb} GB` : EMPTY_PLACEHOLDER; + const runnerId = volume?.runnerId || ''; + const agentId = volume?.agentId || ''; + const runnerLink = organizationId && runnerId ? `/organizations/${organizationId}/runners/${runnerId}` : ''; + const agentLink = organizationId && agentId ? `/organizations/${organizationId}/agents/${agentId}` : ''; + const threadLink = organizationId && volume?.threadId ? `/organizations/${organizationId}/threads/${volume.threadId}` : ''; + const attachments = volume?.attachments ?? []; + + return ( +
+
+ +
+ {volumeQuery.isPending ?
Loading volume...
: null} + {showError ?
Failed to load volume.
: null} + {showNotFound ?
Volume not found.
: null} + {volume && !showNotFound ? ( +
+ + +
+

Details

+

Identifiers and storage status.

+
+
+
+
Name
+
{volumeName}
+
+
+
Volume ID
+
{volumeIdLabel}
+
+
+
Status
+ {formatVolumeStatus(volume.status)} +
+
+
Size
+
{sizeLabel}
+
+
+
Organization ID
+
{volume.organizationId || EMPTY_PLACEHOLDER}
+
+
+
Runner
+
+ {runnerLink ? ( + + {runnerId || EMPTY_PLACEHOLDER} + + ) : ( + runnerId || EMPTY_PLACEHOLDER + )} +
+
+
+
Agent
+
+ {agentLink ? ( + + {agentId || EMPTY_PLACEHOLDER} + + ) : ( + agentId || EMPTY_PLACEHOLDER + )} +
+
+
+
Thread
+
+ {threadLink ? ( + + {truncate(volume.threadId, 18)} + + ) : ( + truncate(volume.threadId, 18) + )} +
+
+
+
Instance ID
+
{volume.instanceId || EMPTY_PLACEHOLDER}
+
+
+
Created
+
{formatTimestamp(volume.meta?.createdAt)}
+
+
+
Removed
+
{formatTimestamp(volume.removedAt)}
+
+
+
Last Metering Sample
+
{formatTimestamp(volume.lastMeteringSampledAt)}
+
+
+
+
+ + +
+

Attachments

+

Active attachment targets for this volume.

+
+ {attachments.length === 0 ? ( +
No attachments reported.
+ ) : ( +
+ {attachments.map((attachment) => { + const label = formatAttachmentLabel(attachment); + return ( +
+
{label}
+
+ ); + })} +
+ )} +
+
+
+ ) : null} +
+ ); +} diff --git a/src/pages/WorkloadDetailPage.tsx b/src/pages/WorkloadDetailPage.tsx index 264819d..249259e 100644 --- a/src/pages/WorkloadDetailPage.tsx +++ b/src/pages/WorkloadDetailPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { NavLink, useLocation, useParams } from 'react-router-dom'; import { Code, ConnectError } from '@connectrpc/connect'; import { useQuery } from '@tanstack/react-query'; @@ -14,6 +14,7 @@ import { useNotifications } from '@/hooks/useNotifications'; import { EMPTY_PLACEHOLDER, formatContainerStatus, + formatDurationBetween, formatTimestamp, formatWorkloadStatus, truncate, @@ -254,11 +255,18 @@ export function WorkloadDetailPage() { const workloadId = workloadIdParam ?? ''; const location = useLocation(); + const notificationRooms = useMemo(() => { + const rooms: string[] = []; + if (organizationId) rooms.push(`organization:${organizationId}`); + if (workloadId) rooms.push(`workload:${workloadId}`); + return rooms; + }, [organizationId, workloadId]); + useNotifications({ - rooms: workloadId ? [`workload:${workloadId}`] : [], events: ['workload.status_changed', 'workload.updated'], invalidateKeys: [['workloads', workloadId, 'detail']], - enabled: Boolean(workloadId), + rooms: notificationRooms, + enabled: Boolean(workloadId) && notificationRooms.length > 0, }); const workloadQuery = useQuery({ @@ -328,6 +336,21 @@ export function WorkloadDetailPage() { : '← Back to Runners'; const workloadIdLabel = workload?.meta?.id ?? EMPTY_PLACEHOLDER; + const agentName = workload?.agentName?.trim(); + const runnerName = workload?.runnerName?.trim(); + const agentId = workload?.agentId ?? ''; + const runnerId = workload?.runnerId ?? ''; + const agentLink = organizationId && agentId && agentName ? `/organizations/${organizationId}/agents/${agentId}` : ''; + const runnerLink = organizationId && runnerId && runnerName ? `/organizations/${organizationId}/runners/${runnerId}` : ''; + const agentLabel = agentName || EMPTY_PLACEHOLDER; + const runnerLabel = runnerName || EMPTY_PLACEHOLDER; + const durationEnd = workload + ? workload.removedAt ?? + (workload.status === WorkloadStatus.STOPPED || workload.status === WorkloadStatus.FAILED + ? workload.lastActivityAt + : undefined) + : undefined; + const durationLabel = workload ? formatDurationBetween(workload.meta?.createdAt, durationEnd) : EMPTY_PLACEHOLDER; const allocatedCpu = workload ? `${workload.allocatedCpuMillicores.toLocaleString()} m` : EMPTY_PLACEHOLDER; const allocatedRam = workload ? `${workload.allocatedRamBytes.toString()} bytes` : EMPTY_PLACEHOLDER; @@ -363,16 +386,32 @@ export function WorkloadDetailPage() {
{workload.organizationId || EMPTY_PLACEHOLDER}
-
Runner ID
-
{workload.runnerId || EMPTY_PLACEHOLDER}
+
Runner
+
+ {runnerLink ? ( + + {runnerLabel} + + ) : ( + runnerLabel + )} +
Thread ID
{workload.threadId || EMPTY_PLACEHOLDER}
-
Agent ID
-
{workload.agentId || EMPTY_PLACEHOLDER}
+
Agent
+
+ {agentLink ? ( + + {agentLabel} + + ) : ( + agentLabel + )} +
Instance ID
@@ -386,6 +425,10 @@ export function WorkloadDetailPage() {
Created
{formatTimestamp(workload.meta?.createdAt)}
+
+
Duration
+
{durationLabel}
+
Last Activity
{formatTimestamp(workload.lastActivityAt)}