Publish curated ITWorx_MCP_Hub source from 6c7434368ce2a594005983c5a6601a31a639e7f1
This commit is contained in:
1 parent
77946d62c4
commit
6d40ab975c
86 files changed
+6747
-1441
No files matched your search
@@ -1,3 +1,3 @@
|
||||
# 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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"sourceRevision": "931c7aca4392dc771dad004a65d54f2b1c430c37",
|
||||
"sourceRevision": "6c7434368ce2a594005983c5a6601a31a639e7f1",
|
||||
"files": [
|
||||
{
|
||||
"path": ".dockerignore",
|
||||
@@ -49,13 +49,13 @@
|
||||
},
|
||||
{
|
||||
"path": "apps/web/package.json",
|
||||
"sha256": "ab0984ea38b8c4f01d09694ebda943ae6aa7e477a62a9c382736ad1c70aa4a94",
|
||||
"sha256": "a2720410e2436fa772af15d884717b12f0330d0b1dbf73c9496bb046072766d0",
|
||||
"bytes": 967
|
||||
},
|
||||
{
|
||||
"path": "apps/web/playwright.config.ts",
|
||||
"sha256": "166983e0d59eccdec84161ade3e431f9dd63822a3d34f2130d7be60bf4c7cde2",
|
||||
"bytes": 1854
|
||||
"sha256": "4b82bc4dfd9d022202f2f8266d02a4aa53a3cbf908d6b413ddb2484cfc61b701",
|
||||
"bytes": 2140
|
||||
},
|
||||
{
|
||||
"path": "apps/web/public/favicon.svg",
|
||||
@@ -69,13 +69,13 @@
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/api.ts",
|
||||
"sha256": "d0aebbda4a6fd5645ce019c1462df26c041f32992dfccd775ebf9ef688143c06",
|
||||
"bytes": 4358
|
||||
"sha256": "6d0f9c3a36b206c4c970026132e8e447d29967dcaad89e788ac39a4b8ce06db1",
|
||||
"bytes": 5572
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/app.tsx",
|
||||
"sha256": "0a421dd05b1d9cb5d94a89f30086fb4af3af6b595ddc78b3bee3fa3281eeb97b",
|
||||
"bytes": 8546
|
||||
"sha256": "fe1cc1fa47fa976872907183b6bdf35272c4927b6582c5d9c2bee303503e7f0b",
|
||||
"bytes": 12190
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/bootstrap.ts",
|
||||
@@ -94,13 +94,13 @@
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/components/CommandMenu.tsx",
|
||||
"sha256": "8dc002409163a9e0ac139fc802bc9807da79d3e0161a16a91927f8248a7efe42",
|
||||
"bytes": 2329
|
||||
"sha256": "d8b66fee31037b2eb087c37a5d8db4eb26e6193bc30c6e0312825aa62bf6fbef",
|
||||
"bytes": 11392
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/components/DataTable.tsx",
|
||||
"sha256": "9fad5252ac45a4feb611e0209fccd46bef7a500f0cfaba46902bbb3a6c343895",
|
||||
"bytes": 5553
|
||||
"sha256": "ddabe3b1f83d697d22615f76de34ebccf22e8ba237ae55302fe8ce06148e0e34",
|
||||
"bytes": 6999
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/components/ErrorPanel.tsx",
|
||||
@@ -129,23 +129,38 @@
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/components/Modal.tsx",
|
||||
"sha256": "96f10e6f24a58daf6041f0d93722c464a33a6f3226bb0065bf099f0f514d1aee",
|
||||
"bytes": 2919
|
||||
"sha256": "fc049f55c84540de410bb3746455b2b8c5308fce42fc10b2165b6be411ab58fa",
|
||||
"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",
|
||||
"sha256": "08c9972cdc5d26731c5e7b8421882bc9854be9de89d876f7c91fb58004794285",
|
||||
"bytes": 1184
|
||||
"sha256": "918b41bc5503255040b8a4907a7c320b1b1a889b425858a3810594bef618d9b9",
|
||||
"bytes": 1294
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/components/StatusPill.tsx",
|
||||
"sha256": "de784a2c451c0dba4986125a5e6b794060b8bfa9b11ec9fe19b869b21a3b5e3b",
|
||||
"bytes": 298
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/components/modal-focus.ts",
|
||||
"sha256": "2bc1fc1d53c1aed3a35e73027cd0eb49e1bbbda0045106ffb69b0d7a3dfa57a9",
|
||||
"bytes": 4456
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/contracts.ts",
|
||||
"sha256": "b7c36d27a6011cd6c5660da72db0b6ff7700d7a80e8829d331f2351fd5f3d09b",
|
||||
"bytes": 7629
|
||||
"sha256": "fc7a6e3a4349860d738b19e705b6f03967d66512cd6a81bf90202ccdca73061b",
|
||||
"bytes": 10423
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/format.ts",
|
||||
@@ -154,19 +169,24 @@
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/hooks.ts",
|
||||
"sha256": "f3474ce60c82458aead1aee26f7db8e668e98dea3f25e79848e68195bedb765b",
|
||||
"bytes": 1436
|
||||
"sha256": "56b7e31485e60a9aba1725e6582d76a9a68708830482247970a3f0120a707042",
|
||||
"bytes": 1432
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/main.ts",
|
||||
"sha256": "c500632a52617540774ac856616edc585649678a475ec07d5d04d49272b8cc58",
|
||||
"bytes": 438
|
||||
"sha256": "b7f471b3399e51f1dc8eca32e7ad5efc8a867937b693ed834a9fd354ab35aed5",
|
||||
"bytes": 629
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/navigation.ts",
|
||||
"sha256": "c7d5d922ae27ded37a2f9e129e86dd04e3896b2caf31b157b71ea206be3ff8a8",
|
||||
"bytes": 1881
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/operator-data.ts",
|
||||
"sha256": "dc63e2796ac5761769f3bfe6840f81dabb3d888395204f1138078b988cf1b380",
|
||||
"bytes": 4742
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/pages/AuthGateway.tsx",
|
||||
"sha256": "929b9a368bdc8a1c289022c21798374c5a65f3bad48abcbbf4524cc604df15d6",
|
||||
@@ -174,18 +194,23 @@
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/pages/CatalogWorkflow.tsx",
|
||||
"sha256": "aba9390d623d2b6a1a3e2fc30ceea4434bf196765157c66df810f185438aafcf",
|
||||
"bytes": 3325
|
||||
"sha256": "be520658fb1b87da9ac0d0af4e34865276c484ad4659d7c6df17164b6ce0e6b0",
|
||||
"bytes": 4583
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/pages/ClientOnboarding.tsx",
|
||||
"sha256": "6bc5c21c5655771d2b461bc6725b8516e4fe85c5fa9167aa2c27a9d1d043a4fb",
|
||||
"bytes": 6758
|
||||
"sha256": "0a02c1f5b1f074571b8bb473aad16193d18b2ddbb4bebc84de3eb652c816918a",
|
||||
"bytes": 21663
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/pages/ConnectorDetail.tsx",
|
||||
"sha256": "9794b70c2b31f5590c6c179354c8b118e4e235d265adc8346c6d970a372b2967",
|
||||
"bytes": 7467
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/pages/Dashboard.tsx",
|
||||
"sha256": "a265e25ad03f3822c0d459afa8d9574cde83b6024c233cb07b6c8ad7dc25a9cb",
|
||||
"bytes": 10185
|
||||
"sha256": "37823853d25b8675ab97d8ed1b67c7d79e7e9fbe8ea3b2c5d009601414b93d29",
|
||||
"bytes": 10452
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/pages/ForbiddenPanel.tsx",
|
||||
@@ -194,8 +219,8 @@
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/pages/Operations.tsx",
|
||||
"sha256": "f197f902bd591a9accff51376ef7d8aae690b36962da0cb41774c6c786f122b6",
|
||||
"bytes": 7941
|
||||
"sha256": "e4c90be00fa09a4db9385c2bf388af8c5c4f4810c9a938ddc6dc27cd93f7a1d0",
|
||||
"bytes": 11932
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/pages/PolicySimulator.tsx",
|
||||
@@ -204,23 +229,28 @@
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/pages/ProjectDetail.tsx",
|
||||
"sha256": "562e110e7fa170df08509fde06d8dcd5666f5731c0e8427eaa5330a42ee34b27",
|
||||
"bytes": 6743
|
||||
"sha256": "70e84876db7ca02c1332afde9565a294cdc2313d09493bb4bcddf70ec4b249ae",
|
||||
"bytes": 13104
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/pages/ResourcePage.tsx",
|
||||
"sha256": "79e708edeae9d1d2978d8d195f1783403546bfdfaaadc5bc2eed0674db1ad99a",
|
||||
"bytes": 9466
|
||||
"sha256": "2a092a436b7c91514d98e4f0104497362967a13e3d7343b6b85f2d39f4d3b9dd",
|
||||
"bytes": 11672
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/pages/SettingsPage.tsx",
|
||||
"sha256": "417ea7501e9608dd8aae9d06215eb50d80cb720ee95405c5a0952135ad8bae00",
|
||||
"bytes": 2147
|
||||
"sha256": "83abb9907bfefd5572b9acdebdfcbc689afbdbf26df8114effdeaed610caa26a",
|
||||
"bytes": 2169
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/pages/ToolReadinessModal.tsx",
|
||||
"sha256": "9c8c81ec498b6b3b25d5cd58aa36709734a254f37ffceb2557c3276675479086",
|
||||
"bytes": 9157
|
||||
"sha256": "75b09cb94ce445d374a3b54e89bb2504bac92d180af20affe3009e903dc7df0b",
|
||||
"bytes": 11552
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/preferences.ts",
|
||||
"sha256": "298faf383f6a4414f98cc02e4530c2575380d1b028342a28cb2f0dad749ec67a",
|
||||
"bytes": 558
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/rbac-context.tsx",
|
||||
@@ -229,23 +259,58 @@
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/rbac.ts",
|
||||
"sha256": "d085bf7104c1c92c11cd50037c748d6ef0daa3c58a932a937a8a569908b3267c",
|
||||
"bytes": 1621
|
||||
"sha256": "35f12f86889e39f766ab1eb96f88e144f59fae61e9dc81ab0a7a5239042d3ac9",
|
||||
"bytes": 1683
|
||||
},
|
||||
{
|
||||
"path": "apps/web/src/routing.ts",
|
||||
"sha256": "4eaf247684474b126ee96d11a1d559e20a6357a1508c39f8a3b2d6aa772773e6",
|
||||
"bytes": 1149
|
||||
"sha256": "1941e048a034a5ac0e0d1d30e0759c2bc05ed58c0301fd436877e1eccd59f8bb",
|
||||
"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",
|
||||
"sha256": "8f22fd7261c86abcab916a65d14e407db7d3d1d31116a776d866099ff75ddcc9",
|
||||
"bytes": 64960
|
||||
"sha256": "e6caa3209994a4cd716a94e7b5e4b52888cbe16f8e3c38f5e9910f1c013d6ca5",
|
||||
"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",
|
||||
"sha256": "79dc9e71d364f7d359ebdc1d1b5a3b80839c5c4ea400bd6029f1314f1c85951a",
|
||||
"bytes": 5789
|
||||
"sha256": "f9b4bbb9d1337672568e7d27258c15fef52b697fab6a7a6dcdf60b0854d3b426",
|
||||
"bytes": 6405
|
||||
},
|
||||
{
|
||||
"path": "apps/web/tests/bootstrap.test.ts",
|
||||
@@ -254,8 +319,8 @@
|
||||
},
|
||||
{
|
||||
"path": "apps/web/tests/contracts.test.ts",
|
||||
"sha256": "13313c4a99971007d9c354e491ddfa23c5fbb9e09c9d40010d9288bc788a22b5",
|
||||
"bytes": 1122
|
||||
"sha256": "c315bd4d881014d286f07c5baf71377792011b2ba107064768add1362c1fd38b",
|
||||
"bytes": 1367
|
||||
},
|
||||
{
|
||||
"path": "apps/web/tests/e2e/accessibility.spec.ts",
|
||||
@@ -264,8 +329,8 @@
|
||||
},
|
||||
{
|
||||
"path": "apps/web/tests/e2e/auth.spec.ts",
|
||||
"sha256": "e73c68a37faf2c96e061d814302c768fb94a449e275873e56e57262736b54ee8",
|
||||
"bytes": 3513
|
||||
"sha256": "72abb30d223c4a847f8ce958c9c916801cf412e17b1a0c9ec56dde9448811d32",
|
||||
"bytes": 3700
|
||||
},
|
||||
{
|
||||
"path": "apps/web/tests/e2e/fixtures.ts",
|
||||
@@ -274,8 +339,28 @@
|
||||
},
|
||||
{
|
||||
"path": "apps/web/tests/e2e/navigation.spec.ts",
|
||||
"sha256": "693edec83b9d1569dbe60468921e15de559e5370fc4dda2a006b7acf3b206f06",
|
||||
"bytes": 2747
|
||||
"sha256": "3a7fd93bdef6f25795720d7fe47e5ab600158d44043e559dfd7dd579d10c7c0b",
|
||||
"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",
|
||||
@@ -289,19 +374,39 @@
|
||||
},
|
||||
{
|
||||
"path": "apps/web/tests/e2e/visual-quality.spec.ts",
|
||||
"sha256": "07ed212e6b8e9e253775672eac848b216bb71575139fecf079fb504ee4c902a7",
|
||||
"bytes": 5553
|
||||
"sha256": "dc00e990afedf58ae57deec7e02b11b4a61681d45075bf7dbc2263e75ed8e352",
|
||||
"bytes": 5685
|
||||
},
|
||||
{
|
||||
"path": "apps/web/tests/e2e/workflows.spec.ts",
|
||||
"sha256": "83122d00735fef8adea67eeb7cb6e43b74f1bbb5e90c2650d63734723445fd36",
|
||||
"bytes": 12525
|
||||
"sha256": "ccfe4e2e2f277ed6069d30376719ae7bcb94e8f1b2b2614d4612d9d409dfb4ad",
|
||||
"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",
|
||||
"sha256": "e9a2827f96cc12550cb4cccdd30ab72efb15f5eb55ae0ac11fdb26e5134990bf",
|
||||
"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",
|
||||
"sha256": "b13b349ceb0a6062cf5509591e92e98cb4a1b26822c734e935e7d4e45935047e",
|
||||
@@ -309,8 +414,8 @@
|
||||
},
|
||||
{
|
||||
"path": "apps/web/vite.config.ts",
|
||||
"sha256": "2497c38e866dbf9a64400811144a4e5afea5c099c048881c8abdaaf7051a5223",
|
||||
"bytes": 277
|
||||
"sha256": "dd03015ec0252f3de6a62aa0c01fbc5803afd5f8294f202f0bc192cfdd6c3699",
|
||||
"bytes": 309
|
||||
},
|
||||
{
|
||||
"path": "catalog/README.md",
|
||||
@@ -1639,8 +1744,8 @@
|
||||
},
|
||||
{
|
||||
"path": "connectors/gitea/fake.py",
|
||||
"sha256": "94f7349380ab0695792c7308e0b08003661834801189b5005f934dc05d828eae",
|
||||
"bytes": 30597
|
||||
"sha256": "e8012cd1b40e35f6947dcfb967ae83e3a8f85816c9a5c790704887223f093073",
|
||||
"bytes": 30667
|
||||
},
|
||||
{
|
||||
"path": "connectors/gitea/lifecycle.py",
|
||||
@@ -1654,8 +1759,8 @@
|
||||
},
|
||||
{
|
||||
"path": "connectors/gitea/server.py",
|
||||
"sha256": "d8c5be1efed22d99dc287a83fd78156e306afa731f6b233b4d076bb5736152b4",
|
||||
"bytes": 227648
|
||||
"sha256": "be5f401d9bc75d531f1d082ba8893547fa752d1fad5c22069b165f3cea64db3b",
|
||||
"bytes": 228031
|
||||
},
|
||||
{
|
||||
"path": "connectors/knowledge/__init__.py",
|
||||
@@ -2009,8 +2114,8 @@
|
||||
},
|
||||
{
|
||||
"path": "package.json",
|
||||
"sha256": "9bd1cc5653ee2aac73b71005363817d687d016668de737e77b85a5e418d9a6c1",
|
||||
"bytes": 379
|
||||
"sha256": "1f03209a7d913ba92b911cb2eddde44c34d64689761e5fdab8b3afc306e2d361",
|
||||
"bytes": 583
|
||||
},
|
||||
{
|
||||
"path": "packages/README.md",
|
||||
@@ -2034,8 +2139,8 @@
|
||||
},
|
||||
{
|
||||
"path": "packages/connector_kit/gitea.py",
|
||||
"sha256": "2a5af5c47531ba65002e11fc61893bc477aec9481b76da0e0e3b64b15ad52850",
|
||||
"bytes": 50212
|
||||
"sha256": "118e657d06142f71db40741b08d25504e30f13754bf0f67fc5f45f865cb41e2e",
|
||||
"bytes": 50270
|
||||
},
|
||||
{
|
||||
"path": "packages/connector_kit/gitea_resilient.py",
|
||||
@@ -2144,8 +2249,8 @@
|
||||
},
|
||||
{
|
||||
"path": "packages/hub_policy/audit.py",
|
||||
"sha256": "171711bd6c6045b4fc73f25600ad96e7043d2fc9de4633a464c81e7481583b1d",
|
||||
"bytes": 6191
|
||||
"sha256": "0dc272103d6cee37260b6c6cb8ef3b378b883ccdda9114627c0bee1dc4712ddb",
|
||||
"bytes": 6233
|
||||
},
|
||||
{
|
||||
"path": "packages/hub_policy/engine.py",
|
||||
@@ -2154,8 +2259,8 @@
|
||||
},
|
||||
{
|
||||
"path": "packages/hub_policy/governance.py",
|
||||
"sha256": "c600bfa0429bd12523b120462472bafe4338569e206e76532f54d18be41c16df",
|
||||
"bytes": 5874
|
||||
"sha256": "643bac3aa11de19e58ce36bc61090aa472cfd47f5f7c028d9fa32b051035cd52",
|
||||
"bytes": 5938
|
||||
},
|
||||
{
|
||||
"path": "packages/hub_policy/rate_limit.py",
|
||||
@@ -2239,8 +2344,8 @@
|
||||
},
|
||||
{
|
||||
"path": "pnpm-lock.yaml",
|
||||
"sha256": "7f80ff769f89743192d6eff6a637a4c60cb5ceaa2bbece621a7a9ac03a7caf71",
|
||||
"bytes": 90269
|
||||
"sha256": "eb333f963d85f9d33359a83eb5cbebe94a2042e5f1636778f49aa96fa5b5376a",
|
||||
"bytes": 80522
|
||||
},
|
||||
{
|
||||
"path": "pnpm-workspace.yaml",
|
||||
@@ -2249,8 +2354,8 @@
|
||||
},
|
||||
{
|
||||
"path": "pyproject.toml",
|
||||
"sha256": "f42fb743257f77af9066b85ce9a2eb633d3dfbddc10d67315a0d9f7352b4938d",
|
||||
"bytes": 857
|
||||
"sha256": "561e2da3f0a6ef04eb8681f02c67e2c352c5cc3d90e9353237991ec2132fb316",
|
||||
"bytes": 877
|
||||
},
|
||||
{
|
||||
"path": "scripts/README.md",
|
||||
@@ -2419,13 +2524,13 @@
|
||||
},
|
||||
{
|
||||
"path": "scripts/validate_pack.py",
|
||||
"sha256": "36e33bab19294b0d0353fd6af80cf9db6de03d0440d2e4d5ac750deb97492e8d",
|
||||
"bytes": 35777
|
||||
"sha256": "f77573eba9128c9920a293ed3f73e9f2307b2bc6a4505b6814ac3c950fe68712",
|
||||
"bytes": 35865
|
||||
},
|
||||
{
|
||||
"path": "scripts/verify.py",
|
||||
"sha256": "b2c39fb0f6ca2624be4f0f27db70b14f12689efb6d1b3ba3ced045712cdc4638",
|
||||
"bytes": 13732
|
||||
"sha256": "da819db31ca891d8f9f2aaad42c02728b0dd8fa9bd2801a43f1fd0e947facca8",
|
||||
"bytes": 14006
|
||||
},
|
||||
{
|
||||
"path": "scripts/verify_demo_token_flow.py",
|
||||
@@ -2504,8 +2609,8 @@
|
||||
},
|
||||
{
|
||||
"path": "services/control_api/authz.py",
|
||||
"sha256": "f4f47704949be21fbe2d7d5a515a451bde79265292628e8c4f43bf1c652a4104",
|
||||
"bytes": 5183
|
||||
"sha256": "4b363e26856c775762ab09a2a44f371021044dcc860ba80a42081ef7954bc881",
|
||||
"bytes": 5270
|
||||
},
|
||||
{
|
||||
"path": "services/control_api/catalog/__init__.py",
|
||||
@@ -2514,8 +2619,8 @@
|
||||
},
|
||||
{
|
||||
"path": "services/control_api/catalog/loader.py",
|
||||
"sha256": "6ba03597ef4b4dac2fb05b07516c5cf657622a239d38debb24482ba2f465956c",
|
||||
"bytes": 13928
|
||||
"sha256": "61b5f6651b1a33d06d50d6fdbbf341cc21cb1f94ff2069651aa4f4463b3f68e2",
|
||||
"bytes": 14060
|
||||
},
|
||||
{
|
||||
"path": "services/control_api/catalog/models.py",
|
||||
@@ -2549,8 +2654,8 @@
|
||||
},
|
||||
{
|
||||
"path": "services/control_api/clients/router.py",
|
||||
"sha256": "7f51cfc3c87925a6286118773a446e2b4886e0fcda031f2e51e31bfc7a6f5373",
|
||||
"bytes": 5785
|
||||
"sha256": "c1dbcb8decbf3aa04ac50bb727345288c1b32ec6a97e4708484707a38c83ae22",
|
||||
"bytes": 7548
|
||||
},
|
||||
{
|
||||
"path": "services/control_api/clients/service.py",
|
||||
@@ -2629,8 +2734,8 @@
|
||||
},
|
||||
{
|
||||
"path": "services/control_api/persistence/machine_client.py",
|
||||
"sha256": "ac8886cae645aaf87032aa74e59002c1036225a4f90c924aecb569a875e910dc",
|
||||
"bytes": 6051
|
||||
"sha256": "2ec55f8bf1881e0f7b2726bcfa710e1df812ec79877574838b69570ed97326e4",
|
||||
"bytes": 6384
|
||||
},
|
||||
{
|
||||
"path": "services/control_api/persistence/models.py",
|
||||
@@ -2744,8 +2849,8 @@
|
||||
},
|
||||
{
|
||||
"path": "services/runtime/client_service.py",
|
||||
"sha256": "0dcb25a3c0cd5391409e40add822e48920a4560fb9fe182e901bf045876f547c",
|
||||
"bytes": 8331
|
||||
"sha256": "73ff3a633fb0d622bacf19d6955a274f9072467e3a0a9a3456fd6432365e9058",
|
||||
"bytes": 10709
|
||||
},
|
||||
{
|
||||
"path": "services/runtime/common.py",
|
||||
@@ -2759,8 +2864,8 @@
|
||||
},
|
||||
{
|
||||
"path": "services/runtime/connector_gitea.py",
|
||||
"sha256": "c89874089d1bdde3020864e9222052d2470970ad7c4aa5677201cbfbc4a88eea",
|
||||
"bytes": 37958
|
||||
"sha256": "5218c32351530d4b44cf99f61eac1543b0668de0a737a130f7d66a88cc0e2807",
|
||||
"bytes": 37952
|
||||
},
|
||||
{
|
||||
"path": "services/runtime/connector_ingestion.py",
|
||||
@@ -2794,8 +2899,8 @@
|
||||
},
|
||||
{
|
||||
"path": "services/runtime/control_api.py",
|
||||
"sha256": "348f126c7816d28cdd1c542a438621b332f8fdbfdd432dddfbbb15b83432778e",
|
||||
"bytes": 55774
|
||||
"sha256": "5ea476237d69129634fa391ac3b5c2061bc889a4bbd83b98a1781e12c90539c2",
|
||||
"bytes": 113286
|
||||
},
|
||||
{
|
||||
"path": "services/runtime/dataplane.py",
|
||||
@@ -2889,8 +2994,8 @@
|
||||
},
|
||||
{
|
||||
"path": "tests/e2e/harness.py",
|
||||
"sha256": "c776c167b001a372906e9dca1e39a1a13a09a2fa88f8093b3cc02b99183eb98d",
|
||||
"bytes": 6538
|
||||
"sha256": "a4edee80a05beeee8e07787c7bfa4607f3f27927c87d948051eea4d07ff30287",
|
||||
"bytes": 7503
|
||||
},
|
||||
{
|
||||
"path": "tests/e2e/tls.py",
|
||||
@@ -2924,8 +3029,8 @@
|
||||
},
|
||||
{
|
||||
"path": "tests/test_appops_connector.py",
|
||||
"sha256": "4e088d187b239cd1e8b81b7c5d72aed27eddae78491678564bfe2776d37913be",
|
||||
"bytes": 9448
|
||||
"sha256": "93fea80c542f6c3bb6e490626c6b4b00db2a0238dfb0210c0edeb415abc1ca06",
|
||||
"bytes": 9388
|
||||
},
|
||||
{
|
||||
"path": "tests/test_auth.py",
|
||||
@@ -2934,13 +3039,23 @@
|
||||
},
|
||||
{
|
||||
"path": "tests/test_bootstrap.py",
|
||||
"sha256": "15dbc0d8e2499f10faf9e3eba79756fb8f4b77251fe5b5d6d5967c4dd91ef9a9",
|
||||
"bytes": 10812
|
||||
"sha256": "48b59147ceb5bf46ade6327d2e1484fad2eaa6d76294654ad98bcfbb811b500c",
|
||||
"bytes": 11767
|
||||
},
|
||||
{
|
||||
"path": "tests/test_catalog.py",
|
||||
"sha256": "adbe584172170fa17fe21c0e6a1953729e9b83190845d8e9d053b0d2cef4b1c6",
|
||||
"bytes": 16467
|
||||
"sha256": "1614357a0e3f15894f7bca4d0b7c67a6d71c13f6a372f15d8d2622abdcb34641",
|
||||
"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",
|
||||
@@ -3052,6 +3167,11 @@
|
||||
"sha256": "fb4007bd62d722e17140ac41623f374f16fb599a41f8f3ac7ca2be18cb21cab5",
|
||||
"bytes": 2395
|
||||
},
|
||||
{
|
||||
"path": "tests/test_e2e_harness.py",
|
||||
"sha256": "aba3682b8185ef4d0b1b28dc536b7cf106729fb5135f91cc790e019763335adb",
|
||||
"bytes": 1516
|
||||
},
|
||||
{
|
||||
"path": "tests/test_gitea_access_diagnostics.py",
|
||||
"sha256": "7b5c383119f017761551b28c5516eaa5b8bf5b0aeddf039d4fb4dd846c2824f3",
|
||||
@@ -3064,8 +3184,8 @@
|
||||
},
|
||||
{
|
||||
"path": "tests/test_gitea_connector.py",
|
||||
"sha256": "c6389e924471d392f1a19cae48e9c409b349223b76faa34c20b9ae4c7894ede2",
|
||||
"bytes": 128684
|
||||
"sha256": "dd61db44c16f1ecb2fcc2ac099900b3908fa7b5aa7ab397c40648795a4e2ecaa",
|
||||
"bytes": 128560
|
||||
},
|
||||
{
|
||||
"path": "tests/test_gitea_merge_eligibility.py",
|
||||
@@ -3154,8 +3274,8 @@
|
||||
},
|
||||
{
|
||||
"path": "tests/test_machine_client_provisioning.py",
|
||||
"sha256": "c1695cb3d087d58337d0abd14bdef3fec1c253648650c7a5ec98db9ac5e36aee",
|
||||
"bytes": 6140
|
||||
"sha256": "f925d9624d81035f9131e4f4f25281d27b6c59f8bb1edfa2e2c7b7dcbafb6385",
|
||||
"bytes": 8728
|
||||
},
|
||||
{
|
||||
"path": "tests/test_managed_validation_action_pins.py",
|
||||
@@ -3187,6 +3307,16 @@
|
||||
"sha256": "a8f32e6569ed3fa947ba8b714128a86610f0a186bc7a0447b3497e41ba6c8e9f",
|
||||
"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",
|
||||
"sha256": "139f399570d6123d3fb5f6d8f52eb4a47d1a2f2f72f68fd7d2b12d4bcf5a85d2",
|
||||
@@ -3244,8 +3374,8 @@
|
||||
},
|
||||
{
|
||||
"path": "tests/test_wp110_governance.py",
|
||||
"sha256": "dbb358f03c4b55060949dd728228862fba59a0e51bc45bc166b9d8062de59af4",
|
||||
"bytes": 9314
|
||||
"sha256": "721200a16676a76ef239d3966ff0f4e71dc5d54f8b3571495966a2faf8dff329",
|
||||
"bytes": 9480
|
||||
},
|
||||
{
|
||||
"path": "tests/test_wp140_clients.py",
|
||||
@@ -3294,18 +3424,18 @@
|
||||
},
|
||||
{
|
||||
"path": "tests/test_wp230_authz.py",
|
||||
"sha256": "218bd70139767573ced7f210d230ddbd2cb5e3beafff7954f9b017efdf67a64f",
|
||||
"bytes": 6696
|
||||
"sha256": "ba186a4cb0bea077feb240e6d738fa5fb907550d2b7ba45184d802c06a2b2995",
|
||||
"bytes": 6746
|
||||
},
|
||||
{
|
||||
"path": "tests/test_wp230_policy_simulate.py",
|
||||
"sha256": "f62386357ca2aadc0775fc7bba9393470ebdb85ff8f140f0f6c27fa994a1c74b",
|
||||
"bytes": 20597
|
||||
"sha256": "1c8c6a9b8711824d03903844e70104522f494f08f05bb0c55287515db5efa4ff",
|
||||
"bytes": 20697
|
||||
},
|
||||
{
|
||||
"path": "tests/test_wp230_rbac.py",
|
||||
"sha256": "63665cb5c25c4ac13bfcd9378d5db12fa4ebac804c616970862f67c51a4902d9",
|
||||
"bytes": 20973
|
||||
"sha256": "1b540347d8053040dbc8d7850fa163e3814f22c01f8619a19b4222bbff082ed8",
|
||||
"bytes": 21361
|
||||
},
|
||||
{
|
||||
"path": "tests/test_wp230_session_store.py",
|
||||
@@ -3314,8 +3444,8 @@
|
||||
},
|
||||
{
|
||||
"path": "tests/test_wp230_ui_completion.py",
|
||||
"sha256": "dacb89c17ea74db69c8cf28b3ff382f5e55d2f8952b93d4fdbd8e5aeaf7b596e",
|
||||
"bytes": 10276
|
||||
"sha256": "45718516ff3b1aef1201c0eca71c0ebf828737ecfcbe8e462e570ed0c11964c7",
|
||||
"bytes": 10132
|
||||
},
|
||||
{
|
||||
"path": "tests/test_wp270_functional_operations.py",
|
||||
@@ -3329,8 +3459,8 @@
|
||||
},
|
||||
{
|
||||
"path": "uv.lock",
|
||||
"sha256": "4e8b857f3a4b8dee50b2148a208d887ce869ee2a6851734a8911d3ef61b7677c",
|
||||
"bytes": 104010
|
||||
"sha256": "0157f642a8d62a7c0777989761bff66d0c09cb99338c1f2c2146f85bcfe3a908",
|
||||
"bytes": 104059
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,17 +19,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.11.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/node": "^26.1.2",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/node": "26.1.2",
|
||||
"@types/react": "19.1.10",
|
||||
"@types/react-dom": "19.1.7",
|
||||
"@typescript-eslint/parser": "8.39.0",
|
||||
"@vitejs/plugin-react": "4.7.0",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"eslint": "9.35.0",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"typescript": "5.9.2",
|
||||
"vite": "7.1.3",
|
||||
"vitest": "3.2.4"
|
||||
"vite": "7.3.5",
|
||||
"vitest": "4.1.11"
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const DIST = path.join(WEB_DIR, "dist");
|
||||
const WEB_PORT = 8780;
|
||||
const CONTROL_PORT = 8781;
|
||||
const OIDC_PORT = 8782;
|
||||
const WORKER_PORT = 8783;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
@@ -43,7 +44,14 @@ export default defineConfig({
|
||||
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`,
|
||||
ignoreHTTPSErrors: true,
|
||||
reuseExistingServer: false,
|
||||
|
||||
+18
-3
@@ -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 {
|
||||
readonly status: number;
|
||||
@@ -24,8 +24,9 @@ export async function logoutSession(): Promise<void> {
|
||||
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 }; },
|
||||
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)}`),
|
||||
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),
|
||||
testTool: (input) => postJson<ToolReadinessResult>("/api/tools/test", input, csrfToken),
|
||||
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),
|
||||
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),
|
||||
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."; }
|
||||
+75
-21
@@ -1,10 +1,10 @@
|
||||
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 { ApiError, createApiClient, safeApiMessage } from "./api";
|
||||
import { RbacProvider } from "./rbac-context";
|
||||
import { can } from "./rbac";
|
||||
import { basePath, resourceKind, usePath } from "./routing";
|
||||
import { basePath, connectorDetailId, resourceKind, usePath } from "./routing";
|
||||
import { Brand } from "./components/Brand";
|
||||
import { CommandMenu } from "./components/CommandMenu";
|
||||
import { ErrorPanel } from "./components/ErrorPanel";
|
||||
@@ -19,6 +19,8 @@ import { Operations } from "./pages/Operations";
|
||||
import { ProjectDetail } from "./pages/ProjectDetail";
|
||||
import { ResourcePage } from "./pages/ResourcePage";
|
||||
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 }) {
|
||||
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 [navOpen, setNavOpen] = 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 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 = () => {
|
||||
setRoles([]);
|
||||
setSnapshot(null);
|
||||
setSubject(null);
|
||||
setCreatedClient(null);
|
||||
};
|
||||
|
||||
const closeNavigation = (restoreFocus = true) => {
|
||||
setNavOpen(false);
|
||||
if (restoreFocus) window.setTimeout(() => navTriggerRef.current?.focus(), 0);
|
||||
};
|
||||
const closeNavigation = () => setNavOpen(false);
|
||||
|
||||
const load = async () => {
|
||||
setSession("checking");
|
||||
@@ -90,13 +97,45 @@ export function App({ api: apiProp }: { api?: ManagementApiClient }) {
|
||||
useEffect(() => { void load(); }, []);
|
||||
useEffect(() => { setNavOpen(false); }, [path]);
|
||||
useEffect(() => {
|
||||
if (!navOpen) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") closeNavigation();
|
||||
const media = window.matchMedia("(max-width: 840px)");
|
||||
const update = () => {
|
||||
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);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [navOpen]);
|
||||
return () => {
|
||||
document.removeEventListener("focusin", onFocusIn, true);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
restoreIsolation();
|
||||
restoreScroll();
|
||||
if (previouslyFocused?.isConnected) previouslyFocused.focus();
|
||||
};
|
||||
}, [navOpen, compactNavigation]);
|
||||
useEffect(() => {
|
||||
if (session !== "authenticated") return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -114,6 +153,9 @@ export function App({ api: apiProp }: { api?: ManagementApiClient }) {
|
||||
|
||||
const kind = resourceKind(path);
|
||||
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 active = (href: string) => basePath(path) === href || (href !== "/" && basePath(path).startsWith(`${href}/`));
|
||||
const authenticated = session === "authenticated";
|
||||
@@ -125,13 +167,15 @@ export function App({ api: apiProp }: { api?: ManagementApiClient }) {
|
||||
: loadState === "error"
|
||||
? <ErrorPanel message={error} expired={session === "expired"} retry={() => void load()} />
|
||||
: 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
|
||||
? <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) === "/"
|
||||
? <Dashboard snapshot={snapshot} reload={() => void load()} navigate={navigate} />
|
||||
? <Dashboard snapshot={snapshot} api={api} reload={() => void load()} navigate={navigate} />
|
||||
: basePath(path) === "/operations"
|
||||
? <Operations path={path} navigate={navigate} snapshot={snapshot} />
|
||||
? <Operations path={path} navigate={navigate} snapshot={snapshot} api={api} />
|
||||
: basePath(path) === "/settings"
|
||||
? <SettingsPage path={path} navigate={navigate} />
|
||||
: <ForbiddenPanel />;
|
||||
@@ -141,11 +185,20 @@ export function App({ api: apiProp }: { api?: ManagementApiClient }) {
|
||||
<a className="skip-link" href="#main-content">Skip to main content</a>
|
||||
<div className={authenticated ? "app-shell" : "preauth-shell"}>
|
||||
{authenticated ? <>
|
||||
{navOpen ? <button type="button" className="nav-backdrop" onClick={() => closeNavigation()} aria-label="Dismiss navigation" /> : null}
|
||||
<aside className={navOpen ? "sidebar sidebar-open" : "sidebar"} aria-label="Application sidebar">
|
||||
{navOpen ? <button ref={navBackdropRef} type="button" className="nav-backdrop" onClick={closeNavigation} aria-label="Dismiss navigation" aria-hidden="true" tabIndex={-1} /> : null}
|
||||
<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">
|
||||
<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>
|
||||
<nav aria-label="Primary navigation">
|
||||
{navigation.map((group) => {
|
||||
@@ -170,7 +223,7 @@ export function App({ api: apiProp }: { api?: ManagementApiClient }) {
|
||||
</> : null}
|
||||
<main className="main-content" id="main-content" tabIndex={-1}>
|
||||
{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-actions">
|
||||
<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} />
|
||||
</div>
|
||||
</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}
|
||||
</main>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +1,253 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { availableNavigation } from "../navigation";
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import type { ManagementApiClient, ResourceKind, ResourceRecord } from "../contracts";
|
||||
import { ApiError, safeApiMessage } from "../api";
|
||||
import { availableNavigation, navigation, type NavigationItem } from "../navigation";
|
||||
import { Icon } from "./Icon";
|
||||
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({
|
||||
open,
|
||||
api,
|
||||
canReadAudit,
|
||||
navigate,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
api: ManagementApiClient;
|
||||
canReadAudit: boolean;
|
||||
navigate: (next: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
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 items = useMemo(() => availableNavigation(canReadAudit), [canReadAudit]);
|
||||
const results = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return items;
|
||||
return items.filter((item) => `${item.label} ${item.description}`.toLowerCase().includes(needle));
|
||||
}, [items, query]);
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const allowedNavigation = useMemo(() => availableNavigation(canReadAudit), [canReadAudit]);
|
||||
const iconForKind = (kind: ResourceKind) => allowedNavigation.find((item) => item.href === `/${kind}`)?.icon ?? "overview";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setQuery("");
|
||||
setActiveIndex(-1);
|
||||
const focusTimer = window.setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => window.clearTimeout(focusTimer);
|
||||
}, [open]);
|
||||
|
||||
const select = (href: string) => {
|
||||
onClose();
|
||||
navigate(href);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
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 (
|
||||
<Modal open={open} title="Quick jump" onClose={onClose} className="command-modal">
|
||||
<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} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="true"
|
||||
aria-controls="command-results"
|
||||
aria-activedescendant={activeOptionId}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Find projects, catalog, policies…"
|
||||
onKeyDown={onInputKeyDown}
|
||||
onChange={(event) => { setQuery(event.target.value); setActiveIndex(-1); }}
|
||||
placeholder="Find projects, connectors, clients…"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<kbd>Esc</kbd>
|
||||
</label>
|
||||
<div className="command-results" aria-live="polite">
|
||||
{results.length ? results.map((item) => (
|
||||
<button type="button" className="command-result" key={item.href} onClick={() => select(item.href)}>
|
||||
<div className="command-results" id="command-results" role="listbox" aria-label="Quick jump destinations" aria-live="polite">
|
||||
{groups.map((group) => <section className="command-group" role="group" aria-label={group.label} key={group.label}>
|
||||
<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><strong>{item.label}</strong><small>{item.description}</small></span>
|
||||
<Icon name="arrow" size={16} />
|
||||
</button>
|
||||
)) : (
|
||||
<div className="command-empty"><strong>No workspace found</strong><span>Try a broader name or capability.</span></div>
|
||||
)}
|
||||
</button>;
|
||||
})}
|
||||
</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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const ALL_COLUMNS: Record<ResourceKind, ColumnKey[]> = {
|
||||
export function columnsFor(kind: ResourceKind): ColumnKey[] { return ALL_COLUMNS[kind]; }
|
||||
|
||||
export function DataTable({
|
||||
data, kind, density, visibleColumns, onClientAction, onConfig, onTest,
|
||||
data, kind, density, visibleColumns, onClientAction, onConfig, onTest, navigate, busyClientId,
|
||||
}: {
|
||||
data: ResourcePage;
|
||||
kind: ResourceKind;
|
||||
@@ -28,14 +28,25 @@ export function DataTable({
|
||||
onClientAction: (id: string, action: "disable" | "revoke") => void;
|
||||
onConfig: (id: string) => void;
|
||||
onTest: (row: ResourceRecord) => void;
|
||||
navigate?: (next: string) => void;
|
||||
busyClientId?: string | null;
|
||||
}) {
|
||||
const show = (column: ColumnKey) => visibleColumns.includes(column);
|
||||
const [confirmingAction, setConfirmingAction] = useState<{ id: string; action: "disable" | "revoke"; name: string } | null>(null);
|
||||
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>;
|
||||
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 (
|
||||
<Fragment>
|
||||
<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>
|
||||
{data.rows.map((row) => (
|
||||
<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("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 === "audit" && show("decision") && <td>{row.decision ?? "unknown"}{row.traceId && <small>{row.traceId}</small>}</td>}
|
||||
{(kind === "clients" || kind === "tools") && show("actions") && <td>{actions(row)}</td>}
|
||||
@@ -68,7 +79,7 @@ export function DataTable({
|
||||
</table>
|
||||
</div>
|
||||
<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>
|
||||
<Modal
|
||||
open={confirmingAction !== null}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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 }) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
@@ -7,56 +9,49 @@ export function Modal({ open, title, onClose, children, className = "" }: { open
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// Kept in its own effect, keyed only on `open` (not `onClose`): if this
|
||||
// ran on every `onClose` identity change (callers commonly pass a fresh
|
||||
// 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]);
|
||||
const dialog = dialogRef.current;
|
||||
const overlay = dialog?.parentElement;
|
||||
if (!open || !dialog || !overlay) return;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// Capture the trigger before moving focus into the body-level portal. This
|
||||
// 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) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
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();
|
||||
} else {
|
||||
trapTab(event, dialog);
|
||||
}
|
||||
};
|
||||
|
||||
dialog.focus();
|
||||
document.addEventListener("focusin", onFocusIn, true);
|
||||
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]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
if (!open || typeof document === "undefined") return null;
|
||||
return createPortal(
|
||||
<div className="modal-overlay" data-modal-overlay onClick={(event) => { if (event.target === event.currentTarget) onCloseRef.current(); }}>
|
||||
<div
|
||||
className={`modal ${className}`.trim()}
|
||||
role="dialog"
|
||||
@@ -68,10 +63,11 @@ export function Modal({ open, title, onClose, children, className = "" }: { open
|
||||
>
|
||||
<div className="modal-heading">
|
||||
<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 className="modal-body">{children}</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>;
|
||||
}
|
||||
@@ -17,5 +17,19 @@ export function SessionMenu({ subject, roles }: { subject: string | null; roles:
|
||||
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>;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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 SessionInfo { authenticated: boolean; csrf_token?: string; session?: SessionMetadata; }
|
||||
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 {
|
||||
generatedAt: string; staleAfterSeconds: number;
|
||||
health: { hub: Health; gateway: Health; detail: string };
|
||||
@@ -18,9 +22,9 @@ export interface DashboardSnapshot {
|
||||
}
|
||||
export interface ApiClient { session(): Promise<SessionInfo>; dashboard(): Promise<DashboardSnapshot>; }
|
||||
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 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 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; }
|
||||
@@ -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 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 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 ClientActionResult { accepted: boolean; message: string; }
|
||||
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 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 {
|
||||
resource(kind: ResourceKind, query: PageQuery): Promise<ResourcePage>;
|
||||
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>;
|
||||
testTool(input: { project: string; environment: string; tool: string; arguments: Record<string, unknown> }): Promise<ToolReadinessResult>;
|
||||
toolDefinition(canonicalName: string): Promise<ToolDefinition>;
|
||||
@@ -59,7 +109,9 @@ export interface ManagementApiClient extends ApiClient {
|
||||
applyCatalog(input: { project: string }): Promise<ClientActionResult>;
|
||||
clientAction(id: string, action: "disable" | "revoke"): Promise<ClientActionResult>;
|
||||
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 {
|
||||
const generated = Date.parse(snapshot.generatedAt);
|
||||
@@ -69,5 +121,6 @@ export function routeTitle(pathname: string): string {
|
||||
const route = pathname.replace(/\/$/, "") || "/";
|
||||
if (route === "/") return "Overview";
|
||||
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";
|
||||
}
|
||||
@@ -14,6 +14,6 @@ export function useProjectOptions(api: ManagementApiClient) {
|
||||
* selected or the lookup is in flight. */
|
||||
export function useProjectEnvironments(api: ManagementApiClient, project: 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;
|
||||
}
|
||||
@@ -2,8 +2,14 @@ import { createRoot } from "react-dom/client";
|
||||
import { createElement } from "react";
|
||||
import { App } from "./app";
|
||||
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") {
|
||||
document.documentElement.setAttribute("data-theme", storedTheme);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,20 +1,28 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { CatalogPreview, ManagementApiClient } from "../contracts";
|
||||
import { safeApiMessage } from "../api";
|
||||
import { useProjectOptions } from "../hooks";
|
||||
import { useCan } from "../rbac-context";
|
||||
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 projects = useProjectOptions(api);
|
||||
const [project, setProject] = useState("");
|
||||
const [project, setProject] = useState(initialProject);
|
||||
const [preview, setPreview] = useState<CatalogPreview | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
useEffect(() => { if (!project && projects[0]) setProject(projects[0].id); }, [projects, project]);
|
||||
const run = async () => { if (!project) return; setBusy(true); setMessage(""); try { setPreview(await api.previewCatalog({ project })); } catch (caught) { setMessage(safeApiMessage(caught)); } finally { setBusy(false); } };
|
||||
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); } };
|
||||
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>;
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [previewProject, setPreviewProject] = useState("");
|
||||
const currentProject = useRef(project);
|
||||
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>;
|
||||
}
|
||||
@@ -1,93 +1,330 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ManagementApiClient, OnboardingClientKind } from "../contracts";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { ClientOnboardingResult, ClientOnboardingStatus, GeneratedClientConfig, ManagementApiClient, OnboardingClientKind, OnboardingCheckState } from "../contracts";
|
||||
import { safeApiMessage } from "../api";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { StatusPill } from "../components/StatusPill";
|
||||
import { useProjectEnvironments, useProjectOptions } from "../hooks";
|
||||
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 canVerify = useCan("client.verify");
|
||||
const projects = useProjectOptions(api);
|
||||
const [project, setProject] = useState("");
|
||||
const environments = useProjectEnvironments(api, project);
|
||||
const [environment, setEnvironment] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [kind, setKind] = useState<OnboardingClientKind>("stdio-bridge");
|
||||
const [result, setResult] = useState<Awaited<ReturnType<ManagementApiClient["createClient"]>> | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const activeClientId = clientId ?? null;
|
||||
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 [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(() => { 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 ready = 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 readyToCreate = normalizedName.length >= 1 && project !== "" && environment !== "";
|
||||
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 () => {
|
||||
if (!ready) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
if (!readyToCreate || !canCreate) return;
|
||||
const requestClientId = activeClientId;
|
||||
const requestGeneration = workflowContext.current.generation;
|
||||
const requestIsCurrent = () => mounted.current && workflowContext.current.clientId === requestClientId && workflowContext.current.generation === requestGeneration;
|
||||
setBusy("create");
|
||||
setActionError("");
|
||||
setActionMessage("");
|
||||
setCopyStatus("");
|
||||
setResult(null);
|
||||
setCreateResult(null);
|
||||
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) {
|
||||
setError(safeApiMessage(caught));
|
||||
if (requestIsCurrent()) setActionError(safeApiMessage(caught));
|
||||
} 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 {
|
||||
await navigator.clipboard.writeText(JSON.stringify({
|
||||
name: `itworx-${result.client.project}-${result.client.environment}`,
|
||||
endpoint: result.config.endpoint,
|
||||
hubClientId: result.client.id,
|
||||
const result = await api.verifyClientOnboarding(requestClientId);
|
||||
if (!requestIsCurrent()) return;
|
||||
if (result.clientId !== requestClientId) throw new Error("mismatched client status");
|
||||
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: {
|
||||
issuer: result.provisioning.issuer,
|
||||
tokenEndpoint: result.provisioning.tokenEndpoint,
|
||||
oauthClientId: result.provisioning.oauthClientId,
|
||||
username: result.provisioning.oauthUsername,
|
||||
issuer: visibleProvisioning.issuer,
|
||||
tokenEndpoint: visibleProvisioning.tokenEndpoint,
|
||||
oauthClientId: visibleProvisioning.oauthClientId,
|
||||
username: visibleProvisioning.oauthUsername,
|
||||
},
|
||||
installScript: "scripts/install_codex_mcp.ps1",
|
||||
credential: "operator-owned user-only secret file",
|
||||
}, null, 2));
|
||||
setCopyStatus("Configuration copied");
|
||||
} : {}),
|
||||
credential: "Set this up through the external administrator and store it in a user-only secret file.",
|
||||
};
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(safeConfig, null, 2));
|
||||
setCopyStatus("Secret-free configuration copied.");
|
||||
} catch {
|
||||
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><p className="eyebrow">Machine access</p><h3>Onboard a project client</h3></div>
|
||||
<span className="tag">Independent binding</span>
|
||||
</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">
|
||||
<li className="active"><span>1</span><div><strong>Define</strong><small>Profile and scope</small></div></li>
|
||||
<li><span>2</span><div><strong>Provision</strong><small>External identity</small></div></li>
|
||||
<li><span>3</span><div><strong>Verify</strong><small>Discovery and audit</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}>
|
||||
<span>{index + 1}</span><div><strong>{healthLabel(step)}</strong><small>{step === "define" ? "Hub identity" : step === "provision" ? "External account" : "Scope and proof"}</small></div>
|
||||
</li>)}
|
||||
</ol>
|
||||
|
||||
{!activeClientId ? <>
|
||||
{canCreate ? <>
|
||||
<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>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>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>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 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>
|
||||
{!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>
|
||||
<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>
|
||||
{error ? <p className="error-text" role="alert">{error}</p> : null}
|
||||
{result ? <div className="onboarding-result" role="status">
|
||||
<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>
|
||||
<ol>{result.provisioning.steps.map((step) => <li key={step}>{step}</li>)}</ol>
|
||||
<p className="muted">No bearer credential, app password or client secret is returned to this browser.</p>
|
||||
{copyStatus ? <p className="copy-status">{copyStatus}</p> : null}
|
||||
{projects.length === 0 ? <p className="notice notice-info">No registered projects are available for this identity.</p> : null}
|
||||
<button className="button" onClick={() => void create()} disabled={busy !== "" || !readyToCreate}>{busy === "create" ? "Creating pending client…" : <>Create pending client <Icon name="arrow" size={15} /></>}</button>
|
||||
</> : <div className="notice notice-info">Client creation requires the admin role. An operator can resume an existing onboarding link when one is provided.</div>}
|
||||
<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="onboarding-identity-bar">
|
||||
<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>
|
||||
<div className="onboarding-identity-actions">
|
||||
{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}
|
||||
|
||||
{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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MouseEvent } from "react";
|
||||
import type { DashboardSnapshot, Health } from "../contracts";
|
||||
import { useCallback, useState, type MouseEvent } from "react";
|
||||
import type { DashboardSnapshot, Health, ManagementApiClient } from "../contracts";
|
||||
import { isSnapshotStale } from "../contracts";
|
||||
import { formatBrusselsTime } from "../format";
|
||||
import { Icon } from "../components/Icon";
|
||||
@@ -7,37 +7,26 @@ import { McpTopology } from "../components/McpTopology";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import { StatusPill } from "../components/StatusPill";
|
||||
import { useCan } from "../rbac-context";
|
||||
|
||||
import { actionInbox } from "../operator-data";
|
||||
import { refreshLabel, useStatusRefresh } from "../status-refresh";
|
||||
function healthLabel(value: Health) {
|
||||
return ({ healthy: "Healthy", degraded: "Degraded", unavailable: "Unavailable", unknown: "Unknown" } as const)[value];
|
||||
}
|
||||
|
||||
type Exception = { title: string; detail: string; status: Health; href: string };
|
||||
|
||||
function exceptionsFor(snapshot: DashboardSnapshot, stale: boolean, canReadAudit: boolean): Exception[] {
|
||||
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 }) {
|
||||
export function Dashboard({ snapshot: initialSnapshot, api, navigate }: { snapshot: DashboardSnapshot; api: ManagementApiClient; reload?: () => void; navigate: (next: string) => void }) {
|
||||
const read = useCallback(() => api.dashboard(), [api]);
|
||||
const { value, status, error, refresh } = useStatusRefresh(initialSnapshot, read, isSnapshotStale);
|
||||
const snapshot = value ?? initialSnapshot;
|
||||
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 allCurrent = !stale && exceptions.length === 0;
|
||||
const go = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||
event.preventDefault();
|
||||
navigate(href);
|
||||
};
|
||||
|
||||
return <div className="dashboard">
|
||||
<section className="hero dashboard-hero">
|
||||
<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 button-secondary" href="/tools" onClick={(event) => go(event, "/tools")}>Explore catalog</a>
|
||||
</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>
|
||||
<McpTopology
|
||||
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 }))}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<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="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="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 className="panel exception-panel" aria-labelledby="attention-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>
|
||||
<button className="button button-small button-secondary refresh-button" onClick={reload}><Icon name="refresh" size={14} />Refresh</button>
|
||||
<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={() => void refresh()} disabled={status === "refreshing"}><Icon name="refresh" size={14} />Refresh</button>
|
||||
</div>
|
||||
{error ? <div className="notice notice-danger" role="alert">{error} The last snapshot is retained; retry with Refresh.</div> : null}
|
||||
{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="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 className="dashboard-columns">
|
||||
<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>
|
||||
{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 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="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>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="metric-grid secondary-grid">
|
||||
<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}` : ""}`} />
|
||||
|
||||
@@ -1,81 +1,80 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createApiClient, safeApiMessage } from "../api";
|
||||
import type { DashboardSnapshot, OperationsCheck, OperationsSummary } from "../contracts";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { safeApiMessage } from "../api";
|
||||
import type { DashboardSnapshot, ManagementApiClient, OperationsCheck, OperationsSummary } from "../contracts";
|
||||
import { isSnapshotStale } from "../contracts";
|
||||
import { formatBrusselsTime } from "../format";
|
||||
import { Breadcrumbs } from "../components/Breadcrumbs";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import { StatusPill } from "../components/StatusPill";
|
||||
import { useCan } from "../rbac-context";
|
||||
import { aggregateTrend, alertDestination, type TrendBucket } from "../operator-data";
|
||||
import { refreshLabel, useStatusRefresh } from "../status-refresh";
|
||||
|
||||
function SummaryLoading() {
|
||||
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 }) {
|
||||
const [api] = useState(() => createApiClient());
|
||||
type OperationalRead = { snapshot: DashboardSnapshot; summary: OperationsSummary };
|
||||
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 [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 [busy, setBusy] = useState(false);
|
||||
const [summaryLoading, setSummaryLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
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 section = new URLSearchParams(path.split("?")[1] ?? "").get("section");
|
||||
useEffect(() => { if (section && ["alerts", "checks", "recovery"].includes(section)) document.getElementById(`operations-${section}`)?.scrollIntoView({ block: "start" }); }, [section, summary]);
|
||||
const runCheck = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setCheck(await api.runOperationsCheck());
|
||||
setSummary(await api.operationsSummary());
|
||||
} catch (caught) {
|
||||
setError(safeApiMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
if (busy) return;
|
||||
setBusy(true); setError("");
|
||||
try { setCheck(await api.runOperationsCheck()); await refresh(); }
|
||||
catch (caught) { setError(safeApiMessage(caught)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
if (!snapshot) return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate} /><section className="panel resource-page"><SummaryLoading /></section></div>;
|
||||
|
||||
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>;
|
||||
const audit = summary?.audit24h;
|
||||
const totalCalls = (audit?.success ?? 0) + (audit?.denied ?? 0) + (audit?.error ?? 0) + (audit?.rate_limited ?? 0);
|
||||
const maximum = Math.max(1, ...(summary?.trend.map((item) => item.success + item.denied + item.error + item.rate_limited) ?? [1]));
|
||||
|
||||
const totalCalls = audit ? audit.success + audit.denied + audit.error + audit.rate_limited : null;
|
||||
const snapshotStale = isSnapshotStale(snapshot);
|
||||
return <div className="resource-stack operations-page">
|
||||
<Breadcrumbs path={path} navigate={navigate} />
|
||||
<section className="panel resource-page operations-overview">
|
||||
<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="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>
|
||||
{error ? <div className="notice notice-danger" role="alert">{error}</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><span>{item.status} · {item.detail}</span></div>)}</div></div> : null}
|
||||
<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">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={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 || 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>{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 className="operations-grid">
|
||||
<article className="panel attention-panel">
|
||||
<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?.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 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>
|
||||
{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>}
|
||||
</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>
|
||||
<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 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>
|
||||
{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>
|
||||
{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>}
|
||||
</article>
|
||||
</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>
|
||||
</div>;
|
||||
}
|
||||
@@ -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 { ApiError, safeApiMessage } from "../api";
|
||||
import { Breadcrumbs } from "../components/Breadcrumbs";
|
||||
@@ -7,11 +7,62 @@ import { LoadingPanel } from "../components/LoadingPanel";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import { basePath } from "../routing";
|
||||
import { useCan } from "../rbac-context";
|
||||
import "../styles/details.css";
|
||||
|
||||
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[] }[] }) {
|
||||
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 }) {
|
||||
@@ -19,17 +70,149 @@ export function ProjectDetail({ path, navigate, api }: { path: string; navigate:
|
||||
const requestedTab = new URLSearchParams(path.split("?")[1] ?? "").get("tab") ?? "Summary";
|
||||
const canReadAudit = useCan("audit.read");
|
||||
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 [state, setState] = useState<"loading" | "error" | "notFound">("loading");
|
||||
const [state, setState] = useState<"loading" | "ready" | "error" | "notFound">("loading");
|
||||
const [error, setError] = useState("");
|
||||
const [scopedRows, setScopedRows] = useState<ResourceRecord[] | null>(null);
|
||||
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"); } } };
|
||||
useEffect(() => { void load(); }, [slug]);
|
||||
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 loadGeneration = useRef(0);
|
||||
const [scopedRows, setScopedRows] = useState<ResourceRecord[]>([]);
|
||||
const [scopedState, setScopedState] = useState<"idle" | "loading" | "ready" | "error">("idle");
|
||||
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 === "error") return <ErrorPanel message={error} retry={() => void load()}/>;
|
||||
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>;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { safeApiMessage } from "../api";
|
||||
import { Breadcrumbs } from "../components/Breadcrumbs";
|
||||
@@ -13,6 +13,8 @@ import { useCan } from "../rbac-context";
|
||||
import { CatalogWorkflow } from "./CatalogWorkflow";
|
||||
import { ClientOnboarding } from "./ClientOnboarding";
|
||||
import { PolicySimulator } from "./PolicySimulator";
|
||||
import { readPreference, writePreference } from "../preferences";
|
||||
import { RecentToolRuns } from "../components/RecentToolRuns";
|
||||
import { ToolReadinessModal } from "./ToolReadinessModal";
|
||||
|
||||
const descriptions: Record<ResourceKind, string> = {
|
||||
@@ -27,7 +29,7 @@ const descriptions: Record<ResourceKind, string> = {
|
||||
|
||||
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 canTestTools = useCan("tool.test");
|
||||
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 [feedback, setFeedback] = useState<Feedback>(null);
|
||||
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 [refreshKey, setRefreshKey] = useState(0);
|
||||
const cursor = cursorStack[cursorStack.length - 1];
|
||||
@@ -50,20 +56,34 @@ export function ResourcePage({ path, kind, api, navigate }: { path: string; kind
|
||||
}, [kind, canDisableClients, canTestTools]);
|
||||
|
||||
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(() => {
|
||||
const normalizedFilter = filterInput.trim();
|
||||
if (normalizedFilter === urlFilter) return;
|
||||
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);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [filterInput, kind, navigate, urlFilter]);
|
||||
useEffect(() => { setCursorStack([undefined]); }, [kind, urlFilter]);
|
||||
}, [filterInput, initialEnvironment, initialProject, kind, navigate, urlFilter]);
|
||||
useEffect(() => { setCursorStack([undefined]); }, [initialEnvironment, initialProject, kind, urlFilter]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setState("loading");
|
||||
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) => {
|
||||
if (!active) return;
|
||||
setData(value);
|
||||
@@ -75,7 +95,7 @@ export function ResourcePage({ path, kind, api, navigate }: { path: string; kind
|
||||
setState("error");
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [api, cursor, kind, refreshKey, urlFilter]);
|
||||
}, [api, cursor, initialEnvironment, initialProject, kind, refreshKey, urlFilter]);
|
||||
|
||||
const nextPage = () => {
|
||||
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 retry = () => setRefreshKey((value) => value + 1);
|
||||
const action = async (id: string, verb: "disable" | "revoke") => {
|
||||
if (busyClientId) return;
|
||||
setBusyClientId(id);
|
||||
setFeedback({ tone: "info", message: "Submitting server-authorized client action…" });
|
||||
try {
|
||||
const result = await api.clientAction(id, verb);
|
||||
@@ -90,15 +112,17 @@ export function ResourcePage({ path, kind, api, navigate }: { path: string; kind
|
||||
retry();
|
||||
} catch (caught) {
|
||||
setFeedback({ tone: "danger", message: safeApiMessage(caught) });
|
||||
}
|
||||
} finally { setBusyClientId(null); }
|
||||
};
|
||||
const config = async (id: string) => {
|
||||
if (busyClientId) return;
|
||||
setBusyClientId(id);
|
||||
try {
|
||||
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.` });
|
||||
} catch (caught) {
|
||||
setFeedback({ tone: "danger", message: safeApiMessage(caught) });
|
||||
}
|
||||
} finally { setBusyClientId(null); }
|
||||
};
|
||||
|
||||
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>
|
||||
<div className="table-controls" role="group" aria-label="Table density">
|
||||
<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 === "compact" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={density === "compact"} onClick={() => setDensity("compact")}>Compact</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={() => changeDensity("compact")}>Compact</button>
|
||||
</div>
|
||||
</div>
|
||||
<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}
|
||||
{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>
|
||||
: <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 className="pagination">
|
||||
<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>
|
||||
{feedback ? <div className={`notice notice-${feedback.tone}`} role={feedback.tone === "danger" ? "alert" : "status"}>{feedback.message}</div> : null}
|
||||
</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 === "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}
|
||||
{testingTool ? <ToolReadinessModal tool={testingTool} api={api} onClose={() => setTestingTool(null)} /> : 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} initialProject={initialProject} initialEnvironment={initialEnvironment} onClose={() => { setTestingTool(null); if (reopened) navigate("/tools"); }} /> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useState } from "react";
|
||||
import { readPreference, writePreference } from "../preferences";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import { Breadcrumbs } from "../components/Breadcrumbs";
|
||||
|
||||
// Unset localStorage means the CSS falls back to prefers-color-scheme, whose
|
||||
// default in this design is dark -- so an absent stored value is treated as
|
||||
// "dark" here too, keeping the toggle's active button in sync with what's
|
||||
// actually on screen on first load.
|
||||
function currentTheme(): "light" | "dark" { return localStorage.getItem("hub-theme") === "light" ? "light" : "dark"; }
|
||||
function currentTheme(): "light" | "dark" {
|
||||
const rendered = document.documentElement.dataset.theme;
|
||||
const fallback = rendered === "light" || rendered === "dark" ? rendered : window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||||
return readPreference("hub-theme", ["light", "dark"], fallback) as "light" | "dark";
|
||||
}
|
||||
|
||||
export function SettingsPage({ path, navigate }: { path: string; navigate: (next: string) => void }) {
|
||||
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>;
|
||||
}
|
||||
@@ -1,103 +1,203 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { safeApiMessage } from "../api";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { SchemaInputFields } from "../components/SchemaInputFields";
|
||||
import { StatusPill } from "../components/StatusPill";
|
||||
import type { ManagementApiClient, ResourceRecord, ToolDefinition, ToolInvocationResult, ToolReadinessResult } from "../contracts";
|
||||
import { useProjectEnvironments, useProjectOptions } from "../hooks";
|
||||
import { useCan } from "../rbac-context";
|
||||
import { defaultSchemaInputValues, parseSchemaArguments } from "../schema-input";
|
||||
import { recordToolRun } from "../tool-history";
|
||||
|
||||
type Property = NonNullable<ToolDefinition["inputSchema"]["properties"]>[string];
|
||||
|
||||
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 invocationOutput(invocation: ToolInvocationResult): unknown {
|
||||
return invocation.structuredContent ?? invocation.content ?? invocation.error ?? null;
|
||||
}
|
||||
|
||||
function fieldValue(value: unknown): 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 {
|
||||
function renderOutput(invocation: ToolInvocationResult): string {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(sessionStorage.getItem("itworx-tool-history-v1") ?? "[]");
|
||||
const history = Array.isArray(parsed) ? parsed : [];
|
||||
sessionStorage.setItem(
|
||||
"itworx-tool-history-v1",
|
||||
JSON.stringify([metadata, ...history].slice(0, 20)),
|
||||
);
|
||||
return JSON.stringify(invocationOutput(invocation), null, 2) ?? "No output was returned.";
|
||||
} 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 projects = useProjectOptions(api);
|
||||
const [project, setProject] = useState("");
|
||||
const [project, setProject] = useState(initialProject ?? "");
|
||||
const environments = useProjectEnvironments(api, project);
|
||||
const [environment, setEnvironment] = useState("");
|
||||
const [environment, setEnvironment] = useState(initialEnvironment ?? "");
|
||||
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 [resultContext, setResultContext] = useState<string | null>(null);
|
||||
const [invocation, setInvocation] = useState<ToolInvocationResult | null>(null);
|
||||
const [invocationContext, setInvocationContext] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [copyStatus, setCopyStatus] = useState("");
|
||||
const [busy, setBusy] = useState<"readiness" | "invoke" | "">("");
|
||||
const canonicalName = tool.canonicalName;
|
||||
|
||||
useEffect(() => { if (!project && projects[0]) setProject(projects[0].id); }, [project, projects]);
|
||||
useEffect(() => { setEnvironment((current) => environments.includes(current) ? current : environments[0] ?? ""); }, [environments]);
|
||||
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]);
|
||||
useEffect(() => {
|
||||
if (!projects.length) return;
|
||||
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]);
|
||||
const selectable = Boolean(tool.canonicalName && project && environment && definition?.readOnly);
|
||||
const update = (name: string, raw: string, property: Property) => {
|
||||
try {
|
||||
const parsed = parseField(raw, property);
|
||||
useEffect(() => {
|
||||
if (!project) {
|
||||
setEnvironment("");
|
||||
return;
|
||||
}
|
||||
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("");
|
||||
setArgumentsValue((current) => { const next = { ...current }; if (parsed === undefined) delete next[name]; else next[name] = parsed; return next; });
|
||||
} catch { setError(`${name} must contain valid JSON.`); }
|
||||
setCopyStatus("");
|
||||
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 () => {
|
||||
if (!selectable || !tool.canonicalName) return;
|
||||
setBusy("readiness"); setError(""); setResult(null); setInvocation(null);
|
||||
try { setResult(await api.testTool({ project, environment, tool: tool.canonicalName, arguments: argumentsValue })); }
|
||||
catch (caught) { setError(safeApiMessage(caught)); }
|
||||
finally { setBusy(""); }
|
||||
};
|
||||
const invoke = async () => {
|
||||
if (!selectable || !tool.canonicalName || !result?.ready) return;
|
||||
setBusy("invoke"); setError(""); setInvocation(null);
|
||||
if (!selectable || !canTest || !canonicalName) return;
|
||||
const context = requestContext;
|
||||
const input = parsedInput.arguments;
|
||||
setBusy("readiness");
|
||||
setError("");
|
||||
setCopyStatus("");
|
||||
clearRunState();
|
||||
try {
|
||||
const response = await api.invokeTool({ project, environment, tool: tool.canonicalName, arguments: argumentsValue });
|
||||
setInvocation(response);
|
||||
recordInvocation({ tool: tool.canonicalName, project, environment, ok: response.ok, traceId: response.traceId, at: new Date().toISOString() });
|
||||
} catch (caught) { setError(safeApiMessage(caught)); }
|
||||
finally { setBusy(""); }
|
||||
const response = await api.testTool({ project, environment, tool: canonicalName, arguments: input });
|
||||
setResult(response);
|
||||
setResultContext(context);
|
||||
} catch (caught) {
|
||||
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}>
|
||||
<p className="muted">Run a schema-validated readiness check, then invoke this cataloged read-only tool through the audited operator path.</p>
|
||||
<div className="form-grid readiness-form">
|
||||
<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>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>
|
||||
{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>)}
|
||||
<div className="tool-console" aria-busy={Boolean(busy)}>
|
||||
<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>
|
||||
<div className="form-grid readiness-form tool-scope-form">
|
||||
<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>
|
||||
<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 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}
|
||||
{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}
|
||||
{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}
|
||||
{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}
|
||||
{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>;
|
||||
}
|
||||
@@ -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. */ }
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export type ManagementAction =
|
||||
| "operations.check"
|
||||
| "client.read"
|
||||
| "client.bootstrap"
|
||||
| "client.verify"
|
||||
| "client.disable"
|
||||
| "client.revoke"
|
||||
| "client.create"
|
||||
@@ -32,6 +33,7 @@ const PERMISSIONS: Record<ManagementAction, readonly ManagementRole[]> = {
|
||||
"operations.check": ["operator", "admin"],
|
||||
"client.read": ["viewer", "auditor", "operator", "admin"],
|
||||
"client.bootstrap": ["operator", "admin"],
|
||||
"client.verify": ["operator", "admin"],
|
||||
"client.disable": ["operator", "admin"],
|
||||
"client.revoke": ["operator", "admin"],
|
||||
"client.create": ["admin"],
|
||||
|
||||
@@ -13,4 +13,11 @@ export function usePath() {
|
||||
return [path, navigate] as const;
|
||||
}
|
||||
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; }
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
+122
-159
@@ -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; }
|
||||
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:focus { transform: translateY(0); }
|
||||
button, a, input, select { font: inherit; }
|
||||
@@ -88,38 +84,34 @@ button { font-family: var(--font-sans); }
|
||||
|
||||
/* Shell */
|
||||
.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; }
|
||||
|
||||
.brand { display: flex; gap: 12px; align-items: center; text-decoration: none; padding: 0 10px 30px; }
|
||||
.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 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; }
|
||||
|
||||
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-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.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; }
|
||||
|
||||
.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; }
|
||||
|
||||
/* 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 { 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); }
|
||||
.topbar-actions { display: flex; align-items: center; gap: 16px; }
|
||||
|
||||
.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: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 h2 { font-size: clamp(22px, 3vw, 31px); }
|
||||
.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 */
|
||||
.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 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 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-heading { align-items: flex-start; margin-bottom: 19px; }
|
||||
.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; }
|
||||
.exception-panel { margin-bottom: 20px; }
|
||||
|
||||
.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: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 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; }
|
||||
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 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; }
|
||||
@@ -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 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-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 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; }
|
||||
.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); }
|
||||
.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); } }
|
||||
@@ -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; }
|
||||
.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
|
||||
the persistent sidebar (rendered unconditionally in app.tsx) is used
|
||||
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; }
|
||||
.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 */
|
||||
.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 > span:last-child { display: grid; }
|
||||
.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; }
|
||||
.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 { 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-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; }
|
||||
@@ -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; }
|
||||
.inline-actions { display: flex; gap: 5px; }
|
||||
.button-small { padding: 7px 10px; font-size: 10.5px; }
|
||||
.table-controls { display: flex; gap: 10px; }
|
||||
|
||||
.table-controls .active { border-color: var(--accent); }
|
||||
.button-danger { color: #f6dcd7; background: var(--status-danger-dim); border-color: #7a3b30; }
|
||||
.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 h3 { margin-bottom: 9px; }
|
||||
.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 .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); }
|
||||
.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-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); }
|
||||
@@ -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 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-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); }
|
||||
@@ -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
|
||||
security-oriented dark control-plane identity. */
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
background-image:
|
||||
linear-gradient(var(--line-soft) 1px, transparent 1px),
|
||||
body { margin: 0; min-width: 320px; background: var(--surface-0); background-image: linear-gradient(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%);
|
||||
background-size: 64px 64px, 64px 64px, auto;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
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; }
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
@@ -420,41 +336,27 @@ body::before {
|
||||
.icon { display: block; flex: 0 0 auto; }
|
||||
|
||||
/* Authenticated shell */
|
||||
.sidebar {
|
||||
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 { 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); }
|
||||
.sidebar .brand { padding: 0 9px 25px; }
|
||||
.brand-mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 9px;
|
||||
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; }
|
||||
.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; }
|
||||
.nav-group { display: grid; gap: 3px; margin: 0 0 14px; margin-bottom: 11px; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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.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); }
|
||||
.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 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 { 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 .eyebrow { margin-bottom: 4px; font-size: 9.5px; }
|
||||
.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 {
|
||||
display: inline-flex;
|
||||
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); }
|
||||
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; }
|
||||
|
||||
/* 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); }
|
||||
.auth-layout {
|
||||
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 > span { color: var(--ink-faint); font: 10px/1.5 var(--font-mono); }
|
||||
.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 > div { padding: 13px; background: var(--surface-1); }
|
||||
.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 .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-meta { margin-top: 27px; text-align: left; }
|
||||
.hero-meta strong { display: inline; margin: 0 0 0 10px; font-size: 10.5px; }
|
||||
.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; 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; }
|
||||
.primary-metrics { margin: 0; }
|
||||
.metric-card, .panel { 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, .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 { 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::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-degraded)::before { background: var(--accent); }
|
||||
.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 > .icon { color: var(--ink-faint); transition: transform var(--motion-fast) ease; }
|
||||
.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-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; }
|
||||
.dashboard-columns { margin: 0; }
|
||||
.panel-heading a { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.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 { 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: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); }
|
||||
@@ -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 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; }
|
||||
.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: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-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); }
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.desktop-data-table table { table-layout: fixed; }
|
||||
.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%; }
|
||||
tbody tr { transition: background-color var(--motion-fast) ease; }
|
||||
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); }
|
||||
.pagination { min-height: 38px; margin-top: 11px; }
|
||||
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 { 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 .button { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.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-error, .trend-error { background: var(--status-danger); }
|
||||
.legend-limited, .trend-limited { background: var(--status-unknown); }
|
||||
.trend-chart { gap: 4px; height: 230px; padding: 16px 10px 0; }
|
||||
.trend-column { grid-template-rows: 16px 1fr 18px; min-width: 19px; }
|
||||
.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 { 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-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); }
|
||||
@@ -730,6 +632,8 @@ 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); }
|
||||
|
||||
/* Responsive composition */
|
||||
|
||||
/* Responsive layout: tablet, mobile, then narrow phones. */
|
||||
@media (max-width: 1220px) {
|
||||
.dashboard-hero { grid-template-columns: minmax(0, 1fr); }
|
||||
.dashboard-hero h2 { max-width: 20ch; }
|
||||
@@ -750,18 +654,53 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
|
||||
.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) {
|
||||
/* 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; }
|
||||
|
||||
.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-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.sidebar .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; }
|
||||
}
|
||||
|
||||
@@ -776,8 +715,29 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
|
||||
}
|
||||
|
||||
@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; }
|
||||
.sidebar { position: fixed; height: 100vh; width: min(86vw, 310px); }
|
||||
.nav-backdrop { border: 0; padding: 0; }
|
||||
.main-content { padding: 19px 14px 44px; }
|
||||
.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; }
|
||||
.command-trigger { width: 36px; justify-content: center; padding: 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; }
|
||||
.auth-layout { min-height: calc(100vh - 20px); border-radius: var(--radius-lg); }
|
||||
.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 .button { flex: 1; }
|
||||
.onboarding-steps li { display: grid; justify-items: center; text-align: center; }
|
||||
.onboarding-steps li > div { display: none; }
|
||||
.onboarding-form { grid-template-columns: 1fr; }
|
||||
.client-safety, .approvals-panel { display: grid; }
|
||||
.boundary-badge { justify-self: start; }
|
||||
@@ -857,6 +814,12 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
|
||||
}
|
||||
|
||||
@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; }
|
||||
.metric-card:hover { transform: none; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,32 @@ describe("management API safety contracts", () => {
|
||||
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" } }));
|
||||
});
|
||||
it("maps protected client actions and never invents returned secret material", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ clientId: "client-1", endpoint: "https://mcp.example.test/sse", scopes: ["tools:read"] }) });
|
||||
it("projects only safe client profile fields from the generated configuration", async () => {
|
||||
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);
|
||||
const config = await createApiClient().generatedConfig("client-1");
|
||||
expect(config).toEqual({ clientId: "client-1", endpoint: "https://mcp.example.test/sse", scopes: ["tools:read"] });
|
||||
expect(JSON.stringify(config)).not.toContain("secret");
|
||||
expect(config).toEqual({
|
||||
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" }));
|
||||
});
|
||||
it("fails closed without CSRF and sends the session-bound token on writes", async () => {
|
||||
|
||||
@@ -3,6 +3,10 @@ import { isSnapshotStale, routeTitle } from "../src/contracts";
|
||||
import { formatBrusselsTime, healthLabel } from "../src/format";
|
||||
|
||||
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("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"); });
|
||||
|
||||
@@ -16,8 +16,10 @@ test.describe("authentication", () => {
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(page.getByText("Authenticated", { exact: true })).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.
|
||||
await expect(page.getByText("Ludarium", { exact: true })).toBeVisible();
|
||||
// Assert a real bounded dashboard row without depending on catalog ordering.
|
||||
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 }) => {
|
||||
|
||||
@@ -18,7 +18,8 @@ test.describe("mobile navigation drawer", () => {
|
||||
|
||||
await trigger.click();
|
||||
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 }) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -33,12 +33,12 @@ test.describe("product-quality interaction and composition", () => {
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Quick jump" });
|
||||
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(dialog.getByRole("button", { name: /Audit/ })).toHaveCount(0);
|
||||
await expect(dialog.getByRole("option", { name: /Audit/ })).toHaveCount(0);
|
||||
|
||||
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.getByRole("heading", { name: "Catalog", level: 2 })).toBeVisible();
|
||||
});
|
||||
@@ -48,7 +48,7 @@ test.describe("product-quality interaction and composition", () => {
|
||||
await page.goto("/tools");
|
||||
const filter = page.getByLabel("Filter records");
|
||||
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 filter.fill(" ");
|
||||
@@ -79,7 +79,7 @@ test.describe("product-quality interaction and composition", () => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
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 expectNoPageOverflow(page);
|
||||
if (captureReview) await page.screenshot({ path: testInfo.outputPath("catalog-desktop.png"), fullPage: false });
|
||||
|
||||
@@ -29,13 +29,13 @@ test.describe("management workflows, driven end to end from the browser", () =>
|
||||
}) => {
|
||||
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.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
|
||||
// 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 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 }) => {
|
||||
@@ -63,12 +63,14 @@ test.describe("management workflows, driven end to end from the browser", () =>
|
||||
await page.goto("/tools");
|
||||
await page.getByRole("button", { name: "Validate and preview" }).click();
|
||||
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" });
|
||||
if (await outcome.filter({ hasText: "Valid preview" }).count()) {
|
||||
await expect(applyButton).toBeEnabled();
|
||||
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 expect(page.getByText(/Catalog reconciliation completed and verified/)).toBeVisible({ timeout: 15_000 });
|
||||
} else {
|
||||
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 }) => {
|
||||
await page.goto("/tools");
|
||||
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: "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");
|
||||
const row = page.getByRole("row", { name: /Fleet Ops Operations Summary/ });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
coverage: { reporter: ["text", "json-summary"] },
|
||||
coverage: { include: ["src/**/*.{ts,tsx}"], reporter: ["text", "json-summary"] },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -118,7 +118,7 @@ class FakeGiteaServer:
|
||||
"html_url": "https://gitea.example/acme/hub/issues/1",
|
||||
}
|
||||
]
|
||||
self._pulls = [
|
||||
self._pulls: list[dict[str, Any]] = [
|
||||
{
|
||||
"number": 7,
|
||||
"title": "Add governed Codex workflows",
|
||||
@@ -420,7 +420,9 @@ class FakeGiteaServer:
|
||||
)
|
||||
if body.get("state") == "open":
|
||||
self._action_run.update(
|
||||
event="pull_request", status="queued", conclusion="",
|
||||
event="pull_request",
|
||||
status="queued",
|
||||
conclusion="",
|
||||
head_sha=self._pulls[0]["head"]["sha"],
|
||||
)
|
||||
return self._json(200, self._pulls[0], request)
|
||||
|
||||
+97
-89
@@ -16,7 +16,7 @@ from threading import Lock
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
import yaml
|
||||
import yaml # type: ignore[import-untyped]
|
||||
|
||||
from packages.connector_kit import (
|
||||
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."""
|
||||
|
||||
|
||||
@@ -205,21 +205,17 @@ def _projectbrain_managed_validation_is_strong(workflow_content: str) -> bool:
|
||||
return False
|
||||
|
||||
triggers = _workflow_mapping(document.get("on"))
|
||||
if (
|
||||
triggers is None
|
||||
or set(triggers) != {"push", "pull_request", "workflow_dispatch"}
|
||||
):
|
||||
if triggers is None or set(triggers) != {"push", "pull_request", "workflow_dispatch"}:
|
||||
return False
|
||||
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 = (
|
||||
_workflow_mapping(dispatch_inputs.get("profile"))
|
||||
if dispatch_inputs is not None
|
||||
else None
|
||||
_workflow_mapping(dispatch_inputs.get("profile")) if dispatch_inputs is not None else None
|
||||
)
|
||||
if (
|
||||
dispatch_inputs is None
|
||||
or set(dispatch) != {"inputs"}
|
||||
or set(dispatch_inputs) != {"profile"}
|
||||
or profile_input is None
|
||||
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
|
||||
push = _workflow_mapping(triggers.get("push"))
|
||||
if (
|
||||
push is None
|
||||
or set(push) != {"branches"}
|
||||
or push.get("branches") != ["main"]
|
||||
):
|
||||
if push is None or set(push) != {"branches"} or push.get("branches") != ["main"]:
|
||||
return False
|
||||
pull_request = triggers.get("pull_request")
|
||||
if pull_request not in (None, ""):
|
||||
@@ -1028,9 +1020,7 @@ class GiteaConnector:
|
||||
if isinstance(existing_contexts, list)
|
||||
else set()
|
||||
)
|
||||
desired["status_check_contexts"] = sorted(
|
||||
set(required_contexts).union(preserved_contexts)
|
||||
)
|
||||
desired["status_check_contexts"] = sorted(set(required_contexts).union(preserved_contexts))
|
||||
current_projection = _branch_protection_projection(current)
|
||||
protection_diff = {
|
||||
key: {
|
||||
@@ -1061,12 +1051,9 @@ class GiteaConnector:
|
||||
if workflow_content is not None
|
||||
else None
|
||||
)
|
||||
workflow_profile = _managed_validation_profile(
|
||||
repository.qualified_name, workflow_content
|
||||
)
|
||||
workflow_profile = _managed_validation_profile(repository.qualified_name, workflow_content)
|
||||
is_projectbrain = (
|
||||
repository.qualified_name.casefold()
|
||||
== _PROJECTBRAIN_MANAGED_VALIDATION_REPOSITORY
|
||||
repository.qualified_name.casefold() == _PROJECTBRAIN_MANAGED_VALIDATION_REPOSITORY
|
||||
)
|
||||
if is_projectbrain and workflow_profile is None:
|
||||
blockers.append("managed-validation-projectbrain-profile-invalid")
|
||||
@@ -1083,9 +1070,7 @@ class GiteaConnector:
|
||||
else "update"
|
||||
)
|
||||
desired_workflow_digest = (
|
||||
workflow_digest
|
||||
if workflow_profile == "projectbrain"
|
||||
else MANAGED_VALIDATION_DIGEST
|
||||
workflow_digest if workflow_profile == "projectbrain" else MANAGED_VALIDATION_DIGEST
|
||||
)
|
||||
if is_projectbrain and current_projection is not None:
|
||||
if any(
|
||||
@@ -1250,11 +1235,7 @@ class GiteaConnector:
|
||||
if apply_scope not in {"all", "protection_only"}:
|
||||
raise GiteaError("GITEA_INPUT_INVALID", "governance apply scope is not allowlisted")
|
||||
plan = self._governance_plan_data(context, repository_ref)
|
||||
expected_hash = (
|
||||
plan["planHash"]
|
||||
if apply_scope == "all"
|
||||
else plan["protectionOnlyPlanHash"]
|
||||
)
|
||||
expected_hash = plan["planHash"] if apply_scope == "all" else plan["protectionOnlyPlanHash"]
|
||||
if (
|
||||
not isinstance(expected_hash, str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", expected_plan_hash)
|
||||
@@ -1267,9 +1248,8 @@ class GiteaConnector:
|
||||
|
||||
if apply_scope == "protection_only":
|
||||
allowed_blockers = {"managed-validation-projectbrain-profile-invalid"}
|
||||
if (
|
||||
not plan["_protectionOnlyAvailable"]
|
||||
or any(blocker not in allowed_blockers for blocker in plan["blockers"])
|
||||
if not plan["_protectionOnlyAvailable"] or any(
|
||||
blocker not in allowed_blockers for blocker in plan["blockers"]
|
||||
):
|
||||
raise GiteaError(
|
||||
"GITEA_GOVERNANCE_BLOCKED",
|
||||
@@ -1311,16 +1291,17 @@ class GiteaConnector:
|
||||
actual_effective_protection = _branch_protection_effective_projection(
|
||||
verified_plan["_currentProtection"]
|
||||
)
|
||||
actual_contexts = set(
|
||||
verified_protection.get("status_check_contexts") or []
|
||||
) if verified_protection is not None else set()
|
||||
actual_contexts = (
|
||||
set(verified_protection.get("status_check_contexts") or [])
|
||||
if verified_protection is not None
|
||||
else set()
|
||||
)
|
||||
if (
|
||||
verified_protection is None
|
||||
or verified_protection.get("enable_status_check") is not True
|
||||
or actual_contexts != set(expected_contexts)
|
||||
or actual_effective_protection != expected_effective_protection
|
||||
or verified_plan["workflow"]["currentDigest"]
|
||||
!= plan["workflow"]["currentDigest"]
|
||||
or verified_plan["workflow"]["currentDigest"] != plan["workflow"]["currentDigest"]
|
||||
):
|
||||
raise GiteaError(
|
||||
"GITEA_GOVERNANCE_VERIFY_FAILED",
|
||||
@@ -1379,9 +1360,7 @@ class GiteaConnector:
|
||||
writer.create_branch_protection(repository, desired)
|
||||
elif protection_action == "update":
|
||||
if plan["workflow"]["profile"] == "projectbrain":
|
||||
edit_payload = {
|
||||
"status_check_contexts": desired["status_check_contexts"]
|
||||
}
|
||||
edit_payload = {"status_check_contexts": desired["status_check_contexts"]}
|
||||
else:
|
||||
edit_payload = {
|
||||
key: value
|
||||
@@ -1420,28 +1399,22 @@ class GiteaConnector:
|
||||
verified_plan = self._governance_plan_data(context, repository_ref)
|
||||
is_projectbrain_profile = plan["workflow"]["profile"] == "projectbrain"
|
||||
verified_contexts = set(
|
||||
(verified_plan["branchProtection"]["current"] or {}).get(
|
||||
"status_check_contexts"
|
||||
(verified_plan["branchProtection"]["current"] or {}).get("status_check_contexts") or []
|
||||
)
|
||||
or []
|
||||
)
|
||||
expected_contexts = set(
|
||||
verified_expected_contexts = set(
|
||||
plan["branchProtection"]["desired"].get("status_check_contexts") or []
|
||||
)
|
||||
projectbrain_protection_changed = is_projectbrain_profile and (
|
||||
verified_contexts != expected_contexts
|
||||
verified_contexts != verified_expected_contexts
|
||||
or (
|
||||
plan["_currentProtection"] is not None
|
||||
and _branch_protection_effective_projection(
|
||||
verified_plan["_currentProtection"]
|
||||
)
|
||||
and _branch_protection_effective_projection(verified_plan["_currentProtection"])
|
||||
!= _branch_protection_effective_projection(plan["_currentProtection"])
|
||||
)
|
||||
)
|
||||
projectbrain_workflow_changed = (
|
||||
is_projectbrain_profile
|
||||
and verified_plan["workflow"]["currentDigest"]
|
||||
!= plan["workflow"]["currentDigest"]
|
||||
and verified_plan["workflow"]["currentDigest"] != plan["workflow"]["currentDigest"]
|
||||
)
|
||||
if (
|
||||
not verified_plan["compliant"]
|
||||
@@ -2245,7 +2218,9 @@ class GiteaConnector:
|
||||
next_actions.append("fix-validation")
|
||||
if pull is not None and not ready_to_merge:
|
||||
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",
|
||||
}:
|
||||
next_actions.append("ensure-pr-validation")
|
||||
@@ -2276,9 +2251,8 @@ class GiteaConnector:
|
||||
),
|
||||
"validationState": (
|
||||
"pr_check_missing"
|
||||
if merge_eligibility["reason"] in {
|
||||
"GITEA_PR_CHECK_MISSING", "GITEA_PR_VALIDATION_REQUIRED"
|
||||
}
|
||||
if merge_eligibility["reason"]
|
||||
in {"GITEA_PR_CHECK_MISSING", "GITEA_PR_VALIDATION_REQUIRED"}
|
||||
else "pr_check_pending"
|
||||
if merge_eligibility["reason"] == "GITEA_PR_CHECK_PENDING"
|
||||
else "pr_check_failed"
|
||||
@@ -3924,7 +3898,10 @@ class GiteaConnector:
|
||||
|
||||
@governed_tool("gitea.pullrequest.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,
|
||||
) -> dict[str, Any]:
|
||||
"""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:
|
||||
raise GiteaError("GITEA_TARGET_INVALID", "PR does not target the default branch")
|
||||
protection = self.client.branch_protection(repository, str(base.get("ref", "")))
|
||||
if (
|
||||
not protection.get("enable_status_check")
|
||||
or MANAGED_VALIDATION_CONTEXT not in
|
||||
(protection.get("status_check_contexts") or [])
|
||||
if not protection.get("enable_status_check") or MANAGED_VALIDATION_CONTEXT not in (
|
||||
protection.get("status_check_contexts") or []
|
||||
):
|
||||
raise GiteaError("GITEA_PROTECTION_DRIFT", "canonical PR check is not required")
|
||||
|
||||
@@ -3958,7 +3933,8 @@ class GiteaConnector:
|
||||
break
|
||||
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"
|
||||
and str(item.get("head_sha", "")) == expected_head_sha
|
||||
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()
|
||||
@@ -3983,9 +3960,11 @@ class GiteaConnector:
|
||||
for run in runs:
|
||||
run_id = int(run["id"])
|
||||
if (
|
||||
current_status and current_status.get("status") == "success"
|
||||
and re.search(rf"/actions/runs/{run_id}(?:/|$)",
|
||||
str(current_status.get("target_url", "")))
|
||||
current_status
|
||||
and current_status.get("status") == "success"
|
||||
and re.search(
|
||||
rf"/actions/runs/{run_id}(?:/|$)", str(current_status.get("target_url", ""))
|
||||
)
|
||||
and run.get("status") == "completed"
|
||||
and run.get("conclusion") == "success"
|
||||
and any(
|
||||
@@ -3993,19 +3972,35 @@ class GiteaConnector:
|
||||
for job in self.client.action_run_jobs(repository, run_id)
|
||||
)
|
||||
):
|
||||
return self._envelope(context, {
|
||||
"repositoryRef": repository_ref, "pr": number,
|
||||
"headSha": expected_head_sha, "requiredContext": MANAGED_VALIDATION_CONTEXT,
|
||||
"state": "success", "runId": run_id, "event": "pull_request",
|
||||
return self._envelope(
|
||||
context,
|
||||
{
|
||||
"repositoryRef": repository_ref,
|
||||
"pr": number,
|
||||
"headSha": expected_head_sha,
|
||||
"requiredContext": MANAGED_VALIDATION_CONTEXT,
|
||||
"state": "success",
|
||||
"runId": run_id,
|
||||
"event": "pull_request",
|
||||
"reused": True,
|
||||
}, source=f"gitea:{repository_ref}")
|
||||
},
|
||||
source=f"gitea:{repository_ref}",
|
||||
)
|
||||
if run.get("status") != "completed":
|
||||
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",
|
||||
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": True,
|
||||
}, source=f"gitea:{repository_ref}")
|
||||
},
|
||||
source=f"gitea:{repository_ref}",
|
||||
)
|
||||
break
|
||||
|
||||
if runs:
|
||||
@@ -4041,7 +4036,8 @@ class GiteaConnector:
|
||||
raise GiteaError(
|
||||
"GITEA_PR_REOPEN_FAILED",
|
||||
"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
|
||||
trigger_method = "reopen_pull_request"
|
||||
run_id = 0
|
||||
@@ -4064,14 +4060,24 @@ class GiteaConnector:
|
||||
raise GiteaError(
|
||||
"GITEA_PR_RUN_NOT_VISIBLE",
|
||||
"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")
|
||||
def pull_request_close(
|
||||
@@ -4212,7 +4218,10 @@ class GiteaConnector:
|
||||
)
|
||||
|
||||
def _require_merge_eligible(
|
||||
self, context: ConnectorContext, repository_ref: str, number: int,
|
||||
self,
|
||||
context: ConnectorContext,
|
||||
repository_ref: str,
|
||||
number: int,
|
||||
expected_head_sha: str,
|
||||
) -> tuple[RegisteredRepository, str, int, str]:
|
||||
"""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")
|
||||
required_approvals = max(int(protection.get("required_approvals", 0) or 0), 0)
|
||||
approval_count = sum(
|
||||
str(item.get("state", "")).upper() == "APPROVED"
|
||||
for item in latest_reviews.values()
|
||||
str(item.get("state", "")).upper() == "APPROVED" for item in latest_reviews.values()
|
||||
)
|
||||
if approval_count < required_approvals:
|
||||
raise GiteaError(
|
||||
|
||||
@@ -11,5 +11,14 @@
|
||||
},
|
||||
"engines": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -777,9 +777,7 @@ class GiteaClient:
|
||||
),
|
||||
)
|
||||
|
||||
def reopen_pull_request(
|
||||
self, repository: RegisteredRepository, index: int
|
||||
) -> dict[str, Any]:
|
||||
def reopen_pull_request(self, repository: RegisteredRepository, index: int) -> dict[str, Any]:
|
||||
return cast(
|
||||
dict[str, Any],
|
||||
self._request(
|
||||
@@ -822,9 +820,7 @@ class GiteaClient:
|
||||
return self._merge_strategy_key
|
||||
payload = self._request(self._path("version"))
|
||||
version = payload.get("version") if isinstance(payload, dict) else None
|
||||
legacy_match = re.fullmatch(
|
||||
r"1\.(14|25|26|27)(?:\.\d+)?(?:[-+].*)?", str(version or "")
|
||||
)
|
||||
legacy_match = re.fullmatch(r"1\.(14|25|26|27)(?:\.\d+)?(?:[-+].*)?", 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:
|
||||
raise GiteaError(
|
||||
@@ -835,9 +831,7 @@ class GiteaClient:
|
||||
),
|
||||
)
|
||||
self._merge_strategy_key = (
|
||||
"Do"
|
||||
if legacy_match is not None and legacy_match.group(1) in {"14", "25"}
|
||||
else "do"
|
||||
"Do" if legacy_match is not None and legacy_match.group(1) in {"14", "25"} else "do"
|
||||
)
|
||||
return self._merge_strategy_key
|
||||
|
||||
@@ -1053,14 +1047,19 @@ class GiteaClient:
|
||||
payload = payload.get("workflow_runs") or payload.get("runs")
|
||||
return self._as_list(payload)
|
||||
|
||||
def rerun_action_run(
|
||||
self, repository: RegisteredRepository, run_id: int
|
||||
) -> dict[str, Any]:
|
||||
def rerun_action_run(self, repository: RegisteredRepository, run_id: int) -> dict[str, Any]:
|
||||
return cast(
|
||||
dict[str, Any],
|
||||
self._request(
|
||||
self._path("repos", repository.owner, repository.name,
|
||||
"actions", "runs", str(run_id), "rerun"),
|
||||
self._path(
|
||||
"repos",
|
||||
repository.owner,
|
||||
repository.name,
|
||||
"actions",
|
||||
"runs",
|
||||
str(run_id),
|
||||
"rerun",
|
||||
),
|
||||
method="POST",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -100,10 +100,12 @@ class AuditEvent(BaseModel):
|
||||
matched_rule_ids=trace.matched_rule_ids,
|
||||
policy_trace=tuple(item.model_dump(mode="json") for item in trace.steps),
|
||||
arguments_hash=hashlib.sha256(raw).hexdigest(),
|
||||
sanitized_arguments=sanitize_arguments({
|
||||
sanitized_arguments=sanitize_arguments(
|
||||
{
|
||||
**dict(context.arguments),
|
||||
**({"_result": result_summary} if result_summary else {}),
|
||||
}),
|
||||
}
|
||||
),
|
||||
duration_ms=min(120_000, int((time.monotonic() - started) * 1000)),
|
||||
outcome=outcome,
|
||||
error_code=error_code,
|
||||
|
||||
@@ -117,7 +117,11 @@ class Governance:
|
||||
) -> None:
|
||||
self.audit.append(
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
Generated
+263
-579
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"pyyaml==6.0.2",
|
||||
"psycopg[binary]==3.2.10",
|
||||
"pydantic==2.12.0",
|
||||
"pyjwt[crypto]==2.13.0",
|
||||
"pyjwt[crypto]==2.15.0",
|
||||
"sqlalchemy==2.0.42",
|
||||
"dulwich==1.2.13",
|
||||
"pypdf==6.19.0",
|
||||
@@ -26,6 +26,7 @@ default-groups = ["dev"]
|
||||
constraint-dependencies = [
|
||||
"httpcore2>=2.10.0",
|
||||
"httpx2>=2.12.0",
|
||||
"urllib3==2.8.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -129,6 +129,7 @@ CONTROLLED_PROJECTBRAIN_WRITE_TOOLS = frozenset(
|
||||
|
||||
CONTROLLED_APPOPS_WRITE_TOOLS = frozenset(
|
||||
{
|
||||
"server.appops.self_upgrade.apply",
|
||||
"server.operation.request",
|
||||
"server.operation.execute",
|
||||
"server.job.cancel",
|
||||
@@ -174,6 +175,7 @@ GITEA_LIFECYCLE_TOOLS = frozenset(
|
||||
)
|
||||
APPOPS_LIFECYCLE_TOOLS = frozenset(
|
||||
{
|
||||
"server.appops.self_upgrade.apply",
|
||||
"server.operation.execute",
|
||||
"server.deployment.apply",
|
||||
"server.deployment.rollback",
|
||||
|
||||
@@ -95,6 +95,7 @@ def main() -> int:
|
||||
"infrastructure/database/alembic/versions/0004_audit_retention.py",
|
||||
"tests/test_auth.py",
|
||||
"tests/test_catalog.py",
|
||||
"tests/test_catalog_write_validation.py",
|
||||
"tests/test_dataplane_router.py",
|
||||
"tests/test_knowledge_connector.py",
|
||||
"tests/test_gitea_connector.py",
|
||||
@@ -125,6 +126,11 @@ def main() -> int:
|
||||
"tests/test_wp230_session_store.py",
|
||||
"tests/test_wp270_functional_operations.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/tls.py",
|
||||
"tests/test_promotion_preflight.py",
|
||||
|
||||
@@ -23,6 +23,7 @@ against every route.
|
||||
| tool.invoke | | | X | X |
|
||||
| operations.check | | | X | X |
|
||||
| client.bootstrap | | | X | X |
|
||||
| client.verify | | | X | X |
|
||||
| client.disable | | | X | X |
|
||||
| client.revoke | | | X | X |
|
||||
| client.create | | | | X |
|
||||
@@ -62,6 +63,7 @@ ENDPOINT_PERMISSIONS: Mapping[str, frozenset[ManagementRole]] = {
|
||||
"operations.check": frozenset({_R.OPERATOR, _R.ADMIN}),
|
||||
"client.read": frozenset({_R.VIEWER, _R.AUDITOR, _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.revoke": frozenset({_R.OPERATOR, _R.ADMIN}),
|
||||
"client.create": frozenset({_R.ADMIN}),
|
||||
|
||||
@@ -150,6 +150,7 @@ class CatalogLoader:
|
||||
documents: list[CatalogDocument] = []
|
||||
source_files: list[str] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
schema_store: dict[str, Any] | None = None
|
||||
if not self.root.is_dir():
|
||||
return ValidationReport(
|
||||
valid=False,
|
||||
@@ -191,7 +192,9 @@ class CatalogLoader:
|
||||
continue
|
||||
issues.extend(self._secret_issues(raw, path, []))
|
||||
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(
|
||||
Draft202012Validator(schema, resolver=resolver).iter_errors(raw),
|
||||
key=lambda item: list(item.path),
|
||||
|
||||
@@ -9,7 +9,13 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
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
|
||||
|
||||
@@ -50,6 +56,9 @@ def build_client_router(
|
||||
*,
|
||||
authenticate: Callable[[Request], Any] = _deny_by_default,
|
||||
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",
|
||||
token_endpoint: str = "https://auth.example.invalid/application/o/token/", # noqa: S107
|
||||
oauth_client_id: str = "itworx-mcp-hub",
|
||||
@@ -112,6 +121,28 @@ def build_client_router(
|
||||
except KeyError as 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)
|
||||
def bootstrap(client_id: UUID, principal: Any = authenticated) -> BootstrapHandle:
|
||||
authorize(principal, "client.bootstrap")
|
||||
@@ -126,15 +157,25 @@ def build_client_router(
|
||||
def disable(client_id: UUID, principal: Any = authenticated) -> ClientRecord:
|
||||
authorize(principal, "client.disable")
|
||||
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:
|
||||
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)
|
||||
def revoke(client_id: UUID, principal: Any = authenticated) -> ClientRecord:
|
||||
authorize(principal, "client.revoke")
|
||||
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:
|
||||
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.orm import Session
|
||||
|
||||
from packages.hub_clients import ClientKind
|
||||
|
||||
from .models import (
|
||||
ClientBindingRow,
|
||||
ClientRow,
|
||||
@@ -114,6 +116,12 @@ def provision_machine_client(
|
||||
or by_id.status != "pending"
|
||||
):
|
||||
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
|
||||
metadata = {
|
||||
"managedBy": "authentik-machine-provisioning",
|
||||
@@ -121,7 +129,7 @@ def provision_machine_client(
|
||||
"name": name,
|
||||
"project": project_slug,
|
||||
"environment": environment_name,
|
||||
"kind": "stdio-bridge",
|
||||
"kind": kind.value,
|
||||
"oauthUsername": oauth_username,
|
||||
"credentialReference": f"authentik://service-account/{oauth_username}",
|
||||
"operatorDisabled": False,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
@@ -14,7 +16,9 @@ from packages.hub_clients import (
|
||||
ClientStatus,
|
||||
generate_config,
|
||||
)
|
||||
from services.control_api.auth.models import AuthenticatedPrincipal
|
||||
from services.control_api.persistence.models import (
|
||||
AuditEventRow,
|
||||
ClientBindingRow,
|
||||
ClientRow,
|
||||
EnvironmentRow,
|
||||
@@ -158,13 +162,21 @@ class PersistentClientService:
|
||||
selected = record if kind is None else record.model_copy(update={"kind": kind})
|
||||
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)
|
||||
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:
|
||||
raise KeyError(client_id)
|
||||
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
|
||||
if status is ClientStatus.DISABLED:
|
||||
metadata["disabledAt"] = now.isoformat()
|
||||
@@ -173,18 +185,55 @@ class PersistentClientService:
|
||||
row.status = "revoked"
|
||||
row.revoked_at = now
|
||||
row.record_metadata = metadata
|
||||
for binding in session.scalars(
|
||||
bindings = session.scalars(
|
||||
select(ClientBindingRow).where(ClientBindingRow.client_id == client_id)
|
||||
).all():
|
||||
).all()
|
||||
for binding in bindings:
|
||||
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()
|
||||
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)
|
||||
|
||||
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:
|
||||
return self._transition(client_id, ClientStatus.REVOKED)
|
||||
return self.transition(client_id, ClientStatus.REVOKED)
|
||||
|
||||
def bootstrap(self, client_id: UUID) -> BootstrapHandle:
|
||||
record = self._get(client_id)
|
||||
|
||||
@@ -677,9 +677,7 @@ def build_server() -> MCPServer[Any]:
|
||||
)
|
||||
async def portfolio_governance_apply(
|
||||
context: Context[Any, Any],
|
||||
plans: Annotated[
|
||||
list[PortfolioGovernanceApplyPlan], Field(min_length=1, max_length=5)
|
||||
],
|
||||
plans: Annotated[list[PortfolioGovernanceApplyPlan], Field(min_length=1, max_length=5)],
|
||||
) -> dict[str, Any]:
|
||||
"""Apply at most five exact governance plans; every item is independently verified."""
|
||||
return connector.portfolio_governance_apply(
|
||||
@@ -813,7 +811,9 @@ def build_server() -> MCPServer[Any]:
|
||||
structured_output=True,
|
||||
)
|
||||
async def pull_request_validation_ensure(
|
||||
repositoryRef: str, number: int, expectedHeadSha: str,
|
||||
repositoryRef: str,
|
||||
number: int,
|
||||
expectedHeadSha: str,
|
||||
context: Context[Any, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Ensure a real canonical full PR run for the exact current head."""
|
||||
|
||||
+1213
-18
File diff suppressed because it is too large.
Load diff
+29
-4
@@ -11,8 +11,10 @@ authorization, and the real policy engine -- not a mocked frontend:
|
||||
-> fake ContextForge (in-process MockTransport; nothing the
|
||||
browser talks to directly, so no real HTTP needed here)
|
||||
|
||||
Each subcommand below blocks running one of these processes; Playwright's
|
||||
`webServer` config (apps/web/playwright.config.ts) starts all three and
|
||||
The worker readiness dependency is an explicit offline fixture; worker reconciliation
|
||||
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.
|
||||
|
||||
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 uvicorn # noqa: E402
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
|
||||
from services.control_api.auth.config import OIDCSettings # 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")
|
||||
|
||||
|
||||
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():
|
||||
raise SystemExit(
|
||||
f"{dist_root} has no built frontend; "
|
||||
"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_WORKER_URL"] = f"http://127.0.0.1:{worker_port}"
|
||||
os.environ["HUB_WEB_ROOT"] = str(dist_root)
|
||||
tls = generate_local_tls_material(STATE_DIR / "tls-web")
|
||||
uvicorn.run(
|
||||
@@ -158,15 +177,21 @@ def main() -> None:
|
||||
web_parser = sub.add_parser("web")
|
||||
web_parser.add_argument("--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)
|
||||
|
||||
worker_parser = sub.add_parser("worker")
|
||||
worker_parser.add_argument("--port", type=int, required=True)
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.command == "oidc":
|
||||
run_oidc(args.port)
|
||||
elif args.command == "control-api":
|
||||
run_control_api(args.port, args.oidc_port, args.web_port)
|
||||
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__":
|
||||
|
||||
@@ -33,14 +33,8 @@ def test_appops_tool_catalog_is_bijective_and_server_namespaced() -> None:
|
||||
PUBLIC_TO_UPSTREAM["server.appops.self_upgrade.preflight"]
|
||||
== "appops.self_upgrade.preflight"
|
||||
)
|
||||
assert (
|
||||
PUBLIC_TO_UPSTREAM["server.appops.self_upgrade.apply"]
|
||||
== "appops.self_upgrade.apply"
|
||||
)
|
||||
assert (
|
||||
PUBLIC_TO_UPSTREAM["server.appops.self_upgrade.status"]
|
||||
== "appops.self_upgrade.status"
|
||||
)
|
||||
assert PUBLIC_TO_UPSTREAM["server.appops.self_upgrade.apply"] == "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:
|
||||
@@ -60,9 +54,7 @@ def test_appops_self_upgrade_manifests_are_fixed_target_and_fingerprint_bound()
|
||||
for path in (ROOT / "catalog" / "tools").glob("*.yaml"):
|
||||
document = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
spec = document.get("spec") if isinstance(document, dict) else None
|
||||
if isinstance(spec, dict) and spec.get("canonicalName", "").startswith(
|
||||
"server.appops."
|
||||
):
|
||||
if isinstance(spec, dict) and spec.get("canonicalName", "").startswith("server.appops."):
|
||||
specs[spec["canonicalName"]] = spec
|
||||
|
||||
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"]
|
||||
assert apply["riskClass"] == "R3"
|
||||
assert apply["readOnly"] is False
|
||||
assert apply["inputSchema"]["properties"]["expected_preflight_fingerprint"][
|
||||
"pattern"
|
||||
] == "^[0-9a-f]{64}$"
|
||||
assert (
|
||||
apply["inputSchema"]["properties"]["expected_preflight_fingerprint"]["pattern"]
|
||||
== "^[0-9a-f]{64}$"
|
||||
)
|
||||
assert {
|
||||
"expected_preflight_fingerprint",
|
||||
"idempotency_key",
|
||||
|
||||
@@ -76,6 +76,11 @@ class BootstrapTests(unittest.TestCase):
|
||||
"apps/web/src/rbac-context.tsx",
|
||||
"apps/web/src/navigation.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/Breadcrumbs.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/SessionMenu.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/CatalogWorkflow.tsx",
|
||||
"apps/web/src/pages/ClientOnboarding.tsx",
|
||||
"apps/web/src/pages/ConnectorDetail.tsx",
|
||||
"apps/web/src/pages/Dashboard.tsx",
|
||||
"apps/web/src/pages/ForbiddenPanel.tsx",
|
||||
"apps/web/src/pages/Operations.tsx",
|
||||
@@ -102,6 +111,10 @@ class BootstrapTests(unittest.TestCase):
|
||||
"apps/web/tests/contracts.test.ts",
|
||||
"apps/web/tests/api.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/auth.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/accessibility.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/tests/bootstrap.test.ts",
|
||||
"apps/web/vite.config.ts",
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI
|
||||
@@ -30,6 +31,59 @@ def adapter_for(fake: FakeContextForge) -> ContextForgeClient:
|
||||
|
||||
|
||||
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:
|
||||
report = CatalogLoader(ROOT / "catalog").load()
|
||||
self.assertTrue(report.valid, report.issues)
|
||||
|
||||
@@ -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)
|
||||
@@ -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() == []
|
||||
@@ -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",)
|
||||
@@ -267,8 +267,7 @@ class GiteaConnectorTests(unittest.TestCase):
|
||||
"Jens/projectbrain",
|
||||
PROJECTBRAIN_VALIDATION_WORKFLOW.replace(
|
||||
" - name: Validate repository with a bounded profile\n",
|
||||
" - name: Validate repository with a bounded profile\n"
|
||||
" if: false\n",
|
||||
" - name: Validate repository with a bounded profile\n if: false\n",
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -293,14 +292,11 @@ class GiteaConnectorTests(unittest.TestCase):
|
||||
bypass_mutations = (
|
||||
PROJECTBRAIN_VALIDATION_WORKFLOW.replace(
|
||||
" pnpm test:e2e:real\n",
|
||||
" cat <<'EOF'\n"
|
||||
" pnpm test:e2e:real\n"
|
||||
" EOF\n",
|
||||
" cat <<'EOF'\n pnpm test:e2e:real\n EOF\n",
|
||||
),
|
||||
PROJECTBRAIN_VALIDATION_WORKFLOW.replace(
|
||||
" pnpm --dir backend test:database\n",
|
||||
" exit 0\n"
|
||||
" pnpm --dir backend test:database\n",
|
||||
" exit 0\n pnpm --dir backend test:database\n",
|
||||
),
|
||||
PROJECTBRAIN_VALIDATION_WORKFLOW.replace(
|
||||
" pnpm test:e2e:real\n",
|
||||
@@ -357,9 +353,7 @@ class GiteaConnectorTests(unittest.TestCase):
|
||||
"Jens/projectbrain", PROJECTBRAIN_VALIDATION_WORKFLOW + "\njobs: {}\n"
|
||||
)
|
||||
)
|
||||
self.assertIsNone(
|
||||
_managed_validation_profile("Acme/hub", PROJECTBRAIN_VALIDATION_WORKFLOW)
|
||||
)
|
||||
self.assertIsNone(_managed_validation_profile("Acme/hub", PROJECTBRAIN_VALIDATION_WORKFLOW))
|
||||
self.assertEqual(
|
||||
_managed_validation_profile("Acme/hub", MANAGED_VALIDATION_WORKFLOW),
|
||||
"canonical",
|
||||
@@ -1400,9 +1394,7 @@ class GiteaConnectorTests(unittest.TestCase):
|
||||
fixture._branch_protections["Jens/projectbrain"][0]["status_check_contexts"] = [
|
||||
"Pull request / quality (pull_request)"
|
||||
]
|
||||
original_protection = copy.deepcopy(
|
||||
fixture._branch_protections["Jens/projectbrain"][0]
|
||||
)
|
||||
original_protection = copy.deepcopy(fixture._branch_protections["Jens/projectbrain"][0])
|
||||
try:
|
||||
current = context()
|
||||
preview = connector_instance.branch_protection_preview(
|
||||
@@ -1467,9 +1459,9 @@ class GiteaConnectorTests(unittest.TestCase):
|
||||
nonlocal read_count
|
||||
read_count += 1
|
||||
if read_count == 3:
|
||||
fixture._branch_protections["Jens/projectbrain"][0][
|
||||
"status_check_contexts"
|
||||
].remove("Additional required check")
|
||||
fixture._branch_protections["Jens/projectbrain"][0]["status_check_contexts"].remove(
|
||||
"Additional required check"
|
||||
)
|
||||
return original_read(repository)
|
||||
|
||||
try:
|
||||
@@ -1704,9 +1696,9 @@ class GiteaConnectorTests(unittest.TestCase):
|
||||
nonlocal read_count
|
||||
read_count += 1
|
||||
if read_count == 3:
|
||||
fixture._branch_protections["Jens/projectbrain"][0][
|
||||
"status_check_contexts"
|
||||
].remove("Additional required check")
|
||||
fixture._branch_protections["Jens/projectbrain"][0]["status_check_contexts"].remove(
|
||||
"Additional required check"
|
||||
)
|
||||
return original_read(repository)
|
||||
|
||||
try:
|
||||
|
||||
@@ -4,6 +4,7 @@ from uuid import uuid4
|
||||
|
||||
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.machine_client import provision_machine_client
|
||||
from services.control_api.persistence.models import (
|
||||
@@ -14,6 +15,7 @@ from services.control_api.persistence.models import (
|
||||
VirtualServerRow,
|
||||
WorkspaceRow,
|
||||
)
|
||||
from services.runtime.client_service import PersistentClientService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -114,8 +116,19 @@ def test_revoked_machine_client_cannot_be_silently_reactivated(database) -> None
|
||||
_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()
|
||||
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():
|
||||
workspace = session.query(WorkspaceRow).filter_by(slug="itworx").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}",
|
||||
issuer="https://auth.itworx.test/application/o/itworx-mcp-hub/",
|
||||
status="pending",
|
||||
record_metadata={
|
||||
"managedBy": "runtime-onboarding",
|
||||
"project": "ludarium",
|
||||
"environment": "prod",
|
||||
},
|
||||
record_metadata=metadata,
|
||||
)
|
||||
)
|
||||
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.subject == "b7f5d1aa-f199-4334-b077-f6be0870115f"
|
||||
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"
|
||||
@@ -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"] == []
|
||||
@@ -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
|
||||
@@ -47,19 +47,29 @@ def invocation(
|
||||
class WP110GovernanceTests(unittest.TestCase):
|
||||
def test_merge_result_is_retained_in_append_only_audit(self) -> None:
|
||||
audit = InMemoryAuditLog()
|
||||
engine = PolicyEngine([
|
||||
engine = PolicyEngine(
|
||||
[
|
||||
PolicyRule(
|
||||
slug="allow-merge", project="ludarium", priority=10,
|
||||
description="allow merge", match={"tool": "gitea.pullrequest.merge"},
|
||||
slug="allow-merge",
|
||||
project="ludarium",
|
||||
priority=10,
|
||||
description="allow merge",
|
||||
match={"tool": "gitea.pullrequest.merge"},
|
||||
decision=Decision.ALLOW,
|
||||
)
|
||||
])
|
||||
]
|
||||
)
|
||||
governance = Governance(engine, audit)
|
||||
evidence = {
|
||||
"repositoryRef": "test-repo", "pr": 7, "headSha": "e" * 40,
|
||||
"baseSha": "a" * 40, "requiredContext": "Managed validation / full (pull_request)",
|
||||
"validationRunId": 42, "validationEvent": "pull_request",
|
||||
"validationConclusion": "success", "mergeMethod": "squash",
|
||||
"repositoryRef": "test-repo",
|
||||
"pr": 7,
|
||||
"headSha": "e" * 40,
|
||||
"baseSha": "a" * 40,
|
||||
"requiredContext": "Managed validation / full (pull_request)",
|
||||
"validationRunId": 42,
|
||||
"validationEvent": "pull_request",
|
||||
"validationConclusion": "success",
|
||||
"mergeMethod": "squash",
|
||||
"resultingMainSha": "6" * 40,
|
||||
}
|
||||
result = governance.invoke(
|
||||
|
||||
@@ -93,6 +93,7 @@ def test_audit_read_denied_for_viewer_alone() -> None:
|
||||
"tool.invoke",
|
||||
"operations.check",
|
||||
"client.bootstrap",
|
||||
"client.verify",
|
||||
"client.disable",
|
||||
"client.revoke",
|
||||
],
|
||||
@@ -111,6 +112,7 @@ def test_client_lifecycle_actions_allowed_for_operator_and_admin(
|
||||
"tool.invoke",
|
||||
"operations.check",
|
||||
"client.bootstrap",
|
||||
"client.verify",
|
||||
"client.disable",
|
||||
"client.revoke",
|
||||
],
|
||||
|
||||
@@ -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",)
|
||||
denied = engine.evaluate(
|
||||
InvocationContext(
|
||||
workspace="itworx", project="gitea-loop", environment="prod",
|
||||
principal="chatgpt-gitea", tool="gitea.pullrequest.merge",
|
||||
connector="gitea", risk_class="R3",
|
||||
environment_classification="production", binding_present=False,
|
||||
workspace="itworx",
|
||||
project="gitea-loop",
|
||||
environment="prod",
|
||||
principal="chatgpt-gitea",
|
||||
tool="gitea.pullrequest.merge",
|
||||
connector="gitea",
|
||||
risk_class="R3",
|
||||
environment_classification="production",
|
||||
binding_present=False,
|
||||
client_enabled=True,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -204,8 +204,11 @@ def test_policy_simulate_available_to_every_role(
|
||||
def test_catalog_preview_available_to_every_role(
|
||||
role_sessions: dict[ManagementRole, RoleSession], role: ManagementRole
|
||||
) -> 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]
|
||||
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 ------------------
|
||||
@@ -400,6 +403,9 @@ def test_live_operations_check_available_to_operator_and_admin(
|
||||
response = role_sessions[role].post("/api/operations/check")
|
||||
assert response.status_code == 200, (role, response.text) # 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 ---------------------------
|
||||
@@ -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]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["bootstrap", "disable", "revoke"])
|
||||
@pytest.mark.parametrize("action", ["bootstrap", "verify", "disable", "revoke"])
|
||||
@pytest.mark.parametrize("role", [ManagementRole.VIEWER, ManagementRole.AUDITOR])
|
||||
def test_client_lifecycle_actions_denied_below_operator(
|
||||
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]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["bootstrap", "disable"])
|
||||
@pytest.mark.parametrize("action", ["bootstrap", "verify", "disable"])
|
||||
def test_client_lifecycle_actions_allowed_for_operator(
|
||||
role_sessions: dict[ManagementRole, RoleSession], action: str
|
||||
) -> None:
|
||||
|
||||
@@ -237,24 +237,20 @@ def test_dashboard_drift_is_measured_against_managed_connector_gateways(
|
||||
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,
|
||||
) -> None:
|
||||
"""The dashboard overview used to hardcode "unknown" for every project
|
||||
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.
|
||||
"""
|
||||
"""A valid registration is not evidence that a connector route is live."""
|
||||
dashboard = viewer_client.get("/api/dashboard")
|
||||
assert dashboard.status_code == 200
|
||||
body = dashboard.json()
|
||||
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 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")
|
||||
assert projects_list.status_code == 200
|
||||
|
||||
@@ -6,6 +6,7 @@ requires-python = "==3.13.*"
|
||||
constraints = [
|
||||
{ name = "httpcore2", specifier = ">=2.10.0" },
|
||||
{ name = "httpx2", specifier = ">=2.12.0" },
|
||||
{ name = "urllib3", specifier = "==2.8.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -382,7 +383,7 @@ requires-dist = [
|
||||
{ name = "mcp", specifier = "==2.0.0" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = "==3.2.10" },
|
||||
{ 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 = "pyyaml", specifier = "==6.0.2" },
|
||||
{ name = "sqlalchemy", specifier = "==2.0.42" },
|
||||
@@ -670,11 +671,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.13.0"
|
||||
version = "2.15.0"
|
||||
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 = [
|
||||
{ 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]
|
||||
@@ -913,11 +914,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
version = "2.8.0"
|
||||
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 = [
|
||||
{ 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]]
|
||||
|
||||
Reference in new issue
Block a user