Publish curated ITWorx_MCP_Hub source from 6c7434368ce2a594005983c5a6601a31a639e7f1

This commit is contained in:
Public Source Publisher committed 2026-10-01 18:03:00 +00:00
1 parent 77946d62c4
commit 6d40ab975c
86 files changed
+6751 -1445

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
# Public source export # Public source export
This source snapshot was generated from the private canonical repository at revision `931c7aca4392dc771dad004a65d54f2b1c430c37`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. This source snapshot was generated from the private canonical repository at revision `6c7434368ce2a594005983c5a6601a31a639e7f1`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
+246 -116
View File
@@ -1,6 +1,6 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"sourceRevision": "931c7aca4392dc771dad004a65d54f2b1c430c37", "sourceRevision": "6c7434368ce2a594005983c5a6601a31a639e7f1",
"files": [ "files": [
{ {
"path": ".dockerignore", "path": ".dockerignore",
@@ -49,13 +49,13 @@
}, },
{ {
"path": "apps/web/package.json", "path": "apps/web/package.json",
"sha256": "ab0984ea38b8c4f01d09694ebda943ae6aa7e477a62a9c382736ad1c70aa4a94", "sha256": "a2720410e2436fa772af15d884717b12f0330d0b1dbf73c9496bb046072766d0",
"bytes": 967 "bytes": 967
}, },
{ {
"path": "apps/web/playwright.config.ts", "path": "apps/web/playwright.config.ts",
"sha256": "166983e0d59eccdec84161ade3e431f9dd63822a3d34f2130d7be60bf4c7cde2", "sha256": "4b82bc4dfd9d022202f2f8266d02a4aa53a3cbf908d6b413ddb2484cfc61b701",
"bytes": 1854 "bytes": 2140
}, },
{ {
"path": "apps/web/public/favicon.svg", "path": "apps/web/public/favicon.svg",
@@ -69,13 +69,13 @@
}, },
{ {
"path": "apps/web/src/api.ts", "path": "apps/web/src/api.ts",
"sha256": "d0aebbda4a6fd5645ce019c1462df26c041f32992dfccd775ebf9ef688143c06", "sha256": "6d0f9c3a36b206c4c970026132e8e447d29967dcaad89e788ac39a4b8ce06db1",
"bytes": 4358 "bytes": 5572
}, },
{ {
"path": "apps/web/src/app.tsx", "path": "apps/web/src/app.tsx",
"sha256": "0a421dd05b1d9cb5d94a89f30086fb4af3af6b595ddc78b3bee3fa3281eeb97b", "sha256": "fe1cc1fa47fa976872907183b6bdf35272c4927b6582c5d9c2bee303503e7f0b",
"bytes": 8546 "bytes": 12190
}, },
{ {
"path": "apps/web/src/bootstrap.ts", "path": "apps/web/src/bootstrap.ts",
@@ -94,13 +94,13 @@
}, },
{ {
"path": "apps/web/src/components/CommandMenu.tsx", "path": "apps/web/src/components/CommandMenu.tsx",
"sha256": "8dc002409163a9e0ac139fc802bc9807da79d3e0161a16a91927f8248a7efe42", "sha256": "d8b66fee31037b2eb087c37a5d8db4eb26e6193bc30c6e0312825aa62bf6fbef",
"bytes": 2329 "bytes": 11392
}, },
{ {
"path": "apps/web/src/components/DataTable.tsx", "path": "apps/web/src/components/DataTable.tsx",
"sha256": "9fad5252ac45a4feb611e0209fccd46bef7a500f0cfaba46902bbb3a6c343895", "sha256": "ddabe3b1f83d697d22615f76de34ebccf22e8ba237ae55302fe8ce06148e0e34",
"bytes": 5553 "bytes": 6999
}, },
{ {
"path": "apps/web/src/components/ErrorPanel.tsx", "path": "apps/web/src/components/ErrorPanel.tsx",
@@ -129,23 +129,38 @@
}, },
{ {
"path": "apps/web/src/components/Modal.tsx", "path": "apps/web/src/components/Modal.tsx",
"sha256": "96f10e6f24a58daf6041f0d93722c464a33a6f3226bb0065bf099f0f514d1aee", "sha256": "fc049f55c84540de410bb3746455b2b8c5308fce42fc10b2165b6be411ab58fa",
"bytes": 2919 "bytes": 2972
},
{
"path": "apps/web/src/components/RecentToolRuns.tsx",
"sha256": "f36bdb6dfe37d8973e86a731330da896a148c49ffadb8d22fd273d06f9e7380f",
"bytes": 2792
},
{
"path": "apps/web/src/components/SchemaInputFields.tsx",
"sha256": "b1b723140a07ab2fbaff375f706e119bf8a0621b9e4b7beb3c25396844c9fd02",
"bytes": 4785
}, },
{ {
"path": "apps/web/src/components/SessionMenu.tsx", "path": "apps/web/src/components/SessionMenu.tsx",
"sha256": "08c9972cdc5d26731c5e7b8421882bc9854be9de89d876f7c91fb58004794285", "sha256": "918b41bc5503255040b8a4907a7c320b1b1a889b425858a3810594bef618d9b9",
"bytes": 1184 "bytes": 1294
}, },
{ {
"path": "apps/web/src/components/StatusPill.tsx", "path": "apps/web/src/components/StatusPill.tsx",
"sha256": "de784a2c451c0dba4986125a5e6b794060b8bfa9b11ec9fe19b869b21a3b5e3b", "sha256": "de784a2c451c0dba4986125a5e6b794060b8bfa9b11ec9fe19b869b21a3b5e3b",
"bytes": 298 "bytes": 298
}, },
{
"path": "apps/web/src/components/modal-focus.ts",
"sha256": "2bc1fc1d53c1aed3a35e73027cd0eb49e1bbbda0045106ffb69b0d7a3dfa57a9",
"bytes": 4456
},
{ {
"path": "apps/web/src/contracts.ts", "path": "apps/web/src/contracts.ts",
"sha256": "b7c36d27a6011cd6c5660da72db0b6ff7700d7a80e8829d331f2351fd5f3d09b", "sha256": "fc7a6e3a4349860d738b19e705b6f03967d66512cd6a81bf90202ccdca73061b",
"bytes": 7629 "bytes": 10423
}, },
{ {
"path": "apps/web/src/format.ts", "path": "apps/web/src/format.ts",
@@ -154,19 +169,24 @@
}, },
{ {
"path": "apps/web/src/hooks.ts", "path": "apps/web/src/hooks.ts",
"sha256": "f3474ce60c82458aead1aee26f7db8e668e98dea3f25e79848e68195bedb765b", "sha256": "56b7e31485e60a9aba1725e6582d76a9a68708830482247970a3f0120a707042",
"bytes": 1436 "bytes": 1432
}, },
{ {
"path": "apps/web/src/main.ts", "path": "apps/web/src/main.ts",
"sha256": "c500632a52617540774ac856616edc585649678a475ec07d5d04d49272b8cc58", "sha256": "b7f471b3399e51f1dc8eca32e7ad5efc8a867937b693ed834a9fd354ab35aed5",
"bytes": 438 "bytes": 629
}, },
{ {
"path": "apps/web/src/navigation.ts", "path": "apps/web/src/navigation.ts",
"sha256": "c7d5d922ae27ded37a2f9e129e86dd04e3896b2caf31b157b71ea206be3ff8a8", "sha256": "c7d5d922ae27ded37a2f9e129e86dd04e3896b2caf31b157b71ea206be3ff8a8",
"bytes": 1881 "bytes": 1881
}, },
{
"path": "apps/web/src/operator-data.ts",
"sha256": "dc63e2796ac5761769f3bfe6840f81dabb3d888395204f1138078b988cf1b380",
"bytes": 4742
},
{ {
"path": "apps/web/src/pages/AuthGateway.tsx", "path": "apps/web/src/pages/AuthGateway.tsx",
"sha256": "929b9a368bdc8a1c289022c21798374c5a65f3bad48abcbbf4524cc604df15d6", "sha256": "929b9a368bdc8a1c289022c21798374c5a65f3bad48abcbbf4524cc604df15d6",
@@ -174,18 +194,23 @@
}, },
{ {
"path": "apps/web/src/pages/CatalogWorkflow.tsx", "path": "apps/web/src/pages/CatalogWorkflow.tsx",
"sha256": "aba9390d623d2b6a1a3e2fc30ceea4434bf196765157c66df810f185438aafcf", "sha256": "be520658fb1b87da9ac0d0af4e34865276c484ad4659d7c6df17164b6ce0e6b0",
"bytes": 3325 "bytes": 4583
}, },
{ {
"path": "apps/web/src/pages/ClientOnboarding.tsx", "path": "apps/web/src/pages/ClientOnboarding.tsx",
"sha256": "6bc5c21c5655771d2b461bc6725b8516e4fe85c5fa9167aa2c27a9d1d043a4fb", "sha256": "0a02c1f5b1f074571b8bb473aad16193d18b2ddbb4bebc84de3eb652c816918a",
"bytes": 6758 "bytes": 21663
},
{
"path": "apps/web/src/pages/ConnectorDetail.tsx",
"sha256": "9794b70c2b31f5590c6c179354c8b118e4e235d265adc8346c6d970a372b2967",
"bytes": 7467
}, },
{ {
"path": "apps/web/src/pages/Dashboard.tsx", "path": "apps/web/src/pages/Dashboard.tsx",
"sha256": "a265e25ad03f3822c0d459afa8d9574cde83b6024c233cb07b6c8ad7dc25a9cb", "sha256": "37823853d25b8675ab97d8ed1b67c7d79e7e9fbe8ea3b2c5d009601414b93d29",
"bytes": 10185 "bytes": 10452
}, },
{ {
"path": "apps/web/src/pages/ForbiddenPanel.tsx", "path": "apps/web/src/pages/ForbiddenPanel.tsx",
@@ -194,8 +219,8 @@
}, },
{ {
"path": "apps/web/src/pages/Operations.tsx", "path": "apps/web/src/pages/Operations.tsx",
"sha256": "f197f902bd591a9accff51376ef7d8aae690b36962da0cb41774c6c786f122b6", "sha256": "e4c90be00fa09a4db9385c2bf388af8c5c4f4810c9a938ddc6dc27cd93f7a1d0",
"bytes": 7941 "bytes": 11932
}, },
{ {
"path": "apps/web/src/pages/PolicySimulator.tsx", "path": "apps/web/src/pages/PolicySimulator.tsx",
@@ -204,23 +229,28 @@
}, },
{ {
"path": "apps/web/src/pages/ProjectDetail.tsx", "path": "apps/web/src/pages/ProjectDetail.tsx",
"sha256": "562e110e7fa170df08509fde06d8dcd5666f5731c0e8427eaa5330a42ee34b27", "sha256": "70e84876db7ca02c1332afde9565a294cdc2313d09493bb4bcddf70ec4b249ae",
"bytes": 6743 "bytes": 13104
}, },
{ {
"path": "apps/web/src/pages/ResourcePage.tsx", "path": "apps/web/src/pages/ResourcePage.tsx",
"sha256": "79e708edeae9d1d2978d8d195f1783403546bfdfaaadc5bc2eed0674db1ad99a", "sha256": "2a092a436b7c91514d98e4f0104497362967a13e3d7343b6b85f2d39f4d3b9dd",
"bytes": 9466 "bytes": 11672
}, },
{ {
"path": "apps/web/src/pages/SettingsPage.tsx", "path": "apps/web/src/pages/SettingsPage.tsx",
"sha256": "417ea7501e9608dd8aae9d06215eb50d80cb720ee95405c5a0952135ad8bae00", "sha256": "83abb9907bfefd5572b9acdebdfcbc689afbdbf26df8114effdeaed610caa26a",
"bytes": 2147 "bytes": 2169
}, },
{ {
"path": "apps/web/src/pages/ToolReadinessModal.tsx", "path": "apps/web/src/pages/ToolReadinessModal.tsx",
"sha256": "9c8c81ec498b6b3b25d5cd58aa36709734a254f37ffceb2557c3276675479086", "sha256": "75b09cb94ce445d374a3b54e89bb2504bac92d180af20affe3009e903dc7df0b",
"bytes": 9157 "bytes": 11552
},
{
"path": "apps/web/src/preferences.ts",
"sha256": "298faf383f6a4414f98cc02e4530c2575380d1b028342a28cb2f0dad749ec67a",
"bytes": 558
}, },
{ {
"path": "apps/web/src/rbac-context.tsx", "path": "apps/web/src/rbac-context.tsx",
@@ -229,23 +259,58 @@
}, },
{ {
"path": "apps/web/src/rbac.ts", "path": "apps/web/src/rbac.ts",
"sha256": "d085bf7104c1c92c11cd50037c748d6ef0daa3c58a932a937a8a569908b3267c", "sha256": "35f12f86889e39f766ab1eb96f88e144f59fae61e9dc81ab0a7a5239042d3ac9",
"bytes": 1621 "bytes": 1683
}, },
{ {
"path": "apps/web/src/routing.ts", "path": "apps/web/src/routing.ts",
"sha256": "4eaf247684474b126ee96d11a1d559e20a6357a1508c39f8a3b2d6aa772773e6", "sha256": "1941e048a034a5ac0e0d1d30e0759c2bc05ed58c0301fd436877e1eccd59f8bb",
"bytes": 1149 "bytes": 1650
},
{
"path": "apps/web/src/schema-input.ts",
"sha256": "d363239aab90dc4c93747f645cc27757fb0f129a59c59700ecfea194d91a7cdf",
"bytes": 5990
},
{
"path": "apps/web/src/status-refresh.ts",
"sha256": "856a294fd4de301779b301c4e095d7a9500731f4923d1de7d1c0a02034677366",
"bytes": 2892
}, },
{ {
"path": "apps/web/src/styles.css", "path": "apps/web/src/styles.css",
"sha256": "8f22fd7261c86abcab916a65d14e407db7d3d1d31116a776d866099ff75ddcc9", "sha256": "e6caa3209994a4cd716a94e7b5e4b52888cbe16f8e3c38f5e9910f1c013d6ca5",
"bytes": 64960 "bytes": 62320
},
{
"path": "apps/web/src/styles/details.css",
"sha256": "962d87e466c4e1873dd1d0ed80e81bcc40dfd042d7a0184a2b4d96ac8cf20a2f",
"bytes": 2611
},
{
"path": "apps/web/src/styles/operator.css",
"sha256": "f8c7d52769885843f83e08d2b89004826f84511e048b10f8539e1619aab61b70",
"bytes": 4297
},
{
"path": "apps/web/src/styles/shell.css",
"sha256": "543d658bad7791d0d4554c0a741fe07aee824515c5cc7a23e4ada58352dc2583",
"bytes": 5242
},
{
"path": "apps/web/src/styles/workflows.css",
"sha256": "66e08d736cbd7b532642d23d9bddad5351835640a06f572bdedbffb3a6773edf",
"bytes": 8978
},
{
"path": "apps/web/src/tool-history.ts",
"sha256": "dbfdd8063af2ce3b6d85b744a3b802648044d0656f8f5503848c3a6dcdff68bc",
"bytes": 2988
}, },
{ {
"path": "apps/web/tests/api.test.ts", "path": "apps/web/tests/api.test.ts",
"sha256": "79dc9e71d364f7d359ebdc1d1b5a3b80839c5c4ea400bd6029f1314f1c85951a", "sha256": "f9b4bbb9d1337672568e7d27258c15fef52b697fab6a7a6dcdf60b0854d3b426",
"bytes": 5789 "bytes": 6405
}, },
{ {
"path": "apps/web/tests/bootstrap.test.ts", "path": "apps/web/tests/bootstrap.test.ts",
@@ -254,8 +319,8 @@
}, },
{ {
"path": "apps/web/tests/contracts.test.ts", "path": "apps/web/tests/contracts.test.ts",
"sha256": "13313c4a99971007d9c354e491ddfa23c5fbb9e09c9d40010d9288bc788a22b5", "sha256": "c315bd4d881014d286f07c5baf71377792011b2ba107064768add1362c1fd38b",
"bytes": 1122 "bytes": 1367
}, },
{ {
"path": "apps/web/tests/e2e/accessibility.spec.ts", "path": "apps/web/tests/e2e/accessibility.spec.ts",
@@ -264,8 +329,8 @@
}, },
{ {
"path": "apps/web/tests/e2e/auth.spec.ts", "path": "apps/web/tests/e2e/auth.spec.ts",
"sha256": "e73c68a37faf2c96e061d814302c768fb94a449e275873e56e57262736b54ee8", "sha256": "72abb30d223c4a847f8ce958c9c916801cf412e17b1a0c9ec56dde9448811d32",
"bytes": 3513 "bytes": 3700
}, },
{ {
"path": "apps/web/tests/e2e/fixtures.ts", "path": "apps/web/tests/e2e/fixtures.ts",
@@ -274,8 +339,28 @@
}, },
{ {
"path": "apps/web/tests/e2e/navigation.spec.ts", "path": "apps/web/tests/e2e/navigation.spec.ts",
"sha256": "693edec83b9d1569dbe60468921e15de559e5370fc4dda2a006b7acf3b206f06", "sha256": "3a7fd93bdef6f25795720d7fe47e5ab600158d44043e559dfd7dd579d10c7c0b",
"bytes": 2747 "bytes": 2862
},
{
"path": "apps/web/tests/e2e/operator-matrix.spec.ts",
"sha256": "31caf7cf64405304cbce4a653647f7d7ddd3915fe460b3469f49d1623ddda939",
"bytes": 10426
},
{
"path": "apps/web/tests/e2e/operator-review.spec.ts",
"sha256": "03cb44204c40bef2c8ab4e245a7374a8636ff32a15ff90b23c1022c0a76bd191",
"bytes": 7983
},
{
"path": "apps/web/tests/e2e/operator-shell.spec.ts",
"sha256": "cdefdbbada4610c631d292d1b6c9f5f15a3d57746c50c6f1c95a02bfbb0496ae",
"bytes": 7687
},
{
"path": "apps/web/tests/e2e/operator-workflows.spec.ts",
"sha256": "8a15c4305656d0068a4af4a8215b7b405055a85a1dfb55f745fae46d8e19bb44",
"bytes": 17752
}, },
{ {
"path": "apps/web/tests/e2e/rbac.spec.ts", "path": "apps/web/tests/e2e/rbac.spec.ts",
@@ -289,19 +374,39 @@
}, },
{ {
"path": "apps/web/tests/e2e/visual-quality.spec.ts", "path": "apps/web/tests/e2e/visual-quality.spec.ts",
"sha256": "07ed212e6b8e9e253775672eac848b216bb71575139fecf079fb504ee4c902a7", "sha256": "dc00e990afedf58ae57deec7e02b11b4a61681d45075bf7dbc2263e75ed8e352",
"bytes": 5553 "bytes": 5685
}, },
{ {
"path": "apps/web/tests/e2e/workflows.spec.ts", "path": "apps/web/tests/e2e/workflows.spec.ts",
"sha256": "83122d00735fef8adea67eeb7cb6e43b74f1bbb5e90c2650d63734723445fd36", "sha256": "ccfe4e2e2f277ed6069d30376719ae7bcb94e8f1b2b2614d4612d9d409dfb4ad",
"bytes": 12525 "bytes": 12984
},
{
"path": "apps/web/tests/operator-data.test.ts",
"sha256": "079b4b17a8adfcff3019e1519bedd5e619d34e34f5b9822c8a2ac4303add8718",
"bytes": 3371
},
{
"path": "apps/web/tests/preferences.test.ts",
"sha256": "b420285fe2d4c6d36aca301006ec034ba51af50f9b08858921a7152e999dac9e",
"bytes": 1066
}, },
{ {
"path": "apps/web/tests/rbac.test.ts", "path": "apps/web/tests/rbac.test.ts",
"sha256": "e9a2827f96cc12550cb4cccdd30ab72efb15f5eb55ae0ac11fdb26e5134990bf", "sha256": "e9a2827f96cc12550cb4cccdd30ab72efb15f5eb55ae0ac11fdb26e5134990bf",
"bytes": 2661 "bytes": 2661
}, },
{
"path": "apps/web/tests/schema-input.test.ts",
"sha256": "269bc46ef8dc90e87ac100780e59abb36dff6d50cf7e508421431cfade9b8025",
"bytes": 3460
},
{
"path": "apps/web/tests/tool-history.test.ts",
"sha256": "d9f52f79abad7bb39a473dcc36c6d55838e6cb28aa234b0f881eb41a87381357",
"bytes": 2451
},
{ {
"path": "apps/web/tsconfig.json", "path": "apps/web/tsconfig.json",
"sha256": "b13b349ceb0a6062cf5509591e92e98cb4a1b26822c734e935e7d4e45935047e", "sha256": "b13b349ceb0a6062cf5509591e92e98cb4a1b26822c734e935e7d4e45935047e",
@@ -309,8 +414,8 @@
}, },
{ {
"path": "apps/web/vite.config.ts", "path": "apps/web/vite.config.ts",
"sha256": "2497c38e866dbf9a64400811144a4e5afea5c099c048881c8abdaaf7051a5223", "sha256": "dd03015ec0252f3de6a62aa0c01fbc5803afd5f8294f202f0bc192cfdd6c3699",
"bytes": 277 "bytes": 309
}, },
{ {
"path": "catalog/README.md", "path": "catalog/README.md",
@@ -1639,8 +1744,8 @@
}, },
{ {
"path": "connectors/gitea/fake.py", "path": "connectors/gitea/fake.py",
"sha256": "94f7349380ab0695792c7308e0b08003661834801189b5005f934dc05d828eae", "sha256": "e8012cd1b40e35f6947dcfb967ae83e3a8f85816c9a5c790704887223f093073",
"bytes": 30597 "bytes": 30667
}, },
{ {
"path": "connectors/gitea/lifecycle.py", "path": "connectors/gitea/lifecycle.py",
@@ -1654,8 +1759,8 @@
}, },
{ {
"path": "connectors/gitea/server.py", "path": "connectors/gitea/server.py",
"sha256": "d8c5be1efed22d99dc287a83fd78156e306afa731f6b233b4d076bb5736152b4", "sha256": "be5f401d9bc75d531f1d082ba8893547fa752d1fad5c22069b165f3cea64db3b",
"bytes": 227648 "bytes": 228031
}, },
{ {
"path": "connectors/knowledge/__init__.py", "path": "connectors/knowledge/__init__.py",
@@ -2009,8 +2114,8 @@
}, },
{ {
"path": "package.json", "path": "package.json",
"sha256": "9bd1cc5653ee2aac73b71005363817d687d016668de737e77b85a5e418d9a6c1", "sha256": "1f03209a7d913ba92b911cb2eddde44c34d64689761e5fdab8b3afc306e2d361",
"bytes": 379 "bytes": 583
}, },
{ {
"path": "packages/README.md", "path": "packages/README.md",
@@ -2034,8 +2139,8 @@
}, },
{ {
"path": "packages/connector_kit/gitea.py", "path": "packages/connector_kit/gitea.py",
"sha256": "2a5af5c47531ba65002e11fc61893bc477aec9481b76da0e0e3b64b15ad52850", "sha256": "118e657d06142f71db40741b08d25504e30f13754bf0f67fc5f45f865cb41e2e",
"bytes": 50212 "bytes": 50270
}, },
{ {
"path": "packages/connector_kit/gitea_resilient.py", "path": "packages/connector_kit/gitea_resilient.py",
@@ -2144,8 +2249,8 @@
}, },
{ {
"path": "packages/hub_policy/audit.py", "path": "packages/hub_policy/audit.py",
"sha256": "171711bd6c6045b4fc73f25600ad96e7043d2fc9de4633a464c81e7481583b1d", "sha256": "0dc272103d6cee37260b6c6cb8ef3b378b883ccdda9114627c0bee1dc4712ddb",
"bytes": 6191 "bytes": 6233
}, },
{ {
"path": "packages/hub_policy/engine.py", "path": "packages/hub_policy/engine.py",
@@ -2154,8 +2259,8 @@
}, },
{ {
"path": "packages/hub_policy/governance.py", "path": "packages/hub_policy/governance.py",
"sha256": "c600bfa0429bd12523b120462472bafe4338569e206e76532f54d18be41c16df", "sha256": "643bac3aa11de19e58ce36bc61090aa472cfd47f5f7c028d9fa32b051035cd52",
"bytes": 5874 "bytes": 5938
}, },
{ {
"path": "packages/hub_policy/rate_limit.py", "path": "packages/hub_policy/rate_limit.py",
@@ -2239,8 +2344,8 @@
}, },
{ {
"path": "pnpm-lock.yaml", "path": "pnpm-lock.yaml",
"sha256": "7f80ff769f89743192d6eff6a637a4c60cb5ceaa2bbece621a7a9ac03a7caf71", "sha256": "eb333f963d85f9d33359a83eb5cbebe94a2042e5f1636778f49aa96fa5b5376a",
"bytes": 90269 "bytes": 80522
}, },
{ {
"path": "pnpm-workspace.yaml", "path": "pnpm-workspace.yaml",
@@ -2249,8 +2354,8 @@
}, },
{ {
"path": "pyproject.toml", "path": "pyproject.toml",
"sha256": "f42fb743257f77af9066b85ce9a2eb633d3dfbddc10d67315a0d9f7352b4938d", "sha256": "561e2da3f0a6ef04eb8681f02c67e2c352c5cc3d90e9353237991ec2132fb316",
"bytes": 857 "bytes": 877
}, },
{ {
"path": "scripts/README.md", "path": "scripts/README.md",
@@ -2419,13 +2524,13 @@
}, },
{ {
"path": "scripts/validate_pack.py", "path": "scripts/validate_pack.py",
"sha256": "36e33bab19294b0d0353fd6af80cf9db6de03d0440d2e4d5ac750deb97492e8d", "sha256": "f77573eba9128c9920a293ed3f73e9f2307b2bc6a4505b6814ac3c950fe68712",
"bytes": 35777 "bytes": 35865
}, },
{ {
"path": "scripts/verify.py", "path": "scripts/verify.py",
"sha256": "b2c39fb0f6ca2624be4f0f27db70b14f12689efb6d1b3ba3ced045712cdc4638", "sha256": "da819db31ca891d8f9f2aaad42c02728b0dd8fa9bd2801a43f1fd0e947facca8",
"bytes": 13732 "bytes": 14006
}, },
{ {
"path": "scripts/verify_demo_token_flow.py", "path": "scripts/verify_demo_token_flow.py",
@@ -2504,8 +2609,8 @@
}, },
{ {
"path": "services/control_api/authz.py", "path": "services/control_api/authz.py",
"sha256": "f4f47704949be21fbe2d7d5a515a451bde79265292628e8c4f43bf1c652a4104", "sha256": "4b363e26856c775762ab09a2a44f371021044dcc860ba80a42081ef7954bc881",
"bytes": 5183 "bytes": 5270
}, },
{ {
"path": "services/control_api/catalog/__init__.py", "path": "services/control_api/catalog/__init__.py",
@@ -2514,8 +2619,8 @@
}, },
{ {
"path": "services/control_api/catalog/loader.py", "path": "services/control_api/catalog/loader.py",
"sha256": "6ba03597ef4b4dac2fb05b07516c5cf657622a239d38debb24482ba2f465956c", "sha256": "61b5f6651b1a33d06d50d6fdbbf341cc21cb1f94ff2069651aa4f4463b3f68e2",
"bytes": 13928 "bytes": 14060
}, },
{ {
"path": "services/control_api/catalog/models.py", "path": "services/control_api/catalog/models.py",
@@ -2549,8 +2654,8 @@
}, },
{ {
"path": "services/control_api/clients/router.py", "path": "services/control_api/clients/router.py",
"sha256": "7f51cfc3c87925a6286118773a446e2b4886e0fcda031f2e51e31bfc7a6f5373", "sha256": "c1dbcb8decbf3aa04ac50bb727345288c1b32ec6a97e4708484707a38c83ae22",
"bytes": 5785 "bytes": 7548
}, },
{ {
"path": "services/control_api/clients/service.py", "path": "services/control_api/clients/service.py",
@@ -2629,8 +2734,8 @@
}, },
{ {
"path": "services/control_api/persistence/machine_client.py", "path": "services/control_api/persistence/machine_client.py",
"sha256": "ac8886cae645aaf87032aa74e59002c1036225a4f90c924aecb569a875e910dc", "sha256": "2ec55f8bf1881e0f7b2726bcfa710e1df812ec79877574838b69570ed97326e4",
"bytes": 6051 "bytes": 6384
}, },
{ {
"path": "services/control_api/persistence/models.py", "path": "services/control_api/persistence/models.py",
@@ -2744,8 +2849,8 @@
}, },
{ {
"path": "services/runtime/client_service.py", "path": "services/runtime/client_service.py",
"sha256": "0dcb25a3c0cd5391409e40add822e48920a4560fb9fe182e901bf045876f547c", "sha256": "73ff3a633fb0d622bacf19d6955a274f9072467e3a0a9a3456fd6432365e9058",
"bytes": 8331 "bytes": 10709
}, },
{ {
"path": "services/runtime/common.py", "path": "services/runtime/common.py",
@@ -2759,8 +2864,8 @@
}, },
{ {
"path": "services/runtime/connector_gitea.py", "path": "services/runtime/connector_gitea.py",
"sha256": "c89874089d1bdde3020864e9222052d2470970ad7c4aa5677201cbfbc4a88eea", "sha256": "5218c32351530d4b44cf99f61eac1543b0668de0a737a130f7d66a88cc0e2807",
"bytes": 37958 "bytes": 37952
}, },
{ {
"path": "services/runtime/connector_ingestion.py", "path": "services/runtime/connector_ingestion.py",
@@ -2794,8 +2899,8 @@
}, },
{ {
"path": "services/runtime/control_api.py", "path": "services/runtime/control_api.py",
"sha256": "348f126c7816d28cdd1c542a438621b332f8fdbfdd432dddfbbb15b83432778e", "sha256": "5ea476237d69129634fa391ac3b5c2061bc889a4bbd83b98a1781e12c90539c2",
"bytes": 55774 "bytes": 113286
}, },
{ {
"path": "services/runtime/dataplane.py", "path": "services/runtime/dataplane.py",
@@ -2889,8 +2994,8 @@
}, },
{ {
"path": "tests/e2e/harness.py", "path": "tests/e2e/harness.py",
"sha256": "c776c167b001a372906e9dca1e39a1a13a09a2fa88f8093b3cc02b99183eb98d", "sha256": "a4edee80a05beeee8e07787c7bfa4607f3f27927c87d948051eea4d07ff30287",
"bytes": 6538 "bytes": 7503
}, },
{ {
"path": "tests/e2e/tls.py", "path": "tests/e2e/tls.py",
@@ -2924,8 +3029,8 @@
}, },
{ {
"path": "tests/test_appops_connector.py", "path": "tests/test_appops_connector.py",
"sha256": "4e088d187b239cd1e8b81b7c5d72aed27eddae78491678564bfe2776d37913be", "sha256": "93fea80c542f6c3bb6e490626c6b4b00db2a0238dfb0210c0edeb415abc1ca06",
"bytes": 9448 "bytes": 9388
}, },
{ {
"path": "tests/test_auth.py", "path": "tests/test_auth.py",
@@ -2934,13 +3039,23 @@
}, },
{ {
"path": "tests/test_bootstrap.py", "path": "tests/test_bootstrap.py",
"sha256": "15dbc0d8e2499f10faf9e3eba79756fb8f4b77251fe5b5d6d5967c4dd91ef9a9", "sha256": "48b59147ceb5bf46ade6327d2e1484fad2eaa6d76294654ad98bcfbb811b500c",
"bytes": 10812 "bytes": 11767
}, },
{ {
"path": "tests/test_catalog.py", "path": "tests/test_catalog.py",
"sha256": "adbe584172170fa17fe21c0e6a1953729e9b83190845d8e9d053b0d2cef4b1c6", "sha256": "1614357a0e3f15894f7bca4d0b7c67a6d71c13f6a372f15d8d2622abdcb34641",
"bytes": 16467 "bytes": 18805
},
{
"path": "tests/test_catalog_write_validation.py",
"sha256": "785959baca7e28c8da6b363af7835f97fc94a3a4116dc7bf3052468876c2611d",
"bytes": 1487
},
{
"path": "tests/test_client_lifecycle_audit.py",
"sha256": "4a93dd3c4ade40e241c526a9ec116da2d4b5c32f91d3368e0b20c99e05e727a7",
"bytes": 4639
}, },
{ {
"path": "tests/test_codex_bridge.py", "path": "tests/test_codex_bridge.py",
@@ -3052,6 +3167,11 @@
"sha256": "fb4007bd62d722e17140ac41623f374f16fb599a41f8f3ac7ca2be18cb21cab5", "sha256": "fb4007bd62d722e17140ac41623f374f16fb599a41f8f3ac7ca2be18cb21cab5",
"bytes": 2395 "bytes": 2395
}, },
{
"path": "tests/test_e2e_harness.py",
"sha256": "aba3682b8185ef4d0b1b28dc536b7cf106729fb5135f91cc790e019763335adb",
"bytes": 1516
},
{ {
"path": "tests/test_gitea_access_diagnostics.py", "path": "tests/test_gitea_access_diagnostics.py",
"sha256": "7b5c383119f017761551b28c5516eaa5b8bf5b0aeddf039d4fb4dd846c2824f3", "sha256": "7b5c383119f017761551b28c5516eaa5b8bf5b0aeddf039d4fb4dd846c2824f3",
@@ -3064,8 +3184,8 @@
}, },
{ {
"path": "tests/test_gitea_connector.py", "path": "tests/test_gitea_connector.py",
"sha256": "c6389e924471d392f1a19cae48e9c409b349223b76faa34c20b9ae4c7894ede2", "sha256": "dd61db44c16f1ecb2fcc2ac099900b3908fa7b5aa7ab397c40648795a4e2ecaa",
"bytes": 128684 "bytes": 128560
}, },
{ {
"path": "tests/test_gitea_merge_eligibility.py", "path": "tests/test_gitea_merge_eligibility.py",
@@ -3154,8 +3274,8 @@
}, },
{ {
"path": "tests/test_machine_client_provisioning.py", "path": "tests/test_machine_client_provisioning.py",
"sha256": "c1695cb3d087d58337d0abd14bdef3fec1c253648650c7a5ec98db9ac5e36aee", "sha256": "f925d9624d81035f9131e4f4f25281d27b6c59f8bb1edfa2e2c7b7dcbafb6385",
"bytes": 6140 "bytes": 8728
}, },
{ {
"path": "tests/test_managed_validation_action_pins.py", "path": "tests/test_managed_validation_action_pins.py",
@@ -3187,6 +3307,16 @@
"sha256": "a8f32e6569ed3fa947ba8b714128a86610f0a186bc7a0447b3497e41ba6c8e9f", "sha256": "a8f32e6569ed3fa947ba8b714128a86610f0a186bc7a0447b3497e41ba6c8e9f",
"bytes": 1134 "bytes": 1134
}, },
{
"path": "tests/test_operator_search.py",
"sha256": "92a6e536600449d343477fa4b3866c5ba314cdc20e5bed992b19ac42fea8ecff",
"bytes": 6719
},
{
"path": "tests/test_operator_workspaces.py",
"sha256": "dbe4d6e42c85b0dbf674288096e6481fa0346c2b26300f67e89fc824794b07ef",
"bytes": 22774
},
{ {
"path": "tests/test_persistence.py", "path": "tests/test_persistence.py",
"sha256": "139f399570d6123d3fb5f6d8f52eb4a47d1a2f2f72f68fd7d2b12d4bcf5a85d2", "sha256": "139f399570d6123d3fb5f6d8f52eb4a47d1a2f2f72f68fd7d2b12d4bcf5a85d2",
@@ -3244,8 +3374,8 @@
}, },
{ {
"path": "tests/test_wp110_governance.py", "path": "tests/test_wp110_governance.py",
"sha256": "dbb358f03c4b55060949dd728228862fba59a0e51bc45bc166b9d8062de59af4", "sha256": "721200a16676a76ef239d3966ff0f4e71dc5d54f8b3571495966a2faf8dff329",
"bytes": 9314 "bytes": 9480
}, },
{ {
"path": "tests/test_wp140_clients.py", "path": "tests/test_wp140_clients.py",
@@ -3294,18 +3424,18 @@
}, },
{ {
"path": "tests/test_wp230_authz.py", "path": "tests/test_wp230_authz.py",
"sha256": "218bd70139767573ced7f210d230ddbd2cb5e3beafff7954f9b017efdf67a64f", "sha256": "ba186a4cb0bea077feb240e6d738fa5fb907550d2b7ba45184d802c06a2b2995",
"bytes": 6696 "bytes": 6746
}, },
{ {
"path": "tests/test_wp230_policy_simulate.py", "path": "tests/test_wp230_policy_simulate.py",
"sha256": "f62386357ca2aadc0775fc7bba9393470ebdb85ff8f140f0f6c27fa994a1c74b", "sha256": "1c8c6a9b8711824d03903844e70104522f494f08f05bb0c55287515db5efa4ff",
"bytes": 20597 "bytes": 20697
}, },
{ {
"path": "tests/test_wp230_rbac.py", "path": "tests/test_wp230_rbac.py",
"sha256": "63665cb5c25c4ac13bfcd9378d5db12fa4ebac804c616970862f67c51a4902d9", "sha256": "1b540347d8053040dbc8d7850fa163e3814f22c01f8619a19b4222bbff082ed8",
"bytes": 20973 "bytes": 21361
}, },
{ {
"path": "tests/test_wp230_session_store.py", "path": "tests/test_wp230_session_store.py",
@@ -3314,8 +3444,8 @@
}, },
{ {
"path": "tests/test_wp230_ui_completion.py", "path": "tests/test_wp230_ui_completion.py",
"sha256": "dacb89c17ea74db69c8cf28b3ff382f5e55d2f8952b93d4fdbd8e5aeaf7b596e", "sha256": "45718516ff3b1aef1201c0eca71c0ebf828737ecfcbe8e462e570ed0c11964c7",
"bytes": 10276 "bytes": 10132
}, },
{ {
"path": "tests/test_wp270_functional_operations.py", "path": "tests/test_wp270_functional_operations.py",
@@ -3329,8 +3459,8 @@
}, },
{ {
"path": "uv.lock", "path": "uv.lock",
"sha256": "4e8b857f3a4b8dee50b2148a208d887ce869ee2a6851734a8911d3ef61b7677c", "sha256": "0157f642a8d62a7c0777989761bff66d0c09cb99338c1f2c2146f85bcfe3a908",
"bytes": 104010 "bytes": 104059
} }
] ]
} }
+5 -5
View File
@@ -19,17 +19,17 @@
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "4.11.0", "@axe-core/playwright": "4.11.0",
"@playwright/test": "^1.62.1", "@playwright/test": "1.62.1",
"@types/node": "^26.1.2", "@types/node": "26.1.2",
"@types/react": "19.1.10", "@types/react": "19.1.10",
"@types/react-dom": "19.1.7", "@types/react-dom": "19.1.7",
"@typescript-eslint/parser": "8.39.0", "@typescript-eslint/parser": "8.39.0",
"@vitejs/plugin-react": "4.7.0", "@vitejs/plugin-react": "4.7.0",
"@vitest/coverage-v8": "3.2.4", "@vitest/coverage-v8": "4.1.11",
"eslint": "9.35.0", "eslint": "9.35.0",
"eslint-plugin-react-hooks": "5.2.0", "eslint-plugin-react-hooks": "5.2.0",
"typescript": "5.9.2", "typescript": "5.9.2",
"vite": "7.1.3", "vite": "7.3.5",
"vitest": "3.2.4" "vitest": "4.1.11"
} }
} }
+9 -1
View File
@@ -9,6 +9,7 @@ const DIST = path.join(WEB_DIR, "dist");
const WEB_PORT = 8780; const WEB_PORT = 8780;
const CONTROL_PORT = 8781; const CONTROL_PORT = 8781;
const OIDC_PORT = 8782; const OIDC_PORT = 8782;
const WORKER_PORT = 8783;
export default defineConfig({ export default defineConfig({
testDir: "./tests/e2e", testDir: "./tests/e2e",
@@ -43,7 +44,14 @@ export default defineConfig({
cwd: REPO_ROOT, cwd: REPO_ROOT,
}, },
{ {
command: `"${PYTHON}" -m tests.e2e.harness web --port ${WEB_PORT} --control-port ${CONTROL_PORT} --dist "${DIST}"`, command: `"${PYTHON}" -m tests.e2e.harness worker --port ${WORKER_PORT}`,
url: `http://127.0.0.1:${WORKER_PORT}/health/ready`,
reuseExistingServer: false,
timeout: 90_000,
cwd: REPO_ROOT,
},
{
command: `"${PYTHON}" -m tests.e2e.harness web --port ${WEB_PORT} --control-port ${CONTROL_PORT} --worker-port ${WORKER_PORT} --dist "${DIST}"`,
url: `https://127.0.0.1:${WEB_PORT}/ready`, url: `https://127.0.0.1:${WEB_PORT}/ready`,
ignoreHTTPSErrors: true, ignoreHTTPSErrors: true,
reuseExistingServer: false, reuseExistingServer: false,
+18 -3
View File
@@ -1,4 +1,4 @@
import type { CatalogPreview, ClientActionResult, ClientOnboardingResult, DashboardSnapshot, ManagementApiClient, ManagementRole, OnboardingClientKind, OperationsCheck, OperationsSummary, PageQuery, PolicySimulation, ProjectDetailInfo, ResourceKind, ResourcePage, ToolDefinition, ToolInvocationResult, ToolReadinessResult } from "./contracts"; import type { CatalogPreview, ClientActionResult, ClientOnboardingResult, ClientOnboardingStatus, ConnectorDetailInfo, DashboardSnapshot, ManagementApiClient, ManagementRole, OnboardingClientKind, OperationsCheck, OperationsSummary, PageQuery, PolicySimulation, ProjectDetailInfo, ResourceKind, ResourcePage, ToolDefinition, ToolInvocationResult, ToolReadinessResult } from "./contracts";
export class ApiError extends Error { export class ApiError extends Error {
readonly status: number; readonly status: number;
@@ -24,8 +24,9 @@ export async function logoutSession(): Promise<void> {
export function createApiClient(): ManagementApiClient { let csrfToken: string | null = null; return { export function createApiClient(): ManagementApiClient { let csrfToken: string | null = null; return {
session: async () => { const raw = await getJson<{ authenticated: boolean; csrf_token?: string; session?: { subject: string; client_id: string | null; kind: string; roles: string[]; expires_at: string } }>("/auth/session"); csrfToken = raw.csrf_token ?? null; return { authenticated: raw.authenticated, csrf_token: raw.csrf_token, session: raw.session ? { subject: raw.session.subject, clientId: raw.session.client_id, kind: raw.session.kind as "management_user" | "machine_client", roles: raw.session.roles as ManagementRole[], expiresAt: raw.session.expires_at } : undefined }; }, session: async () => { const raw = await getJson<{ authenticated: boolean; csrf_token?: string; session?: { subject: string; client_id: string | null; kind: string; roles: string[]; expires_at: string } }>("/auth/session"); csrfToken = raw.csrf_token ?? null; return { authenticated: raw.authenticated, csrf_token: raw.csrf_token, session: raw.session ? { subject: raw.session.subject, clientId: raw.session.client_id, kind: raw.session.kind as "management_user" | "machine_client", roles: raw.session.roles as ManagementRole[], expiresAt: raw.session.expires_at } : undefined }; },
dashboard: () => getJson<DashboardSnapshot>("/api/dashboard"), dashboard: () => getJson<DashboardSnapshot>("/api/dashboard"),
resource: (kind: ResourceKind, query: PageQuery) => { const params = new URLSearchParams({ q: query.filter, limit: String(query.limit) }); if (query.cursor) params.set("cursor", query.cursor); return getJson<ResourcePage>(`/api/${kind}?${params.toString()}`); }, resource: (kind: ResourceKind, query: PageQuery) => { const params = new URLSearchParams({ q: query.filter, limit: String(query.limit) }); if (query.cursor) params.set("cursor", query.cursor); if (query.project) params.set("project", query.project); if (query.environment) params.set("environment", query.environment); return getJson<ResourcePage>(`/api/${kind}?${params.toString()}`); },
projectDetail: (slug) => getJson<ProjectDetailInfo>(`/api/projects/${encodeURIComponent(slug)}`), projectDetail: (slug) => getJson<ProjectDetailInfo>(`/api/projects/${encodeURIComponent(slug)}`),
connectorDetail: (slug, scope) => { const params = new URLSearchParams(); if (scope) { params.set("project", scope.project); params.set("environment", scope.environment); } const value = params.toString(); const query = value ? `?${value}` : ""; return getJson<ConnectorDetailInfo>(`/api/connectors/${encodeURIComponent(slug)}${query}`); },
simulatePolicy: (input) => postJson<PolicySimulation>("/api/policies/simulate", input, csrfToken), simulatePolicy: (input) => postJson<PolicySimulation>("/api/policies/simulate", input, csrfToken),
testTool: (input) => postJson<ToolReadinessResult>("/api/tools/test", input, csrfToken), testTool: (input) => postJson<ToolReadinessResult>("/api/tools/test", input, csrfToken),
toolDefinition: (canonicalName) => getJson<ToolDefinition>(`/api/tool-definitions/${encodeURIComponent(canonicalName)}`), toolDefinition: (canonicalName) => getJson<ToolDefinition>(`/api/tool-definitions/${encodeURIComponent(canonicalName)}`),
@@ -36,6 +37,20 @@ export function createApiClient(): ManagementApiClient { let csrfToken: string |
applyCatalog: (input) => postJson<ClientActionResult>("/api/catalog/apply", input, csrfToken), applyCatalog: (input) => postJson<ClientActionResult>("/api/catalog/apply", input, csrfToken),
clientAction: async (id, action) => { await postJson(`/api/client-management/${encodeURIComponent(id)}/${action}`, {}, csrfToken); return { accepted: true, message: `Client ${action} completed.` }; }, clientAction: async (id, action) => { await postJson(`/api/client-management/${encodeURIComponent(id)}/${action}`, {}, csrfToken); return { accepted: true, message: `Client ${action} completed.` }; },
createClient: (input: { name: string; project: string; environment: string; kind: OnboardingClientKind }) => postJson<ClientOnboardingResult>("/api/client-management", input, csrfToken), createClient: (input: { name: string; project: string; environment: string; kind: OnboardingClientKind }) => postJson<ClientOnboardingResult>("/api/client-management", input, csrfToken),
generatedConfig: (id) => getJson<{ clientId: string; endpoint: string; scopes: string[] }>(`/api/client-management/${encodeURIComponent(id)}/config`), generatedConfig: async (id) => {
const raw = await getJson<ClientOnboardingResult["config"]>(`/api/client-management/${encodeURIComponent(id)}/config`);
return {
clientId: raw.clientId,
kind: raw.kind,
protocolVersion: raw.protocolVersion,
endpoint: raw.endpoint,
transport: raw.transport,
scopes: raw.scopes,
...(typeof raw.environmentVariable === "string" ? { environmentVariable: raw.environmentVariable } : {}),
...(typeof raw.bridgeCommand === "string" ? { bridgeCommand: raw.bridgeCommand } : {}),
};
},
clientOnboardingStatus: (id) => getJson<ClientOnboardingStatus>(`/api/client-management/${encodeURIComponent(id)}/onboarding-status`),
verifyClientOnboarding: (id) => postJson<ClientOnboardingStatus>(`/api/client-management/${encodeURIComponent(id)}/verify`, {}, csrfToken),
}; } }; }
export function safeApiMessage(error: unknown): string { return error instanceof ApiError ? error.message : "The control API returned no usable data."; } export function safeApiMessage(error: unknown): string { return error instanceof ApiError ? error.message : "The control API returned no usable data."; }
+75 -21
View File
@@ -1,10 +1,10 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { DashboardSnapshot, ManagementApiClient, ManagementRole, SessionStatus } from "./contracts"; import type { ClientOnboardingResult, DashboardSnapshot, ManagementApiClient, ManagementRole, SessionStatus } from "./contracts";
import { routeTitle } from "./contracts"; import { routeTitle } from "./contracts";
import { ApiError, createApiClient, safeApiMessage } from "./api"; import { ApiError, createApiClient, safeApiMessage } from "./api";
import { RbacProvider } from "./rbac-context"; import { RbacProvider } from "./rbac-context";
import { can } from "./rbac"; import { can } from "./rbac";
import { basePath, resourceKind, usePath } from "./routing"; import { basePath, connectorDetailId, resourceKind, usePath } from "./routing";
import { Brand } from "./components/Brand"; import { Brand } from "./components/Brand";
import { CommandMenu } from "./components/CommandMenu"; import { CommandMenu } from "./components/CommandMenu";
import { ErrorPanel } from "./components/ErrorPanel"; import { ErrorPanel } from "./components/ErrorPanel";
@@ -19,6 +19,8 @@ import { Operations } from "./pages/Operations";
import { ProjectDetail } from "./pages/ProjectDetail"; import { ProjectDetail } from "./pages/ProjectDetail";
import { ResourcePage } from "./pages/ResourcePage"; import { ResourcePage } from "./pages/ResourcePage";
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from "./pages/SettingsPage";
import { ConnectorDetail } from "./pages/ConnectorDetail";
import { focusableElements, isolateOutside, lockBodyScroll, trapTab } from "./components/modal-focus";
export function App({ api: apiProp }: { api?: ManagementApiClient }) { export function App({ api: apiProp }: { api?: ManagementApiClient }) {
const [api] = useState<ManagementApiClient>(() => apiProp ?? createApiClient()); const [api] = useState<ManagementApiClient>(() => apiProp ?? createApiClient());
@@ -31,19 +33,24 @@ export function App({ api: apiProp }: { api?: ManagementApiClient }) {
const [subject, setSubject] = useState<string | null>(null); const [subject, setSubject] = useState<string | null>(null);
const [navOpen, setNavOpen] = useState(false); const [navOpen, setNavOpen] = useState(false);
const [commandOpen, setCommandOpen] = useState(false); const [commandOpen, setCommandOpen] = useState(false);
const [createdClient, setCreatedClient] = useState<ClientOnboardingResult["client"] | null>(null);
const identitySubject = useRef<string | null>(null);
identitySubject.current = session === "authenticated" ? subject : null;
const navTriggerRef = useRef<HTMLButtonElement>(null); const navTriggerRef = useRef<HTMLButtonElement>(null);
const title = useMemo(() => routeTitle(basePath(path)), [path]); const navCloseRef = useRef<HTMLButtonElement>(null);
const navSidebarRef = useRef<HTMLElement>(null);
const navBackdropRef = useRef<HTMLButtonElement>(null);
const [compactNavigation, setCompactNavigation] = useState(() => window.matchMedia("(max-width: 840px)").matches);
const title = useMemo(() => connectorDetailId(path) ? "Connector detail" : routeTitle(basePath(path)), [path]);
const clearIdentity = () => { const clearIdentity = () => {
setRoles([]); setRoles([]);
setSnapshot(null); setSnapshot(null);
setSubject(null); setSubject(null);
setCreatedClient(null);
}; };
const closeNavigation = (restoreFocus = true) => { const closeNavigation = () => setNavOpen(false);
setNavOpen(false);
if (restoreFocus) window.setTimeout(() => navTriggerRef.current?.focus(), 0);
};
const load = async () => { const load = async () => {
setSession("checking"); setSession("checking");
@@ -90,13 +97,45 @@ export function App({ api: apiProp }: { api?: ManagementApiClient }) {
useEffect(() => { void load(); }, []); useEffect(() => { void load(); }, []);
useEffect(() => { setNavOpen(false); }, [path]); useEffect(() => { setNavOpen(false); }, [path]);
useEffect(() => { useEffect(() => {
if (!navOpen) return; const media = window.matchMedia("(max-width: 840px)");
const onKeyDown = (event: KeyboardEvent) => { const update = () => {
if (event.key === "Escape") closeNavigation(); setCompactNavigation(media.matches);
if (!media.matches) setNavOpen(false);
}; };
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
useEffect(() => {
const sidebar = navSidebarRef.current;
if (!navOpen || !compactNavigation || !sidebar) return;
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const backdrop = navBackdropRef.current;
const restoreIsolation = isolateOutside(sidebar, backdrop ? [backdrop] : []);
const restoreScroll = lockBodyScroll();
const focusables = () => focusableElements(sidebar);
const onFocusIn = (event: FocusEvent) => {
if (event.target instanceof Node && sidebar.contains(event.target)) return;
(focusables()[0] ?? sidebar).focus();
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
setNavOpen(false);
} else {
trapTab(event, sidebar);
}
};
navCloseRef.current?.focus();
document.addEventListener("focusin", onFocusIn, true);
document.addEventListener("keydown", onKeyDown); document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown); return () => {
}, [navOpen]); document.removeEventListener("focusin", onFocusIn, true);
document.removeEventListener("keydown", onKeyDown);
restoreIsolation();
restoreScroll();
if (previouslyFocused?.isConnected) previouslyFocused.focus();
};
}, [navOpen, compactNavigation]);
useEffect(() => { useEffect(() => {
if (session !== "authenticated") return; if (session !== "authenticated") return;
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -114,6 +153,9 @@ export function App({ api: apiProp }: { api?: ManagementApiClient }) {
const kind = resourceKind(path); const kind = resourceKind(path);
const detail = basePath(path).startsWith("/projects/"); const detail = basePath(path).startsWith("/projects/");
const connectorId = connectorDetailId(path);
const scopeParams = new URLSearchParams(path.split("?")[1] ?? "");
const scopeKey = `${scopeParams.get("project") ?? ""}:${scopeParams.get("environment") ?? ""}`;
const canReadAudit = can(roles, "audit.read"); const canReadAudit = can(roles, "audit.read");
const active = (href: string) => basePath(path) === href || (href !== "/" && basePath(path).startsWith(`${href}/`)); const active = (href: string) => basePath(path) === href || (href !== "/" && basePath(path).startsWith(`${href}/`));
const authenticated = session === "authenticated"; const authenticated = session === "authenticated";
@@ -125,13 +167,15 @@ export function App({ api: apiProp }: { api?: ManagementApiClient }) {
: loadState === "error" : loadState === "error"
? <ErrorPanel message={error} expired={session === "expired"} retry={() => void load()} /> ? <ErrorPanel message={error} expired={session === "expired"} retry={() => void load()} />
: detail : detail
? <ProjectDetail path={path} navigate={navigate} api={api} /> ? <ProjectDetail key={basePath(path)} path={path} navigate={navigate} api={api} />
: connectorId
? <ConnectorDetail key={`${connectorId}:${scopeKey}`} path={path} navigate={navigate} api={api} />
: kind : kind
? <ResourcePage path={path} kind={kind} api={api} navigate={navigate} /> ? <ResourcePage key={`${kind}:${scopeKey}`} path={path} kind={kind} api={api} navigate={navigate} onClientCreated={(client) => { if (subject !== null && identitySubject.current === subject) setCreatedClient(client); }} />
: snapshot && basePath(path) === "/" : snapshot && basePath(path) === "/"
? <Dashboard snapshot={snapshot} reload={() => void load()} navigate={navigate} /> ? <Dashboard snapshot={snapshot} api={api} reload={() => void load()} navigate={navigate} />
: basePath(path) === "/operations" : basePath(path) === "/operations"
? <Operations path={path} navigate={navigate} snapshot={snapshot} /> ? <Operations path={path} navigate={navigate} snapshot={snapshot} api={api} />
: basePath(path) === "/settings" : basePath(path) === "/settings"
? <SettingsPage path={path} navigate={navigate} /> ? <SettingsPage path={path} navigate={navigate} />
: <ForbiddenPanel />; : <ForbiddenPanel />;
@@ -141,11 +185,20 @@ export function App({ api: apiProp }: { api?: ManagementApiClient }) {
<a className="skip-link" href="#main-content">Skip to main content</a> <a className="skip-link" href="#main-content">Skip to main content</a>
<div className={authenticated ? "app-shell" : "preauth-shell"}> <div className={authenticated ? "app-shell" : "preauth-shell"}>
{authenticated ? <> {authenticated ? <>
{navOpen ? <button type="button" className="nav-backdrop" onClick={() => closeNavigation()} aria-label="Dismiss navigation" /> : null} {navOpen ? <button ref={navBackdropRef} type="button" className="nav-backdrop" onClick={closeNavigation} aria-label="Dismiss navigation" aria-hidden="true" tabIndex={-1} /> : null}
<aside className={navOpen ? "sidebar sidebar-open" : "sidebar"} aria-label="Application sidebar"> <aside
ref={navSidebarRef}
className={navOpen ? "sidebar sidebar-open" : "sidebar"}
id="primary-navigation-panel"
aria-label="Application navigation"
role={compactNavigation && navOpen ? "dialog" : undefined}
aria-modal={compactNavigation && navOpen ? true : undefined}
aria-hidden={compactNavigation && !navOpen ? true : undefined}
inert={compactNavigation && !navOpen}
>
<div className="sidebar-head"> <div className="sidebar-head">
<a href="/" onClick={(event) => { event.preventDefault(); navigate("/"); }}><Brand /></a> <a href="/" onClick={(event) => { event.preventDefault(); navigate("/"); }}><Brand /></a>
<button type="button" className="nav-close" aria-label="Close navigation" onClick={() => closeNavigation()}>×</button> <button ref={navCloseRef} type="button" className="nav-close" aria-label="Close navigation" onClick={closeNavigation}>×</button>
</div> </div>
<nav aria-label="Primary navigation"> <nav aria-label="Primary navigation">
{navigation.map((group) => { {navigation.map((group) => {
@@ -170,7 +223,7 @@ export function App({ api: apiProp }: { api?: ManagementApiClient }) {
</> : null} </> : null}
<main className="main-content" id="main-content" tabIndex={-1}> <main className="main-content" id="main-content" tabIndex={-1}>
{authenticated ? <header className="topbar"> {authenticated ? <header className="topbar">
<button ref={navTriggerRef} type="button" className="nav-trigger" aria-label="Open navigation" aria-expanded={navOpen} onClick={() => setNavOpen(true)}><span aria-hidden="true" /><span aria-hidden="true" /><span aria-hidden="true" /></button> <button ref={navTriggerRef} type="button" className="nav-trigger" aria-label="Open navigation" aria-expanded={navOpen} aria-controls="primary-navigation-panel" onClick={() => setNavOpen(true)}><span aria-hidden="true" /><span aria-hidden="true" /><span aria-hidden="true" /></button>
<div className="topbar-title"><p className="eyebrow">Governance control plane</p><h1>{title}</h1></div> <div className="topbar-title"><p className="eyebrow">Governance control plane</p><h1>{title}</h1></div>
<div className="topbar-actions"> <div className="topbar-actions">
<button className="command-trigger" type="button" onClick={() => setCommandOpen(true)} aria-label="Open quick jump"> <button className="command-trigger" type="button" onClick={() => setCommandOpen(true)} aria-label="Open quick jump">
@@ -179,10 +232,11 @@ export function App({ api: apiProp }: { api?: ManagementApiClient }) {
<SessionMenu subject={subject} roles={roles} /> <SessionMenu subject={subject} roles={roles} />
</div> </div>
</header> : null} </header> : null}
{authenticated && createdClient ? <div className="notice notice-info client-created-notice" role="status"><span>Pending client {createdClient.name} created for {createdClient.project} / {createdClient.environment}.</span><a href={`/clients?onboard=${encodeURIComponent(createdClient.id)}`} onClick={(event) => { event.preventDefault(); navigate(`/clients?onboard=${encodeURIComponent(createdClient.id)}`); setCreatedClient(null); }}>Resume onboarding</a><button type="button" aria-label="Dismiss client creation notice" onClick={() => setCreatedClient(null)}>Dismiss</button></div> : null}
{content} {content}
</main> </main>
</div> </div>
{authenticated ? <CommandMenu open={commandOpen} canReadAudit={canReadAudit} navigate={navigate} onClose={() => setCommandOpen(false)} /> : null} {authenticated ? <CommandMenu open={commandOpen} api={api} canReadAudit={canReadAudit} navigate={navigate} onClose={() => setCommandOpen(false)} /> : null}
</RbacProvider> </RbacProvider>
); );
} }
+209 -22
View File
@@ -1,66 +1,253 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { availableNavigation } from "../navigation"; import type { ManagementApiClient, ResourceKind, ResourceRecord } from "../contracts";
import { ApiError, safeApiMessage } from "../api";
import { availableNavigation, navigation, type NavigationItem } from "../navigation";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
type DestinationOption = {
id: string;
href: string;
label: string;
description: string;
icon: NavigationItem["icon"];
source: "navigation" | "resource";
};
type DestinationGroup = { label: string; items: DestinationOption[] };
type SearchResults = Partial<Record<ResourceKind, ResourceRecord[]>>;
type SearchState = { query: string; status: "idle" | "loading" | "ready"; results: SearchResults; error: string | null };
const ENTITY_LABELS: Record<ResourceKind, string> = {
projects: "Projects",
servers: "MCP Servers",
tools: "Catalog",
connectors: "Connectors",
clients: "Clients",
policies: "Policies",
audit: "Audit",
};
const SEARCH_KINDS: readonly ResourceKind[] = ["projects", "servers", "tools", "connectors", "clients", "policies", "audit"];
const EMPTY_SEARCH: SearchState = { query: "", status: "idle", results: {}, error: null };
function recordDestination(kind: ResourceKind, record: ResourceRecord): string {
if (kind === "projects" || kind === "connectors") return `/${kind}/${encodeURIComponent(record.id)}`;
if (kind === "tools") {
const params = new URLSearchParams({ reopen: record.canonicalName ?? record.id });
if (record.project) params.set("project", record.project);
if (record.environment) params.set("environment", record.environment);
return `/tools?${params.toString()}`;
}
if (kind === "clients") return record.onboardingStage ? `/clients?onboard=${encodeURIComponent(record.id)}` : `/clients?q=${encodeURIComponent(record.id)}`;
const exactKey = kind === "servers" ? record.id : kind === "audit" ? record.traceId ?? record.name : record.name;
return `/${kind}?q=${encodeURIComponent(exactKey)}`;
}
function searchFailure(errors: unknown[]): string | null {
if (!errors.length) return null;
const unauthorized = errors.find((error) => error instanceof ApiError && error.status === 401);
if (unauthorized) return safeApiMessage(unauthorized);
if (errors.some((error) => error instanceof ApiError && error.status === 403)) {
return "Some results are unavailable to your role, so this search is incomplete.";
}
return errors.length === 1
? "One resource type could not be searched. Results may be incomplete."
: "Some resource types could not be searched. Results may be incomplete.";
}
export function CommandMenu({ export function CommandMenu({
open, open,
api,
canReadAudit, canReadAudit,
navigate, navigate,
onClose, onClose,
}: { }: {
open: boolean; open: boolean;
api: ManagementApiClient;
canReadAudit: boolean; canReadAudit: boolean;
navigate: (next: string) => void; navigate: (next: string) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(-1);
const [recentHrefs, setRecentHrefs] = useState<string[]>([]);
const [search, setSearch] = useState<SearchState>(EMPTY_SEARCH);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const items = useMemo(() => availableNavigation(canReadAudit), [canReadAudit]); const normalizedQuery = query.trim().toLowerCase();
const results = useMemo(() => { const allowedNavigation = useMemo(() => availableNavigation(canReadAudit), [canReadAudit]);
const needle = query.trim().toLowerCase(); const iconForKind = (kind: ResourceKind) => allowedNavigation.find((item) => item.href === `/${kind}`)?.icon ?? "overview";
if (!needle) return items;
return items.filter((item) => `${item.label} ${item.description}`.toLowerCase().includes(needle));
}, [items, query]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setQuery(""); setQuery("");
setActiveIndex(-1);
const focusTimer = window.setTimeout(() => inputRef.current?.focus(), 0); const focusTimer = window.setTimeout(() => inputRef.current?.focus(), 0);
return () => window.clearTimeout(focusTimer); return () => window.clearTimeout(focusTimer);
}, [open]); }, [open]);
const select = (href: string) => { useEffect(() => {
onClose(); if (!open) return;
navigate(href); const term = query.trim().toLowerCase();
if (term.length < 2) {
setSearch({ query: term, status: "idle", results: {}, error: null });
return;
}
let current = true;
setSearch({ query: term, status: "loading", results: {}, error: null });
const kinds = SEARCH_KINDS.filter((kind) => kind !== "audit" || canReadAudit);
const timer = window.setTimeout(() => {
void Promise.all(kinds.map(async (kind) => {
try {
const page = await api.resource(kind, { filter: term, limit: 6 });
return { kind, rows: page.rows, error: null as unknown };
} catch (error) {
return { kind, rows: [] as ResourceRecord[], error };
}
})).then((outcomes) => {
if (!current) return;
const results: SearchResults = {};
const errors: unknown[] = [];
for (const outcome of outcomes) {
if (outcome.error) errors.push(outcome.error);
else results[outcome.kind] = outcome.rows.slice(0, 6);
}
setSearch({ query: term, status: "ready", results, error: searchFailure(errors) });
setActiveIndex(-1);
});
}, 250);
return () => {
current = false;
window.clearTimeout(timer);
}; };
}, [api, canReadAudit, open, query]);
const navigationGroups = useMemo(() => navigation.flatMap((group) => {
const items = group.items
.filter((item) => allowedNavigation.some((allowed) => allowed.href === item.href))
.filter((item) => !normalizedQuery || `${item.label} ${item.description}`.toLowerCase().includes(normalizedQuery))
.map((item) => ({ id: `nav:${item.href}`, href: item.href, label: item.label, description: item.description, icon: item.icon, source: "navigation" as const }));
return items.length ? [{ label: group.label, items }] : [];
}), [allowedNavigation, normalizedQuery]);
const groups = useMemo<DestinationGroup[]>(() => {
const result: DestinationGroup[] = [];
if (!normalizedQuery) {
const recent = recentHrefs.flatMap((href) => {
const item = allowedNavigation.find((candidate) => candidate.href === href);
return item ? [{ id: `recent:${href}`, href, label: item.label, description: item.description, icon: item.icon, source: "navigation" as const }] : [];
});
if (recent.length) result.push({ label: "Recent destinations", items: recent });
}
result.push(...navigationGroups);
const hasCurrentSearch = search.query === normalizedQuery && search.status === "ready";
if (normalizedQuery.length >= 2 && hasCurrentSearch) {
for (const kind of SEARCH_KINDS) {
if (kind === "audit" && !canReadAudit) continue;
const rows = search.results[kind] ?? [];
if (!rows.length) continue;
result.push({
label: ENTITY_LABELS[kind],
items: rows.map((record) => ({
id: `resource:${kind}:${record.id}`,
href: recordDestination(kind, record),
label: record.name,
description: [record.project, record.summary].filter(Boolean).join(" · ") || ENTITY_LABELS[kind],
icon: iconForKind(kind),
source: "resource" as const,
})),
});
}
}
return result;
}, [allowedNavigation, canReadAudit, iconForKind, navigationGroups, normalizedQuery, recentHrefs, search]);
const options = useMemo(() => groups.flatMap((group) => group.items), [groups]);
const activeOptionId = activeIndex >= 0 && activeIndex < options.length ? `quick-jump-option-${activeIndex}` : undefined;
useEffect(() => {
if (!activeOptionId) return;
document.getElementById(activeOptionId)?.scrollIntoView?.({ block: "nearest" });
}, [activeOptionId]);
const select = (option: DestinationOption) => {
if (option.source === "navigation") {
setRecentHrefs((current) => [option.href, ...current.filter((href) => href !== option.href)].slice(0, 4));
}
onClose();
navigate(option.href);
};
const onInputKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowDown") {
if (!options.length) return;
event.preventDefault();
setActiveIndex((current) => (current + 1) % options.length);
} else if (event.key === "ArrowUp") {
if (!options.length) return;
event.preventDefault();
setActiveIndex((current) => current <= 0 ? options.length - 1 : current - 1);
} else if (event.key === "Enter" && activeIndex >= 0 && options[activeIndex]) {
event.preventDefault();
select(options[activeIndex]);
}
};
const currentSearch = search.query === normalizedQuery ? search : null;
const searching = normalizedQuery.length >= 2 && (!currentSearch || currentSearch.status === "loading");
const error = currentSearch?.status === "ready" ? currentSearch.error : null;
const empty = !searching && !error && options.length === 0;
return ( return (
<Modal open={open} title="Quick jump" onClose={onClose} className="command-modal"> <Modal open={open} title="Quick jump" onClose={onClose} className="command-modal">
<label className="command-search"> <label className="command-search">
<span className="sr-only">Find a workspace</span> <span className="sr-only">Find a workspace or resource</span>
<Icon name="search" size={17} /> <Icon name="search" size={17} />
<input <input
ref={inputRef} ref={inputRef}
role="combobox"
aria-autocomplete="list"
aria-haspopup="listbox"
aria-expanded="true"
aria-controls="command-results"
aria-activedescendant={activeOptionId}
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onKeyDown={onInputKeyDown}
placeholder="Find projects, catalog, policies…" onChange={(event) => { setQuery(event.target.value); setActiveIndex(-1); }}
placeholder="Find projects, connectors, clients…"
autoComplete="off" autoComplete="off"
/> />
<kbd>Esc</kbd> <kbd>Esc</kbd>
</label> </label>
<div className="command-results" aria-live="polite"> <div className="command-results" id="command-results" role="listbox" aria-label="Quick jump destinations" aria-live="polite">
{results.length ? results.map((item) => ( {groups.map((group) => <section className="command-group" role="group" aria-label={group.label} key={group.label}>
<button type="button" className="command-result" key={item.href} onClick={() => select(item.href)}> <h3>{group.label}</h3>
{group.items.map((item) => {
const index = options.findIndex((option) => option.id === item.id);
return <button
type="button"
role="option"
tabIndex={-1}
id={`quick-jump-option-${index}`}
aria-selected={activeIndex === index}
className={activeIndex === index ? "command-result command-result-active" : "command-result"}
key={item.id}
onMouseMove={() => setActiveIndex(index)}
onClick={() => select(item)}
>
<span className="command-result-icon"><Icon name={item.icon} /></span> <span className="command-result-icon"><Icon name={item.icon} /></span>
<span><strong>{item.label}</strong><small>{item.description}</small></span> <span><strong>{item.label}</strong><small>{item.description}</small></span>
<Icon name="arrow" size={16} /> <Icon name="arrow" size={16} />
</button> </button>;
)) : ( })}
<div className="command-empty"><strong>No workspace found</strong><span>Try a broader name or capability.</span></div> </section>)}
)} {searching ? <div className="command-empty" role="status"><strong>Searching platform records…</strong><span>Checking destinations available to your role.</span></div> : null}
{error ? <div className="command-search-error" role="alert">{error}</div> : null}
{normalizedQuery.length === 1 ? <div className="command-empty"><strong>Keep typing to search resources</strong><span>Enter at least two characters to search platform records.</span></div> : null}
{empty ? <div className="command-empty"><strong>No destinations found</strong><span>Try a broader name or capability.</span></div> : null}
</div> </div>
<p className="command-hint">Navigate without leaving the keyboard. Access-controlled destinations remain hidden.</p> <p className="command-hint">Use ↑ and ↓ to move, Enter to open, and Esc to close. Destinations follow your current role.</p>
</Modal> </Modal>
); );
} }
+16 -5
View File
@@ -19,7 +19,7 @@ const ALL_COLUMNS: Record<ResourceKind, ColumnKey[]> = {
export function columnsFor(kind: ResourceKind): ColumnKey[] { return ALL_COLUMNS[kind]; } export function columnsFor(kind: ResourceKind): ColumnKey[] { return ALL_COLUMNS[kind]; }
export function DataTable({ export function DataTable({
data, kind, density, visibleColumns, onClientAction, onConfig, onTest, data, kind, density, visibleColumns, onClientAction, onConfig, onTest, navigate, busyClientId,
}: { }: {
data: ResourcePage; data: ResourcePage;
kind: ResourceKind; kind: ResourceKind;
@@ -28,14 +28,25 @@ export function DataTable({
onClientAction: (id: string, action: "disable" | "revoke") => void; onClientAction: (id: string, action: "disable" | "revoke") => void;
onConfig: (id: string) => void; onConfig: (id: string) => void;
onTest: (row: ResourceRecord) => void; onTest: (row: ResourceRecord) => void;
navigate?: (next: string) => void;
busyClientId?: string | null;
}) { }) {
const show = (column: ColumnKey) => visibleColumns.includes(column); const show = (column: ColumnKey) => visibleColumns.includes(column);
const [confirmingAction, setConfirmingAction] = useState<{ id: string; action: "disable" | "revoke"; name: string } | null>(null); const [confirmingAction, setConfirmingAction] = useState<{ id: string; action: "disable" | "revoke"; name: string } | null>(null);
const actions = (row: ResourceRecord) => { const actions = (row: ResourceRecord) => {
if (kind === "clients" && show("actions")) return <div className="inline-actions"><button className="button button-small" onClick={() => setConfirmingAction({ id: row.id, action: "disable", name: row.name })} disabled={row.status !== "healthy"}>Disable</button><button className="button button-small button-danger" onClick={() => setConfirmingAction({ id: row.id, action: "revoke", name: row.name })} disabled={row.status === "unavailable"}>Revoke</button><button className="button button-small button-secondary" onClick={() => onConfig(row.id)}>Config</button></div>; if (kind === "clients" && show("actions")) {
const disabled = busyClientId === row.id;
const resume = `/clients?onboard=${encodeURIComponent(row.id)}`;
const secondary = <><button type="button" className="button button-small button-secondary" onClick={() => setConfirmingAction({ id: row.id, action: "disable", name: row.name })} disabled={disabled || row.status !== "healthy"}>Disable</button><button type="button" className="button button-small button-danger" onClick={() => setConfirmingAction({ id: row.id, action: "revoke", name: row.name })} disabled={disabled || row.status === "unavailable"}>Revoke</button>{row.onboardingStage ? <a className="button button-small button-secondary" href={resume} onClick={navigate ? (event) => { event.preventDefault(); navigate(resume); } : undefined}>Onboarding</a> : null}</>;
return <div className="inline-actions client-actions"><button type="button" className="button button-small button-secondary" disabled={disabled} onClick={() => onConfig(row.id)}>{disabled ? "Working…" : "Config"}</button><div className="desktop-client-actions">{secondary}</div><details className="mobile-client-actions"><summary>More actions</summary><div>{secondary}</div></details></div>;
}
if (kind === "tools" && show("actions")) return <div className="inline-actions"><button className="button button-small button-secondary" onClick={() => onTest(row)}>Readiness</button></div>; if (kind === "tools" && show("actions")) return <div className="inline-actions"><button className="button button-small button-secondary" onClick={() => onTest(row)}>Readiness</button></div>;
return null; return null;
}; };
const name = (row: ResourceRecord) => {
const href = kind === "projects" ? `/projects/${encodeURIComponent(row.id)}` : kind === "connectors" ? `/connectors/${encodeURIComponent(row.id)}` : kind === "clients" ? row.onboardingStage ? `/clients?onboard=${encodeURIComponent(row.id)}` : `/clients?q=${encodeURIComponent(row.id)}` : null;
return href ? <a className="record-link" href={href} onClick={navigate ? (event) => { event.preventDefault(); navigate(href); } : undefined}><strong className="long-name">{row.name}</strong></a> : <strong className="long-name">{row.name}</strong>;
};
return ( return (
<Fragment> <Fragment>
<div className={`table-wrap desktop-data-table table-kind-${kind} density-${density}`} role="region" tabIndex={0} aria-label={`${kind} records table`}> <div className={`table-wrap desktop-data-table table-kind-${kind} density-${density}`} role="region" tabIndex={0} aria-label={`${kind} records table`}>
@@ -55,10 +66,10 @@ export function DataTable({
<tbody> <tbody>
{data.rows.map((row) => ( {data.rows.map((row) => (
<tr key={row.id}> <tr key={row.id}>
{show("name") && <td><strong className="long-name">{row.name}</strong><small>{row.id}</small></td>} {show("name") && <td>{name(row)}<small>{row.id}</small></td>}
{show("status") && <td><StatusPill value={row.status} /></td>} {show("status") && <td><StatusPill value={row.status} /></td>}
{show("project") && <td>{row.project ?? "—"}</td>} {show("project") && <td>{row.project ?? "—"}</td>}
{show("summary") && <td>{row.summary}</td>} {show("summary") && <td>{row.summary}{row.onboardingStage ? <small>{row.onboardingStage}</small> : null}</td>}
{kind === "tools" && show("risk") && <td>{row.risk ?? "R0"}</td>} {kind === "tools" && show("risk") && <td>{row.risk ?? "R0"}</td>}
{kind === "audit" && show("decision") && <td>{row.decision ?? "unknown"}{row.traceId && <small>{row.traceId}</small>}</td>} {kind === "audit" && show("decision") && <td>{row.decision ?? "unknown"}{row.traceId && <small>{row.traceId}</small>}</td>}
{(kind === "clients" || kind === "tools") && show("actions") && <td>{actions(row)}</td>} {(kind === "clients" || kind === "tools") && show("actions") && <td>{actions(row)}</td>}
@@ -68,7 +79,7 @@ export function DataTable({
</table> </table>
</div> </div>
<div className="mobile-record-list" aria-label={`${kind} records`}> <div className="mobile-record-list" aria-label={`${kind} records`}>
{data.rows.map((row) => <article className="mobile-record" key={row.id}><div className="mobile-record-heading"><div><strong>{row.name}</strong><small>{row.id}</small></div>{show("status") && <StatusPill value={row.status} />}</div><dl>{show("project") && <><dt>Project</dt><dd>{row.project ?? "—"}</dd></>}{show("summary") && <><dt>Summary</dt><dd>{row.summary}</dd></>}{kind === "tools" && show("risk") && <><dt>Risk</dt><dd><span className="risk-label" title="Risk classification">{row.risk ?? "R0"}</span></dd></>}{kind === "audit" && show("decision") && <><dt>Decision / trace</dt><dd>{row.decision ?? "unknown"}{row.traceId && <small>{row.traceId}</small>}</dd></>}</dl>{actions(row)}</article>)} {data.rows.map((row) => <article className="mobile-record" key={row.id}><div className="mobile-record-heading"><div>{name(row)}<small>{row.id}</small></div>{show("status") && <StatusPill value={row.status} />}</div><dl>{show("project") && <><dt>Project</dt><dd>{row.project ?? "—"}</dd></>}{show("summary") && <><dt>Summary</dt><dd>{row.summary}{row.onboardingStage ? <small>{row.onboardingStage}</small> : null}</dd></>}{kind === "tools" && show("risk") && <><dt>Risk</dt><dd><span className="risk-label" title="Risk classification">{row.risk ?? "R0"}</span></dd></>}{kind === "audit" && show("decision") && <><dt>Decision / trace</dt><dd>{row.decision ?? "unknown"}{row.traceId && <small>{row.traceId}</small>}</dd></>}</dl>{actions(row)}</article>)}
</div> </div>
<Modal <Modal
open={confirmingAction !== null} open={confirmingAction !== null}
+38 -42
View File
@@ -1,4 +1,6 @@
import { useEffect, useId, useRef, type ReactNode } from "react"; import { useEffect, useId, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { focusableElements, isolateOutside, lockBodyScroll, trapTab } from "./modal-focus";
export function Modal({ open, title, onClose, children, className = "" }: { open: boolean; title: string; onClose: () => void; children: ReactNode; className?: string }) { export function Modal({ open, title, onClose, children, className = "" }: { open: boolean; title: string; onClose: () => void; children: ReactNode; className?: string }) {
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
@@ -7,56 +9,49 @@ export function Modal({ open, title, onClose, children, className = "" }: { open
onCloseRef.current = onClose; onCloseRef.current = onClose;
useEffect(() => { useEffect(() => {
if (!open) return; const dialog = dialogRef.current;
// Kept in its own effect, keyed only on `open` (not `onClose`): if this const overlay = dialog?.parentElement;
// ran on every `onClose` identity change (callers commonly pass a fresh if (!open || !dialog || !overlay) return;
// inline arrow each render), it would re-capture document.activeElement
// after focus had already moved into the dialog, overwriting the real
// trigger element with the dialog itself.
const previouslyFocused = document.activeElement as HTMLElement | null;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialogRef.current?.focus();
return () => {
document.body.style.overflow = previousOverflow;
previouslyFocused?.focus();
};
}, [open]);
useEffect(() => { // Capture the trigger before moving focus into the body-level portal. This
if (!open) return; // effect only follows `open`, so fresh inline onClose callbacks cannot
// replace the original trigger during a dialog render.
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const restoreIsolation = isolateOutside(overlay);
const restoreScroll = lockBodyScroll();
let lastFocused: HTMLElement = dialog;
const focusables = () => focusableElements(dialog);
const onFocusIn = (event: FocusEvent) => {
if (event.target instanceof Node && dialog.contains(event.target)) {
lastFocused = event.target instanceof HTMLElement ? event.target : dialog;
return;
}
(lastFocused.isConnected && dialog.contains(lastFocused) ? lastFocused : focusables()[0] ?? dialog).focus();
};
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
onCloseRef.current(); onCloseRef.current();
return; } else {
} trapTab(event, dialog);
if (event.key !== "Tab" || !dialogRef.current) return;
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
)).filter((element) => !element.hasAttribute("hidden"));
if (!focusable.length) {
event.preventDefault();
dialogRef.current.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
} }
}; };
dialog.focus();
document.addEventListener("focusin", onFocusIn, true);
document.addEventListener("keydown", onKeyDown); document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown); return () => {
document.removeEventListener("focusin", onFocusIn, true);
document.removeEventListener("keydown", onKeyDown);
restoreIsolation();
restoreScroll();
if (previouslyFocused?.isConnected) previouslyFocused.focus();
};
}, [open]); }, [open]);
if (!open) return null; if (!open || typeof document === "undefined") return null;
return ( return createPortal(
<div className="modal-overlay" onClick={onClose}> <div className="modal-overlay" data-modal-overlay onClick={(event) => { if (event.target === event.currentTarget) onCloseRef.current(); }}>
<div <div
className={`modal ${className}`.trim()} className={`modal ${className}`.trim()}
role="dialog" role="dialog"
@@ -68,10 +63,11 @@ export function Modal({ open, title, onClose, children, className = "" }: { open
> >
<div className="modal-heading"> <div className="modal-heading">
<h2 id={titleId}>{title}</h2> <h2 id={titleId}>{title}</h2>
<button className="modal-close" onClick={onClose} aria-label="Close dialog">×</button> <button className="modal-close" type="button" onClick={() => onCloseRef.current()} aria-label="Close dialog">×</button>
</div> </div>
<div className="modal-body">{children}</div> <div className="modal-body">{children}</div>
</div> </div>
</div> </div>,
document.body,
); );
} }
@@ -0,0 +1,44 @@
import { useEffect, useState } from "react";
import type { ManagementApiClient } from "../contracts";
import { formatBrusselsTime } from "../format";
import { readToolHistory, TOOL_HISTORY_EVENT, type ToolRunMetadata } from "../tool-history";
function reopenUrl(run: ToolRunMetadata): string {
const params = new URLSearchParams({ reopen: run.tool, project: run.project, environment: run.environment });
return `/tools?${params.toString()}`;
}
export function RecentToolRuns({ navigate }: {
/** Accepted so callers can mount this beside API-backed tool content; recent
* runs deliberately read only the bounded browser metadata store. */
api?: ManagementApiClient;
navigate: (next: string) => void;
}) {
const [runs, setRuns] = useState<ToolRunMetadata[]>(() => readToolHistory());
useEffect(() => {
const reload = () => setRuns(readToolHistory());
window.addEventListener(TOOL_HISTORY_EVENT, reload);
window.addEventListener("storage", reload);
window.addEventListener("focus", reload);
return () => {
window.removeEventListener(TOOL_HISTORY_EVENT, reload);
window.removeEventListener("storage", reload);
window.removeEventListener("focus", reload);
};
}, []);
return <section className="panel recent-tool-runs" aria-labelledby="recent-tool-runs-title">
<div className="panel-heading"><div><p className="eyebrow">Operator history</p><h3 id="recent-tool-runs-title">Recent Tool Runs</h3></div><span className="tag">Session metadata only</span></div>
<p className="muted">Recent success and controlled-error records include scope and trace only. Arguments, results, errors and credentials are never retained here.</p>
{runs.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span><strong>No recent tool runs</strong><small>Completed invocations in this browser session will appear here.</small></span></div> : <ol className="recent-run-list">
{runs.map((run) => <li className="recent-run" key={`${run.traceId}:${run.at}`}>
<div className="recent-run-main">
<div className="recent-run-heading"><strong className="recent-run-tool">{run.tool}</strong><span className={run.ok ? "recent-run-outcome recent-run-success" : "recent-run-outcome recent-run-error"}>{run.ok ? "Success" : "Controlled error"}</span></div>
<dl><div><dt>Project</dt><dd>{run.project}</dd></div><div><dt>Environment</dt><dd>{run.environment}</dd></div><div><dt>Time</dt><dd>{formatBrusselsTime(run.at)}</dd></div><div><dt>Trace</dt><dd><code>{run.traceId}</code></dd></div></dl>
</div>
<button className="button button-small button-secondary recent-run-open" onClick={() => navigate(reopenUrl(run))}>Open tool context</button>
</li>)}
</ol>}
</section>;
}
@@ -0,0 +1,104 @@
import { useId, type ChangeEvent } from "react";
import type { ToolInputProperty, ToolInputSchema } from "../schema-input";
import { NULL_INPUT_VALUE } from "../schema-input";
function inputType(property: ToolInputProperty): string {
const types = Array.isArray(property.type) ? property.type : [property.type ?? "string"];
return types.find((type) => type !== "null") ?? "string";
}
function optionKey(value: unknown): string {
try {
return JSON.stringify(value) ?? String(value);
} catch {
return String(value);
}
}
function optionLabel(value: unknown): string {
return typeof value === "string" ? value : optionKey(value);
}
export function SchemaInputFields({
schema,
rawValues,
errors,
onChange,
disabled = false,
}: {
schema: ToolInputSchema;
rawValues: Record<string, string>;
errors: Record<string, string>;
onChange: (name: string, value: string) => void;
disabled?: boolean;
}) {
const idPrefix = useId();
const properties = Object.entries(schema.properties ?? {});
const required = new Set(schema.required ?? []);
if (properties.length === 0) return <p className="muted schema-input-empty">This tool takes no arguments.</p>;
return <div className="form-grid readiness-form schema-inputs">
{properties.map(([name, property]) => {
const id = `${idPrefix}-${name}`;
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
const isRequired = required.has(name);
const raw = rawValues[name] ?? "";
const error = errors[name];
const type = inputType(property);
const nullableByType = Array.isArray(property.type) && property.type.includes("null");
const nullAllowed = nullableByType && (!property.enum || property.enum.some((value) => value === null));
const setValue = (value: string) => onChange(name, value);
const fieldProps = {
id,
"aria-required": isRequired,
"aria-invalid": Boolean(error),
"aria-describedby": error ? `${hintId} ${errorId}` : hintId,
disabled,
value: raw,
onChange: (event: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => setValue(event.currentTarget.value),
};
const isNull = raw === NULL_INPUT_VALUE;
return <div className="schema-input-field" key={name}>
<label className="schema-input-label" htmlFor={id}>{property.title ?? name}{isRequired ? <span className="required-mark" aria-hidden="true"> *</span> : null}</label>
{property.enum ? <select {...fieldProps}>
<option value="">Select a value…</option>
{property.enum.map((option) => <option key={optionKey(option)} value={optionKey(option)}>{optionLabel(option)}</option>)}
</select> : type === "boolean" ? <select {...fieldProps}>
<option value="">Choose…</option>
<option value="true">Yes</option>
<option value="false">No</option>
{nullAllowed ? <option value={NULL_INPUT_VALUE}>Null</option> : null}
</select> : <>
{nullAllowed ? <select
className="schema-null-choice"
aria-label={`${property.title ?? name} value`}
value={isNull ? NULL_INPUT_VALUE : "value"}
disabled={disabled}
onChange={(event) => setValue(event.currentTarget.value === NULL_INPUT_VALUE ? NULL_INPUT_VALUE : raw === NULL_INPUT_VALUE ? "" : raw)}
><option value="value">Set a value</option><option value={NULL_INPUT_VALUE}>Null</option></select> : null}
{isNull ? <span className="schema-null-value">Null will be sent for this field.</span> : type === "object" || type === "array" ? <textarea
{...fieldProps}
rows={5}
spellCheck={false}
placeholder={type === "array" ? "Enter a JSON array" : "Enter a JSON object"}
/> : <input
{...fieldProps}
type={type === "integer" || type === "number" ? "number" : "text"}
step={type === "integer" ? 1 : type === "number" ? "any" : undefined}
min={type === "integer" || type === "number" ? property.minimum : undefined}
max={type === "integer" || type === "number" ? property.maximum : undefined}
minLength={type === "string" ? property.minLength : undefined}
maxLength={type === "string" ? property.maxLength : undefined}
disabled={isNull}
placeholder={property.description ? "Enter a value" : undefined}
/>}
</>}
<small id={hintId}>{property.description ? `${property.description} ` : ""}{isRequired ? "Required." : "Optional."}</small>
{error ? <small className="schema-field-error" id={errorId} role="alert">{error}</small> : null}
</div>;
})}
</div>;
}
+15 -1
View File
@@ -17,5 +17,19 @@ export function SessionMenu({ subject, roles }: { subject: string | null; roles:
setBusy(false); setBusy(false);
} }
}; };
return <div className="session-menu"><span className="tag">Authenticated</span><div className="session-identity"><span className="avatar" aria-hidden="true">{initial}</span><span><strong>{subject ?? "Authenticated user"}</strong><small>{roles.join(" · ") || "management"}</small></span></div><button className="button button-small button-secondary" type="button" disabled={busy} onClick={() => void signOut()}>{busy ? "Signing out…" : "Sign out"}</button>{error ? <span className="session-error" role="alert">{error}</span> : null}</div>; return <div className="session-menu">
<span className="tag">Authenticated</span>
<div className="session-identity">
<span className="avatar" aria-hidden="true">{initial}</span>
<span><strong>{subject ?? "Authenticated user"}</strong><small>{roles.join(" · ") || "management"}</small></span>
</div>
<button
className="button button-small button-secondary session-signout"
type="button"
disabled={busy}
aria-busy={busy}
onClick={() => void signOut()}
>{busy ? "Signing out…" : "Sign out"}</button>
{error ? <span className="session-error" role="alert">{error}</span> : null}
</div>;
} }
+114
View File
@@ -0,0 +1,114 @@
const FOCUSABLE_SELECTOR = [
"a[href]",
"area[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"iframe",
"object",
"embed",
"[contenteditable='true']",
"[tabindex]:not([tabindex='-1'])",
].join(",");
export function focusableElements(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter((element) => {
if (element.hidden || element.closest("[hidden], [inert], [aria-hidden='true']")) return false;
const closedDetails = element.closest("details:not([open])");
if (closedDetails && !closedDetails.querySelector(":scope > summary")?.contains(element)) return false;
let ancestor: HTMLElement | null = element;
while (ancestor) {
const style = window.getComputedStyle(ancestor);
if (style.display === "none" || style.visibility === "hidden" || style.contentVisibility === "hidden") return false;
if (ancestor === container) break;
ancestor = ancestor.parentElement;
}
return element.getClientRects().length > 0;
});
}
export function trapTab(event: KeyboardEvent, container: HTMLElement): void {
if (event.key !== "Tab") return;
const focusable = focusableElements(container);
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (!first || !last) {
event.preventDefault();
container.focus();
return;
}
if (!container.contains(active)) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
} else if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
}
/** Make everything outside a focus scope unavailable until its cleanup runs. */
export function isolateOutside(container: HTMLElement, interactiveExceptions: HTMLElement[] = []): () => void {
const body = document.body;
const changed = new Map<HTMLElement, { inert: boolean; ariaHidden: string | null }>();
const protect = (element: HTMLElement) => {
if (changed.has(element)) return;
changed.set(element, { inert: element.inert, ariaHidden: element.getAttribute("aria-hidden") });
element.inert = true;
element.setAttribute("aria-hidden", "true");
};
const isException = (element: HTMLElement) => interactiveExceptions.some((exception) => element === exception || element.contains(exception));
let branch: HTMLElement = container;
let parent = branch.parentElement;
while (parent) {
for (const sibling of Array.from(parent.children)) {
if (sibling !== branch && sibling instanceof HTMLElement && !isException(sibling)) protect(sibling);
}
if (parent === body) break;
branch = parent;
parent = parent.parentElement;
}
return () => {
for (const [element, original] of changed) {
element.inert = original.inert;
if (original.ariaHidden === null) element.removeAttribute("aria-hidden");
else element.setAttribute("aria-hidden", original.ariaHidden);
}
};
}
/** Lock document scrolling while preserving inline styles and the scroll position. */
export function lockBodyScroll(): () => void {
const body = document.body;
const root = document.documentElement;
const scrollX = window.scrollX;
const scrollY = window.scrollY;
const styleNames = ["overflow", "position", "top", "left", "right", "width"] as const;
const bodyStyles = new Map(styleNames.map((name) => [name, { value: body.style.getPropertyValue(name), priority: body.style.getPropertyPriority(name) }]));
const rootOverflow = { value: root.style.getPropertyValue("overflow"), priority: root.style.getPropertyPriority("overflow") };
body.style.overflow = "hidden";
body.style.position = "fixed";
body.style.top = `${-scrollY}px`;
body.style.left = `${-scrollX}px`;
body.style.right = "0";
body.style.width = "100%";
root.style.overflow = "hidden";
return () => {
for (const [name, original] of bodyStyles) {
if (original.value) body.style.setProperty(name, original.value, original.priority);
else body.style.removeProperty(name);
}
if (rootOverflow.value) root.style.setProperty("overflow", rootOverflow.value, rootOverflow.priority);
else root.style.removeProperty("overflow");
window.scrollTo(scrollX, scrollY);
};
}
+60 -7
View File
@@ -6,7 +6,11 @@ export type PrincipalKind = "management_user" | "machine_client";
export interface SessionMetadata { subject: string; clientId: string | null; kind: PrincipalKind; roles: ManagementRole[]; expiresAt: string; } export interface SessionMetadata { subject: string; clientId: string | null; kind: PrincipalKind; roles: ManagementRole[]; expiresAt: string; }
export interface SessionInfo { authenticated: boolean; csrf_token?: string; session?: SessionMetadata; } export interface SessionInfo { authenticated: boolean; csrf_token?: string; session?: SessionMetadata; }
export interface ProjectStatus { slug: string; name: string; environment: string; health: Health; detail: string; } export interface ProjectStatus { slug: string; name: string; environment: string; health: Health; detail: string; }
export interface ConnectorStatus { name: string; project: string; state: Health; detail: string; } export interface ConnectorStatus {
id: string; name: string; project: string; state: Health; detail: string;
registrationState: "registered" | "invalid";
readiness: { state: Health; detail: string; observedAt: string | null; source: "not-probed" };
}
export interface DashboardSnapshot { export interface DashboardSnapshot {
generatedAt: string; staleAfterSeconds: number; generatedAt: string; staleAfterSeconds: number;
health: { hub: Health; gateway: Health; detail: string }; health: { hub: Health; gateway: Health; detail: string };
@@ -18,9 +22,9 @@ export interface DashboardSnapshot {
} }
export interface ApiClient { session(): Promise<SessionInfo>; dashboard(): Promise<DashboardSnapshot>; } export interface ApiClient { session(): Promise<SessionInfo>; dashboard(): Promise<DashboardSnapshot>; }
export type ResourceKind = "projects" | "servers" | "tools" | "connectors" | "clients" | "policies" | "audit"; export type ResourceKind = "projects" | "servers" | "tools" | "connectors" | "clients" | "policies" | "audit";
export interface PageQuery { filter: string; cursor?: string; limit: number; } export interface PageQuery { filter: string; cursor?: string; limit: number; project?: string; environment?: string; }
export interface PageInfo { nextCursor?: string; total: number; } export interface PageInfo { nextCursor?: string; total: number; }
export interface ResourceRecord { id: string; name: string; status: Health; summary: string; project?: string; environment?: string; risk?: string; decision?: string; traceId?: string; canonicalName?: string; } export interface ResourceRecord { id: string; name: string; status: Health; summary: string; project?: string; environment?: string; risk?: string; decision?: string; traceId?: string; canonicalName?: string; onboardingStage?: string; }
export interface ResourcePage { kind: ResourceKind; generatedAt: string; rows: ResourceRecord[]; page: PageInfo; } export interface ResourcePage { kind: ResourceKind; generatedAt: string; rows: ResourceRecord[]; page: PageInfo; }
export type PolicyDecision = "allow" | "allow_with_preview" | "allow_with_approval" | "allow_if_conditions" | "enforce" | "deny" | "deny_and_alert"; export type PolicyDecision = "allow" | "allow_with_preview" | "allow_with_approval" | "allow_if_conditions" | "enforce" | "deny" | "deny_and_alert";
export interface PolicyTraceStep { phase: string; rule: string | null; matched: boolean; reason: string; } export interface PolicyTraceStep { phase: string; rule: string | null; matched: boolean; reason: string; }
@@ -38,17 +42,63 @@ export interface ToolReadinessResult {
export interface ToolDefinition { canonicalName: string; description: string; readOnly: boolean; riskClass: string; inputSchema: { type?: string; required?: string[]; properties?: Record<string, { type?: string | string[]; title?: string; description?: string; default?: unknown; enum?: unknown[]; minimum?: number; maximum?: number; minLength?: number; maxLength?: number }> }; limits: { timeoutMs?: number; maxOutputBytes?: number; maxItems?: number }; } export interface ToolDefinition { canonicalName: string; description: string; readOnly: boolean; riskClass: string; inputSchema: { type?: string; required?: string[]; properties?: Record<string, { type?: string | string[]; title?: string; description?: string; default?: unknown; enum?: unknown[]; minimum?: number; maximum?: number; minLength?: number; maxLength?: number }> }; limits: { timeoutMs?: number; maxOutputBytes?: number; maxItems?: number }; }
export interface ToolInvocationResult { ok: boolean; traceId: string; tool: string; content?: Array<{ type?: string; text?: string }>; structuredContent?: unknown; warnings?: string[]; error?: { code: string; message: string }; } export interface ToolInvocationResult { ok: boolean; traceId: string; tool: string; content?: Array<{ type?: string; text?: string }>; structuredContent?: unknown; warnings?: string[]; error?: { code: string; message: string }; }
export interface OperationsSummary { generatedAt: string; backup: DashboardSnapshot["backup"]; audit24h: { success: number; denied: number; error: number; rate_limited: number }; auditTruncated: boolean; trend: Array<{ hour: string; success: number; denied: number; error: number; rate_limited: number }>; alerts: Array<{ severity: "info" | "warning"; code: string; message: string }>; } export interface OperationsSummary { generatedAt: string; backup: DashboardSnapshot["backup"]; audit24h: { success: number; denied: number; error: number; rate_limited: number }; auditTruncated: boolean; trend: Array<{ hour: string; success: number; denied: number; error: number; rate_limited: number }>; alerts: Array<{ severity: "info" | "warning"; code: string; message: string }>; }
export interface OperationsCheck { generatedAt: string; healthy: boolean; connectors: Record<string, { reachable: boolean; status: string; detail: string }>; } export interface OperationsCheck { generatedAt: string; healthy: boolean; connectors: Record<string, { reachable: boolean; status: string; detail: string; connectorRefs?: Array<{ id: string; name: string }>; connectorRefsTruncated?: boolean }>; }
export interface CatalogPreview { valid: boolean; changes: string[]; errors: string[]; } export interface CatalogPreview { valid: boolean; changes: string[]; errors: string[]; }
export interface ClientActionResult { accepted: boolean; message: string; } export interface ClientActionResult { accepted: boolean; message: string; }
export type OnboardingClientKind = "native-2026" | "compatibility-2025" | "stdio-bridge" | "inspector"; export type OnboardingClientKind = "native-2026" | "compatibility-2025" | "stdio-bridge" | "inspector";
export interface ClientOnboardingResult { client: { id: string; name: string; project: string; environment: string; status: string }; bootstrap: { reference: string; delivery: string; expiresAt: string }; config: { clientId: string; kind: OnboardingClientKind; protocolVersion: string; endpoint: string; transport: string; scopes: string[]; credentialReference: string; environmentVariable?: string; bridgeCommand?: string }; provisioning: { status: string; subject: string | null; issuer: string; oauthUsername: string; tokenEndpoint: string; oauthClientId: string; steps: string[] }; } export interface GeneratedClientConfig {
clientId: string; kind: OnboardingClientKind; protocolVersion: string; endpoint: string;
transport: string; scopes: string[]; environmentVariable?: string | null; bridgeCommand?: string | null;
}
export interface ClientOnboardingResult {
client: { id: string; name: string; project: string; environment: string; status: string };
bootstrap: { reference: string; delivery: string; expiresAt: string };
config: GeneratedClientConfig & { credentialReference: string };
provisioning: { status: string; subject: string | null; issuer: string; oauthUsername: string; tokenEndpoint: string; oauthClientId: string; steps: string[] };
}
export interface ProjectEnvironment { name: string; classification: string | null; } export interface ProjectEnvironment { name: string; classification: string | null; }
export interface ProjectConnectorBinding { ref: string; connectorType: string | null; environments: string[]; verification: string | null; } export interface ProjectConnectorBinding { ref: string; connectorType: string | null; environments: string[]; verification: string | null; }
export interface ProjectDetailInfo { slug: string; name: string; description: string; environments: ProjectEnvironment[]; capabilityPacks: string[]; connectorBindings: ProjectConnectorBinding[]; virtualServerCount: number; clientCount: number; activeClientCount: number; } export interface ProjectMcpServer {
slug: string; environment: string; endpointAlias: string; protocolRevisions: string[];
defaultDecision: string; runtimeState: string; enabled: boolean | null; stateSource: "persisted" | "catalog-only";
stateDetail: string;
}
export interface ProjectKnowledgeSource {
id: string; uri: string; include: string[]; verification: string; maxDocumentBytes: number | null;
indexesSecrets: boolean; accessBoundary: string;
}
export interface ProjectDetailInfo {
slug: string; name: string; description: string; environments: ProjectEnvironment[];
capabilityPacks: string[]; connectorBindings: ProjectConnectorBinding[];
mcpServers: ProjectMcpServer[]; knowledgeSources: ProjectKnowledgeSource[];
virtualServerCount: number; clientCount: number; activeClientCount: number;
}
export interface ConnectorDetailInfo {
id: string; name: string; connectorType: string; description: string;
registration: { state: "registered" | "invalid"; detail: string };
readiness: { reachable: boolean | null; status: string; detail: string; observedAt: string | null; source: "direct-probe" | "persisted" | "unavailable" };
bindings: Array<{ project: string; environment: string; verification: string }>;
relatedTools: Array<{ canonicalName: string; name: string; summary: string; risk: string; readOnly: boolean }>;
}
export type OnboardingCheckState = "ready" | "pending" | "blocked" | "degraded" | "healthy" | "unavailable" | "unknown";
export interface ClientOnboardingStatus {
clientId: string; project: string; environment: string;
clientStatus: "pending" | "enabled" | "disabled" | "revoked";
provisioning: { status: string; subject: string | null; issuer: string; oauthUsername: string; tokenEndpoint: string; oauthClientId: string; steps: string[] };
checks: {
externalSubject: { state: OnboardingCheckState; detail: string };
externalScope: { state: "unavailable"; detail: string };
hubBinding: { state: OnboardingCheckState; detail: string };
catalog: { state: OnboardingCheckState; detail: string };
connector: { state: OnboardingCheckState; detail: string; observedAt: string | null };
mcpProof: { state: "unavailable"; detail: string };
};
verification: { checkedAt: string | null; checkedByManagement: boolean; verifiedByManagement: boolean; hubChecksPassed: boolean; detail: string };
}
export interface ManagementApiClient extends ApiClient { export interface ManagementApiClient extends ApiClient {
resource(kind: ResourceKind, query: PageQuery): Promise<ResourcePage>; resource(kind: ResourceKind, query: PageQuery): Promise<ResourcePage>;
projectDetail(slug: string): Promise<ProjectDetailInfo>; projectDetail(slug: string): Promise<ProjectDetailInfo>;
connectorDetail(slug: string, scope?: { project: string; environment: string }): Promise<ConnectorDetailInfo>;
simulatePolicy(input: { project: string; environment: string; tool: string; clientId?: string; capability?: string; scopes?: string[]; bindingPresent?: boolean; clientEnabled?: boolean }): Promise<PolicySimulation>; simulatePolicy(input: { project: string; environment: string; tool: string; clientId?: string; capability?: string; scopes?: string[]; bindingPresent?: boolean; clientEnabled?: boolean }): Promise<PolicySimulation>;
testTool(input: { project: string; environment: string; tool: string; arguments: Record<string, unknown> }): Promise<ToolReadinessResult>; testTool(input: { project: string; environment: string; tool: string; arguments: Record<string, unknown> }): Promise<ToolReadinessResult>;
toolDefinition(canonicalName: string): Promise<ToolDefinition>; toolDefinition(canonicalName: string): Promise<ToolDefinition>;
@@ -59,7 +109,9 @@ export interface ManagementApiClient extends ApiClient {
applyCatalog(input: { project: string }): Promise<ClientActionResult>; applyCatalog(input: { project: string }): Promise<ClientActionResult>;
clientAction(id: string, action: "disable" | "revoke"): Promise<ClientActionResult>; clientAction(id: string, action: "disable" | "revoke"): Promise<ClientActionResult>;
createClient(input: { name: string; project: string; environment: string; kind: OnboardingClientKind }): Promise<ClientOnboardingResult>; createClient(input: { name: string; project: string; environment: string; kind: OnboardingClientKind }): Promise<ClientOnboardingResult>;
generatedConfig(id: string): Promise<{ clientId: string; endpoint: string; scopes: string[] }>; generatedConfig(id: string): Promise<GeneratedClientConfig>;
clientOnboardingStatus(id: string): Promise<ClientOnboardingStatus>;
verifyClientOnboarding(id: string): Promise<ClientOnboardingStatus>;
} }
export function isSnapshotStale(snapshot: DashboardSnapshot, now = Date.now()): boolean { export function isSnapshotStale(snapshot: DashboardSnapshot, now = Date.now()): boolean {
const generated = Date.parse(snapshot.generatedAt); const generated = Date.parse(snapshot.generatedAt);
@@ -69,5 +121,6 @@ export function routeTitle(pathname: string): string {
const route = pathname.replace(/\/$/, "") || "/"; const route = pathname.replace(/\/$/, "") || "/";
if (route === "/") return "Overview"; if (route === "/") return "Overview";
if (route.startsWith("/projects/")) return "Project detail"; if (route.startsWith("/projects/")) return "Project detail";
if (route.startsWith("/connectors/")) return "Connector detail";
return ({ "/projects": "Projects", "/servers": "MCP Servers", "/tools": "Catalog", "/connectors": "Connectors", "/clients": "Clients", "/policies": "Policies", "/operations": "Operations", "/audit": "Audit", "/settings": "Platform" } as Record<string, string>)[route] ?? "Not found"; return ({ "/projects": "Projects", "/servers": "MCP Servers", "/tools": "Catalog", "/connectors": "Connectors", "/clients": "Clients", "/policies": "Policies", "/operations": "Operations", "/audit": "Audit", "/settings": "Platform" } as Record<string, string>)[route] ?? "Not found";
} }
+1 -1
View File
@@ -14,6 +14,6 @@ export function useProjectOptions(api: ManagementApiClient) {
* selected or the lookup is in flight. */ * selected or the lookup is in flight. */
export function useProjectEnvironments(api: ManagementApiClient, project: string) { export function useProjectEnvironments(api: ManagementApiClient, project: string) {
const [environments, setEnvironments] = useState<string[]>([]); const [environments, setEnvironments] = useState<string[]>([]);
useEffect(() => { if (!project) { setEnvironments([]); return; } let active = true; void api.projectDetail(project).then((detail) => { if (active) setEnvironments(detail.environments.map((item) => item.name)); }).catch(() => { if (active) setEnvironments([]); }); return () => { active = false; }; }, [api, project]); useEffect(() => { setEnvironments([]); if (!project) return; let active = true; void api.projectDetail(project).then((detail) => { if (active) setEnvironments(detail.environments.map((item) => item.name)); }).catch(() => { if (active) setEnvironments([]); }); return () => { active = false; }; }, [api, project]);
return environments; return environments;
} }
+7 -1
View File
@@ -2,8 +2,14 @@ import { createRoot } from "react-dom/client";
import { createElement } from "react"; import { createElement } from "react";
import { App } from "./app"; import { App } from "./app";
import "./styles.css"; import "./styles.css";
import "./styles/operator.css";
import "./styles/details.css";
import "./styles/workflows.css";
import "./styles/shell.css";
const storedTheme = localStorage.getItem("hub-theme"); import { readPreference } from "./preferences";
const storedTheme = readPreference("hub-theme", ["light", "dark"], "");
if (storedTheme === "light" || storedTheme === "dark") { if (storedTheme === "light" || storedTheme === "dark") {
document.documentElement.setAttribute("data-theme", storedTheme); document.documentElement.setAttribute("data-theme", storedTheme);
} }
+41
View File
@@ -0,0 +1,41 @@
import type { DashboardSnapshot, Health, OperationsSummary } from "./contracts";
export interface ActionSignal { title: string; detail: string; impact: string; nextStep: string; status: Health; href: string; subsystem: string; project?: string; observedAt: string | null; }
export function actionInbox(snapshot: DashboardSnapshot, stale: boolean, canReadAudit: boolean): ActionSignal[] {
const signals: ActionSignal[] = [];
const add = (signal: Omit<ActionSignal, "observedAt">) => signals.push({ ...signal, observedAt: snapshot.generatedAt });
if (stale) add({ title: "Operational data is stale", detail: "Health information has exceeded its freshness window.", impact: "Current availability is unknown until refreshed.", nextStep: "Refresh status before acting", status: "degraded", href: "/operations", subsystem: "Freshness" });
for (const [subsystem, status, detail] of [["Hub", snapshot.health.hub, snapshot.health.detail], ["Gateway", snapshot.health.gateway, "Trusted ContextForge route availability is not healthy."]] as const) {
if (status !== "healthy") add({ title: `${subsystem} needs investigation`, detail, impact: "Governed requests may be unavailable.", nextStep: "Inspect operational checks", status, href: "/operations?section=checks", subsystem });
}
for (const connector of snapshot.connectors.filter((item) => item.state !== "healthy")) {
const href = connector.id ? `/connectors/${encodeURIComponent(connector.id)}` : `/connectors?q=${encodeURIComponent(connector.name)}`;
add({ title: `${connector.name} · ${connector.state === "unknown" ? "health not observed" : connector.state}`, detail: connector.detail, impact: connector.state === "unknown" ? "Availability has not been verified by a live probe." : "Tools using this integration may be affected.", nextStep: "Inspect connector and scoped bindings", status: connector.state, href, subsystem: connector.name, project: connector.project === "registered" ? undefined : connector.project });
}
if (!snapshot.drift.known || snapshot.drift.count > 0) add({ title: "Catalog reconciliation needs attention", detail: snapshot.drift.known ? `${snapshot.drift.count} drifted gateway(s): ${snapshot.drift.projects.join(", ") || "Inspect reconciliation"}` : snapshot.drift.detail, impact: snapshot.drift.known ? "Published discovery may differ from the intended catalog." : "Published catalog parity is unknown.", nextStep: "Review catalog checks", status: snapshot.drift.known ? "degraded" : "unknown", href: "/operations?section=checks", subsystem: "Catalog" });
if (snapshot.backup.state !== "healthy") add({ title: "Backup freshness needs attention", detail: snapshot.backup.detail, impact: "Recovery confidence requires a verified backup and restore record.", nextStep: "Inspect recovery evidence", status: snapshot.backup.state, href: "/operations?section=recovery", subsystem: "Recovery" });
if (snapshot.denials.count > 0) add({ title: "Recent authorization denials", detail: `${snapshot.denials.count} in ${snapshot.denials.window}${snapshot.denials.latestTool ? ` · latest ${snapshot.denials.latestTool}` : ""}`, impact: "Requests were blocked by governance; the cause requires authorized audit inspection.", nextStep: canReadAudit ? "Inspect matching audit activity" : "Inspect operational alerts; detailed audit requires auditor access", status: "unknown", href: canReadAudit ? `/audit${snapshot.denials.latestTool ? `?q=${encodeURIComponent(snapshot.denials.latestTool)}` : ""}` : "/operations?section=alerts", subsystem: "Authorization" });
return signals;
}
export function alertDestination(code: string, canReadAudit: boolean): string {
if (/BACKUP|RESTORE|RECOVERY/i.test(code)) return "/operations?section=recovery";
if (/DRIFT|CATALOG|RECONCIL/i.test(code)) return "/tools";
if (/DENI|AUTH/i.test(code) && canReadAudit) return "/audit";
return "/operations?section=checks";
}
export type TrendBucket = OperationsSummary["trend"][number] & { endHour: string };
export function aggregateTrend(trend: OperationsSummary["trend"], size: number): TrendBucket[] {
const buckets: TrendBucket[] = [];
const width = Math.max(1, Math.floor(size));
for (let index = 0; index < trend.length; index += width) {
const group = trend.slice(index, index + width);
const first = group[0];
const last = group[group.length - 1];
if (!first || !last) continue;
buckets.push({ hour: first.hour, endHour: last.hour, success: group.reduce((total, item) => total + item.success, 0), denied: group.reduce((total, item) => total + item.denied, 0), error: group.reduce((total, item) => total + item.error, 0), rate_limited: group.reduce((total, item) => total + item.rate_limited, 0) });
}
return buckets;
}
+15 -7
View File
@@ -1,20 +1,28 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { CatalogPreview, ManagementApiClient } from "../contracts"; import type { CatalogPreview, ManagementApiClient } from "../contracts";
import { safeApiMessage } from "../api"; import { safeApiMessage } from "../api";
import { useProjectOptions } from "../hooks"; import { useProjectOptions } from "../hooks";
import { useCan } from "../rbac-context"; import { useCan } from "../rbac-context";
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
export function CatalogWorkflow({ api }: { api: ManagementApiClient }) { export function CatalogWorkflow({ api, initialProject = "" }: { api: ManagementApiClient; initialProject?: string }) {
const canApply = useCan("catalog.apply"); const canApply = useCan("catalog.apply");
const projects = useProjectOptions(api); const projects = useProjectOptions(api);
const [project, setProject] = useState(""); const [project, setProject] = useState(initialProject);
const [preview, setPreview] = useState<CatalogPreview | null>(null); const [preview, setPreview] = useState<CatalogPreview | null>(null);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
useEffect(() => { if (!project && projects[0]) setProject(projects[0].id); }, [projects, project]); const [applying, setApplying] = useState(false);
const run = async () => { if (!project) return; setBusy(true); setMessage(""); try { setPreview(await api.previewCatalog({ project })); } catch (caught) { setMessage(safeApiMessage(caught)); } finally { setBusy(false); } }; const [previewProject, setPreviewProject] = useState("");
const apply = async () => { if (!project) return; setBusy(true); try { const result = await api.applyCatalog({ project }); setMessage(result.message); } catch (caught) { setMessage(safeApiMessage(caught)); } finally { setBusy(false); } }; const currentProject = useRef(project);
return <section className="panel workflow-panel"><div className="panel-heading"><div><p className="eyebrow">Safe configuration</p><h3>Catalog validation and preview</h3></div></div><p className="muted">Preview is read-only. Apply remains disabled until validation reports no errors and the server authorizes the change.</p><div className="form-grid"><label>Project<select value={project} onChange={(event) => { setProject(event.target.value); setPreview(null); setMessage(""); }} disabled={projects.length === 0}>{projects.length === 0 ? <option value="">No registered projects</option> : projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label></div><button className="button button-secondary" onClick={() => void run()} disabled={busy || !project}>{busy ? "Validating…" : "Validate and preview"}</button>{preview && <div className="diff-box"><strong>{preview.valid ? "Valid preview" : "Validation blocked"}</strong>{preview.changes.map((change) => <div key={change} className="diff-add">+ {change}</div>)}{preview.errors.map((error) => <div key={error} className="diff-error">! {error}</div>)}{canApply ? <button className="button button-small" onClick={() => setConfirming(true)} disabled={!preview.valid || busy} title={!preview.valid ? "Resolve validation errors before applying" : "Server authorization is still required"}>Apply approved catalog</button> : <p className="muted">Applying requires the admin role.</p>}</div>}{message && <p className="error-text" role="status">{message}</p>}<Modal open={confirming} title="Apply catalog changes?" onClose={() => setConfirming(false)}><p>This reconciles the catalog for <strong>{project}</strong> against the previewed changes above. This action is logged and cannot be undone.</p><div className="state-actions"><button className="button" onClick={() => { setConfirming(false); void apply(); }}>Confirm apply</button><button className="button button-secondary" onClick={() => setConfirming(false)}>Cancel</button></div></Modal></section>; currentProject.current = project;
const active = useRef(true);
useEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
useEffect(() => { setConfirming(false); }, [project]);
useEffect(() => { if (projects.length && !projects.some((item) => item.id === project)) setProject(projects[0]?.id ?? ""); }, [projects, project]);
useEffect(() => { if (initialProject) { setProject(initialProject); setPreview(null); setMessage(""); } }, [initialProject]);
const run = async () => { if (!project) return; setBusy(true); setMessage(""); try { const next = await api.previewCatalog({ project }); if (active.current && currentProject.current === project) { setPreview(next); setPreviewProject(project); } } catch (caught) { if (active.current && currentProject.current === project) setMessage(safeApiMessage(caught)); } finally { if (active.current) setBusy(false); } };
const apply = async () => { if (busy || !project || !preview?.valid || previewProject !== project) return; setApplying(true); setBusy(true); try { const result = await api.applyCatalog({ project }); if (active.current && currentProject.current === project) setMessage(result.message); } catch (caught) { if (active.current && currentProject.current === project) setMessage(safeApiMessage(caught)); } finally { if (active.current) { setBusy(false); setApplying(false); } } };
return <section className="panel workflow-panel"><div className="panel-heading"><div><p className="eyebrow">Safe configuration</p><h3>Catalog validation and preview</h3></div></div><p className="muted">Preview is read-only and checks the complete catalog, including the selected project. Apply reconciles all registered projects and remains disabled until validation reports no errors and the server authorizes the change.</p><div className="form-grid"><label>Project<select value={project} onChange={(event) => { setProject(event.target.value); setPreview(null); setMessage(""); }} disabled={projects.length === 0 || confirming || applying}>{projects.length === 0 ? <option value="">No registered projects</option> : projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label></div><button className="button button-secondary" onClick={() => void run()} disabled={busy || !project}>{applying ? "Applying…" : busy ? "Validating…" : "Validate and preview"}</button>{preview && <div className="diff-box"><strong>{preview.valid ? "Valid preview" : "Validation blocked"}</strong>{preview.changes.map((change) => <div key={change} className="diff-add">+ {change}</div>)}{preview.errors.map((error) => <div key={error} className="diff-error">! {error}</div>)}{canApply ? <button className="button button-small" onClick={() => setConfirming(true)} disabled={!preview.valid || previewProject !== project || busy} title={!preview.valid ? "Resolve validation errors before applying" : "Server authorization is still required"}>Apply approved catalog</button> : <p className="muted">Applying requires the admin role.</p>}</div>}{message && <p className="error-text" role="status">{message}</p>}<Modal open={confirming} title="Apply catalog changes?" onClose={() => setConfirming(false)}><p>This reconciles the complete registered catalog, including <strong>{project}</strong>. All registered project routes may be updated. This action is logged and cannot be undone.</p><div className="state-actions"><button className="button" onClick={() => { setConfirming(false); void apply(); }}>Confirm apply</button><button className="button button-secondary" onClick={() => setConfirming(false)}>Cancel</button></div></Modal></section>;
} }
+283 -46
View File
@@ -1,93 +1,330 @@
import { useEffect, useState } from "react"; import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { ManagementApiClient, OnboardingClientKind } from "../contracts"; import type { ClientOnboardingResult, ClientOnboardingStatus, GeneratedClientConfig, ManagementApiClient, OnboardingClientKind, OnboardingCheckState } from "../contracts";
import { safeApiMessage } from "../api"; import { safeApiMessage } from "../api";
import { Icon } from "../components/Icon"; import { Icon } from "../components/Icon";
import { StatusPill } from "../components/StatusPill";
import { useProjectEnvironments, useProjectOptions } from "../hooks"; import { useProjectEnvironments, useProjectOptions } from "../hooks";
import { useCan } from "../rbac-context"; import { useCan } from "../rbac-context";
import { formatBrusselsTime, healthLabel } from "../format";
export function ClientOnboarding({ api }: { api: ManagementApiClient }) { type SafeGeneratedConfig = GeneratedClientConfig;
type WorkflowStage = "define" | "provision" | "verify";
function healthForCheck(state: OnboardingCheckState): "healthy" | "degraded" | "unavailable" | "unknown" {
if (state === "ready" || state === "healthy") return "healthy";
if (state === "pending" || state === "degraded") return "degraded";
if (state === "blocked" || state === "unavailable") return "unavailable";
return "unknown";
}
function currentStage(status: ClientOnboardingStatus | null, clientId: string | null, loading: boolean): WorkflowStage {
if (!clientId) return "define";
if (loading || !status) return "provision";
const externalReady = status.checks.externalSubject.state === "ready" && status.checks.hubBinding.state === "ready";
return externalReady ? "verify" : "provision";
}
function CheckRow({ label, state, detail, observedAt }: { label: string; state: OnboardingCheckState; detail: string; observedAt?: string | null }) {
return <div className="onboarding-check">
<dt>{label}</dt>
<dd>
<StatusPill value={healthForCheck(state)} />
<span className="onboarding-check-detail">{detail}</span>
{observedAt ? <small>Observed {formatBrusselsTime(observedAt)}</small> : null}
</dd>
</div>;
}
export function ClientOnboarding({
api,
clientId,
navigate,
onCreated,
}: {
api: ManagementApiClient;
clientId?: string;
navigate?: (next: string) => void;
onCreated?: (client: ClientOnboardingResult["client"]) => void;
}) {
const canCreate = useCan("client.create"); const canCreate = useCan("client.create");
const canVerify = useCan("client.verify");
const projects = useProjectOptions(api); const projects = useProjectOptions(api);
const [project, setProject] = useState(""); const [project, setProject] = useState("");
const environments = useProjectEnvironments(api, project); const environments = useProjectEnvironments(api, project);
const [environment, setEnvironment] = useState(""); const [environment, setEnvironment] = useState("");
const [name, setName] = useState(""); const [name, setName] = useState("");
const [kind, setKind] = useState<OnboardingClientKind>("stdio-bridge"); const [kind, setKind] = useState<OnboardingClientKind>("stdio-bridge");
const [result, setResult] = useState<Awaited<ReturnType<ManagementApiClient["createClient"]>> | null>(null); const activeClientId = clientId ?? null;
const [error, setError] = useState(""); const workflowContext = useRef({ clientId: activeClientId, generation: 0 });
useLayoutEffect(() => {
if (workflowContext.current.clientId !== activeClientId) {
workflowContext.current = { clientId: activeClientId, generation: workflowContext.current.generation + 1 };
}
}, [activeClientId]);
const mounted = useRef(true);
const [createResult, setCreateResult] = useState<ClientOnboardingResult | null>(null);
const [status, setStatus] = useState<ClientOnboardingStatus | null>(null);
const [generatedConfig, setGeneratedConfig] = useState<SafeGeneratedConfig | null>(null);
const [statusState, setStatusState] = useState<"idle" | "loading" | "ready" | "error">("idle");
const [configState, setConfigState] = useState<"idle" | "loading" | "ready" | "error">("idle");
const [statusError, setStatusError] = useState("");
const [actionError, setActionError] = useState("");
const [actionMessage, setActionMessage] = useState("");
const [actionTone, setActionTone] = useState<"success" | "info">("info");
const [copyStatus, setCopyStatus] = useState(""); const [copyStatus, setCopyStatus] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState<"create" | "verify" | "">("");
const [refreshKey, setRefreshKey] = useState(0);
useLayoutEffect(() => {
mounted.current = true;
return () => { mounted.current = false; };
}, []);
useEffect(() => { if (!project && projects[0]) setProject(projects[0].id); }, [projects, project]); useEffect(() => { if (!project && projects[0]) setProject(projects[0].id); }, [projects, project]);
useEffect(() => { setEnvironment((current) => environments.includes(current) ? current : environments[0] ?? ""); }, [environments]); useEffect(() => { setEnvironment((current) => environments.includes(current) ? current : environments[0] ?? ""); }, [environments]);
useEffect(() => {
if (!activeClientId) {
setStatus(null);
setGeneratedConfig(null);
setStatusState("idle");
setConfigState("idle");
setStatusError("");
setBusy("");
setActionError("");
setActionMessage("");
return;
}
let active = true;
const generation = workflowContext.current.generation;
setStatus(null);
setGeneratedConfig(null);
setStatusState("loading");
setConfigState("loading");
setStatusError("");
setBusy("");
setActionError("");
setActionMessage("");
void api.clientOnboardingStatus(activeClientId).then((value) => {
if (!active || !mounted.current || workflowContext.current.clientId !== activeClientId || workflowContext.current.generation !== generation) return;
if (value.clientId !== activeClientId) {
setStatusError("The Hub returned status for a different client identity.");
setStatusState("error");
return;
}
setStatus(value);
setStatusState("ready");
}).catch((caught) => {
if (!active || !mounted.current || workflowContext.current.clientId !== activeClientId || workflowContext.current.generation !== generation) return;
setStatusError(safeApiMessage(caught));
setStatusState("error");
});
void api.generatedConfig(activeClientId).then((value) => {
if (!active || !mounted.current || workflowContext.current.clientId !== activeClientId || workflowContext.current.generation !== generation) return;
if (value.clientId !== activeClientId) {
setConfigState("error");
return;
}
setGeneratedConfig(value);
setConfigState("ready");
}).catch(() => {
if (active && mounted.current && workflowContext.current.clientId === activeClientId && workflowContext.current.generation === generation) setConfigState("error");
});
return () => { active = false; };
}, [activeClientId, api, refreshKey]);
const normalizedName = name.trim(); const normalizedName = name.trim();
const ready = normalizedName.length >= 1 && project !== "" && environment !== ""; const readyToCreate = normalizedName.length >= 1 && project !== "" && environment !== "";
if (!canCreate) return <section className="panel workflow-panel"><p className="eyebrow">Machine access</p><h3>Onboard a project client</h3><div className="notice notice-info">Client creation requires the admin role.</div></section>; const visibleStatus = status?.clientId === activeClientId ? status : null;
const stage = currentStage(visibleStatus, activeClientId, statusState === "loading");
const visibleConfig = createResult?.client.id === activeClientId
? createResult.config
: generatedConfig?.clientId === activeClientId ? generatedConfig : null;
const visibleProvisioning = createResult?.client.id === activeClientId
? createResult.provisioning
: visibleStatus?.provisioning ?? null;
const openClient = (id: string) => {
const next = `/clients?onboard=${encodeURIComponent(id)}`;
if (navigate) navigate(next);
else window.location.assign(next);
};
const create = async () => { const create = async () => {
if (!ready) return; if (!readyToCreate || !canCreate) return;
setBusy(true); const requestClientId = activeClientId;
setError(""); const requestGeneration = workflowContext.current.generation;
const requestIsCurrent = () => mounted.current && workflowContext.current.clientId === requestClientId && workflowContext.current.generation === requestGeneration;
setBusy("create");
setActionError("");
setActionMessage("");
setCopyStatus(""); setCopyStatus("");
setResult(null); setCreateResult(null);
try { try {
setResult(await api.createClient({ name: normalizedName, project, environment, kind })); const result = await api.createClient({ name: normalizedName, project, environment, kind });
onCreated?.(result.client);
if (!requestIsCurrent()) return;
setCreateResult(result);
setName("");
openClient(result.client.id);
setActionTone("info");
setActionMessage("Pending Hub client created. External identity provisioning remains a separate administrator task.");
} catch (caught) { } catch (caught) {
setError(safeApiMessage(caught)); if (requestIsCurrent()) setActionError(safeApiMessage(caught));
} finally { } finally {
setBusy(false); if (requestIsCurrent()) setBusy("");
} }
}; };
const copyConfig = async () => {
if (!result) return; const verify = async () => {
if (!activeClientId || !canVerify || statusState !== "ready" || status?.clientId !== activeClientId) return;
const requestClientId = activeClientId;
const requestGeneration = workflowContext.current.generation;
const requestIsCurrent = () => mounted.current && workflowContext.current.clientId === requestClientId && workflowContext.current.generation === requestGeneration;
setBusy("verify");
setActionError("");
setActionMessage("");
try { try {
await navigator.clipboard.writeText(JSON.stringify({ const result = await api.verifyClientOnboarding(requestClientId);
name: `itworx-${result.client.project}-${result.client.environment}`, if (!requestIsCurrent()) return;
endpoint: result.config.endpoint, if (result.clientId !== requestClientId) throw new Error("mismatched client status");
hubClientId: result.client.id, setStatus(result);
setStatusState("ready");
setActionTone(result.verification.verifiedByManagement ? "success" : "info");
setActionMessage(result.verification.verifiedByManagement
? "Hub checks passed. External OAuth scopes and MCP discovery proof still require operator verification."
: "Hub checks were recorded, but one or more Hub prerequisites are still pending. External OAuth scopes and MCP discovery proof remain unverified.");
} catch (caught) {
if (requestIsCurrent()) setActionError(safeApiMessage(caught));
} finally {
if (requestIsCurrent()) setBusy("");
}
};
const copyConfig = async () => {
if (!activeClientId || !visibleConfig) return;
const safeConfig = {
hubClientId: activeClientId,
endpoint: visibleConfig.endpoint,
scopes: visibleConfig.scopes,
profile: visibleConfig.kind,
protocolVersion: visibleConfig.protocolVersion,
transport: visibleConfig.transport,
...(visibleConfig.environmentVariable ? { environmentVariable: visibleConfig.environmentVariable } : {}),
...(visibleConfig.bridgeCommand ? { bridgeCommand: visibleConfig.bridgeCommand } : {}),
...(visibleProvisioning ? {
authentik: { authentik: {
issuer: result.provisioning.issuer, issuer: visibleProvisioning.issuer,
tokenEndpoint: result.provisioning.tokenEndpoint, tokenEndpoint: visibleProvisioning.tokenEndpoint,
oauthClientId: result.provisioning.oauthClientId, oauthClientId: visibleProvisioning.oauthClientId,
username: result.provisioning.oauthUsername, username: visibleProvisioning.oauthUsername,
}, },
installScript: "scripts/install_codex_mcp.ps1", } : {}),
credential: "operator-owned user-only secret file", credential: "Set this up through the external administrator and store it in a user-only secret file.",
}, null, 2)); };
setCopyStatus("Configuration copied"); try {
await navigator.clipboard.writeText(JSON.stringify(safeConfig, null, 2));
setCopyStatus("Secret-free configuration copied.");
} catch { } catch {
setCopyStatus("Clipboard access is unavailable. Copy the non-secret fields manually."); setCopyStatus("Clipboard access is unavailable. Copy the non-secret fields manually.");
} }
}; };
return <section className="panel workflow-panel onboarding-panel"> const reset = () => {
setCreateResult(null);
setActionError("");
setActionMessage("");
const next = "/clients";
if (navigate) navigate(next);
else window.location.assign(next);
};
return <section className="panel workflow-panel onboarding-panel" aria-busy={busy !== "" || statusState === "loading"}>
<div className="panel-heading"> <div className="panel-heading">
<div><p className="eyebrow">Machine access</p><h3>Onboard a project client</h3></div> <div><p className="eyebrow">Machine access</p><h3>Onboard a project client</h3></div>
<span className="tag">Independent binding</span> <span className="tag">Independent binding</span>
</div> </div>
<p className="muted">Create a disabled-by-default Hub identity, then complete its explicit Authentik and Codex hand-off. Access starts only after the real external subject is bound.</p> <p className="muted">Define a disabled Hub identity, complete its external identity hand-off, then verify the real project binding and connector. Credentials and external MCP proof stay outside this browser.</p>
<ol className="onboarding-steps" aria-label="Client onboarding stages"> <ol className="onboarding-steps" aria-label="Client onboarding stages">
<li className="active"><span>1</span><div><strong>Define</strong><small>Profile and scope</small></div></li> {(["define", "provision", "verify"] as WorkflowStage[]).map((step, index) => <li className={stage === step ? "active" : visibleStatus && index < (["define", "provision", "verify"].indexOf(stage)) ? "complete" : ""} aria-current={stage === step ? "step" : undefined} key={step}>
<li><span>2</span><div><strong>Provision</strong><small>External identity</small></div></li> <span>{index + 1}</span><div><strong>{healthLabel(step)}</strong><small>{step === "define" ? "Hub identity" : step === "provision" ? "External account" : "Scope and proof"}</small></div>
<li><span>3</span><div><strong>Verify</strong><small>Discovery and audit</small></div></li> </li>)}
</ol> </ol>
{!activeClientId ? <>
{canCreate ? <>
<div className="form-grid onboarding-form"> <div className="form-grid onboarding-form">
<label>Name<input value={name} onChange={(event) => setName(event.target.value)} placeholder="Codex workstation" minLength={1} maxLength={128} required aria-describedby="client-name-help" /><small id="client-name-help">A recognizable operator-owned device or workflow name.</small></label> <label>Name<input aria-label="Name" value={name} onChange={(event) => setName(event.target.value)} placeholder="Codex workstation" minLength={1} maxLength={128} required aria-describedby="client-name-help" /><small id="client-name-help">A recognizable operator-owned device or workflow name.</small></label>
<label>Client profile<select value={kind} onChange={(event) => setKind(event.target.value as OnboardingClientKind)}><option value="stdio-bridge">Codex · automatic token refresh</option><option value="native-2026">Native MCP 2026 · bearer token</option><option value="compatibility-2025">Compatibility 2025</option><option value="inspector">LAN Inspector</option></select></label> <label>Client profile<select value={kind} onChange={(event) => setKind(event.target.value as OnboardingClientKind)}><option value="stdio-bridge">Codex · automatic token refresh</option><option value="native-2026">Native MCP 2026 · bearer token</option><option value="compatibility-2025">Compatibility 2025</option><option value="inspector">LAN Inspector</option></select></label>
<label>Project<select value={project} onChange={(event) => setProject(event.target.value)} disabled={projects.length === 0}>{projects.length === 0 ? <option value="">No registered projects</option> : projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label> <label>Project<select aria-label="Project" value={project} onChange={(event) => setProject(event.target.value)} disabled={projects.length === 0}>{projects.length === 0 ? <option value="">No registered projects</option> : projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
<label>Environment<select value={environment} onChange={(event) => setEnvironment(event.target.value)} disabled={environments.length === 0}>{environments.length === 0 ? <option value="">No registered environments</option> : environments.map((item) => <option key={item} value={item}>{item}</option>)}</select></label> <label>Environment<select aria-label="Environment" value={environment} onChange={(event) => setEnvironment(event.target.value)} disabled={environments.length === 0}>{environments.length === 0 ? <option value="">No registered environments</option> : environments.map((item) => <option key={item} value={item}>{item}</option>)}</select></label>
</div> </div>
{!normalizedName ? <p className="form-hint">Enter a client name to continue.</p> : null} {!normalizedName ? <p className="form-hint">Enter a client name to continue.</p> : null}
<button className="button" onClick={() => void create()} disabled={busy || !ready}>{busy ? "Creating pending client…" : <>Create pending client <Icon name="arrow" size={15} /></>}</button> {projects.length === 0 ? <p className="notice notice-info">No registered projects are available for this identity.</p> : null}
<p className="external-provisioning-note"><span aria-hidden="true">i</span><span><strong>External identity provisioning is a separate administrator task.</strong> The Hub produces an exact plan but never creates or displays the credential in this browser.</span></p> <button className="button" onClick={() => void create()} disabled={busy !== "" || !readyToCreate}>{busy === "create" ? "Creating pending client…" : <>Create pending client <Icon name="arrow" size={15} /></>}</button>
{error ? <p className="error-text" role="alert">{error}</p> : null} </> : <div className="notice notice-info">Client creation requires the admin role. An operator can resume an existing onboarding link when one is provided.</div>}
{result ? <div className="onboarding-result" role="status"> <p className="external-provisioning-note"><span aria-hidden="true">i</span><span><strong>External identity provisioning is a separate administrator task.</strong> The Hub creates a pending identity and shows secret-free setup details; it does not create an Authentik account or credential.</span></p>
<div className="panel-heading"><div><p className="eyebrow">Provisioning plan ready</p><h3>Client created: {result.client.name}</h3><p className="muted">Pending client created; it has no access yet.</p></div><button className="button button-small button-secondary" onClick={() => void copyConfig()}>Copy client config</button></div> </> : <>
<dl><div><dt>Authentik account</dt><dd>{result.provisioning.oauthUsername}</dd></div><div><dt>Subject</dt><dd>{result.provisioning.subject ?? "Assigned during Authentik provisioning"}</dd></div><div><dt>Endpoint</dt><dd>{result.config.endpoint}</dd></div><div><dt>Scopes</dt><dd>{result.config.scopes.join(", ")}</dd></div><div><dt>Status</dt><dd>{result.provisioning.status}</dd></div></dl> <div className="onboarding-identity-bar">
<ol>{result.provisioning.steps.map((step) => <li key={step}>{step}</li>)}</ol> <div><span className="eyebrow">Resumable Hub identity</span><strong>{activeClientId}</strong><small>{visibleStatus?.project ?? (createResult?.client.id === activeClientId ? createResult.client.project : "Project loading")} / {visibleStatus?.environment ?? (createResult?.client.id === activeClientId ? createResult.client.environment : "Environment loading")} · {visibleStatus?.clientStatus ?? "Status loading"}</small></div>
<p className="muted">No bearer credential, app password or client secret is returned to this browser.</p> <div className="onboarding-identity-actions">
{copyStatus ? <p className="copy-status">{copyStatus}</p> : null} {statusState === "error" ? <button className="button button-small button-secondary" onClick={() => setRefreshKey((value) => value + 1)}>Retry status</button> : null}
<button className="button button-small button-secondary" onClick={reset}>Start another client</button>
</div>
</div>
{statusState === "loading" ? <p className="onboarding-loading" role="status">Loading saved onboarding state…</p> : null}
{statusError ? <p className="error-text" role="alert">{statusError}</p> : null}
{visibleConfig ? <div className="onboarding-config">
<div className="panel-heading"><div><p className="eyebrow">Secret-free client configuration</p><h4>External hand-off details</h4>{createResult?.client.id === activeClientId ? <p className="onboarding-created-title">Client created: {createResult.client.name}</p> : null}</div><button className="button button-small button-secondary" onClick={() => void copyConfig()} disabled={!visibleProvisioning}>Copy safe config</button></div>
<dl>
<div><dt>Client profile</dt><dd>{visibleConfig.kind}</dd></div>
<div><dt>Protocol version</dt><dd>{visibleConfig.protocolVersion}</dd></div>
<div><dt>Transport</dt><dd>{visibleConfig.transport}</dd></div>
<div><dt>Hub endpoint</dt><dd>{visibleConfig.endpoint}</dd></div>
<div><dt>Client reference</dt><dd>{visibleConfig.clientId}</dd></div>
<div><dt>Scopes</dt><dd>{visibleConfig.scopes.join(", ") || "None listed"}</dd></div>
{visibleConfig.environmentVariable ? <div><dt>Credential environment variable</dt><dd>{visibleConfig.environmentVariable}</dd></div> : null}
{visibleConfig.bridgeCommand ? <div><dt>Bridge command</dt><dd>{visibleConfig.bridgeCommand}</dd></div> : null}
</dl>
{visibleProvisioning ? <>
<div className="onboarding-config-auth"><p><strong>External account:</strong> {visibleProvisioning.oauthUsername}</p><p><strong>Issuer:</strong> {visibleProvisioning.issuer}</p><p><strong>Token endpoint:</strong> {visibleProvisioning.tokenEndpoint}</p><p><strong>OAuth client ID:</strong> {visibleProvisioning.oauthClientId}</p></div>
<ol className="onboarding-handoff-steps">{visibleProvisioning.steps.map((step) => <li key={step}>{step}</li>)}</ol>
</> : <p className="muted">Loading saved external identity hand-off details…</p>}
{configState === "loading" ? <p className="muted" role="status">Loading saved endpoint configuration…</p> : null}
{configState === "error" ? <p className="muted">Saved endpoint configuration could not be loaded. Retry the onboarding status to refresh it.</p> : null}
{copyStatus ? <p className="copy-status" role="status">{copyStatus}</p> : null}
</div> : null} </div> : null}
{visibleStatus ? <div className="onboarding-stage-grid">
<section className="onboarding-stage-card" aria-labelledby="onboarding-provision-title">
<div className="onboarding-stage-heading"><span className="stage-number">2</span><div><p className="eyebrow">Provision</p><h4 id="onboarding-provision-title">External identity hand-off</h4></div></div>
<p className="muted">Complete these steps with the identity administrator. The Hub never provisions the external account, grants OAuth scopes or handles its credential.</p>
<dl className="onboarding-checks">
<CheckRow label="External subject" state={visibleStatus.checks.externalSubject.state} detail={visibleStatus.checks.externalSubject.detail} />
<CheckRow label="Hub binding" state={visibleStatus.checks.hubBinding.state} detail={visibleStatus.checks.hubBinding.detail} />
<CheckRow label="OAuth scopes" state={visibleStatus.checks.externalScope.state} detail={visibleStatus.checks.externalScope.detail} />
</dl>
<div className="external-boundary-note"><strong>Manual prerequisite</strong><span>Provision the external identity, grant only the expected scopes, and store its credential in a user-only secret file. The Hub will not enable this pending client automatically.</span></div>
</section>
<section className="onboarding-stage-card" aria-labelledby="onboarding-verify-title">
<div className="onboarding-stage-heading"><span className="stage-number">3</span><div><p className="eyebrow">Verify</p><h4 id="onboarding-verify-title">Hub checks and MCP proof</h4></div></div>
<dl className="onboarding-checks">
<CheckRow label="Catalog binding" state={visibleStatus.checks.catalog.state} detail={visibleStatus.checks.catalog.detail} />
<CheckRow label="Connector" state={visibleStatus.checks.connector.state} detail={visibleStatus.checks.connector.detail} observedAt={visibleStatus.checks.connector.observedAt} />
<CheckRow label="MCP client proof" state={visibleStatus.checks.mcpProof.state} detail={visibleStatus.checks.mcpProof.detail} />
</dl>
<div className="manual-proof-note"><strong>External verification still required</strong><span>Use an MCP Inspector or the configured client to discover tools, run one approved read-only invocation, then confirm that a cross-project request is denied. The Hub cannot assert those client-side checks from this management session.</span></div>
{visibleStatus.verification.verifiedByManagement ? <p className="onboarding-verified" role="status">Hub checks passed {formatBrusselsTime(visibleStatus.verification.checkedAt)}. External OAuth grants and MCP discovery proof remain separate prerequisites.</p> : visibleStatus.verification.checkedByManagement ? <p className="onboarding-check-recorded" role="status">Hub checks were recorded {formatBrusselsTime(visibleStatus.verification.checkedAt)}, but one or more Hub prerequisites are still pending. This record does not mark external OAuth grants or MCP discovery as complete.</p> : <p className="muted">No Hub check record yet. Recording checks saves only the server-observed safe status and time.</p>}
{canVerify ? <button className="button" onClick={() => void verify()} disabled={busy !== "" || statusState !== "ready" || !visibleStatus} aria-busy={busy === "verify"}>{busy === "verify" ? "Recording Hub checks…" : "Record Hub checks"}</button> : <p className="notice notice-info">Recording Hub checks requires the operator or admin role.</p>}
{visibleStatus.verification.detail ? <p className="muted onboarding-verification-detail">{visibleStatus.verification.detail}</p> : null}
</section>
</div> : null}
</>}
{actionError ? <p className="error-text" role="alert">{actionError}</p> : null}
{actionMessage ? <p className={`onboarding-action-message onboarding-action-${actionTone}`} role="status">{actionMessage}</p> : null}
</section>; </section>;
} }
+135
View File
@@ -0,0 +1,135 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ConnectorDetailInfo, ManagementApiClient } from "../contracts";
import { ApiError, safeApiMessage } from "../api";
import { Breadcrumbs } from "../components/Breadcrumbs";
import { ErrorPanel } from "../components/ErrorPanel";
import { LoadingPanel } from "../components/LoadingPanel";
import { formatBrusselsTime } from "../format";
import { basePath } from "../routing";
import { DetailCards } from "./ProjectDetail";
import type { DetailCardItem } from "./ProjectDetail";
import "../styles/details.css";
const sections = ["Overview", "Bindings", "Related tools"] as const;
type Section = typeof sections[number];
export function ConnectorDetail({ path, navigate, api }: { path: string; navigate: (next: string) => void; api: ManagementApiClient }) {
const id = basePath(path).split("/").pop() ?? "connector";
const search = new URLSearchParams(path.split("?")[1] ?? "");
const project = search.get("project");
const environment = search.get("environment");
const requestedSection = search.get("section") ?? "Overview";
const section = sections.includes(requestedSection as Section) ? requestedSection as Section : "Overview";
const [detail, setDetail] = useState<ConnectorDetailInfo | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error" | "notFound">("loading");
const [error, setError] = useState("");
const loadGeneration = useRef(0);
const load = useCallback(async () => {
const generation = ++loadGeneration.current;
setState("loading");
setDetail(null);
setError("");
try {
if (Boolean(project) !== Boolean(environment)) {
setError("Project and environment filters must be supplied together.");
setState("error");
return;
}
const scope = project && environment ? { project, environment } : undefined;
const next = await api.connectorDetail(id, scope);
if (generation !== loadGeneration.current) return;
setDetail(next);
setState("ready");
} catch (caught) {
if (generation !== loadGeneration.current) return;
if (caught instanceof ApiError && caught.status === 404) setState("notFound");
else { setError(safeApiMessage(caught)); setState("error"); }
}
}, [api, environment, id, project]);
useEffect(() => { void load(); return () => { loadGeneration.current += 1; }; }, [load]);
if (state === "loading") return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate}/><LoadingPanel /></div>;
if (state === "error") return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate}/><ErrorPanel message={error} retry={() => void load()}/></div>;
if (state === "notFound" || !detail) return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate}/><section className="panel resource-page"><div className="notice notice-danger" role="alert">Connector <strong>{id}</strong> is not registered in this scope.</div></section></div>;
const readiness = detail.readiness;
const readinessCard: DetailCardItem = {
id: "readiness",
title: "Connector readiness",
summary: readiness.detail,
status: readiness.status,
fields: [
{ label: "Reachable", value: readiness.reachable === null ? "Unknown" : readiness.reachable ? "Yes" : "No" },
{ label: "Observation source", value: readiness.source },
{ label: "Observed at", value: readiness.observedAt ? formatBrusselsTime(readiness.observedAt) : "No observation recorded" },
],
};
const registrationCard: DetailCardItem = {
id: "registration",
title: "Catalog registration",
summary: detail.registration.detail,
status: detail.registration.state,
fields: [{ label: "Connector type", value: detail.connectorType }],
};
const bindingCards: DetailCardItem[] = detail.bindings.map((binding) => ({
id: `${binding.project}-${binding.environment}`,
title: `${binding.project} · ${binding.environment}`,
status: binding.verification,
fields: [
{ label: "Project", value: binding.project },
{ label: "Environment", value: binding.environment },
{ label: "Verification", value: binding.verification },
],
action: { label: "Open project", onSelect: () => navigate(`/projects/${encodeURIComponent(binding.project)}`) },
}));
const toolCards: DetailCardItem[] = detail.relatedTools.map((tool) => ({
id: tool.canonicalName,
title: tool.canonicalName,
summary: tool.summary,
status: tool.risk,
fields: [
{ label: "Read only", value: tool.readOnly ? "Yes" : "No" },
{ label: "Risk class", value: tool.risk },
],
action: { label: "Open tool catalog", onSelect: () => navigate(`/tools?q=${encodeURIComponent(tool.canonicalName)}`) },
}));
const currentCards = section === "Overview" ? [readinessCard, registrationCard]
: section === "Bindings" ? bindingCards
: toolCards;
return <div className="resource-stack">
<Breadcrumbs path={path} navigate={navigate}/>
<section className="panel resource-page">
<p className="eyebrow">Connector workspace</p>
<h2>{detail.name}</h2>
<p className="muted">{detail.description || "Secret-free connector registration, bindings, and observed readiness."}</p>
<label className="detail-mobile-section"><span>Connector section</span>
<select value={section} onChange={(event) => {
const params = new URLSearchParams();
if (project && environment) { params.set("project", project); params.set("environment", environment); }
params.set("section", event.target.value);
navigate(`/connectors/${encodeURIComponent(id)}?${params.toString()}`);
}}>{sections.map((item) => <option key={item} value={item}>{item}</option>)}</select>
</label>
<nav className="tabs detail-desktop-tabs" aria-label="Connector detail sections">
{sections.map((item) => {
const params = new URLSearchParams();
if (project && environment) { params.set("project", project); params.set("environment", environment); }
params.set("section", item);
return <a key={item} aria-current={section === item ? "page" : undefined} className={section === item ? "tab active" : "tab"}
href={`/connectors/${encodeURIComponent(id)}?${params.toString()}`}
onClick={(event) => { event.preventDefault(); navigate(`/connectors/${encodeURIComponent(id)}?${params.toString()}`); }}>{item}</a>;
})}
</nav>
{readiness.source === "unavailable" && <p className="detail-boundary-note">The Hub has no live or persisted health observation for this connector, so readiness stays unknown.</p>}
<a className="button button-secondary" href="/operations?section=checks" onClick={(event) => { event.preventDefault(); navigate("/operations?section=checks"); }}>Inspect operational checks</a>
{section === "Bindings" && bindingCards.length === 0
? <div className="empty-line"><span aria-hidden="true">○</span><span>No project environments are bound to this connector.</span></div>
: section === "Related tools" && toolCards.length === 0
? <div className="empty-line"><span aria-hidden="true">○</span><span>No registered tools reference this connector.</span></div>
: <DetailCards label={section} cards={currentCards} />}
</section>
</div>;
}
+21 -34
View File
@@ -1,5 +1,5 @@
import type { MouseEvent } from "react"; import { useCallback, useState, type MouseEvent } from "react";
import type { DashboardSnapshot, Health } from "../contracts"; import type { DashboardSnapshot, Health, ManagementApiClient } from "../contracts";
import { isSnapshotStale } from "../contracts"; import { isSnapshotStale } from "../contracts";
import { formatBrusselsTime } from "../format"; import { formatBrusselsTime } from "../format";
import { Icon } from "../components/Icon"; import { Icon } from "../components/Icon";
@@ -7,37 +7,26 @@ import { McpTopology } from "../components/McpTopology";
import { MetricCard } from "../components/MetricCard"; import { MetricCard } from "../components/MetricCard";
import { StatusPill } from "../components/StatusPill"; import { StatusPill } from "../components/StatusPill";
import { useCan } from "../rbac-context"; import { useCan } from "../rbac-context";
import { actionInbox } from "../operator-data";
import { refreshLabel, useStatusRefresh } from "../status-refresh";
function healthLabel(value: Health) { function healthLabel(value: Health) {
return ({ healthy: "Healthy", degraded: "Degraded", unavailable: "Unavailable", unknown: "Unknown" } as const)[value]; return ({ healthy: "Healthy", degraded: "Degraded", unavailable: "Unavailable", unknown: "Unknown" } as const)[value];
} }
export function Dashboard({ snapshot: initialSnapshot, api, navigate }: { snapshot: DashboardSnapshot; api: ManagementApiClient; reload?: () => void; navigate: (next: string) => void }) {
type Exception = { title: string; detail: string; status: Health; href: string }; const read = useCallback(() => api.dashboard(), [api]);
const { value, status, error, refresh } = useStatusRefresh(initialSnapshot, read, isSnapshotStale);
function exceptionsFor(snapshot: DashboardSnapshot, stale: boolean, canReadAudit: boolean): Exception[] { const snapshot = value ?? initialSnapshot;
const exceptions: Exception[] = [];
if (stale) exceptions.push({ title: "Operational data is stale", detail: `Last generated ${formatBrusselsTime(snapshot.generatedAt)}. Refresh before acting on health information.`, status: "degraded", href: "/operations" });
if (snapshot.health.hub !== "healthy") exceptions.push({ title: "Hub health needs investigation", detail: snapshot.health.detail, status: snapshot.health.hub, href: "/operations" });
if (snapshot.health.gateway !== "healthy") exceptions.push({ title: "Gateway health needs investigation", detail: "Trusted ContextForge route availability is not healthy.", status: snapshot.health.gateway, href: "/operations" });
for (const connector of snapshot.connectors.filter((item) => item.state !== "healthy")) {
exceptions.push({ title: `${connector.name} connector is ${healthLabel(connector.state).toLowerCase()}`, detail: `${connector.project} · ${connector.detail}`, status: connector.state, href: "/connectors" });
}
if (!snapshot.drift.known || snapshot.drift.count > 0) exceptions.push({ title: "Catalog reconciliation needs attention", detail: snapshot.drift.known ? `${snapshot.drift.count} drifted project(s): ${snapshot.drift.projects.join(", ") || "details available in Operations"}` : snapshot.drift.detail, status: snapshot.drift.known ? "degraded" : "unknown", href: "/operations" });
if (snapshot.backup.state !== "healthy") exceptions.push({ title: "Backup freshness needs attention", detail: snapshot.backup.detail, status: snapshot.backup.state, href: "/operations" });
if (snapshot.denials.count > 0) exceptions.push({ title: "Recent authorization denials", detail: `${snapshot.denials.count} in ${snapshot.denials.window}${snapshot.denials.latestTool ? ` · latest ${snapshot.denials.latestTool}` : ""}${canReadAudit ? "" : " · detailed audit access requires the auditor role"}`, status: "unknown", href: canReadAudit ? "/audit" : "/operations" });
return exceptions;
}
export function Dashboard({ snapshot, reload, navigate }: { snapshot: DashboardSnapshot; reload: () => void; navigate: (next: string) => void }) {
const stale = isSnapshotStale(snapshot); const stale = isSnapshotStale(snapshot);
const exceptions = exceptionsFor(snapshot, stale, useCan("audit.read")); const exceptions = actionInbox(snapshot, stale, useCan("audit.read")).sort((left, right) => ({ unavailable: 0, degraded: 1, unknown: 2, healthy: 3 })[left.status] - ({ unavailable: 0, degraded: 1, unknown: 2, healthy: 3 })[right.status]);
const [showAllSignals, setShowAllSignals] = useState(false);
const visibleSignals = showAllSignals ? exceptions : exceptions.slice(0, 4);
const connectorHealth: Health = snapshot.connectors.some((connector) => ["degraded", "unavailable"].includes(connector.state)) ? "degraded" : snapshot.connectors.some((connector) => connector.state === "unknown") ? "unknown" : snapshot.connectors.length ? "healthy" : "unknown";
const healthyConnectors = snapshot.connectors.filter((connector) => connector.state === "healthy").length; const healthyConnectors = snapshot.connectors.filter((connector) => connector.state === "healthy").length;
const allCurrent = !stale && exceptions.length === 0; const allCurrent = !stale && exceptions.length === 0;
const go = (event: MouseEvent<HTMLAnchorElement>, href: string) => { const go = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
event.preventDefault(); event.preventDefault();
navigate(href); navigate(href);
}; };
return <div className="dashboard"> return <div className="dashboard">
<section className="hero dashboard-hero"> <section className="hero dashboard-hero">
<div className="hero-content"> <div className="hero-content">
@@ -51,7 +40,7 @@ export function Dashboard({ snapshot, reload, navigate }: { snapshot: DashboardS
<a className="button" href="/operations" onClick={(event) => go(event, "/operations")}>Review operations <Icon name="arrow" size={15} /></a> <a className="button" href="/operations" onClick={(event) => go(event, "/operations")}>Review operations <Icon name="arrow" size={15} /></a>
<a className="button button-secondary" href="/tools" onClick={(event) => go(event, "/tools")}>Explore catalog</a> <a className="button button-secondary" href="/tools" onClick={(event) => go(event, "/tools")}>Explore catalog</a>
</div> </div>
<div className="hero-meta"><span>Last synchronized</span><strong>{formatBrusselsTime(snapshot.generatedAt)}</strong></div> <div className="hero-meta"><span className={`freshness freshness-${status}`} role="status">{refreshLabel[status]}</span><span>Last synchronized</span><strong>{formatBrusselsTime(snapshot.generatedAt)}</strong></div>
</div> </div>
<McpTopology <McpTopology
hub={stale ? "degraded" : snapshot.health.hub} hub={stale ? "degraded" : snapshot.health.hub}
@@ -59,36 +48,34 @@ export function Dashboard({ snapshot, reload, navigate }: { snapshot: DashboardS
connectors={snapshot.connectors.map((connector) => ({ name: connector.name, state: stale ? "degraded" : connector.state }))} connectors={snapshot.connectors.map((connector) => ({ name: connector.name, state: stale ? "degraded" : connector.state }))}
/> />
</section> </section>
<section className="metric-grid primary-metrics" aria-label="Aggregate health"> <section className="metric-grid primary-metrics" aria-label="Aggregate health">
<MetricCard label="Hub control plane" value={stale ? "Stale" : healthLabel(snapshot.health.hub)} detail={snapshot.health.detail} status={stale ? "degraded" : snapshot.health.hub} /> <MetricCard label="Hub control plane" value={stale ? "Stale" : healthLabel(snapshot.health.hub)} detail={snapshot.health.detail} status={stale ? "degraded" : snapshot.health.hub} />
<MetricCard label="Gateway route" value={stale ? "Stale" : healthLabel(snapshot.health.gateway)} detail="Trusted ContextForge route availability" status={stale ? "degraded" : snapshot.health.gateway} /> <MetricCard label="Gateway route" value={stale ? "Stale" : healthLabel(snapshot.health.gateway)} detail="Trusted ContextForge route availability" status={stale ? "degraded" : snapshot.health.gateway} />
<MetricCard label="Project environments" value={`${snapshot.projects.length}`} detail={`${new Set(snapshot.projects.map((project) => project.slug)).size} governed projects`} /> <MetricCard label="Project environments" value={`${snapshot.projects.length}`} detail={`${new Set(snapshot.projects.map((project) => project.slug)).size} governed projects`} />
<MetricCard label="Connector routes" value={`${healthyConnectors} / ${snapshot.connectors.length}`} detail="Healthy of registered integrations" status={healthyConnectors === snapshot.connectors.length ? "healthy" : "degraded"} /> <MetricCard label="Connector routes" value={`${healthyConnectors} / ${snapshot.connectors.length}`} detail="Healthy of registered integrations" status={stale ? "degraded" : connectorHealth} />
</section> </section>
<section className="panel exception-panel" aria-labelledby="attention-heading"> <section className="panel exception-panel" aria-labelledby="attention-heading">
<div className="panel-heading"> <div className="panel-heading">
<div><p className="eyebrow">Attention queue</p><h3 id="attention-heading">{exceptions.length ? `${exceptions.length} actionable signal${exceptions.length === 1 ? "" : "s"}` : "No active exceptions"}</h3></div> <div><p className="eyebrow">Action Inbox</p><h3 id="attention-heading">{exceptions.length ? `${exceptions.length} actionable signal${exceptions.length === 1 ? "" : "s"}` : "No active exceptions"}</h3></div>
<button className="button button-small button-secondary refresh-button" onClick={reload}><Icon name="refresh" size={14} />Refresh</button> <button className="button button-small button-secondary refresh-button" onClick={() => void refresh()} disabled={status === "refreshing"}><Icon name="refresh" size={14} />Refresh</button>
</div> </div>
{error ? <div className="notice notice-danger" role="alert">{error} The last snapshot is retained; retry with Refresh.</div> : null}
{exceptions.length === 0 {exceptions.length === 0
? <div className="empty-line empty-success"><span aria-hidden="true">✓</span><span><strong>Everything reported is healthy.</strong><small>Hub, gateway, connectors, drift and recovery data are current.</small></span></div> ? <div className="empty-line empty-success"><span aria-hidden="true">✓</span><span><strong>Everything reported is healthy.</strong><small>Hub, gateway, connectors, drift and recovery data are current.</small></span></div>
: <div className="exception-list">{exceptions.map((exception) => <a className="exception-item" href={exception.href} key={`${exception.title}-${exception.detail}`} onClick={(event) => go(event, exception.href)}><div><strong>{exception.title}</strong><small>{exception.detail}</small></div><span className="exception-action"><StatusPill value={exception.status} /><Icon name="arrow" size={15} /></span></a>)}</div>} : <div className="exception-list">{visibleSignals.map((exception) => <a className="exception-item" href={exception.href} key={`${exception.title}-${exception.detail}`} onClick={(event) => go(event, exception.href)}><div><strong>{exception.title}</strong><small>{exception.project ? `${exception.project} · ` : ""}{exception.detail}</small><small>{exception.impact}</small><span className="signal-next-step">{exception.nextStep}</span><small className="signal-time">Snapshot {formatBrusselsTime(exception.observedAt)}</small></div><span className="exception-action"><StatusPill value={exception.status} /><Icon name="arrow" size={15} /></span></a>)}</div>}
{exceptions.length > 4 && <button className="button button-secondary inbox-expand" aria-expanded={showAllSignals} onClick={() => setShowAllSignals((current) => !current)}>{showAllSignals ? "Show fewer signals" : `Show ${exceptions.length - 4} more signals`}</button>}
</section> </section>
<section className="dashboard-columns"> <section className="dashboard-columns">
<article className="panel"> <article className="panel">
<div className="panel-heading"><div><p className="eyebrow">Projects</p><h3>Project status</h3></div><a href="/projects" onClick={(event) => go(event, "/projects")}>View all <Icon name="arrow" size={14} /></a></div> <div className="panel-heading"><div><p className="eyebrow">Projects</p><h3>Project status</h3></div><a href="/projects" onClick={(event) => go(event, "/projects")}>View all <Icon name="arrow" size={14} /></a></div>
{snapshot.projects.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No projects have been registered.</span></div> : <div className="table-wrap" role="region" tabIndex={0} aria-label="Project status table"><table><caption className="sr-only">Project status</caption><thead><tr><th>Project</th><th>Environment</th><th>Health</th><th>Detail</th></tr></thead><tbody>{snapshot.projects.slice(0, 6).map((project) => <tr key={`${project.slug}-${project.environment}`}><td><a className="record-link" href={`/projects/${project.slug}`} onClick={(event) => go(event, `/projects/${project.slug}`)}><strong>{project.name}</strong><small>{project.slug}</small></a></td><td>{project.environment}</td><td><StatusPill value={stale ? "degraded" : project.health} /></td><td>{project.detail}</td></tr>)}</tbody></table></div>} {snapshot.projects.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No projects have been registered.</span></div> : <><div className="table-wrap desktop-data-table" role="region" tabIndex={0} aria-label="Project status table"><table><caption className="sr-only">Project status</caption><thead><tr><th>Project</th><th>Environment</th><th>Health</th><th>Detail</th></tr></thead><tbody>{snapshot.projects.slice(0, 6).map((project) => <tr key={`${project.slug}-${project.environment}`}><td><a className="record-link" href={`/projects/${project.slug}`} onClick={(event) => go(event, `/projects/${project.slug}`)}><strong>{project.name}</strong><small>{project.slug}</small></a></td><td>{project.environment}</td><td><StatusPill value={stale ? "degraded" : project.health} /></td><td>{project.detail}</td></tr>)}</tbody></table></div><div className="mobile-record-list" aria-label="Project status records">{snapshot.projects.slice(0, 6).map((project) => <article className="mobile-record" key={`${project.slug}-${project.environment}`}><div className="mobile-record-heading"><a className="record-link" href={`/projects/${encodeURIComponent(project.slug)}`} onClick={(event) => go(event, `/projects/${encodeURIComponent(project.slug)}`)}><strong>{project.name}</strong><small>{project.slug}</small></a><StatusPill value={stale ? "degraded" : project.health} /></div><dl><dt>Environment</dt><dd>{project.environment}</dd><dt>Detail</dt><dd>{project.detail}</dd></dl></article>)}</div></>}
</article> </article>
<article className="panel connector-panel"> <article className="panel connector-panel">
<div className="panel-heading"><div><p className="eyebrow">Connectors</p><h3>Integration signal</h3></div><a href="/connectors" onClick={(event) => go(event, "/connectors")}>View all <Icon name="arrow" size={14} /></a></div> <div className="panel-heading"><div><p className="eyebrow">Connectors</p><h3>Integration signal</h3></div><a href="/connectors" onClick={(event) => go(event, "/connectors")}>View all <Icon name="arrow" size={14} /></a></div>
<div className="connector-score"><strong>{healthyConnectors}</strong><span>healthy routes<small>of {snapshot.connectors.length} registered</small></span></div> <div className="connector-score"><strong>{healthyConnectors}</strong><span>verified healthy routes<small>of {snapshot.connectors.length} registered</small></span></div>
<div className="stack-list">{snapshot.connectors.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No connectors registered.</span></div> : snapshot.connectors.slice(0, 5).map((connector) => <div className="stack-item" key={`${connector.project}-${connector.name}`}><div><strong>{connector.name}</strong><small>{connector.project} · {connector.detail}</small></div><StatusPill value={stale ? "degraded" : connector.state} /></div>)}</div> <div className="stack-list">{snapshot.connectors.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No connectors registered.</span></div> : snapshot.connectors.slice(0, 5).map((connector) => <div className="stack-item" key={`${connector.project}-${connector.name}`}><div><strong>{connector.name}</strong><small>{connector.project} · {connector.detail}</small></div><StatusPill value={stale ? "degraded" : connector.state} /></div>)}</div>
</article> </article>
</section> </section>
<section className="metric-grid secondary-grid"> <section className="metric-grid secondary-grid">
<MetricCard label="Active clients" value={`${snapshot.activeClients}`} detail="Enabled management and MCP identities" /> <MetricCard label="Active clients" value={`${snapshot.activeClients}`} detail="Enabled management and MCP identities" />
<MetricCard label="Recent denials" value={`${snapshot.denials.count}`} detail={`${snapshot.denials.window}${snapshot.denials.latestTool ? ` · latest ${snapshot.denials.latestTool}` : ""}`} /> <MetricCard label="Recent denials" value={`${snapshot.denials.count}`} detail={`${snapshot.denials.window}${snapshot.denials.latestTool ? ` · latest ${snapshot.denials.latestTool}` : ""}`} />
+52 -53
View File
@@ -1,81 +1,80 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { createApiClient, safeApiMessage } from "../api"; import { safeApiMessage } from "../api";
import type { DashboardSnapshot, OperationsCheck, OperationsSummary } from "../contracts"; import type { DashboardSnapshot, ManagementApiClient, OperationsCheck, OperationsSummary } from "../contracts";
import { isSnapshotStale } from "../contracts";
import { formatBrusselsTime } from "../format"; import { formatBrusselsTime } from "../format";
import { Breadcrumbs } from "../components/Breadcrumbs"; import { Breadcrumbs } from "../components/Breadcrumbs";
import { Icon } from "../components/Icon"; import { Icon } from "../components/Icon";
import { MetricCard } from "../components/MetricCard"; import { MetricCard } from "../components/MetricCard";
import { StatusPill } from "../components/StatusPill"; import { StatusPill } from "../components/StatusPill";
import { useCan } from "../rbac-context"; import { useCan } from "../rbac-context";
import { aggregateTrend, alertDestination, type TrendBucket } from "../operator-data";
import { refreshLabel, useStatusRefresh } from "../status-refresh";
function SummaryLoading() { function SummaryLoading() {
return <div className="inline-loading" role="status"><span className="spinner spinner-small" aria-hidden="true" /><span>Loading operational summary…</span></div>; return <div className="inline-loading" role="status"><span className="spinner spinner-small" aria-hidden="true" /><span>Loading operational summary…</span></div>;
} }
function hourLabel(hour: string): string {
return new Intl.DateTimeFormat("en-GB", { timeZone: "Europe/Brussels", hour: "2-digit", minute: "2-digit" }).format(new Date(hour));
}
function Trend({ points, label }: { points: TrendBucket[]; label: string }) {
const [selected, setSelected] = useState<TrendBucket | null>(null);
const maximum = Math.max(1, ...points.map((item) => item.success + item.denied + item.error + item.rate_limited));
const description = (point: TrendBucket) => `${formatBrusselsTime(point.hour)}${point.hour !== point.endHour ? ` to ${hourLabel(point.endHour)}` : ""}: ${point.success} success, ${point.denied} denied, ${point.error} errors, ${point.rate_limited} rate limited`;
return <div className={label === "Four-hour call buckets" ? "mobile-trend" : "desktop-trend"}>
<div className="trend-chart" role="group" aria-label={label}>{points.map((point) => {
const total = point.success + point.denied + point.error + point.rate_limited;
return <button type="button" className="trend-column" key={point.hour} aria-label={description(point)} aria-pressed={selected?.hour === point.hour} onFocus={() => setSelected(point)} onClick={() => setSelected(point)}>
<span className="trend-total">{total || ""}</span><span className="trend-stack" style={{ height: `${Math.max(4, (total / maximum) * 100)}%` }}>{total ? <><i className="trend-success" style={{ flexGrow: point.success }} /><i className="trend-denied" style={{ flexGrow: point.denied }} /><i className="trend-error" style={{ flexGrow: point.error }} /><i className="trend-limited" style={{ flexGrow: point.rate_limited }} /></> : <i className="trend-empty" />}</span><small>{hourLabel(point.hour)}</small>
</button>;
})}</div><p className="trend-detail" role="status">{selected ? description(selected) : "Select a time bucket for its outcomes. Times shown in Brussels."}</p>
</div>;
}
export function Operations({ path, navigate, snapshot }: { path: string; navigate: (next: string) => void; snapshot: DashboardSnapshot | null }) { type OperationalRead = { snapshot: DashboardSnapshot; summary: OperationsSummary };
const [api] = useState(() => createApiClient()); export function Operations({ path, navigate, snapshot: initialSnapshot, api }: { path: string; navigate: (next: string) => void; snapshot: DashboardSnapshot | null; api: ManagementApiClient }) {
const canCheck = useCan("operations.check"); const canCheck = useCan("operations.check");
const [summary, setSummary] = useState<OperationsSummary | null>(null); const canReadAudit = useCan("audit.read");
const read = useCallback(async () => { const [snapshot, summary] = await Promise.all([api.dashboard(), api.operationsSummary()]); return { snapshot, summary }; }, [api]);
const stale = useCallback((value: OperationalRead) => isSnapshotStale(value.snapshot) || Date.now() - Date.parse(value.summary.generatedAt) > 60_000, []);
const { value, status, error: refreshError, refresh } = useStatusRefresh<OperationalRead>(null, read, stale);
const snapshot = value?.snapshot ?? initialSnapshot;
const summary = value?.summary ?? null;
const summaryLoading = !summary && status === "refreshing";
const [check, setCheck] = useState<OperationsCheck | null>(null); const [check, setCheck] = useState<OperationsCheck | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [summaryLoading, setSummaryLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const section = new URLSearchParams(path.split("?")[1] ?? "").get("section");
useEffect(() => { useEffect(() => { if (section && ["alerts", "checks", "recovery"].includes(section)) document.getElementById(`operations-${section}`)?.scrollIntoView({ block: "start" }); }, [section, summary]);
let active = true;
setSummaryLoading(true);
void Promise.all([api.session(), api.operationsSummary()])
.then(([, value]) => { if (active) setSummary(value); })
.catch((caught) => { if (active) setError(safeApiMessage(caught)); })
.finally(() => { if (active) setSummaryLoading(false); });
return () => { active = false; };
}, [api]);
const runCheck = async () => { const runCheck = async () => {
setBusy(true); if (busy) return;
setError(""); setBusy(true); setError("");
try { try { setCheck(await api.runOperationsCheck()); await refresh(); }
setCheck(await api.runOperationsCheck()); catch (caught) { setError(safeApiMessage(caught)); }
setSummary(await api.operationsSummary()); finally { setBusy(false); }
} catch (caught) {
setError(safeApiMessage(caught));
} finally {
setBusy(false);
}
}; };
if (!snapshot) return <section className="panel">{refreshError ? <div className="notice notice-danger" role="alert">{refreshError}<button type="button" onClick={() => void refresh()}>Retry</button></div> : <SummaryLoading />}</section>;
if (!snapshot) return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate} /><section className="panel resource-page"><SummaryLoading /></section></div>;
const audit = summary?.audit24h; const audit = summary?.audit24h;
const totalCalls = (audit?.success ?? 0) + (audit?.denied ?? 0) + (audit?.error ?? 0) + (audit?.rate_limited ?? 0); const totalCalls = audit ? audit.success + audit.denied + audit.error + audit.rate_limited : null;
const maximum = Math.max(1, ...(summary?.trend.map((item) => item.success + item.denied + item.error + item.rate_limited) ?? [1])); const snapshotStale = isSnapshotStale(snapshot);
return <div className="resource-stack operations-page"> return <div className="resource-stack operations-page">
<Breadcrumbs path={path} navigate={navigate} /> <Breadcrumbs path={path} navigate={navigate} />
<section className="panel resource-page operations-overview"> <section className="panel resource-page operations-overview" id="operations-checks">
<div className="panel-heading"><div><p className="eyebrow">Operational health</p><h2>Operations</h2><p className="resource-description">Validate the live connector path, reconcile signal, governed call volume and recovery posture.</p></div>{canCheck ? <button className="button button-small" onClick={() => void runCheck()} disabled={busy}>{busy ? <><span className="spinner spinner-small" aria-hidden="true" />Checking live routes…</> : <><Icon name="refresh" size={14} />Run live checks</>}</button> : null}</div> <div className="panel-heading"><div><p className="eyebrow">Operational health</p><h2>Operations</h2><p className="resource-description">Inspect connector checks, catalog parity, governed calls and recovery evidence.</p><span className={`freshness freshness-${status}`} role="status">{refreshLabel[status]}</span></div><div className="operations-actions"><button type="button" className="button button-small button-secondary" onClick={() => void refresh()} disabled={status === "refreshing"}><Icon name="refresh" size={14} />Refresh status</button>{canCheck ? <button className="button button-small" onClick={() => void runCheck()} disabled={busy}>{busy ? "Checking live routes…" : "Run live checks"}</button> : null}</div></div>
<div className="detail-grid"><MetricCard label="Reconcile drift" value={snapshot.drift.known ? `${snapshot.drift.count}` : "Unknown"} detail={snapshot.drift.known ? (snapshot.drift.projects.length ? snapshot.drift.projects.join(", ") : "No drift reported") : snapshot.drift.detail} status={!snapshot.drift.known ? "unknown" : snapshot.drift.count ? "degraded" : "healthy"} /><MetricCard label="Backup freshness" value={snapshot.backup.lastSuccessfulAt ? formatBrusselsTime(snapshot.backup.lastSuccessfulAt) : "Not recorded"} detail={`${snapshot.backup.detail}${snapshot.backup.restoreVerified ? ` · ${snapshot.backup.artifactCount ?? 0} artifacts` : ""}`} status={snapshot.backup.state} /><MetricCard label="Governed calls · 24h" value={`${totalCalls}`} detail={summaryLoading ? "Loading call totals…" : `${audit?.success ?? 0} success · ${audit?.denied ?? 0} denied · ${audit?.error ?? 0} errors · ${audit?.rate_limited ?? 0} limited`} /></div> <div className="detail-grid"><MetricCard label="Reconcile drift" value={snapshot.drift.known ? `${snapshot.drift.count}` : "Unknown"} detail={snapshot.drift.known ? (snapshot.drift.projects.length ? snapshot.drift.projects.join(", ") : "No drift reported") : snapshot.drift.detail} status={snapshotStale ? "degraded" : !snapshot.drift.known ? "unknown" : snapshot.drift.count ? "degraded" : "healthy"} /><MetricCard label="Backup freshness" value={snapshot.backup.lastSuccessfulAt ? formatBrusselsTime(snapshot.backup.lastSuccessfulAt) : "Not recorded"} detail={snapshot.backup.detail} status={snapshotStale ? "degraded" : snapshot.backup.state} /><MetricCard label="Governed calls · 24h" value={totalCalls === null ? "Unknown" : `${totalCalls}`} detail={audit ? `${audit.success} success · ${audit.denied} denied · ${audit.error} errors · ${audit.rate_limited} limited` : summaryLoading ? "Loading call totals…" : "Summary unavailable; retry refresh"} /></div>
{error ? <div className="notice notice-danger" role="alert">{error}</div> : null} {error || refreshError ? <div className="notice notice-danger" role="alert">{error || refreshError} Any retained observations keep their original timestamp.</div> : null}<p className="generated-time">Health snapshot {formatBrusselsTime(snapshot.generatedAt)}{snapshotStale ? " · stale" : ""}</p>
{check ? <div className={check.healthy ? "live-check-summary live-check-healthy" : "live-check-summary live-check-attention"}><div><StatusPill value={check.healthy ? "healthy" : "degraded"} /><strong>{check.healthy ? "All checked connector paths responded" : "One or more connector paths need attention"}</strong><small>Checked {formatBrusselsTime(check.generatedAt)}</small></div><div className="connector-checks" role="status">{Object.entries(check.connectors).map(([name, item]) => <div key={name}><StatusPill value={item.reachable ? "healthy" : "unavailable"} /><strong>{name}</strong><span>{item.status} · {item.detail}</span></div>)}</div></div> : null} {check ? <div className={check.healthy ? "live-check-summary live-check-healthy" : "live-check-summary live-check-attention"}><div><StatusPill value={check.healthy ? "healthy" : "degraded"} /><strong>{check.healthy ? "All checked connector paths responded" : "One or more connector paths need attention"}</strong><small>Checked {formatBrusselsTime(check.generatedAt)}</small></div><div className="connector-checks" role="status">{Object.entries(check.connectors).map(([name, item]) => <div key={name}><StatusPill value={item.reachable ? "healthy" : "unavailable"} /><strong>{name}</strong>{item.connectorRefs?.map((connector) => <a key={connector.id} href={`/connectors/${encodeURIComponent(connector.id)}`} onClick={(event) => { event.preventDefault(); navigate(`/connectors/${encodeURIComponent(connector.id)}`); }}>{connector.name}</a>)}{item.connectorRefsTruncated && <small>First 100 registrations shown</small>}<span>{item.status} · {item.detail}</span></div>)}</div><p className="muted">This is an explicit point-in-time probe; automatic refresh does not run checks.</p></div> : <div className="notice notice-info">{canCheck ? "Run live checks to record a point-in-time connector observation." : "Live connector probes require the operator role."}</div>}
</section> </section>
<section className="operations-grid"> <section className="operations-grid">
<article className="panel attention-panel"> <article className="panel attention-panel" id="operations-alerts"><div className="panel-heading"><div><p className="eyebrow">Notifications</p><h3>Current attention queue</h3></div>{summary ? <span className="tag">{summary.alerts.length} reported</span> : null}</div>
<div className="panel-heading"><div><p className="eyebrow">Notifications</p><h3>Current attention queue</h3></div>{summary ? <span className="tag">{summary.alerts.length} active</span> : null}</div> {summaryLoading ? <SummaryLoading /> : !summary ? <div className="notice notice-warning">Alerts are unknown while the summary is unavailable.</div> : summary.alerts.length ? <div className="alert-list">{summary.alerts.map((alert) => { const href = alertDestination(alert.code, canReadAudit); return <div className={`notice ${alert.severity === "warning" ? "notice-danger" : "notice-info"}`} key={alert.code}><strong>{alert.severity} · {alert.code.replaceAll("_", " ")}</strong><span>{alert.message}</span><a className="signal-next-step" href={href} onClick={(event) => { event.preventDefault(); navigate(href); }}>Inspect context <Icon name="arrow" size={14} /></a></div>; })}</div> : <div className="empty-line empty-success"><span aria-hidden="true">✓</span><span><strong>No alerts in the latest summary</strong><small>Generated {formatBrusselsTime(summary.generatedAt)}</small></span></div>}
{summaryLoading ? <SummaryLoading /> : summary?.alerts.length ? <div className="alert-list">{summary.alerts.map((alert) => <div className={`notice ${alert.severity === "warning" ? "notice-danger" : "notice-info"}`} key={alert.code}><strong>{alert.code.replaceAll("_", " ")}</strong><span>{alert.message}</span></div>)}</div> : <div className="empty-line empty-success"><span aria-hidden="true">✓</span><span><strong>No active operational alerts</strong><small>Current summaries report no condition requiring intervention.</small></span></div>}
</article> </article>
<article className="panel trend-panel"><div className="panel-heading"><div><p className="eyebrow">Last 24 hours</p><h3>Governed-call trend</h3></div>{summary ? <small className="generated-time">Updated {formatBrusselsTime(summary.generatedAt)}</small> : null}</div>
<article className="panel trend-panel"> {summary?.auditTruncated ? <div className="notice notice-warning">The bounded audit window is truncated; totals describe only the returned sample.</div> : null}<div className="chart-legend" aria-label="Chart legend"><span><i className="legend-success" />Success</span><span><i className="legend-denied" />Denied</span><span><i className="legend-error" />Error</span><span><i className="legend-limited" />Rate limited</span></div>
<div className="panel-heading"><div><p className="eyebrow">Last 24 hours</p><h3>Governed-call trend</h3></div>{summary ? <small className="generated-time">Updated {formatBrusselsTime(summary.generatedAt)}</small> : null}</div> {summaryLoading ? <SummaryLoading /> : summary?.trend.length ? <><Trend points={aggregateTrend(summary.trend, 1)} label="Hourly governed calls" /><Trend points={aggregateTrend(summary.trend, 4)} label="Four-hour call buckets" /></> : <div className="empty-line"><span aria-hidden="true">○</span><span>{summary ? "No call observations in this window" : "Call trend unavailable; refresh to retry"}</span></div>}
<div className="chart-legend" aria-label="Chart legend"><span><i className="legend-success" />Success</span><span><i className="legend-denied" />Denied</span><span><i className="legend-error" />Error</span><span><i className="legend-limited" />Rate limited</span></div>
{summaryLoading ? <SummaryLoading /> : summary?.trend.length ? <div className="trend-chart" role="img" aria-label={`Hourly governed calls. ${audit?.success ?? 0} successful, ${audit?.denied ?? 0} denied, ${audit?.error ?? 0} errors and ${audit?.rate_limited ?? 0} rate limited in the last 24 hours.`}>{summary.trend.map((point) => {
const total = point.success + point.denied + point.error + point.rate_limited;
const height = Math.max(4, (total / maximum) * 100);
return <div className="trend-column" key={point.hour} title={`${formatBrusselsTime(point.hour)} · ${total} calls`}><span className="trend-total">{total || ""}</span><span className="trend-stack" style={{ height: `${height}%` }}>{total ? <><i className="trend-success" style={{ flexGrow: point.success }} /><i className="trend-denied" style={{ flexGrow: point.denied }} /><i className="trend-error" style={{ flexGrow: point.error }} /><i className="trend-limited" style={{ flexGrow: point.rate_limited }} /></> : <i className="trend-empty" />}</span><small>{new Date(point.hour).getHours().toString().padStart(2, "0")}</small></div>;
})}</div> : <div className="empty-line"><span aria-hidden="true">○</span><span><strong>No governed calls in this window</strong><small>The chart will populate when audited traffic is recorded.</small></span></div>}
</article> </article>
</section> </section>
<section className="panel recovery-panel" id="operations-recovery"><div className="panel-heading"><div><p className="eyebrow">Recovery evidence</p><h3>Backup and restore</h3></div><StatusPill value={snapshotStale ? "degraded" : snapshot.backup.state} /></div><dl className="recovery-record"><dt>Last successful backup</dt><dd>{formatBrusselsTime(snapshot.backup.lastSuccessfulAt)}</dd><dt>Restore verification</dt><dd>{snapshot.backup.restoreVerified === true ? "Recorded as verified" : "Not verified in the current snapshot"}</dd><dt>Recorded artifacts</dt><dd>{snapshot.backup.artifactCount ?? "Unknown"}</dd><dt>Observation</dt><dd>{snapshot.backup.detail}</dd></dl><p className="muted">Recovery is an operator-run process. This browser displays evidence; it does not restore or change production state.</p></section>
<section className="panel approvals-panel"><div><p className="eyebrow">Controlled changes</p><h3>Approvals</h3><p className="muted">Approval workflow is not yet part of the authorized v1 management surface.</p></div><span className="boundary-badge">Intentionally unavailable</span></section> <section className="panel approvals-panel"><div><p className="eyebrow">Controlled changes</p><h3>Approvals</h3><p className="muted">Approval workflow is not yet part of the authorized v1 management surface.</p></div><span className="boundary-badge">Intentionally unavailable</span></section>
</div>; </div>;
} }
+193 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { ManagementApiClient, ProjectDetailInfo, ResourceKind, ResourceRecord } from "../contracts"; import type { ManagementApiClient, ProjectDetailInfo, ResourceKind, ResourceRecord } from "../contracts";
import { ApiError, safeApiMessage } from "../api"; import { ApiError, safeApiMessage } from "../api";
import { Breadcrumbs } from "../components/Breadcrumbs"; import { Breadcrumbs } from "../components/Breadcrumbs";
@@ -7,11 +7,62 @@ import { LoadingPanel } from "../components/LoadingPanel";
import { MetricCard } from "../components/MetricCard"; import { MetricCard } from "../components/MetricCard";
import { basePath } from "../routing"; import { basePath } from "../routing";
import { useCan } from "../rbac-context"; import { useCan } from "../rbac-context";
import "../styles/details.css";
const projectTabs = ["Summary", "Environments", "MCP Servers", "Capabilities", "Connectors", "Knowledge Sources", "Clients", "Audit"] as const; const projectTabs = ["Summary", "Environments", "MCP Servers", "Capabilities", "Connectors", "Knowledge Sources", "Clients", "Audit"] as const;
type ProjectTab = typeof projectTabs[number];
export interface DetailCardItem {
id: string;
title: string;
summary?: string;
status?: string;
fields: Array<{ label: string; value: string }>;
action?: { label: string; onSelect: () => void };
}
export function DetailCards({ label, cards }: { label: string; cards: DetailCardItem[] }) {
return <div className="detail-card-grid" aria-label={label}>
{cards.map((card) => <article className="detail-card" key={card.id}>
<div className="detail-card-heading">
<h3>{card.title}</h3>
{card.status && <span className="detail-card-status">{card.status}</span>}
</div>
{card.summary && <p className="detail-card-summary">{card.summary}</p>}
<dl className="detail-card-fields">{card.fields.map((field) => <div className="detail-card-field" key={`${card.id}-${field.label}`}>
<dt>{field.label}</dt><dd>{field.value}</dd>
</div>)}</dl>
{card.action && <button className="button button-quiet detail-card-action" type="button" onClick={card.action.onSelect}>{card.action.label}</button>}
</article>)}
</div>;
}
function DetailTable({ label, headers, rows }: { label: string; headers: string[]; rows: { key: string; values: string[] }[] }) { function DetailTable({ label, headers, rows }: { label: string; headers: string[]; rows: { key: string; values: string[] }[] }) {
return <><div className="table-wrap desktop-data-table" role="region" tabIndex={0} aria-label={`${label} table`}><table><caption className="sr-only">{label}</caption><thead><tr>{headers.map((header) => <th key={header}>{header}</th>)}</tr></thead><tbody>{rows.map((row) => <tr key={row.key}>{row.values.map((value, index) => <td key={`${row.key}-${headers[index]}`}>{value}</td>)}</tr>)}</tbody></table></div><div className="mobile-record-list" aria-label={label}>{rows.map((row) => <article className="mobile-record" key={row.key}><dl>{row.values.map((value, index) => <><dt key={`${row.key}-dt-${headers[index]}`}>{headers[index]}</dt><dd key={`${row.key}-dd-${headers[index]}`}>{value}</dd></>)}</dl></article>)}</div></>; return <>
<div className="table-wrap desktop-data-table" role="region" tabIndex={0} aria-label={`${label} table`}>
<table><caption className="sr-only">{label}</caption><thead><tr>{headers.map((header) => <th key={header}>{header}</th>)}</tr></thead>
<tbody>{rows.map((row) => <tr key={row.key}>{row.values.map((value, index) => <td key={`${row.key}-${headers[index]}`}>{value}</td>)}</tr>)}</tbody>
</table>
</div>
<div className="mobile-record-list" aria-label={label}>{rows.map((row) => <article className="mobile-record" key={row.key}>
<dl>{row.values.map((value, index) => <div className="mobile-record-field" key={`${row.key}-${headers[index]}`}><dt>{headers[index]}</dt><dd>{value}</dd></div>)}</dl>
</article>)}</div>
</>;
}
async function loadScopedRows(api: ManagementApiClient, kind: ResourceKind, project: string): Promise<{ rows: ResourceRecord[]; total: number; truncated: boolean }> {
const rows: ResourceRecord[] = [];
let cursor: string | undefined;
let total = 0;
let pages = 0;
do {
const page = await api.resource(kind, { filter: "", limit: 100, cursor, project });
rows.push(...page.rows);
total = page.page.total;
cursor = page.page.nextCursor;
pages += 1;
} while (cursor && pages < 10);
return { rows, total, truncated: Boolean(cursor) };
} }
export function ProjectDetail({ path, navigate, api }: { path: string; navigate: (next: string) => void; api: ManagementApiClient }) { export function ProjectDetail({ path, navigate, api }: { path: string; navigate: (next: string) => void; api: ManagementApiClient }) {
@@ -19,17 +70,149 @@ export function ProjectDetail({ path, navigate, api }: { path: string; navigate:
const requestedTab = new URLSearchParams(path.split("?")[1] ?? "").get("tab") ?? "Summary"; const requestedTab = new URLSearchParams(path.split("?")[1] ?? "").get("tab") ?? "Summary";
const canReadAudit = useCan("audit.read"); const canReadAudit = useCan("audit.read");
const tabs = projectTabs.filter((item) => item !== "Audit" || canReadAudit); const tabs = projectTabs.filter((item) => item !== "Audit" || canReadAudit);
const tab = tabs.includes(requestedTab as typeof tabs[number]) ? requestedTab : "Summary"; const tab = tabs.includes(requestedTab as ProjectTab) ? requestedTab as ProjectTab : "Summary";
const [detail, setDetail] = useState<ProjectDetailInfo | null>(null); const [detail, setDetail] = useState<ProjectDetailInfo | null>(null);
const [state, setState] = useState<"loading" | "error" | "notFound">("loading"); const [state, setState] = useState<"loading" | "ready" | "error" | "notFound">("loading");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [scopedRows, setScopedRows] = useState<ResourceRecord[] | null>(null); const loadGeneration = useRef(0);
const load = async () => { setState("loading"); try { setDetail(await api.projectDetail(slug)); setState("loading"); } catch (caught) { if (caught instanceof ApiError && caught.status === 404) setState("notFound"); else { setError(safeApiMessage(caught)); setState("error"); } } }; const [scopedRows, setScopedRows] = useState<ResourceRecord[]>([]);
useEffect(() => { void load(); }, [slug]); const [scopedState, setScopedState] = useState<"idle" | "loading" | "ready" | "error">("idle");
useEffect(() => { if (tab !== "Clients" && tab !== "Audit") { setScopedRows(null); return; } const kind: ResourceKind = tab === "Clients" ? "clients" : "audit"; let active = true; void api.resource(kind, { filter: "", limit: 100 }).then((page) => { if (active) setScopedRows(page.rows.filter((row) => row.project === slug)); }).catch(() => { if (active) setScopedRows([]); }); return () => { active = false; }; }, [api, tab, slug]); const [scopedError, setScopedError] = useState("");
const [scopedTruncated, setScopedTruncated] = useState(false);
const [scopedTotal, setScopedTotal] = useState(0);
const [scopedRevision, setScopedRevision] = useState(0);
const load = useCallback(async () => {
const generation = ++loadGeneration.current;
setState("loading");
setDetail(null);
setError("");
try {
const next = await api.projectDetail(slug);
if (generation !== loadGeneration.current) return;
setDetail(next);
setState("ready");
} catch (caught) {
if (generation !== loadGeneration.current) return;
if (caught instanceof ApiError && caught.status === 404) setState("notFound");
else { setError(safeApiMessage(caught)); setState("error"); }
}
}, [api, slug]);
useEffect(() => { void load(); return () => { loadGeneration.current += 1; }; }, [load]);
useEffect(() => {
if (tab !== "Clients" && tab !== "Audit") { setScopedState("idle"); return; }
const kind: ResourceKind = tab === "Clients" ? "clients" : "audit";
let active = true;
setScopedState("loading");
setScopedError("");
setScopedTruncated(false);
void loadScopedRows(api, kind, slug).then((result) => {
if (!active) return;
setScopedRows(result.rows);
setScopedTotal(result.total);
setScopedTruncated(result.truncated);
setScopedState("ready");
}).catch((caught: unknown) => {
if (!active) return;
setScopedError(safeApiMessage(caught));
setScopedState("error");
});
return () => { active = false; };
}, [api, tab, slug, scopedRevision]);
if (state === "notFound") return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate}/><section className="panel resource-page"><div className="notice notice-danger" role="alert">Project <strong>{slug}</strong> is not registered.</div></section></div>; if (state === "notFound") return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate}/><section className="panel resource-page"><div className="notice notice-danger" role="alert">Project <strong>{slug}</strong> is not registered.</div></section></div>;
if (state === "error") return <ErrorPanel message={error} retry={() => void load()}/>; if (state === "error") return <ErrorPanel message={error} retry={() => void load()}/>;
if (!detail) return <LoadingPanel />; if (!detail) return <LoadingPanel />;
const scopedTable = scopedRows === null ? <LoadingPanel /> : scopedRows.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No {tab.toLowerCase()} records for this project.</span></div> : <DetailTable label={tab} headers={["Name", "Status", "Summary"]} rows={scopedRows.map((row) => ({ key: row.id, values: [row.name, row.status, row.summary] }))} />;
return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate}/><section className="panel resource-page"><p className="eyebrow">Project workspace</p><h2>{detail.name}</h2><p className="muted">{detail.description || "Scoped inspection for this project. Reads are bounded by project and environment context."}</p><nav className="tabs" aria-label="Project detail sections">{tabs.map((item) => <a key={item} aria-current={tab === item ? "page" : undefined} className={tab === item ? "tab active" : "tab"} href={`/projects/${slug}?tab=${encodeURIComponent(item)}`} onClick={(event) => { event.preventDefault(); navigate(`/projects/${slug}?tab=${encodeURIComponent(item)}`); }}>{item}</a>)}</nav>{tab === "Summary" && <div className="detail-grid"><MetricCard label="Environments" value={`${detail.environments.length}`} detail={detail.environments.map((item) => item.name).join(", ") || "None registered"} status={detail.environments.length ? "healthy" : "unknown"}/><MetricCard label="Clients" value={`${detail.activeClientCount} / ${detail.clientCount}`} detail="Active of total registered clients"/><MetricCard label="Virtual servers" value={`${detail.virtualServerCount}`} detail="Read-only virtual servers"/></div>}{tab === "Environments" && (detail.environments.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No environments registered for this project.</span></div> : <DetailTable label="Environments" headers={["Environment", "Classification"]} rows={detail.environments.map((item) => ({ key: item.name, values: [item.name, item.classification ?? "unclassified"] }))} />)}{tab === "Capabilities" && (detail.capabilityPacks.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No capability packs registered.</span></div> : <div className="stack-list">{detail.capabilityPacks.map((pack) => <div className="stack-item" key={pack}><strong>{pack}</strong></div>)}</div>)}{tab === "Connectors" && (detail.connectorBindings.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No connector bindings registered.</span></div> : <DetailTable label="Connector bindings" headers={["Ref", "Type", "Environments", "Verification"]} rows={detail.connectorBindings.map((item) => ({ key: item.ref, values: [item.ref, item.connectorType ?? "unknown", item.environments.join(", ") || "—", item.verification ?? "unverified"] }))} />)}{tab === "MCP Servers" && <div className="notice notice-info">Per-server inspection is not yet part of the authorized v1 read surface. {detail.virtualServerCount} virtual server(s) are provisioned for this project.</div>}{tab === "Knowledge Sources" && <div className="notice notice-info">Knowledge source listing is not yet part of the authorized v1 read surface.</div>}{(tab === "Clients" || tab === "Audit") && scopedTable}</section></div>; const scopedRowsContent = scopedState === "loading" ? <LoadingPanel />
: scopedState === "error" ? <div className="notice notice-danger" role="alert"><p>{scopedError}</p><button className="button button-quiet" type="button" onClick={() => setScopedRevision((value) => value + 1)}>Retry</button></div>
: scopedRows.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No {tab.toLowerCase()} records for this project.</span></div>
: tab === "Clients" ? <DetailCards label="Project clients" cards={scopedRows.map((row) => ({
id: row.id, title: row.name, status: row.status, summary: row.summary,
fields: [
{ label: "Environment", value: row.environment ?? "Unknown" },
{ label: "Onboarding", value: row.onboardingStage ?? "Not managed by onboarding" },
],
action: row.onboardingStage ? { label: "Resume onboarding", onSelect: () => navigate(`/clients?onboard=${encodeURIComponent(row.id)}`) } : undefined,
}))} />
: <DetailTable label={tab} headers={["Name", "Status", "Summary"]} rows={scopedRows.map((row) => ({ key: row.id, values: [row.name, row.status, row.summary] }))} />;
const connectorCards: DetailCardItem[] = detail.connectorBindings.map((item) => ({
id: item.ref,
title: item.connectorType ?? item.ref,
summary: `Catalog connector binding: ${item.ref}`,
status: item.verification ?? "unverified",
fields: [{ label: "Environments", value: item.environments.join(", ") || "None" }],
action: { label: "Open connector detail", onSelect: () => navigate(`/connectors/${encodeURIComponent(item.ref)}`) },
}));
const serverCards: DetailCardItem[] = detail.mcpServers.map((server) => ({
id: server.slug,
title: server.slug,
summary: server.stateDetail,
status: server.runtimeState,
fields: [
{ label: "Environment", value: server.environment },
{ label: "Endpoint alias", value: server.endpointAlias },
{ label: "Enabled", value: server.enabled === null ? "No persisted state" : server.enabled ? "Yes" : "No" },
{ label: "Default decision", value: server.defaultDecision },
{ label: "Protocol revisions", value: server.protocolRevisions.join(", ") || "None registered" },
],
}));
const knowledgeCards: DetailCardItem[] = detail.knowledgeSources.map((source) => ({
id: source.id,
title: source.uri,
summary: source.accessBoundary,
status: source.verification,
fields: [
{ label: "Included paths", value: source.include.join(", ") || "No path filters registered" },
{ label: "Maximum document size", value: source.maxDocumentBytes === null ? "Unspecified" : `${source.maxDocumentBytes.toLocaleString()} bytes` },
{ label: "Indexes secrets", value: source.indexesSecrets ? "Catalog marks this source as indexing secrets" : "No" },
],
}));
return <div className="resource-stack">
<Breadcrumbs path={path} navigate={navigate}/>
<section className="panel resource-page">
<p className="eyebrow">Project workspace</p><h2>{detail.name}</h2>
<p className="muted">{detail.description || "Scoped inspection for this project. Reads are bounded by project and environment context."}</p>
<label className="detail-mobile-section"><span>Project section</span>
<select value={tab} onChange={(event) => navigate(`/projects/${encodeURIComponent(slug)}?tab=${encodeURIComponent(event.target.value)}`)}>
{tabs.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
<nav className="tabs detail-desktop-tabs" aria-label="Project detail sections">
{tabs.map((item) => <a key={item} aria-current={tab === item ? "page" : undefined} className={tab === item ? "tab active" : "tab"}
href={`/projects/${encodeURIComponent(slug)}?tab=${encodeURIComponent(item)}`}
onClick={(event) => { event.preventDefault(); navigate(`/projects/${encodeURIComponent(slug)}?tab=${encodeURIComponent(item)}`); }}>{item}</a>)}
</nav>
{tab === "Summary" && <div className="detail-grid">
<MetricCard label="Environments" value={`${detail.environments.length}`} detail={detail.environments.map((item) => item.name).join(", ") || "None registered"} status={detail.environments.length ? "healthy" : "unknown"}/>
<MetricCard label="Clients" value={`${detail.activeClientCount} / ${detail.clientCount}`} detail="Active of total registered clients"/>
<MetricCard label="MCP servers" value={`${detail.virtualServerCount}`} detail="Cataloged project server routes"/>
</div>}
{tab === "Environments" && (detail.environments.length === 0
? <div className="empty-line"><span aria-hidden="true">○</span><span>No environments registered for this project.</span></div>
: <DetailCards label="Project environments" cards={detail.environments.map((item) => ({ id: item.name, title: item.name, status: item.classification ?? "unclassified", fields: [{ label: "Classification", value: item.classification ?? "Unclassified" }] }))} />)}
{tab === "Capabilities" && (detail.capabilityPacks.length === 0
? <div className="empty-line"><span aria-hidden="true">○</span><span>No capability packs registered.</span></div>
: <DetailCards label="Capability packs" cards={detail.capabilityPacks.map((pack) => ({ id: pack, title: pack, fields: [] }))} />)}
{tab === "Connectors" && (connectorCards.length === 0
? <div className="empty-line"><span aria-hidden="true">○</span><span>No connector bindings registered.</span></div>
: <DetailCards label="Connector bindings" cards={connectorCards} />)}
{tab === "MCP Servers" && (serverCards.length === 0
? <div className="empty-line"><span aria-hidden="true">○</span><span>No MCP servers are registered for this project.</span></div>
: <DetailCards label="MCP servers" cards={serverCards} />)}
{tab === "Knowledge Sources" && <>
<p className="detail-boundary-note">This view shows cataloged source registrations and verification metadata. Original document contents remain behind the scoped Knowledge connector and project policy.</p>
{knowledgeCards.length === 0
? <div className="empty-line"><span aria-hidden="true">○</span><span>No knowledge sources are registered for this project.</span></div>
: <DetailCards label="Knowledge sources" cards={knowledgeCards} />}
</>}
{(tab === "Clients" || tab === "Audit") && <>
{scopedTruncated && <p className="notice notice-info">Showing the first {scopedRows.length} of at least {scopedTotal} records. Narrow the resource view to inspect more.</p>}
{scopedRowsContent}
</>}
</section>
</div>;
} }
+41 -17
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { ManagementApiClient, ResourceKind, ResourcePage as ResourcePageRecord, ResourceRecord } from "../contracts"; import type { ClientOnboardingResult, ManagementApiClient, ResourceKind, ResourcePage as ResourcePageRecord, ResourceRecord } from "../contracts";
import { routeTitle } from "../contracts"; import { routeTitle } from "../contracts";
import { safeApiMessage } from "../api"; import { safeApiMessage } from "../api";
import { Breadcrumbs } from "../components/Breadcrumbs"; import { Breadcrumbs } from "../components/Breadcrumbs";
@@ -13,6 +13,8 @@ import { useCan } from "../rbac-context";
import { CatalogWorkflow } from "./CatalogWorkflow"; import { CatalogWorkflow } from "./CatalogWorkflow";
import { ClientOnboarding } from "./ClientOnboarding"; import { ClientOnboarding } from "./ClientOnboarding";
import { PolicySimulator } from "./PolicySimulator"; import { PolicySimulator } from "./PolicySimulator";
import { readPreference, writePreference } from "../preferences";
import { RecentToolRuns } from "../components/RecentToolRuns";
import { ToolReadinessModal } from "./ToolReadinessModal"; import { ToolReadinessModal } from "./ToolReadinessModal";
const descriptions: Record<ResourceKind, string> = { const descriptions: Record<ResourceKind, string> = {
@@ -27,7 +29,7 @@ const descriptions: Record<ResourceKind, string> = {
type Feedback = { tone: "info" | "danger"; message: string } | null; type Feedback = { tone: "info" | "danger"; message: string } | null;
export function ResourcePage({ path, kind, api, navigate }: { path: string; kind: ResourceKind; api: ManagementApiClient; navigate: (next: string) => void }) { export function ResourcePage({ path, kind, api, navigate, onClientCreated }: { path: string; kind: ResourceKind; api: ManagementApiClient; navigate: (next: string) => void; onClientCreated?: (client: ClientOnboardingResult["client"]) => void }) {
const canDisableClients = useCan("client.disable"); const canDisableClients = useCan("client.disable");
const canTestTools = useCan("tool.test"); const canTestTools = useCan("tool.test");
const params = new URLSearchParams(path.split("?")[1] ?? ""); const params = new URLSearchParams(path.split("?")[1] ?? "");
@@ -38,7 +40,11 @@ export function ResourcePage({ path, kind, api, navigate }: { path: string; kind
const [error, setError] = useState(""); const [error, setError] = useState("");
const [feedback, setFeedback] = useState<Feedback>(null); const [feedback, setFeedback] = useState<Feedback>(null);
const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([undefined]); const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([undefined]);
const [density, setDensity] = useState<Density>("comfortable"); const [density, setDensity] = useState<Density>(() => readPreference("hub-density", ["comfortable", "compact"], "comfortable") as Density);
const [busyClientId, setBusyClientId] = useState<string | null>(null);
const reopened = params.get("reopen");
const initialProject = params.get("project") ?? "";
const initialEnvironment = params.get("environment") ?? "";
const [testingTool, setTestingTool] = useState<ResourceRecord | null>(null); const [testingTool, setTestingTool] = useState<ResourceRecord | null>(null);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const cursor = cursorStack[cursorStack.length - 1]; const cursor = cursorStack[cursorStack.length - 1];
@@ -50,20 +56,34 @@ export function ResourcePage({ path, kind, api, navigate }: { path: string; kind
}, [kind, canDisableClients, canTestTools]); }, [kind, canDisableClients, canTestTools]);
useEffect(() => { setFilterInput(urlFilter); }, [urlFilter]); useEffect(() => { setFilterInput(urlFilter); }, [urlFilter]);
useEffect(() => {
if (kind !== "tools" || !reopened || !canTestTools) return;
let active = true;
void api.toolDefinition(reopened).then((definition) => {
if (active) setTestingTool({ id: definition.canonicalName, canonicalName: definition.canonicalName, name: definition.canonicalName, status: "unknown", summary: definition.description, risk: definition.riskClass });
}).catch((caught) => { if (active) setFeedback({ tone: "danger", message: safeApiMessage(caught) }); });
return () => { active = false; };
}, [api, canTestTools, kind, reopened]);
const changeDensity = (next: Density) => { setDensity(next); writePreference("hub-density", next); };
useEffect(() => { useEffect(() => {
const normalizedFilter = filterInput.trim(); const normalizedFilter = filterInput.trim();
if (normalizedFilter === urlFilter) return; if (normalizedFilter === urlFilter) return;
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
navigate(normalizedFilter ? `/${kind}?q=${encodeURIComponent(normalizedFilter)}` : `/${kind}`); const next = new URLSearchParams();
if (initialProject) next.set("project", initialProject);
if (initialEnvironment) next.set("environment", initialEnvironment);
if (normalizedFilter) next.set("q", normalizedFilter);
navigate(`/${kind}${next.size ? `?${next.toString()}` : ""}`);
}, 260); }, 260);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [filterInput, kind, navigate, urlFilter]); }, [filterInput, initialEnvironment, initialProject, kind, navigate, urlFilter]);
useEffect(() => { setCursorStack([undefined]); }, [kind, urlFilter]); useEffect(() => { setCursorStack([undefined]); }, [initialEnvironment, initialProject, kind, urlFilter]);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
setState("loading"); setState("loading");
setError(""); setError("");
void api.resource(kind, { filter: urlFilter, cursor, limit: 25 }) void api.resource(kind, { filter: urlFilter, cursor, limit: 25, project: initialProject || undefined, environment: initialEnvironment || undefined })
.then((value) => { .then((value) => {
if (!active) return; if (!active) return;
setData(value); setData(value);
@@ -75,7 +95,7 @@ export function ResourcePage({ path, kind, api, navigate }: { path: string; kind
setState("error"); setState("error");
}); });
return () => { active = false; }; return () => { active = false; };
}, [api, cursor, kind, refreshKey, urlFilter]); }, [api, cursor, initialEnvironment, initialProject, kind, refreshKey, urlFilter]);
const nextPage = () => { const nextPage = () => {
if (data?.page.nextCursor) setCursorStack((stack) => [...stack, data.page.nextCursor]); if (data?.page.nextCursor) setCursorStack((stack) => [...stack, data.page.nextCursor]);
@@ -83,6 +103,8 @@ export function ResourcePage({ path, kind, api, navigate }: { path: string; kind
const previousPage = () => setCursorStack((stack) => (stack.length > 1 ? stack.slice(0, -1) : stack)); const previousPage = () => setCursorStack((stack) => (stack.length > 1 ? stack.slice(0, -1) : stack));
const retry = () => setRefreshKey((value) => value + 1); const retry = () => setRefreshKey((value) => value + 1);
const action = async (id: string, verb: "disable" | "revoke") => { const action = async (id: string, verb: "disable" | "revoke") => {
if (busyClientId) return;
setBusyClientId(id);
setFeedback({ tone: "info", message: "Submitting server-authorized client action…" }); setFeedback({ tone: "info", message: "Submitting server-authorized client action…" });
try { try {
const result = await api.clientAction(id, verb); const result = await api.clientAction(id, verb);
@@ -90,15 +112,17 @@ export function ResourcePage({ path, kind, api, navigate }: { path: string; kind
retry(); retry();
} catch (caught) { } catch (caught) {
setFeedback({ tone: "danger", message: safeApiMessage(caught) }); setFeedback({ tone: "danger", message: safeApiMessage(caught) });
} } finally { setBusyClientId(null); }
}; };
const config = async (id: string) => { const config = async (id: string) => {
if (busyClientId) return;
setBusyClientId(id);
try { try {
const value = await api.generatedConfig(id); const value = await api.generatedConfig(id);
setFeedback({ tone: "info", message: `Generated configuration for ${value.clientId}: ${value.endpoint} · scopes ${value.scopes.join(", ")}. Secret material is write-only and is not displayed.` }); setFeedback({ tone: "info", message: `Generated configuration for ${value.clientId}: ${value.endpoint} · scopes ${value.scopes.join(", ")}. Secret material is write-only and is not displayed.` });
} catch (caught) { } catch (caught) {
setFeedback({ tone: "danger", message: safeApiMessage(caught) }); setFeedback({ tone: "danger", message: safeApiMessage(caught) });
} } finally { setBusyClientId(null); }
}; };
if (!hasCurrentData && state === "error") return <ErrorPanel message={error} retry={retry} />; if (!hasCurrentData && state === "error") return <ErrorPanel message={error} retry={retry} />;
@@ -118,8 +142,8 @@ export function ResourcePage({ path, kind, api, navigate }: { path: string; kind
</label> </label>
<div className="table-controls" role="group" aria-label="Table density"> <div className="table-controls" role="group" aria-label="Table density">
<span>Density</span> <span>Density</span>
<button className={density === "comfortable" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={density === "comfortable"} onClick={() => setDensity("comfortable")}>Comfortable</button> <button className={density === "comfortable" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={density === "comfortable"} onClick={() => changeDensity("comfortable")}>Comfortable</button>
<button className={density === "compact" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={density === "compact"} onClick={() => setDensity("compact")}>Compact</button> <button className={density === "compact" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={density === "compact"} onClick={() => changeDensity("compact")}>Compact</button>
</div> </div>
</div> </div>
<div className={isRefreshing ? "resource-results results-refreshing" : "resource-results"}> <div className={isRefreshing ? "resource-results results-refreshing" : "resource-results"}>
@@ -127,17 +151,17 @@ export function ResourcePage({ path, kind, api, navigate }: { path: string; kind
{state === "error" ? <div className="notice notice-danger" role="alert"><span>{error}</span><button type="button" onClick={retry}>Retry</button></div> : null} {state === "error" ? <div className="notice notice-danger" role="alert"><span>{error}</span><button type="button" onClick={retry}>Retry</button></div> : null}
{data.rows.length === 0 {data.rows.length === 0
? <div className="empty-line"><span aria-hidden="true">○</span><span><strong>No matching records</strong><small>{urlFilter ? "Clear or broaden the current filter." : "Nothing has been registered in this workspace yet."}</small></span></div> ? <div className="empty-line"><span aria-hidden="true">○</span><span><strong>No matching records</strong><small>{urlFilter ? "Clear or broaden the current filter." : "Nothing has been registered in this workspace yet."}</small></span></div>
: <DataTable data={data} kind={kind} density={density} visibleColumns={visibleColumns} onClientAction={action} onConfig={config} onTest={setTestingTool} />} : <DataTable data={data} kind={kind} density={density} visibleColumns={visibleColumns} onClientAction={action} onConfig={config} onTest={setTestingTool} navigate={navigate} busyClientId={busyClientId} />}
</div> </div>
<div className="pagination"> <div className="pagination">
<span>Showing {data.rows.length} of {data.page.total}</span> <span>Showing {data.rows.length} of {data.page.total}</span>
<div><button className="button button-small button-secondary" aria-label="Previous page" onClick={previousPage} disabled={cursorStack.length <= 1} title={cursorStack.length <= 1 ? "No previous page" : "Load previous page"}>Previous</button><button className="button button-small button-secondary" aria-label="Next page" onClick={nextPage} disabled={!data.page.nextCursor} title={!data.page.nextCursor ? "No next page" : "Load next page"}>Next <Icon name="arrow" size={13} /></button></div> <div><button className="button button-small button-secondary" aria-label="Previous page" onClick={previousPage} disabled={isRefreshing || cursorStack.length <= 1} title={cursorStack.length <= 1 ? "No previous page" : "Load previous page"}>Previous</button><button className="button button-small button-secondary" aria-label="Next page" onClick={nextPage} disabled={isRefreshing || !data.page.nextCursor} title={!data.page.nextCursor ? "No next page" : "Load next page"}>Next <Icon name="arrow" size={13} /></button></div>
</div> </div>
{feedback ? <div className={`notice notice-${feedback.tone}`} role={feedback.tone === "danger" ? "alert" : "status"}>{feedback.message}</div> : null} {feedback ? <div className={`notice notice-${feedback.tone}`} role={feedback.tone === "danger" ? "alert" : "status"}>{feedback.message}</div> : null}
</section> </section>
{kind === "tools" ? <CatalogWorkflow api={api} /> : null} {kind === "tools" ? <><RecentToolRuns api={api} navigate={navigate} /><CatalogWorkflow api={api} initialProject={initialProject} /></> : null}
{kind === "policies" ? <PolicySimulator api={api} /> : null} {kind === "policies" ? <PolicySimulator api={api} /> : null}
{kind === "clients" ? <><ClientOnboarding api={api} /><section className="panel workflow-panel client-safety"><div><p className="eyebrow">Client safety</p><h3>Write-only credentials</h3><p className="muted">Generated configuration includes endpoint, identity reference and scopes only. Client secrets are never returned by the API or rendered into the DOM.</p></div><span className="boundary-badge">Secret-free browser surface</span></section></> : null} {kind === "clients" ? <><ClientOnboarding api={api} clientId={params.get("onboard") ?? undefined} navigate={navigate} onCreated={(client) => { retry(); onClientCreated?.(client); }} /><section className="panel workflow-panel client-safety"><div><p className="eyebrow">Client safety</p><h3>Write-only credentials</h3><p className="muted">Generated configuration includes profile, endpoint and scopes. Client secrets are never returned by the API or rendered into the DOM.</p></div><span className="boundary-badge">Secret-free browser surface</span></section></> : null}
{testingTool ? <ToolReadinessModal tool={testingTool} api={api} onClose={() => setTestingTool(null)} /> : null} {testingTool ? <ToolReadinessModal tool={testingTool} api={api} initialProject={initialProject} initialEnvironment={initialEnvironment} onClose={() => { setTestingTool(null); if (reopened) navigate("/tools"); }} /> : null}
</div>; </div>;
} }
+7 -6
View File
@@ -1,15 +1,16 @@
import { useState } from "react"; import { useState } from "react";
import { readPreference, writePreference } from "../preferences";
import { MetricCard } from "../components/MetricCard"; import { MetricCard } from "../components/MetricCard";
import { Breadcrumbs } from "../components/Breadcrumbs"; import { Breadcrumbs } from "../components/Breadcrumbs";
// Unset localStorage means the CSS falls back to prefers-color-scheme, whose function currentTheme(): "light" | "dark" {
// default in this design is dark -- so an absent stored value is treated as const rendered = document.documentElement.dataset.theme;
// "dark" here too, keeping the toggle's active button in sync with what's const fallback = rendered === "light" || rendered === "dark" ? rendered : window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
// actually on screen on first load. return readPreference("hub-theme", ["light", "dark"], fallback) as "light" | "dark";
function currentTheme(): "light" | "dark" { return localStorage.getItem("hub-theme") === "light" ? "light" : "dark"; } }
export function SettingsPage({ path, navigate }: { path: string; navigate: (next: string) => void }) { export function SettingsPage({ path, navigate }: { path: string; navigate: (next: string) => void }) {
const [theme, setThemeState] = useState<"light" | "dark">(currentTheme); const [theme, setThemeState] = useState<"light" | "dark">(currentTheme);
const applyTheme = (next: "light" | "dark") => { document.documentElement.setAttribute("data-theme", next); localStorage.setItem("hub-theme", next); setThemeState(next); }; const applyTheme = (next: "light" | "dark") => { document.documentElement.setAttribute("data-theme", next); writePreference("hub-theme", next); setThemeState(next); };
return <section className="panel resource-page"><Breadcrumbs path={path} navigate={navigate}/><p className="eyebrow">Control-plane preferences</p><h2>Platform</h2><p className="muted">Platform settings are server-managed and scoped by management authorization. Sensitive values are write-only.</p><div className="detail-grid"><MetricCard label="Session" value="Same-origin" detail="Authentik session checked by the BFF" status="healthy"/><MetricCard label="Audit" value="Immutable" detail="Corrections append linked events"/><MetricCard label="Credentials" value="Write-only" detail="Secrets are never read back"/></div><div className="theme-toggle"><span>Appearance</span><button className={theme === "dark" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={theme === "dark"} onClick={() => applyTheme("dark")}>Dark</button><button className={theme === "light" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={theme === "light"} onClick={() => applyTheme("light")}>Light</button></div><div className="notice notice-info" role="status">No writable platform settings are available in this browser until the control API publishes an authorized configuration schema.</div></section>; return <section className="panel resource-page"><Breadcrumbs path={path} navigate={navigate}/><p className="eyebrow">Control-plane preferences</p><h2>Platform</h2><p className="muted">Platform settings are server-managed and scoped by management authorization. Sensitive values are write-only.</p><div className="detail-grid"><MetricCard label="Session" value="Same-origin" detail="Authentik session checked by the BFF" status="healthy"/><MetricCard label="Audit" value="Immutable" detail="Corrections append linked events"/><MetricCard label="Credentials" value="Write-only" detail="Secrets are never read back"/></div><div className="theme-toggle"><span>Appearance</span><button className={theme === "dark" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={theme === "dark"} onClick={() => applyTheme("dark")}>Dark</button><button className={theme === "light" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={theme === "light"} onClick={() => applyTheme("light")}>Light</button></div><div className="notice notice-info" role="status">No writable platform settings are available in this browser until the control API publishes an authorized configuration schema.</div></section>;
} }
+167 -67
View File
@@ -1,103 +1,203 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { safeApiMessage } from "../api"; import { safeApiMessage } from "../api";
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import { SchemaInputFields } from "../components/SchemaInputFields";
import { StatusPill } from "../components/StatusPill"; import { StatusPill } from "../components/StatusPill";
import type { ManagementApiClient, ResourceRecord, ToolDefinition, ToolInvocationResult, ToolReadinessResult } from "../contracts"; import type { ManagementApiClient, ResourceRecord, ToolDefinition, ToolInvocationResult, ToolReadinessResult } from "../contracts";
import { useProjectEnvironments, useProjectOptions } from "../hooks"; import { useProjectEnvironments, useProjectOptions } from "../hooks";
import { useCan } from "../rbac-context"; import { useCan } from "../rbac-context";
import { defaultSchemaInputValues, parseSchemaArguments } from "../schema-input";
import { recordToolRun } from "../tool-history";
type Property = NonNullable<ToolDefinition["inputSchema"]["properties"]>[string]; function invocationOutput(invocation: ToolInvocationResult): unknown {
return invocation.structuredContent ?? invocation.content ?? invocation.error ?? null;
function defaultArguments(definition: ToolDefinition | null): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [name, property] of Object.entries(definition?.inputSchema.properties ?? {})) {
if (property.default !== undefined) result[name] = property.default;
}
return result;
} }
function fieldValue(value: unknown): string { function renderOutput(invocation: ToolInvocationResult): string {
if (value === undefined || value === null) return "";
return typeof value === "object" ? JSON.stringify(value) : String(value);
}
function parseField(raw: string, property: Property): unknown {
const type = Array.isArray(property.type) ? property.type.find((item) => item !== "null") : property.type;
if (raw === "") return undefined;
if (type === "integer" || type === "number") return Number(raw);
if (type === "boolean") return raw === "true";
if (type === "array" || type === "object") return JSON.parse(raw);
return raw;
}
function recordInvocation(metadata: Record<string, unknown>): void {
try { try {
const parsed: unknown = JSON.parse(sessionStorage.getItem("itworx-tool-history-v1") ?? "[]"); return JSON.stringify(invocationOutput(invocation), null, 2) ?? "No output was returned.";
const history = Array.isArray(parsed) ? parsed : [];
sessionStorage.setItem(
"itworx-tool-history-v1",
JSON.stringify([metadata, ...history].slice(0, 20)),
);
} catch { } catch {
// Invocation succeeded even when browser storage is disabled or corrupt. return "The result could not be displayed as JSON.";
} }
} }
export function ToolReadinessModal({ tool, api, onClose }: { tool: ResourceRecord; api: ManagementApiClient; onClose: () => void }) { export function ToolReadinessModal({
tool,
api,
onClose,
initialProject,
initialEnvironment,
}: {
tool: ResourceRecord;
api: ManagementApiClient;
onClose: () => void;
initialProject?: string;
initialEnvironment?: string;
}) {
const canTest = useCan("tool.test");
const canInvoke = useCan("tool.invoke"); const canInvoke = useCan("tool.invoke");
const projects = useProjectOptions(api); const projects = useProjectOptions(api);
const [project, setProject] = useState(""); const [project, setProject] = useState(initialProject ?? "");
const environments = useProjectEnvironments(api, project); const environments = useProjectEnvironments(api, project);
const [environment, setEnvironment] = useState(""); const [environment, setEnvironment] = useState(initialEnvironment ?? "");
const [definition, setDefinition] = useState<ToolDefinition | null>(null); const [definition, setDefinition] = useState<ToolDefinition | null>(null);
const [argumentsValue, setArgumentsValue] = useState<Record<string, unknown>>({}); const [rawArguments, setRawArguments] = useState<Record<string, string>>({});
const [result, setResult] = useState<ToolReadinessResult | null>(null); const [result, setResult] = useState<ToolReadinessResult | null>(null);
const [resultContext, setResultContext] = useState<string | null>(null);
const [invocation, setInvocation] = useState<ToolInvocationResult | null>(null); const [invocation, setInvocation] = useState<ToolInvocationResult | null>(null);
const [invocationContext, setInvocationContext] = useState<string | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [copyStatus, setCopyStatus] = useState("");
const [busy, setBusy] = useState<"readiness" | "invoke" | "">(""); const [busy, setBusy] = useState<"readiness" | "invoke" | "">("");
const canonicalName = tool.canonicalName;
useEffect(() => { if (!project && projects[0]) setProject(projects[0].id); }, [project, projects]); useEffect(() => {
useEffect(() => { setEnvironment((current) => environments.includes(current) ? current : environments[0] ?? ""); }, [environments]); if (!projects.length) return;
useEffect(() => { const canonicalName = tool.canonicalName; if (!canonicalName) return; let active = true; void api.toolDefinition(canonicalName).then((value) => { if (active) { setDefinition(value); setArgumentsValue(defaultArguments(value)); } }).catch((caught) => { if (active) setError(safeApiMessage(caught)); }); return () => { active = false; }; }, [api, tool.canonicalName]); if (initialProject) {
setProject(projects.some((item) => item.id === initialProject) ? initialProject : "");
return;
}
setProject((current) => projects.some((item) => item.id === current) ? current : projects[0]?.id ?? "");
}, [initialProject, projects]);
const required = useMemo(() => new Set(definition?.inputSchema.required ?? []), [definition]); useEffect(() => {
const selectable = Boolean(tool.canonicalName && project && environment && definition?.readOnly); if (!project) {
const update = (name: string, raw: string, property: Property) => { setEnvironment("");
try { return;
const parsed = parseField(raw, property); }
if (initialEnvironment) {
setEnvironment(environments.includes(initialEnvironment) ? initialEnvironment : "");
return;
}
setEnvironment((current) => environments.includes(current) ? current : environments[0] ?? "");
}, [environments, initialEnvironment, project]);
useEffect(() => {
setDefinition(null);
setRawArguments({});
setResult(null);
setResultContext(null);
setInvocation(null);
setInvocationContext(null);
setError(""); setError("");
setArgumentsValue((current) => { const next = { ...current }; if (parsed === undefined) delete next[name]; else next[name] = parsed; return next; }); setCopyStatus("");
} catch { setError(`${name} must contain valid JSON.`); } if (!canonicalName) return;
let active = true;
void api.toolDefinition(canonicalName).then((value) => {
if (!active) return;
setDefinition(value);
setRawArguments(defaultSchemaInputValues(value.inputSchema));
}).catch((caught) => {
if (active) setError(safeApiMessage(caught));
});
return () => { active = false; };
}, [api, canonicalName]);
const parsedInput = useMemo(() => definition
? parseSchemaArguments(rawArguments, definition.inputSchema)
: { arguments: {}, errors: {}, valid: false }, [definition, rawArguments]);
const requestContext = JSON.stringify([canonicalName, project, environment, parsedInput.arguments]);
const currentResult = resultContext === requestContext ? result : null;
const currentInvocation = invocationContext === requestContext ? invocation : null;
const selectable = Boolean(canonicalName && project && environment && definition?.readOnly && parsedInput.valid);
const clearRunState = () => {
setResult(null);
setResultContext(null);
setInvocation(null);
setInvocationContext(null);
setCopyStatus("");
}; };
const updateArgument = (name: string, value: string) => {
setRawArguments((current) => ({ ...current, [name]: value }));
clearRunState();
setError("");
};
const changeProject = (value: string) => {
setProject(value);
setEnvironment("");
clearRunState();
setError("");
};
const changeEnvironment = (value: string) => {
setEnvironment(value);
clearRunState();
setError("");
};
const readiness = async () => { const readiness = async () => {
if (!selectable || !tool.canonicalName) return; if (!selectable || !canTest || !canonicalName) return;
setBusy("readiness"); setError(""); setResult(null); setInvocation(null); const context = requestContext;
try { setResult(await api.testTool({ project, environment, tool: tool.canonicalName, arguments: argumentsValue })); } const input = parsedInput.arguments;
catch (caught) { setError(safeApiMessage(caught)); } setBusy("readiness");
finally { setBusy(""); } setError("");
}; setCopyStatus("");
const invoke = async () => { clearRunState();
if (!selectable || !tool.canonicalName || !result?.ready) return;
setBusy("invoke"); setError(""); setInvocation(null);
try { try {
const response = await api.invokeTool({ project, environment, tool: tool.canonicalName, arguments: argumentsValue }); const response = await api.testTool({ project, environment, tool: canonicalName, arguments: input });
setInvocation(response); setResult(response);
recordInvocation({ tool: tool.canonicalName, project, environment, ok: response.ok, traceId: response.traceId, at: new Date().toISOString() }); setResultContext(context);
} catch (caught) { setError(safeApiMessage(caught)); } } catch (caught) {
finally { setBusy(""); } setError(safeApiMessage(caught));
} finally {
setBusy("");
}
}; };
const copyResult = async () => { if (invocation) await navigator.clipboard.writeText(JSON.stringify(invocation.structuredContent ?? invocation.content ?? invocation.error, null, 2)); };
const invoke = async () => {
if (!selectable || !canInvoke || !canonicalName || !currentResult?.ready) return;
const context = requestContext;
const input = parsedInput.arguments;
setBusy("invoke");
setError("");
setCopyStatus("");
setInvocation(null);
setInvocationContext(null);
try {
const response = await api.invokeTool({ project, environment, tool: canonicalName, arguments: input });
setInvocation(response);
setInvocationContext(context);
recordToolRun({ tool: canonicalName, project, environment, ok: response.ok, traceId: response.traceId, at: new Date().toISOString() });
} catch (caught) {
setError(safeApiMessage(caught));
} finally {
setBusy("");
}
};
const copyResult = async () => {
if (!currentInvocation) return;
try {
await navigator.clipboard.writeText(renderOutput(currentInvocation));
setCopyStatus("Displayed result copied. It is not saved in recent tool history.");
} catch {
setCopyStatus("Clipboard access is unavailable. Select the displayed result to copy it.");
}
};
const projectContextMissing = Boolean(initialProject && projects.length > 0 && !projects.some((item) => item.id === initialProject));
const environmentContextMissing = Boolean(initialEnvironment && project && environments.length > 0 && !environments.includes(initialEnvironment));
return <Modal open title={`Tool console · ${tool.name}`} onClose={onClose}> return <Modal open title={`Tool console · ${tool.name}`} onClose={onClose}>
<p className="muted">Run a schema-validated readiness check, then invoke this cataloged read-only tool through the audited operator path.</p> <div className="tool-console" aria-busy={Boolean(busy)}>
<div className="form-grid readiness-form"> <p className="muted">Check this cataloged read-only route, then invoke it through the audited operator path. Arguments and output stay in this page and are never saved in recent history.</p>
<label>Project<select value={project} onChange={(event) => setProject(event.target.value)} disabled={projects.length === 0}>{projects.length === 0 ? <option value="">No registered projects</option> : projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label> <div className="form-grid readiness-form tool-scope-form">
<label>Environment<select value={environment} onChange={(event) => setEnvironment(event.target.value)} disabled={environments.length === 0}>{environments.length === 0 ? <option value="">No registered environments</option> : environments.map((name) => <option key={name} value={name}>{name}</option>)}</select></label> <label>Project<select aria-label="Project" value={project} onChange={(event) => changeProject(event.target.value)} disabled={projects.length === 0 || Boolean(busy)}><option value="">{projects.length === 0 ? "No registered projects" : "Select a project…"}</option>{projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
{Object.entries(definition?.inputSchema.properties ?? {}).map(([name, property]) => <label key={name}>{property.title ?? name}{required.has(name) ? " *" : ""}{property.enum ? <select value={fieldValue(argumentsValue[name])} onChange={(event) => update(name, event.target.value, property)}><option value="">Select…</option>{property.enum.map((option) => <option key={String(option)} value={String(option)}>{String(option)}</option>)}</select> : property.type === "boolean" ? <select value={fieldValue(argumentsValue[name])} onChange={(event) => update(name, event.target.value, property)}><option value="">Select…</option><option value="true">Yes</option><option value="false">No</option></select> : <input type={property.type === "integer" || property.type === "number" ? "number" : "text"} min={property.minimum} max={property.maximum} minLength={property.minLength} maxLength={property.maxLength} value={fieldValue(argumentsValue[name])} onChange={(event) => update(name, event.target.value, property)} placeholder={property.description ?? name} />}</label>)} <label>Environment<select aria-label="Environment" value={environment} onChange={(event) => changeEnvironment(event.target.value)} disabled={!project || environments.length === 0 || Boolean(busy)}><option value="">{!project ? "Select a project first" : environments.length === 0 ? "No registered environments" : "Select an environment…"}</option>{environments.map((name) => <option key={name} value={name}>{name}</option>)}</select></label>
</div>
{projectContextMissing ? <p className="notice notice-info">The project from this recent run is no longer registered. Choose a current project to continue.</p> : null}
{environmentContextMissing ? <p className="notice notice-info">The environment from this recent run is not registered for this project. Choose a current environment to continue.</p> : null}
{definition ? <SchemaInputFields key={canonicalName} schema={definition.inputSchema} rawValues={rawArguments} errors={parsedInput.errors} onChange={updateArgument} disabled={Boolean(busy)} /> : error ? null : <p className="muted" role="status">Loading the registered input schema…</p>}
{definition && !definition.readOnly ? <p className="notice notice-info">This tool is not registered as read-only, so this console will not invoke it.</p> : null}
{!canTest ? <p className="notice notice-info">Tool readiness requires the operator or admin role.</p> : null}
{Object.keys(parsedInput.errors).length > 0 ? <p className="tool-input-summary" role="status">Fix the marked schema inputs before running a readiness check.</p> : null}
<div className="tool-console-actions">
<button className="button button-secondary" onClick={() => void readiness()} disabled={Boolean(busy) || !selectable || !canTest} aria-busy={busy === "readiness"}>{busy === "readiness" ? "Checking route…" : "Run readiness check"}</button>
<button className="button" onClick={() => void invoke()} disabled={Boolean(busy) || !canInvoke || !currentResult?.ready} title={!canInvoke ? "Operator or admin role required" : !currentResult?.ready ? "Run a successful readiness check for the current inputs first" : "Invoke the read-only tool"}>{busy === "invoke" ? "Running tool…" : "Run tool"}</button>
</div> </div>
<div className="tool-console-actions"><button className="button button-secondary" onClick={() => void readiness()} disabled={Boolean(busy) || !selectable} aria-busy={busy === "readiness"}>{busy === "readiness" ? "Checking…" : "Run readiness check"}</button><button className="button" onClick={() => void invoke()} disabled={Boolean(busy) || !canInvoke || !result?.ready} title={!canInvoke ? "Operator or admin role required" : !result?.ready ? "Run a successful readiness check first" : "Invoke read-only tool"}>{busy === "invoke" ? "Running…" : "Run tool"}</button></div>
{error ? <p className="error-text" role="alert">{error}</p> : null} {error ? <p className="error-text" role="alert">{error}</p> : null}
{result ? <div className="readiness-result" role="status"><div className="readiness-summary"><StatusPill value={result.ready ? "healthy" : "degraded"}/><strong>{result.ready ? "Ready" : "Not ready"}</strong><span>{result.project} / {result.environment}</span></div><dl className="readiness-checks"><div><dt>Input</dt><dd><StatusPill value={result.checks.input.valid ? "healthy" : "degraded"}/>{result.checks.input.valid ? "Schema valid" : result.checks.input.errors.join(" · ")}</dd></div><div><dt>Policy</dt><dd><StatusPill value={result.checks.policy.allowed ? "healthy" : "degraded"}/>{result.checks.policy.decision} · {result.checks.policy.reasonCode}</dd></div><div><dt>Connector</dt><dd><StatusPill value={result.checks.connector.reachable ? "healthy" : "unavailable"}/>{result.checks.connector.status} · {result.checks.connector.detail}</dd></div></dl><p className="muted">{result.note}</p><small>Readiness trace {result.checks.policy.traceId}</small></div> : null} {currentResult ? <div className="readiness-result" role="status"><div className="readiness-summary"><StatusPill value={currentResult.ready ? "healthy" : "degraded"}/><strong>{currentResult.ready ? "Ready" : "Not ready"}</strong><span>{currentResult.project} / {currentResult.environment}</span></div><dl className="readiness-checks"><div><dt>Input</dt><dd><StatusPill value={currentResult.checks.input.valid ? "healthy" : "degraded"}/>{currentResult.checks.input.valid ? "Schema valid" : currentResult.checks.input.errors.join(" · ")}</dd></div><div><dt>Policy</dt><dd><StatusPill value={currentResult.checks.policy.allowed ? "healthy" : "degraded"}/>{currentResult.checks.policy.decision} · {currentResult.checks.policy.reasonCode}</dd></div><div><dt>Connector</dt><dd><StatusPill value={currentResult.checks.connector.reachable ? "healthy" : "unavailable"}/>{currentResult.checks.connector.status} · {currentResult.checks.connector.detail}</dd></div></dl><p className="muted">{currentResult.note}</p><small>Readiness trace {currentResult.checks.policy.traceId}</small></div> : null}
{invocation ? <div className="tool-result" role="status"><div className="panel-heading"><div><p className="eyebrow">Invocation result</p><h3>{invocation.ok ? "Completed" : "Controlled error"}</h3></div><button className="button button-small button-secondary" onClick={() => void copyResult()}>Copy result</button></div><pre>{JSON.stringify(invocation.structuredContent ?? invocation.content ?? invocation.error, null, 2)}</pre><small>Audit trace {invocation.traceId}</small></div> : null} {currentInvocation ? <div className="tool-result" role="status"><div className="panel-heading"><div><p className="eyebrow">Invocation result</p><h3>{currentInvocation.ok ? "Completed" : "Controlled error"}</h3></div><button className="button button-small button-secondary" onClick={() => void copyResult()}>Copy displayed result</button></div><pre aria-label="Tool invocation output">{renderOutput(currentInvocation)}</pre><small>Audit trace {currentInvocation.traceId}</small>{copyStatus ? <p className="copy-status" role="status">{copyStatus}</p> : null}</div> : null}
</div>
</Modal>; </Modal>;
} }
+7
View File
@@ -0,0 +1,7 @@
/** Only presentation values belong here. Storage may be denied by the browser. */
export function readPreference(key: "hub-theme" | "hub-density", values: readonly string[], fallback: string): string {
try { const stored = localStorage.getItem(key); return stored && values.includes(stored) ? stored : fallback; } catch { return fallback; }
}
export function writePreference(key: "hub-theme" | "hub-density", value: string): void {
try { localStorage.setItem(key, value); } catch { /* In-memory interaction continues when storage is unavailable. */ }
}
+2
View File
@@ -12,6 +12,7 @@ export type ManagementAction =
| "operations.check" | "operations.check"
| "client.read" | "client.read"
| "client.bootstrap" | "client.bootstrap"
| "client.verify"
| "client.disable" | "client.disable"
| "client.revoke" | "client.revoke"
| "client.create" | "client.create"
@@ -32,6 +33,7 @@ const PERMISSIONS: Record<ManagementAction, readonly ManagementRole[]> = {
"operations.check": ["operator", "admin"], "operations.check": ["operator", "admin"],
"client.read": ["viewer", "auditor", "operator", "admin"], "client.read": ["viewer", "auditor", "operator", "admin"],
"client.bootstrap": ["operator", "admin"], "client.bootstrap": ["operator", "admin"],
"client.verify": ["operator", "admin"],
"client.disable": ["operator", "admin"], "client.disable": ["operator", "admin"],
"client.revoke": ["operator", "admin"], "client.revoke": ["operator", "admin"],
"client.create": ["admin"], "client.create": ["admin"],
+8 -1
View File
@@ -13,4 +13,11 @@ export function usePath() {
return [path, navigate] as const; return [path, navigate] as const;
} }
export function basePath(path: string) { return (path.split("?")[0] ?? "").replace(/\/$/, "") || "/"; } export function basePath(path: string) { return (path.split("?")[0] ?? "").replace(/\/$/, "") || "/"; }
export function resourceKind(path: string): ResourceKind | null { const base = basePath(path); if (base.startsWith("/projects/") && base !== "/projects") return "projects"; return resourceRoutes[base] ?? null; } export function projectDetailId(path: string): string | null { return decodeDetailId(path, "projects"); }
export function connectorDetailId(path: string): string | null { return decodeDetailId(path, "connectors"); }
function decodeDetailId(path: string, collection: "projects" | "connectors"): string | null {
const match = new RegExp(`^/${collection}/([^/]+)$`).exec(basePath(path));
if (!match?.[1]) return null;
try { return decodeURIComponent(match[1]); } catch { return null; }
}
export function resourceKind(path: string): ResourceKind | null { const base = basePath(path); if (projectDetailId(base)) return "projects"; if (connectorDetailId(base)) return null; return resourceRoutes[base] ?? null; }
+148
View File
@@ -0,0 +1,148 @@
import type { ToolDefinition } from "./contracts";
export type ToolInputProperty = NonNullable<ToolDefinition["inputSchema"]["properties"]>[string];
export type ToolInputSchema = ToolDefinition["inputSchema"];
export interface ParsedSchemaArguments {
arguments: Record<string, unknown>;
errors: Record<string, string>;
valid: boolean;
}
export const NULL_INPUT_VALUE = "__schema_null__";
function allowedTypes(property: ToolInputProperty): string[] {
const types = Array.isArray(property.type) ? property.type : [property.type ?? "string"];
return types.filter((type) => type !== "null");
}
function supportsNull(property: ToolInputProperty): boolean {
return Array.isArray(property.type) && property.type.includes("null");
}
function optionValue(raw: string): unknown {
try {
return JSON.parse(raw) as unknown;
} catch {
return raw;
}
}
function matchesEnum(value: unknown, values: unknown[] | undefined): boolean {
if (!values) return true;
return values.some((option) => {
if (Object.is(option, value)) return true;
try {
return JSON.stringify(option) === JSON.stringify(value);
} catch {
return false;
}
});
}
function boundsError(value: number | string, property: ToolInputProperty): string | null {
if (typeof value === "number") {
if (property.minimum !== undefined && value < property.minimum) return `Enter a number no less than ${property.minimum}.`;
if (property.maximum !== undefined && value > property.maximum) return `Enter a number no greater than ${property.maximum}.`;
}
if (typeof value === "string") {
if (property.minLength !== undefined && value.length < property.minLength) return `Enter at least ${property.minLength} characters.`;
if (property.maxLength !== undefined && value.length > property.maxLength) return `Use no more than ${property.maxLength} characters.`;
}
return null;
}
function parseForType(raw: string, type: string, property: ToolInputProperty): { value?: unknown; error?: string } {
if (type === "integer" || type === "number") {
const numeric = Number(raw);
if (!Number.isFinite(numeric)) return { error: "Enter a valid number." };
if (type === "integer" && !Number.isSafeInteger(numeric)) return { error: "Enter a whole number." };
return { value: numeric, error: boundsError(numeric, property) ?? undefined };
}
if (type === "boolean") {
if (raw === "true") return { value: true };
if (raw === "false") return { value: false };
return { error: "Choose yes or no." };
}
if (type === "array" || type === "object") {
let value: unknown;
try {
value = JSON.parse(raw) as unknown;
} catch {
return { error: type === "array" ? "Enter valid JSON for this array." : "Enter valid JSON for this object." };
}
if (type === "array" && !Array.isArray(value)) return { error: "Enter a JSON array." };
if (type === "object" && (typeof value !== "object" || value === null || Array.isArray(value))) return { error: "Enter a JSON object." };
return { value };
}
if (type === "string") return { value: raw, error: boundsError(raw, property) ?? undefined };
return { error: "This tool input type cannot be entered here." };
}
function parseField(raw: string, property: ToolInputProperty): { value?: unknown; error?: string } {
if (raw === "") return {};
if (raw === NULL_INPUT_VALUE) return supportsNull(property) ? { value: null } : { error: "This field does not accept null." };
if (property.enum) {
const value = optionValue(raw);
return matchesEnum(value, property.enum)
? { value }
: { error: "Choose a value listed by this tool's schema." };
}
const types = allowedTypes(property);
if (types.length === 0) return { error: "This field does not accept a value." };
// Prefer the most specific scalar/structured representation for unions;
// a plain string remains the fallback when another declared type rejects it.
const orderedTypes = [...types].sort((left, right) => {
const priority = (type: string) => ({ integer: 0, number: 1, boolean: 2, object: 3, array: 4, string: 5 }[type as "integer"] ?? 6);
return priority(left) - priority(right);
});
let firstError: string | undefined;
for (const type of orderedTypes) {
const parsed = parseForType(raw, type, property);
if (!parsed.error) return parsed;
firstError ??= parsed.error;
}
return { error: firstError ?? "This tool input is invalid." };
}
function displayValue(value: unknown, property: ToolInputProperty): string {
if (value === null) return supportsNull(property) ? NULL_INPUT_VALUE : "";
if (property.enum || typeof value === "object") {
try {
return JSON.stringify(value) ?? "";
} catch {
return "";
}
}
return String(value);
}
export function defaultSchemaInputValues(schema: ToolInputSchema): Record<string, string> {
const raw: Record<string, string> = {};
for (const [name, property] of Object.entries(schema.properties ?? {})) {
if (property.default !== undefined) raw[name] = displayValue(property.default, property);
}
return raw;
}
export function parseSchemaArguments(rawValues: Record<string, string>, schema: ToolInputSchema): ParsedSchemaArguments {
const args: Record<string, unknown> = {};
const errors: Record<string, string> = {};
const properties = schema.properties ?? {};
const required = new Set(schema.required ?? []);
for (const name of required) {
if (!properties[name] || rawValues[name] === undefined || rawValues[name] === "") errors[name] = "This field is required.";
}
for (const [name, property] of Object.entries(properties)) {
const raw = rawValues[name] ?? "";
if (raw === "") continue;
const parsed = parseField(raw, property);
if (parsed.error) errors[name] = parsed.error;
else if (parsed.value !== undefined) args[name] = parsed.value;
}
return { arguments: args, errors, valid: Object.keys(errors).length === 0 };
}
+54
View File
@@ -0,0 +1,54 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { safeApiMessage } from "./api";
export type RefreshStatus = "current" | "refreshing" | "stale" | "disconnected" | "paused";
export const refreshLabel: Record<RefreshStatus, string> = { current: "Current · auto refresh", refreshing: "Refreshing…", stale: "Stale · refresh needed", disconnected: "Disconnected · retry available", paused: "Auto refresh paused" };
/** Read-only refresh: no overlapping calls, hidden-tab suspension, bounded backoff. */
export function useStatusRefresh<T>(initial: T | null, read: () => Promise<T>, stale: (value: T) => boolean) {
const [value, setValue] = useState(initial);
const [status, setStatus] = useState<RefreshStatus>(initial ? (stale(initial) ? "stale" : "current") : "refreshing");
const [error, setError] = useState("");
const active = useRef(false);
const pending = useRef(false);
const failures = useRef(0);
const readRef = useRef(read);
const staleRef = useRef(stale);
const timer = useRef<number | undefined>(undefined);
useEffect(() => { readRef.current = read; staleRef.current = stale; }, [read, stale]);
const refresh = useCallback(async () => {
if (pending.current || !active.current) return;
if (document.hidden) { setStatus("paused"); return; }
pending.current = true;
window.clearTimeout(timer.current);
setStatus("refreshing");
try {
const next = await readRef.current();
if (active.current) { setValue(next); setError(""); setStatus(staleRef.current(next) ? "stale" : "current"); failures.current = 0; }
} catch (caught) {
if (active.current) { failures.current += 1; setError(safeApiMessage(caught)); setStatus("disconnected"); }
} finally {
pending.current = false;
if (active.current && !document.hidden) timer.current = window.setTimeout(() => { void refresh(); }, Math.min(120_000, 30_000 * 2 ** Math.min(failures.current, 2)));
}
}, []);
useEffect(() => {
active.current = true;
if (document.hidden) setStatus("paused");
else if (initial) timer.current = window.setTimeout(() => { void refresh(); }, 30_000);
else void refresh();
const visibility = () => {
window.clearTimeout(timer.current);
if (document.hidden) setStatus("paused");
else void refresh();
};
document.addEventListener("visibilitychange", visibility);
return () => { active.current = false; window.clearTimeout(timer.current); document.removeEventListener("visibilitychange", visibility); };
}, [refresh]);
useEffect(() => {
if (!value) return;
const freshness = window.setInterval(() => { if (!document.hidden && staleRef.current(value)) setStatus((current) => current === "current" ? "stale" : current); }, 10_000);
return () => window.clearInterval(freshness);
}, [value]);
return { value, status, error, refresh };
}
+126 -163
View File
@@ -73,12 +73,8 @@
} }
} }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: var(--surface-0); background-image: radial-gradient(1200px 640px at 14% -8%, var(--surface-2) 0%, transparent 55%); background-attachment: fixed; }
.skip-link { position: fixed; z-index: 1000; top: 10px; left: 10px; transform: translateY(-180%); padding: 9px 12px; border-radius: 3px; background: var(--paper-bright); color: var(--surface-0); font-weight: 700; text-decoration: none; box-shadow: var(--shadow-2); } .skip-link { position: fixed; z-index: 1000; top: 10px; left: 10px; transform: translateY(-180%); padding: 9px 12px; border-radius: 3px; background: var(--paper-bright); color: var(--surface-0); font-weight: 700; text-decoration: none; box-shadow: var(--shadow-2); }
.skip-link:focus { transform: translateY(0); } .skip-link:focus { transform: translateY(0); }
button, a, input, select { font: inherit; } button, a, input, select { font: inherit; }
@@ -88,38 +84,34 @@ button { font-family: var(--font-sans); }
/* Shell */ /* Shell */
.app-shell { display: flex; min-height: 100vh; } .app-shell { display: flex; min-height: 100vh; }
.preauth-shell { min-height: 100vh; display: grid; place-items: start center; }
.sidebar { width: 240px; background: var(--surface-1); border-right: 1px solid var(--line); padding: 28px 16px; display: flex; flex-direction: column; flex-shrink: 0; position: relative; }
.sidebar::after { content: ""; position: absolute; top: 0; right: -1px; bottom: 0; width: 1px; background: linear-gradient(180deg, transparent, var(--accent-dim) 18%, var(--accent-dim) 82%, transparent); opacity: 0.55; } .sidebar::after { content: ""; position: absolute; top: 0; right: -1px; bottom: 0; width: 1px; background: linear-gradient(180deg, transparent, var(--accent-dim) 18%, var(--accent-dim) 82%, transparent); opacity: 0.55; }
.brand { display: flex; gap: 12px; align-items: center; text-decoration: none; padding: 0 10px 30px; } .brand { display: flex; gap: 12px; align-items: center; text-decoration: none; padding: 0 10px 30px; }
.brand strong, .brand small { display: block; } .brand strong, .brand small { display: block; }
.brand strong { font-family: var(--font-sans); font-weight: 700; letter-spacing: 0.01em; color: var(--paper-bright); } .brand strong { font-family: var(--font-sans); font-weight: 700; letter-spacing: 0.01em; color: var(--paper-bright); }
.brand small { color: var(--ink-dim); margin-top: 3px; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; } .brand small { color: var(--ink-dim); margin-top: 3px; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; }
.brand-mark { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 10px; color: var(--surface-0); background: linear-gradient(160deg, var(--accent-bright), var(--accent)); box-shadow: 0 0 0 1px var(--accent-dim), 0 2px 10px -2px #000a; flex-shrink: 0; }
.brand-mark svg { display: block; } .brand-mark svg { display: block; }
nav[aria-label="Primary navigation"] { display: grid; gap: 3px; } nav[aria-label="Primary navigation"] { display: grid; gap: 3px; }
.nav-group { display: grid; gap: 3px; margin: 0 0 14px; }
.nav-group > p { margin: 8px 12px 3px; color: var(--ink-faint); font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .nav-group > p { margin: 8px 12px 3px; color: var(--ink-faint); font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
.nav-link { display: flex; align-items: center; gap: 10px; color: var(--ink-dim); padding: 10px 12px; border-radius: 7px; text-decoration: none; font-size: 13.5px; font-weight: 500; position: relative; transition: color 120ms ease, background-color 120ms ease; }
.nav-link::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: var(--line); flex-shrink: 0; transition: background-color 120ms ease, box-shadow 120ms ease; }
.nav-link:hover, .nav-link:focus-visible { color: var(--paper-bright); background: var(--surface-2-hover); } .nav-link:hover, .nav-link:focus-visible { color: var(--paper-bright); background: var(--surface-2-hover); }
.nav-link.active { color: var(--paper-bright); background: var(--surface-2); font-weight: 600; }
.nav-link.active::before { background: var(--accent); box-shadow: 0 0 6px 1px #00b0fe99; } .nav-link.active::before { background: var(--accent); box-shadow: 0 0 6px 1px #00b0fe99; }
.sidebar-foot { margin-top: auto; padding: 16px 12px 0; border-top: 1px solid var(--line); color: var(--ink-dim); font-size: 12px; line-height: 1.6; }
.sidebar-foot small { color: var(--ink-faint); }
.secure-dot { display: inline-block; width: 7px; height: 7px; background: var(--status-success); border-radius: 50%; margin-right: 7px; box-shadow: 0 0 5px 0 #34d39999; } .secure-dot { display: inline-block; width: 7px; height: 7px; background: var(--status-success); border-radius: 50%; margin-right: 7px; box-shadow: 0 0 5px 0 #34d39999; }
/* Main */ /* Main */
.main-content { flex: 1; min-width: 0; padding: 32px clamp(20px, 4vw, 60px) 56px; }
.topbar { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; margin-bottom: 34px; }
.topbar h1, .hero h2, .panel h2, .panel h3 { margin: 0; color: var(--paper-bright); letter-spacing: -0.01em; font-family: var(--font-sans); font-weight: 700; } .topbar h1, .hero h2, .panel h2, .panel h3 { margin: 0; color: var(--paper-bright); letter-spacing: -0.01em; font-family: var(--font-sans); font-weight: 700; }
.topbar h1 { font-size: 27px; }
.eyebrow { text-transform: uppercase; letter-spacing: 0.11em; font-size: 12px; font-weight: 600; color: var(--accent); margin: 0 0 9px; font-family: var(--font-mono); } .eyebrow { text-transform: uppercase; letter-spacing: 0.11em; font-size: 12px; font-weight: 600; color: var(--accent); margin: 0 0 9px; font-family: var(--font-mono); }
.topbar-actions { display: flex; align-items: center; gap: 16px; }
.session-label { color: var(--ink-dim); font-size: 12px; font-family: var(--font-mono); } .session-label { color: var(--ink-dim); font-size: 12px; font-family: var(--font-mono); }
.avatar { display: grid; place-items: center; width: 34px; height: 34px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 50%; color: var(--paper); text-decoration: none; font-weight: 700; font-family: var(--font-sans); } .avatar { display: grid; place-items: center; width: 34px; height: 34px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 50%; color: var(--paper); text-decoration: none; font-weight: 700; font-family: var(--font-sans); }
.avatar:hover, .avatar:focus-visible { border-color: var(--accent-dim); } .avatar:hover, .avatar:focus-visible { border-color: var(--accent-dim); }
@@ -129,26 +121,22 @@ nav[aria-label="Primary navigation"] { display: grid; gap: 3px; }
.hero::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 2px; background: linear-gradient(90deg, var(--accent-dim), var(--accent) 30%, var(--accent-dim) 60%, transparent); opacity: 0.7; } .hero::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 2px; background: linear-gradient(90deg, var(--accent-dim), var(--accent) 30%, var(--accent-dim) 60%, transparent); opacity: 0.7; }
.hero h2 { font-size: clamp(22px, 3vw, 31px); } .hero h2 { font-size: clamp(22px, 3vw, 31px); }
.hero-copy { color: var(--ink-dim); margin: 10px 0 0; max-width: 46ch; } .hero-copy { color: var(--ink-dim); margin: 10px 0 0; max-width: 46ch; }
.hero-meta { min-width: 190px; color: var(--ink-faint); font-size: 11px; text-align: right; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.08em; }
.hero-meta strong { display: block; color: var(--paper); margin-top: 8px; font-size: 15px; letter-spacing: 0.02em; font-variant-numeric: tabular-nums; text-transform: none; }
/* Cards + panels */ /* Cards + panels */
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 20px; } .metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 20px; }
.metric-card, .panel { border: 1px solid var(--line); background: var(--surface-1); border-radius: 4px; }
.metric-card { min-height: 134px; padding: 19px; position: relative; }
.metric-card::before { content: ""; position: absolute; top: 0; left: 16px; right: 16px; height: 2px; background: var(--line-soft); }
.metric-heading, .panel-heading { display: flex; justify-content: space-between; gap: 10px; align-items: center; } .metric-heading, .panel-heading { display: flex; justify-content: space-between; gap: 10px; align-items: center; }
.metric-heading span:first-child { color: var(--ink-dim); font-size: 12px; font-weight: 500; letter-spacing: 0.02em; } .metric-heading span:first-child { color: var(--ink-dim); font-size: 12px; font-weight: 500; letter-spacing: 0.02em; }
.metric-card strong { display: block; font-size: 26px; color: var(--paper-bright); margin: 18px 0 8px; font-weight: 700; font-variant-numeric: tabular-nums; } .metric-card strong { display: block; font-size: 26px; color: var(--paper-bright); margin: 18px 0 8px; font-weight: 700; font-variant-numeric: tabular-nums; }
.metric-card p, .panel p { color: var(--ink-dim); font-size: 12px; margin: 0; line-height: 1.55; } .metric-card p, .panel p { color: var(--ink-dim); font-size: 12px; margin: 0; line-height: 1.55; }
.dashboard-columns { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(300px, 0.7fr); gap: 18px; margin-bottom: 20px; }
.panel { padding: 23px; } .panel { padding: 23px; }
.panel-heading { align-items: flex-start; margin-bottom: 19px; } .panel-heading { align-items: flex-start; margin-bottom: 19px; }
.panel-heading h3 { font-size: 16.5px; } .panel-heading h3 { font-size: 16.5px; }
.panel-heading a { color: var(--accent); font-size: 12px; text-decoration: none; font-weight: 600; }
.panel-heading a:hover { text-decoration: underline; } .panel-heading a:hover { text-decoration: underline; }
.exception-panel { margin-bottom: 20px; }
.exception-list { display: grid; gap: 4px; } .exception-list { display: grid; gap: 4px; }
.exception-item { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--line-soft); text-decoration: none; } .exception-item { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--line-soft); text-decoration: none; }
.exception-item:last-child { border-bottom: 0; } .exception-item:last-child { border-bottom: 0; }
@@ -157,10 +145,10 @@ nav[aria-label="Primary navigation"] { display: grid; gap: 3px; }
.exception-item strong { color: var(--paper); font-size: 12.5px; } .exception-item strong { color: var(--paper); font-size: 12.5px; }
.exception-item small { max-width: 78ch; margin-top: 3px; color: var(--ink-faint); font-size: 11px; line-height: 1.45; } .exception-item small { max-width: 78ch; margin-top: 3px; color: var(--ink-faint); font-size: 11px; line-height: 1.45; }
.table-wrap { overflow-x: auto; }
.mobile-record-list { display: none; } .mobile-record-list { display: none; }
table { border-collapse: collapse; width: 100%; font-size: 12.5px; text-align: left; } table { border-collapse: collapse; width: 100%; font-size: 12.5px; text-align: left; }
th { color: var(--ink-faint); font-weight: 600; padding: 9px 11px; border-bottom: 1px solid var(--line); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; font-family: var(--font-mono); }
td { color: var(--ink-dim); padding: 13px 11px; border-bottom: 1px solid var(--line-soft); white-space: nowrap; } td { color: var(--ink-dim); padding: 13px 11px; border-bottom: 1px solid var(--line-soft); white-space: nowrap; }
td strong, td small, .stack-item strong, .stack-item small { display: block; } td strong, td small, .stack-item strong, .stack-item small { display: block; }
td strong, .stack-item strong { color: var(--paper); font-family: var(--font-sans); font-weight: 600; letter-spacing: 0.01em; } td strong, .stack-item strong { color: var(--paper); font-family: var(--font-sans); font-weight: 600; letter-spacing: 0.01em; }
@@ -185,14 +173,12 @@ td small, .stack-item small { color: var(--ink-faint); margin-top: 3px; font-fam
.notice-info { border-left-color: var(--status-info); } .notice-info { border-left-color: var(--status-info); }
.notice button { color: var(--accent); background: transparent; border: 0; cursor: pointer; padding: 0 0 0 8px; text-decoration: underline; font-weight: 600; } .notice button { color: var(--accent); background: transparent; border: 0; cursor: pointer; padding: 0 0 0 8px; text-decoration: underline; font-weight: 600; }
.secondary-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
/* State panels */ /* State panels */
.state-panel, .auth-panel, .resource-page { max-width: 720px; margin: 10vh auto 0; text-align: center; padding: 50px 32px; } .state-panel, .auth-panel, .resource-page { max-width: 720px; margin: 10vh auto 0; text-align: center; padding: 50px 32px; }
.state-panel h2, .auth-panel h2, .auth-panel h1, .resource-page h2 { margin-bottom: 12px; font-size: 22px; } .state-panel h2, .auth-panel h2, .auth-panel h1, .resource-page h2 { margin-bottom: 12px; font-size: 22px; }
.state-panel p, .auth-panel p, .resource-page p { max-width: 500px; margin: 0 auto 22px; color: var(--ink-dim); } .state-panel p, .auth-panel p, .resource-page p { max-width: 500px; margin: 0 auto 22px; color: var(--ink-dim); }
.preauth-brand { display: inline-flex; margin-bottom: 30px; padding-bottom: 0; text-align: left; } .preauth-brand { display: inline-flex; margin-bottom: 30px; padding-bottom: 0; text-align: left; }
.auth-help { display: block; max-width: 390px; margin: 21px auto 0; color: var(--ink-faint); font-size: 11px; line-height: 1.5; }
.state-icon { display: grid; place-items: center; width: 44px; height: 44px; margin: 0 auto 18px; border-radius: 50%; background: var(--surface-2); border: 1px solid var(--accent-dim); color: var(--accent); font-size: 21px; font-family: var(--font-sans); } .state-icon { display: grid; place-items: center; width: 44px; height: 44px; margin: 0 auto 18px; border-radius: 50%; background: var(--surface-2); border: 1px solid var(--accent-dim); color: var(--accent); font-size: 21px; font-family: var(--font-sans); }
.spinner { width: 26px; height: 26px; margin: 0 auto 20px; border: 3px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; } .spinner { width: 26px; height: 26px; margin: 0 auto 20px; border: 3px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
@@ -206,11 +192,7 @@ td small, .stack-item small { color: var(--ink-faint); margin-top: 3px; font-fam
.empty-line { display: flex; align-items: center; justify-content: center; gap: 9px; padding: 19px; border: 1px dashed var(--line); border-radius: 3px; color: var(--ink-faint); font-size: 12px; } .empty-line { display: flex; align-items: center; justify-content: center; gap: 9px; padding: 19px; border: 1px dashed var(--line); border-radius: 3px; color: var(--ink-faint); font-size: 12px; }
.muted { color: var(--ink-dim); } .muted { color: var(--ink-dim); }
@media (max-width: 940px) {
.sidebar { width: 212px; }
.metric-grid, .secondary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-columns { grid-template-columns: 1fr; }
}
/* Compact navigation drawer + trigger -- hidden entirely above 840px, where /* Compact navigation drawer + trigger -- hidden entirely above 840px, where
the persistent sidebar (rendered unconditionally in app.tsx) is used the persistent sidebar (rendered unconditionally in app.tsx) is used
instead. Kept as its own rule set, not nested inside the 840px media instead. Kept as its own rule set, not nested inside the 840px media
@@ -223,45 +205,9 @@ td small, .stack-item small { color: var(--ink-faint); margin-top: 3px; font-fam
.nav-backdrop { display: none; } .nav-backdrop { display: none; }
.sidebar-head { display: contents; } .sidebar-head { display: contents; }
@media (max-width: 840px) {
.app-shell { display: block; }
.nav-trigger { display: flex; }
.topbar { align-items: center; }
/* Real slide-in drawer, not a squeezed copy of the desktop sidebar:
fixed off-canvas panel, translated into view only while open, above a
dedicated backdrop that closes it on click (Escape closes it too, via
app.tsx). */
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 210; width: min(84vw, 300px); overflow-y: auto; transform: translateX(-100%); transition: transform var(--motion-base) ease; box-shadow: var(--shadow-2); padding: 20px 16px; }
.sidebar-open { transform: translateX(0); }
.sidebar-head { display: flex; align-items: center; justify-content: space-between; }
.brand { padding: 0 4px 14px; }
.nav-close { display: block; }
nav[aria-label="Primary navigation"] { display: grid; }
.nav-link { font-size: 13.5px; padding: 10px 12px; }
.sidebar-foot { display: block; }
.nav-backdrop { display: block; position: fixed; inset: 0; background: #00000099; z-index: 200; }
.main-content { padding: 24px 14px 42px; }
.preauth-shell .main-content { padding-top: 10px; }
.topbar { margin-bottom: 24px; gap: 12px; }
.topbar h1 { font-size: 22px; }
.session-label { display: none; }
.hero { display: block; padding: 21px; }
.hero-meta { text-align: left; margin-top: 19px; }
.metric-card { min-height: 120px; padding: 15px; }
.metric-card strong { font-size: 21px; margin-top: 15px; }
.panel { min-width: 0; padding: 18px; }
.dashboard-columns > * { min-width: 0; }
}
@media (prefers-reduced-motion: reduce) {
.sidebar { transition: none; }
}
/* Resource / detail views */ /* Resource / detail views */
.breadcrumbs { display: flex; gap: 9px; align-items: center; color: var(--ink-faint); font-size: 12px; margin-bottom: 17px; font-family: var(--font-mono); } .breadcrumbs { display: flex; gap: 9px; align-items: center; color: var(--ink-faint); font-size: 12px; margin-bottom: 17px; font-family: var(--font-mono); }
.session-menu { display: flex; align-items: center; gap: 12px; margin-left: auto; }
.session-identity { display: flex; align-items: center; gap: 8px; text-align: right; line-height: 1.25; } .session-identity { display: flex; align-items: center; gap: 8px; text-align: right; line-height: 1.25; }
.session-identity > span:last-child { display: grid; } .session-identity > span:last-child { display: grid; }
.session-identity strong { color: var(--paper-bright); font-size: 12px; max-width: 240px; overflow: hidden; text-overflow: ellipsis; } .session-identity strong { color: var(--paper-bright); font-size: 12px; max-width: 240px; overflow: hidden; text-overflow: ellipsis; }
@@ -276,7 +222,7 @@ td small, .stack-item small { color: var(--ink-faint); margin-top: 3px; font-fam
.tag { border: 1px solid var(--line); background: var(--surface-2); border-radius: 999px; color: var(--ink-dim); padding: 5px 10px; font-size: 10.5px; white-space: nowrap; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.04em; } .tag { border: 1px solid var(--line); background: var(--surface-2); border-radius: 999px; color: var(--ink-dim); padding: 5px 10px; font-size: 10.5px; white-space: nowrap; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.04em; }
.filter-row { display: flex; align-items: end; gap: 14px; margin: 19px 0; } .filter-row { display: flex; align-items: end; gap: 14px; margin: 19px 0; }
.filter-field, .form-grid label { display: grid; gap: 6px; color: var(--ink-dim); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; } .filter-field, .form-grid label { display: grid; gap: 6px; color: var(--ink-dim); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; }
.filter-field { flex: 1; max-width: 440px; }
.filter-help { color: var(--ink-faint); font-size: 12px; padding-bottom: 11px; font-family: var(--font-mono); text-transform: none; letter-spacing: 0; } .filter-help { color: var(--ink-faint); font-size: 12px; padding-bottom: 11px; font-family: var(--font-mono); text-transform: none; letter-spacing: 0; }
.filter-field input, .form-grid input, .form-grid select, .form-grid textarea { width: 100%; border: 1px solid var(--line); border-radius: 3px; background: var(--surface-0); color: var(--paper); padding: 10px 11px; outline: none; font-size: 13px; text-transform: none; letter-spacing: 0; font-weight: 400; transition: border-color 120ms ease, box-shadow 120ms ease; } .filter-field input, .form-grid input, .form-grid select, .form-grid textarea { width: 100%; border: 1px solid var(--line); border-radius: 3px; background: var(--surface-0); color: var(--paper); padding: 10px 11px; outline: none; font-size: 13px; text-transform: none; letter-spacing: 0; font-weight: 400; transition: border-color 120ms ease, box-shadow 120ms ease; }
.filter-field input:focus, .form-grid input:focus, .form-grid select:focus, .form-grid textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 2px #00b0fe33; } .filter-field input:focus, .form-grid input:focus, .form-grid select:focus, .form-grid textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 2px #00b0fe33; }
@@ -284,11 +230,11 @@ td small, .stack-item small { color: var(--ink-faint); margin-top: 3px; font-fam
.risk-label { display: inline-flex; align-items: center; min-height: 24px; padding: 3px 8px; border: 1px solid var(--line); border-radius: var(--radius-pill); color: var(--paper); background: var(--surface-2); font-family: var(--font-mono); font-size: 11px; } .risk-label { display: inline-flex; align-items: center; min-height: 24px; padding: 3px 8px; border: 1px solid var(--line); border-radius: var(--radius-pill); color: var(--paper); background: var(--surface-2); font-family: var(--font-mono); font-size: 11px; }
.inline-actions { display: flex; gap: 5px; } .inline-actions { display: flex; gap: 5px; }
.button-small { padding: 7px 10px; font-size: 10.5px; } .button-small { padding: 7px 10px; font-size: 10.5px; }
.table-controls { display: flex; gap: 10px; }
.table-controls .active { border-color: var(--accent); } .table-controls .active { border-color: var(--accent); }
.button-danger { color: #f6dcd7; background: var(--status-danger-dim); border-color: #7a3b30; } .button-danger { color: #f6dcd7; background: var(--status-danger-dim); border-color: #7a3b30; }
.button:disabled { cursor: not-allowed; opacity: 0.4; filter: none; } .button:disabled { cursor: not-allowed; opacity: 0.4; filter: none; }
.pagination { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--ink-faint); font-size: 11px; margin-top: 16px; font-family: var(--font-mono); }
.workflow-panel { max-width: none; } .workflow-panel { max-width: none; }
.workflow-panel h3 { margin-bottom: 9px; } .workflow-panel h3 { margin-bottom: 9px; }
.form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 19px 0; } .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 19px 0; }
@@ -313,8 +259,8 @@ td small, .stack-item small { color: var(--ink-faint); margin-top: 3px; font-fam
.alert-list { display: grid; gap: 10px; margin-top: 16px; } .alert-list { display: grid; gap: 10px; margin-top: 16px; }
.alert-list .notice { display: grid; grid-template-columns: minmax(130px, .3fr) 1fr; gap: 14px; } .alert-list .notice { display: grid; grid-template-columns: minmax(130px, .3fr) 1fr; gap: 14px; }
.alert-list .notice strong { text-transform: uppercase; font: 11px/1.4 var(--font-mono); } .alert-list .notice strong { text-transform: uppercase; font: 11px/1.4 var(--font-mono); }
.trend-chart { display: flex; align-items: end; gap: 5px; height: 180px; margin-top: 18px; padding: 12px 8px 0; border: 1px solid var(--line); background: var(--surface-0); overflow-x: auto; }
.trend-column { display: grid; grid-template-rows: 1fr 18px; align-items: end; justify-items: center; min-width: 20px; height: 100%; flex: 1; }
.trend-bar { display: block; width: min(12px, 70%); min-height: 4px; border-radius: 2px 2px 0 0; background: var(--accent); } .trend-bar { display: block; width: min(12px, 70%); min-height: 4px; border-radius: 2px 2px 0 0; background: var(--accent); }
.trend-column small { color: var(--ink-faint); font: 9px/1 var(--font-mono); } .trend-column small { color: var(--ink-faint); font: 9px/1 var(--font-mono); }
.onboarding-result { margin-top: 18px; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--accent); background: var(--surface-2); } .onboarding-result { margin-top: 18px; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--accent); background: var(--surface-2); }
@@ -354,29 +300,6 @@ td small, .stack-item small { color: var(--ink-faint); margin-top: 3px; font-fam
.theme-toggle { display: flex; align-items: center; gap: 10px; margin-bottom: 19px; } .theme-toggle { display: flex; align-items: center; gap: 10px; margin-bottom: 19px; }
.theme-toggle span { color: var(--ink-dim); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } .theme-toggle span { color: var(--ink-dim); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
@media (max-width: 640px) {
.filter-row, .form-grid { display: grid; grid-template-columns: 1fr; align-items: stretch; }
.filter-field { max-width: none; }
.filter-help { padding: 0; }
.inline-actions { flex-wrap: wrap; }
.detail-grid { grid-template-columns: 1fr; }
.resource-stack .resource-page { padding: 18px; }
.resource-stack .panel-heading { flex-wrap: wrap; }
.session-identity { display: none; }
.connector-checks > div, .alert-list .notice { grid-template-columns: auto 1fr; }
.connector-checks span { grid-column: 1 / -1; }
.desktop-data-table { display: none; }
.mobile-record-list { display: grid; gap: 10px; }
.mobile-record { padding: 14px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); }
.mobile-record-heading { display: flex; justify-content: space-between; align-items: start; gap: 10px; }
.mobile-record strong, .mobile-record small { display: block; }
.mobile-record strong { color: var(--paper-bright); overflow-wrap: anywhere; }
.mobile-record small { margin-top: 3px; color: var(--ink-faint); font-family: var(--font-mono); font-size: 10.5px; overflow-wrap: anywhere; }
.mobile-record dl { display: grid; grid-template-columns: minmax(88px, 0.42fr) 1fr; gap: 8px 12px; margin: 14px 0; font-size: 12px; }
.mobile-record dt { color: var(--ink-faint); font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
.mobile-record dd { min-width: 0; margin: 0; color: var(--ink-dim); overflow-wrap: anywhere; }
}
/* Modal */ /* Modal */
.modal-overlay { position: fixed; inset: 0; background: #000a; display: grid; place-items: center; z-index: 100; padding: var(--space-4); } .modal-overlay { position: fixed; inset: 0; background: #000a; display: grid; place-items: center; z-index: 100; padding: var(--space-4); }
.modal { background: var(--surface-1); border: 1px solid var(--line); border-radius: var(--radius-md); box-shadow: var(--shadow-2); max-width: 760px; width: 100%; max-height: calc(100vh - 32px); overflow-y: auto; padding: var(--space-6); } .modal { background: var(--surface-1); border: 1px solid var(--line); border-radius: var(--radius-md); box-shadow: var(--shadow-2); max-width: 760px; width: 100%; max-height: calc(100vh - 32px); overflow-y: auto; padding: var(--space-6); }
@@ -398,16 +321,9 @@ td small, .stack-item small { color: var(--ink-faint); margin-top: 3px; font-fam
sharpen hierarchy and interaction without changing the established sharpen hierarchy and interaction without changing the established
security-oriented dark control-plane identity. */ security-oriented dark control-plane identity. */
body { body { margin: 0; min-width: 320px; background: var(--surface-0); background-image: linear-gradient(var(--line-soft) 1px, transparent 1px),
min-height: 100vh;
line-height: 1.5;
background-image:
linear-gradient(var(--line-soft) 1px, transparent 1px),
linear-gradient(90deg, var(--line-soft) 1px, transparent 1px), linear-gradient(90deg, var(--line-soft) 1px, transparent 1px),
radial-gradient(900px 540px at 8% -10%, color-mix(in srgb, var(--accent-dim) 34%, transparent), transparent 62%); radial-gradient(900px 540px at 8% -10%, color-mix(in srgb, var(--accent-dim) 34%, transparent), transparent 62%); background-attachment: fixed; min-height: 100vh; line-height: 1.5; background-size: 64px 64px, 64px 64px, auto; }
background-size: 64px 64px, 64px 64px, auto;
background-attachment: fixed;
}
body::before { body::before {
content: ""; content: "";
position: fixed; position: fixed;
@@ -420,41 +336,27 @@ body::before {
.icon { display: block; flex: 0 0 auto; } .icon { display: block; flex: 0 0 auto; }
/* Authenticated shell */ /* Authenticated shell */
.sidebar { .sidebar { width: 252px; background: color-mix(in srgb, var(--surface-1) 96%, transparent); border-right: 1px solid var(--line); padding: 25px 17px 20px; display: flex; flex-direction: column; flex-shrink: 0; position: sticky; top: 0; z-index: 20; height: 100vh; backdrop-filter: blur(18px); }
position: sticky;
top: 0;
z-index: 20;
width: 252px;
height: 100vh;
padding: 25px 17px 20px;
background: color-mix(in srgb, var(--surface-1) 96%, transparent);
backdrop-filter: blur(18px);
}
.sidebar .brand { padding: 0 9px 25px; } .sidebar .brand { padding: 0 9px 25px; }
.brand-mark { .brand-mark { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 9px; color: var(--surface-0); background: linear-gradient(160deg, var(--accent-bright), var(--accent)); box-shadow: 0 0 0 1px var(--accent-dim), 0 8px 22px -10px color-mix(in srgb, var(--accent) 60%, transparent); flex-shrink: 0; }
width: 38px; .nav-group { display: grid; gap: 3px; margin: 0 0 14px; margin-bottom: 11px; }
height: 38px; .nav-link { display: flex; align-items: center; gap: 11px; color: var(--ink-dim); padding: 9px 11px; border-radius: 7px; text-decoration: none; font-size: 13.5px; font-weight: 500; position: relative; transition: color 120ms ease, background-color 120ms ease; min-height: 40px; border: 1px solid transparent; }
border-radius: 9px; .nav-link::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: var(--line); flex-shrink: 0; transition: background-color 120ms ease, box-shadow 120ms ease; display: none; }
box-shadow: 0 0 0 1px var(--accent-dim), 0 8px 22px -10px color-mix(in srgb, var(--accent) 60%, transparent);
}
.nav-group { margin-bottom: 11px; }
.nav-link { gap: 11px; min-height: 40px; padding: 9px 11px; border: 1px solid transparent; }
.nav-link::before { display: none; }
.nav-link .icon { color: var(--ink-faint); transition: color var(--motion-fast) ease, transform var(--motion-fast) ease; } .nav-link .icon { color: var(--ink-faint); transition: color var(--motion-fast) ease, transform var(--motion-fast) ease; }
.nav-link:hover .icon, .nav-link:focus-visible .icon { color: var(--paper); transform: translateX(1px); } .nav-link:hover .icon, .nav-link:focus-visible .icon { color: var(--paper); transform: translateX(1px); }
.nav-link.active { border-color: color-mix(in srgb, var(--accent-dim) 72%, var(--line)); background: color-mix(in srgb, var(--surface-2) 90%, var(--accent-dim)); } .nav-link.active { color: var(--paper-bright); background: color-mix(in srgb, var(--surface-2) 90%, var(--accent-dim)); font-weight: 600; border-color: color-mix(in srgb, var(--accent-dim) 72%, var(--line)); }
.nav-link.active .icon { color: var(--accent-bright); } .nav-link.active .icon { color: var(--accent-bright); }
.sidebar-foot { display: grid; gap: 3px; } .sidebar-foot { margin-top: auto; padding: 16px 12px 0; border-top: 1px solid var(--line); color: var(--ink-dim); font-size: 12px; line-height: 1.6; display: grid; gap: 3px; }
.sidebar-foot > span { color: var(--paper); font-size: 11.5px; } .sidebar-foot > span { color: var(--paper); font-size: 11.5px; }
.sidebar-foot small { font-family: var(--font-mono); font-size: 9.5px; } .sidebar-foot small { color: var(--ink-faint); font-family: var(--font-mono); font-size: 9.5px; }
.main-content { padding: 28px clamp(22px, 3.6vw, 58px) 64px; } .main-content { flex: 1; min-width: 0; padding: 28px clamp(22px, 3.6vw, 58px) 64px; }
.topbar, .dashboard, .resource-stack { width: min(100%, 1480px); margin-inline: auto; } .topbar, .dashboard, .resource-stack { width: min(100%, 1480px); margin-inline: auto; }
.topbar { align-items: center; min-height: 52px; margin-bottom: 29px; } .topbar { display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-bottom: 29px; min-height: 52px; }
.topbar-title { min-width: 0; } .topbar-title { min-width: 0; }
.topbar-title .eyebrow { margin-bottom: 4px; font-size: 9.5px; } .topbar-title .eyebrow { margin-bottom: 4px; font-size: 9.5px; }
.topbar h1 { font-size: clamp(23px, 2.2vw, 29px); } .topbar h1 { font-size: clamp(23px, 2.2vw, 29px); }
.topbar-actions { min-width: 0; } .topbar-actions { display: flex; align-items: center; gap: 16px; min-width: 0; }
.command-trigger { .command-trigger {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -471,11 +373,11 @@ body::before {
} }
.command-trigger:hover, .command-trigger:focus-visible { color: var(--paper-bright); border-color: var(--ink-faint); background: var(--surface-2); } .command-trigger:hover, .command-trigger:focus-visible { color: var(--paper-bright); border-color: var(--ink-faint); background: var(--surface-2); }
kbd { border: 1px solid var(--line); border-bottom-color: var(--ink-faint); border-radius: 4px; padding: 2px 6px; color: var(--ink-faint); background: var(--surface-0); font: 9px/1.5 var(--font-mono); } kbd { border: 1px solid var(--line); border-bottom-color: var(--ink-faint); border-radius: 4px; padding: 2px 6px; color: var(--ink-faint); background: var(--surface-0); font: 9px/1.5 var(--font-mono); }
.session-menu { gap: 9px; } .session-menu { display: flex; align-items: center; gap: 9px; margin-left: auto; }
.session-menu > .tag { padding-inline: 8px; } .session-menu > .tag { padding-inline: 8px; }
/* Public gateway */ /* Public gateway */
.preauth-shell { display: block; min-height: 100vh; } .preauth-shell { min-height: 100vh; display: block; place-items: start center; }
.preauth-shell .main-content { width: 100%; min-height: 100vh; padding: clamp(18px, 3vw, 42px); } .preauth-shell .main-content { width: 100%; min-height: 100vh; padding: clamp(18px, 3vw, 42px); }
.auth-layout { .auth-layout {
display: grid; display: grid;
@@ -497,7 +399,7 @@ kbd { border: 1px solid var(--line); border-bottom-color: var(--ink-faint); bord
.auth-actions { display: flex; align-items: center; gap: 18px; margin-top: 31px; } .auth-actions { display: flex; align-items: center; gap: 18px; margin-top: 31px; }
.auth-actions > span { color: var(--ink-faint); font: 10px/1.5 var(--font-mono); } .auth-actions > span { color: var(--ink-faint); font: 10px/1.5 var(--font-mono); }
.auth-button { gap: 10px; min-height: 45px; } .auth-button { gap: 10px; min-height: 45px; }
.auth-help { max-width: 58ch; margin: 17px 0 0; color: var(--ink-faint); font-size: 10.5px; } .auth-help { display: block; max-width: 58ch; margin: 17px 0 0; color: var(--ink-faint); font-size: 10.5px; line-height: 1.5; }
.auth-trust-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; margin: clamp(42px, 7vh, 74px) 0 0; border: 1px solid var(--line); background: var(--line); } .auth-trust-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; margin: clamp(42px, 7vh, 74px) 0 0; border: 1px solid var(--line); background: var(--line); }
.auth-trust-list > div { padding: 13px; background: var(--surface-1); } .auth-trust-list > div { padding: 13px; background: var(--surface-1); }
.auth-trust-list dt { color: var(--paper); font-size: 11px; font-weight: 650; } .auth-trust-list dt { color: var(--paper); font-size: 11px; font-weight: 650; }
@@ -563,18 +465,18 @@ kbd { border: 1px solid var(--line); border-bottom-color: var(--ink-faint); bord
.dashboard-hero h2 { max-width: 14ch; font-size: clamp(30px, 3vw, 47px); line-height: 1.04; letter-spacing: -.045em; } .dashboard-hero h2 { max-width: 14ch; font-size: clamp(30px, 3vw, 47px); line-height: 1.04; letter-spacing: -.045em; }
.dashboard-hero .hero-copy { max-width: 62ch; margin-top: 15px; font-size: 13px; line-height: 1.65; } .dashboard-hero .hero-copy { max-width: 62ch; margin-top: 15px; font-size: 13px; line-height: 1.65; }
.hero-actions { display: flex; align-items: center; gap: 9px; margin-top: 24px; } .hero-actions { display: flex; align-items: center; gap: 9px; margin-top: 24px; }
.hero-meta { margin-top: 27px; text-align: left; } .hero-meta { min-width: 190px; color: var(--ink-faint); font-size: 11px; text-align: left; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.08em; margin-top: 27px; }
.hero-meta strong { display: inline; margin: 0 0 0 10px; font-size: 10.5px; } .hero-meta strong { display: inline; color: var(--paper); margin-top: 8px; font-size: 10.5px; letter-spacing: 0.02em; font-variant-numeric: tabular-nums; text-transform: none; margin: 0 0 0 10px; }
.dashboard-hero .topology { align-self: center; } .dashboard-hero .topology { align-self: center; }
.primary-metrics { margin: 0; } .primary-metrics { margin: 0; }
.metric-card, .panel { border-color: color-mix(in srgb, var(--line) 88%, transparent); box-shadow: var(--shadow-1); } .metric-card, .panel { border: 1px solid var(--line); background: var(--surface-1); border-radius: 4px; border-color: color-mix(in srgb, var(--line) 88%, transparent); box-shadow: var(--shadow-1); }
.metric-card { overflow: hidden; transition: border-color var(--motion-base) ease, transform var(--motion-base) ease, background-color var(--motion-base) ease; } .metric-card { min-height: 134px; padding: 19px; position: relative; overflow: hidden; transition: border-color var(--motion-base) ease, transform var(--motion-base) ease, background-color var(--motion-base) ease; }
.metric-card:hover { border-color: var(--ink-faint); transform: translateY(-1px); background: color-mix(in srgb, var(--surface-1) 93%, var(--surface-2-hover)); } .metric-card:hover { border-color: var(--ink-faint); transform: translateY(-1px); background: color-mix(in srgb, var(--surface-1) 93%, var(--surface-2-hover)); }
.metric-card::before { left: 0; right: auto; width: 3px; height: 100%; background: var(--line); } .metric-card::before { content: ""; position: absolute; top: 0; left: 0; right: auto; height: 100%; background: var(--line); width: 3px; }
.metric-card:has(.status-healthy)::before { background: var(--status-success); } .metric-card:has(.status-healthy)::before { background: var(--status-success); }
.metric-card:has(.status-degraded)::before { background: var(--accent); } .metric-card:has(.status-degraded)::before { background: var(--accent); }
.metric-card:has(.status-unavailable)::before { background: var(--status-danger); } .metric-card:has(.status-unavailable)::before { background: var(--status-danger); }
.exception-panel { margin: 0; } .exception-panel { margin-bottom: 20px; margin: 0; }
.exception-action { display: inline-flex; align-items: center; gap: 9px; } .exception-action { display: inline-flex; align-items: center; gap: 9px; }
.exception-action > .icon { color: var(--ink-faint); transition: transform var(--motion-fast) ease; } .exception-action > .icon { color: var(--ink-faint); transition: transform var(--motion-fast) ease; }
.exception-item:hover .exception-action > .icon { transform: translateX(3px); color: var(--accent); } .exception-item:hover .exception-action > .icon { transform: translateX(3px); color: var(--accent); }
@@ -584,8 +486,8 @@ kbd { border: 1px solid var(--line); border-bottom-color: var(--ink-faint); bord
.empty-line small { margin-top: 3px; color: var(--ink-faint); font-size: 10.5px; } .empty-line small { margin-top: 3px; color: var(--ink-faint); font-size: 10.5px; }
.empty-success { justify-content: flex-start; border-style: solid; border-color: color-mix(in srgb, var(--status-success) 24%, var(--line)); background: color-mix(in srgb, var(--status-success-dim) 34%, transparent); } .empty-success { justify-content: flex-start; border-style: solid; border-color: color-mix(in srgb, var(--status-success) 24%, var(--line)); background: color-mix(in srgb, var(--status-success-dim) 34%, transparent); }
.empty-success > span:first-child { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; color: var(--status-success); background: var(--status-success-dim); font-weight: 700; } .empty-success > span:first-child { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; color: var(--status-success); background: var(--status-success-dim); font-weight: 700; }
.dashboard-columns { margin: 0; } .dashboard-columns { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(300px, 0.7fr); gap: 18px; margin-bottom: 20px; margin: 0; }
.panel-heading a { display: inline-flex; align-items: center; gap: 5px; } .panel-heading a { color: var(--accent); font-size: 12px; text-decoration: none; font-weight: 600; display: inline-flex; align-items: center; gap: 5px; }
.record-link { text-decoration: none; } .record-link { text-decoration: none; }
.record-link:hover strong, .record-link:focus-visible strong { color: var(--accent-bright); } .record-link:hover strong, .record-link:focus-visible strong { color: var(--accent-bright); }
.connector-score { display: flex; align-items: center; gap: 13px; margin: -3px 0 11px; padding: 0 0 16px; border-bottom: 1px solid var(--line); } .connector-score { display: flex; align-items: center; gap: 13px; margin: -3px 0 11px; padding: 0 0 16px; border-bottom: 1px solid var(--line); }
@@ -624,18 +526,18 @@ kbd { border: 1px solid var(--line); border-bottom-color: var(--ink-faint); bord
.record-count strong { color: var(--paper-bright); font-size: 17px; font-variant-numeric: tabular-nums; } .record-count strong { color: var(--paper-bright); font-size: 17px; font-variant-numeric: tabular-nums; }
.record-count small { color: var(--ink-faint); font: 8.5px/1.4 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; } .record-count small { color: var(--ink-faint); font: 8.5px/1.4 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; }
.resource-toolbar { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin: 19px 0 16px; } .resource-toolbar { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin: 19px 0 16px; }
.filter-field { max-width: 540px; } .filter-field { flex: 1; max-width: 540px; }
.filter-input-wrap { display: flex; align-items: center; gap: 9px; min-height: 42px; padding: 0 10px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface-0); color: var(--ink-faint); transition: border-color var(--motion-fast) ease, box-shadow var(--motion-fast) ease; } .filter-input-wrap { display: flex; align-items: center; gap: 9px; min-height: 42px; padding: 0 10px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface-0); color: var(--ink-faint); transition: border-color var(--motion-fast) ease, box-shadow var(--motion-fast) ease; }
.filter-input-wrap:focus-within { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent); } .filter-input-wrap:focus-within { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent); }
.filter-input-wrap input { flex: 1; min-width: 0; padding: 0; border: 0; outline: 0; box-shadow: none !important; } .filter-input-wrap input { flex: 1; min-width: 0; padding: 0; border: 0; outline: 0; box-shadow: none !important; }
.filter-clear { display: grid; place-items: center; width: 25px; height: 25px; border: 0; border-radius: 50%; color: var(--ink-faint); background: transparent; cursor: pointer; } .filter-clear { display: grid; place-items: center; width: 25px; height: 25px; border: 0; border-radius: 50%; color: var(--ink-faint); background: transparent; cursor: pointer; }
.filter-clear:hover, .filter-clear:focus-visible { color: var(--paper); background: var(--surface-2-hover); } .filter-clear:hover, .filter-clear:focus-visible { color: var(--paper); background: var(--surface-2-hover); }
.table-controls { align-items: center; } .table-controls { display: flex; gap: 10px; align-items: center; }
.table-controls > span { color: var(--ink-faint); font: 9px/1 var(--font-mono); text-transform: uppercase; letter-spacing: .06em; } .table-controls > span { color: var(--ink-faint); font: 9px/1 var(--font-mono); text-transform: uppercase; letter-spacing: .06em; }
.resource-results { position: relative; min-height: 84px; } .resource-results { position: relative; min-height: 84px; }
.results-refreshing .desktop-data-table, .results-refreshing .mobile-record-list { opacity: .5; transition: opacity var(--motion-fast) ease; } .results-refreshing .desktop-data-table, .results-refreshing .mobile-record-list { opacity: .5; transition: opacity var(--motion-fast) ease; }
.refresh-indicator { position: absolute; z-index: 3; top: 12px; left: 50%; display: inline-flex; align-items: center; gap: 8px; transform: translateX(-50%); padding: 7px 10px; border: 1px solid var(--line); border-radius: var(--radius-pill); color: var(--paper); background: var(--surface-2); box-shadow: var(--shadow-2); font-size: 10px; } .refresh-indicator { position: absolute; z-index: 3; top: 12px; left: 50%; display: inline-flex; align-items: center; gap: 8px; transform: translateX(-50%); padding: 7px 10px; border: 1px solid var(--line); border-radius: var(--radius-pill); color: var(--paper); background: var(--surface-2); box-shadow: var(--shadow-2); font-size: 10px; }
.table-wrap { max-width: 100%; overflow-x: auto; border: 1px solid var(--line-soft); border-radius: var(--radius-md); } .table-wrap { overflow-x: auto; max-width: 100%; border: 1px solid var(--line-soft); border-radius: var(--radius-md); }
.table-wrap table { min-width: 820px; } .table-wrap table { min-width: 820px; }
.desktop-data-table table { table-layout: fixed; } .desktop-data-table table { table-layout: fixed; }
.desktop-data-table th:nth-child(1) { width: 24%; } .desktop-data-table th:nth-child(1) { width: 24%; }
@@ -655,8 +557,8 @@ kbd { border: 1px solid var(--line); border-bottom-color: var(--ink-faint); bord
.table-kind-audit th:nth-child(5) { width: 20%; } .table-kind-audit th:nth-child(5) { width: 20%; }
tbody tr { transition: background-color var(--motion-fast) ease; } tbody tr { transition: background-color var(--motion-fast) ease; }
tbody tr:hover { background: color-mix(in srgb, var(--surface-2-hover) 62%, transparent); } tbody tr:hover { background: color-mix(in srgb, var(--surface-2-hover) 62%, transparent); }
th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); } th { color: var(--ink-faint); font-weight: 600; padding: 9px 11px; border-bottom: 1px solid var(--line); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; font-family: var(--font-mono); background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
.pagination { min-height: 38px; margin-top: 11px; } .pagination { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--ink-faint); font-size: 11px; margin-top: 11px; font-family: var(--font-mono); min-height: 38px; }
.pagination > div { display: flex; gap: 6px; } .pagination > div { display: flex; gap: 6px; }
.pagination .button { display: inline-flex; align-items: center; gap: 5px; } .pagination .button { display: inline-flex; align-items: center; gap: 5px; }
.client-safety, .approvals-panel { display: flex; justify-content: space-between; align-items: center; gap: 24px; } .client-safety, .approvals-panel { display: flex; justify-content: space-between; align-items: center; gap: 24px; }
@@ -700,8 +602,8 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
.legend-denied, .trend-denied { background: var(--accent); } .legend-denied, .trend-denied { background: var(--accent); }
.legend-error, .trend-error { background: var(--status-danger); } .legend-error, .trend-error { background: var(--status-danger); }
.legend-limited, .trend-limited { background: var(--status-unknown); } .legend-limited, .trend-limited { background: var(--status-unknown); }
.trend-chart { gap: 4px; height: 230px; padding: 16px 10px 0; } .trend-chart { display: flex; align-items: end; gap: 4px; height: 230px; margin-top: 18px; padding: 16px 10px 0; border: 1px solid var(--line); background: var(--surface-0); overflow-x: auto; }
.trend-column { grid-template-rows: 16px 1fr 18px; min-width: 19px; } .trend-column { display: grid; grid-template-rows: 16px 1fr 18px; align-items: end; justify-items: center; min-width: 19px; height: 100%; flex: 1; }
.trend-total { color: var(--ink-faint); font: 8px/1 var(--font-mono); } .trend-total { color: var(--ink-faint); font: 8px/1 var(--font-mono); }
.trend-stack { display: flex; flex-direction: column-reverse; width: min(15px, 75%); min-height: 4px; overflow: hidden; border-radius: 3px 3px 1px 1px; background: var(--surface-2); transition: filter var(--motion-fast) ease; } .trend-stack { display: flex; flex-direction: column-reverse; width: min(15px, 75%); min-height: 4px; overflow: hidden; border-radius: 3px 3px 1px 1px; background: var(--surface-2); transition: filter var(--motion-fast) ease; }
.trend-column:hover .trend-stack { filter: brightness(1.28); } .trend-column:hover .trend-stack { filter: brightness(1.28); }
@@ -730,8 +632,10 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
.state-footnote { display: block; margin-top: 20px; color: var(--ink-faint); font: 9.5px/1.5 var(--font-mono); } .state-footnote { display: block; margin-top: 20px; color: var(--ink-faint); font: 9.5px/1.5 var(--font-mono); }
/* Responsive composition */ /* Responsive composition */
/* Responsive layout: tablet, mobile, then narrow phones. */
@media (max-width: 1220px) { @media (max-width: 1220px) {
.dashboard-hero { grid-template-columns: minmax(0, 1fr); } .dashboard-hero { grid-template-columns: minmax(0, 1fr); }
.dashboard-hero h2 { max-width: 20ch; } .dashboard-hero h2 { max-width: 20ch; }
.secondary-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } .secondary-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.onboarding-form { grid-template-columns: repeat(2, minmax(0, 1fr)); } .onboarding-form { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -740,7 +644,7 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
} }
@media (max-width: 980px) { @media (max-width: 980px) {
.auth-layout { grid-template-columns: 1fr; } .auth-layout { grid-template-columns: 1fr; }
.auth-copy .preauth-brand { margin-bottom: 46px; } .auth-copy .preauth-brand { margin-bottom: 46px; }
.auth-copy h1 { max-width: 16ch; } .auth-copy h1 { max-width: 16ch; }
.auth-visual { border-top: 1px solid var(--line); border-left: 0; } .auth-visual { border-top: 1px solid var(--line); border-left: 0; }
@@ -750,23 +654,58 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
.command-trigger span, .command-trigger kbd { display: none; } .command-trigger span, .command-trigger kbd { display: none; }
} }
@media (max-width: 940px) {
.sidebar { width: 212px; }
.metric-grid, .secondary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-columns { grid-template-columns: 1fr; }
}
@media (max-width: 840px) { @media (max-width: 840px) {
/* Real slide-in drawer, not a squeezed copy of the desktop sidebar:
fixed off-canvas panel, translated into view only while open, above a
dedicated backdrop that closes it on click (Escape closes it too, via
app.tsx). */
.brand { padding: 0 4px 14px; }
.main-content { padding: 24px 14px 42px; }
.preauth-shell .main-content { padding-top: 10px; }
.topbar h1 { font-size: 22px; }
.session-label { display: none; }
.hero { display: block; padding: 21px; }
.hero-meta { text-align: left; margin-top: 19px; }
.metric-card { min-height: 120px; padding: 15px; }
.metric-card strong { font-size: 21px; margin-top: 15px; }
.panel { min-width: 0; padding: 18px; }
.dashboard-columns > * { min-width: 0; }
.app-shell { display: block; } .app-shell { display: block; }
.nav-trigger { display: flex; } .nav-trigger { display: flex; }
.topbar { align-items: center; }
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 210; width: min(84vw, 300px); height: 100vh; overflow-y: auto; transform: translateX(-100%); transition: transform var(--motion-base) ease; box-shadow: var(--shadow-2); padding: 20px 16px; } .topbar { align-items: center; margin-bottom: 24px; gap: 12px; }
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 210; width: min(84vw, 300px); overflow-y: auto; transform: translateX(-100%); transition: transform var(--motion-base) ease; box-shadow: var(--shadow-2); padding: 20px 16px; height: 100vh; }
.sidebar-open { transform: translateX(0); } .sidebar-open { transform: translateX(0); }
.sidebar-head { display: flex; align-items: center; justify-content: space-between; } .sidebar-head { display: flex; align-items: center; justify-content: space-between; }
.sidebar .brand { padding: 0 4px 14px; } .sidebar .brand { padding: 0 4px 14px; }
.nav-close { display: block; } .nav-close { display: block; }
nav[aria-label="Primary navigation"] { display: grid; } nav[aria-label="Primary navigation"] { display: grid; }
.nav-link { font-size: 13.5px; padding: 10px 12px; } .nav-link { font-size: 13.5px; padding: 10px 12px; }
.sidebar-foot { display: block; } .sidebar-foot { display: block; }
.nav-backdrop { display: block; position: fixed; inset: 0; background: #00000099; z-index: 200; } .nav-backdrop { display: block; position: fixed; inset: 0; background: #00000099; z-index: 200; }
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.session-menu > .tag { display: none; } .session-menu > .tag { display: none; }
.resource-toolbar { display: grid; grid-template-columns: 1fr; align-items: stretch; } .resource-toolbar { display: grid; grid-template-columns: 1fr; align-items: stretch; }
.filter-field { max-width: none; } .filter-field { max-width: none; }
.table-controls { flex-wrap: wrap; } .table-controls { flex-wrap: wrap; }
@@ -776,8 +715,29 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.filter-row, .form-grid { display: grid; grid-template-columns: 1fr; align-items: stretch; }
.filter-field { max-width: none; }
.filter-help { padding: 0; }
.inline-actions { flex-wrap: wrap; }
.detail-grid { grid-template-columns: 1fr; }
.resource-stack .resource-page { padding: 18px; }
.resource-stack .panel-heading { flex-wrap: wrap; }
.session-identity { display: none; }
.connector-checks > div, .alert-list .notice { grid-template-columns: auto 1fr; }
.connector-checks span { grid-column: 1 / -1; }
.desktop-data-table { display: none; }
.mobile-record-list { display: grid; gap: 10px; }
.mobile-record { padding: 14px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); }
.mobile-record-heading { display: flex; justify-content: space-between; align-items: start; gap: 10px; }
.mobile-record strong, .mobile-record small { display: block; }
.mobile-record strong { color: var(--paper-bright); overflow-wrap: anywhere; }
.mobile-record small { margin-top: 3px; color: var(--ink-faint); font-family: var(--font-mono); font-size: 10.5px; overflow-wrap: anywhere; }
.mobile-record dl { display: grid; grid-template-columns: minmax(88px, 0.42fr) 1fr; gap: 8px 12px; margin: 14px 0; font-size: 12px; }
.mobile-record dt { color: var(--ink-faint); font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
.mobile-record dd { min-width: 0; margin: 0; color: var(--ink-dim); overflow-wrap: anywhere; }
body { background-size: 42px 42px, 42px 42px, auto; } body { background-size: 42px 42px, 42px 42px, auto; }
.sidebar { position: fixed; height: 100vh; width: min(86vw, 310px); }
.nav-backdrop { border: 0; padding: 0; } .nav-backdrop { border: 0; padding: 0; }
.main-content { padding: 19px 14px 44px; } .main-content { padding: 19px 14px 44px; }
.topbar { min-height: 44px; margin-bottom: 22px; } .topbar { min-height: 44px; margin-bottom: 22px; }
@@ -786,8 +746,6 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
.topbar h1 { font-size: 20px; } .topbar h1 { font-size: 20px; }
.command-trigger { width: 36px; justify-content: center; padding: 0; } .command-trigger { width: 36px; justify-content: center; padding: 0; }
.session-menu { margin: 0; } .session-menu { margin: 0; }
.session-menu .button { min-width: 36px; padding-inline: 8px; font-size: 0; }
.session-menu .button::after { content: "↗"; font-size: 14px; }
.preauth-shell .main-content { padding: 10px; } .preauth-shell .main-content { padding: 10px; }
.auth-layout { min-height: calc(100vh - 20px); border-radius: var(--radius-lg); } .auth-layout { min-height: calc(100vh - 20px); border-radius: var(--radius-lg); }
.auth-copy, .auth-visual { padding: 30px 22px; } .auth-copy, .auth-visual { padding: 30px 22px; }
@@ -835,7 +793,6 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
.pagination > div { width: 100%; } .pagination > div { width: 100%; }
.pagination .button { flex: 1; } .pagination .button { flex: 1; }
.onboarding-steps li { display: grid; justify-items: center; text-align: center; } .onboarding-steps li { display: grid; justify-items: center; text-align: center; }
.onboarding-steps li > div { display: none; }
.onboarding-form { grid-template-columns: 1fr; } .onboarding-form { grid-template-columns: 1fr; }
.client-safety, .approvals-panel { display: grid; } .client-safety, .approvals-panel { display: grid; }
.boundary-badge { justify-self: start; } .boundary-badge { justify-self: start; }
@@ -849,7 +806,7 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
} }
@media (max-width: 420px) { @media (max-width: 420px) {
.metric-grid, .secondary-grid { grid-template-columns: 1fr; } .metric-grid, .secondary-grid { grid-template-columns: 1fr; }
.metric-card { min-height: 102px; } .metric-card { min-height: 102px; }
.auth-copy h1 { font-size: 36px; } .auth-copy h1 { font-size: 36px; }
.auth-visual-intro { align-items: flex-start; } .auth-visual-intro { align-items: flex-start; }
@@ -857,6 +814,12 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
.sidebar { transition: none; }
.topology-flow path, .state-orbit span, .loading-skeleton span { animation: none; } .topology-flow path, .state-orbit span, .loading-skeleton span { animation: none; }
.metric-card:hover { transform: none; } .metric-card:hover { transform: none; }
} }
+130
View File
@@ -0,0 +1,130 @@
.detail-mobile-section { display: none; }
.detail-card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin: 16px 0;
}
.detail-card {
min-width: 0;
padding: 16px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface-1);
box-shadow: var(--shadow-1);
}
.detail-card-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.detail-card h3 {
min-width: 0;
margin: 0;
color: var(--paper-bright);
font-size: 14px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.detail-card-status {
flex: 0 0 auto;
max-width: 48%;
padding: 3px 7px;
border: 1px solid var(--line);
border-radius: var(--radius-pill);
color: var(--ink-dim);
font: 10px/1.4 var(--font-mono);
text-align: right;
overflow-wrap: anywhere;
}
.detail-card-summary {
margin: 9px 0 0;
color: var(--ink-dim);
font-size: 12px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.detail-card-fields {
display: grid;
gap: 9px;
margin: 14px 0 0;
}
.detail-card-field {
display: grid;
grid-template-columns: minmax(90px, 0.45fr) minmax(0, 1fr);
align-items: start;
gap: 8px 14px;
padding-top: 8px;
border-top: 1px solid var(--line-soft);
}
.detail-card-field dt {
color: var(--ink-faint);
font: 10px/1.45 var(--font-mono);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.detail-card-field dd {
min-width: 0;
margin: 0;
color: var(--paper);
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.detail-card-action { margin-top: 14px; }
.detail-boundary-note {
margin: 12px 0;
padding: 11px 13px;
border-left: 2px solid var(--status-info);
background: var(--surface-2);
color: var(--ink-dim);
font-size: 12px;
line-height: 1.55;
}
.mobile-record-field { display: contents; }
@media (max-width: 640px) {
.detail-mobile-section {
display: grid;
gap: 7px;
margin: 18px 0;
color: var(--ink-dim);
font-size: 12px;
}
.detail-mobile-section span {
color: var(--ink-faint);
font: 10px/1.4 var(--font-mono);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.detail-mobile-section select {
width: 100%;
min-height: 44px;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface-2);
color: var(--paper-bright);
font: inherit;
}
.detail-desktop-tabs { display: none; }
.detail-card-grid { grid-template-columns: 1fr; gap: 10px; margin: 12px 0; }
.detail-card { padding: 14px; }
}
+63
View File
@@ -0,0 +1,63 @@
/* Shared operator surfaces; component-specific shell/workflows follow. */
.dashboard, .dashboard > *, .dashboard-columns > * { min-width: 0; }
.inbox-expand { margin-top: 12px; }
.hero-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }
.hero-meta strong { margin: 0; }
.freshness { display: inline-flex; align-items: center; gap: 6px; font: 11px/1.5 var(--font-mono); color: var(--ink-dim); }
.freshness::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--status-success); }
.freshness-stale::before, .freshness-disconnected::before { background: var(--status-danger); }
.freshness-refreshing::before, .freshness-paused::before { background: var(--ink-faint); }
.signal-next-step { display: inline-flex; gap: 6px; align-items: center; margin-top: 8px; color: var(--accent); font-size: 12px; font-weight: 600; }
.exception-item > div { min-width: 0; overflow-wrap: anywhere; }
.exception-item .signal-time { font-size: 10px; }
.operations-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.operations-overview .panel-heading { gap: 18px; }
.recovery-record { display: grid; grid-template-columns: minmax(130px, .4fr) 1fr; gap: 10px 16px; font-size: 12px; }
.recovery-record dt { color: var(--ink-faint); }
.recovery-record dd { margin: 0; overflow-wrap: anywhere; }
[id^="operations-"] { scroll-margin-top: 20px; }
.alert-list .signal-next-step { grid-column: 1 / -1; }
.trend-chart { display: grid; grid-template-columns: repeat(24, minmax(0, 1fr)); gap: 3px; overflow: visible; }
.trend-column { border: 0; padding: 0; min-width: 0; background: transparent; color: var(--paper); cursor: pointer; border-radius: 3px; }
.trend-column[aria-pressed="true"] { background: var(--surface-2); outline: 1px solid var(--accent); }
.trend-column small { font-size: 8px; }
.trend-detail { min-height: 36px; margin-top: 12px !important; overflow-wrap: anywhere; }
.mobile-trend, .mobile-client-actions { display: none; }
.desktop-client-actions { display: inline-flex; flex-wrap: wrap; gap: 6px; }
.client-actions { align-items: flex-start; }
.metric-card strong, .metric-card p { overflow-wrap: anywhere; }
@media (max-width: 640px) {
.table-controls { display: none; }
.desktop-client-actions, .desktop-trend { display: none; }
.mobile-client-actions, .mobile-trend { display: block; }
.mobile-client-actions { flex: 1; }
.mobile-client-actions summary { display: flex; align-items: center; justify-content: center; min-height: 44px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 4px; color: var(--paper); font-size: 12px; cursor: pointer; }
.mobile-client-actions summary::after { content: "+"; margin-left: 8px; }
.mobile-client-actions[open] summary::after { content: "−"; }
.mobile-client-actions > div { display: grid; gap: 8px; padding-top: 8px; }
.mobile-trend .trend-chart { grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 2px; padding-inline: 0; }
.mobile-trend .trend-column { min-height: 44px; }
.mobile-trend .trend-column small { font-size: 9px; }
.recovery-record { grid-template-columns: 1fr; gap: 4px; }
.recovery-record dd { margin-bottom: 10px; }
.operations-actions { justify-content: flex-start; width: 100%; }
.operations-actions .button { flex: 1; }
.button, .filter-clear, .tab, .theme-toggle button, .panel-heading a { min-height: 44px; }
.button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
.panel-heading a { display: inline-flex; align-items: center; }
.filter-clear { min-width: 44px; }
.filter-input-wrap input { padding-right: 48px; }
input, select, textarea { font-size: 16px; }
.exception-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; }
.exception-action .status { font-size: 10px; padding-inline: 7px; }
}
/* Content, rather than a device label, sets the metric wrapping threshold. */
@media (max-width: 480px) {
.metric-grid, .secondary-grid { grid-template-columns: 1fr; }
.metric-card { min-height: 102px; }
}
.client-created-notice { margin: 0 0 1rem; flex-wrap: wrap; overflow-wrap: anywhere; }
.client-created-notice a, .client-created-notice button { display: inline-flex; align-items: center; min-height: 44px; padding: .5rem .75rem; }
+190
View File
@@ -0,0 +1,190 @@
/* Focus and compact-screen behavior for the application shell and dialogs. */
.nav-trigger,
.nav-close,
.modal-close {
min-width: 44px;
min-height: 44px;
}
.nav-link,
.command-result {
min-height: 44px;
}
.command-result-active {
border-color: var(--line);
color: var(--paper);
background: var(--surface-2);
}
.command-group { display: grid; align-content: start; gap: 3px; }
.command-group + .command-group { margin-top: 8px; }
.command-group h3 {
margin: 8px 10px 2px;
color: var(--ink-faint);
font: 600 10px/1.4 var(--font-mono);
letter-spacing: .08em;
text-transform: uppercase;
}
.command-search-error {
margin: 10px 2px;
padding: 11px 12px;
border: 1px solid color-mix(in srgb, var(--status-danger) 45%, var(--line));
border-radius: var(--radius-md);
color: var(--status-danger);
background: var(--status-danger-dim);
font-size: 12px;
}
.session-signout { min-height: 44px; white-space: nowrap; }
.session-error { max-width: min(80vw, 320px); overflow-wrap: anywhere; }
@media (max-width: 840px) {
.app-shell { min-height: 100dvh; }
.sidebar {
top: 0;
bottom: 0;
height: 100dvh;
max-height: 100dvh;
min-height: 0;
padding-top: calc(20px + env(safe-area-inset-top));
padding-left: calc(16px + env(safe-area-inset-left));
padding-right: calc(16px + env(safe-area-inset-right));
padding-bottom: calc(20px + env(safe-area-inset-bottom));
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.sidebar .nav-link { min-height: 44px; }
.nav-backdrop { touch-action: none; }
.main-content {
min-height: 100dvh;
padding-top: calc(19px + env(safe-area-inset-top));
padding-left: calc(14px + env(safe-area-inset-left));
padding-right: calc(14px + env(safe-area-inset-right));
padding-bottom: calc(44px + env(safe-area-inset-bottom));
}
}
@media (max-width: 640px) {
.topbar { gap: 8px; }
.topbar-title { flex: 1 1 auto; }
.topbar-actions { flex: 0 0 auto; gap: 6px; }
.nav-trigger,
.command-trigger { min-width: 44px; min-height: 44px; }
.session-menu { position: relative; flex: 0 0 auto; }
.session-menu .session-identity {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.session-menu .session-signout {
min-width: 72px;
min-height: 44px;
padding: 0 9px;
font-size: 11px;
}
.session-menu .session-signout::after { content: none; }
.session-error {
position: absolute;
z-index: 5;
top: calc(100% + 5px);
right: 0;
width: min(80vw, 320px);
padding: 8px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-1);
}
.modal-overlay {
padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left));
place-items: center;
}
.modal-overlay:has(.command-modal),
.modal-overlay:has(.readiness-form) { padding: 0; place-items: stretch; }
.modal.command-modal {
width: 100%;
height: 100dvh;
max-height: 100dvh;
max-width: none;
margin: 0;
padding: 0;
padding-top: env(safe-area-inset-top);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
padding-bottom: env(safe-area-inset-bottom);
border: 0;
border-radius: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.command-modal .modal-heading {
flex: 0 0 auto;
padding: 10px 12px 9px;
}
.command-modal .modal-body {
display: flex;
flex: 1 1 auto;
min-height: 0;
flex-direction: column;
overflow: hidden;
padding: 10px 12px 8px;
}
.command-results {
align-content: start;
flex: 1 1 auto;
min-height: 0;
max-height: none;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.command-hint { flex: 0 0 auto; padding-bottom: 2px; }
.modal:has(.readiness-form) {
width: 100%;
height: 100dvh;
max-height: 100dvh;
max-width: none;
margin: 0;
padding: 0;
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
border: 0;
border-radius: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal:has(.readiness-form) .modal-heading {
flex: 0 0 auto;
margin: 0;
padding: calc(8px + env(safe-area-inset-top)) 12px 8px;
border-bottom: 1px solid var(--line);
}
.modal:has(.readiness-form) .modal-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding: 14px 14px calc(18px + env(safe-area-inset-bottom));
}
/* Short phones keep confirmations compact while the dialog itself stays
within the dynamic viewport and can scroll if its copy needs more room. */
.modal:not(.command-modal):not(:has(.readiness-form)) {
place-self: center;
width: min(100%, 760px);
height: auto;
max-height: calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 24px);
overflow-y: auto;
}
}
+99
View File
@@ -0,0 +1,99 @@
.onboarding-steps li.complete > span { border-color: var(--status-success); color: var(--status-success); }
.onboarding-steps li.complete strong { color: var(--status-success); }
.onboarding-identity-bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 17px 0; padding: 13px 15px; border: 1px solid var(--line); background: var(--surface-2); }
.onboarding-identity-bar > div:first-child { display: grid; gap: 4px; min-width: 0; }
.onboarding-identity-bar strong { color: var(--paper-bright); font: 11px/1.5 var(--font-mono); overflow-wrap: anywhere; }
.onboarding-identity-bar small { color: var(--ink-faint); font-size: 11px; }
.onboarding-identity-actions { display: flex; flex-wrap: wrap; gap: 7px; }
.onboarding-created-title { margin: 5px 0 0; color: var(--paper); font-size: 13px; }
.onboarding-config { margin-top: 15px; padding: 15px; border: 1px solid var(--line); border-left: 3px solid var(--accent); background: var(--surface-2); }
.onboarding-config .panel-heading { align-items: center; }
.onboarding-config h4, .onboarding-stage-card h4 { margin: 0; color: var(--paper-bright); font-size: 15px; }
.onboarding-config dl { display: grid; gap: 8px; margin: 14px 0; }
.onboarding-config dl > div { display: grid; grid-template-columns: minmax(105px, .34fr) minmax(0, 1fr); gap: 12px; padding-top: 8px; border-top: 1px solid var(--line); }
.onboarding-config dt, .onboarding-checks dt { color: var(--ink-faint); font: 10px/1.5 var(--font-mono); text-transform: uppercase; letter-spacing: .04em; }
.onboarding-config dd { min-width: 0; margin: 0; color: var(--ink-dim); overflow-wrap: anywhere; }
.onboarding-config-auth { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 16px; margin: 14px 0; padding: 12px; border: 1px solid var(--line); background: var(--surface-1); }
.onboarding-config-auth p { min-width: 0; margin: 0; color: var(--ink-dim); font-size: 11px; overflow-wrap: anywhere; }
.onboarding-config-auth strong { color: var(--paper); }
.onboarding-handoff-steps { display: grid; gap: 7px; margin: 12px 0 0; padding-left: 19px; }
.onboarding-handoff-steps li { color: var(--ink-dim); font-size: 11px; line-height: 1.55; }
.onboarding-stage-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 15px; }
.onboarding-stage-card { min-width: 0; padding: 15px; border: 1px solid var(--line); background: var(--surface-1); }
.onboarding-stage-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.onboarding-stage-heading .eyebrow { margin: 0 0 4px; }
.stage-number { display: grid; place-items: center; flex: 0 0 auto; width: 30px; height: 30px; border: 1px solid var(--accent-dim); border-radius: 50%; color: var(--accent); background: var(--accent-dim); font: 11px var(--font-mono); }
.onboarding-checks { display: grid; gap: 8px; margin: 14px 0; }
.onboarding-check { display: grid; grid-template-columns: minmax(82px, .35fr) minmax(0, 1fr); align-items: start; gap: 10px; padding-top: 9px; border-top: 1px solid var(--line); }
.onboarding-check dd { display: grid; grid-template-columns: max-content minmax(0, 1fr); align-items: start; gap: 7px 9px; min-width: 0; margin: 0; color: var(--ink-dim); font-size: 11px; }
.onboarding-check .status { grid-row: span 2; }
.onboarding-check-detail { min-width: 0; overflow-wrap: anywhere; }
.onboarding-check dd > small { grid-column: 2; color: var(--ink-faint); font: 9px/1.4 var(--font-mono); }
.external-boundary-note, .manual-proof-note { display: grid; gap: 4px; margin-top: 13px; padding: 11px; border: 1px solid var(--line); background: var(--surface-2); }
.external-boundary-note strong, .manual-proof-note strong { color: var(--paper); font-size: 11px; }
.external-boundary-note span, .manual-proof-note span { color: var(--ink-dim); font-size: 10.5px; line-height: 1.55; }
.onboarding-verified, .onboarding-action-success { margin: 12px 0; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--status-success) 35%, var(--line)); color: var(--status-success); background: var(--status-success-dim); font-size: 11px; line-height: 1.5; }
.onboarding-action-info { margin: 12px 0; padding: 10px 12px; border: 1px solid var(--line); color: var(--ink-dim); background: var(--surface-2); font-size: 11px; line-height: 1.5; }
.onboarding-check-recorded { margin: 12px 0; padding: 10px 12px; border: 1px solid var(--line); color: var(--ink-dim); background: var(--surface-2); font-size: 11px; line-height: 1.5; }
.onboarding-verification-detail { margin: 10px 0 0; font-size: 10.5px; overflow-wrap: anywhere; }
.onboarding-loading { display: flex; align-items: center; gap: 8px; min-height: 44px; color: var(--ink-faint); font-size: 11px; }
.schema-inputs { margin-top: 14px; }
.schema-input-field { display: grid; align-content: start; gap: 6px; min-width: 0; }
.schema-input-label { color: var(--paper); font-size: 11px; font-weight: 600; }
.required-mark { color: var(--accent); }
.schema-input-field input, .schema-input-field select, .schema-input-field textarea { min-width: 0; }
.schema-input-field textarea { resize: vertical; font-family: var(--font-mono); font-size: 11px; line-height: 1.5; }
.schema-input-field > small { color: var(--ink-faint); font-size: 10px; font-weight: 400; line-height: 1.45; }
.schema-input-field .schema-field-error { color: var(--status-danger); font-weight: 600; }
.schema-null-choice { width: max-content !important; max-width: 100%; }
.schema-null-value { padding: 10px 11px; border: 1px dashed var(--line); color: var(--ink-faint); font-size: 11px; }
.schema-input-empty, .tool-input-summary { margin: 12px 0; color: var(--ink-faint); font-size: 11px; }
.tool-console-actions { flex-wrap: wrap; }
.tool-result { border-left-color: var(--accent); }
.tool-result pre { max-height: min(54dvh, 560px); max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; tab-size: 2; }
.tool-result .copy-status { overflow-wrap: anywhere; }
.recent-tool-runs { max-width: none; }
.recent-run-list { display: grid; gap: 9px; margin: 14px 0 0; padding: 0; list-style: none; }
.recent-run { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-width: 0; padding: 12px; border: 1px solid var(--line); background: var(--surface-1); }
.recent-run-main { min-width: 0; flex: 1; }
.recent-run-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.recent-run-tool { color: var(--paper-bright); font: 11px/1.5 var(--font-mono); overflow-wrap: anywhere; }
.recent-run-outcome { display: inline-flex; align-items: center; min-height: 22px; padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px; font: 9px/1.2 var(--font-mono); }
.recent-run-success { color: var(--status-success); border-color: color-mix(in srgb, var(--status-success) 38%, var(--line)); }
.recent-run-error { color: var(--status-danger); border-color: color-mix(in srgb, var(--status-danger) 38%, var(--line)); }
.recent-run dl { display: flex; flex-wrap: wrap; gap: 5px 14px; margin: 8px 0 0; }
.recent-run dl > div { display: flex; flex-wrap: wrap; gap: 4px; min-width: 0; }
.recent-run dt { color: var(--ink-faint); font: 9px/1.5 var(--font-mono); text-transform: uppercase; }
.recent-run dd { min-width: 0; margin: 0; color: var(--ink-dim); font-size: 10px; overflow-wrap: anywhere; }
.recent-run dd code { color: var(--ink-faint); font: 9px/1.5 var(--font-mono); overflow-wrap: anywhere; }
.recent-run-open { flex: 0 0 auto; }
@media (max-width: 760px) {
.onboarding-steps { gap: 6px; }
.onboarding-steps li { gap: 5px; }
.onboarding-steps li > div { display: grid; min-width: 0; }
.onboarding-steps strong { font-size: 10px; }
.onboarding-steps small { font-size: 9px; line-height: 1.35; }
.onboarding-stage-grid { grid-template-columns: 1fr; }
.recent-run { align-items: stretch; flex-direction: column; }
.recent-run-open { width: 100%; min-height: 44px; }
}
@media (max-width: 520px) {
.onboarding-identity-bar { align-items: stretch; flex-direction: column; }
.onboarding-identity-actions { display: grid; grid-template-columns: 1fr; }
.onboarding-identity-actions .button { min-height: 44px; }
.onboarding-config .panel-heading { align-items: stretch; flex-direction: column; }
.onboarding-config .panel-heading > .button { min-height: 44px; }
.onboarding-config dl > div { grid-template-columns: 1fr; gap: 3px; }
.onboarding-config-auth { grid-template-columns: 1fr; }
.onboarding-check { grid-template-columns: 1fr; gap: 5px; }
.onboarding-check dd { grid-template-columns: max-content minmax(0, 1fr); }
.onboarding-check dd > small { grid-column: 2; }
.tool-console-actions { display: grid; grid-template-columns: 1fr; }
.tool-console-actions .button { min-height: 44px; }
.tool-result .panel-heading { align-items: stretch; flex-direction: column; }
.tool-result .panel-heading > .button { align-self: flex-start; min-height: 40px; }
}
+82
View File
@@ -0,0 +1,82 @@
/** Browser-local history contains only the identifiers needed to reopen a
* tool's project scope. Invocation arguments, output and error text stay out
* of storage. */
export const TOOL_HISTORY_KEY = "itworx-tool-history-v1";
export const TOOL_HISTORY_EVENT = "itworx:tool-history-updated";
export const MAX_TOOL_RUNS = 20;
export interface ToolRunMetadata {
tool: string;
project: string;
environment: string;
ok: boolean;
traceId: string;
at: string;
}
type HistoryStorage = Pick<Storage, "getItem" | "setItem">;
function browserSessionStorage(): HistoryStorage | null {
try {
return typeof window === "undefined" ? null : window.sessionStorage;
} catch {
return null;
}
}
function boundedString(value: unknown, maxLength: number, pattern: RegExp): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim();
return normalized.length > 0 && normalized.length <= maxLength && pattern.test(normalized)
? normalized
: null;
}
function normalizeToolRun(value: unknown): ToolRunMetadata | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
const candidate = value as Record<string, unknown>;
const tool = boundedString(candidate.tool, 192, /^[A-Za-z0-9][A-Za-z0-9._:-]*$/);
const project = boundedString(candidate.project, 63, /^[a-z0-9][a-z0-9-]*$/);
const environment = boundedString(candidate.environment, 32, /^[a-z0-9][a-z0-9-]*$/);
const traceId = boundedString(candidate.traceId, 256, /^[A-Za-z0-9][A-Za-z0-9._:-]*$/);
const timestamp = typeof candidate.at === "string" ? Date.parse(candidate.at) : Number.NaN;
if (!tool || !project || !environment || !traceId || typeof candidate.ok !== "boolean" || !Number.isFinite(timestamp)) return null;
return {
tool,
project,
environment,
ok: candidate.ok,
traceId,
at: new Date(timestamp).toISOString(),
};
}
export function readToolHistory(storage = browserSessionStorage()): ToolRunMetadata[] {
if (!storage) return [];
try {
const raw = storage.getItem(TOOL_HISTORY_KEY);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.slice(0, MAX_TOOL_RUNS).flatMap((entry) => {
const normalized = normalizeToolRun(entry);
return normalized ? [normalized] : [];
});
} catch {
return [];
}
}
export function recordToolRun(value: ToolRunMetadata, storage = browserSessionStorage()): void {
if (!storage) return;
const run = normalizeToolRun(value);
if (!run) return;
try {
const history = readToolHistory(storage).filter((entry) => entry.traceId !== run.traceId);
storage.setItem(TOOL_HISTORY_KEY, JSON.stringify([run, ...history].slice(0, MAX_TOOL_RUNS)));
if (typeof window !== "undefined") window.dispatchEvent(new Event(TOOL_HISTORY_EVENT));
} catch {
// Browser history is optional; never turn a completed invocation into a failure.
}
}
+24 -4
View File
@@ -10,12 +10,32 @@ describe("management API safety contracts", () => {
await createApiClient().resource("tools", { filter: "risk:R1", limit: 25 }); await createApiClient().resource("tools", { filter: "risk:R1", limit: 25 });
expect(fetchMock).toHaveBeenCalledWith("/api/tools?q=risk%3AR1&limit=25", expect.objectContaining({ credentials: "same-origin", headers: { Accept: "application/json" } })); expect(fetchMock).toHaveBeenCalledWith("/api/tools?q=risk%3AR1&limit=25", expect.objectContaining({ credentials: "same-origin", headers: { Accept: "application/json" } }));
}); });
it("maps protected client actions and never invents returned secret material", async () => { it("projects only safe client profile fields from the generated configuration", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ clientId: "client-1", endpoint: "https://mcp.example.test/sse", scopes: ["tools:read"] }) }); const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({
clientId: "client-1",
kind: "stdio-bridge",
protocolVersion: "2026-07-28",
endpoint: "https://mcp.example.test/mcp/ludarium/prod",
transport: "stdio",
scopes: ["tools:read"],
credentialReference: "authentik://mcp-client/private-reference",
environmentVariable: "ITWORX_MCP_ACCESS_TOKEN",
bridgeCommand: "itworx-mcp-bridge",
}) });
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
const config = await createApiClient().generatedConfig("client-1"); const config = await createApiClient().generatedConfig("client-1");
expect(config).toEqual({ clientId: "client-1", endpoint: "https://mcp.example.test/sse", scopes: ["tools:read"] }); expect(config).toEqual({
expect(JSON.stringify(config)).not.toContain("secret"); clientId: "client-1",
kind: "stdio-bridge",
protocolVersion: "2026-07-28",
endpoint: "https://mcp.example.test/mcp/ludarium/prod",
transport: "stdio",
scopes: ["tools:read"],
environmentVariable: "ITWORX_MCP_ACCESS_TOKEN",
bridgeCommand: "itworx-mcp-bridge",
});
expect(config).not.toHaveProperty("credentialReference");
expect(JSON.stringify(config)).not.toContain("authentik://mcp-client/private-reference");
expect(fetchMock.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ credentials: "same-origin" })); expect(fetchMock.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ credentials: "same-origin" }));
}); });
it("fails closed without CSRF and sends the session-bound token on writes", async () => { it("fails closed without CSRF and sends the session-bound token on writes", async () => {
+4
View File
@@ -3,6 +3,10 @@ import { isSnapshotStale, routeTitle } from "../src/contracts";
import { formatBrusselsTime, healthLabel } from "../src/format"; import { formatBrusselsTime, healthLabel } from "../src/format";
describe("management shell contracts", () => { describe("management shell contracts", () => {
it("labels connector detail breadcrumbs as a registered route", () => {
expect(routeTitle("/connectors/knowledge-default")).toBe("Connector detail");
expect(routeTitle("/connectors/knowledge-default/")).toBe("Connector detail");
});
it("maps required routes to stable titles", () => { expect(routeTitle("/")).toBe("Overview"); expect(routeTitle("/projects/ludarium")).toBe("Project detail"); expect(routeTitle("/audit")).toBe("Audit"); expect(routeTitle("/missing")).toBe("Not found"); }); it("maps required routes to stable titles", () => { expect(routeTitle("/")).toBe("Overview"); expect(routeTitle("/projects/ludarium")).toBe("Project detail"); expect(routeTitle("/audit")).toBe("Audit"); expect(routeTitle("/missing")).toBe("Not found"); });
it("marks invalid and old snapshots stale instead of implying health", () => { const snapshot = { generatedAt: "2026-08-01T10:00:00Z", staleAfterSeconds: 30 } as Parameters<typeof isSnapshotStale>[0]; expect(isSnapshotStale(snapshot, Date.parse("2026-08-01T10:00:31Z"))).toBe(true); expect(isSnapshotStale({ ...snapshot, generatedAt: "not-a-date" })).toBe(true); }); it("marks invalid and old snapshots stale instead of implying health", () => { const snapshot = { generatedAt: "2026-08-01T10:00:00Z", staleAfterSeconds: 30 } as Parameters<typeof isSnapshotStale>[0]; expect(isSnapshotStale(snapshot, Date.parse("2026-08-01T10:00:31Z"))).toBe(true); expect(isSnapshotStale({ ...snapshot, generatedAt: "not-a-date" })).toBe(true); });
it("formats operator times in Brussels and labels state textually", () => { expect(formatBrusselsTime("2026-08-01T10:00:00Z")).toContain("12:00"); expect(formatBrusselsTime(null)).toBe("Never recorded"); expect(healthLabel("unavailable")).toBe("Unavailable"); }); it("formats operator times in Brussels and labels state textually", () => { expect(formatBrusselsTime("2026-08-01T10:00:00Z")).toContain("12:00"); expect(formatBrusselsTime(null)).toBe("Never recorded"); expect(healthLabel("unavailable")).toBe("Unavailable"); });
+4 -2
View File
@@ -16,8 +16,10 @@ test.describe("authentication", () => {
await expect(page).toHaveURL(/\/$/); await expect(page).toHaveURL(/\/$/);
await expect(page.getByText("Authenticated", { exact: true })).toBeVisible(); await expect(page.getByText("Authenticated", { exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Governed MCP, with the current signal first." })).toBeVisible(); await expect(page.getByRole("heading", { name: "Governed MCP, with the current signal first." })).toBeVisible();
// Real backend data, not a placeholder: Ludarium is the seeded catalog project. // Assert a real bounded dashboard row without depending on catalog ordering.
await expect(page.getByText("Ludarium", { exact: true })).toBeVisible(); const snapshot = await (await page.request.get("/api/dashboard")).json();
expect(snapshot.projects.length).toBeGreaterThan(0);
await expect(page.locator(`[href="/projects/${snapshot.projects[0].slug}"]`).first()).toContainText(snapshot.projects[0].name);
}); });
test("an authenticated user can sign out and stale management data disappears", async ({ page }) => { test("an authenticated user can sign out and stale management data disappears", async ({ page }) => {
+2 -1
View File
@@ -18,7 +18,8 @@ test.describe("mobile navigation drawer", () => {
await trigger.click(); await trigger.click();
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(1); await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(1);
await expect(trigger).toHaveAttribute("aria-expanded", "true"); await expect(page.locator(".nav-trigger")).toHaveAttribute("aria-expanded", "true");
await expect(page.getByRole("dialog", { name: "Application navigation" })).toBeVisible();
}); });
test("closes via its own close button", async ({ page }) => { test("closes via its own close button", async ({ page }) => {
+194
View File
@@ -0,0 +1,194 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, loginAs, test } from "./fixtures";
const darkReview = process.env.WP400_DARK_REVIEW === "1";
test.use({ colorScheme: darkReview ? "dark" : "light" });
const viewports = [
{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 390, height: 844 },
{ width: 430, height: 932 }, { width: 768, height: 1024 }, { width: 1280, height: 800 },
{ width: 1440, height: 900 }, { width: 844, height: 390 },
];
for (const viewport of viewports) {
test(`operator layout and accessible surfaces at ${viewport.width}x${viewport.height}`, async ({ page }, info) => {
test.setTimeout(180_000);
await page.setViewportSize(viewport);
await loginAs(page, "hub-admin");
for (const path of ["/", "/projects", "/servers", "/tools", "/connectors", "/clients", "/policies", "/audit", "/operations", "/settings", "/projects/ludarium", "/connectors/knowledge-default"]) {
await page.goto(path);
await expect(page.locator(".topbar")).toBeVisible();
if (viewport.width <= 840) {
const target = await page.locator(".nav-trigger").boundingBox();
expect(target?.width).toBeGreaterThanOrEqual(44);
expect(target?.height).toBeGreaterThanOrEqual(44);
}
await expect(page.locator(".loading-state")).toHaveCount(0, { timeout: 15_000 });
const dimensions = await page.evaluate(() => ({ document: document.documentElement.scrollWidth, viewport: innerWidth }));
expect(dimensions.document, path).toBeLessThanOrEqual(dimensions.viewport + 1);
if (path === "/" && viewport.width <= 430) {
await expect(page.getByLabel("Project status records")).toBeVisible();
await expect(page.getByLabel("Project status table")).toBeHidden();
const grid = await page.locator(".primary-metrics").evaluate((element) => getComputedStyle(element).gridTemplateColumns);
expect(grid.split(" ")).toHaveLength(1);
}
if (path === "/tools" && viewport.width <= 430) await expect(page.getByRole("group", { name: "Table density" })).toBeHidden();
if (viewport.width === 390 || viewport.width === 1440) {
const result = await new AxeBuilder({ page }).analyze();
expect(result.violations.filter((violation) => ["serious", "critical"].includes(violation.impact ?? "")), path).toEqual([]);
}
if (process.env.SOL_VISUAL_REVIEW && ["/", "/tools", "/clients", "/operations", "/projects/ludarium", "/connectors/knowledge-default"].includes(path)) {
const themeSuffix = darkReview ? "-dark" : "";
await page.screenshot({ path: info.outputPath(`${path.replaceAll("/", "_") || "dashboard"}-${viewport.width}${themeSuffix}.png`), fullPage: true });
await page.screenshot({ path: info.outputPath(`${path.replaceAll("/", "_") || "dashboard"}-${viewport.width}${themeSuffix}-viewport.png`) });
}
}
});
}
test("mobile client actions require an explicit disclosure and confirmation", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await loginAs(page, "hub-admin");
await page.goto("/clients");
const name = `mobile-safety-${Date.now()}`;
await page.getByLabel("Name", { exact: true }).fill(name);
await page.getByRole("button", { name: "Create pending client" }).click();
const card = page.locator(".mobile-record").filter({ hasText: name }).first();
await expect(card.getByRole("button", { name: "Config", exact: true })).toBeVisible();
await expect(card.getByRole("button", { name: "Revoke", exact: true })).toBeHidden();
await card.locator("summary").click();
await card.getByRole("button", { name: "Revoke", exact: true }).click();
await expect(page.getByRole("dialog", { name: /Revoke/ })).toBeVisible();
await page.getByRole("button", { name: "Cancel", exact: true }).click();
});
test("desktop density persists while mobile cards have no inert density control", async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/tools");
await page.getByRole("button", { name: "Compact", exact: true }).click();
await page.reload();
await expect(page.getByRole("button", { name: "Compact", exact: true })).toHaveAttribute("aria-pressed", "true");
await page.setViewportSize({ width: 430, height: 932 });
await expect(page.getByRole("group", { name: "Table density" })).toBeHidden();
});
test("phone trend keeps outcome details and operations errors never imply no alerts", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await loginAs(page, "hub-viewer");
await page.route("**/api/operations/summary", async (route) => {
const response = await route.fetch();
const summary = await response.json();
summary.trend = Array.from({ length: 24 }, (_, hour) => ({ hour: new Date(Date.UTC(2026, 9, 1, hour)).toISOString(), success: hour, denied: 1, error: 0, rate_limited: 0 }));
await route.fulfill({ json: summary });
});
await page.goto("/operations");
const mobile = page.getByRole("group", { name: "Four-hour call buckets" });
await expect(mobile).toBeVisible();
await expect(mobile.getByRole("button")).toHaveCount(6);
await mobile.getByRole("button").first().click();
await expect(page.locator(".mobile-trend .trend-detail")).toContainText("success");
await page.unroute("**/api/operations/summary");
await page.route("**/api/operations/summary", (route) => route.fulfill({ status: 503, body: "unavailable" }));
await page.reload();
await expect(page.getByText("Alerts are unknown while the summary is unavailable.")).toBeVisible();
await expect(page.getByText("No alerts in the latest summary")).toHaveCount(0);
});
test("status refresh backs off, reports disconnection and does not invoke probes", async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.clock.install();
await page.goto("/");
await expect(page.getByText("Current · auto refresh")).toBeVisible();
let reads = 0;
let writes = 0;
page.on("request", (request) => { if (request.method() === "POST" && request.url().includes("/api/")) writes += 1; });
await page.route("**/api/dashboard", (route) => { reads += 1; return route.fulfill({ status: 503, body: "unavailable" }); });
await page.clock.fastForward(31_000);
await expect(page.getByText("Disconnected · retry available")).toBeVisible();
expect(reads).toBe(1);
await page.clock.fastForward(30_000);
expect(reads).toBe(1);
await page.clock.fastForward(31_000);
await expect.poll(() => reads).toBe(2);
expect(writes).toBe(0);
});
test("a hidden dashboard pauses scheduled refresh and never sends an automatic write", async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.clock.install();
await page.addInitScript(() => {
Object.defineProperty(document, "hidden", {
configurable: true,
get: () => sessionStorage.getItem("e2e-document-hidden") !== "false",
});
});
let dashboardReads = 0;
let apiWrites = 0;
page.on("request", (request) => {
const url = new URL(request.url());
if (url.pathname === "/api/dashboard" && request.method() === "GET") dashboardReads += 1;
if (url.pathname.startsWith("/api/") && request.method() === "POST") apiWrites += 1;
});
await page.goto("/");
await expect(page.getByText("Auto refresh paused")).toBeVisible();
expect(dashboardReads).toBe(1); // The one initial snapshot; no timer-driven read.
await page.clock.fastForward(120_000);
expect(dashboardReads).toBe(1);
expect(apiWrites).toBe(0);
});
test("the dashboard action inbox starts with four signals and expands the rest on demand", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await loginAs(page, "hub-viewer");
await page.route("**/api/dashboard", async (route) => {
const response = await route.fetch();
const snapshot = await response.json();
snapshot.generatedAt = new Date().toISOString();
snapshot.staleAfterSeconds = 3600;
snapshot.health = { hub: "healthy", gateway: "healthy", detail: "Fixture is current." };
snapshot.connectors = Array.from({ length: 6 }, (_, index) => ({
id: `inbox-fixture-${index + 1}`,
name: `Inbox fixture ${index + 1}`,
project: "ludarium",
state: "degraded",
detail: "A controlled connector needs review.",
registrationState: "registered",
readiness: { state: "unknown", detail: "No live probe was requested.", observedAt: null, source: "not-probed" },
}));
snapshot.drift = { count: 0, projects: [], known: true, detail: "No drift." };
snapshot.backup = { state: "healthy", lastSuccessfulAt: null, detail: "Fixture backup state." };
snapshot.denials = { count: 0, window: "24h" };
await route.fulfill({ json: snapshot });
});
await page.goto("/");
const inbox = page.getByRole("region", { name: "6 actionable signals" });
await expect(inbox.locator(".exception-item")).toHaveCount(4);
await expect(inbox.getByRole("link", { name: /Inbox fixture 1/ })).toBeVisible();
await expect(inbox.getByRole("link", { name: /Inbox fixture 4/ })).toBeVisible();
await expect(inbox.getByRole("link", { name: /Inbox fixture 5/ })).toHaveCount(0);
await inbox.getByRole("button", { name: "Show 2 more signals" }).click();
await expect(inbox.locator(".exception-item")).toHaveCount(6);
await expect(inbox.getByRole("link", { name: /Inbox fixture 6/ })).toBeVisible();
await inbox.getByRole("button", { name: "Show fewer signals" }).click();
await expect(inbox.locator(".exception-item")).toHaveCount(4);
});
test("project and connector detail links resolve to registered records", async ({ page }) => {
await loginAs(page, "hub-admin");
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/connectors/knowledge-default");
await expect(page.getByRole("heading", { name: "Knowledge Default", exact: true })).toBeVisible();
await page.goto("/projects/ludarium?tab=MCP%20Servers");
await expect(page.getByLabel("MCP servers", { exact: true }).getByRole("heading", { name: "ludarium-prod-readonly", exact: true })).toBeVisible();
await page.goto("/projects/ludarium?tab=Knowledge%20Sources");
await expect(page.getByLabel("Knowledge sources", { exact: true }).getByRole("heading", { name: "discovery://repository/ludarium", exact: true })).toBeVisible();
});
+118
View File
@@ -0,0 +1,118 @@
import { expect, loginAs, test } from "./fixtures";
test.describe("operator review regressions", () => {
test.beforeEach(async ({ page }) => { await loginAs(page, "hub-admin"); });
test("filter changes retain the URL project and environment at the API boundary", async ({ page }) => {
await page.goto("/connectors?project=ludarium&environment=prod");
await expect(page.getByLabel("Filter records")).toBeVisible();
const response = page.waitForResponse((value) => {
const url = new URL(value.url());
return url.pathname === "/api/connectors" && url.searchParams.get("q") === "knowledge-default";
});
await page.getByLabel("Filter records").fill("knowledge-default");
const actual = new URL((await response).url());
expect(actual.searchParams.get("project")).toBe("ludarium");
expect(actual.searchParams.get("environment")).toBe("prod");
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("knowledge-default");
expect(new URL(page.url()).searchParams.get("project")).toBe("ludarium");
expect(new URL(page.url()).searchParams.get("environment")).toBe("prod");
await expect(page.getByRole("link", { name: "Knowledge Default", exact: true })).toBeVisible();
});
test("catalog scope stays locked until an explicit apply finishes", async ({ page }) => {
let release!: () => void;
const pending = new Promise<void>((resolve) => { release = resolve; });
await page.route("**/api/catalog/preview", (route) => route.fulfill({ json: { valid: true, changes: ["Review fixture change"], errors: [] } }));
await page.route("**/api/catalog/apply", async (route) => { await pending; await route.fulfill({ json: { accepted: true, message: "Reviewed catalog apply completed" } }); });
await page.goto("/tools?project=ludarium");
const workflow = page.locator(".workflow-panel").filter({ has: page.getByRole("heading", { name: "Catalog validation and preview" }) });
await workflow.getByRole("button", { name: "Validate and preview" }).click();
await workflow.getByRole("button", { name: "Apply approved catalog" }).click();
await page.getByRole("button", { name: "Confirm apply" }).click();
await expect(workflow.getByRole("combobox", { name: "Project", exact: true })).toBeDisabled();
await expect(workflow.getByRole("button", { name: "Applying…" })).toBeDisabled();
release();
await expect(workflow.getByText("Reviewed catalog apply completed")).toBeVisible();
await expect(workflow.getByRole("combobox", { name: "Project", exact: true })).toBeEnabled();
});
test("a pending client created after SPA navigation retains a visible resume path", async ({ page }) => {
let release!: () => void;
const pending = new Promise<void>((resolve) => { release = resolve; });
let createdId = "";
await page.route("**/api/client-management", async (route) => {
if (route.request().method() !== "POST") { await route.continue(); return; }
const response = await route.fetch();
createdId = ((await response.json()) as { client: { id: string } }).client.id;
await pending;
await route.fulfill({ response });
});
await page.goto("/clients");
const name = `creation-navigation-${Date.now()}`;
await page.getByLabel("Name", { exact: true }).fill(name);
await page.getByRole("button", { name: "Create pending client" }).click();
await expect.poll(() => createdId).not.toBe("");
await page.getByRole("navigation", { name: "Primary navigation" }).getByRole("link", { name: "Operations", exact: true }).click();
await expect(page.getByRole("heading", { name: "Operations", level: 1 })).toBeVisible();
release();
const notice = page.locator(".client-created-notice");
await expect(notice).toContainText(name);
expect(new URL(page.url()).pathname).toBe("/operations");
await expect(notice.getByRole("link", { name: "Resume onboarding" })).toHaveAttribute("href", `/clients?onboard=${createdId}`);
await notice.getByRole("link", { name: "Resume onboarding" }).click();
await expect(page.getByText(/Resumable Hub identity/)).toBeVisible();
expect(new URL(page.url()).searchParams.get("onboard")).toBe(createdId);
});
test("operation-check results link to registered connector identities", async ({ page }) => {
await page.route("**/api/operations/check", (route) => route.fulfill({ json: {
generatedAt: new Date().toISOString(), healthy: true,
connectors: { "itworx-knowledge": { reachable: true, status: "healthy", detail: "Bounded contract fixture", connectorRefs: [{ id: "knowledge-default", name: "Knowledge Default" }], connectorRefsTruncated: false } },
} }));
await page.goto("/operations");
await page.getByRole("button", { name: "Run live checks" }).click();
const registration = page.locator(".connector-checks").getByRole("link", { name: "Knowledge Default", exact: true });
await expect(registration).toHaveAttribute("href", "/connectors/knowledge-default");
await registration.click();
await expect(page.getByRole("heading", { name: "Knowledge Default", exact: true })).toBeVisible();
});
test("Quick Jump sends an ordinary client to its filtered records instead of an unavailable onboarding workflow", async ({ page }) => {
await page.route("**/api/clients?*", (route) => route.fulfill({ json: {
kind: "clients", generatedAt: new Date().toISOString(),
rows: [{ id: "ordinary-fixture-client", name: "Ordinary fixture client", status: "healthy", summary: "Existing client", project: "ludarium", environment: "prod" }],
page: { total: 1, nextCursor: null },
} }));
await page.getByRole("button", { name: "Open quick jump" }).click();
const dialog = page.getByRole("dialog", { name: "Quick jump" });
await dialog.getByRole("combobox").fill("ordinary-fixture-client");
await dialog.getByRole("group", { name: "Clients", exact: true }).getByRole("option", { name: /Ordinary fixture client/ }).click();
await expect(page).toHaveURL(/\/clients\?q=ordinary-fixture-client$/);
await expect(page.locator(".desktop-data-table").getByText("Existing client", { exact: true })).toBeVisible();
const desktopRecord = page.locator(".desktop-data-table").getByRole("link", { name: "Ordinary fixture client", exact: true });
await expect(desktopRecord).toHaveAttribute("href", "/clients?q=ordinary-fixture-client");
await desktopRecord.click();
await expect(page).toHaveURL(/\/clients\?q=ordinary-fixture-client$/);
await page.setViewportSize({ width: 390, height: 844 });
const mobileRecord = page.locator(".mobile-record-list").getByRole("link", { name: "Ordinary fixture client", exact: true });
await expect(mobileRecord).toHaveAttribute("href", "/clients?q=ordinary-fixture-client");
await mobileRecord.click();
await expect(page).toHaveURL(/\/clients\?q=ordinary-fixture-client$/);
await expect(page.getByText(/Resumable Hub identity/)).toHaveCount(0);
});
test("Quick Jump retains the selected audit trace as its destination filter", async ({ page }) => {
await page.route("**/api/audit?*", (route) => route.fulfill({ json: {
kind: "audit", generatedAt: new Date().toISOString(),
rows: [{ id: "audit-fixture", name: "tool.invocation", status: "healthy", summary: "Fixture event", traceId: "review-fixture-trace", project: "ludarium" }],
page: { total: 1, nextCursor: null },
} }));
await page.getByRole("button", { name: "Open quick jump" }).click();
const dialog = page.getByRole("dialog", { name: "Quick jump" });
await dialog.getByRole("combobox").fill("review-fixture-trace");
await dialog.getByRole("group", { name: "Audit", exact: true }).getByRole("option", { name: /tool.invocation/ }).click();
await expect(page).toHaveURL(/\/audit\?q=review-fixture-trace$/);
await expect(page.getByLabel("Filter records")).toHaveValue("review-fixture-trace");
});
});
+144
View File
@@ -0,0 +1,144 @@
import { expect, loginAs, test } from "./fixtures";
test.use({ viewport: { width: 390, height: 844 } });
test.describe("mobile operator shell", () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.goto("/");
});
test("navigation isolates the page, traps focus, locks scroll, and restores focus on Escape", async ({ page }, testInfo) => {
const trigger = page.getByRole("button", { name: "Open navigation" });
const main = page.locator("main");
await trigger.focus();
await trigger.press("Enter");
const drawer = page.getByRole("dialog", { name: "Application navigation" });
const close = drawer.getByRole("button", { name: "Close navigation" });
await expect(drawer).toBeVisible();
await expect(drawer).toHaveAttribute("aria-modal", "true");
await expect(close).toBeFocused();
if (process.env.SOL_VISUAL_REVIEW === "1") await page.screenshot({ path: testInfo.outputPath("mobile-drawer.png"), animations: "disabled" });
await expect.poll(() => main.evaluate((element) => (element as HTMLElement).inert)).toBe(true);
await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe("hidden");
await drawer.getByRole("link", { name: "ITWorx MCP Hub" }).focus();
await page.keyboard.press("Shift+Tab");
await expect(drawer.getByRole("link", { name: "Platform" })).toBeFocused();
await page.keyboard.press("Tab");
await expect(drawer.getByRole("link", { name: "ITWorx MCP Hub" })).toBeFocused();
await page.keyboard.press("Escape");
await expect(drawer).toHaveCount(0);
await expect(trigger).toBeFocused();
await expect.poll(() => main.evaluate((element) => (element as HTMLElement).inert)).toBe(false);
await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe("");
});
test("short phones can scroll the full drawer while the page stays locked", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 520 });
await page.getByRole("button", { name: "Open navigation" }).click();
const drawer = page.getByRole("dialog", { name: "Application navigation" });
await expect(drawer).toBeVisible();
const metrics = await drawer.evaluate((element) => ({
height: element.getBoundingClientRect().height,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
overflowY: getComputedStyle(element).overflowY,
}));
expect(metrics.height).toBeLessThanOrEqual(521);
expect(metrics.overflowY).toBe("auto");
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight);
await drawer.evaluate((element) => { element.scrollTop = element.scrollHeight; });
await expect(drawer.getByRole("link", { name: "Platform" })).toBeInViewport();
});
test("Quick Jump searches grouped entities with keyboard control and never requests audit for a viewer", async ({ page }, testInfo) => {
const auditRequests: string[] = [];
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/audit") auditRequests.push(request.url());
});
await page.getByRole("button", { name: "Open quick jump" }).click();
const dialog = page.getByRole("dialog", { name: "Quick jump" });
const input = dialog.getByRole("combobox", { name: "Find a workspace or resource" });
await expect(input).toBeFocused();
await expect(dialog).toHaveAttribute("aria-modal", "true");
await expect.poll(() => page.locator("main").evaluate((element) => Boolean(element.closest("[inert]")))).toBe(true);
await input.fill("Ludarium");
const project = dialog.getByRole("group", { name: "Projects", exact: true }).getByRole("option", { name: /Ludarium/i });
await expect(project).toBeVisible();
expect((await project.boundingBox())!.height).toBeLessThanOrEqual(120);
if (process.env.SOL_VISUAL_REVIEW === "1") await page.screenshot({ path: testInfo.outputPath("mobile-quick-jump.png"), animations: "disabled" });
await expect(dialog.getByRole("group", { name: "Projects" })).toBeVisible();
await input.press("ArrowDown");
await expect(input).toHaveAttribute("aria-activedescendant", /quick-jump-option-/);
await input.press("Enter");
await expect(page).toHaveURL(/\/projects\/ludarium$/);
expect(auditRequests).toEqual([]);
});
test("Quick Jump keeps recent navigation in memory and shows search failures", async ({ page }) => {
await page.getByRole("button", { name: "Open quick jump" }).click();
let dialog = page.getByRole("dialog", { name: "Quick jump" });
await dialog.getByRole("option", { name: "Catalog" }).click();
await expect(page).toHaveURL(/\/tools$/);
await page.getByRole("button", { name: "Open quick jump" }).click();
dialog = page.getByRole("dialog", { name: "Quick jump" });
await expect(dialog.getByRole("group", { name: "Recent destinations" }).getByRole("option", { name: "Catalog" })).toBeVisible();
await page.route("**/api/projects?*", (route) => route.fulfill({ status: 503, body: "unavailable" }));
const input = dialog.getByRole("combobox", { name: "Find a workspace or resource" });
await input.fill("Ludarium");
await expect(dialog.getByRole("alert")).toHaveText("One resource type could not be searched. Results may be incomplete.");
});
test("sign-out remains explicit and reports its busy state on a narrow screen", async ({ page }) => {
await page.route("**/auth/logout", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 300));
await route.fulfill({ status: 503, body: "unavailable" });
});
const signOut = page.getByRole("button", { name: "Sign out" });
await expect(signOut).toBeVisible();
await expect(signOut).toHaveText("Sign out");
await signOut.click();
const busyButton = page.getByRole("button", { name: "Signing out…" });
await expect(busyButton).toBeDisabled();
await expect(page.getByRole("alert")).toContainText("control API is unavailable");
await expect(page.getByRole("button", { name: "Sign out" })).toBeEnabled();
});
});
test.describe("mobile workflow dialog", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("large tool workflow fills the dynamic viewport and scrolls inside the dialog", async ({ page }, testInfo) => {
await loginAs(page, "hub-operator");
await page.goto("/tools");
const trigger = page.locator(".mobile-record-list").getByRole("button", { name: "Readiness" }).first();
await trigger.click();
const dialog = page.getByRole("dialog", { name: /Tool console/ });
await expect(dialog).toBeVisible();
await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe("hidden");
await expect.poll(() => page.locator("main").evaluate((element) => Boolean(element.closest("[inert]")))).toBe(true);
const layout = await dialog.evaluate((element) => ({
height: element.getBoundingClientRect().height,
overflow: getComputedStyle(element).overflowY,
bodyOverflow: getComputedStyle(element.querySelector(".modal-body")!).overflowY,
}));
expect(layout.height).toBeGreaterThan(800);
expect(layout.overflow).toBe("hidden");
expect(layout.bodyOverflow).toBe("auto");
if (process.env.SOL_VISUAL_REVIEW === "1") {
await expect(dialog.getByRole("button", { name: "Run readiness check" })).toBeEnabled();
await page.screenshot({ path: testInfo.outputPath("mobile-tool-workflow.png"), animations: "disabled" });
}
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(trigger).toBeFocused();
});
});
@@ -0,0 +1,329 @@
import { expect, loginAs, test } from "./fixtures";
function projectDetailFixture(slug: string) {
return {
slug,
name: `Project ${slug.toUpperCase()}`,
description: `Delayed route fixture for ${slug}.`,
environments: [{ name: "prod", classification: "production" }],
capabilityPacks: [],
connectorBindings: [],
mcpServers: [],
knowledgeSources: [],
virtualServerCount: 0,
clientCount: 0,
activeClientCount: 0,
};
}
function connectorDetailFixture(id: string) {
return {
id,
name: `Connector ${id.toUpperCase()}`,
connectorType: "fixture",
description: `Delayed route fixture for ${id}.`,
registration: { state: "registered" as const, detail: "Fixture registration." },
readiness: { reachable: null, status: "unknown", detail: "No probe was requested.", observedAt: null, source: "unavailable" as const },
bindings: [],
relatedTools: [],
};
}
test.describe("operator workflow details", () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, "hub-admin");
});
test("client onboarding resumes from its durable identity and never treats a Hub check as MCP proof", async ({ page }) => {
await page.goto("/clients");
const clientName = `onboarding-resume-${Date.now()}`;
await page.getByLabel("Name").fill(clientName);
await page.getByLabel("Client profile").selectOption("native-2026");
const createResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/client-management";
});
await page.getByRole("button", { name: "Create pending client" }).click();
const created = await (await createResponsePromise).json() as {
client: { id: string };
bootstrap: { reference: string };
config: { kind: string; protocolVersion: string; transport: string; credentialReference: string };
provisioning: { oauthUsername: string; issuer: string; tokenEndpoint: string; oauthClientId: string };
};
await expect(page.getByText(`Client created: ${clientName}`)).toBeVisible();
await expect.poll(() => page.url()).toMatch(/\/clients\?onboard=[0-9a-f-]+$/i);
const savedUrl = page.url();
expect(new URL(savedUrl).searchParams.get("onboard")).toBe(created.client.id);
await page.reload();
await expect(page.getByText(/Resumable Hub identity/)).toBeVisible();
const handoff = page.locator(".onboarding-config-auth");
const configPanel = page.locator(".onboarding-config");
const configValue = (label: string) => configPanel.locator("dl > div").filter({ hasText: label }).locator("dd");
await expect(configValue("Client profile")).toHaveText(created.config.kind);
await expect(configValue("Protocol version")).toHaveText(created.config.protocolVersion);
await expect(configValue("Transport")).toHaveText(created.config.transport);
await expect(configPanel.getByText("Credential environment variable", { exact: true })).toHaveCount(0);
await expect(configPanel.getByText("Bridge command", { exact: true })).toHaveCount(0);
await expect(handoff).toContainText(`External account: ${created.provisioning.oauthUsername}`);
await expect(handoff).toContainText(`Issuer: ${created.provisioning.issuer}`);
await expect(handoff).toContainText(`Token endpoint: ${created.provisioning.tokenEndpoint}`);
await expect(handoff).toContainText(`OAuth client ID: ${created.provisioning.oauthClientId}`);
const resumedText = await page.locator("body").innerText();
expect(resumedText).not.toContain(created.bootstrap.reference);
expect(resumedText).not.toContain(created.config.credentialReference);
await expect(page.getByText("OAuth scopes", { exact: true })).toBeVisible();
await expect(page.getByText(/External verification still required/)).toBeVisible();
await expect(page.getByRole("button", { name: "Record Hub checks" })).toBeEnabled();
await page.getByRole("button", { name: "Record Hub checks" }).click();
await expect(page.locator(".onboarding-check-recorded")).toContainText("Hub checks were recorded");
await expect(page.getByText(/does not mark external OAuth grants or MCP discovery as complete/)).toBeVisible();
await expect(page.locator(".onboarding-identity-bar small")).toContainText("· pending");
expect(page.url()).toBe(savedUrl);
});
test("a delayed verify response cannot overwrite the onboarding identity after the route changes", async ({ page }) => {
const onboardingStatus = (clientId: string, subjectDetail: string, verified = false) => ({
clientId,
project: "ludarium",
environment: "prod",
clientStatus: "pending",
provisioning: {
status: "pending-authentik-service-account",
subject: null,
issuer: "https://identity.example.invalid",
oauthUsername: `svc-mcp-codex-ludarium-${clientId}`,
tokenEndpoint: "https://identity.example.invalid/application/o/token/",
oauthClientId: "hub-fixture-client",
steps: ["Complete external account setup with the administrator."],
},
checks: {
externalSubject: { state: "pending", detail: subjectDetail },
externalScope: { state: "unavailable", detail: "External OAuth grants are not visible to the Hub." },
hubBinding: { state: "pending", detail: "The client remains disabled." },
catalog: { state: "unknown", detail: "Catalog check has not passed." },
connector: { state: "unknown", detail: "Connector state is unknown." , observedAt: null },
mcpProof: { state: "unavailable", detail: "MCP discovery and invocation must be confirmed externally." },
},
verification: { checkedAt: verified ? "2026-10-01T10:00:00.000Z" : null, checkedByManagement: verified, verifiedByManagement: verified, hubChecksPassed: verified, detail: verified ? "Client A checks passed." : "No verification record." },
});
let releaseVerify!: () => void;
const verifyGate = new Promise<void>((resolve) => { releaseVerify = resolve; });
await page.route("**/api/client-management/a/onboarding-status", (route) => route.fulfill({ json: onboardingStatus("a", "Client A is awaiting its external subject.") }));
await page.route("**/api/client-management/a/config", (route) => route.fulfill({ json: { clientId: "a", endpoint: "https://hub.example.invalid/mcp", scopes: ["project:ludarium:read"] } }));
await page.route("**/api/client-management/b/onboarding-status", (route) => route.fulfill({ json: onboardingStatus("b", "Client B is awaiting its external subject.") }));
await page.route("**/api/client-management/b/config", (route) => route.fulfill({ json: { clientId: "b", endpoint: "https://hub.example.invalid/mcp", scopes: ["project:ludarium:read"] } }));
await page.route("**/api/client-management/a/verify", async (route) => {
await verifyGate;
await route.fulfill({ json: onboardingStatus("a", "Client A is verified.", true) });
});
await page.goto("/clients?onboard=a");
await expect(page.getByText("Client A is awaiting its external subject.")).toBeVisible();
const verifyRequest = page.waitForRequest((request) => request.url().endsWith("/api/client-management/a/verify"));
await page.getByRole("button", { name: "Record Hub checks" }).click();
await verifyRequest;
await page.evaluate(() => {
window.history.pushState({}, "", "/clients?onboard=b");
window.dispatchEvent(new PopStateEvent("popstate"));
});
await expect(page.getByText("Client B is awaiting its external subject.")).toBeVisible();
releaseVerify();
await expect(page.getByText("Client B is awaiting its external subject.")).toBeVisible();
await expect(page.getByText("Client A is verified.")).toHaveCount(0);
await expect(page.getByText(/Hub checks passed/)).toHaveCount(0);
await expect(page).toHaveURL(/\/clients\?onboard=b$/);
});
test("a delayed catalog preview for the previous project cannot be shown or applied", async ({ page }) => {
await page.route((url) => url.pathname === "/api/projects" && url.searchParams.has("limit"), (route) => route.fulfill({ json: {
kind: "projects",
generatedAt: "2026-10-01T10:00:00.000Z",
rows: [
{ id: "ludarium", name: "Ludarium", status: "healthy", summary: "Fixture project A" },
{ id: "forgeflow", name: "ForgeFlow", status: "healthy", summary: "Fixture project B" },
],
page: { total: 2 },
} }));
let releasePreview!: () => void;
const previewGate = new Promise<void>((resolve) => { releasePreview = resolve; });
const previewProjects: string[] = [];
await page.route("**/api/catalog/preview", async (route) => {
const body = route.request().postDataJSON() as { project: string };
previewProjects.push(body.project);
if (body.project === "ludarium") {
await previewGate;
await route.fulfill({ json: { valid: true, changes: ["Project A private preview"], errors: [] } });
return;
}
await route.fulfill({ json: { valid: true, changes: ["Project B preview"], errors: [] } });
});
await page.goto("/tools");
const workflow = page.locator(".workflow-panel").filter({ has: page.getByRole("heading", { name: "Catalog validation and preview" }) });
const project = workflow.getByLabel("Project");
await expect(project.locator("option")).toHaveCount(2);
await project.selectOption("ludarium");
const previewRequest = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/catalog/preview");
await workflow.getByRole("button", { name: "Validate and preview" }).click();
await previewRequest;
try {
await project.selectOption("forgeflow");
await expect(workflow.locator(".diff-box")).toHaveCount(0);
releasePreview();
await expect(workflow.getByText("Project A private preview")).toHaveCount(0);
await expect(workflow.getByRole("button", { name: "Apply approved catalog" })).toHaveCount(0);
expect(previewProjects).toEqual(["ludarium"]);
} finally {
releasePreview();
}
});
test("a delayed project detail response is ignored after navigating to another project", async ({ page }) => {
let releaseProjectA!: () => void;
const projectGate = new Promise<void>((resolve) => { releaseProjectA = resolve; });
await page.route("**/api/projects/a", async (route) => {
await projectGate;
await route.fulfill({ json: projectDetailFixture("a") });
});
await page.route("**/api/projects/b", (route) => route.fulfill({ json: projectDetailFixture("b") }));
const projectARequest = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/projects/a");
await page.goto("/projects/a");
await projectARequest;
await page.evaluate(() => {
window.history.pushState({}, "", "/projects/b");
window.dispatchEvent(new PopStateEvent("popstate"));
});
try {
await expect(page.getByRole("heading", { name: "Project B", exact: true })).toBeVisible();
releaseProjectA();
await expect(page.getByRole("heading", { name: "Project A", exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/projects\/b$/);
} finally {
releaseProjectA();
}
});
test("a delayed connector detail response is ignored after navigating to another connector", async ({ page }) => {
let releaseConnectorA!: () => void;
const connectorGate = new Promise<void>((resolve) => { releaseConnectorA = resolve; });
await page.route("**/api/connectors/a", async (route) => {
await connectorGate;
await route.fulfill({ json: connectorDetailFixture("a") });
});
await page.route("**/api/connectors/b", (route) => route.fulfill({ json: connectorDetailFixture("b") }));
const connectorARequest = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/connectors/a");
await page.goto("/connectors/a");
await connectorARequest;
await page.evaluate(() => {
window.history.pushState({}, "", "/connectors/b");
window.dispatchEvent(new PopStateEvent("popstate"));
});
try {
await expect(page.getByRole("heading", { name: "Connector B", exact: true })).toBeVisible();
releaseConnectorA();
await expect(page.getByRole("heading", { name: "Connector A", exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/connectors\/b$/);
} finally {
releaseConnectorA();
}
});
test("typed tool arguments reach the readiness API and recent runs retain metadata only", async ({ page }) => {
const capturedRequests: Array<Record<string, unknown>> = [];
await page.route(/\/api\/tools(?:\?.*)?$/, (route) => route.fulfill({ json: {
kind: "tools",
generatedAt: "2026-10-01T10:00:00.000Z",
rows: [{ id: "typed-tool", name: "Typed Tool", status: "healthy", summary: "Typed schema fixture", project: "ludarium", environment: "prod", risk: "R1", canonicalName: "test.typed" }],
page: { total: 1 },
} }));
await page.route("**/api/tool-definitions/test.typed", (route) => route.fulfill({ json: {
canonicalName: "test.typed",
description: "Typed schema fixture",
readOnly: true,
riskClass: "R1",
inputSchema: { type: "object", required: ["count", "enabled", "mode", "tags", "filters"], properties: {
count: { type: "integer", title: "Count", minimum: 1, maximum: 10 },
enabled: { type: "boolean", title: "Enabled" },
mode: { type: "string", title: "Mode", enum: ["safe", "full"] },
tags: { type: "array", title: "Tags" },
filters: { type: "object", title: "Filters" },
} },
limits: { timeoutMs: 1000, maxOutputBytes: 2048, maxItems: 10 },
} }));
await page.route("**/api/tools/test", async (route) => {
capturedRequests.push(route.request().postDataJSON() as Record<string, unknown>);
await route.fulfill({ json: {
ready: true,
tool: "test.typed",
project: "ludarium",
environment: "prod",
note: "Readiness only; no MCP tool invocation was performed.",
checks: {
catalog: { valid: true, readOnly: true, connectorType: "fixture" },
input: { valid: true, errors: [] },
binding: { valid: true, detail: "Fixture binding" },
policy: { allowed: true, decision: "allow", reasonCode: "FIXTURE_ALLOWED", traceId: "ready-trace" },
connector: { reachable: true, status: "healthy", detail: "Fixture route" },
},
} });
});
await page.route("**/api/tools/invoke", (route) => route.fulfill({ json: {
ok: false,
traceId: "trace-typed-001",
tool: "test.typed",
content: [{ type: "text", text: "controlled fixture result" }],
error: { code: "controlled", message: "The fixture returned a controlled error." },
} }));
await page.goto("/tools");
await page.getByRole("button", { name: "Readiness" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("button", { name: "Run readiness check" })).toBeDisabled();
await dialog.getByLabel("Project").selectOption("ludarium");
await dialog.getByLabel("Environment").selectOption("prod");
await dialog.getByLabel("Count").fill("4");
await dialog.getByLabel("Enabled").selectOption("true");
await dialog.getByLabel("Mode").selectOption({ label: "full" });
await dialog.getByLabel("Tags").fill('["never-store-tags"]');
await dialog.getByLabel("Filters").fill('{"author":"never-store-filter"}');
await dialog.getByRole("button", { name: "Run readiness check" }).click();
await expect(dialog.getByText("Ready", { exact: true })).toBeVisible();
expect(capturedRequests[0]).toMatchObject({
project: "ludarium",
environment: "prod",
tool: "test.typed",
arguments: { count: 4, enabled: true, mode: "full", tags: ["never-store-tags"], filters: { author: "never-store-filter" } },
});
await dialog.getByLabel("Count").fill("5");
await expect(dialog.getByText("Ready", { exact: true })).toHaveCount(0);
await expect(dialog.getByRole("button", { name: "Run tool" })).toBeDisabled();
await dialog.getByRole("button", { name: "Run readiness check" }).click();
await expect(dialog.getByText("Ready", { exact: true })).toBeVisible();
await dialog.getByRole("button", { name: "Run tool" }).click();
await expect(dialog.getByRole("heading", { name: "Controlled error" })).toBeVisible();
const storedHistory = await page.evaluate(() => sessionStorage.getItem("itworx-tool-history-v1"));
expect(storedHistory).toContain("trace-typed-001");
expect(storedHistory).not.toContain("never-store-tags");
expect(storedHistory).not.toContain("never-store-filter");
expect(storedHistory).not.toContain("controlled fixture result");
await dialog.getByRole("button", { name: "Close dialog" }).click();
const recentRuns = page.getByRole("region", { name: "Recent Tool Runs" });
await expect(recentRuns.getByText("Controlled error")).toBeVisible();
await expect(recentRuns.getByText("trace-typed-001")).toBeVisible();
await recentRuns.getByRole("button", { name: "Open tool context" }).click();
await expect(page).toHaveURL(/\/tools\?reopen=test\.typed&project=ludarium&environment=prod/);
await expect(page.getByRole("dialog", { name: /Tool console/ })).toBeVisible();
await expect(page.getByRole("dialog").getByLabel("Project")).toHaveValue("ludarium");
await expect(page.getByRole("dialog").getByLabel("Environment")).toHaveValue("prod");
});
});
+5 -5
View File
@@ -33,12 +33,12 @@ test.describe("product-quality interaction and composition", () => {
const dialog = page.getByRole("dialog", { name: "Quick jump" }); const dialog = page.getByRole("dialog", { name: "Quick jump" });
await expect(dialog).toBeVisible(); await expect(dialog).toBeVisible();
const search = dialog.getByLabel("Find a workspace"); const search = dialog.getByRole("combobox", { name: "Find a workspace or resource" });
await expect(search).toBeFocused(); await expect(search).toBeFocused();
await expect(dialog.getByRole("button", { name: /Audit/ })).toHaveCount(0); await expect(dialog.getByRole("option", { name: /Audit/ })).toHaveCount(0);
await search.fill("catalog"); await search.fill("catalog");
await dialog.getByRole("button", { name: /Catalog/ }).click(); await dialog.getByRole("group", { name: "Build", exact: true }).getByRole("option", { name: /Catalog/ }).click();
await expect(page).toHaveURL(/\/tools$/); await expect(page).toHaveURL(/\/tools$/);
await expect(page.getByRole("heading", { name: "Catalog", level: 2 })).toBeVisible(); await expect(page.getByRole("heading", { name: "Catalog", level: 2 })).toBeVisible();
}); });
@@ -48,7 +48,7 @@ test.describe("product-quality interaction and composition", () => {
await page.goto("/tools"); await page.goto("/tools");
const filter = page.getByLabel("Filter records"); const filter = page.getByLabel("Filter records");
await filter.fill(" Fleet Ops "); await filter.fill(" Fleet Ops ");
await expect(page).toHaveURL(/\/tools\?q=Fleet%20Ops$/); await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("Fleet Ops");
await expect(page.getByText("Updating results…")).toHaveCount(0); await expect(page.getByText("Updating results…")).toHaveCount(0);
await filter.fill(" "); await filter.fill(" ");
@@ -79,7 +79,7 @@ test.describe("product-quality interaction and composition", () => {
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/tools"); await page.goto("/tools");
await expect(page.getByRole("heading", { name: "Catalog", level: 2 })).toBeVisible(); await expect(page.getByRole("heading", { name: "Catalog", level: 2 })).toBeVisible({ timeout: 15_000 });
await expect(page.getByLabel("Filter records")).toBeVisible(); await expect(page.getByLabel("Filter records")).toBeVisible();
await expectNoPageOverflow(page); await expectNoPageOverflow(page);
if (captureReview) await page.screenshot({ path: testInfo.outputPath("catalog-desktop.png"), fullPage: false }); if (captureReview) await page.screenshot({ path: testInfo.outputPath("catalog-desktop.png"), fullPage: false });
+8 -6
View File
@@ -29,13 +29,13 @@ test.describe("management workflows, driven end to end from the browser", () =>
}) => { }) => {
await page.goto("/projects/ludarium?tab=Environments"); await page.goto("/projects/ludarium?tab=Environments");
await expect(page.getByLabel("Project detail sections").getByRole("link", { name: "Environments" })).toHaveAttribute("aria-current", "page"); await expect(page.getByLabel("Project detail sections").getByRole("link", { name: "Environments" })).toHaveAttribute("aria-current", "page");
await expect(page.getByRole("cell", { name: "prod", exact: true })).toBeVisible(); await expect(page.getByLabel("Project environments", { exact: true }).getByRole("heading", { name: "prod", exact: true })).toBeVisible();
// ludarium never registered a staging environment (docs/known-state, and // ludarium never registered a staging environment (docs/known-state, and
// tests/test_wp230_ui_completion.py's equivalent backend assertion). // tests/test_wp230_ui_completion.py's equivalent backend assertion).
await expect(page.getByRole("cell", { name: "staging" })).toHaveCount(0); await expect(page.getByLabel("Project environments", { exact: true }).getByRole("heading", { name: "staging", exact: true })).toHaveCount(0);
await page.getByLabel("Project detail sections").getByRole("link", { name: "Connectors" }).click(); await page.getByLabel("Project detail sections").getByRole("link", { name: "Connectors" }).click();
await expect(page.getByRole("cell", { name: "knowledge", exact: true })).toBeVisible(); await expect(page.getByLabel("Connector bindings", { exact: true }).getByRole("heading", { name: "knowledge", exact: true })).toBeVisible();
}); });
test("an unregistered project is a real 404, not a fabricated default", async ({ page }) => { test("an unregistered project is a real 404, not a fabricated default", async ({ page }) => {
@@ -63,12 +63,14 @@ test.describe("management workflows, driven end to end from the browser", () =>
await page.goto("/tools"); await page.goto("/tools");
await page.getByRole("button", { name: "Validate and preview" }).click(); await page.getByRole("button", { name: "Validate and preview" }).click();
const outcome = page.getByText(/Valid preview|Validation blocked/); const outcome = page.getByText(/Valid preview|Validation blocked/);
await expect(outcome).toBeVisible(); await expect(outcome).toBeVisible({ timeout: 15_000 });
const applyButton = page.getByRole("button", { name: "Apply approved catalog" }); const applyButton = page.getByRole("button", { name: "Apply approved catalog" });
if (await outcome.filter({ hasText: "Valid preview" }).count()) { if (await outcome.filter({ hasText: "Valid preview" }).count()) {
await expect(applyButton).toBeEnabled(); await expect(applyButton).toBeEnabled();
await applyButton.click(); await applyButton.click();
await expect(page.getByRole("dialog")).toContainText("All registered project routes may be updated");
await page.getByRole("button", { name: "Confirm apply" }).click(); await page.getByRole("button", { name: "Confirm apply" }).click();
await expect(page.getByText(/Catalog reconciliation completed and verified/)).toBeVisible({ timeout: 15_000 });
} else { } else {
await expect(applyButton).toBeDisabled(); await expect(applyButton).toBeDisabled();
} }
@@ -167,10 +169,10 @@ test.describe("management workflows, driven end to end from the browser", () =>
test("tool readiness validates the full registered path without invoking the tool", async ({ page }) => { test("tool readiness validates the full registered path without invoking the tool", async ({ page }) => {
await page.goto("/tools"); await page.goto("/tools");
await page.getByRole("button", { name: "Validate and preview" }).click(); await page.getByRole("button", { name: "Validate and preview" }).click();
await expect(page.getByText("Valid preview")).toBeVisible(); await expect(page.getByText("Valid preview")).toBeVisible({ timeout: 15_000 });
await page.getByRole("button", { name: "Apply approved catalog" }).click(); await page.getByRole("button", { name: "Apply approved catalog" }).click();
await page.getByRole("button", { name: "Confirm apply" }).click(); await page.getByRole("button", { name: "Confirm apply" }).click();
await expect(page.getByText(/Catalog reconciliation completed and verified/)).toBeVisible(); await expect(page.getByText(/Catalog reconciliation completed and verified/)).toBeVisible({ timeout: 15_000 });
await page.getByLabel("Filter records").fill("Fleet Ops Operations Summary"); await page.getByLabel("Filter records").fill("Fleet Ops Operations Summary");
const row = page.getByRole("row", { name: /Fleet Ops Operations Summary/ }); const row = page.getByRole("row", { name: /Fleet Ops Operations Summary/ });
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import type { DashboardSnapshot } from "../src/contracts";
import { actionInbox, aggregateTrend, alertDestination } from "../src/operator-data";
const snapshot: DashboardSnapshot = {
generatedAt: "2026-10-01T10:00:00Z", staleAfterSeconds: 90,
health: { hub: "healthy", gateway: "healthy", detail: "Available" },
projects: [], connectors: [{ id: "gitea-alpha", name: "gitea", project: "alpha", state: "degraded", registrationState: "registered", readiness: { state: "unknown", detail: "Not probed", observedAt: null, source: "not-probed" }, detail: "Readiness failed" }],
denials: { count: 2, window: "24h", latestTool: "gitea.repository.get" },
drift: { count: 1, projects: ["alpha"], known: true, detail: "Catalog differs" },
activeClients: 1, backup: { state: "degraded", lastSuccessfulAt: null, detail: "No backup observed" },
version: { application: "1.0.0", schema: "1" },
};
describe("operator signals", () => {
it("routes a connector signal to its exact project binding context", () => {
const item = actionInbox(snapshot, false, false).find((signal) => signal.project === "alpha" && signal.subsystem === "gitea");
expect(item?.href).toBe("/connectors/gitea-alpha");
expect(item?.detail).toBe("Readiness failed");
expect(item?.observedAt).toBe("2026-10-01T10:00:00Z");
});
it("exposes audit navigation only to an authorized auditor", () => {
expect(actionInbox(snapshot, false, false).find((signal) => signal.subsystem === "Authorization")?.href).toBe("/operations?section=alerts");
expect(actionInbox(snapshot, false, true).find((signal) => signal.subsystem === "Authorization")?.href).toBe("/audit?q=gitea.repository.get");
});
it("links drift and backup to their bounded resolution workspace", () => {
const signals = actionInbox(snapshot, false, true);
expect(signals.find((signal) => signal.subsystem === "Catalog")?.href).toBe("/operations?section=checks");
expect(signals.find((signal) => signal.subsystem === "Recovery")?.href).toBe("/operations?section=recovery");
expect(alertDestination("BACKUP_STALE", false)).toBe("/operations?section=recovery");
});
it("keeps unknown and stale state explicit without claiming a diagnosis", () => {
const signals = actionInbox({ ...snapshot, drift: { ...snapshot.drift, known: false } }, true, false);
expect(signals[0]?.subsystem).toBe("Freshness");
expect(signals.find((signal) => signal.subsystem === "Catalog")?.status).toBe("unknown");
});
});
describe("mobile call buckets", () => {
it("aggregates each four-hour interval without dropping outcomes", () => {
const trend = Array.from({ length: 24 }, (_, hour) => ({ hour: `2026-10-01T${String(hour).padStart(2, "0")}:00:00Z`, success: 1, denied: 2, error: 3, rate_limited: 4 }));
const buckets = aggregateTrend(trend, 4);
expect(buckets).toHaveLength(6);
expect(buckets[0]).toEqual({ hour: "2026-10-01T00:00:00Z", endHour: "2026-10-01T03:00:00Z", success: 4, denied: 8, error: 12, rate_limited: 16 });
expect(buckets[5]?.success).toBe(4);
});
it("retains partial buckets and handles empty observations", () => {
expect(aggregateTrend([], 4)).toEqual([]);
expect(aggregateTrend([{ hour: "2026-10-01T00:00:00Z", success: 2, denied: 0, error: 1, rate_limited: 0 }], 4)[0]?.error).toBe(1);
});
});
+16
View File
@@ -0,0 +1,16 @@
import { afterEach, expect, it, vi } from "vitest";
import { readPreference, writePreference } from "../src/preferences";
afterEach(() => vi.unstubAllGlobals());
it("accepts only known presentation preferences", () => {
const values = new Map<string, string>([["hub-density", "arbitrary"]]);
vi.stubGlobal("localStorage", { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value) });
expect(readPreference("hub-density", ["compact", "comfortable"], "comfortable")).toBe("comfortable");
writePreference("hub-density", "compact");
expect(readPreference("hub-density", ["compact", "comfortable"], "comfortable")).toBe("compact");
});
it("continues with safe defaults when storage is denied", () => {
vi.stubGlobal("localStorage", { getItem: () => { throw new Error("storage denied"); }, setItem: () => { throw new Error("storage denied"); } });
expect(readPreference("hub-theme", ["dark", "light"], "dark")).toBe("dark");
expect(() => writePreference("hub-theme", "light")).not.toThrow();
});
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { NULL_INPUT_VALUE, parseSchemaArguments } from "../src/schema-input";
import type { ToolDefinition } from "../src/contracts";
const schema = (properties: NonNullable<ToolDefinition["inputSchema"]["properties"]>, required: string[] = []): ToolDefinition["inputSchema"] => ({
type: "object",
properties,
required,
});
describe("schema-driven tool arguments", () => {
it("parses strings, integers, decimals, booleans, enums, arrays and objects by declared type", () => {
const inputSchema = schema({
query: { type: "string" },
limit: { type: "integer", minimum: 1, maximum: 20 },
score: { type: "number" },
includeClosed: { type: "boolean" },
format: { type: "string", enum: ["compact", "full"] },
labels: { type: "array" },
filters: { type: "object" },
}, ["query", "limit", "includeClosed", "format"]);
expect(parseSchemaArguments({
query: "pull request",
limit: "4",
score: "3.5",
includeClosed: "false",
format: JSON.stringify("full"),
labels: "[\"bug\",\"urgent\"]",
filters: "{\"author\":\"jens\"}",
}, inputSchema)).toEqual({
arguments: {
query: "pull request",
limit: 4,
score: 3.5,
includeClosed: false,
format: "full",
labels: ["bug", "urgent"],
filters: { author: "jens" },
},
errors: {},
valid: true,
});
});
it("reports missing required values, malformed JSON and type mismatches clearly", () => {
const inputSchema = schema({
count: { type: "integer" },
enabled: { type: "boolean" },
tags: { type: "array" },
options: { type: "object" },
}, ["count", "enabled", "tags", "options"]);
const parsed = parseSchemaArguments({ count: "2.3", enabled: "sometimes", tags: "{oops", options: "[]" }, inputSchema);
expect(parsed.valid).toBe(false);
expect(parsed.arguments).toEqual({});
expect(parsed.errors).toEqual({
count: "Enter a whole number.",
enabled: "Choose yes or no.",
tags: "Enter valid JSON for this array.",
options: "Enter a JSON object.",
});
});
it("applies string and numeric bounds and rejects enum values outside the schema", () => {
const inputSchema = schema({
query: { type: "string", minLength: 2, maxLength: 5 },
limit: { type: "integer", minimum: 1, maximum: 8 },
mode: { type: "string", enum: ["safe", "full"] },
});
const parsed = parseSchemaArguments({ query: "x", limit: "9", mode: JSON.stringify("unsafe") }, inputSchema);
expect(parsed.valid).toBe(false);
expect(parsed.errors.query).toBe("Enter at least 2 characters.");
expect(parsed.errors.limit).toBe("Enter a number no greater than 8.");
expect(parsed.errors.mode).toBe("Choose a value listed by this tool's schema.");
});
it("omits empty optional fields and accepts explicit null only for nullable fields", () => {
const inputSchema = schema({ optional: { type: "string" }, nullable: { type: ["string", "null"] } });
expect(parseSchemaArguments({ optional: "", nullable: NULL_INPUT_VALUE }, inputSchema)).toEqual({
arguments: { nullable: null },
errors: {},
valid: true,
});
expect(parseSchemaArguments({ nullable: NULL_INPUT_VALUE }, schema({ nullable: { type: "string" } })).errors.nullable).toBe("This field does not accept null.");
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { MAX_TOOL_RUNS, recordToolRun, readToolHistory, type ToolRunMetadata } from "../src/tool-history";
function createStorage(seed: string | null = null) {
let value = seed;
return {
getItem: () => value,
setItem: (_key: string, next: string) => { value = next; },
value: () => value,
};
}
const run = (overrides: Partial<ToolRunMetadata> = {}): ToolRunMetadata => ({
tool: "gitea.pullrequest.status",
project: "ludarium",
environment: "prod",
ok: true,
traceId: "trace-123",
at: "2026-09-30T10:00:00.000Z",
...overrides,
});
describe("bounded recent tool history", () => {
it("stores only safe invocation metadata and never serializes arguments or results", () => {
const storage = createStorage();
recordToolRun({ ...run(), arguments: { token: "fixture-do-not-store" }, result: "private output" } as ToolRunMetadata, storage);
expect(JSON.parse(storage.value() ?? "null")).toEqual([run()]);
expect(storage.value()).not.toContain("fixture-do-not-store");
expect(storage.value()).not.toContain("private output");
});
it("bounds history to the newest twenty valid entries", () => {
const storage = createStorage();
for (let index = 0; index < MAX_TOOL_RUNS + 4; index += 1) {
recordToolRun(run({ traceId: `trace-${index}`, at: `2026-09-30T10:${String(index).padStart(2, "0")}:00.000Z` }), storage);
}
const history = readToolHistory(storage);
expect(history).toHaveLength(MAX_TOOL_RUNS);
expect(history[0]?.traceId).toBe(`trace-${MAX_TOOL_RUNS + 3}`);
expect(history.at(-1)?.traceId).toBe("trace-4");
});
it("drops malformed and oversized stored entries without exposing extra fields", () => {
const storage = createStorage(JSON.stringify([
{ ...run(), arguments: { apiKey: "secret" }, content: "result" },
{ ...run(), traceId: "trace\nsecret" },
{ ...run(), at: "yesterday" },
null,
]));
expect(readToolHistory(storage)).toEqual([run()]);
});
it("treats corrupt or unavailable browser storage as an empty history", () => {
expect(readToolHistory(createStorage("not-json"))).toEqual([]);
expect(() => readToolHistory({ getItem: () => { throw new Error("blocked"); }, setItem: () => undefined })).not.toThrow();
expect(() => recordToolRun(run(), { getItem: () => null, setItem: () => { throw new Error("blocked"); } })).not.toThrow();
});
});
+1 -1
View File
@@ -6,7 +6,7 @@ export default defineConfig({
test: { test: {
environment: "node", environment: "node",
include: ["tests/**/*.test.ts"], include: ["tests/**/*.test.ts"],
coverage: { reporter: ["text", "json-summary"] }, coverage: { include: ["src/**/*.{ts,tsx}"], reporter: ["text", "json-summary"] },
}, },
}); });
+4 -2
View File
@@ -118,7 +118,7 @@ class FakeGiteaServer:
"html_url": "https://gitea.example/acme/hub/issues/1", "html_url": "https://gitea.example/acme/hub/issues/1",
} }
] ]
self._pulls = [ self._pulls: list[dict[str, Any]] = [
{ {
"number": 7, "number": 7,
"title": "Add governed Codex workflows", "title": "Add governed Codex workflows",
@@ -420,7 +420,9 @@ class FakeGiteaServer:
) )
if body.get("state") == "open": if body.get("state") == "open":
self._action_run.update( self._action_run.update(
event="pull_request", status="queued", conclusion="", event="pull_request",
status="queued",
conclusion="",
head_sha=self._pulls[0]["head"]["sha"], head_sha=self._pulls[0]["head"]["sha"],
) )
return self._json(200, self._pulls[0], request) return self._json(200, self._pulls[0], request)
+97 -89
View File
@@ -16,7 +16,7 @@ from threading import Lock
from typing import Any, Literal from typing import Any, Literal
from urllib.parse import quote, unquote, urlparse from urllib.parse import quote, unquote, urlparse
import yaml import yaml # type: ignore[import-untyped]
from packages.connector_kit import ( from packages.connector_kit import (
ConnectorContext, ConnectorContext,
@@ -120,7 +120,7 @@ _PROJECTBRAIN_VALIDATION_CONCURRENCY = {
} }
class _UniqueWorkflowLoader(yaml.BaseLoader): class _UniqueWorkflowLoader(yaml.BaseLoader): # type: ignore[misc]
"""Parse workflow YAML without YAML 1.1's special treatment of the `on` key.""" """Parse workflow YAML without YAML 1.1's special treatment of the `on` key."""
@@ -205,21 +205,17 @@ def _projectbrain_managed_validation_is_strong(workflow_content: str) -> bool:
return False return False
triggers = _workflow_mapping(document.get("on")) triggers = _workflow_mapping(document.get("on"))
if ( if triggers is None or set(triggers) != {"push", "pull_request", "workflow_dispatch"}:
triggers is None
or set(triggers) != {"push", "pull_request", "workflow_dispatch"}
):
return False return False
dispatch = _workflow_mapping(triggers.get("workflow_dispatch")) dispatch = _workflow_mapping(triggers.get("workflow_dispatch"))
dispatch_inputs = _workflow_mapping(dispatch.get("inputs")) if dispatch is not None else None if dispatch is None or set(dispatch) != {"inputs"}:
return False
dispatch_inputs = _workflow_mapping(dispatch.get("inputs"))
profile_input = ( profile_input = (
_workflow_mapping(dispatch_inputs.get("profile")) _workflow_mapping(dispatch_inputs.get("profile")) if dispatch_inputs is not None else None
if dispatch_inputs is not None
else None
) )
if ( if (
dispatch_inputs is None dispatch_inputs is None
or set(dispatch) != {"inputs"}
or set(dispatch_inputs) != {"profile"} or set(dispatch_inputs) != {"profile"}
or profile_input is None or profile_input is None
or set(profile_input) != {"description", "required", "default", "type", "options"} or set(profile_input) != {"description", "required", "default", "type", "options"}
@@ -231,11 +227,7 @@ def _projectbrain_managed_validation_is_strong(workflow_content: str) -> bool:
): ):
return False return False
push = _workflow_mapping(triggers.get("push")) push = _workflow_mapping(triggers.get("push"))
if ( if push is None or set(push) != {"branches"} or push.get("branches") != ["main"]:
push is None
or set(push) != {"branches"}
or push.get("branches") != ["main"]
):
return False return False
pull_request = triggers.get("pull_request") pull_request = triggers.get("pull_request")
if pull_request not in (None, ""): if pull_request not in (None, ""):
@@ -1028,9 +1020,7 @@ class GiteaConnector:
if isinstance(existing_contexts, list) if isinstance(existing_contexts, list)
else set() else set()
) )
desired["status_check_contexts"] = sorted( desired["status_check_contexts"] = sorted(set(required_contexts).union(preserved_contexts))
set(required_contexts).union(preserved_contexts)
)
current_projection = _branch_protection_projection(current) current_projection = _branch_protection_projection(current)
protection_diff = { protection_diff = {
key: { key: {
@@ -1061,12 +1051,9 @@ class GiteaConnector:
if workflow_content is not None if workflow_content is not None
else None else None
) )
workflow_profile = _managed_validation_profile( workflow_profile = _managed_validation_profile(repository.qualified_name, workflow_content)
repository.qualified_name, workflow_content
)
is_projectbrain = ( is_projectbrain = (
repository.qualified_name.casefold() repository.qualified_name.casefold() == _PROJECTBRAIN_MANAGED_VALIDATION_REPOSITORY
== _PROJECTBRAIN_MANAGED_VALIDATION_REPOSITORY
) )
if is_projectbrain and workflow_profile is None: if is_projectbrain and workflow_profile is None:
blockers.append("managed-validation-projectbrain-profile-invalid") blockers.append("managed-validation-projectbrain-profile-invalid")
@@ -1083,9 +1070,7 @@ class GiteaConnector:
else "update" else "update"
) )
desired_workflow_digest = ( desired_workflow_digest = (
workflow_digest workflow_digest if workflow_profile == "projectbrain" else MANAGED_VALIDATION_DIGEST
if workflow_profile == "projectbrain"
else MANAGED_VALIDATION_DIGEST
) )
if is_projectbrain and current_projection is not None: if is_projectbrain and current_projection is not None:
if any( if any(
@@ -1250,11 +1235,7 @@ class GiteaConnector:
if apply_scope not in {"all", "protection_only"}: if apply_scope not in {"all", "protection_only"}:
raise GiteaError("GITEA_INPUT_INVALID", "governance apply scope is not allowlisted") raise GiteaError("GITEA_INPUT_INVALID", "governance apply scope is not allowlisted")
plan = self._governance_plan_data(context, repository_ref) plan = self._governance_plan_data(context, repository_ref)
expected_hash = ( expected_hash = plan["planHash"] if apply_scope == "all" else plan["protectionOnlyPlanHash"]
plan["planHash"]
if apply_scope == "all"
else plan["protectionOnlyPlanHash"]
)
if ( if (
not isinstance(expected_hash, str) not isinstance(expected_hash, str)
or not re.fullmatch(r"[0-9a-f]{64}", expected_plan_hash) or not re.fullmatch(r"[0-9a-f]{64}", expected_plan_hash)
@@ -1267,9 +1248,8 @@ class GiteaConnector:
if apply_scope == "protection_only": if apply_scope == "protection_only":
allowed_blockers = {"managed-validation-projectbrain-profile-invalid"} allowed_blockers = {"managed-validation-projectbrain-profile-invalid"}
if ( if not plan["_protectionOnlyAvailable"] or any(
not plan["_protectionOnlyAvailable"] blocker not in allowed_blockers for blocker in plan["blockers"]
or any(blocker not in allowed_blockers for blocker in plan["blockers"])
): ):
raise GiteaError( raise GiteaError(
"GITEA_GOVERNANCE_BLOCKED", "GITEA_GOVERNANCE_BLOCKED",
@@ -1311,16 +1291,17 @@ class GiteaConnector:
actual_effective_protection = _branch_protection_effective_projection( actual_effective_protection = _branch_protection_effective_projection(
verified_plan["_currentProtection"] verified_plan["_currentProtection"]
) )
actual_contexts = set( actual_contexts = (
verified_protection.get("status_check_contexts") or [] set(verified_protection.get("status_check_contexts") or [])
) if verified_protection is not None else set() if verified_protection is not None
else set()
)
if ( if (
verified_protection is None verified_protection is None
or verified_protection.get("enable_status_check") is not True or verified_protection.get("enable_status_check") is not True
or actual_contexts != set(expected_contexts) or actual_contexts != set(expected_contexts)
or actual_effective_protection != expected_effective_protection or actual_effective_protection != expected_effective_protection
or verified_plan["workflow"]["currentDigest"] or verified_plan["workflow"]["currentDigest"] != plan["workflow"]["currentDigest"]
!= plan["workflow"]["currentDigest"]
): ):
raise GiteaError( raise GiteaError(
"GITEA_GOVERNANCE_VERIFY_FAILED", "GITEA_GOVERNANCE_VERIFY_FAILED",
@@ -1379,9 +1360,7 @@ class GiteaConnector:
writer.create_branch_protection(repository, desired) writer.create_branch_protection(repository, desired)
elif protection_action == "update": elif protection_action == "update":
if plan["workflow"]["profile"] == "projectbrain": if plan["workflow"]["profile"] == "projectbrain":
edit_payload = { edit_payload = {"status_check_contexts": desired["status_check_contexts"]}
"status_check_contexts": desired["status_check_contexts"]
}
else: else:
edit_payload = { edit_payload = {
key: value key: value
@@ -1420,28 +1399,22 @@ class GiteaConnector:
verified_plan = self._governance_plan_data(context, repository_ref) verified_plan = self._governance_plan_data(context, repository_ref)
is_projectbrain_profile = plan["workflow"]["profile"] == "projectbrain" is_projectbrain_profile = plan["workflow"]["profile"] == "projectbrain"
verified_contexts = set( verified_contexts = set(
(verified_plan["branchProtection"]["current"] or {}).get( (verified_plan["branchProtection"]["current"] or {}).get("status_check_contexts") or []
"status_check_contexts"
) )
or [] verified_expected_contexts = set(
)
expected_contexts = set(
plan["branchProtection"]["desired"].get("status_check_contexts") or [] plan["branchProtection"]["desired"].get("status_check_contexts") or []
) )
projectbrain_protection_changed = is_projectbrain_profile and ( projectbrain_protection_changed = is_projectbrain_profile and (
verified_contexts != expected_contexts verified_contexts != verified_expected_contexts
or ( or (
plan["_currentProtection"] is not None plan["_currentProtection"] is not None
and _branch_protection_effective_projection( and _branch_protection_effective_projection(verified_plan["_currentProtection"])
verified_plan["_currentProtection"]
)
!= _branch_protection_effective_projection(plan["_currentProtection"]) != _branch_protection_effective_projection(plan["_currentProtection"])
) )
) )
projectbrain_workflow_changed = ( projectbrain_workflow_changed = (
is_projectbrain_profile is_projectbrain_profile
and verified_plan["workflow"]["currentDigest"] and verified_plan["workflow"]["currentDigest"] != plan["workflow"]["currentDigest"]
!= plan["workflow"]["currentDigest"]
) )
if ( if (
not verified_plan["compliant"] not verified_plan["compliant"]
@@ -2245,7 +2218,9 @@ class GiteaConnector:
next_actions.append("fix-validation") next_actions.append("fix-validation")
if pull is not None and not ready_to_merge: if pull is not None and not ready_to_merge:
if merge_eligibility["reason"] in { if merge_eligibility["reason"] in {
"GITEA_PR_CHECK_MISSING", "GITEA_PR_CHECK_PENDING", "GITEA_PR_CHECK_FAILED", "GITEA_PR_CHECK_MISSING",
"GITEA_PR_CHECK_PENDING",
"GITEA_PR_CHECK_FAILED",
"GITEA_PR_VALIDATION_REQUIRED", "GITEA_PR_VALIDATION_REQUIRED",
}: }:
next_actions.append("ensure-pr-validation") next_actions.append("ensure-pr-validation")
@@ -2276,9 +2251,8 @@ class GiteaConnector:
), ),
"validationState": ( "validationState": (
"pr_check_missing" "pr_check_missing"
if merge_eligibility["reason"] in { if merge_eligibility["reason"]
"GITEA_PR_CHECK_MISSING", "GITEA_PR_VALIDATION_REQUIRED" in {"GITEA_PR_CHECK_MISSING", "GITEA_PR_VALIDATION_REQUIRED"}
}
else "pr_check_pending" else "pr_check_pending"
if merge_eligibility["reason"] == "GITEA_PR_CHECK_PENDING" if merge_eligibility["reason"] == "GITEA_PR_CHECK_PENDING"
else "pr_check_failed" else "pr_check_failed"
@@ -3924,7 +3898,10 @@ class GiteaConnector:
@governed_tool("gitea.pullrequest.validation.ensure") @governed_tool("gitea.pullrequest.validation.ensure")
def pull_request_validation_ensure( def pull_request_validation_ensure(
self, context: ConnectorContext, repository_ref: str, number: int, self,
context: ConnectorContext,
repository_ref: str,
number: int,
expected_head_sha: str, expected_head_sha: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Reuse or retrigger a real full pull_request run for the exact PR head.""" """Reuse or retrigger a real full pull_request run for the exact PR head."""
@@ -3942,10 +3919,8 @@ class GiteaConnector:
if str(base.get("ref", "")) != default_branch: if str(base.get("ref", "")) != default_branch:
raise GiteaError("GITEA_TARGET_INVALID", "PR does not target the default branch") raise GiteaError("GITEA_TARGET_INVALID", "PR does not target the default branch")
protection = self.client.branch_protection(repository, str(base.get("ref", ""))) protection = self.client.branch_protection(repository, str(base.get("ref", "")))
if ( if not protection.get("enable_status_check") or MANAGED_VALIDATION_CONTEXT not in (
not protection.get("enable_status_check") protection.get("status_check_contexts") or []
or MANAGED_VALIDATION_CONTEXT not in
(protection.get("status_check_contexts") or [])
): ):
raise GiteaError("GITEA_PROTECTION_DRIFT", "canonical PR check is not required") raise GiteaError("GITEA_PROTECTION_DRIFT", "canonical PR check is not required")
@@ -3958,7 +3933,8 @@ class GiteaConnector:
break break
return sorted( return sorted(
( (
self._action_run_projection(item) for item in runs self._action_run_projection(item)
for item in runs
if str(item.get("event", "")) == "pull_request" if str(item.get("event", "")) == "pull_request"
and str(item.get("head_sha", "")) == expected_head_sha and str(item.get("head_sha", "")) == expected_head_sha
and ( and (
@@ -3971,7 +3947,8 @@ class GiteaConnector:
) )
) )
), ),
key=lambda item: int(item.get("id", 0)), reverse=True, key=lambda item: int(item.get("id", 0)),
reverse=True,
) )
runs = exact_runs() runs = exact_runs()
@@ -3983,9 +3960,11 @@ class GiteaConnector:
for run in runs: for run in runs:
run_id = int(run["id"]) run_id = int(run["id"])
if ( if (
current_status and current_status.get("status") == "success" current_status
and re.search(rf"/actions/runs/{run_id}(?:/|$)", and current_status.get("status") == "success"
str(current_status.get("target_url", ""))) and re.search(
rf"/actions/runs/{run_id}(?:/|$)", str(current_status.get("target_url", ""))
)
and run.get("status") == "completed" and run.get("status") == "completed"
and run.get("conclusion") == "success" and run.get("conclusion") == "success"
and any( and any(
@@ -3993,19 +3972,35 @@ class GiteaConnector:
for job in self.client.action_run_jobs(repository, run_id) for job in self.client.action_run_jobs(repository, run_id)
) )
): ):
return self._envelope(context, { return self._envelope(
"repositoryRef": repository_ref, "pr": number, context,
"headSha": expected_head_sha, "requiredContext": MANAGED_VALIDATION_CONTEXT, {
"state": "success", "runId": run_id, "event": "pull_request", "repositoryRef": repository_ref,
"pr": number,
"headSha": expected_head_sha,
"requiredContext": MANAGED_VALIDATION_CONTEXT,
"state": "success",
"runId": run_id,
"event": "pull_request",
"reused": True, "reused": True,
}, source=f"gitea:{repository_ref}") },
source=f"gitea:{repository_ref}",
)
if run.get("status") != "completed": if run.get("status") != "completed":
return self._envelope(context, { return self._envelope(
"repositoryRef": repository_ref, "pr": number, context,
"headSha": expected_head_sha, "requiredContext": MANAGED_VALIDATION_CONTEXT, {
"state": "pending", "runId": run_id, "event": "pull_request", "repositoryRef": repository_ref,
"pr": number,
"headSha": expected_head_sha,
"requiredContext": MANAGED_VALIDATION_CONTEXT,
"state": "pending",
"runId": run_id,
"event": "pull_request",
"reused": True, "reused": True,
}, source=f"gitea:{repository_ref}") },
source=f"gitea:{repository_ref}",
)
break break
if runs: if runs:
@@ -4041,7 +4036,8 @@ class GiteaConnector:
raise GiteaError( raise GiteaError(
"GITEA_PR_REOPEN_FAILED", "GITEA_PR_REOPEN_FAILED",
"PR was closed but could not be reopened; retry the exact PR reopen", "PR was closed but could not be reopened; retry the exact PR reopen",
retryable=True, safe_to_retry=True, retryable=True,
safe_to_retry=True,
) from exc ) from exc
trigger_method = "reopen_pull_request" trigger_method = "reopen_pull_request"
run_id = 0 run_id = 0
@@ -4064,14 +4060,24 @@ class GiteaConnector:
raise GiteaError( raise GiteaError(
"GITEA_PR_RUN_NOT_VISIBLE", "GITEA_PR_RUN_NOT_VISIBLE",
"PR reopened but its validation run is not visible yet; retry ensure", "PR reopened but its validation run is not visible yet; retry ensure",
retryable=True, safe_to_retry=True, retryable=True,
safe_to_retry=True,
)
return self._envelope(
context,
{
"repositoryRef": repository_ref,
"pr": number,
"headSha": expected_head_sha,
"requiredContext": MANAGED_VALIDATION_CONTEXT,
"state": "pending",
"runId": run_id,
"event": "pull_request",
"reused": False,
"triggerMethod": trigger_method,
},
source=f"gitea:{repository_ref}",
) )
return self._envelope(context, {
"repositoryRef": repository_ref, "pr": number,
"headSha": expected_head_sha, "requiredContext": MANAGED_VALIDATION_CONTEXT,
"state": "pending", "runId": run_id, "event": "pull_request",
"reused": False, "triggerMethod": trigger_method,
}, source=f"gitea:{repository_ref}")
@governed_tool("gitea.pullrequest.close") @governed_tool("gitea.pullrequest.close")
def pull_request_close( def pull_request_close(
@@ -4212,7 +4218,10 @@ class GiteaConnector:
) )
def _require_merge_eligible( def _require_merge_eligible(
self, context: ConnectorContext, repository_ref: str, number: int, self,
context: ConnectorContext,
repository_ref: str,
number: int,
expected_head_sha: str, expected_head_sha: str,
) -> tuple[RegisteredRepository, str, int, str]: ) -> tuple[RegisteredRepository, str, int, str]:
"""Authoritative server-side PR, protection, review and exact-run gate.""" """Authoritative server-side PR, protection, review and exact-run gate."""
@@ -4281,8 +4290,7 @@ class GiteaConnector:
raise GiteaError("GITEA_BLOCKING_REVIEW", "a reviewer requested changes") raise GiteaError("GITEA_BLOCKING_REVIEW", "a reviewer requested changes")
required_approvals = max(int(protection.get("required_approvals", 0) or 0), 0) required_approvals = max(int(protection.get("required_approvals", 0) or 0), 0)
approval_count = sum( approval_count = sum(
str(item.get("state", "")).upper() == "APPROVED" str(item.get("state", "")).upper() == "APPROVED" for item in latest_reviews.values()
for item in latest_reviews.values()
) )
if approval_count < required_approvals: if approval_count < required_approvals:
raise GiteaError( raise GiteaError(
+9
View File
@@ -11,5 +11,14 @@
}, },
"engines": { "engines": {
"node": ">=24.18.1 <25" "node": ">=24.18.1 <25"
},
"pnpm": {
"overrides": {
"nanoid@3": "3.3.18",
"js-yaml@4": "4.3.2",
"brace-expansion@1": "1.1.21",
"brace-expansion@2": "2.1.7",
"vite@7>esbuild": "0.28.1"
}
} }
} }
+13 -14
View File
@@ -777,9 +777,7 @@ class GiteaClient:
), ),
) )
def reopen_pull_request( def reopen_pull_request(self, repository: RegisteredRepository, index: int) -> dict[str, Any]:
self, repository: RegisteredRepository, index: int
) -> dict[str, Any]:
return cast( return cast(
dict[str, Any], dict[str, Any],
self._request( self._request(
@@ -822,9 +820,7 @@ class GiteaClient:
return self._merge_strategy_key return self._merge_strategy_key
payload = self._request(self._path("version")) payload = self._request(self._path("version"))
version = payload.get("version") if isinstance(payload, dict) else None version = payload.get("version") if isinstance(payload, dict) else None
legacy_match = re.fullmatch( legacy_match = re.fullmatch(r"1\.(14|25|26|27)(?:\.\d+)?(?:[-+].*)?", str(version or ""))
r"1\.(14|25|26|27)(?:\.\d+)?(?:[-+].*)?", str(version or "")
)
modern_match = re.fullmatch(r"(28)(?:\.\d+){1,2}(?:[-+].*)?", str(version or "")) modern_match = re.fullmatch(r"(28)(?:\.\d+){1,2}(?:[-+].*)?", str(version or ""))
if legacy_match is None and modern_match is None: if legacy_match is None and modern_match is None:
raise GiteaError( raise GiteaError(
@@ -835,9 +831,7 @@ class GiteaClient:
), ),
) )
self._merge_strategy_key = ( self._merge_strategy_key = (
"Do" "Do" if legacy_match is not None and legacy_match.group(1) in {"14", "25"} else "do"
if legacy_match is not None and legacy_match.group(1) in {"14", "25"}
else "do"
) )
return self._merge_strategy_key return self._merge_strategy_key
@@ -1053,14 +1047,19 @@ class GiteaClient:
payload = payload.get("workflow_runs") or payload.get("runs") payload = payload.get("workflow_runs") or payload.get("runs")
return self._as_list(payload) return self._as_list(payload)
def rerun_action_run( def rerun_action_run(self, repository: RegisteredRepository, run_id: int) -> dict[str, Any]:
self, repository: RegisteredRepository, run_id: int
) -> dict[str, Any]:
return cast( return cast(
dict[str, Any], dict[str, Any],
self._request( self._request(
self._path("repos", repository.owner, repository.name, self._path(
"actions", "runs", str(run_id), "rerun"), "repos",
repository.owner,
repository.name,
"actions",
"runs",
str(run_id),
"rerun",
),
method="POST", method="POST",
), ),
) )
+4 -2
View File
@@ -100,10 +100,12 @@ class AuditEvent(BaseModel):
matched_rule_ids=trace.matched_rule_ids, matched_rule_ids=trace.matched_rule_ids,
policy_trace=tuple(item.model_dump(mode="json") for item in trace.steps), policy_trace=tuple(item.model_dump(mode="json") for item in trace.steps),
arguments_hash=hashlib.sha256(raw).hexdigest(), arguments_hash=hashlib.sha256(raw).hexdigest(),
sanitized_arguments=sanitize_arguments({ sanitized_arguments=sanitize_arguments(
{
**dict(context.arguments), **dict(context.arguments),
**({"_result": result_summary} if result_summary else {}), **({"_result": result_summary} if result_summary else {}),
}), }
),
duration_ms=min(120_000, int((time.monotonic() - started) * 1000)), duration_ms=min(120_000, int((time.monotonic() - started) * 1000)),
outcome=outcome, outcome=outcome,
error_code=error_code, error_code=error_code,
+5 -1
View File
@@ -117,7 +117,11 @@ class Governance:
) -> None: ) -> None:
self.audit.append( self.audit.append(
AuditEvent.from_context( AuditEvent.from_context(
context, trace, outcome=outcome, started=started, error_code=error_code, context,
trace,
outcome=outcome,
started=started,
error_code=error_code,
result_summary=result_summary, result_summary=result_summary,
) )
) )
+263 -579
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"pyyaml==6.0.2", "pyyaml==6.0.2",
"psycopg[binary]==3.2.10", "psycopg[binary]==3.2.10",
"pydantic==2.12.0", "pydantic==2.12.0",
"pyjwt[crypto]==2.13.0", "pyjwt[crypto]==2.15.0",
"sqlalchemy==2.0.42", "sqlalchemy==2.0.42",
"dulwich==1.2.13", "dulwich==1.2.13",
"pypdf==6.19.0", "pypdf==6.19.0",
@@ -26,6 +26,7 @@ default-groups = ["dev"]
constraint-dependencies = [ constraint-dependencies = [
"httpcore2>=2.10.0", "httpcore2>=2.10.0",
"httpx2>=2.12.0", "httpx2>=2.12.0",
"urllib3==2.8.0",
] ]
[dependency-groups] [dependency-groups]
+2
View File
@@ -129,6 +129,7 @@ CONTROLLED_PROJECTBRAIN_WRITE_TOOLS = frozenset(
CONTROLLED_APPOPS_WRITE_TOOLS = frozenset( CONTROLLED_APPOPS_WRITE_TOOLS = frozenset(
{ {
"server.appops.self_upgrade.apply",
"server.operation.request", "server.operation.request",
"server.operation.execute", "server.operation.execute",
"server.job.cancel", "server.job.cancel",
@@ -174,6 +175,7 @@ GITEA_LIFECYCLE_TOOLS = frozenset(
) )
APPOPS_LIFECYCLE_TOOLS = frozenset( APPOPS_LIFECYCLE_TOOLS = frozenset(
{ {
"server.appops.self_upgrade.apply",
"server.operation.execute", "server.operation.execute",
"server.deployment.apply", "server.deployment.apply",
"server.deployment.rollback", "server.deployment.rollback",
+6
View File
@@ -95,6 +95,7 @@ def main() -> int:
"infrastructure/database/alembic/versions/0004_audit_retention.py", "infrastructure/database/alembic/versions/0004_audit_retention.py",
"tests/test_auth.py", "tests/test_auth.py",
"tests/test_catalog.py", "tests/test_catalog.py",
"tests/test_catalog_write_validation.py",
"tests/test_dataplane_router.py", "tests/test_dataplane_router.py",
"tests/test_knowledge_connector.py", "tests/test_knowledge_connector.py",
"tests/test_gitea_connector.py", "tests/test_gitea_connector.py",
@@ -125,6 +126,11 @@ def main() -> int:
"tests/test_wp230_session_store.py", "tests/test_wp230_session_store.py",
"tests/test_wp270_functional_operations.py", "tests/test_wp270_functional_operations.py",
"tests/test_wp280_runtime_quality.py", "tests/test_wp280_runtime_quality.py",
"tests/test_operator_workspaces.py",
"tests/test_operator_search.py",
"tests/test_machine_client_provisioning.py",
"tests/test_client_lifecycle_audit.py",
"tests/test_e2e_harness.py",
"tests/e2e/harness.py", "tests/e2e/harness.py",
"tests/e2e/tls.py", "tests/e2e/tls.py",
"tests/test_promotion_preflight.py", "tests/test_promotion_preflight.py",
+2
View File
@@ -23,6 +23,7 @@ against every route.
| tool.invoke | | | X | X | | tool.invoke | | | X | X |
| operations.check | | | X | X | | operations.check | | | X | X |
| client.bootstrap | | | X | X | | client.bootstrap | | | X | X |
| client.verify | | | X | X |
| client.disable | | | X | X | | client.disable | | | X | X |
| client.revoke | | | X | X | | client.revoke | | | X | X |
| client.create | | | | X | | client.create | | | | X |
@@ -62,6 +63,7 @@ ENDPOINT_PERMISSIONS: Mapping[str, frozenset[ManagementRole]] = {
"operations.check": frozenset({_R.OPERATOR, _R.ADMIN}), "operations.check": frozenset({_R.OPERATOR, _R.ADMIN}),
"client.read": frozenset({_R.VIEWER, _R.AUDITOR, _R.OPERATOR, _R.ADMIN}), "client.read": frozenset({_R.VIEWER, _R.AUDITOR, _R.OPERATOR, _R.ADMIN}),
"client.bootstrap": frozenset({_R.OPERATOR, _R.ADMIN}), "client.bootstrap": frozenset({_R.OPERATOR, _R.ADMIN}),
"client.verify": frozenset({_R.OPERATOR, _R.ADMIN}),
"client.disable": frozenset({_R.OPERATOR, _R.ADMIN}), "client.disable": frozenset({_R.OPERATOR, _R.ADMIN}),
"client.revoke": frozenset({_R.OPERATOR, _R.ADMIN}), "client.revoke": frozenset({_R.OPERATOR, _R.ADMIN}),
"client.create": frozenset({_R.ADMIN}), "client.create": frozenset({_R.ADMIN}),
+4 -1
View File
@@ -150,6 +150,7 @@ class CatalogLoader:
documents: list[CatalogDocument] = [] documents: list[CatalogDocument] = []
source_files: list[str] = [] source_files: list[str] = []
seen: set[tuple[str, str]] = set() seen: set[tuple[str, str]] = set()
schema_store: dict[str, Any] | None = None
if not self.root.is_dir(): if not self.root.is_dir():
return ValidationReport( return ValidationReport(
valid=False, valid=False,
@@ -191,7 +192,9 @@ class CatalogLoader:
continue continue
issues.extend(self._secret_issues(raw, path, [])) issues.extend(self._secret_issues(raw, path, []))
schema = self._schema(schema_name) schema = self._schema(schema_name)
resolver = RefResolver.from_schema(schema, store=self._schema_store()) if schema_store is None:
schema_store = self._schema_store()
resolver = RefResolver.from_schema(schema, store=schema_store)
for error in sorted( for error in sorted(
Draft202012Validator(schema, resolver=resolver).iter_errors(raw), Draft202012Validator(schema, resolver=resolver).iter_errors(raw),
key=lambda item: list(item.path), key=lambda item: list(item.path),
+44 -3
View File
@@ -9,7 +9,13 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from packages.hub_clients import BootstrapHandle, ClientConfig, ClientKind, ClientRecord from packages.hub_clients import (
BootstrapHandle,
ClientConfig,
ClientKind,
ClientRecord,
ClientStatus,
)
from .service import ClientService from .service import ClientService
@@ -50,6 +56,9 @@ def build_client_router(
*, *,
authenticate: Callable[[Request], Any] = _deny_by_default, authenticate: Callable[[Request], Any] = _deny_by_default,
authorize: Callable[[Any, str], None] = _deny_all_actions, authorize: Callable[[Any, str], None] = _deny_all_actions,
onboarding_status: Callable[[UUID], dict[str, Any]] | None = None,
verify_onboarding: Callable[[UUID, Any], dict[str, Any]] | None = None,
transition_client: Callable[[UUID, ClientStatus, Any], ClientRecord] | None = None,
prefix: str = "/clients", prefix: str = "/clients",
token_endpoint: str = "https://auth.example.invalid/application/o/token/", # noqa: S107 token_endpoint: str = "https://auth.example.invalid/application/o/token/", # noqa: S107
oauth_client_id: str = "itworx-mcp-hub", oauth_client_id: str = "itworx-mcp-hub",
@@ -112,6 +121,28 @@ def build_client_router(
except KeyError as exc: except KeyError as exc:
raise HTTPException(status_code=404, detail="client not found") from exc raise HTTPException(status_code=404, detail="client not found") from exc
@router.get("/{client_id}/onboarding-status")
def onboarding(client_id: UUID, principal: Any = authenticated) -> dict[str, Any]:
authorize(principal, "client.read")
if onboarding_status is None:
raise HTTPException(status_code=404, detail="client onboarding status is unavailable")
try:
return onboarding_status(client_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail="onboarding client not found") from exc
@router.post("/{client_id}/verify")
def verify(client_id: UUID, principal: Any = authenticated) -> dict[str, Any]:
authorize(principal, "client.verify")
if verify_onboarding is None:
raise HTTPException(
status_code=404, detail="client onboarding verification is unavailable"
)
try:
return verify_onboarding(client_id, principal)
except KeyError as exc:
raise HTTPException(status_code=404, detail="onboarding client not found") from exc
@router.post("/{client_id}/bootstrap", response_model=BootstrapHandle) @router.post("/{client_id}/bootstrap", response_model=BootstrapHandle)
def bootstrap(client_id: UUID, principal: Any = authenticated) -> BootstrapHandle: def bootstrap(client_id: UUID, principal: Any = authenticated) -> BootstrapHandle:
authorize(principal, "client.bootstrap") authorize(principal, "client.bootstrap")
@@ -126,15 +157,25 @@ def build_client_router(
def disable(client_id: UUID, principal: Any = authenticated) -> ClientRecord: def disable(client_id: UUID, principal: Any = authenticated) -> ClientRecord:
authorize(principal, "client.disable") authorize(principal, "client.disable")
try: try:
return service.disable(client_id) return (
transition_client(client_id, ClientStatus.DISABLED, principal)
if transition_client is not None
else service.disable(client_id)
)
except KeyError as exc: except KeyError as exc:
raise HTTPException(status_code=404, detail="client not found") from exc raise HTTPException(status_code=404, detail="client not found") from exc
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post("/{client_id}/revoke", response_model=ClientRecord) @router.post("/{client_id}/revoke", response_model=ClientRecord)
def revoke(client_id: UUID, principal: Any = authenticated) -> ClientRecord: def revoke(client_id: UUID, principal: Any = authenticated) -> ClientRecord:
authorize(principal, "client.revoke") authorize(principal, "client.revoke")
try: try:
return service.revoke(client_id) return (
transition_client(client_id, ClientStatus.REVOKED, principal)
if transition_client is not None
else service.revoke(client_id)
)
except KeyError as exc: except KeyError as exc:
raise HTTPException(status_code=404, detail="client not found") from exc raise HTTPException(status_code=404, detail="client not found") from exc
@@ -8,6 +8,8 @@ from uuid import NAMESPACE_URL, UUID, uuid5
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from packages.hub_clients import ClientKind
from .models import ( from .models import (
ClientBindingRow, ClientBindingRow,
ClientRow, ClientRow,
@@ -114,6 +116,12 @@ def provision_machine_client(
or by_id.status != "pending" or by_id.status != "pending"
): ):
raise ValueError("existing Hub client is not an activatable pending identity") raise ValueError("existing Hub client is not an activatable pending identity")
try:
kind = ClientKind(existing_metadata.get("kind", ClientKind.STDIO_BRIDGE.value))
except (TypeError, ValueError) as exc:
raise ValueError("pending machine client has an invalid client kind") from exc
else:
kind = ClientKind.STDIO_BRIDGE
client = principal or by_id client = principal or by_id
metadata = { metadata = {
"managedBy": "authentik-machine-provisioning", "managedBy": "authentik-machine-provisioning",
@@ -121,7 +129,7 @@ def provision_machine_client(
"name": name, "name": name,
"project": project_slug, "project": project_slug,
"environment": environment_name, "environment": environment_name,
"kind": "stdio-bridge", "kind": kind.value,
"oauthUsername": oauth_username, "oauthUsername": oauth_username,
"credentialReference": f"authentik://service-account/{oauth_username}", "credentialReference": f"authentik://service-account/{oauth_username}",
"operatorDisabled": False, "operatorDisabled": False,
+55 -6
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@@ -14,7 +16,9 @@ from packages.hub_clients import (
ClientStatus, ClientStatus,
generate_config, generate_config,
) )
from services.control_api.auth.models import AuthenticatedPrincipal
from services.control_api.persistence.models import ( from services.control_api.persistence.models import (
AuditEventRow,
ClientBindingRow, ClientBindingRow,
ClientRow, ClientRow,
EnvironmentRow, EnvironmentRow,
@@ -158,13 +162,21 @@ class PersistentClientService:
selected = record if kind is None else record.model_copy(update={"kind": kind}) selected = record if kind is None else record.model_copy(update={"kind": kind})
return generate_config(selected, public_host=self.public_host) return generate_config(selected, public_host=self.public_host)
def _transition(self, client_id: UUID, status: ClientStatus) -> ClientRecord: def transition(
self, client_id: UUID, status: ClientStatus, actor: AuthenticatedPrincipal | None = None
) -> ClientRecord:
now = datetime.now(UTC) now = datetime.now(UTC)
with self.sessions() as session, session.begin(): with self.sessions() as session, session.begin():
row = session.get(ClientRow, client_id) row = session.scalar(
select(ClientRow)
.join(WorkspaceRow, WorkspaceRow.id == ClientRow.workspace_id)
.where(ClientRow.id == client_id, WorkspaceRow.slug == "itworx")
)
if row is None: if row is None:
raise KeyError(client_id) raise KeyError(client_id)
metadata = dict(row.record_metadata or {}) metadata = dict(row.record_metadata or {})
if row.status == "revoked" and status is ClientStatus.DISABLED:
raise ValueError("revoked clients cannot be changed to disabled")
metadata["operatorDisabled"] = True metadata["operatorDisabled"] = True
if status is ClientStatus.DISABLED: if status is ClientStatus.DISABLED:
metadata["disabledAt"] = now.isoformat() metadata["disabledAt"] = now.isoformat()
@@ -173,18 +185,55 @@ class PersistentClientService:
row.status = "revoked" row.status = "revoked"
row.revoked_at = now row.revoked_at = now
row.record_metadata = metadata row.record_metadata = metadata
for binding in session.scalars( bindings = session.scalars(
select(ClientBindingRow).where(ClientBindingRow.client_id == client_id) select(ClientBindingRow).where(ClientBindingRow.client_id == client_id)
).all(): ).all()
for binding in bindings:
binding.enabled = False binding.enabled = False
scope_binding = bindings[0] if len(bindings) == 1 else None
scope_server = (
session.get(VirtualServerRow, scope_binding.virtual_server_id)
if scope_binding is not None
else None
)
session.flush() session.flush()
action = "client.disable" if status is ClientStatus.DISABLED else "client.revoke"
arguments = {"clientId": str(client_id), "action": action}
session.add(
AuditEventRow(
id=uuid4(),
occurred_at=now,
trace_id=str(uuid4()),
workspace_id=row.workspace_id,
project_id=scope_binding.project_id if scope_binding else None,
environment_id=scope_server.environment_id if scope_server else None,
virtual_server_id=scope_server.id if scope_server else None,
client_id=row.id,
event_type=action,
tool_name=action,
principal_issuer=(actor.issuer if actor is not None else self.issuer)[:512],
principal_subject=(
actor.subject if actor is not None else "runtime-client-management"
)[:256],
policy_decision="allow",
matched_rule_ids=[action],
policy_trace=[{"action": action, "decision": "allow"}],
arguments_hash=hashlib.sha256(
json.dumps(arguments, sort_keys=True).encode("utf-8")
).hexdigest(),
sanitized_arguments=arguments,
duration_ms=0,
outcome="success",
retention_until=now + timedelta(days=30),
)
)
return self._record(row) return self._record(row)
def disable(self, client_id: UUID) -> ClientRecord: def disable(self, client_id: UUID) -> ClientRecord:
return self._transition(client_id, ClientStatus.DISABLED) return self.transition(client_id, ClientStatus.DISABLED)
def revoke(self, client_id: UUID) -> ClientRecord: def revoke(self, client_id: UUID) -> ClientRecord:
return self._transition(client_id, ClientStatus.REVOKED) return self.transition(client_id, ClientStatus.REVOKED)
def bootstrap(self, client_id: UUID) -> BootstrapHandle: def bootstrap(self, client_id: UUID) -> BootstrapHandle:
record = self._get(client_id) record = self._get(client_id)
+4 -4
View File
@@ -677,9 +677,7 @@ def build_server() -> MCPServer[Any]:
) )
async def portfolio_governance_apply( async def portfolio_governance_apply(
context: Context[Any, Any], context: Context[Any, Any],
plans: Annotated[ plans: Annotated[list[PortfolioGovernanceApplyPlan], Field(min_length=1, max_length=5)],
list[PortfolioGovernanceApplyPlan], Field(min_length=1, max_length=5)
],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Apply at most five exact governance plans; every item is independently verified.""" """Apply at most five exact governance plans; every item is independently verified."""
return connector.portfolio_governance_apply( return connector.portfolio_governance_apply(
@@ -813,7 +811,9 @@ def build_server() -> MCPServer[Any]:
structured_output=True, structured_output=True,
) )
async def pull_request_validation_ensure( async def pull_request_validation_ensure(
repositoryRef: str, number: int, expectedHeadSha: str, repositoryRef: str,
number: int,
expectedHeadSha: str,
context: Context[Any, Any], context: Context[Any, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Ensure a real canonical full PR run for the exact current head.""" """Ensure a real canonical full PR run for the exact current head."""
File diff suppressed because it is too large. Load diff
+29 -4
View File
@@ -11,8 +11,10 @@ authorization, and the real policy engine -- not a mocked frontend:
-> fake ContextForge (in-process MockTransport; nothing the -> fake ContextForge (in-process MockTransport; nothing the
browser talks to directly, so no real HTTP needed here) browser talks to directly, so no real HTTP needed here)
Each subcommand below blocks running one of these processes; Playwright's The worker readiness dependency is an explicit offline fixture; worker reconciliation
`webServer` config (apps/web/playwright.config.ts) starts all three and is validated by separate integration tests. Each subcommand below blocks running
one of these processes; Playwright's
`webServer` config (apps/web/playwright.config.ts) starts all four and
waits for each to answer its readiness probe before running any test. waits for each to answer its readiness probe before running any test.
Persona switching: `OIDCSettings`/the real `/auth/login` route were left Persona switching: `OIDCSettings`/the real `/auth/login` route were left
@@ -37,6 +39,7 @@ if str(ROOT) not in sys.path:
import httpx # noqa: E402 import httpx # noqa: E402
import uvicorn # noqa: E402 import uvicorn # noqa: E402
from fastapi import FastAPI # noqa: E402
from services.control_api.auth.config import OIDCSettings # noqa: E402 from services.control_api.auth.config import OIDCSettings # noqa: E402
from services.control_api.auth.fake_provider import FakeOIDCProvider # noqa: E402 from services.control_api.auth.fake_provider import FakeOIDCProvider # noqa: E402
@@ -124,13 +127,29 @@ def run_control_api(port: int, oidc_port: int, web_port: int) -> None:
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning") uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
def run_web(port: int, control_port: int, dist_root: Path) -> None: def worker_readiness_fixture() -> FastAPI:
"""Offline readiness dependency; worker reconciliation has separate integration tests."""
app = FastAPI()
@app.get("/health/ready")
def ready() -> dict[str, str]:
return {"status": "ready", "component": "e2e-worker-fixture"}
return app
def run_worker(port: int) -> None:
uvicorn.run(worker_readiness_fixture(), host="127.0.0.1", port=port, log_level="warning")
def run_web(port: int, control_port: int, worker_port: int, dist_root: Path) -> None:
if not (dist_root / "index.html").is_file(): if not (dist_root / "index.html").is_file():
raise SystemExit( raise SystemExit(
f"{dist_root} has no built frontend; " f"{dist_root} has no built frontend; "
"run `pnpm --filter @itworx-mcp-hub/web build` first" "run `pnpm --filter @itworx-mcp-hub/web build` first"
) )
os.environ["HUB_CONTROL_URL"] = f"http://127.0.0.1:{control_port}" os.environ["HUB_CONTROL_URL"] = f"http://127.0.0.1:{control_port}"
os.environ["HUB_WORKER_URL"] = f"http://127.0.0.1:{worker_port}"
os.environ["HUB_WEB_ROOT"] = str(dist_root) os.environ["HUB_WEB_ROOT"] = str(dist_root)
tls = generate_local_tls_material(STATE_DIR / "tls-web") tls = generate_local_tls_material(STATE_DIR / "tls-web")
uvicorn.run( uvicorn.run(
@@ -158,15 +177,21 @@ def main() -> None:
web_parser = sub.add_parser("web") web_parser = sub.add_parser("web")
web_parser.add_argument("--port", type=int, required=True) web_parser.add_argument("--port", type=int, required=True)
web_parser.add_argument("--control-port", type=int, required=True) web_parser.add_argument("--control-port", type=int, required=True)
web_parser.add_argument("--worker-port", type=int, required=True)
web_parser.add_argument("--dist", type=Path, required=True) web_parser.add_argument("--dist", type=Path, required=True)
worker_parser = sub.add_parser("worker")
worker_parser.add_argument("--port", type=int, required=True)
args = parser.parse_args() args = parser.parse_args()
if args.command == "oidc": if args.command == "oidc":
run_oidc(args.port) run_oidc(args.port)
elif args.command == "control-api": elif args.command == "control-api":
run_control_api(args.port, args.oidc_port, args.web_port) run_control_api(args.port, args.oidc_port, args.web_port)
elif args.command == "web": elif args.command == "web":
run_web(args.port, args.control_port, args.dist) run_web(args.port, args.control_port, args.worker_port, args.dist)
elif args.command == "worker":
run_worker(args.port)
if __name__ == "__main__": if __name__ == "__main__":
+7 -14
View File
@@ -33,14 +33,8 @@ def test_appops_tool_catalog_is_bijective_and_server_namespaced() -> None:
PUBLIC_TO_UPSTREAM["server.appops.self_upgrade.preflight"] PUBLIC_TO_UPSTREAM["server.appops.self_upgrade.preflight"]
== "appops.self_upgrade.preflight" == "appops.self_upgrade.preflight"
) )
assert ( assert PUBLIC_TO_UPSTREAM["server.appops.self_upgrade.apply"] == "appops.self_upgrade.apply"
PUBLIC_TO_UPSTREAM["server.appops.self_upgrade.apply"] assert PUBLIC_TO_UPSTREAM["server.appops.self_upgrade.status"] == "appops.self_upgrade.status"
== "appops.self_upgrade.apply"
)
assert (
PUBLIC_TO_UPSTREAM["server.appops.self_upgrade.status"]
== "appops.self_upgrade.status"
)
def test_every_public_appops_tool_has_governance_metadata() -> None: def test_every_public_appops_tool_has_governance_metadata() -> None:
@@ -60,9 +54,7 @@ def test_appops_self_upgrade_manifests_are_fixed_target_and_fingerprint_bound()
for path in (ROOT / "catalog" / "tools").glob("*.yaml"): for path in (ROOT / "catalog" / "tools").glob("*.yaml"):
document = yaml.safe_load(path.read_text(encoding="utf-8")) document = yaml.safe_load(path.read_text(encoding="utf-8"))
spec = document.get("spec") if isinstance(document, dict) else None spec = document.get("spec") if isinstance(document, dict) else None
if isinstance(spec, dict) and spec.get("canonicalName", "").startswith( if isinstance(spec, dict) and spec.get("canonicalName", "").startswith("server.appops."):
"server.appops."
):
specs[spec["canonicalName"]] = spec specs[spec["canonicalName"]] = spec
assert set(specs) == { assert set(specs) == {
@@ -84,9 +76,10 @@ def test_appops_self_upgrade_manifests_are_fixed_target_and_fingerprint_bound()
apply = specs["server.appops.self_upgrade.apply"] apply = specs["server.appops.self_upgrade.apply"]
assert apply["riskClass"] == "R3" assert apply["riskClass"] == "R3"
assert apply["readOnly"] is False assert apply["readOnly"] is False
assert apply["inputSchema"]["properties"]["expected_preflight_fingerprint"][ assert (
"pattern" apply["inputSchema"]["properties"]["expected_preflight_fingerprint"]["pattern"]
] == "^[0-9a-f]{64}$" == "^[0-9a-f]{64}$"
)
assert { assert {
"expected_preflight_fingerprint", "expected_preflight_fingerprint",
"idempotency_key", "idempotency_key",
+17
View File
@@ -76,6 +76,11 @@ class BootstrapTests(unittest.TestCase):
"apps/web/src/rbac-context.tsx", "apps/web/src/rbac-context.tsx",
"apps/web/src/navigation.ts", "apps/web/src/navigation.ts",
"apps/web/src/routing.ts", "apps/web/src/routing.ts",
"apps/web/src/operator-data.ts",
"apps/web/src/preferences.ts",
"apps/web/src/schema-input.ts",
"apps/web/src/status-refresh.ts",
"apps/web/src/tool-history.ts",
"apps/web/src/components/Brand.tsx", "apps/web/src/components/Brand.tsx",
"apps/web/src/components/Breadcrumbs.tsx", "apps/web/src/components/Breadcrumbs.tsx",
"apps/web/src/components/CommandMenu.tsx", "apps/web/src/components/CommandMenu.tsx",
@@ -88,9 +93,13 @@ class BootstrapTests(unittest.TestCase):
"apps/web/src/components/Modal.tsx", "apps/web/src/components/Modal.tsx",
"apps/web/src/components/SessionMenu.tsx", "apps/web/src/components/SessionMenu.tsx",
"apps/web/src/components/StatusPill.tsx", "apps/web/src/components/StatusPill.tsx",
"apps/web/src/components/RecentToolRuns.tsx",
"apps/web/src/components/SchemaInputFields.tsx",
"apps/web/src/components/modal-focus.ts",
"apps/web/src/pages/AuthGateway.tsx", "apps/web/src/pages/AuthGateway.tsx",
"apps/web/src/pages/CatalogWorkflow.tsx", "apps/web/src/pages/CatalogWorkflow.tsx",
"apps/web/src/pages/ClientOnboarding.tsx", "apps/web/src/pages/ClientOnboarding.tsx",
"apps/web/src/pages/ConnectorDetail.tsx",
"apps/web/src/pages/Dashboard.tsx", "apps/web/src/pages/Dashboard.tsx",
"apps/web/src/pages/ForbiddenPanel.tsx", "apps/web/src/pages/ForbiddenPanel.tsx",
"apps/web/src/pages/Operations.tsx", "apps/web/src/pages/Operations.tsx",
@@ -102,6 +111,10 @@ class BootstrapTests(unittest.TestCase):
"apps/web/tests/contracts.test.ts", "apps/web/tests/contracts.test.ts",
"apps/web/tests/api.test.ts", "apps/web/tests/api.test.ts",
"apps/web/tests/rbac.test.ts", "apps/web/tests/rbac.test.ts",
"apps/web/tests/operator-data.test.ts",
"apps/web/tests/preferences.test.ts",
"apps/web/tests/schema-input.test.ts",
"apps/web/tests/tool-history.test.ts",
"apps/web/tests/e2e/fixtures.ts", "apps/web/tests/e2e/fixtures.ts",
"apps/web/tests/e2e/auth.spec.ts", "apps/web/tests/e2e/auth.spec.ts",
"apps/web/tests/e2e/rbac.spec.ts", "apps/web/tests/e2e/rbac.spec.ts",
@@ -110,6 +123,10 @@ class BootstrapTests(unittest.TestCase):
"apps/web/tests/e2e/navigation.spec.ts", "apps/web/tests/e2e/navigation.spec.ts",
"apps/web/tests/e2e/accessibility.spec.ts", "apps/web/tests/e2e/accessibility.spec.ts",
"apps/web/tests/e2e/visual-quality.spec.ts", "apps/web/tests/e2e/visual-quality.spec.ts",
"apps/web/tests/e2e/operator-matrix.spec.ts",
"apps/web/tests/e2e/operator-shell.spec.ts",
"apps/web/tests/e2e/operator-review.spec.ts",
"apps/web/tests/e2e/operator-workflows.spec.ts",
"apps/web/src/main.ts", "apps/web/src/main.ts",
"apps/web/tests/bootstrap.test.ts", "apps/web/tests/bootstrap.test.ts",
"apps/web/vite.config.ts", "apps/web/vite.config.ts",
+54
View File
@@ -5,6 +5,7 @@ import json
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from typing import Any
from uuid import uuid4 from uuid import uuid4
from fastapi import FastAPI from fastapi import FastAPI
@@ -30,6 +31,59 @@ def adapter_for(fake: FakeContextForge) -> ContextForgeClient:
class CatalogLoaderTests(unittest.TestCase): class CatalogLoaderTests(unittest.TestCase):
def test_schema_store_loads_once_per_catalog_load_without_changing_validation(self) -> None:
class CountingCatalogLoader(CatalogLoader):
store_loads = 0
def _schema_store(self) -> dict[str, Any]:
self.store_loads += 1
return super()._schema_store()
loader = CountingCatalogLoader(ROOT / "catalog")
report = loader.load()
self.assertTrue(report.valid, report.issues)
self.assertEqual(len(report.documents), 281)
self.assertEqual(report.issues, ())
self.assertEqual(loader.store_loads, 1)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "catalog"
project_dir = root / "projects"
project_dir.mkdir(parents=True)
payload = json.loads(
(ROOT / "catalog/projects/general-infrastructure.yaml").read_text(encoding="utf-8")
)
payload["spec"]["virtualServers"][0]["endpointAlias"] = "not-an-alias"
payload["spec"]["capabilityPacks"] = []
payload["spec"]["connectorBindings"] = []
payload["spec"]["credentialToken"] = "plain-secret-value"
(project_dir / "bad.yaml").write_text(json.dumps(payload), encoding="utf-8")
invalid_loader = CountingCatalogLoader(root, schema_root=ROOT / "contracts/schemas")
invalid_report = invalid_loader.load()
self.assertFalse(invalid_report.valid)
self.assertEqual(
{
(issue.code, issue.source.file, issue.source.pointer)
for issue in invalid_report.issues
},
{
(
"schema_error",
"projects/bad.yaml",
"/spec/virtualServers/0/endpointAlias",
),
("schema_error", "projects/bad.yaml", "/spec/capabilityPacks"),
("schema_error", "projects/bad.yaml", "/spec"),
("secret_value", "projects/bad.yaml", "/spec/credentialToken"),
},
)
self.assertEqual(invalid_loader.store_loads, 1)
self.assertNotIn(
"plain-secret-value",
json.dumps([document.payload for document in invalid_report.documents]),
)
def test_committed_catalog_validates_without_network_or_secret_values(self) -> None: def test_committed_catalog_validates_without_network_or_secret_values(self) -> None:
report = CatalogLoader(ROOT / "catalog").load() report = CatalogLoader(ROOT / "catalog").load()
self.assertTrue(report.valid, report.issues) self.assertTrue(report.valid, report.issues)
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from scripts import validate_pack
ROOT = Path(__file__).resolve().parents[1]
@pytest.mark.parametrize(
("canonical_name", "risk", "accepted"),
[
("server.appops.self_upgrade.apply", "R3", True),
("server.appops.self_upgrade.apply", "R2", False),
("server.appops.arbitrary.apply", "R3", False),
],
)
def test_catalog_accepts_only_the_approved_exact_risk_write(
monkeypatch: pytest.MonkeyPatch, canonical_name: str, risk: str, accepted: bool
) -> None:
tool_path = ROOT / "catalog/tools/server-appops-self-upgrade-apply.yaml"
policy_path = next((ROOT / "catalog/policies").glob("*global-deny*"))
tool = validate_pack.load_json(tool_path)
tool["spec"]["canonicalName"] = canonical_name
tool["spec"]["riskClass"] = risk
policy = validate_pack.load_json(policy_path)
def read_documents(_errors: list[str]) -> list[tuple[Path, Any]]:
return [(tool_path, tool), (policy_path, policy)]
monkeypatch.setattr(validate_pack, "read_catalog_documents", read_documents)
errors: list[str] = []
validate_pack.validate_catalog(errors, [])
if accepted:
assert errors == []
else:
assert any("invalid risk class" in error for error in errors)
if canonical_name.endswith("arbitrary.apply"):
assert any("not an approved controlled write tool" in error for error in errors)
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
import pytest
from sqlalchemy import event, select
from packages.hub_clients import ClientKind, ClientStatus
from services.control_api.auth.models import AuthenticatedPrincipal, PrincipalKind
from services.control_api.persistence.models import AuditEventRow, ClientBindingRow, ClientRow
from services.runtime.client_service import PersistentClientService
pytest_plugins = ("tests.test_machine_client_provisioning",)
def _actor() -> AuthenticatedPrincipal:
now = datetime.now(UTC)
return AuthenticatedPrincipal(
subject="operator-audit-fixture",
issuer="https://identity.fixture.invalid",
audience=("hub-fixture",),
kind=PrincipalKind.MANAGEMENT_USER,
issued_at=now,
expires_at=now + timedelta(minutes=5),
)
def _active_client(database: Any) -> tuple[PersistentClientService, Any]:
service = PersistentClientService(
database, issuer="https://identity.fixture.invalid", public_host="mcp.fixture.invalid"
)
record, _, _ = service.create(
name="Audit fixture", project="ludarium", environment="prod", kind=ClientKind.STDIO_BRIDGE
)
with database() as session, session.begin():
row = session.get(ClientRow, record.id)
assert row is not None
row.status = "active"
binding = session.scalar(
select(ClientBindingRow).where(ClientBindingRow.client_id == record.id)
)
assert binding is not None
binding.enabled = True
return service, record.id
def test_client_disable_and_revoke_are_scoped_and_append_actor_audits(database: Any) -> None:
service, client_id = _active_client(database)
actor = _actor()
disabled = service.transition(client_id, ClientStatus.DISABLED, actor)
assert disabled.status is ClientStatus.DISABLED
revoked = service.transition(client_id, ClientStatus.REVOKED, actor)
assert revoked.status is ClientStatus.REVOKED
with database() as session:
binding = session.scalar(
select(ClientBindingRow).where(ClientBindingRow.client_id == client_id)
)
assert binding is not None and binding.enabled is False
audits = session.scalars(
select(AuditEventRow)
.where(AuditEventRow.client_id == client_id)
.order_by(AuditEventRow.occurred_at)
).all()
assert [row.event_type for row in audits] == ["client.disable", "client.revoke"]
for row in audits:
assert row.principal_subject == actor.subject
assert row.principal_issuer == actor.issuer
assert row.project_id == binding.project_id
assert row.virtual_server_id == binding.virtual_server_id
assert row.environment_id is not None
assert row.policy_decision == "allow" and row.outcome == "success"
assert row.sanitized_arguments == {"clientId": str(client_id), "action": row.event_type}
assert row.arguments_hash is not None and len(row.arguments_hash) == 64
assert audits[0].trace_id != audits[1].trace_id
with pytest.raises(ValueError, match="revoked"):
service.transition(client_id, ClientStatus.DISABLED, actor)
with database() as session:
row = session.get(ClientRow, client_id)
assert row is not None and row.status == "revoked"
assert len(session.scalars(select(AuditEventRow)).all()) == 2
@pytest.mark.parametrize("status", [ClientStatus.DISABLED, ClientStatus.REVOKED])
def test_audit_failure_rolls_back_client_and_binding_transition(
database: Any, status: ClientStatus
) -> None:
service, client_id = _active_client(database)
def reject_audit(*_: Any) -> None:
raise RuntimeError("fixture audit insert failed")
event.listen(AuditEventRow, "before_insert", reject_audit)
try:
with pytest.raises(RuntimeError, match="fixture audit insert failed"):
service.transition(client_id, status, _actor())
finally:
event.remove(AuditEventRow, "before_insert", reject_audit)
with database() as session:
row = session.get(ClientRow, client_id)
binding = session.scalar(
select(ClientBindingRow).where(ClientBindingRow.client_id == client_id)
)
assert row is not None and row.status == "active"
assert row.revoked_at is None
assert row.record_metadata["operatorDisabled"] is False
assert binding is not None and binding.enabled is True
assert session.scalars(select(AuditEventRow)).all() == []
+37
View File
@@ -0,0 +1,37 @@
"""The browser harness supplies every real web readiness dependency offline."""
from unittest.mock import patch
from fastapi.testclient import TestClient
from tests.e2e.harness import run_web, worker_readiness_fixture
def test_worker_fixture_is_explicit_and_read_only() -> None:
with TestClient(worker_readiness_fixture()) as client:
response = client.get("/health/ready")
assert response.status_code == 200
assert response.json() == {"status": "ready", "component": "e2e-worker-fixture"}
assert client.post("/health/ready").status_code == 405
def test_web_harness_configures_both_upstream_dependencies(tmp_path) -> None:
(tmp_path / "index.html").write_text("<html></html>", encoding="utf-8")
with (
patch("tests.e2e.harness.os.environ", {}),
patch("tests.e2e.harness.web_runtime.build_app") as build,
patch("tests.e2e.harness.generate_local_tls_material") as tls,
patch("tests.e2e.harness.uvicorn.run") as serve,
):
import os
def inspect_dependencies():
assert os.environ["HUB_CONTROL_URL"] == "http://127.0.0.1:8781"
assert os.environ["HUB_WORKER_URL"] == "http://127.0.0.1:8783"
return "configured-app"
build.side_effect = inspect_dependencies
tls.return_value.cert_path = tmp_path / "cert.pem"
tls.return_value.key_path = tmp_path / "key.pem"
run_web(8780, 8781, 8783, tmp_path)
assert serve.call_args.args == ("configured-app",)
+11 -19
View File
@@ -267,8 +267,7 @@ class GiteaConnectorTests(unittest.TestCase):
"Jens/projectbrain", "Jens/projectbrain",
PROJECTBRAIN_VALIDATION_WORKFLOW.replace( PROJECTBRAIN_VALIDATION_WORKFLOW.replace(
" - name: Validate repository with a bounded profile\n", " - name: Validate repository with a bounded profile\n",
" - name: Validate repository with a bounded profile\n" " - name: Validate repository with a bounded profile\n if: false\n",
" if: false\n",
), ),
) )
) )
@@ -293,14 +292,11 @@ class GiteaConnectorTests(unittest.TestCase):
bypass_mutations = ( bypass_mutations = (
PROJECTBRAIN_VALIDATION_WORKFLOW.replace( PROJECTBRAIN_VALIDATION_WORKFLOW.replace(
" pnpm test:e2e:real\n", " pnpm test:e2e:real\n",
" cat <<'EOF'\n" " cat <<'EOF'\n pnpm test:e2e:real\n EOF\n",
" pnpm test:e2e:real\n"
" EOF\n",
), ),
PROJECTBRAIN_VALIDATION_WORKFLOW.replace( PROJECTBRAIN_VALIDATION_WORKFLOW.replace(
" pnpm --dir backend test:database\n", " pnpm --dir backend test:database\n",
" exit 0\n" " exit 0\n pnpm --dir backend test:database\n",
" pnpm --dir backend test:database\n",
), ),
PROJECTBRAIN_VALIDATION_WORKFLOW.replace( PROJECTBRAIN_VALIDATION_WORKFLOW.replace(
" pnpm test:e2e:real\n", " pnpm test:e2e:real\n",
@@ -357,9 +353,7 @@ class GiteaConnectorTests(unittest.TestCase):
"Jens/projectbrain", PROJECTBRAIN_VALIDATION_WORKFLOW + "\njobs: {}\n" "Jens/projectbrain", PROJECTBRAIN_VALIDATION_WORKFLOW + "\njobs: {}\n"
) )
) )
self.assertIsNone( self.assertIsNone(_managed_validation_profile("Acme/hub", PROJECTBRAIN_VALIDATION_WORKFLOW))
_managed_validation_profile("Acme/hub", PROJECTBRAIN_VALIDATION_WORKFLOW)
)
self.assertEqual( self.assertEqual(
_managed_validation_profile("Acme/hub", MANAGED_VALIDATION_WORKFLOW), _managed_validation_profile("Acme/hub", MANAGED_VALIDATION_WORKFLOW),
"canonical", "canonical",
@@ -1400,9 +1394,7 @@ class GiteaConnectorTests(unittest.TestCase):
fixture._branch_protections["Jens/projectbrain"][0]["status_check_contexts"] = [ fixture._branch_protections["Jens/projectbrain"][0]["status_check_contexts"] = [
"Pull request / quality (pull_request)" "Pull request / quality (pull_request)"
] ]
original_protection = copy.deepcopy( original_protection = copy.deepcopy(fixture._branch_protections["Jens/projectbrain"][0])
fixture._branch_protections["Jens/projectbrain"][0]
)
try: try:
current = context() current = context()
preview = connector_instance.branch_protection_preview( preview = connector_instance.branch_protection_preview(
@@ -1467,9 +1459,9 @@ class GiteaConnectorTests(unittest.TestCase):
nonlocal read_count nonlocal read_count
read_count += 1 read_count += 1
if read_count == 3: if read_count == 3:
fixture._branch_protections["Jens/projectbrain"][0][ fixture._branch_protections["Jens/projectbrain"][0]["status_check_contexts"].remove(
"status_check_contexts" "Additional required check"
].remove("Additional required check") )
return original_read(repository) return original_read(repository)
try: try:
@@ -1704,9 +1696,9 @@ class GiteaConnectorTests(unittest.TestCase):
nonlocal read_count nonlocal read_count
read_count += 1 read_count += 1
if read_count == 3: if read_count == 3:
fixture._branch_protections["Jens/projectbrain"][0][ fixture._branch_protections["Jens/projectbrain"][0]["status_check_contexts"].remove(
"status_check_contexts" "Additional required check"
].remove("Additional required check") )
return original_read(repository) return original_read(repository)
try: try:
+67 -6
View File
@@ -4,6 +4,7 @@ from uuid import uuid4
import pytest import pytest
from packages.hub_clients import ClientKind, generate_config
from services.control_api.persistence.db import Base, create_engine_from_url, session_factory from services.control_api.persistence.db import Base, create_engine_from_url, session_factory
from services.control_api.persistence.machine_client import provision_machine_client from services.control_api.persistence.machine_client import provision_machine_client
from services.control_api.persistence.models import ( from services.control_api.persistence.models import (
@@ -14,6 +15,7 @@ from services.control_api.persistence.models import (
VirtualServerRow, VirtualServerRow,
WorkspaceRow, WorkspaceRow,
) )
from services.runtime.client_service import PersistentClientService
@pytest.fixture @pytest.fixture
@@ -114,8 +116,19 @@ def test_revoked_machine_client_cannot_be_silently_reactivated(database) -> None
_provision(database) _provision(database)
def test_pending_ui_client_is_activated_in_place(database) -> None: # type: ignore[no-untyped-def] @pytest.mark.parametrize("profile", [*ClientKind, None])
def test_pending_ui_client_is_activated_in_place_with_selected_profile(
database, profile: ClientKind | None
) -> None: # type: ignore[no-untyped-def]
pending_id = uuid4() pending_id = uuid4()
expected_profile = profile or ClientKind.STDIO_BRIDGE
metadata = {
"managedBy": "runtime-onboarding",
"project": "ludarium",
"environment": "prod",
}
if profile is not None:
metadata["kind"] = profile.value
with database() as session, session.begin(): with database() as session, session.begin():
workspace = session.query(WorkspaceRow).filter_by(slug="itworx").one() workspace = session.query(WorkspaceRow).filter_by(slug="itworx").one()
project = session.query(ProjectRow).filter_by(slug="ludarium").one() project = session.query(ProjectRow).filter_by(slug="ludarium").one()
@@ -127,11 +140,7 @@ def test_pending_ui_client_is_activated_in_place(database) -> None: # type: ign
subject=f"pending-{pending_id}", subject=f"pending-{pending_id}",
issuer="https://auth.itworx.test/application/o/itworx-mcp-hub/", issuer="https://auth.itworx.test/application/o/itworx-mcp-hub/",
status="pending", status="pending",
record_metadata={ record_metadata=metadata,
"managedBy": "runtime-onboarding",
"project": "ludarium",
"environment": "prod",
},
) )
) )
session.flush() session.flush()
@@ -162,3 +171,55 @@ def test_pending_ui_client_is_activated_in_place(database) -> None: # type: ign
assert client.status == "active" assert client.status == "active"
assert client.subject == "b7f5d1aa-f199-4334-b077-f6be0870115f" assert client.subject == "b7f5d1aa-f199-4334-b077-f6be0870115f"
assert binding.enabled is True assert binding.enabled is True
assert client.record_metadata["kind"] == expected_profile.value
assert generate_config(PersistentClientService._record(client)).kind is expected_profile
def test_pending_ui_client_rejects_unknown_profile(database) -> None: # type: ignore[no-untyped-def]
pending_id = uuid4()
with database() as session, session.begin():
workspace = session.query(WorkspaceRow).filter_by(slug="itworx").one()
project = session.query(ProjectRow).filter_by(slug="ludarium").one()
server = session.query(VirtualServerRow).filter_by(slug="ludarium-prod-readonly").one()
session.add(
ClientRow(
id=pending_id,
workspace_id=workspace.id,
subject=f"pending-{pending_id}",
issuer="https://auth.itworx.test/application/o/itworx-mcp-hub/",
status="pending",
record_metadata={
"managedBy": "runtime-onboarding",
"project": "ludarium",
"environment": "prod",
"kind": "arbitrary-profile",
},
)
)
session.flush()
session.add(
ClientBindingRow(
workspace_id=workspace.id,
project_id=project.id,
client_id=pending_id,
virtual_server_id=server.id,
enabled=False,
)
)
with database() as session:
with pytest.raises(ValueError, match="invalid client kind"):
with session.begin():
provision_machine_client(
session,
issuer="https://auth.itworx.test/application/o/itworx-mcp-hub/",
subject="b7f5d1aa-f199-4334-b077-f6be0870115f",
oauth_username="svc-mcp-codex-ludarium",
name="Codex workstation",
project_slug="ludarium",
client_id=pending_id,
)
with database() as session:
client = session.get(ClientRow, pending_id)
assert client is not None
assert client.status == "pending"
assert client.record_metadata["kind"] == "arbitrary-profile"
+194
View File
@@ -0,0 +1,194 @@
from __future__ import annotations
from pathlib import Path
from uuid import uuid4
from services.control_api.persistence import create_engine_from_url, session_factory
from services.control_api.persistence.models import (
AuditEventRow,
EnvironmentRow,
ProjectRow,
WorkspaceRow,
)
from tests.test_wp230_rbac import ManagementRole, RoleSession
pytest_plugins = ("tests.test_wp230_rbac",)
def test_resource_search_matches_connector_slug(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
response = role_sessions[ManagementRole.VIEWER].get(
"/api/connectors", params={"q": "knowledge-default"}
)
assert response.status_code == 200
body = response.json()
assert [row["id"] for row in body["rows"]] == ["knowledge-default"]
def test_project_scoped_connector_search_uses_bindings_and_environment(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
viewer = role_sessions[ManagementRole.VIEWER]
response = viewer.get(
"/api/connectors",
params={
"project": "ludarium",
"environment": "prod",
"q": "knowledge-default",
},
)
assert response.status_code == 200
rows = response.json()["rows"]
assert len(rows) == 1
assert rows[0]["id"] == "knowledge-default"
assert rows[0]["project"] == "ludarium"
assert rows[0]["environment"] == "prod"
assert not any("url" in key.casefold() for key in rows[0])
unbound_project = viewer.get(
"/api/connectors",
params={
"project": "mobilityops",
"environment": "prod",
"q": "knowledge-default",
},
)
assert unbound_project.status_code == 200
assert unbound_project.json()["rows"] == []
def test_resource_search_matches_tool_canonical_name(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
response = role_sessions[ManagementRole.VIEWER].get(
"/api/tools", params={"q": "deployment.project.status"}
)
assert response.status_code == 200
body = response.json()
assert [row["canonicalName"] for row in body["rows"]] == ["deployment.project.status"]
def test_project_scoped_tool_search_uses_bindings_and_capability_packs(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
viewer = role_sessions[ManagementRole.VIEWER]
ludarium_tools = viewer.get(
"/api/tools",
params={"project": "ludarium", "limit": 100},
)
assert ludarium_tools.status_code == 200
ludarium_body = ludarium_tools.json()
ludarium_rows = ludarium_body["rows"]
assert ludarium_body["page"]["total"] == len(ludarium_rows)
ludarium_names = {row["canonicalName"] for row in ludarium_rows}
assert "deployment.project.status" in ludarium_names
assert "gitea.branch.delete" not in ludarium_names
assert all(row["project"] == "ludarium" for row in ludarium_rows)
assert all(row["environment"] == "prod" for row in ludarium_rows)
unbound_connector = viewer.get(
"/api/tools",
params={
"project": "mobilityops",
"environment": "prod",
"q": "deployment.project.status",
},
)
assert unbound_connector.status_code == 200
assert unbound_connector.json()["rows"] == []
environment_without_server = viewer.get(
"/api/tools",
params={
"project": "forgeflow",
"environment": "dev",
"q": "deployment.project.status",
},
)
assert environment_without_server.status_code == 200
assert environment_without_server.json()["rows"] == []
def test_audit_search_matches_trace_id_without_exposing_search_to_viewers(
role_sessions: dict[ManagementRole, RoleSession], db_path: Path
) -> None:
trace_id = f"operator-search-{uuid4()}"
engine = create_engine_from_url(f"sqlite:///{db_path}")
sessions = session_factory(engine)
with sessions() as session, session.begin():
workspace = session.query(WorkspaceRow).filter_by(slug="itworx").one()
project = session.query(ProjectRow).filter_by(slug="ludarium").one()
environment = (
session.query(EnvironmentRow).filter_by(project_id=project.id, name="prod").one()
)
session.add(
AuditEventRow(
trace_id=trace_id,
workspace_id=workspace.id,
project_id=project.id,
environment_id=environment.id,
event_type="operator.search.fixture",
tool_name="operator.search.fixture",
policy_decision="must-not-be-a-search-field",
outcome="success",
)
)
engine.dispose()
auditor = role_sessions[ManagementRole.AUDITOR]
matching = auditor.get("/api/audit", params={"q": trace_id})
assert matching.status_code == 200
assert [row["traceId"] for row in matching.json()["rows"]] == [trace_id]
excluded_field = auditor.get("/api/audit", params={"q": "must-not-be-a-search-field"})
assert excluded_field.status_code == 200
assert excluded_field.json()["rows"] == []
denied = role_sessions[ManagementRole.VIEWER].get("/api/audit", params={"q": trace_id})
assert denied.status_code == 403
def test_servers_list_virtual_server_identities_and_apply_project_environment_scope(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
viewer = role_sessions[ManagementRole.VIEWER]
scoped = viewer.get(
"/api/servers",
params={"project": "forgeflow", "environment": "prod", "limit": 1},
)
assert scoped.status_code == 200
scoped_body = scoped.json()
assert scoped_body["page"]["total"] == 1
assert scoped_body["rows"] == [
{
"id": "forgeflow-prod-readonly",
"name": "forgeflow-prod-readonly",
"status": "unknown",
"summary": "Catalog registered; live runtime health has not been observed.",
"project": "forgeflow",
"environment": "prod",
"risk": None,
"canonicalName": None,
}
]
wrong_environment = viewer.get(
"/api/servers", params={"project": "forgeflow", "environment": "dev"}
)
assert wrong_environment.status_code == 200
assert wrong_environment.json()["page"]["total"] == 0
assert wrong_environment.json()["rows"] == []
wrong_project = viewer.get(
"/api/servers",
params={"project": "mobilityops", "environment": "prod", "q": "forgeflow-prod-readonly"},
)
assert wrong_project.status_code == 200
assert wrong_project.json()["page"]["total"] == 0
assert wrong_project.json()["rows"] == []
+519
View File
@@ -0,0 +1,519 @@
from __future__ import annotations
import os
from collections.abc import Iterator
from contextlib import ExitStack
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from http.cookies import SimpleCookie
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
from uuid import UUID, uuid4
import pytest
from fastapi.routing import APIRoute
from fastapi.testclient import TestClient
from services.control_api.auth.config import OIDCSettings
from services.control_api.auth.fake_provider import FakeOIDCProvider
from services.control_api.auth.models import (
AuthenticatedPrincipal,
ManagementRole,
PrincipalKind,
)
from services.control_api.contextforge.fake import FakeContextForge
from services.control_api.contextforge.models import ContextForgeSettings
from services.control_api.persistence import Base, create_engine_from_url, session_factory
from services.control_api.persistence.machine_client import provision_machine_client
from services.control_api.persistence.models import (
AuditEventRow,
ClientBindingRow,
ClientRow,
ConnectorRow,
EnvironmentRow,
ProjectRow,
VirtualServerRow,
WorkspaceRow,
)
from services.runtime import control_api
from services.runtime.control_api import build_app
from services.runtime.state_sync import sync_runtime_state
ROOT = Path(__file__).resolve().parents[1]
ISSUER = "https://fake.operator-workspaces.test"
ROLE_GROUPS = {
ManagementRole.VIEWER: "hub-viewer",
ManagementRole.OPERATOR: "hub-operator",
ManagementRole.ADMIN: "hub-admin",
}
@dataclass
class RoleSession:
app: Any
client: TestClient
csrf: str
def get(self, path: str) -> Any:
return self.client.get(path)
def post(self, path: str, **kwargs: Any) -> Any:
headers = dict(kwargs.pop("headers", {}) or {})
headers["X-CSRF-Token"] = self.csrf
return self.client.post(path, headers=headers, **kwargs)
@pytest.fixture(scope="module", autouse=True)
def client_bindings_env() -> Iterator[None]:
previous_bindings = os.environ.get("MCP_CLIENT_BINDINGS_JSON")
previous_token = os.environ.get("OPERATOR_WORKSPACES_FAKE_CONTEXTFORGE_TOKEN")
os.environ["MCP_CLIENT_BINDINGS_JSON"] = "[]"
os.environ["OPERATOR_WORKSPACES_FAKE_CONTEXTFORGE_TOKEN"] = "operator-workspaces-fake-token" # noqa: S105
yield
if previous_bindings is None:
os.environ.pop("MCP_CLIENT_BINDINGS_JSON", None)
else:
os.environ["MCP_CLIENT_BINDINGS_JSON"] = previous_bindings
if previous_token is None:
os.environ.pop("OPERATOR_WORKSPACES_FAKE_CONTEXTFORGE_TOKEN", None)
else:
os.environ["OPERATOR_WORKSPACES_FAKE_CONTEXTFORGE_TOKEN"] = previous_token
@pytest.fixture(scope="module")
def db_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
path = tmp_path_factory.mktemp("operator-workspaces") / "hub.db"
engine = create_engine_from_url(f"sqlite:///{path}")
Base.metadata.create_all(engine)
sessions = session_factory(engine)
with sessions() as session, session.begin():
sync_runtime_state(session, ROOT / "catalog", ISSUER)
engine.dispose()
return path
def _login(client: TestClient, provider: FakeOIDCProvider) -> str:
login = client.get("/auth/login", follow_redirects=False)
query = parse_qs(urlparse(login.headers["location"]).query)
code = provider.authorize(login.headers["location"])
callback = client.get(
"/auth/callback",
params={"code": code, "state": query["state"][0]},
follow_redirects=False,
)
cookie = SimpleCookie()
cookie.load(callback.headers["set-cookie"])
cookie_name = next(iter(cookie))
client.cookies.set(cookie_name, cookie[cookie_name].value)
return str(client.get("/auth/session").json()["csrf_token"])
@pytest.fixture(scope="module")
def role_sessions(db_path: Path) -> Iterator[dict[ManagementRole, RoleSession]]:
with ExitStack() as stack:
result: dict[ManagementRole, RoleSession] = {}
for role, group in ROLE_GROUPS.items():
provider = FakeOIDCProvider(issuer=ISSUER, user_groups=(group,))
oidc = OIDCSettings(
issuer=ISSUER,
audience=provider.audience,
client_id="operator-workspaces-hub",
redirect_uri="https://hub.test/auth/callback",
secure_cookie=True,
role_group_map={value: key for key, value in ROLE_GROUPS.items()},
)
engine = create_engine_from_url(f"sqlite:///{db_path}")
app = build_app(
oidc=oidc,
provider_transport=provider.transport(),
contextforge_settings=ContextForgeSettings(
base_url="https://contextforge.test",
auth_token_env="OPERATOR_WORKSPACES_FAKE_CONTEXTFORGE_TOKEN", # noqa: S106
),
contextforge_transport=FakeContextForge().transport(),
engine=engine,
catalog_root=ROOT / "catalog",
)
client = stack.enter_context(TestClient(app))
csrf = _login(client, provider)
result[role] = RoleSession(app=app, client=client, csrf=csrf)
yield result
def _ready_probe(_: str) -> dict[str, Any]:
return {"reachable": True, "status": "ready", "detail": "Direct connector readiness verified."}
def test_project_workspace_returns_registered_server_and_source_metadata(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
response = role_sessions[ManagementRole.VIEWER].get("/api/projects/ludarium")
assert response.status_code == 200, response.text
body = response.json()
assert body["mcpServers"]
assert body["mcpServers"][0]["endpointAlias"] == "/mcp/ludarium/prod"
assert body["mcpServers"][0]["stateSource"] in {"persisted", "catalog-only"}
assert body["knowledgeSources"]
source = body["knowledgeSources"][0]
assert source["uri"] == "discovery://repository/ludarium"
assert source["indexesSecrets"] is False
assert "scoped Knowledge connector" in source["accessBoundary"]
assert "README*" in source["include"]
assert "credentialReferences" not in response.text
def test_connector_workspace_is_scoped_and_secret_free(
role_sessions: dict[ManagementRole, RoleSession], monkeypatch: pytest.MonkeyPatch
) -> None:
probe_calls: list[str] = []
def record_probe(slug: str) -> dict[str, Any]:
probe_calls.append(slug)
return _ready_probe(slug)
monkeypatch.setattr(control_api, "_direct_connector_readiness", record_probe)
viewer = role_sessions[ManagementRole.VIEWER]
response = viewer.get("/api/connectors/knowledge-default?project=ludarium&environment=prod")
assert response.status_code == 200, response.text
body = response.json()
assert body["id"] == "knowledge-default"
assert body["connectorType"] == "knowledge"
assert body["registration"]["state"] == "registered"
assert body["readiness"]["source"] in {"persisted", "unavailable"}
if body["readiness"]["source"] == "unavailable":
assert body["readiness"]["observedAt"] is None
assert probe_calls == []
assert body["bindings"] == [
{"project": "ludarium", "environment": "prod", "verification": "verified"}
]
assert body["relatedTools"]
for sensitive_key in ("credentialReferences", "credentialReference", "endpoint", "token"):
assert sensitive_key not in body
assert "credentialReferences" not in response.text
assert "service_token" not in response.text
assert (
viewer.get("/api/connectors/knowledge-default?project=missing&environment=prod").status_code
== 404
)
assert (
viewer.get(
"/api/connectors/knowledge-default?project=ludarium&environment=staging"
).status_code
== 404
)
assert viewer.get("/api/connectors/knowledge-default?project=ludarium").status_code == 422
def test_connector_workspace_marks_old_persisted_health_unknown_without_probing(
role_sessions: dict[ManagementRole, RoleSession],
db_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
probe_calls: list[str] = []
def record_probe(slug: str) -> dict[str, Any]:
probe_calls.append(slug)
return _ready_probe(slug)
monkeypatch.setattr(control_api, "_direct_connector_readiness", record_probe)
engine = create_engine_from_url(f"sqlite:///{db_path}")
sessions = session_factory(engine)
observed_at = datetime.now(UTC) - timedelta(minutes=5)
with sessions() as session, session.begin():
workspace = session.query(WorkspaceRow).filter_by(slug="itworx").one()
project = (
session.query(ProjectRow).filter_by(workspace_id=workspace.id, slug="ludarium").one()
)
environment = (
session.query(EnvironmentRow)
.filter_by(workspace_id=workspace.id, project_id=project.id, name="prod")
.one()
)
connector = (
session.query(ConnectorRow)
.filter_by(
workspace_id=workspace.id,
project_id=project.id,
environment_scope="prod",
type="knowledge",
slug="knowledge-default",
)
.one_or_none()
)
if connector is None:
connector = ConnectorRow(
id=uuid4(),
workspace_id=workspace.id,
project_id=project.id,
environment_id=environment.id,
environment_scope="prod",
type="knowledge",
slug="knowledge-default",
)
session.add(connector)
connector.health_state = "healthy"
connector.last_check_at = observed_at
connector.endpoint = "https://knowledge.example.invalid/health"
connector.credential_reference = "vault://private-reference"
engine.dispose()
response = role_sessions[ManagementRole.VIEWER].get(
"/api/connectors/knowledge-default?project=ludarium&environment=prod"
)
assert response.status_code == 200, response.text
readiness = response.json()["readiness"]
assert readiness["source"] == "persisted"
assert readiness["stale"] is True
assert readiness["reachable"] is None
assert readiness["status"] == "unknown"
assert readiness["observedAt"]
assert "Stale observation" in readiness["detail"]
assert "knowledge.example.invalid" not in response.text
assert "vault://private-reference" not in response.text
assert probe_calls == []
def test_connector_and_onboarding_routes_deny_machine_principals_even_with_admin_role(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
app = role_sessions[ManagementRole.VIEWER].app
route = next(
route
for route in app.routes
if isinstance(route, APIRoute) and route.path == "/api/connectors/{slug}"
)
dependency_calls: list[Any] = []
def visit(dependant: Any) -> None:
if dependant.call is not None:
dependency_calls.append(dependant.call)
for child in dependant.dependencies:
visit(child)
visit(route.dependant)
authenticate = next(
call for call in dependency_calls if getattr(call, "__name__", "") == "authenticate"
)
now = datetime.now(UTC)
machine = AuthenticatedPrincipal(
subject="machine-connector-reader",
issuer=ISSUER,
audience=("operator-workspaces-hub",),
kind=PrincipalKind.MACHINE_CLIENT,
client_id=uuid4(),
roles=frozenset({ManagementRole.ADMIN}),
expires_at=now + timedelta(minutes=5),
issued_at=now,
)
app.dependency_overrides[authenticate] = lambda: machine
try:
client = role_sessions[ManagementRole.VIEWER].client
connector = client.get("/api/connectors/knowledge-default")
onboarding = client.get(f"/api/client-management/{uuid4()}/onboarding-status")
finally:
app.dependency_overrides.pop(authenticate, None)
assert connector.status_code == 403
assert onboarding.status_code == 403
def test_onboarding_verification_records_server_checks_without_enabling_client_or_binding(
role_sessions: dict[ManagementRole, RoleSession], db_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
probe_calls: list[str] = []
def record_probe(slug: str) -> dict[str, Any]:
probe_calls.append(slug)
return _ready_probe(slug)
monkeypatch.setattr(control_api, "_direct_connector_readiness", record_probe)
admin = role_sessions[ManagementRole.ADMIN]
operator = role_sessions[ManagementRole.OPERATOR]
viewer = role_sessions[ManagementRole.VIEWER]
created = admin.post(
"/api/client-management",
json={
"name": "resume-me",
"project": "gitea-loop",
"environment": "prod",
"kind": "native-2026",
},
)
assert created.status_code == 201, created.text
client_id = created.json()["client"]["id"]
operator_status = operator.get(f"/api/client-management/{client_id}/onboarding-status")
assert operator_status.status_code == 200, operator_status.text
assert operator_status.json()["checks"]["connector"]["state"] == "unknown"
assert probe_calls == []
status = viewer.get(f"/api/client-management/{client_id}/onboarding-status")
assert status.status_code == 200, status.text
before = status.json()
assert before["clientStatus"] == "pending"
assert before["checks"]["externalSubject"]["state"] == "pending"
assert before["checks"]["externalScope"]["state"] == "unavailable"
assert before["checks"]["catalog"]["state"] == "ready"
assert before["checks"]["hubBinding"]["state"] == "pending"
assert before["checks"]["connector"]["state"] == "unknown"
assert before["checks"]["connector"]["observedAt"] is None
assert before["checks"]["mcpProof"]["state"] == "unavailable"
handoff = before["provisioning"]
assert handoff == {
"status": "pending-authentik-service-account",
"subject": None,
"issuer": ISSUER,
"oauthUsername": f"svc-mcp-codex-gitea-loop-{client_id.replace('-', '')[:8]}",
"tokenEndpoint": f"{ISSUER}/application/o/token/",
"oauthClientId": "operator-workspaces-hub",
"steps": [
"Provision the named Authentik service account and app password.",
"Activate this pending Hub client with the exact Authentik user UUID.",
"Install the generated Codex stdio profile using a user-only credential file.",
"Run MCP discovery, one read-only invocation and one cross-project denial.",
],
}
assert not {"credentialReference", "bootstrapReference", "credential", "secret"} & set(handoff)
assert "credentialReference" not in status.text
assert "bootstrapReference" not in status.text
assert "authentik-bootstrap://" not in status.text
assert "authentik://service-account/" not in status.text
assert probe_calls == []
denied_verify = viewer.post(f"/api/client-management/{client_id}/verify")
assert denied_verify.status_code == 403
assert probe_calls == []
checked_pending = admin.post(f"/api/client-management/{client_id}/verify")
assert checked_pending.status_code == 200, checked_pending.text
assert probe_calls
pending_verification_probe_count = len(probe_calls)
assert checked_pending.json()["verification"]["checkedByManagement"] is True
assert checked_pending.json()["verification"]["verifiedByManagement"] is False
assert checked_pending.json()["clientStatus"] == "pending"
engine = create_engine_from_url(f"sqlite:///{db_path}")
sessions = session_factory(engine)
with sessions() as session, session.begin():
server = session.query(VirtualServerRow).filter_by(slug="gitea-loop-prod").one()
server.gateway_external_id = "fixture-contextforge-server"
with sessions() as session, session.begin():
provision_machine_client(
session,
issuer=ISSUER,
subject="authentik-user-uuid-1234",
oauth_username="svc-mcp-codex-gitea-loop-test1234",
name="resume-me",
project_slug="gitea-loop",
environment_name="prod",
public_host="hub.test",
client_id=UUID(client_id),
)
resumed = viewer.get(f"/api/client-management/{client_id}/onboarding-status")
assert resumed.status_code == 200, resumed.text
resumed_body = resumed.json()
assert resumed_body["clientStatus"] == "enabled"
assert resumed_body["checks"]["externalSubject"]["state"] == "ready"
assert resumed_body["checks"]["hubBinding"]["state"] == "ready"
assert resumed_body["provisioning"]["status"] == "authentik-service-account-provisioned"
assert resumed_body["provisioning"]["subject"] == "authentik-user-uuid-1234"
assert resumed_body["provisioning"]["oauthUsername"] == "svc-mcp-codex-gitea-loop-test1234"
assert resumed_body["provisioning"]["issuer"] == ISSUER
assert resumed_body["provisioning"]["tokenEndpoint"] == f"{ISSUER}/application/o/token/"
assert resumed_body["provisioning"]["oauthClientId"] == "operator-workspaces-hub"
assert len(probe_calls) == pending_verification_probe_count
assert resumed_body["checks"]["externalScope"]["state"] == "unavailable"
assert resumed_body["checks"]["mcpProof"]["state"] == "unavailable"
with sessions() as session, session.begin():
client_row = session.get(ClientRow, UUID(client_id))
assert client_row is not None
client_row.issuer = "https://foreign-identity.example.invalid"
probes_before_foreign_issuer_read = len(probe_calls)
foreign_issuer_status = viewer.get(f"/api/client-management/{client_id}/onboarding-status")
assert foreign_issuer_status.status_code == 200, foreign_issuer_status.text
foreign_issuer_body = foreign_issuer_status.json()
assert foreign_issuer_body["checks"]["externalSubject"]["state"] == "pending"
assert foreign_issuer_body["provisioning"]["subject"] is None
assert foreign_issuer_body["verification"]["verifiedByManagement"] is False
assert len(probe_calls) == probes_before_foreign_issuer_read
foreign_issuer_verification = admin.post(f"/api/client-management/{client_id}/verify")
assert foreign_issuer_verification.status_code == 200, foreign_issuer_verification.text
assert foreign_issuer_verification.json()["checks"]["externalSubject"]["state"] == "pending"
assert foreign_issuer_verification.json()["verification"]["verifiedByManagement"] is False
assert len(probe_calls) > probes_before_foreign_issuer_read
with sessions() as session, session.begin():
client_row = session.get(ClientRow, UUID(client_id))
assert client_row is not None
client_row.issuer = ISSUER
verified = admin.post(f"/api/client-management/{client_id}/verify")
assert verified.status_code == 200, verified.text
assert len(probe_calls) > pending_verification_probe_count
after = verified.json()
assert after["verification"]["checkedByManagement"] is True
assert after["verification"]["verifiedByManagement"] is True
assert after["verification"]["hubChecksPassed"] is True
assert after["checks"]["externalScope"]["state"] == "unavailable"
assert after["checks"]["mcpProof"]["state"] == "unavailable"
verified_probe_count = len(probe_calls)
persisted_status = viewer.get(f"/api/client-management/{client_id}/onboarding-status")
assert persisted_status.status_code == 200, persisted_status.text
assert persisted_status.json()["checks"]["connector"]["state"] == "healthy"
assert persisted_status.json()["verification"]["verifiedByManagement"] is True
assert len(probe_calls) == verified_probe_count
for stale_checked_at in (
datetime.now(UTC) - timedelta(minutes=5),
datetime.now(UTC) + timedelta(minutes=5),
):
stale_timestamp = stale_checked_at.isoformat()
with sessions() as session, session.begin():
client_row = session.get(ClientRow, UUID(client_id))
assert client_row is not None
metadata = dict(client_row.record_metadata or {})
verification = dict(metadata["onboardingVerification"])
verification["checkedAt"] = stale_timestamp
metadata["onboardingVerification"] = verification
client_row.record_metadata = metadata
stale_status = viewer.get(f"/api/client-management/{client_id}/onboarding-status")
assert stale_status.status_code == 200, stale_status.text
stale_body = stale_status.json()
assert stale_body["checks"]["connector"]["state"] == "unknown"
assert stale_body["checks"]["connector"]["observedAt"] is None
assert stale_body["verification"]["checkedAt"] == stale_timestamp
assert stale_body["verification"]["checkedByManagement"] is True
assert stale_body["verification"]["verifiedByManagement"] is False
assert stale_body["verification"]["hubChecksPassed"] is False
assert len(probe_calls) == verified_probe_count
with sessions() as session:
client_row = session.get(ClientRow, UUID(client_id))
workspace = session.query(WorkspaceRow).filter_by(slug="itworx").one()
binding = session.query(ClientBindingRow).filter_by(client_id=UUID(client_id)).one()
audit_event = (
session.query(AuditEventRow)
.filter_by(client_id=UUID(client_id), event_type="client.onboarding.verify")
.order_by(AuditEventRow.occurred_at.desc())
.first()
)
engine.dispose()
assert client_row is not None and client_row.status == "active"
assert binding.workspace_id == workspace.id
assert binding.enabled is True
assert client_row.record_metadata["onboardingVerification"]["verifiedByManagement"] is True
assert "credential" not in str(client_row.record_metadata["onboardingVerification"]).lower()
assert audit_event is not None
assert audit_event.principal_subject == "user-management-1"
assert audit_event.sanitized_arguments["hubChecksPassed"] is True
def test_unauthed_client_is_not_exposed_by_connector_management_route(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
unauthenticated = TestClient(role_sessions[ManagementRole.VIEWER].app)
unauthenticated.cookies.clear()
response = unauthenticated.get("/api/connectors/knowledge-default")
assert response.status_code == 401
+18 -8
View File
@@ -47,19 +47,29 @@ def invocation(
class WP110GovernanceTests(unittest.TestCase): class WP110GovernanceTests(unittest.TestCase):
def test_merge_result_is_retained_in_append_only_audit(self) -> None: def test_merge_result_is_retained_in_append_only_audit(self) -> None:
audit = InMemoryAuditLog() audit = InMemoryAuditLog()
engine = PolicyEngine([ engine = PolicyEngine(
[
PolicyRule( PolicyRule(
slug="allow-merge", project="ludarium", priority=10, slug="allow-merge",
description="allow merge", match={"tool": "gitea.pullrequest.merge"}, project="ludarium",
priority=10,
description="allow merge",
match={"tool": "gitea.pullrequest.merge"},
decision=Decision.ALLOW, decision=Decision.ALLOW,
) )
]) ]
)
governance = Governance(engine, audit) governance = Governance(engine, audit)
evidence = { evidence = {
"repositoryRef": "test-repo", "pr": 7, "headSha": "e" * 40, "repositoryRef": "test-repo",
"baseSha": "a" * 40, "requiredContext": "Managed validation / full (pull_request)", "pr": 7,
"validationRunId": 42, "validationEvent": "pull_request", "headSha": "e" * 40,
"validationConclusion": "success", "mergeMethod": "squash", "baseSha": "a" * 40,
"requiredContext": "Managed validation / full (pull_request)",
"validationRunId": 42,
"validationEvent": "pull_request",
"validationConclusion": "success",
"mergeMethod": "squash",
"resultingMainSha": "6" * 40, "resultingMainSha": "6" * 40,
} }
result = governance.invoke( result = governance.invoke(
+2
View File
@@ -93,6 +93,7 @@ def test_audit_read_denied_for_viewer_alone() -> None:
"tool.invoke", "tool.invoke",
"operations.check", "operations.check",
"client.bootstrap", "client.bootstrap",
"client.verify",
"client.disable", "client.disable",
"client.revoke", "client.revoke",
], ],
@@ -111,6 +112,7 @@ def test_client_lifecycle_actions_allowed_for_operator_and_admin(
"tool.invoke", "tool.invoke",
"operations.check", "operations.check",
"client.bootstrap", "client.bootstrap",
"client.verify",
"client.disable", "client.disable",
"client.revoke", "client.revoke",
], ],
+9 -4
View File
@@ -465,10 +465,15 @@ def test_only_reviewed_gitea_r3_lifecycle_set_is_allowed_in_production() -> None
assert loop_trace.matched_rule_ids == ("036-gitea-loop-reviewed-lifecycle-v1",) assert loop_trace.matched_rule_ids == ("036-gitea-loop-reviewed-lifecycle-v1",)
denied = engine.evaluate( denied = engine.evaluate(
InvocationContext( InvocationContext(
workspace="itworx", project="gitea-loop", environment="prod", workspace="itworx",
principal="chatgpt-gitea", tool="gitea.pullrequest.merge", project="gitea-loop",
connector="gitea", risk_class="R3", environment="prod",
environment_classification="production", binding_present=False, principal="chatgpt-gitea",
tool="gitea.pullrequest.merge",
connector="gitea",
risk_class="R3",
environment_classification="production",
binding_present=False,
client_enabled=True, client_enabled=True,
) )
) )
+9 -3
View File
@@ -204,8 +204,11 @@ def test_policy_simulate_available_to_every_role(
def test_catalog_preview_available_to_every_role( def test_catalog_preview_available_to_every_role(
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
) -> None: ) -> None:
response = role_sessions[role].post("/api/catalog/preview", json={"project": "nonexistent"}) response = role_sessions[role].post("/api/catalog/preview", json={"project": "ludarium"})
assert response.status_code == 200, (role, response.text) # type: ignore[union-attr] assert response.status_code == 200, (role, response.text) # type: ignore[union-attr]
preview = response.json() # type: ignore[union-attr]
assert preview["valid"] is True
assert preview["changes"] == ["reconcile all registered project routes, including ludarium"]
# --- Tool readiness: bounded live probe, operator+ only ------------------ # --- Tool readiness: bounded live probe, operator+ only ------------------
@@ -400,6 +403,9 @@ def test_live_operations_check_available_to_operator_and_admin(
response = role_sessions[role].post("/api/operations/check") response = role_sessions[role].post("/api/operations/check")
assert response.status_code == 200, (role, response.text) # type: ignore[union-attr] assert response.status_code == 200, (role, response.text) # type: ignore[union-attr]
assert response.json()["connectors"] # type: ignore[union-attr] assert response.json()["connectors"] # type: ignore[union-attr]
assert {"id": "knowledge-default", "name": "Knowledge Default"} in response.json()[
"connectors"
]["itworx-knowledge"]["connectorRefs"] # type: ignore[union-attr]
# --- Audit read: auditor+ only, viewer denied --------------------------- # --- Audit read: auditor+ only, viewer denied ---------------------------
@@ -457,7 +463,7 @@ def test_client_create_allowed_for_admin(role_sessions: dict[ManagementRole, Rol
assert response.status_code == 201, response.text # type: ignore[union-attr] assert response.status_code == 201, response.text # type: ignore[union-attr]
@pytest.mark.parametrize("action", ["bootstrap", "disable", "revoke"]) @pytest.mark.parametrize("action", ["bootstrap", "verify", "disable", "revoke"])
@pytest.mark.parametrize("role", [ManagementRole.VIEWER, ManagementRole.AUDITOR]) @pytest.mark.parametrize("role", [ManagementRole.VIEWER, ManagementRole.AUDITOR])
def test_client_lifecycle_actions_denied_below_operator( def test_client_lifecycle_actions_denied_below_operator(
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole, action: str role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole, action: str
@@ -470,7 +476,7 @@ def test_client_lifecycle_actions_denied_below_operator(
assert response.status_code == 403, (role, action, response.text) # type: ignore[union-attr] assert response.status_code == 403, (role, action, response.text) # type: ignore[union-attr]
@pytest.mark.parametrize("action", ["bootstrap", "disable"]) @pytest.mark.parametrize("action", ["bootstrap", "verify", "disable"])
def test_client_lifecycle_actions_allowed_for_operator( def test_client_lifecycle_actions_allowed_for_operator(
role_sessions: dict[ManagementRole, RoleSession], action: str role_sessions: dict[ManagementRole, RoleSession], action: str
) -> None: ) -> None:
+6 -10
View File
@@ -237,24 +237,20 @@ def test_dashboard_drift_is_measured_against_managed_connector_gateways(
assert "connector gateway drift" in drift["detail"].lower() assert "connector gateway drift" in drift["detail"].lower()
def test_dashboard_project_and_connector_health_matches_resource_list_not_hardcoded_unknown( def test_dashboard_distinguishes_registration_from_connector_readiness(
viewer_client: TestClient, viewer_client: TestClient,
) -> None: ) -> None:
"""The dashboard overview used to hardcode "unknown" for every project """A valid registration is not evidence that a connector route is live."""
and connector regardless of catalog state, while the /api/resource
list endpoint (GET /api/projects, /api/connectors) already computed a
real "healthy"/"unknown" signal from catalog registration validity --
the two surfaces visually disagreed. Both now share
_catalog_registration_health(); this proves the dashboard reflects it
too, not just the resource list.
"""
dashboard = viewer_client.get("/api/dashboard") dashboard = viewer_client.get("/api/dashboard")
assert dashboard.status_code == 200 assert dashboard.status_code == 200
body = dashboard.json() body = dashboard.json()
assert body["projects"], "expected at least one registered project in the test catalog" assert body["projects"], "expected at least one registered project in the test catalog"
assert body["connectors"], "expected at least one registered connector in the test catalog" assert body["connectors"], "expected at least one registered connector in the test catalog"
assert all(item["health"] == "healthy" for item in body["projects"]) assert all(item["health"] == "healthy" for item in body["projects"])
assert all(item["state"] == "healthy" for item in body["connectors"]) assert all(item["state"] == "unknown" for item in body["connectors"])
assert all(item["registrationState"] == "registered" for item in body["connectors"])
assert all(item["readiness"]["source"] == "not-probed" for item in body["connectors"])
assert all(item["readiness"]["observedAt"] is None for item in body["connectors"])
projects_list = viewer_client.get("/api/projects") projects_list = viewer_client.get("/api/projects")
assert projects_list.status_code == 200 assert projects_list.status_code == 200
Generated
+8 -7
View File
@@ -6,6 +6,7 @@ requires-python = "==3.13.*"
constraints = [ constraints = [
{ name = "httpcore2", specifier = ">=2.10.0" }, { name = "httpcore2", specifier = ">=2.10.0" },
{ name = "httpx2", specifier = ">=2.12.0" }, { name = "httpx2", specifier = ">=2.12.0" },
{ name = "urllib3", specifier = "==2.8.0" },
] ]
[[package]] [[package]]
@@ -382,7 +383,7 @@ requires-dist = [
{ name = "mcp", specifier = "==2.0.0" }, { name = "mcp", specifier = "==2.0.0" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.2.10" }, { name = "psycopg", extras = ["binary"], specifier = "==3.2.10" },
{ name = "pydantic", specifier = "==2.12.0" }, { name = "pydantic", specifier = "==2.12.0" },
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" }, { name = "pyjwt", extras = ["crypto"], specifier = "==2.15.0" },
{ name = "pypdf", specifier = "==6.19.0" }, { name = "pypdf", specifier = "==6.19.0" },
{ name = "pyyaml", specifier = "==6.0.2" }, { name = "pyyaml", specifier = "==6.0.2" },
{ name = "sqlalchemy", specifier = "==2.0.42" }, { name = "sqlalchemy", specifier = "==2.0.42" },
@@ -670,11 +671,11 @@ wheels = [
[[package]] [[package]]
name = "pyjwt" name = "pyjwt"
version = "2.13.0" version = "2.15.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } sdist = { url = "https://files.pythonhosted.org/packages/02/a5/5197bfd06417837ac079921c66fa6393f1dea3557272a263cebfef69e432/pyjwt-2.15.0.tar.gz", hash = "sha256:b11c5f9791d7bf51c2b39a81ed669f6b2dbbd669df2942f6c60167e9e3d1abe4", size = 120513, upload-time = "2026-09-23T16:56:00.689Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, { url = "https://files.pythonhosted.org/packages/e8/55/40e45bf052ee8ee12a4dfd785519660f8effa7b065442b91646ec6828619/pyjwt-2.15.0-py3-none-any.whl", hash = "sha256:7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8", size = 33680, upload-time = "2026-09-23T16:55:59.241Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -913,11 +914,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.7.0" version = "2.8.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
] ]
[[package]] [[package]]