From e569086cf9c1a300d459f85dec52ae96c8c94179 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Sat, 25 Apr 2026 06:23:17 +0000 Subject: [PATCH 01/12] feat(console): migrate activity lists --- buf.lock | 4 +- package-lock.json | 1027 +---------------- src/__tests__/organization-context.test.tsx | 90 +- src/components/WorkloadsTable.tsx | 93 +- src/context/OrganizationContext.tsx | 57 +- src/hooks/useNotifications.ts | 27 +- src/pages/OrganizationActivityStorageTab.tsx | 237 ++-- .../OrganizationActivityWorkloadsTab.tsx | 119 +- src/pages/OrganizationOverviewTab.tsx | 9 +- src/pages/OrganizationThreadsTab.tsx | 130 ++- src/pages/RunnerDetailPage.tsx | 24 +- src/pages/WorkloadDetailPage.tsx | 12 +- 12 files changed, 580 insertions(+), 1249 deletions(-) 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..e8b13f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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/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/WorkloadsTable.tsx b/src/components/WorkloadsTable.tsx index 0a214b6..62f62c2 100644 --- a/src/components/WorkloadsTable.tsx +++ b/src/components/WorkloadsTable.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import type { InfiniteData, UseInfiniteQueryResult } from '@tanstack/react-query'; import { NavLink, useLocation } from 'react-router-dom'; import { SortableHeader } from '@/components/SortableHeader'; @@ -8,14 +9,28 @@ 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 { type SortDirection, useListControls } from '@/hooks/useListControls'; import { formatTimestamp, formatWorkloadStatus, summarizeContainers, timestampToMillis } from '@/lib/format'; +export type WorkloadSortKey = 'agentId' | 'runnerId' | 'threadId' | 'status' | 'started'; + +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; getWorkloadLink?: (workload: Workload) => string | null; + controls?: WorkloadsTableControls; + filterBar?: ReactNode; + searchPlaceholder?: string; + hasActiveFilters?: boolean; testIdPrefix: string; }; @@ -24,6 +39,10 @@ export function WorkloadsTable({ query, showRunnerColumn = false, getWorkloadLink, + controls, + filterBar, + searchPlaceholder = 'Search workloads...', + hasActiveFilters, testIdPrefix, }: WorkloadsTableProps) { const location = useLocation(); @@ -53,8 +72,15 @@ export function WorkloadsTable({ defaultSortDirection: 'desc', }); - const visibleWorkloads = listControls.filteredItems; - const hasSearch = listControls.searchTerm.trim().length > 0; + 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 = searchTerm.trim().length > 0; + const hasFilters = controls ? (hasActiveFilters ?? hasSearch) : hasSearch; const hasAction = Boolean(getWorkloadLink); const getStatusVariant = (status: WorkloadStatus) => { @@ -77,13 +103,16 @@ export function WorkloadsTable({ return (
-
- listControls.setSearchTerm(event.target.value)} - data-testid={`${testIdPrefix}-search`} - /> +
+
+ handleSearchChange(event.target.value)} + data-testid={`${testIdPrefix}-search`} + /> +
+ {filterBar}
{query.isPending ?
Loading workloads...
: null} {query.isError ?
Failed to load workloads.
: null} @@ -96,47 +125,51 @@ export function WorkloadsTable({ {showRunnerColumn ? ( ) : null} - + {controls ? ( + Thread ID + ) : ( + + )} Containers {hasAction ? Action : null}
{visibleWorkloads.length === 0 ? (
- {hasSearch ? 'No results found.' : emptyMessage} + {hasFilters ? 'No results found.' : emptyMessage}
) : ( visibleWorkloads.map((workload) => { 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..37f0c20 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { notificationsClient } from '@/api/client'; @@ -14,26 +14,27 @@ export function useNotifications(options: UseNotificationsOptions): void { 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 roomsRef = useRef([]); eventsRef.current = events; keysRef.current = invalidateKeys; - roomsRef.current = rooms; + 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; @@ -52,5 +53,5 @@ export function useNotifications(options: UseNotificationsOptions): void { return () => { controller.abort(); }; - }, [enabled, hasRooms, queryClient, roomsKey]); + }, [enabled, queryClient, roomsKey]); } diff --git a/src/pages/OrganizationActivityStorageTab.tsx b/src/pages/OrganizationActivityStorageTab.tsx index 233dfba..4efdf4c 100644 --- a/src/pages/OrganizationActivityStorageTab.tsx +++ b/src/pages/OrganizationActivityStorageTab.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { useInfiniteQuery, useQueries } from '@tanstack/react-query'; import { agentsClient, runnersClient } from '@/api/client'; @@ -7,20 +7,33 @@ 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import type { VolumeAttachment } from '@/gen/agynio/api/agents/v1/agents_pb'; -import { VolumeStatus, type Volume } from '@/gen/agynio/api/runners/v1/runners_pb'; +import { + ListVolumesSortField, + SortDirection as VolumesSortDirection, + VolumeStatus, + type Volume, +} from '@/gen/agynio/api/runners/v1/runners_pb'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; -import { useListControls } from '@/hooks/useListControls'; +import { useNotifications } from '@/hooks/useNotifications'; +import { type SortDirection } from '@/hooks/useListControls'; import { EMPTY_PLACEHOLDER, formatVolumeStatus, truncate } from '@/lib/format'; import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from '@/lib/pagination'; const UNATTACHED_LABEL = 'Unattached'; +type VolumeSortKey = 'name' | 'size' | 'status'; + +const VOLUME_STATUS_OPTIONS = [ + VolumeStatus.PROVISIONING, + VolumeStatus.ACTIVE, + VolumeStatus.DEPROVISIONING, + VolumeStatus.DELETED, + VolumeStatus.FAILED, +]; + 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; -}; const formatAttachmentTarget = (attachment: VolumeAttachment) => { const targetId = attachment.target.value; @@ -36,15 +49,56 @@ export function OrganizationActivityStorageTab() { const { id } = useParams(); const organizationId = id ?? ''; + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [sortKey, setSortKey] = useState('name'); + const [sortDirection, setSortDirection] = useState('asc'); + + const notificationRooms = useMemo( + () => (organizationId ? [`organization:${organizationId}`] : []), + [organizationId], + ); + + useNotifications({ + events: ['volume.updated'], + invalidateKeys: [['runners', organizationId, 'volumes', 'list']], + rooms: notificationRooms, + enabled: Boolean(organizationId) && notificationRooms.length > 0, + }); + + const normalizedSearch = searchTerm.trim(); + const filterKey = useMemo( + () => ({ search: normalizedSearch, status: statusFilter }), + [normalizedSearch, statusFilter], + ); + const sortSpec = useMemo(() => { + const fieldMap: Record = { + name: ListVolumesSortField.NAME, + size: ListVolumesSortField.SIZE, + status: ListVolumesSortField.STATUS, + }; + return { + field: fieldMap[sortKey], + direction: sortDirection === 'asc' ? VolumesSortDirection.ASC : VolumesSortDirection.DESC, + }; + }, [sortDirection, sortKey]); + const filterSpec = useMemo(() => { + const statusValue = statusFilter === 'all' ? null : (Number(statusFilter) as VolumeStatus); + return { + volumeNameSubstring: normalizedSearch || undefined, + statusIn: statusValue ? [statusValue] : [], + }; + }, [normalizedSearch, statusFilter]); const volumesQuery = useInfiniteQuery({ - queryKey: ['runners', organizationId, 'volumes', 'list'], + queryKey: ['runners', organizationId, 'volumes', 'list', filterKey, sortSpec], queryFn: ({ pageParam }) => runnersClient.listVolumes({ organizationId, pageSize: DEFAULT_PAGE_SIZE, pageToken: pageParam, - statuses: [], + filter: filterSpec, + sort: sortSpec, }), initialPageParam: '', getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined, @@ -94,27 +148,15 @@ export function OrganizationActivityStorageTab() { return labels.length > 0 ? labels.join(', ') : UNATTACHED_LABEL; }; - 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 hasActiveFilters = normalizedSearch.length > 0 || statusFilter !== 'all'; + const handleSort = (key: VolumeSortKey) => { + if (key === sortKey) { + setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); + return; + } + setSortKey(key); + setSortDirection('asc'); + }; const getStatusVariant = (status: VolumeStatus) => { if (status === VolumeStatus.ACTIVE) return 'default'; @@ -131,20 +173,37 @@ 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} @@ -158,79 +217,61 @@ export function OrganizationActivityStorageTab() { - - + Used + 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 = getVolumeName(volume) || EMPTY_PLACEHOLDER; + const sizeLabel = volume.sizeGb ? `${volume.sizeGb} GB` : EMPTY_PLACEHOLDER; + const attachedLabel = getAttachedLabel(volume); + return ( +
+
+
+ {truncate(name, 24)} +
+
+ {name}
- - {sizeLabel} - - - {EMPTY_PLACEHOLDER} - - - {attachedLabel} - - - {formatVolumeStatus(volume.status)} -
- ); - }) - )} + + {sizeLabel} + + + {EMPTY_PLACEHOLDER} + + + {attachedLabel} + + + {formatVolumeStatus(volume.status)} + +
+ ); + })}
diff --git a/src/pages/OrganizationActivityWorkloadsTab.tsx b/src/pages/OrganizationActivityWorkloadsTab.tsx index e9d58cf..8e357e4 100644 --- a/src/pages/OrganizationActivityWorkloadsTab.tsx +++ b/src/pages/OrganizationActivityWorkloadsTab.tsx @@ -1,32 +1,92 @@ +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 { WorkloadsTable, type WorkloadSortKey } from '@/components/WorkloadsTable'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + ListWorkloadsSortField, + SortDirection as WorkloadsSortDirection, + WorkloadStatus, +} from '@/gen/agynio/api/runners/v1/runners_pb'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useNotifications } from '@/hooks/useNotifications'; +import { type SortDirection } from '@/hooks/useListControls'; +import { formatWorkloadStatus } from '@/lib/format'; import { DEFAULT_PAGE_SIZE } from '@/lib/pagination'; +const WORKLOAD_STATUS_OPTIONS = [ + WorkloadStatus.STARTING, + WorkloadStatus.RUNNING, + WorkloadStatus.STOPPING, + WorkloadStatus.STOPPED, + WorkloadStatus.FAILED, +]; + +type ActivityWorkloadSortKey = Exclude; + export function OrganizationActivityWorkloadsTab() { useDocumentTitle('Workloads'); const { id } = useParams(); const organizationId = id ?? ''; + const [agentIdFilter, setAgentIdFilter] = useState(''); + const [runnerIdFilter, setRunnerIdFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [sortKey, setSortKey] = useState('started'); + const [sortDirection, setSortDirection] = useState('desc'); + + const notificationRooms = useMemo( + () => (organizationId ? [`organization:${organizationId}`] : []), + [organizationId], + ); useNotifications({ - rooms: organizationId ? [`organization:${organizationId}`] : [], events: ['workload.updated'], invalidateKeys: [['workloads', organizationId, 'list']], - enabled: Boolean(organizationId), + rooms: notificationRooms, + enabled: Boolean(organizationId) && notificationRooms.length > 0, }); + const normalizedAgentId = agentIdFilter.trim(); + const normalizedRunnerId = runnerIdFilter.trim(); + const filterKey = useMemo( + () => ({ agentId: normalizedAgentId, runnerId: normalizedRunnerId, status: statusFilter }), + [normalizedAgentId, normalizedRunnerId, statusFilter], + ); + + const sortSpec = useMemo(() => { + const fieldMap: Record = { + agentId: ListWorkloadsSortField.AGENT, + runnerId: ListWorkloadsSortField.RUNNER, + status: ListWorkloadsSortField.STATUS, + started: ListWorkloadsSortField.STARTED, + }; + return { + field: fieldMap[sortKey], + direction: sortDirection === 'asc' ? WorkloadsSortDirection.ASC : WorkloadsSortDirection.DESC, + }; + }, [sortDirection, sortKey]); + + const filterSpec = useMemo(() => { + const statusValue = statusFilter === 'all' ? null : (Number(statusFilter) as WorkloadStatus); + return { + agentIdIn: normalizedAgentId ? [normalizedAgentId] : [], + runnerIdIn: normalizedRunnerId ? [normalizedRunnerId] : [], + statusIn: statusValue ? [statusValue] : [], + }; + }, [normalizedAgentId, normalizedRunnerId, statusFilter]); + const workloadsQuery = useInfiniteQuery({ - queryKey: ['workloads', organizationId, 'list'], + queryKey: ['workloads', organizationId, 'list', filterKey, sortSpec], queryFn: ({ pageParam }) => runnersClient.listWorkloads({ organizationId, pageSize: DEFAULT_PAGE_SIZE, pageToken: pageParam, - statuses: [], + filter: filterSpec, + sort: sortSpec, }), initialPageParam: '', getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined, @@ -37,6 +97,19 @@ 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 = + normalizedAgentId.length > 0 || normalizedRunnerId.length > 0 || statusFilter !== 'all'; + return (
@@ -49,6 +122,42 @@ export function OrganizationActivityWorkloadsTab() { workloads={workloads} query={workloadsQuery} showRunnerColumn + controls={{ + searchTerm: agentIdFilter, + onSearchTermChange: setAgentIdFilter, + sortKey, + sortDirection, + onSort: handleSort, + }} + searchPlaceholder="Filter by agent ID..." + filterBar={ + <> +
+ setRunnerIdFilter(event.target.value)} + data-testid="organization-workloads-runner-filter" + /> +
+
+ +
+ + } + hasActiveFilters={hasActiveFilters} testIdPrefix="organization-workloads" />
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..c2148f6 100644 --- a/src/pages/OrganizationThreadsTab.tsx +++ b/src/pages/OrganizationThreadsTab.tsx @@ -1,31 +1,87 @@ -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 { threadsClient } from '@/api/client'; import { LoadMoreButton } from '@/components/LoadMoreButton'; +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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + ListOrganizationThreadsSortField, + SortDirection as ThreadsSortDirection, + ThreadStatus, +} from '@/gen/agynio/api/threads/v1/threads_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'; + +const THREAD_STATUS_OPTIONS = [ThreadStatus.ACTIVE, ThreadStatus.ARCHIVED, ThreadStatus.DEGRADED]; + export function OrganizationThreadsTab() { useDocumentTitle('Threads'); const { id } = useParams(); const organizationId = id ?? ''; + const { identityId } = useUserContext(); + const [participantFilter, setParticipantFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [sortKey, setSortKey] = useState('created'); + const [sortDirection, setSortDirection] = useState('desc'); + + const notificationRooms = useMemo( + () => (identityId ? [`thread_participant:${identityId}`] : []), + [identityId], + ); + + useNotifications({ + events: ['message.created'], + invalidateKeys: [['threads', organizationId, 'list']], + rooms: notificationRooms, + enabled: Boolean(organizationId) && notificationRooms.length > 0, + }); + + const normalizedParticipant = participantFilter.trim(); + const filterKey = useMemo( + () => ({ participant: normalizedParticipant, status: statusFilter }), + [normalizedParticipant, statusFilter], + ); + const sortSpec = useMemo(() => { + const fieldMap: Record = { + status: ListOrganizationThreadsSortField.STATUS, + messages: ListOrganizationThreadsSortField.MESSAGE_COUNT, + created: ListOrganizationThreadsSortField.CREATED, + }; + return { + field: fieldMap[sortKey], + direction: sortDirection === 'asc' ? ThreadsSortDirection.ASC : ThreadsSortDirection.DESC, + }; + }, [sortDirection, sortKey]); + const filterSpec = useMemo(() => { + const statusValue = statusFilter === 'all' ? null : (Number(statusFilter) as ThreadStatus); + return { + participantIdIn: normalizedParticipant ? [normalizedParticipant] : [], + statusIn: statusValue ? [statusValue] : [], + }; + }, [normalizedParticipant, statusFilter]); const threadsQuery = useInfiniteQuery({ - queryKey: ['threads', organizationId, 'list'], + queryKey: ['threads', organizationId, 'list', filterKey, sortSpec], queryFn: ({ pageParam }) => - threadsClient.getOrganizationThreads({ + threadsClient.listOrganizationThreads({ organizationId, pageSize: DEFAULT_PAGE_SIZE, pageToken: pageParam, - status: ThreadStatus.UNSPECIFIED, + filter: filterSpec, + sort: sortSpec, }), initialPageParam: '', getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined, @@ -54,9 +110,43 @@ export function OrganizationThreadsTab() { }, [threads]); const { formatHandle } = useIdentityHandles(identityIds); + const hasActiveFilters = normalizedParticipant.length > 0 || statusFilter !== 'all'; + const handleSort = (key: ThreadSortKey) => { + if (key === sortKey) { + setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); + return; + } + setSortKey(key); + setSortDirection('asc'); + }; return (
+
+
+ setParticipantFilter(event.target.value)} + data-testid="organization-threads-search" + /> +
+
+ +
+
{isLoading ?
Loading threads...
: null} {isError ? (
@@ -66,7 +156,7 @@ export function OrganizationThreadsTab() { {threads.length === 0 && !isLoading && !isError ? ( - No threads yet. + {hasActiveFilters ? 'No results found.' : 'No threads yet.'} ) : null} @@ -76,9 +166,27 @@ export function OrganizationThreadsTab() {
Thread Participants - Status - Messages - Created + + +
{threads.map((thread) => { @@ -130,7 +238,9 @@ export function OrganizationThreadsTab() { threadsQuery.fetchNextPage()} + onClick={() => { + void threadsQuery.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/WorkloadDetailPage.tsx b/src/pages/WorkloadDetailPage.tsx index 264819d..ff36277 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'; @@ -254,11 +254,19 @@ 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({ From a7695de6f25dc955bc6a24dead804d96a85f1ef9 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Sat, 25 Apr 2026 07:27:51 +0000 Subject: [PATCH 02/12] feat(console): refine activity views --- package-lock.json | 2 +- package.json | 2 +- src/App.tsx | 2 + src/components/MultiSelectFilter.tsx | 90 +++++ src/components/WorkloadsTable.tsx | 151 ++++++-- src/hooks/useNotifications.ts | 9 +- src/lib/format.ts | 33 ++ src/pages/OrganizationActivityStorageTab.tsx | 350 +++++++++++++----- .../OrganizationActivityWorkloadsTab.tsx | 336 ++++++++++++++--- src/pages/OrganizationThreadsTab.tsx | 287 +++++++++++--- src/pages/VolumeDetailPage.tsx | 217 +++++++++++ src/pages/WorkloadDetailPage.tsx | 42 ++- 12 files changed, 1291 insertions(+), 230 deletions(-) create mode 100644 src/components/MultiSelectFilter.tsx create mode 100644 src/pages/VolumeDetailPage.tsx diff --git a/package-lock.json b/package-lock.json index e8b13f9..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", 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/components/MultiSelectFilter.tsx b/src/components/MultiSelectFilter.tsx new file mode 100644 index 0000000..95dae03 --- /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 62f62c2..b48a008 100644 --- a/src/components/WorkloadsTable.tsx +++ b/src/components/WorkloadsTable.tsx @@ -10,7 +10,14 @@ 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 { type SortDirection, useListControls } from '@/hooks/useListControls'; -import { formatTimestamp, formatWorkloadStatus, summarizeContainers, timestampToMillis } from '@/lib/format'; +import { + EMPTY_PLACEHOLDER, + formatDurationBetween, + formatTimestamp, + formatWorkloadStatus, + summarizeContainers, + timestampToMillis, +} from '@/lib/format'; export type WorkloadSortKey = 'agentId' | 'runnerId' | 'threadId' | 'status' | 'started'; @@ -26,7 +33,14 @@ 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; + agentLabel?: string; + runnerLabel?: string; + rowLinkMode?: 'row' | 'action'; controls?: WorkloadsTableControls; filterBar?: ReactNode; searchPlaceholder?: string; @@ -38,7 +52,14 @@ export function WorkloadsTable({ workloads, query, showRunnerColumn = false, + showDuration = false, + showSearch = true, getWorkloadLink, + getAgentName, + getRunnerName, + agentLabel = 'Agent ID', + runnerLabel = 'Runner ID', + rowLinkMode = 'action', controls, filterBar, searchPlaceholder = 'Search workloads...', @@ -46,9 +67,11 @@ export function WorkloadsTable({ testIdPrefix, }: WorkloadsTableProps) { const location = useLocation(); + const resolveAgentName = (workload: Workload) => getAgentName?.(workload)?.trim() || ''; + const resolveRunnerName = (workload: Workload) => getRunnerName?.(workload)?.trim() || ''; 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), ]; @@ -79,9 +102,9 @@ export function WorkloadsTable({ const handleSort = controls?.onSort ?? listControls.handleSort; const visibleWorkloads = controls ? workloads : listControls.filteredItems; - const hasSearch = searchTerm.trim().length > 0; + const hasSearch = showSearch && searchTerm.trim().length > 0; const hasFilters = controls ? (hasActiveFilters ?? hasSearch) : hasSearch; - const hasAction = Boolean(getWorkloadLink); + const hasAction = rowLinkMode === 'action' && Boolean(getWorkloadLink); const getStatusVariant = (status: WorkloadStatus) => { if (status === WorkloadStatus.RUNNING) return 'default'; @@ -91,27 +114,32 @@ 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) gridColumns.push('120px'); + const gridClass = `md:grid-cols-[${gridColumns.join('_')}]`; const emptyMessage = showRunnerColumn ? 'No workloads found.' : 'No workloads on this runner.'; return (
-
- handleSearchChange(event.target.value)} - data-testid={`${testIdPrefix}-search`} - /> -
+ {showSearch ? ( +
+ handleSearchChange(event.target.value)} + data-testid={`${testIdPrefix}-search`} + /> +
+ ) : null} {filterBar}
{query.isPending ?
Loading workloads...
: null} @@ -123,7 +151,7 @@ export function WorkloadsTable({ data-testid={`${testIdPrefix}-header`} > {showRunnerColumn ? ( + {showDuration ? Duration : null} {hasAction ? Action : null}
@@ -179,22 +208,45 @@ 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 agentIdLabel = workload.agentId || EMPTY_PLACEHOLDER; + const runnerIdLabel = workload.runnerId || EMPTY_PLACEHOLDER; + const durationEnd = + workload.removedAt ?? + (workload.status === WorkloadStatus.STOPPED || workload.status === WorkloadStatus.FAILED + ? workload.lastActivityAt + : undefined); + const durationLabel = showDuration + ? formatDurationBetween(workload.meta?.createdAt, durationEnd) + : EMPTY_PLACEHOLDER; + + const rowContent = ( + <> +
+ {agentName ? ( +
+
{agentName}
+
{agentIdLabel}
+
+ ) : ( + agentIdLabel + )} +
{showRunnerColumn ? ( - - {workload.runnerId || '—'} - +
+ {runnerName ? ( +
+
{runnerName}
+
{runnerIdLabel}
+
+ ) : ( + runnerIdLabel + )} +
) : null} - {workload.threadId || '—'} + {workload.threadId || EMPTY_PLACEHOLDER} {formatWorkloadStatus(workload.status)} @@ -205,6 +257,11 @@ export function WorkloadsTable({ {formatTimestamp(workload.meta?.createdAt)} + {showDuration ? ( + + {durationLabel} + + ) : null} {hasAction ? (
{workloadLink ? ( @@ -224,6 +281,30 @@ export function WorkloadsTable({ )}
) : null} + + ); + + if (rowLinkMode === 'row' && workloadLink) { + return ( + + {rowContent} + + ); + } + + return ( +
+ {rowContent}
); }) diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 37f0c20..8590f80 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -1,22 +1,26 @@ 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 onEventRef = useRef(onEvent); const roomsRef = useRef([]); eventsRef.current = events; keysRef.current = invalidateKeys; + onEventRef.current = onEvent; const normalizedRooms = useMemo( () => rooms.map((room) => room.trim()).filter((room) => room.length > 0), [rooms], @@ -39,6 +43,7 @@ export function useNotifications(options: UseNotificationsOptions): void { if (!envelope) continue; if (!eventsRef.current.includes(envelope.event)) continue; + onEventRef.current?.(envelope); for (const key of keysRef.current) { void queryClient.invalidateQueries({ queryKey: key }); } 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 4efdf4c..66369ec 100644 --- a/src/pages/OrganizationActivityStorageTab.tsx +++ b/src/pages/OrganizationActivityStorageTab.tsx @@ -1,29 +1,32 @@ import { useMemo, useState } from 'react'; -import { useParams } from 'react-router-dom'; -import { useInfiniteQuery, useQueries } from '@tanstack/react-query'; -import { agentsClient, runnersClient } from '@/api/client'; +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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import type { VolumeAttachment } from '@/gen/agynio/api/agents/v1/agents_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 { useNotifications } from '@/hooks/useNotifications'; import { type SortDirection } from '@/hooks/useListControls'; -import { EMPTY_PLACEHOLDER, formatVolumeStatus, truncate } from '@/lib/format'; +import { EMPTY_PLACEHOLDER, formatDateOnly, formatVolumeStatus, truncate } from '@/lib/format'; import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from '@/lib/pagination'; const UNATTACHED_LABEL = 'Unattached'; -type VolumeSortKey = 'name' | 'size' | 'status'; +type VolumeSortKey = 'name' | 'size' | 'status' | 'created'; const VOLUME_STATUS_OPTIONS = [ VolumeStatus.PROVISIONING, @@ -33,15 +36,99 @@ const VOLUME_STATUS_OPTIONS = [ VolumeStatus.FAILED, ]; -const getVolumeName = (volume: Volume) => volume.meta?.id || volume.instanceId || volume.volumeId || ''; +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 || volume.volumeId || volume.meta?.id || ''; + +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 summarizeAttachments = (attachments: Attachment[]) => { + if (attachments.length === 0) return UNATTACHED_LABEL; + const labels = [...attachments] + .sort((left, right) => left.name.localeCompare(right.name)) + .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 replaceFirstPage = ( + data: InfiniteData | undefined, + firstPage: TPage, +): InfiniteData => { + if (!data) { + return { pages: [firstPage], pageParams: [''] }; + } + const nextPages = [firstPage, ...data.pages.slice(1)]; + const nextPageParams = data.pageParams.length > 0 ? data.pageParams : ['']; + return { ...data, pages: nextPages, pageParams: nextPageParams }; +}; + +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 }; + } -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; + return { ...data, pages: nextPages }; }; export function OrganizationActivityStorageTab() { @@ -49,33 +136,65 @@ export function OrganizationActivityStorageTab() { const { id } = useParams(); const organizationId = id ?? ''; + const location = useLocation(); + const queryClient = useQueryClient(); const [searchTerm, setSearchTerm] = useState(''); - const [statusFilter, setStatusFilter] = useState('all'); + 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() || runnerId; + return { + value: runnerId, + label: name, + secondary: name === runnerId ? undefined : runnerId, + }; + }) + .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], ); - useNotifications({ - events: ['volume.updated'], - invalidateKeys: [['runners', organizationId, 'volumes', 'list']], - rooms: notificationRooms, - enabled: Boolean(organizationId) && notificationRooms.length > 0, - }); - const normalizedSearch = searchTerm.trim(); const filterKey = useMemo( - () => ({ search: normalizedSearch, status: statusFilter }), - [normalizedSearch, statusFilter], + () => ({ 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], @@ -83,15 +202,25 @@ export function OrganizationActivityStorageTab() { }; }, [sortDirection, sortKey]); const filterSpec = useMemo(() => { - const statusValue = statusFilter === 'all' ? null : (Number(statusFilter) as VolumeStatus); + 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: statusValue ? [statusValue] : [], + statusIn: statusValues, + runnerIdIn: runnerFilter, + attachedToKindIn: attachedKinds, }; - }, [normalizedSearch, statusFilter]); + }, [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', filterKey, sortSpec], + queryKey: volumesQueryKey, queryFn: ({ pageParam }) => runnersClient.listVolumes({ organizationId, @@ -111,44 +240,11 @@ 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 || statusFilter !== 'all'; + 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')); @@ -158,6 +254,8 @@ export function OrganizationActivityStorageTab() { setSortDirection('asc'); }; + const hasActiveControls = hasActiveFilters || sortKey !== 'name' || sortDirection !== 'asc'; + const getStatusVariant = (status: VolumeStatus) => { if (status === VolumeStatus.ACTIVE) return 'default'; if (status === VolumeStatus.PROVISIONING) return 'secondary'; @@ -165,6 +263,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) => replaceFirstPage(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 (
@@ -183,19 +325,31 @@ export function OrganizationActivityStorageTab() { />
- + +
+
+ +
+
+
{volumesQuery.isPending ?
Loading storage volumes...
: null} @@ -211,7 +365,7 @@ export function OrganizationActivityStorageTab() {
- Used + Attached to {volumes.map((volume) => { const name = getVolumeName(volume) || 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 = getAttachedLabel(volume); + const attachedLabel = summarizeAttachments(volume.attachments ?? []); + const createdLabel = formatDateOnly(volume.meta?.createdAt); return (
- {truncate(name, 24)} + {volumeLink ? ( + + {truncate(name, 24)} + + ) : ( + truncate(name, 24) + )}
- {name} + {volumeId || name}
{sizeLabel} - - {EMPTY_PLACEHOLDER} + + {createdLabel} {attachedLabel} diff --git a/src/pages/OrganizationActivityWorkloadsTab.tsx b/src/pages/OrganizationActivityWorkloadsTab.tsx index 8e357e4..8848f14 100644 --- a/src/pages/OrganizationActivityWorkloadsTab.tsx +++ b/src/pages/OrganizationActivityWorkloadsTab.tsx @@ -1,20 +1,24 @@ import { useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { useInfiniteQuery } from '@tanstack/react-query'; -import { runnersClient } from '@/api/client'; +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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 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 { type SortDirection } from '@/hooks/useListControls'; import { formatWorkloadStatus } from '@/lib/format'; -import { DEFAULT_PAGE_SIZE } from '@/lib/pagination'; +import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from '@/lib/pagination'; const WORKLOAD_STATUS_OPTIONS = [ WorkloadStatus.STARTING, @@ -26,34 +30,182 @@ const WORKLOAD_STATUS_OPTIONS = [ 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 replaceFirstPage = ( + data: InfiniteData | undefined, + firstPage: TPage, +): InfiniteData => { + if (!data) { + return { pages: [firstPage], pageParams: [''] }; + } + const nextPages = [firstPage, ...data.pages.slice(1)]; + const nextPageParams = data.pageParams.length > 0 ? data.pageParams : ['']; + return { ...data, pages: nextPages, pageParams: nextPageParams }; +}; + +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 [agentIdFilter, setAgentIdFilter] = useState(''); - const [runnerIdFilter, setRunnerIdFilter] = useState(''); - const [statusFilter, setStatusFilter] = useState('all'); + const queryClient = useQueryClient(); + const [searchTerm, setSearchTerm] = useState(''); + 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'); + 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() || agentId; + return { + value: agentId, + label: name, + secondary: name === agentId ? undefined : agentId, + }; + }) + .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() || runnerId; + return { + value: runnerId, + label: name, + secondary: name === runnerId ? undefined : runnerId, + }; + }) + .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], ); - useNotifications({ - events: ['workload.updated'], - invalidateKeys: [['workloads', organizationId, 'list']], - rooms: notificationRooms, - enabled: Boolean(organizationId) && notificationRooms.length > 0, - }); + 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 normalizedAgentId = agentIdFilter.trim(); - const normalizedRunnerId = runnerIdFilter.trim(); const filterKey = useMemo( - () => ({ agentId: normalizedAgentId, runnerId: normalizedRunnerId, status: statusFilter }), - [normalizedAgentId, normalizedRunnerId, statusFilter], + () => ({ + agents: agentIdFilter, + runners: runnerIdFilter, + status: statusFilter, + startedAfter, + startedBefore, + }), + [agentIdFilter, runnerIdFilter, statusFilter, startedAfter, startedBefore], ); const sortSpec = useMemo(() => { @@ -70,16 +222,23 @@ export function OrganizationActivityWorkloadsTab() { }, [sortDirection, sortKey]); const filterSpec = useMemo(() => { - const statusValue = statusFilter === 'all' ? null : (Number(statusFilter) as WorkloadStatus); + const statusValues = statusFilter.map((value) => Number(value) as WorkloadStatus).filter((value) => value > 0); return { - agentIdIn: normalizedAgentId ? [normalizedAgentId] : [], - runnerIdIn: normalizedRunnerId ? [normalizedRunnerId] : [], - statusIn: statusValue ? [statusValue] : [], + agentIdIn: agentIdFilter, + runnerIdIn: runnerIdFilter, + statusIn: statusValues, + startedAfter: rangeError ? undefined : startDate ? toTimestamp(startDate) : undefined, + startedBefore: rangeError ? undefined : endDate ? toTimestamp(endDate) : undefined, }; - }, [normalizedAgentId, normalizedRunnerId, statusFilter]); + }, [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', filterKey, sortSpec], + queryKey: workloadsQueryKey, queryFn: ({ pageParam }) => runnersClient.listWorkloads({ organizationId, @@ -108,7 +267,56 @@ export function OrganizationActivityWorkloadsTab() { }; const hasActiveFilters = - normalizedAgentId.length > 0 || normalizedRunnerId.length > 0 || statusFilter !== 'all'; + 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) => replaceFirstPage(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 (
@@ -122,44 +330,80 @@ 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 || ''} + agentLabel="Agent" + runnerLabel="Runner" controls={{ - searchTerm: agentIdFilter, - onSearchTermChange: setAgentIdFilter, + searchTerm, + onSearchTermChange: setSearchTerm, sortKey, sortDirection, onSort: handleSort, }} - searchPlaceholder="Filter by agent ID..." filterBar={ <> -
- setRunnerIdFilter(event.target.value)} - data-testid="organization-workloads-runner-filter" +
+
- + +
+
+ +
+
+
+ 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/OrganizationThreadsTab.tsx b/src/pages/OrganizationThreadsTab.tsx index c2148f6..6f94be1 100644 --- a/src/pages/OrganizationThreadsTab.tsx +++ b/src/pages/OrganizationThreadsTab.tsx @@ -1,39 +1,123 @@ 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 { Input } from '@/components/ui/input'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 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'; +type ThreadSortKey = 'status' | 'messages' | 'created' | 'updated'; const THREAD_STATUS_OPTIONS = [ThreadStatus.ACTIVE, ThreadStatus.ARCHIVED, ThreadStatus.DEGRADED]; +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 replaceFirstPage = ( + data: InfiniteData | undefined, + firstPage: TPage, +): InfiniteData => { + if (!data) { + return { pages: [firstPage], pageParams: [''] }; + } + const nextPages = [firstPage, ...data.pages.slice(1)]; + const nextPageParams = data.pageParams.length > 0 ? data.pageParams : ['']; + return { ...data, pages: nextPages, pageParams: nextPageParams }; +}; + +const upsertThread = ( + data: InfiniteData>, unknown> | undefined, + thread: Thread, +): InfiniteData>, unknown> | 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 [participantFilter, setParticipantFilter] = useState(''); - const [statusFilter, setStatusFilter] = useState('all'); + 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'); @@ -42,23 +126,25 @@ export function OrganizationThreadsTab() { [identityId], ); - useNotifications({ - events: ['message.created'], - invalidateKeys: [['threads', organizationId, 'list']], - rooms: notificationRooms, - enabled: Boolean(organizationId) && notificationRooms.length > 0, - }); + 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 normalizedParticipant = participantFilter.trim(); const filterKey = useMemo( - () => ({ participant: normalizedParticipant, status: statusFilter }), - [normalizedParticipant, statusFilter], + () => ({ 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], @@ -66,15 +152,22 @@ export function OrganizationThreadsTab() { }; }, [sortDirection, sortKey]); const filterSpec = useMemo(() => { - const statusValue = statusFilter === 'all' ? null : (Number(statusFilter) as ThreadStatus); + const statusValues = statusFilter.map((value) => Number(value) as ThreadStatus).filter((value) => value > 0); return { - participantIdIn: normalizedParticipant ? [normalizedParticipant] : [], - statusIn: statusValue ? [statusValue] : [], + participantIdIn: participantFilter, + statusIn: statusValues, + createdAfter: rangeError ? undefined : startDate ? toTimestamp(startDate) : undefined, + createdBefore: rangeError ? undefined : endDate ? toTimestamp(endDate) : undefined, }; - }, [normalizedParticipant, statusFilter]); + }, [participantFilter, statusFilter, startDate, endDate, rangeError]); + + const threadsQueryKey = useMemo( + () => ['threads', organizationId, 'list', filterKey, sortSpec] as const, + [filterKey, organizationId, sortSpec], + ); const threadsQuery = useInfiniteQuery({ - queryKey: ['threads', organizationId, 'list', filterKey, sortSpec], + queryKey: threadsQueryKey, queryFn: ({ pageParam }) => threadsClient.listOrganizationThreads({ organizationId, @@ -99,18 +192,38 @@ export function OrganizationThreadsTab() { const isPermissionDenied = threadsQuery.error instanceof ConnectError && threadsQuery.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); + const label = nickname || participant.id; + participantMap.set(participant.id, { + value: participant.id, + label, + secondary: nickname ? participant.id : undefined, + }); }); }); - return Array.from(ids); + return Array.from(participantMap.values()).sort((left, right) => left.label.localeCompare(right.label)); }, [threads]); - const { formatHandle } = useIdentityHandles(identityIds); - const hasActiveFilters = normalizedParticipant.length > 0 || statusFilter !== 'all'; + 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')); @@ -120,33 +233,92 @@ export function OrganizationThreadsTab() { setSortDirection('asc'); }; + useNotifications({ + events: ['message.created'], + rooms: notificationRooms, + enabled: Boolean(organizationId) && notificationRooms.length > 0, + onEvent: (envelope) => { + if (hasActiveControls) { + void (async () => { + try { + const firstPage = await threadsClient.listOrganizationThreads({ + organizationId, + pageSize: DEFAULT_PAGE_SIZE, + pageToken: '', + filter: filterSpec, + sort: sortSpec, + }); + queryClient.setQueryData< + InfiniteData>, unknown> + >(threadsQueryKey, (data) => replaceFirstPage(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>, unknown>>( + threadsQueryKey, + (data) => upsertThread(data, thread), + ); + } catch (error) { + console.error('[useNotifications] thread update error:', error); + } + })(); + }, + }); + return (
-
- setParticipantFilter(event.target.value)} - data-testid="organization-threads-search" +
+
- + +
+
+
+ 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 ? (
@@ -163,7 +335,7 @@ export function OrganizationThreadsTab() { {threads.length > 0 ? ( -
+
Thread Participants +
{threads.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 (
@@ -228,6 +406,9 @@ export function OrganizationThreadsTab() { {formatDateOnly(thread.createdAt)} + + {formatDateOnly(thread.updatedAt)} + ); })} diff --git a/src/pages/VolumeDetailPage.tsx b/src/pages/VolumeDetailPage.tsx new file mode 100644 index 0000000..5172664 --- /dev/null +++ b/src/pages/VolumeDetailPage.tsx @@ -0,0 +1,217 @@ +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}
+ {attachment.id && attachment.id !== attachment.name ? ( +
{attachment.id}
+ ) : null} +
+ ); + })} +
+ )} +
+
+
+ ) : null} +
+ ); +} diff --git a/src/pages/WorkloadDetailPage.tsx b/src/pages/WorkloadDetailPage.tsx index ff36277..0984532 100644 --- a/src/pages/WorkloadDetailPage.tsx +++ b/src/pages/WorkloadDetailPage.tsx @@ -14,6 +14,7 @@ import { useNotifications } from '@/hooks/useNotifications'; import { EMPTY_PLACEHOLDER, formatContainerStatus, + formatDurationBetween, formatTimestamp, formatWorkloadStatus, truncate, @@ -336,6 +337,13 @@ 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 ? `/organizations/${organizationId}/agents/${agentId}` : ''; + const runnerLink = organizationId && runnerId ? `/organizations/${organizationId}/runners/${runnerId}` : ''; + const durationLabel = workload ? formatDurationBetween(workload.meta?.createdAt, workload.removedAt) : EMPTY_PLACEHOLDER; const allocatedCpu = workload ? `${workload.allocatedCpuMillicores.toLocaleString()} m` : EMPTY_PLACEHOLDER; const allocatedRam = workload ? `${workload.allocatedRamBytes.toString()} bytes` : EMPTY_PLACEHOLDER; @@ -371,16 +379,38 @@ export function WorkloadDetailPage() {
{workload.organizationId || EMPTY_PLACEHOLDER}
-
Runner ID
-
{workload.runnerId || EMPTY_PLACEHOLDER}
+
Runner
+
+ {runnerLink ? ( + + {runnerName || runnerId || EMPTY_PLACEHOLDER} + + ) : ( + runnerName || runnerId || EMPTY_PLACEHOLDER + )} +
+ {runnerName && runnerId && runnerName !== runnerId ? ( +
{runnerId}
+ ) : null}
Thread ID
{workload.threadId || EMPTY_PLACEHOLDER}
-
Agent ID
-
{workload.agentId || EMPTY_PLACEHOLDER}
+
Agent
+
+ {agentLink ? ( + + {agentName || agentId || EMPTY_PLACEHOLDER} + + ) : ( + agentName || agentId || EMPTY_PLACEHOLDER + )} +
+ {agentName && agentId && agentName !== agentId ? ( +
{agentId}
+ ) : null}
Instance ID
@@ -394,6 +424,10 @@ export function WorkloadDetailPage() {
Created
{formatTimestamp(workload.meta?.createdAt)}
+
+
Duration
+
{durationLabel}
+
Last Activity
{formatTimestamp(workload.lastActivityAt)}
From e086a7dde95d2a209d6775111528360ca8edc1a5 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Sat, 25 Apr 2026 07:52:45 +0000 Subject: [PATCH 03/12] fix(console): adjust activity listings --- src/components/WorkloadsTable.tsx | 96 +++++++++++++------ src/pages/OrganizationActivityStorageTab.tsx | 22 ++--- .../OrganizationActivityWorkloadsTab.tsx | 22 ++--- src/pages/OrganizationThreadsTab.tsx | 15 +-- src/pages/WorkloadDetailPage.tsx | 16 ++-- 5 files changed, 96 insertions(+), 75 deletions(-) diff --git a/src/components/WorkloadsTable.tsx b/src/components/WorkloadsTable.tsx index b48a008..fd7405e 100644 --- a/src/components/WorkloadsTable.tsx +++ b/src/components/WorkloadsTable.tsx @@ -1,6 +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'; @@ -19,7 +19,7 @@ import { timestampToMillis, } from '@/lib/format'; -export type WorkloadSortKey = 'agentId' | 'runnerId' | 'threadId' | 'status' | 'started'; +export type WorkloadSortKey = 'agentId' | 'runnerId' | 'threadId' | 'status' | 'started' | 'duration'; type WorkloadsTableControls = { searchTerm: string; @@ -38,6 +38,8 @@ type WorkloadsTableProps = { 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'; @@ -57,6 +59,8 @@ export function WorkloadsTable({ getWorkloadLink, getAgentName, getRunnerName, + getAgentLink, + getRunnerLink, agentLabel = 'Agent ID', runnerLabel = 'Runner ID', rowLinkMode = 'action', @@ -67,8 +71,21 @@ export function WorkloadsTable({ testIdPrefix, }: WorkloadsTableProps) { const location = useLocation(); + const navigate = useNavigate(); const resolveAgentName = (workload: Workload) => getAgentName?.(workload)?.trim() || ''; const resolveRunnerName = (workload: Workload) => getRunnerName?.(workload)?.trim() || ''; + 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) => resolveAgentName(workload) || workload.agentId, ...(showRunnerColumn ? [(workload: Workload) => resolveRunnerName(workload) || workload.runnerId] : []), @@ -81,6 +98,7 @@ export function WorkloadsTable({ threadId: (workload) => workload.threadId, status: (workload) => formatWorkloadStatus(workload.status), started: (workload) => timestampToMillis(workload.meta?.createdAt), + duration: (workload) => resolveDurationMillis(workload), }; if (showRunnerColumn) { @@ -192,7 +210,15 @@ export function WorkloadsTable({ sortDirection={sortDirection} onSort={handleSort} /> - {showDuration ? Duration : null} + {showDuration ? ( + + ) : null} {hasAction ? Action : null}
@@ -210,13 +236,11 @@ export function WorkloadsTable({ const workloadLink = getWorkloadLink ? getWorkloadLink(workload) : null; const agentName = resolveAgentName(workload); const runnerName = resolveRunnerName(workload); - const agentIdLabel = workload.agentId || EMPTY_PLACEHOLDER; - const runnerIdLabel = workload.runnerId || EMPTY_PLACEHOLDER; - const durationEnd = - workload.removedAt ?? - (workload.status === WorkloadStatus.STOPPED || workload.status === WorkloadStatus.FAILED - ? workload.lastActivityAt - : undefined); + const agentLabel = agentName || workload.agentId || EMPTY_PLACEHOLDER; + const runnerLabelText = runnerName || workload.runnerId || EMPTY_PLACEHOLDER; + const agentLink = getAgentLink?.(workload) ?? null; + const runnerLink = getRunnerLink?.(workload) ?? null; + const durationEnd = resolveDurationEnd(workload); const durationLabel = showDuration ? formatDurationBetween(workload.meta?.createdAt, durationEnd) : EMPTY_PLACEHOLDER; @@ -224,24 +248,30 @@ export function WorkloadsTable({ const rowContent = ( <>
- {agentName ? ( -
-
{agentName}
-
{agentIdLabel}
-
+ {agentLink ? ( + event.stopPropagation()} + > + {agentLabel} + ) : ( - agentIdLabel + {agentLabel} )}
{showRunnerColumn ? (
- {runnerName ? ( -
-
{runnerName}
-
{runnerIdLabel}
-
+ {runnerLink ? ( + event.stopPropagation()} + > + {runnerLabelText} + ) : ( - runnerIdLabel + {runnerLabelText} )}
) : null} @@ -286,15 +316,27 @@ export function WorkloadsTable({ if (rowLinkMode === 'row' && workloadLink) { return ( - { + const target = event.target as HTMLElement; + if (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} - +
); } diff --git a/src/pages/OrganizationActivityStorageTab.tsx b/src/pages/OrganizationActivityStorageTab.tsx index 66369ec..cc37b85 100644 --- a/src/pages/OrganizationActivityStorageTab.tsx +++ b/src/pages/OrganizationActivityStorageTab.tsx @@ -50,7 +50,7 @@ const ATTACHMENT_KIND_LABELS: Record = { [AttachmentKind.HOOK]: 'Hook', }; -const getVolumeName = (volume: Volume) => volume.volumeName || volume.volumeId || volume.meta?.id || ''; +const getVolumeName = (volume: Volume) => volume.volumeName?.trim() || volume.volumeId || volume.meta?.id || ''; const formatAttachmentLabel = (attachment: Attachment) => { const name = attachment.name?.trim() || attachment.id || ''; @@ -86,17 +86,10 @@ const extractVolumeId = (payload?: NotificationEnvelope['payload']): string | nu return resolveString((meta as Record).id); }; -const replaceFirstPage = ( - data: InfiniteData | undefined, +const resetPagination = ( + _data: InfiniteData | undefined, firstPage: TPage, -): InfiniteData => { - if (!data) { - return { pages: [firstPage], pageParams: [''] }; - } - const nextPages = [firstPage, ...data.pages.slice(1)]; - const nextPageParams = data.pageParams.length > 0 ? data.pageParams : ['']; - return { ...data, pages: nextPages, pageParams: nextPageParams }; -}; +): InfiniteData => ({ pages: [firstPage], pageParams: [''] }); const upsertVolume = ( data: InfiniteData>, unknown> | undefined, @@ -280,7 +273,7 @@ export function OrganizationActivityStorageTab() { }); queryClient.setQueryData>, unknown>>( volumesQueryKey, - (data) => replaceFirstPage(data, firstPage), + (data) => resetPagination(data, firstPage), ); } catch (error) { console.error('[useNotifications] volume refetch error:', error); @@ -400,7 +393,7 @@ export function OrganizationActivityStorageTab() {
{volumes.map((volume) => { - const name = getVolumeName(volume) || EMPTY_PLACEHOLDER; + const name = volume.volumeName?.trim() || getVolumeName(volume) || 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; @@ -427,9 +420,6 @@ export function OrganizationActivityStorageTab() { truncate(name, 24) )}
-
- {volumeId || name} -
{sizeLabel} diff --git a/src/pages/OrganizationActivityWorkloadsTab.tsx b/src/pages/OrganizationActivityWorkloadsTab.tsx index 8848f14..10a73b8 100644 --- a/src/pages/OrganizationActivityWorkloadsTab.tsx +++ b/src/pages/OrganizationActivityWorkloadsTab.tsx @@ -67,17 +67,10 @@ const extractWorkloadId = (payload?: NotificationEnvelope['payload']): string | return resolveString((meta as Record).id); }; -const replaceFirstPage = ( - data: InfiniteData | undefined, +const resetPagination = ( + _data: InfiniteData | undefined, firstPage: TPage, -): InfiniteData => { - if (!data) { - return { pages: [firstPage], pageParams: [''] }; - } - const nextPages = [firstPage, ...data.pages.slice(1)]; - const nextPageParams = data.pageParams.length > 0 ? data.pageParams : ['']; - return { ...data, pages: nextPages, pageParams: nextPageParams }; -}; +): InfiniteData => ({ pages: [firstPage], pageParams: [''] }); const upsertWorkload = ( data: InfiniteData>, unknown> | undefined, @@ -214,6 +207,7 @@ export function OrganizationActivityWorkloadsTab() { runnerId: ListWorkloadsSortField.RUNNER, status: ListWorkloadsSortField.STATUS, started: ListWorkloadsSortField.STARTED, + duration: ListWorkloadsSortField.DURATION, }; return { field: fieldMap[sortKey], @@ -291,7 +285,7 @@ export function OrganizationActivityWorkloadsTab() { }); queryClient.setQueryData>, unknown>>( workloadsQueryKey, - (data) => replaceFirstPage(data, firstPage), + (data) => resetPagination(data, firstPage), ); } catch (error) { console.error('[useNotifications] workload refetch error:', error); @@ -340,6 +334,12 @@ export function OrganizationActivityWorkloadsTab() { }} 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={{ diff --git a/src/pages/OrganizationThreadsTab.tsx b/src/pages/OrganizationThreadsTab.tsx index 6f94be1..69c395a 100644 --- a/src/pages/OrganizationThreadsTab.tsx +++ b/src/pages/OrganizationThreadsTab.tsx @@ -66,17 +66,10 @@ const extractThreadId = (payload?: NotificationEnvelope['payload']): string | nu return resolveString(threadRecord.threadId ?? threadRecord.thread_id ?? threadRecord.id); }; -const replaceFirstPage = ( - data: InfiniteData | undefined, +const resetPagination = ( + _data: InfiniteData | undefined, firstPage: TPage, -): InfiniteData => { - if (!data) { - return { pages: [firstPage], pageParams: [''] }; - } - const nextPages = [firstPage, ...data.pages.slice(1)]; - const nextPageParams = data.pageParams.length > 0 ? data.pageParams : ['']; - return { ...data, pages: nextPages, pageParams: nextPageParams }; -}; +): InfiniteData => ({ pages: [firstPage], pageParams: [''] }); const upsertThread = ( data: InfiniteData>, unknown> | undefined, @@ -250,7 +243,7 @@ export function OrganizationThreadsTab() { }); queryClient.setQueryData< InfiniteData>, unknown> - >(threadsQueryKey, (data) => replaceFirstPage(data, firstPage)); + >(threadsQueryKey, (data) => resetPagination(data, firstPage)); } catch (error) { console.error('[useNotifications] thread refetch error:', error); } diff --git a/src/pages/WorkloadDetailPage.tsx b/src/pages/WorkloadDetailPage.tsx index 0984532..4b2a312 100644 --- a/src/pages/WorkloadDetailPage.tsx +++ b/src/pages/WorkloadDetailPage.tsx @@ -343,6 +343,8 @@ export function WorkloadDetailPage() { const runnerId = workload?.runnerId ?? ''; const agentLink = organizationId && agentId ? `/organizations/${organizationId}/agents/${agentId}` : ''; const runnerLink = organizationId && runnerId ? `/organizations/${organizationId}/runners/${runnerId}` : ''; + const agentLabel = agentName || agentId || EMPTY_PLACEHOLDER; + const runnerLabel = runnerName || runnerId || EMPTY_PLACEHOLDER; const durationLabel = workload ? formatDurationBetween(workload.meta?.createdAt, workload.removedAt) : EMPTY_PLACEHOLDER; const allocatedCpu = workload ? `${workload.allocatedCpuMillicores.toLocaleString()} m` : EMPTY_PLACEHOLDER; const allocatedRam = workload ? `${workload.allocatedRamBytes.toString()} bytes` : EMPTY_PLACEHOLDER; @@ -383,15 +385,12 @@ export function WorkloadDetailPage() {
{runnerLink ? ( - {runnerName || runnerId || EMPTY_PLACEHOLDER} + {runnerLabel} ) : ( - runnerName || runnerId || EMPTY_PLACEHOLDER + runnerLabel )}
- {runnerName && runnerId && runnerName !== runnerId ? ( -
{runnerId}
- ) : null}
Thread ID
@@ -402,15 +401,12 @@ export function WorkloadDetailPage() {
{agentLink ? ( - {agentName || agentId || EMPTY_PLACEHOLDER} + {agentLabel} ) : ( - agentName || agentId || EMPTY_PLACEHOLDER + agentLabel )}
- {agentName && agentId && agentName !== agentId ? ( -
{agentId}
- ) : null}
Instance ID
From 08a85b298c3981340339e11cd92fb8b959d77d9c Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Sat, 25 Apr 2026 08:43:36 +0000 Subject: [PATCH 04/12] fix(console): handle legacy threads list --- src/pages/OrganizationThreadsTab.tsx | 123 +++++++++++++++++++++------ 1 file changed, 99 insertions(+), 24 deletions(-) diff --git a/src/pages/OrganizationThreadsTab.tsx b/src/pages/OrganizationThreadsTab.tsx index 69c395a..b88fece 100644 --- a/src/pages/OrganizationThreadsTab.tsx +++ b/src/pages/OrganizationThreadsTab.tsx @@ -12,6 +12,7 @@ import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { + ListOrganizationThreadsResponseSchema, ListOrganizationThreadsSortField, SortDirection as ThreadsSortDirection, type Thread, @@ -22,13 +23,25 @@ import { useDocumentTitle } from '@/hooks/useDocumentTitle'; 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 { EMPTY_PLACEHOLDER, formatDateOnly, formatThreadStatus, timestampToMillis, 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 = Awaited>; +type LegacyThreadsPage = Awaited>; + +const toThreadsPage = (response: LegacyThreadsPage): ThreadsPage => + create(ListOrganizationThreadsResponseSchema, { + threads: response.threads, + nextPageToken: response.nextPageToken, + }); + +const isLegacyThreadsError = (error: unknown) => + error instanceof ConnectError && (error.code === Code.Unimplemented || error.code === Code.NotFound); + const parseDateInput = (value: string, isEnd = false): Date | null => { if (!value) return null; const [year, month, day] = value.split('-').map((segment) => Number(segment)); @@ -113,6 +126,7 @@ export function OrganizationThreadsTab() { const [createdBefore, setCreatedBefore] = useState(''); const [sortKey, setSortKey] = useState('created'); const [sortDirection, setSortDirection] = useState('desc'); + const [isLegacyThreadsApi, setIsLegacyThreadsApi] = useState(false); const notificationRooms = useMemo( () => (identityId ? [`thread_participant:${identityId}`] : []), @@ -144,31 +158,57 @@ export function OrganizationThreadsTab() { direction: sortDirection === 'asc' ? ThreadsSortDirection.ASC : ThreadsSortDirection.DESC, }; }, [sortDirection, sortKey]); - const filterSpec = useMemo(() => { - const statusValues = statusFilter.map((value) => Number(value) as ThreadStatus).filter((value) => value > 0); - return { + const statusValues = useMemo( + () => statusFilter.map((value) => Number(value) as ThreadStatus).filter((value) => value > 0), + [statusFilter], + ); + + const filterSpec = useMemo( + () => ({ participantIdIn: participantFilter, statusIn: statusValues, createdAfter: rangeError ? undefined : startDate ? toTimestamp(startDate) : undefined, createdBefore: rangeError ? undefined : endDate ? toTimestamp(endDate) : undefined, - }; - }, [participantFilter, statusFilter, startDate, endDate, rangeError]); + }), + [participantFilter, statusValues, startDate, endDate, rangeError], + ); const threadsQueryKey = useMemo( () => ['threads', organizationId, 'list', filterKey, sortSpec] as const, [filterKey, organizationId, sortSpec], ); + const fetchThreadsPage = async (pageToken: string): Promise => { + if (!isLegacyThreadsApi) { + try { + return await threadsClient.listOrganizationThreads({ + organizationId, + pageSize: DEFAULT_PAGE_SIZE, + pageToken, + filter: filterSpec, + sort: sortSpec, + }); + } catch (error) { + if (!isLegacyThreadsError(error)) { + throw error; + } + setIsLegacyThreadsApi(true); + } + } + + const legacyStatus = statusValues.length === 1 ? statusValues[0] : ThreadStatus.UNSPECIFIED; + const legacyResponse = await threadsClient.getOrganizationThreads({ + organizationId, + pageSize: DEFAULT_PAGE_SIZE, + pageToken, + status: legacyStatus, + }); + return toThreadsPage(legacyResponse); + }; + const threadsQuery = useInfiniteQuery({ queryKey: threadsQueryKey, - queryFn: ({ pageParam }) => - threadsClient.listOrganizationThreads({ - organizationId, - pageSize: DEFAULT_PAGE_SIZE, - pageToken: pageParam, - filter: filterSpec, - sort: sortSpec, - }), + queryFn: ({ pageParam }) => fetchThreadsPage(pageParam), initialPageParam: '', getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined, enabled: Boolean(organizationId), @@ -180,6 +220,47 @@ export function OrganizationThreadsTab() { () => threadsQuery.data?.pages.flatMap((page) => page.threads) ?? [], [threadsQuery.data?.pages], ); + const visibleThreads = useMemo(() => { + if (!isLegacyThreadsApi) return threads; + + let nextThreads = threads; + if (participantFilter.length > 0) { + const participantSet = new Set(participantFilter); + nextThreads = nextThreads.filter((thread) => + thread.participants.some((participant) => participant.id && participantSet.has(participant.id)), + ); + } + if (statusValues.length > 0) { + const statusSet = new Set(statusValues); + nextThreads = nextThreads.filter((thread) => statusSet.has(thread.status)); + } + if (!rangeError && (startDate || endDate)) { + const startMillis = startDate ? startDate.getTime() : null; + const endMillis = endDate ? endDate.getTime() : null; + nextThreads = nextThreads.filter((thread) => { + const createdMillis = timestampToMillis(thread.createdAt); + if (!createdMillis) return true; + if (startMillis && createdMillis < startMillis) return false; + if (endMillis && createdMillis > endMillis) return false; + return true; + }); + } + + const sortValueMap: Record number> = { + status: (thread) => thread.status, + messages: (thread) => thread.messageCount ?? 0, + created: (thread) => timestampToMillis(thread.createdAt), + updated: (thread) => timestampToMillis(thread.updatedAt), + }; + const direction = sortDirection === 'asc' ? 1 : -1; + const sorted = [...nextThreads].sort((left, right) => { + const leftValue = sortValueMap[sortKey](left); + const rightValue = sortValueMap[sortKey](right); + if (leftValue === rightValue) return 0; + return leftValue > rightValue ? direction : -direction; + }); + return sorted; + }, [threads, isLegacyThreadsApi, participantFilter, statusValues, rangeError, startDate, endDate, sortKey, sortDirection]); const isLoading = threadsQuery.isPending; const isError = threadsQuery.isError; const isPermissionDenied = @@ -234,13 +315,7 @@ export function OrganizationThreadsTab() { if (hasActiveControls) { void (async () => { try { - const firstPage = await threadsClient.listOrganizationThreads({ - organizationId, - pageSize: DEFAULT_PAGE_SIZE, - pageToken: '', - filter: filterSpec, - sort: sortSpec, - }); + const firstPage = await fetchThreadsPage(''); queryClient.setQueryData< InfiniteData>, unknown> >(threadsQueryKey, (data) => resetPagination(data, firstPage)); @@ -318,14 +393,14 @@ export function OrganizationThreadsTab() { {isPermissionDenied ? 'You do not have permission to view threads.' : 'Failed to load threads.'}
) : null} - {threads.length === 0 && !isLoading && !isError ? ( + {visibleThreads.length === 0 && !isLoading && !isError ? ( {hasActiveFilters ? 'No results found.' : 'No threads yet.'} ) : null} - {threads.length > 0 ? ( + {visibleThreads.length > 0 ? (
@@ -361,7 +436,7 @@ export function OrganizationThreadsTab() { />
- {threads.map((thread) => { + {visibleThreads.map((thread) => { const threadId = thread.id; const messageCount = thread.messageCount ?? 0; const participantHandles = thread.participants From 42fe88d0adfc9f296368e85dadc9e766c30dda8c Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Sun, 26 Apr 2026 20:23:36 +0000 Subject: [PATCH 05/12] fix(console): remove legacy threads fallback --- src/pages/OrganizationThreadsTab.tsx | 83 ++-------------------------- src/pages/WorkloadDetailPage.tsx | 1 - 2 files changed, 6 insertions(+), 78 deletions(-) diff --git a/src/pages/OrganizationThreadsTab.tsx b/src/pages/OrganizationThreadsTab.tsx index b88fece..1325175 100644 --- a/src/pages/OrganizationThreadsTab.tsx +++ b/src/pages/OrganizationThreadsTab.tsx @@ -12,7 +12,6 @@ import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { - ListOrganizationThreadsResponseSchema, ListOrganizationThreadsSortField, SortDirection as ThreadsSortDirection, type Thread, @@ -23,7 +22,7 @@ import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useNotifications } from '@/hooks/useNotifications'; import { type SortDirection } from '@/hooks/useListControls'; import { useUserContext } from '@/context/UserContext'; -import { EMPTY_PLACEHOLDER, formatDateOnly, formatThreadStatus, timestampToMillis, truncate } from '@/lib/format'; +import { EMPTY_PLACEHOLDER, formatDateOnly, formatThreadStatus, truncate } from '@/lib/format'; import { DEFAULT_PAGE_SIZE } from '@/lib/pagination'; type ThreadSortKey = 'status' | 'messages' | 'created' | 'updated'; @@ -31,16 +30,6 @@ type ThreadSortKey = 'status' | 'messages' | 'created' | 'updated'; const THREAD_STATUS_OPTIONS = [ThreadStatus.ACTIVE, ThreadStatus.ARCHIVED, ThreadStatus.DEGRADED]; type ThreadsPage = Awaited>; -type LegacyThreadsPage = Awaited>; - -const toThreadsPage = (response: LegacyThreadsPage): ThreadsPage => - create(ListOrganizationThreadsResponseSchema, { - threads: response.threads, - nextPageToken: response.nextPageToken, - }); - -const isLegacyThreadsError = (error: unknown) => - error instanceof ConnectError && (error.code === Code.Unimplemented || error.code === Code.NotFound); const parseDateInput = (value: string, isEnd = false): Date | null => { if (!value) return null; @@ -126,7 +115,6 @@ export function OrganizationThreadsTab() { const [createdBefore, setCreatedBefore] = useState(''); const [sortKey, setSortKey] = useState('created'); const [sortDirection, setSortDirection] = useState('desc'); - const [isLegacyThreadsApi, setIsLegacyThreadsApi] = useState(false); const notificationRooms = useMemo( () => (identityId ? [`thread_participant:${identityId}`] : []), @@ -178,33 +166,14 @@ export function OrganizationThreadsTab() { [filterKey, organizationId, sortSpec], ); - const fetchThreadsPage = async (pageToken: string): Promise => { - if (!isLegacyThreadsApi) { - try { - return await threadsClient.listOrganizationThreads({ - organizationId, - pageSize: DEFAULT_PAGE_SIZE, - pageToken, - filter: filterSpec, - sort: sortSpec, - }); - } catch (error) { - if (!isLegacyThreadsError(error)) { - throw error; - } - setIsLegacyThreadsApi(true); - } - } - - const legacyStatus = statusValues.length === 1 ? statusValues[0] : ThreadStatus.UNSPECIFIED; - const legacyResponse = await threadsClient.getOrganizationThreads({ + const fetchThreadsPage = (pageToken: string): Promise => + threadsClient.listOrganizationThreads({ organizationId, pageSize: DEFAULT_PAGE_SIZE, pageToken, - status: legacyStatus, + filter: filterSpec, + sort: sortSpec, }); - return toThreadsPage(legacyResponse); - }; const threadsQuery = useInfiniteQuery({ queryKey: threadsQueryKey, @@ -220,47 +189,7 @@ export function OrganizationThreadsTab() { () => threadsQuery.data?.pages.flatMap((page) => page.threads) ?? [], [threadsQuery.data?.pages], ); - const visibleThreads = useMemo(() => { - if (!isLegacyThreadsApi) return threads; - - let nextThreads = threads; - if (participantFilter.length > 0) { - const participantSet = new Set(participantFilter); - nextThreads = nextThreads.filter((thread) => - thread.participants.some((participant) => participant.id && participantSet.has(participant.id)), - ); - } - if (statusValues.length > 0) { - const statusSet = new Set(statusValues); - nextThreads = nextThreads.filter((thread) => statusSet.has(thread.status)); - } - if (!rangeError && (startDate || endDate)) { - const startMillis = startDate ? startDate.getTime() : null; - const endMillis = endDate ? endDate.getTime() : null; - nextThreads = nextThreads.filter((thread) => { - const createdMillis = timestampToMillis(thread.createdAt); - if (!createdMillis) return true; - if (startMillis && createdMillis < startMillis) return false; - if (endMillis && createdMillis > endMillis) return false; - return true; - }); - } - - const sortValueMap: Record number> = { - status: (thread) => thread.status, - messages: (thread) => thread.messageCount ?? 0, - created: (thread) => timestampToMillis(thread.createdAt), - updated: (thread) => timestampToMillis(thread.updatedAt), - }; - const direction = sortDirection === 'asc' ? 1 : -1; - const sorted = [...nextThreads].sort((left, right) => { - const leftValue = sortValueMap[sortKey](left); - const rightValue = sortValueMap[sortKey](right); - if (leftValue === rightValue) return 0; - return leftValue > rightValue ? direction : -direction; - }); - return sorted; - }, [threads, isLegacyThreadsApi, participantFilter, statusValues, rangeError, startDate, endDate, sortKey, sortDirection]); + const visibleThreads = threads; const isLoading = threadsQuery.isPending; const isError = threadsQuery.isError; const isPermissionDenied = diff --git a/src/pages/WorkloadDetailPage.tsx b/src/pages/WorkloadDetailPage.tsx index 4b2a312..74c13de 100644 --- a/src/pages/WorkloadDetailPage.tsx +++ b/src/pages/WorkloadDetailPage.tsx @@ -263,7 +263,6 @@ export function WorkloadDetailPage() { }, [organizationId, workloadId]); useNotifications({ - rooms: workloadId ? [`workload:${workloadId}`] : [], events: ['workload.status_changed', 'workload.updated'], invalidateKeys: [['workloads', workloadId, 'detail']], rooms: notificationRooms, From 630f5ee08cde1596403c736b742380c81b3477d1 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Sun, 26 Apr 2026 20:45:18 +0000 Subject: [PATCH 06/12] fix(console): improve workload list --- src/components/WorkloadsTable.tsx | 22 ++++++++++++++----- .../OrganizationActivityWorkloadsTab.tsx | 3 ++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/components/WorkloadsTable.tsx b/src/components/WorkloadsTable.tsx index fd7405e..0ccfbc7 100644 --- a/src/components/WorkloadsTable.tsx +++ b/src/components/WorkloadsTable.tsx @@ -43,6 +43,7 @@ type WorkloadsTableProps = { agentLabel?: string; runnerLabel?: string; rowLinkMode?: 'row' | 'action'; + actionLabel?: string; controls?: WorkloadsTableControls; filterBar?: ReactNode; searchPlaceholder?: string; @@ -64,6 +65,7 @@ export function WorkloadsTable({ agentLabel = 'Agent ID', runnerLabel = 'Runner ID', rowLinkMode = 'action', + actionLabel, controls, filterBar, searchPlaceholder = 'Search workloads...', @@ -72,8 +74,14 @@ export function WorkloadsTable({ }: WorkloadsTableProps) { const location = useLocation(); const navigate = useNavigate(); - const resolveAgentName = (workload: Workload) => getAgentName?.(workload)?.trim() || ''; - const resolveRunnerName = (workload: Workload) => getRunnerName?.(workload)?.trim() || ''; + 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 resolveDurationEnd = (workload: Workload) => workload.removedAt ?? (workload.status === WorkloadStatus.STOPPED || workload.status === WorkloadStatus.FAILED @@ -122,6 +130,7 @@ export function WorkloadsTable({ 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) => { @@ -140,7 +149,10 @@ export function WorkloadsTable({ gridColumns.push('200px'); gridColumns.push('170px'); if (showDuration) gridColumns.push('140px'); - if (hasAction) gridColumns.push('120px'); + 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.'; @@ -301,12 +313,12 @@ export function WorkloadsTable({ state={{ from: location.pathname }} data-testid={`${testIdPrefix}-view`} > - View + {actionLabelText} ) : ( )}
diff --git a/src/pages/OrganizationActivityWorkloadsTab.tsx b/src/pages/OrganizationActivityWorkloadsTab.tsx index 10a73b8..234bb2e 100644 --- a/src/pages/OrganizationActivityWorkloadsTab.tsx +++ b/src/pages/OrganizationActivityWorkloadsTab.tsx @@ -326,7 +326,8 @@ export function OrganizationActivityWorkloadsTab() { showRunnerColumn showDuration showSearch={false} - rowLinkMode="row" + rowLinkMode="action" + actionLabel="View workload" getWorkloadLink={(workload) => { const workloadId = workload.meta?.id; if (!workloadId) return null; From fb806a493c45f0990aaacda24a54578f364d78c4 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Tue, 28 Apr 2026 22:32:49 +0000 Subject: [PATCH 07/12] fix(console): align workload labels --- src/components/WorkloadsTable.tsx | 8 ++++---- src/pages/OrganizationActivityStorageTab.tsx | 4 ++-- src/pages/OrganizationActivityWorkloadsTab.tsx | 3 +-- src/pages/WorkloadDetailPage.tsx | 8 ++++---- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/components/WorkloadsTable.tsx b/src/components/WorkloadsTable.tsx index 0ccfbc7..f4a4c65 100644 --- a/src/components/WorkloadsTable.tsx +++ b/src/components/WorkloadsTable.tsx @@ -248,10 +248,10 @@ export function WorkloadsTable({ const workloadLink = getWorkloadLink ? getWorkloadLink(workload) : null; const agentName = resolveAgentName(workload); const runnerName = resolveRunnerName(workload); - const agentLabel = agentName || workload.agentId || EMPTY_PLACEHOLDER; - const runnerLabelText = runnerName || workload.runnerId || EMPTY_PLACEHOLDER; - const agentLink = getAgentLink?.(workload) ?? null; - const runnerLink = getRunnerLink?.(workload) ?? null; + 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) diff --git a/src/pages/OrganizationActivityStorageTab.tsx b/src/pages/OrganizationActivityStorageTab.tsx index cc37b85..7f76882 100644 --- a/src/pages/OrganizationActivityStorageTab.tsx +++ b/src/pages/OrganizationActivityStorageTab.tsx @@ -50,7 +50,7 @@ const ATTACHMENT_KIND_LABELS: Record = { [AttachmentKind.HOOK]: 'Hook', }; -const getVolumeName = (volume: Volume) => volume.volumeName?.trim() || volume.volumeId || volume.meta?.id || ''; +const getVolumeName = (volume: Volume) => volume.volumeName?.trim() || ''; const formatAttachmentLabel = (attachment: Attachment) => { const name = attachment.name?.trim() || attachment.id || ''; @@ -393,7 +393,7 @@ export function OrganizationActivityStorageTab() {
{volumes.map((volume) => { - const name = volume.volumeName?.trim() || getVolumeName(volume) || EMPTY_PLACEHOLDER; + 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; diff --git a/src/pages/OrganizationActivityWorkloadsTab.tsx b/src/pages/OrganizationActivityWorkloadsTab.tsx index 234bb2e..10a73b8 100644 --- a/src/pages/OrganizationActivityWorkloadsTab.tsx +++ b/src/pages/OrganizationActivityWorkloadsTab.tsx @@ -326,8 +326,7 @@ export function OrganizationActivityWorkloadsTab() { showRunnerColumn showDuration showSearch={false} - rowLinkMode="action" - actionLabel="View workload" + rowLinkMode="row" getWorkloadLink={(workload) => { const workloadId = workload.meta?.id; if (!workloadId) return null; diff --git a/src/pages/WorkloadDetailPage.tsx b/src/pages/WorkloadDetailPage.tsx index 74c13de..145e3b5 100644 --- a/src/pages/WorkloadDetailPage.tsx +++ b/src/pages/WorkloadDetailPage.tsx @@ -340,10 +340,10 @@ export function WorkloadDetailPage() { const runnerName = workload?.runnerName?.trim(); const agentId = workload?.agentId ?? ''; const runnerId = workload?.runnerId ?? ''; - const agentLink = organizationId && agentId ? `/organizations/${organizationId}/agents/${agentId}` : ''; - const runnerLink = organizationId && runnerId ? `/organizations/${organizationId}/runners/${runnerId}` : ''; - const agentLabel = agentName || agentId || EMPTY_PLACEHOLDER; - const runnerLabel = runnerName || runnerId || EMPTY_PLACEHOLDER; + 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 durationLabel = workload ? formatDurationBetween(workload.meta?.createdAt, workload.removedAt) : EMPTY_PLACEHOLDER; const allocatedCpu = workload ? `${workload.allocatedCpuMillicores.toLocaleString()} m` : EMPTY_PLACEHOLDER; const allocatedRam = workload ? `${workload.allocatedRamBytes.toString()} bytes` : EMPTY_PLACEHOLDER; From 6967640f993aa6f28424a6fd6ded92a5a7b9fa6a Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Tue, 28 Apr 2026 23:33:58 +0000 Subject: [PATCH 08/12] fix(console): avoid empty thread filters --- src/pages/OrganizationThreadsTab.tsx | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/pages/OrganizationThreadsTab.tsx b/src/pages/OrganizationThreadsTab.tsx index 1325175..138c91d 100644 --- a/src/pages/OrganizationThreadsTab.tsx +++ b/src/pages/OrganizationThreadsTab.tsx @@ -151,15 +151,22 @@ export function OrganizationThreadsTab() { [statusFilter], ); - const filterSpec = useMemo( - () => ({ + 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: rangeError ? undefined : startDate ? toTimestamp(startDate) : undefined, - createdBefore: rangeError ? undefined : endDate ? toTimestamp(endDate) : undefined, - }), - [participantFilter, statusValues, startDate, endDate, rangeError], - ); + createdAfter: createdAfterValue, + createdBefore: createdBeforeValue, + }; + }, [participantFilter, statusValues, startDate, endDate, rangeError]); const threadsQueryKey = useMemo( () => ['threads', organizationId, 'list', filterKey, sortSpec] as const, From 2db786ff21c91de828d6eeefdfd75cce69ab0c7c Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Wed, 29 Apr 2026 00:28:49 +0000 Subject: [PATCH 09/12] fix(console): handle legacy threads list --- src/pages/OrganizationThreadsTab.tsx | 134 +++++++++++++++++++++++---- 1 file changed, 116 insertions(+), 18 deletions(-) diff --git a/src/pages/OrganizationThreadsTab.tsx b/src/pages/OrganizationThreadsTab.tsx index 138c91d..5d27c3c 100644 --- a/src/pages/OrganizationThreadsTab.tsx +++ b/src/pages/OrganizationThreadsTab.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { NavLink, useParams } from 'react-router-dom'; import { Code, ConnectError } from '@connectrpc/connect'; import { create } from '@bufbuild/protobuf'; @@ -22,14 +22,17 @@ import { useDocumentTitle } from '@/hooks/useDocumentTitle'; 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 { EMPTY_PLACEHOLDER, formatDateOnly, formatThreadStatus, timestampToMillis, 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 = Awaited>; +type ThreadsPage = { + threads: Thread[]; + nextPageToken?: string; +}; const parseDateInput = (value: string, isEnd = false): Date | null => { if (!value) return null; @@ -74,9 +77,9 @@ const resetPagination = ( ): InfiniteData => ({ pages: [firstPage], pageParams: [''] }); const upsertThread = ( - data: InfiniteData>, unknown> | undefined, + data: InfiniteData | undefined, thread: Thread, -): InfiniteData>, unknown> | undefined => { +): InfiniteData | undefined => { if (!data) return data; if (!thread.id) return data; @@ -109,6 +112,7 @@ export function OrganizationThreadsTab() { const organizationId = id ?? ''; const { identityId } = useUserContext(); const queryClient = useQueryClient(); + const [useLegacyThreads, setUseLegacyThreads] = useState(false); const [participantFilter, setParticipantFilter] = useState([]); const [statusFilter, setStatusFilter] = useState([]); const [createdAfter, setCreatedAfter] = useState(''); @@ -168,12 +172,16 @@ export function OrganizationThreadsTab() { }; }, [participantFilter, statusValues, startDate, endDate, rangeError]); - const threadsQueryKey = useMemo( + const listThreadsQueryKey = useMemo( () => ['threads', organizationId, 'list', filterKey, sortSpec] as const, [filterKey, organizationId, sortSpec], ); + const legacyThreadsQueryKey = useMemo( + () => ['threads', organizationId, 'legacy'] as const, + [organizationId], + ); - const fetchThreadsPage = (pageToken: string): Promise => + const fetchListThreadsPage = (pageToken: string): Promise => threadsClient.listOrganizationThreads({ organizationId, pageSize: DEFAULT_PAGE_SIZE, @@ -182,21 +190,112 @@ export function OrganizationThreadsTab() { sort: sortSpec, }); - const threadsQuery = useInfiniteQuery({ - queryKey: threadsQueryKey, - queryFn: ({ pageParam }) => fetchThreadsPage(pageParam), + const fetchLegacyThreadsPage = (pageToken: string): Promise => + threadsClient.getOrganizationThreads({ + organizationId, + pageSize: DEFAULT_PAGE_SIZE, + pageToken, + }); + + const listThreadsQuery = useInfiniteQuery({ + queryKey: listThreadsQueryKey, + queryFn: ({ pageParam }) => fetchListThreadsPage(pageParam), initialPageParam: '', getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined, - enabled: Boolean(organizationId), + enabled: Boolean(organizationId) && !useLegacyThreads, staleTime: 60_000, refetchOnWindowFocus: false, }); + const legacyThreadsQuery = useInfiniteQuery({ + queryKey: legacyThreadsQueryKey, + queryFn: ({ pageParam }) => fetchLegacyThreadsPage(pageParam), + initialPageParam: '', + getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined, + enabled: Boolean(organizationId) && useLegacyThreads, + staleTime: 60_000, + refetchOnWindowFocus: false, + }); + + useEffect(() => { + if (organizationId) { + setUseLegacyThreads(false); + } + }, [organizationId]); + + useEffect(() => { + if (useLegacyThreads) return; + const error = listThreadsQuery.error; + if (!(error instanceof ConnectError)) return; + if (error.code === Code.NotFound || error.code === Code.Unimplemented) { + setUseLegacyThreads(true); + } + }, [listThreadsQuery.error, useLegacyThreads]); + + const threadsQuery = useLegacyThreads ? legacyThreadsQuery : listThreadsQuery; + const activeThreadsQueryKey = useLegacyThreads ? legacyThreadsQueryKey : listThreadsQueryKey; + const fetchThreadsPage = useLegacyThreads ? fetchLegacyThreadsPage : fetchListThreadsPage; + const threads = useMemo( () => threadsQuery.data?.pages.flatMap((page) => page.threads) ?? [], [threadsQuery.data?.pages], ); - const visibleThreads = threads; + const visibleThreads = useMemo(() => { + if (!useLegacyThreads) return threads; + const filtered = threads.filter((thread) => { + if (participantFilter.length > 0) { + const matchesParticipant = thread.participants.some( + (participant) => participant.id && participantFilter.includes(participant.id), + ); + if (!matchesParticipant) return false; + } + if (statusValues.length > 0 && !statusValues.includes(thread.status)) { + return false; + } + if (!rangeError) { + const createdMillis = timestampToMillis(thread.createdAt); + if (startDate && createdMillis < startDate.getTime()) { + return false; + } + if (endDate && createdMillis > endDate.getTime()) { + return false; + } + } + return true; + }); + const sorted = [...filtered].sort((left, right) => { + const leftValue = (() => { + switch (sortKey) { + case 'status': + return left.status; + case 'messages': + return left.messageCount ?? 0; + case 'created': + return timestampToMillis(left.createdAt); + case 'updated': + return timestampToMillis(left.updatedAt); + default: + return 0; + } + })(); + const rightValue = (() => { + switch (sortKey) { + case 'status': + return right.status; + case 'messages': + return right.messageCount ?? 0; + case 'created': + return timestampToMillis(right.createdAt); + case 'updated': + return timestampToMillis(right.updatedAt); + default: + return 0; + } + })(); + return leftValue - rightValue; + }); + return sortDirection === 'asc' ? sorted : sorted.reverse(); + }, [endDate, participantFilter, rangeError, sortDirection, sortKey, startDate, statusValues, threads, useLegacyThreads]); const isLoading = threadsQuery.isPending; const isError = threadsQuery.isError; const isPermissionDenied = @@ -252,9 +351,9 @@ export function OrganizationThreadsTab() { void (async () => { try { const firstPage = await fetchThreadsPage(''); - queryClient.setQueryData< - InfiniteData>, unknown> - >(threadsQueryKey, (data) => resetPagination(data, firstPage)); + queryClient.setQueryData>(activeThreadsQueryKey, (data) => + resetPagination(data, firstPage), + ); } catch (error) { console.error('[useNotifications] thread refetch error:', error); } @@ -269,9 +368,8 @@ export function OrganizationThreadsTab() { const response = await threadsClient.getThread({ threadId }); const thread = response.thread; if (!thread) return; - queryClient.setQueryData>, unknown>>( - threadsQueryKey, - (data) => upsertThread(data, thread), + queryClient.setQueryData>(activeThreadsQueryKey, (data) => + upsertThread(data, thread), ); } catch (error) { console.error('[useNotifications] thread update error:', error); From ef5aa6ec7cb5a420d98dd2cf46d5882bbe55f328 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Wed, 29 Apr 2026 01:33:42 +0000 Subject: [PATCH 10/12] fix(console): fallback on nickname error --- src/pages/OrganizationThreadsTab.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/OrganizationThreadsTab.tsx b/src/pages/OrganizationThreadsTab.tsx index 5d27c3c..d14594e 100644 --- a/src/pages/OrganizationThreadsTab.tsx +++ b/src/pages/OrganizationThreadsTab.tsx @@ -227,7 +227,9 @@ export function OrganizationThreadsTab() { if (useLegacyThreads) return; const error = listThreadsQuery.error; if (!(error instanceof ConnectError)) return; - if (error.code === Code.NotFound || error.code === Code.Unimplemented) { + if (error.code !== Code.Internal) return; + const message = error.rawMessage || error.message; + if (message.includes('BatchGetNicknames')) { setUseLegacyThreads(true); } }, [listThreadsQuery.error, useLegacyThreads]); From 79a86a1e971fee4666b654166b021db561d3dfec Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Wed, 29 Apr 2026 01:48:45 +0000 Subject: [PATCH 11/12] fix(console): remove threads fallback --- src/pages/OrganizationThreadsTab.tsx | 128 ++++----------------------- 1 file changed, 15 insertions(+), 113 deletions(-) diff --git a/src/pages/OrganizationThreadsTab.tsx b/src/pages/OrganizationThreadsTab.tsx index d14594e..7254961 100644 --- a/src/pages/OrganizationThreadsTab.tsx +++ b/src/pages/OrganizationThreadsTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { NavLink, useParams } from 'react-router-dom'; import { Code, ConnectError } from '@connectrpc/connect'; import { create } from '@bufbuild/protobuf'; @@ -22,7 +22,7 @@ import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useNotifications } from '@/hooks/useNotifications'; import { type SortDirection } from '@/hooks/useListControls'; import { useUserContext } from '@/context/UserContext'; -import { EMPTY_PLACEHOLDER, formatDateOnly, formatThreadStatus, timestampToMillis, truncate } from '@/lib/format'; +import { EMPTY_PLACEHOLDER, formatDateOnly, formatThreadStatus, truncate } from '@/lib/format'; import { DEFAULT_PAGE_SIZE } from '@/lib/pagination'; type ThreadSortKey = 'status' | 'messages' | 'created' | 'updated'; @@ -112,7 +112,6 @@ export function OrganizationThreadsTab() { const organizationId = id ?? ''; const { identityId } = useUserContext(); const queryClient = useQueryClient(); - const [useLegacyThreads, setUseLegacyThreads] = useState(false); const [participantFilter, setParticipantFilter] = useState([]); const [statusFilter, setStatusFilter] = useState([]); const [createdAfter, setCreatedAfter] = useState(''); @@ -176,10 +175,6 @@ export function OrganizationThreadsTab() { () => ['threads', organizationId, 'list', filterKey, sortSpec] as const, [filterKey, organizationId, sortSpec], ); - const legacyThreadsQueryKey = useMemo( - () => ['threads', organizationId, 'legacy'] as const, - [organizationId], - ); const fetchListThreadsPage = (pageToken: string): Promise => threadsClient.listOrganizationThreads({ @@ -190,118 +185,25 @@ export function OrganizationThreadsTab() { sort: sortSpec, }); - const fetchLegacyThreadsPage = (pageToken: string): Promise => - threadsClient.getOrganizationThreads({ - organizationId, - pageSize: DEFAULT_PAGE_SIZE, - pageToken, - }); - const listThreadsQuery = useInfiniteQuery({ queryKey: listThreadsQueryKey, queryFn: ({ pageParam }) => fetchListThreadsPage(pageParam), initialPageParam: '', getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined, - enabled: Boolean(organizationId) && !useLegacyThreads, - staleTime: 60_000, - refetchOnWindowFocus: false, - }); - - const legacyThreadsQuery = useInfiniteQuery({ - queryKey: legacyThreadsQueryKey, - queryFn: ({ pageParam }) => fetchLegacyThreadsPage(pageParam), - initialPageParam: '', - getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined, - enabled: Boolean(organizationId) && useLegacyThreads, + enabled: Boolean(organizationId), staleTime: 60_000, refetchOnWindowFocus: false, }); - useEffect(() => { - if (organizationId) { - setUseLegacyThreads(false); - } - }, [organizationId]); - - useEffect(() => { - if (useLegacyThreads) return; - const error = listThreadsQuery.error; - if (!(error instanceof ConnectError)) return; - if (error.code !== Code.Internal) return; - const message = error.rawMessage || error.message; - if (message.includes('BatchGetNicknames')) { - setUseLegacyThreads(true); - } - }, [listThreadsQuery.error, useLegacyThreads]); - - const threadsQuery = useLegacyThreads ? legacyThreadsQuery : listThreadsQuery; - const activeThreadsQueryKey = useLegacyThreads ? legacyThreadsQueryKey : listThreadsQueryKey; - const fetchThreadsPage = useLegacyThreads ? fetchLegacyThreadsPage : fetchListThreadsPage; - const threads = useMemo( - () => threadsQuery.data?.pages.flatMap((page) => page.threads) ?? [], - [threadsQuery.data?.pages], + () => listThreadsQuery.data?.pages.flatMap((page) => page.threads) ?? [], + [listThreadsQuery.data?.pages], ); - const visibleThreads = useMemo(() => { - if (!useLegacyThreads) return threads; - const filtered = threads.filter((thread) => { - if (participantFilter.length > 0) { - const matchesParticipant = thread.participants.some( - (participant) => participant.id && participantFilter.includes(participant.id), - ); - if (!matchesParticipant) return false; - } - if (statusValues.length > 0 && !statusValues.includes(thread.status)) { - return false; - } - if (!rangeError) { - const createdMillis = timestampToMillis(thread.createdAt); - if (startDate && createdMillis < startDate.getTime()) { - return false; - } - if (endDate && createdMillis > endDate.getTime()) { - return false; - } - } - return true; - }); - const sorted = [...filtered].sort((left, right) => { - const leftValue = (() => { - switch (sortKey) { - case 'status': - return left.status; - case 'messages': - return left.messageCount ?? 0; - case 'created': - return timestampToMillis(left.createdAt); - case 'updated': - return timestampToMillis(left.updatedAt); - default: - return 0; - } - })(); - const rightValue = (() => { - switch (sortKey) { - case 'status': - return right.status; - case 'messages': - return right.messageCount ?? 0; - case 'created': - return timestampToMillis(right.createdAt); - case 'updated': - return timestampToMillis(right.updatedAt); - default: - return 0; - } - })(); - return leftValue - rightValue; - }); - return sortDirection === 'asc' ? sorted : sorted.reverse(); - }, [endDate, participantFilter, rangeError, sortDirection, sortKey, startDate, statusValues, threads, useLegacyThreads]); - 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 participantOptions = useMemo(() => { const participantMap = new Map(); @@ -352,8 +254,8 @@ export function OrganizationThreadsTab() { if (hasActiveControls) { void (async () => { try { - const firstPage = await fetchThreadsPage(''); - queryClient.setQueryData>(activeThreadsQueryKey, (data) => + const firstPage = await fetchListThreadsPage(''); + queryClient.setQueryData>(listThreadsQueryKey, (data) => resetPagination(data, firstPage), ); } catch (error) { @@ -370,7 +272,7 @@ export function OrganizationThreadsTab() { const response = await threadsClient.getThread({ threadId }); const thread = response.thread; if (!thread) return; - queryClient.setQueryData>(activeThreadsQueryKey, (data) => + queryClient.setQueryData>(listThreadsQueryKey, (data) => upsertThread(data, thread), ); } catch (error) { @@ -521,10 +423,10 @@ export function OrganizationThreadsTab() { ) : null} { - void threadsQuery.fetchNextPage(); + void listThreadsQuery.fetchNextPage(); }} />
From 8a87007dc153a36ad72acb9406b49daf37e31bf9 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Wed, 29 Apr 2026 23:21:21 +0000 Subject: [PATCH 12/12] fix(activity): resolve review feedback --- src/components/MultiSelectFilter.tsx | 2 +- src/components/WorkloadsTable.tsx | 18 ++++++++++-------- src/pages/OrganizationActivityStorageTab.tsx | 8 +++++--- src/pages/OrganizationActivityWorkloadsTab.tsx | 11 ++++------- src/pages/OrganizationThreadsTab.tsx | 4 ++-- src/pages/VolumeDetailPage.tsx | 3 --- src/pages/WorkloadDetailPage.tsx | 8 +++++++- 7 files changed, 29 insertions(+), 25 deletions(-) diff --git a/src/components/MultiSelectFilter.tsx b/src/components/MultiSelectFilter.tsx index 95dae03..76d5be3 100644 --- a/src/components/MultiSelectFilter.tsx +++ b/src/components/MultiSelectFilter.tsx @@ -43,7 +43,7 @@ export function MultiSelectFilter({ return ( - diff --git a/src/components/WorkloadsTable.tsx b/src/components/WorkloadsTable.tsx index f4a4c65..37f26e9 100644 --- a/src/components/WorkloadsTable.tsx +++ b/src/components/WorkloadsTable.tsx @@ -22,8 +22,8 @@ import { export type WorkloadSortKey = 'agentId' | 'runnerId' | 'threadId' | 'status' | 'started' | 'duration'; type WorkloadsTableControls = { - searchTerm: string; - onSearchTermChange: (value: string) => void; + searchTerm?: string; + onSearchTermChange?: (value: string) => void; sortKey: WorkloadSortKey; sortDirection: SortDirection; onSort: (key: WorkloadSortKey) => void; @@ -62,8 +62,8 @@ export function WorkloadsTable({ getRunnerName, getAgentLink, getRunnerLink, - agentLabel = 'Agent ID', - runnerLabel = 'Runner ID', + agentLabel = 'Agent', + runnerLabel = 'Runner', rowLinkMode = 'action', actionLabel, controls, @@ -82,6 +82,8 @@ export function WorkloadsTable({ 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 @@ -102,7 +104,7 @@ export function WorkloadsTable({ ]; 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), @@ -110,7 +112,7 @@ export function WorkloadsTable({ }; if (showRunnerColumn) { - sortOptions.runnerId = (workload) => workload.runnerId; + sortOptions.runnerId = (workload) => resolveRunnerSortKey(workload); } const listControls = useListControls({ @@ -335,8 +337,8 @@ export function WorkloadsTable({ className={`grid items-center gap-2 px-6 py-4 text-sm text-foreground ${gridClass} cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`} data-testid={`${testIdPrefix}-row`} onClick={(event) => { - const target = event.target as HTMLElement; - if (target.closest('a, button')) return; + const target = event.target; + if (target instanceof Element && target.closest('a, button')) return; navigate(workloadLink, { state: { from: location.pathname } }); }} onKeyDown={(event) => { diff --git a/src/pages/OrganizationActivityStorageTab.tsx b/src/pages/OrganizationActivityStorageTab.tsx index 7f76882..ccec2e1 100644 --- a/src/pages/OrganizationActivityStorageTab.tsx +++ b/src/pages/OrganizationActivityStorageTab.tsx @@ -59,10 +59,12 @@ const formatAttachmentLabel = (attachment: 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) => left.name.localeCompare(right.name)) + .sort((left, right) => resolveAttachmentSortKey(left).localeCompare(resolveAttachmentSortKey(right))) .map((attachment) => formatAttachmentLabel(attachment)) .filter((label) => label !== EMPTY_PLACEHOLDER); if (labels.length === 0) return UNATTACHED_LABEL; @@ -152,11 +154,11 @@ export function OrganizationActivityStorageTab() { .map((runner) => { const runnerId = runner.meta?.id ?? ''; if (!runnerId) return null; - const name = runner.name?.trim() || runnerId; + const name = runner.name?.trim(); + if (!name) return null; return { value: runnerId, label: name, - secondary: name === runnerId ? undefined : runnerId, }; }) .filter((option): option is NonNullable => option !== null) diff --git a/src/pages/OrganizationActivityWorkloadsTab.tsx b/src/pages/OrganizationActivityWorkloadsTab.tsx index 10a73b8..cae476e 100644 --- a/src/pages/OrganizationActivityWorkloadsTab.tsx +++ b/src/pages/OrganizationActivityWorkloadsTab.tsx @@ -108,7 +108,6 @@ export function OrganizationActivityWorkloadsTab() { const { id } = useParams(); const organizationId = id ?? ''; const queryClient = useQueryClient(); - const [searchTerm, setSearchTerm] = useState(''); const [agentIdFilter, setAgentIdFilter] = useState([]); const [runnerIdFilter, setRunnerIdFilter] = useState([]); const [statusFilter, setStatusFilter] = useState([]); @@ -139,11 +138,11 @@ export function OrganizationActivityWorkloadsTab() { .map((agent) => { const agentId = agent.meta?.id ?? ''; if (!agentId) return null; - const name = agent.name?.trim() || agentId; + const name = agent.name?.trim(); + if (!name) return null; return { value: agentId, label: name, - secondary: name === agentId ? undefined : agentId, }; }) .filter((option): option is NonNullable => option !== null) @@ -156,11 +155,11 @@ export function OrganizationActivityWorkloadsTab() { .map((runner) => { const runnerId = runner.meta?.id ?? ''; if (!runnerId) return null; - const name = runner.name?.trim() || runnerId; + const name = runner.name?.trim(); + if (!name) return null; return { value: runnerId, label: name, - secondary: name === runnerId ? undefined : runnerId, }; }) .filter((option): option is NonNullable => option !== null) @@ -343,8 +342,6 @@ export function OrganizationActivityWorkloadsTab() { agentLabel="Agent" runnerLabel="Runner" controls={{ - searchTerm, - onSearchTermChange: setSearchTerm, sortKey, sortDirection, onSort: handleSort, diff --git a/src/pages/OrganizationThreadsTab.tsx b/src/pages/OrganizationThreadsTab.tsx index 7254961..4c860e7 100644 --- a/src/pages/OrganizationThreadsTab.tsx +++ b/src/pages/OrganizationThreadsTab.tsx @@ -211,11 +211,11 @@ export function OrganizationThreadsTab() { thread.participants.forEach((participant) => { if (!participant.id) return; const nickname = formatNickname(participant.nickname); - const label = nickname || participant.id; + if (!nickname) return; + const label = nickname; participantMap.set(participant.id, { value: participant.id, label, - secondary: nickname ? participant.id : undefined, }); }); }); diff --git a/src/pages/VolumeDetailPage.tsx b/src/pages/VolumeDetailPage.tsx index 5172664..a7dae53 100644 --- a/src/pages/VolumeDetailPage.tsx +++ b/src/pages/VolumeDetailPage.tsx @@ -200,9 +200,6 @@ export function VolumeDetailPage() { return (
{label}
- {attachment.id && attachment.id !== attachment.name ? ( -
{attachment.id}
- ) : null}
); })} diff --git a/src/pages/WorkloadDetailPage.tsx b/src/pages/WorkloadDetailPage.tsx index 145e3b5..249259e 100644 --- a/src/pages/WorkloadDetailPage.tsx +++ b/src/pages/WorkloadDetailPage.tsx @@ -344,7 +344,13 @@ export function WorkloadDetailPage() { const runnerLink = organizationId && runnerId && runnerName ? `/organizations/${organizationId}/runners/${runnerId}` : ''; const agentLabel = agentName || EMPTY_PLACEHOLDER; const runnerLabel = runnerName || EMPTY_PLACEHOLDER; - const durationLabel = workload ? formatDurationBetween(workload.meta?.createdAt, workload.removedAt) : 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;