Publish curated ITWorx_MCP_Hub source from 6c7434368ce2a594005983c5a6601a31a639e7f1

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

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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
}
]
}
+5 -5
View File
@@ -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 -1
View File
@@ -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
View File
@@ -1,4 +1,4 @@
import type { CatalogPreview, ClientActionResult, ClientOnboardingResult, DashboardSnapshot, ManagementApiClient, ManagementRole, OnboardingClientKind, OperationsCheck, OperationsSummary, PageQuery, PolicySimulation, ProjectDetailInfo, ResourceKind, ResourcePage, ToolDefinition, ToolInvocationResult, ToolReadinessResult } from "./contracts";
import type { CatalogPreview, ClientActionResult, ClientOnboardingResult, ClientOnboardingStatus, ConnectorDetailInfo, DashboardSnapshot, ManagementApiClient, ManagementRole, OnboardingClientKind, OperationsCheck, OperationsSummary, PageQuery, PolicySimulation, ProjectDetailInfo, ResourceKind, ResourcePage, ToolDefinition, ToolInvocationResult, ToolReadinessResult } from "./contracts";
export class ApiError extends Error {
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
View File
@@ -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>
);
}
+209 -22
View File
@@ -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>
);
}
+16 -5
View File
@@ -19,7 +19,7 @@ const ALL_COLUMNS: Record<ResourceKind, ColumnKey[]> = {
export function columnsFor(kind: ResourceKind): ColumnKey[] { return ALL_COLUMNS[kind]; }
export function 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}
+38 -42
View File
@@ -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>;
}
+15 -1
View File
@@ -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>;
}
+114
View File
@@ -0,0 +1,114 @@
const FOCUSABLE_SELECTOR = [
"a[href]",
"area[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"iframe",
"object",
"embed",
"[contenteditable='true']",
"[tabindex]:not([tabindex='-1'])",
].join(",");
export function focusableElements(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter((element) => {
if (element.hidden || element.closest("[hidden], [inert], [aria-hidden='true']")) return false;
const closedDetails = element.closest("details:not([open])");
if (closedDetails && !closedDetails.querySelector(":scope > summary")?.contains(element)) return false;
let ancestor: HTMLElement | null = element;
while (ancestor) {
const style = window.getComputedStyle(ancestor);
if (style.display === "none" || style.visibility === "hidden" || style.contentVisibility === "hidden") return false;
if (ancestor === container) break;
ancestor = ancestor.parentElement;
}
return element.getClientRects().length > 0;
});
}
export function trapTab(event: KeyboardEvent, container: HTMLElement): void {
if (event.key !== "Tab") return;
const focusable = focusableElements(container);
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (!first || !last) {
event.preventDefault();
container.focus();
return;
}
if (!container.contains(active)) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
} else if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
}
/** Make everything outside a focus scope unavailable until its cleanup runs. */
export function isolateOutside(container: HTMLElement, interactiveExceptions: HTMLElement[] = []): () => void {
const body = document.body;
const changed = new Map<HTMLElement, { inert: boolean; ariaHidden: string | null }>();
const protect = (element: HTMLElement) => {
if (changed.has(element)) return;
changed.set(element, { inert: element.inert, ariaHidden: element.getAttribute("aria-hidden") });
element.inert = true;
element.setAttribute("aria-hidden", "true");
};
const isException = (element: HTMLElement) => interactiveExceptions.some((exception) => element === exception || element.contains(exception));
let branch: HTMLElement = container;
let parent = branch.parentElement;
while (parent) {
for (const sibling of Array.from(parent.children)) {
if (sibling !== branch && sibling instanceof HTMLElement && !isException(sibling)) protect(sibling);
}
if (parent === body) break;
branch = parent;
parent = parent.parentElement;
}
return () => {
for (const [element, original] of changed) {
element.inert = original.inert;
if (original.ariaHidden === null) element.removeAttribute("aria-hidden");
else element.setAttribute("aria-hidden", original.ariaHidden);
}
};
}
/** Lock document scrolling while preserving inline styles and the scroll position. */
export function lockBodyScroll(): () => void {
const body = document.body;
const root = document.documentElement;
const scrollX = window.scrollX;
const scrollY = window.scrollY;
const styleNames = ["overflow", "position", "top", "left", "right", "width"] as const;
const bodyStyles = new Map(styleNames.map((name) => [name, { value: body.style.getPropertyValue(name), priority: body.style.getPropertyPriority(name) }]));
const rootOverflow = { value: root.style.getPropertyValue("overflow"), priority: root.style.getPropertyPriority("overflow") };
body.style.overflow = "hidden";
body.style.position = "fixed";
body.style.top = `${-scrollY}px`;
body.style.left = `${-scrollX}px`;
body.style.right = "0";
body.style.width = "100%";
root.style.overflow = "hidden";
return () => {
for (const [name, original] of bodyStyles) {
if (original.value) body.style.setProperty(name, original.value, original.priority);
else body.style.removeProperty(name);
}
if (rootOverflow.value) root.style.setProperty("overflow", rootOverflow.value, rootOverflow.priority);
else root.style.removeProperty("overflow");
window.scrollTo(scrollX, scrollY);
};
}
+60 -7
View File
@@ -6,7 +6,11 @@ export type PrincipalKind = "management_user" | "machine_client";
export interface SessionMetadata { subject: string; clientId: string | null; kind: PrincipalKind; roles: ManagementRole[]; expiresAt: string; }
export interface 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";
}
+1 -1
View File
@@ -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;
}
+7 -1
View File
@@ -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);
}
+41
View File
@@ -0,0 +1,41 @@
import type { DashboardSnapshot, Health, OperationsSummary } from "./contracts";
export interface ActionSignal { title: string; detail: string; impact: string; nextStep: string; status: Health; href: string; subsystem: string; project?: string; observedAt: string | null; }
export function actionInbox(snapshot: DashboardSnapshot, stale: boolean, canReadAudit: boolean): ActionSignal[] {
const signals: ActionSignal[] = [];
const add = (signal: Omit<ActionSignal, "observedAt">) => signals.push({ ...signal, observedAt: snapshot.generatedAt });
if (stale) add({ title: "Operational data is stale", detail: "Health information has exceeded its freshness window.", impact: "Current availability is unknown until refreshed.", nextStep: "Refresh status before acting", status: "degraded", href: "/operations", subsystem: "Freshness" });
for (const [subsystem, status, detail] of [["Hub", snapshot.health.hub, snapshot.health.detail], ["Gateway", snapshot.health.gateway, "Trusted ContextForge route availability is not healthy."]] as const) {
if (status !== "healthy") add({ title: `${subsystem} needs investigation`, detail, impact: "Governed requests may be unavailable.", nextStep: "Inspect operational checks", status, href: "/operations?section=checks", subsystem });
}
for (const connector of snapshot.connectors.filter((item) => item.state !== "healthy")) {
const href = connector.id ? `/connectors/${encodeURIComponent(connector.id)}` : `/connectors?q=${encodeURIComponent(connector.name)}`;
add({ title: `${connector.name} · ${connector.state === "unknown" ? "health not observed" : connector.state}`, detail: connector.detail, impact: connector.state === "unknown" ? "Availability has not been verified by a live probe." : "Tools using this integration may be affected.", nextStep: "Inspect connector and scoped bindings", status: connector.state, href, subsystem: connector.name, project: connector.project === "registered" ? undefined : connector.project });
}
if (!snapshot.drift.known || snapshot.drift.count > 0) add({ title: "Catalog reconciliation needs attention", detail: snapshot.drift.known ? `${snapshot.drift.count} drifted gateway(s): ${snapshot.drift.projects.join(", ") || "Inspect reconciliation"}` : snapshot.drift.detail, impact: snapshot.drift.known ? "Published discovery may differ from the intended catalog." : "Published catalog parity is unknown.", nextStep: "Review catalog checks", status: snapshot.drift.known ? "degraded" : "unknown", href: "/operations?section=checks", subsystem: "Catalog" });
if (snapshot.backup.state !== "healthy") add({ title: "Backup freshness needs attention", detail: snapshot.backup.detail, impact: "Recovery confidence requires a verified backup and restore record.", nextStep: "Inspect recovery evidence", status: snapshot.backup.state, href: "/operations?section=recovery", subsystem: "Recovery" });
if (snapshot.denials.count > 0) add({ title: "Recent authorization denials", detail: `${snapshot.denials.count} in ${snapshot.denials.window}${snapshot.denials.latestTool ? ` · latest ${snapshot.denials.latestTool}` : ""}`, impact: "Requests were blocked by governance; the cause requires authorized audit inspection.", nextStep: canReadAudit ? "Inspect matching audit activity" : "Inspect operational alerts; detailed audit requires auditor access", status: "unknown", href: canReadAudit ? `/audit${snapshot.denials.latestTool ? `?q=${encodeURIComponent(snapshot.denials.latestTool)}` : ""}` : "/operations?section=alerts", subsystem: "Authorization" });
return signals;
}
export function alertDestination(code: string, canReadAudit: boolean): string {
if (/BACKUP|RESTORE|RECOVERY/i.test(code)) return "/operations?section=recovery";
if (/DRIFT|CATALOG|RECONCIL/i.test(code)) return "/tools";
if (/DENI|AUTH/i.test(code) && canReadAudit) return "/audit";
return "/operations?section=checks";
}
export type TrendBucket = OperationsSummary["trend"][number] & { endHour: string };
export function aggregateTrend(trend: OperationsSummary["trend"], size: number): TrendBucket[] {
const buckets: TrendBucket[] = [];
const width = Math.max(1, Math.floor(size));
for (let index = 0; index < trend.length; index += width) {
const group = trend.slice(index, index + width);
const first = group[0];
const last = group[group.length - 1];
if (!first || !last) continue;
buckets.push({ hour: first.hour, endHour: last.hour, success: group.reduce((total, item) => total + item.success, 0), denied: group.reduce((total, item) => total + item.denied, 0), error: group.reduce((total, item) => total + item.error, 0), rate_limited: group.reduce((total, item) => total + item.rate_limited, 0) });
}
return buckets;
}
+15 -7
View File
@@ -1,20 +1,28 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { CatalogPreview, ManagementApiClient } from "../contracts";
import { 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>;
}
+283 -46
View File
@@ -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>;
}
+135
View File
@@ -0,0 +1,135 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ConnectorDetailInfo, ManagementApiClient } from "../contracts";
import { ApiError, safeApiMessage } from "../api";
import { Breadcrumbs } from "../components/Breadcrumbs";
import { ErrorPanel } from "../components/ErrorPanel";
import { LoadingPanel } from "../components/LoadingPanel";
import { formatBrusselsTime } from "../format";
import { basePath } from "../routing";
import { DetailCards } from "./ProjectDetail";
import type { DetailCardItem } from "./ProjectDetail";
import "../styles/details.css";
const sections = ["Overview", "Bindings", "Related tools"] as const;
type Section = typeof sections[number];
export function ConnectorDetail({ path, navigate, api }: { path: string; navigate: (next: string) => void; api: ManagementApiClient }) {
const id = basePath(path).split("/").pop() ?? "connector";
const search = new URLSearchParams(path.split("?")[1] ?? "");
const project = search.get("project");
const environment = search.get("environment");
const requestedSection = search.get("section") ?? "Overview";
const section = sections.includes(requestedSection as Section) ? requestedSection as Section : "Overview";
const [detail, setDetail] = useState<ConnectorDetailInfo | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error" | "notFound">("loading");
const [error, setError] = useState("");
const loadGeneration = useRef(0);
const load = useCallback(async () => {
const generation = ++loadGeneration.current;
setState("loading");
setDetail(null);
setError("");
try {
if (Boolean(project) !== Boolean(environment)) {
setError("Project and environment filters must be supplied together.");
setState("error");
return;
}
const scope = project && environment ? { project, environment } : undefined;
const next = await api.connectorDetail(id, scope);
if (generation !== loadGeneration.current) return;
setDetail(next);
setState("ready");
} catch (caught) {
if (generation !== loadGeneration.current) return;
if (caught instanceof ApiError && caught.status === 404) setState("notFound");
else { setError(safeApiMessage(caught)); setState("error"); }
}
}, [api, environment, id, project]);
useEffect(() => { void load(); return () => { loadGeneration.current += 1; }; }, [load]);
if (state === "loading") return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate}/><LoadingPanel /></div>;
if (state === "error") return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate}/><ErrorPanel message={error} retry={() => void load()}/></div>;
if (state === "notFound" || !detail) return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate}/><section className="panel resource-page"><div className="notice notice-danger" role="alert">Connector <strong>{id}</strong> is not registered in this scope.</div></section></div>;
const readiness = detail.readiness;
const readinessCard: DetailCardItem = {
id: "readiness",
title: "Connector readiness",
summary: readiness.detail,
status: readiness.status,
fields: [
{ label: "Reachable", value: readiness.reachable === null ? "Unknown" : readiness.reachable ? "Yes" : "No" },
{ label: "Observation source", value: readiness.source },
{ label: "Observed at", value: readiness.observedAt ? formatBrusselsTime(readiness.observedAt) : "No observation recorded" },
],
};
const registrationCard: DetailCardItem = {
id: "registration",
title: "Catalog registration",
summary: detail.registration.detail,
status: detail.registration.state,
fields: [{ label: "Connector type", value: detail.connectorType }],
};
const bindingCards: DetailCardItem[] = detail.bindings.map((binding) => ({
id: `${binding.project}-${binding.environment}`,
title: `${binding.project} · ${binding.environment}`,
status: binding.verification,
fields: [
{ label: "Project", value: binding.project },
{ label: "Environment", value: binding.environment },
{ label: "Verification", value: binding.verification },
],
action: { label: "Open project", onSelect: () => navigate(`/projects/${encodeURIComponent(binding.project)}`) },
}));
const toolCards: DetailCardItem[] = detail.relatedTools.map((tool) => ({
id: tool.canonicalName,
title: tool.canonicalName,
summary: tool.summary,
status: tool.risk,
fields: [
{ label: "Read only", value: tool.readOnly ? "Yes" : "No" },
{ label: "Risk class", value: tool.risk },
],
action: { label: "Open tool catalog", onSelect: () => navigate(`/tools?q=${encodeURIComponent(tool.canonicalName)}`) },
}));
const currentCards = section === "Overview" ? [readinessCard, registrationCard]
: section === "Bindings" ? bindingCards
: toolCards;
return <div className="resource-stack">
<Breadcrumbs path={path} navigate={navigate}/>
<section className="panel resource-page">
<p className="eyebrow">Connector workspace</p>
<h2>{detail.name}</h2>
<p className="muted">{detail.description || "Secret-free connector registration, bindings, and observed readiness."}</p>
<label className="detail-mobile-section"><span>Connector section</span>
<select value={section} onChange={(event) => {
const params = new URLSearchParams();
if (project && environment) { params.set("project", project); params.set("environment", environment); }
params.set("section", event.target.value);
navigate(`/connectors/${encodeURIComponent(id)}?${params.toString()}`);
}}>{sections.map((item) => <option key={item} value={item}>{item}</option>)}</select>
</label>
<nav className="tabs detail-desktop-tabs" aria-label="Connector detail sections">
{sections.map((item) => {
const params = new URLSearchParams();
if (project && environment) { params.set("project", project); params.set("environment", environment); }
params.set("section", item);
return <a key={item} aria-current={section === item ? "page" : undefined} className={section === item ? "tab active" : "tab"}
href={`/connectors/${encodeURIComponent(id)}?${params.toString()}`}
onClick={(event) => { event.preventDefault(); navigate(`/connectors/${encodeURIComponent(id)}?${params.toString()}`); }}>{item}</a>;
})}
</nav>
{readiness.source === "unavailable" && <p className="detail-boundary-note">The Hub has no live or persisted health observation for this connector, so readiness stays unknown.</p>}
<a className="button button-secondary" href="/operations?section=checks" onClick={(event) => { event.preventDefault(); navigate("/operations?section=checks"); }}>Inspect operational checks</a>
{section === "Bindings" && bindingCards.length === 0
? <div className="empty-line"><span aria-hidden="true">○</span><span>No project environments are bound to this connector.</span></div>
: section === "Related tools" && toolCards.length === 0
? <div className="empty-line"><span aria-hidden="true">○</span><span>No registered tools reference this connector.</span></div>
: <DetailCards label={section} cards={currentCards} />}
</section>
</div>;
}
+21 -34
View File
@@ -1,5 +1,5 @@
import type { MouseEvent } from "react";
import 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}` : ""}`} />
+52 -53
View File
@@ -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>;
}
+193 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ManagementApiClient, ProjectDetailInfo, ResourceKind, ResourceRecord } from "../contracts";
import { 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>;
}
+41 -17
View File
@@ -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>;
}
+7 -6
View File
@@ -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>;
}
+167 -67
View File
@@ -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>;
}
+7
View File
@@ -0,0 +1,7 @@
/** Only presentation values belong here. Storage may be denied by the browser. */
export function readPreference(key: "hub-theme" | "hub-density", values: readonly string[], fallback: string): string {
try { const stored = localStorage.getItem(key); return stored && values.includes(stored) ? stored : fallback; } catch { return fallback; }
}
export function writePreference(key: "hub-theme" | "hub-density", value: string): void {
try { localStorage.setItem(key, value); } catch { /* In-memory interaction continues when storage is unavailable. */ }
}
+2
View File
@@ -12,6 +12,7 @@ export type ManagementAction =
| "operations.check"
| "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"],
+8 -1
View File
@@ -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; }
+148
View File
@@ -0,0 +1,148 @@
import type { ToolDefinition } from "./contracts";
export type ToolInputProperty = NonNullable<ToolDefinition["inputSchema"]["properties"]>[string];
export type ToolInputSchema = ToolDefinition["inputSchema"];
export interface ParsedSchemaArguments {
arguments: Record<string, unknown>;
errors: Record<string, string>;
valid: boolean;
}
export const NULL_INPUT_VALUE = "__schema_null__";
function allowedTypes(property: ToolInputProperty): string[] {
const types = Array.isArray(property.type) ? property.type : [property.type ?? "string"];
return types.filter((type) => type !== "null");
}
function supportsNull(property: ToolInputProperty): boolean {
return Array.isArray(property.type) && property.type.includes("null");
}
function optionValue(raw: string): unknown {
try {
return JSON.parse(raw) as unknown;
} catch {
return raw;
}
}
function matchesEnum(value: unknown, values: unknown[] | undefined): boolean {
if (!values) return true;
return values.some((option) => {
if (Object.is(option, value)) return true;
try {
return JSON.stringify(option) === JSON.stringify(value);
} catch {
return false;
}
});
}
function boundsError(value: number | string, property: ToolInputProperty): string | null {
if (typeof value === "number") {
if (property.minimum !== undefined && value < property.minimum) return `Enter a number no less than ${property.minimum}.`;
if (property.maximum !== undefined && value > property.maximum) return `Enter a number no greater than ${property.maximum}.`;
}
if (typeof value === "string") {
if (property.minLength !== undefined && value.length < property.minLength) return `Enter at least ${property.minLength} characters.`;
if (property.maxLength !== undefined && value.length > property.maxLength) return `Use no more than ${property.maxLength} characters.`;
}
return null;
}
function parseForType(raw: string, type: string, property: ToolInputProperty): { value?: unknown; error?: string } {
if (type === "integer" || type === "number") {
const numeric = Number(raw);
if (!Number.isFinite(numeric)) return { error: "Enter a valid number." };
if (type === "integer" && !Number.isSafeInteger(numeric)) return { error: "Enter a whole number." };
return { value: numeric, error: boundsError(numeric, property) ?? undefined };
}
if (type === "boolean") {
if (raw === "true") return { value: true };
if (raw === "false") return { value: false };
return { error: "Choose yes or no." };
}
if (type === "array" || type === "object") {
let value: unknown;
try {
value = JSON.parse(raw) as unknown;
} catch {
return { error: type === "array" ? "Enter valid JSON for this array." : "Enter valid JSON for this object." };
}
if (type === "array" && !Array.isArray(value)) return { error: "Enter a JSON array." };
if (type === "object" && (typeof value !== "object" || value === null || Array.isArray(value))) return { error: "Enter a JSON object." };
return { value };
}
if (type === "string") return { value: raw, error: boundsError(raw, property) ?? undefined };
return { error: "This tool input type cannot be entered here." };
}
function parseField(raw: string, property: ToolInputProperty): { value?: unknown; error?: string } {
if (raw === "") return {};
if (raw === NULL_INPUT_VALUE) return supportsNull(property) ? { value: null } : { error: "This field does not accept null." };
if (property.enum) {
const value = optionValue(raw);
return matchesEnum(value, property.enum)
? { value }
: { error: "Choose a value listed by this tool's schema." };
}
const types = allowedTypes(property);
if (types.length === 0) return { error: "This field does not accept a value." };
// Prefer the most specific scalar/structured representation for unions;
// a plain string remains the fallback when another declared type rejects it.
const orderedTypes = [...types].sort((left, right) => {
const priority = (type: string) => ({ integer: 0, number: 1, boolean: 2, object: 3, array: 4, string: 5 }[type as "integer"] ?? 6);
return priority(left) - priority(right);
});
let firstError: string | undefined;
for (const type of orderedTypes) {
const parsed = parseForType(raw, type, property);
if (!parsed.error) return parsed;
firstError ??= parsed.error;
}
return { error: firstError ?? "This tool input is invalid." };
}
function displayValue(value: unknown, property: ToolInputProperty): string {
if (value === null) return supportsNull(property) ? NULL_INPUT_VALUE : "";
if (property.enum || typeof value === "object") {
try {
return JSON.stringify(value) ?? "";
} catch {
return "";
}
}
return String(value);
}
export function defaultSchemaInputValues(schema: ToolInputSchema): Record<string, string> {
const raw: Record<string, string> = {};
for (const [name, property] of Object.entries(schema.properties ?? {})) {
if (property.default !== undefined) raw[name] = displayValue(property.default, property);
}
return raw;
}
export function parseSchemaArguments(rawValues: Record<string, string>, schema: ToolInputSchema): ParsedSchemaArguments {
const args: Record<string, unknown> = {};
const errors: Record<string, string> = {};
const properties = schema.properties ?? {};
const required = new Set(schema.required ?? []);
for (const name of required) {
if (!properties[name] || rawValues[name] === undefined || rawValues[name] === "") errors[name] = "This field is required.";
}
for (const [name, property] of Object.entries(properties)) {
const raw = rawValues[name] ?? "";
if (raw === "") continue;
const parsed = parseField(raw, property);
if (parsed.error) errors[name] = parsed.error;
else if (parsed.value !== undefined) args[name] = parsed.value;
}
return { arguments: args, errors, valid: Object.keys(errors).length === 0 };
}
+54
View File
@@ -0,0 +1,54 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { safeApiMessage } from "./api";
export type RefreshStatus = "current" | "refreshing" | "stale" | "disconnected" | "paused";
export const refreshLabel: Record<RefreshStatus, string> = { current: "Current · auto refresh", refreshing: "Refreshing…", stale: "Stale · refresh needed", disconnected: "Disconnected · retry available", paused: "Auto refresh paused" };
/** Read-only refresh: no overlapping calls, hidden-tab suspension, bounded backoff. */
export function useStatusRefresh<T>(initial: T | null, read: () => Promise<T>, stale: (value: T) => boolean) {
const [value, setValue] = useState(initial);
const [status, setStatus] = useState<RefreshStatus>(initial ? (stale(initial) ? "stale" : "current") : "refreshing");
const [error, setError] = useState("");
const active = useRef(false);
const pending = useRef(false);
const failures = useRef(0);
const readRef = useRef(read);
const staleRef = useRef(stale);
const timer = useRef<number | undefined>(undefined);
useEffect(() => { readRef.current = read; staleRef.current = stale; }, [read, stale]);
const refresh = useCallback(async () => {
if (pending.current || !active.current) return;
if (document.hidden) { setStatus("paused"); return; }
pending.current = true;
window.clearTimeout(timer.current);
setStatus("refreshing");
try {
const next = await readRef.current();
if (active.current) { setValue(next); setError(""); setStatus(staleRef.current(next) ? "stale" : "current"); failures.current = 0; }
} catch (caught) {
if (active.current) { failures.current += 1; setError(safeApiMessage(caught)); setStatus("disconnected"); }
} finally {
pending.current = false;
if (active.current && !document.hidden) timer.current = window.setTimeout(() => { void refresh(); }, Math.min(120_000, 30_000 * 2 ** Math.min(failures.current, 2)));
}
}, []);
useEffect(() => {
active.current = true;
if (document.hidden) setStatus("paused");
else if (initial) timer.current = window.setTimeout(() => { void refresh(); }, 30_000);
else void refresh();
const visibility = () => {
window.clearTimeout(timer.current);
if (document.hidden) setStatus("paused");
else void refresh();
};
document.addEventListener("visibilitychange", visibility);
return () => { active.current = false; window.clearTimeout(timer.current); document.removeEventListener("visibilitychange", visibility); };
}, [refresh]);
useEffect(() => {
if (!value) return;
const freshness = window.setInterval(() => { if (!document.hidden && staleRef.current(value)) setStatus((current) => current === "current" ? "stale" : current); }, 10_000);
return () => window.clearInterval(freshness);
}, [value]);
return { value, status, error, refresh };
}
+122 -159
View File
@@ -73,12 +73,8 @@
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
}
* { box-sizing: border-box; }
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; }
}
+130
View File
@@ -0,0 +1,130 @@
.detail-mobile-section { display: none; }
.detail-card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin: 16px 0;
}
.detail-card {
min-width: 0;
padding: 16px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface-1);
box-shadow: var(--shadow-1);
}
.detail-card-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.detail-card h3 {
min-width: 0;
margin: 0;
color: var(--paper-bright);
font-size: 14px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.detail-card-status {
flex: 0 0 auto;
max-width: 48%;
padding: 3px 7px;
border: 1px solid var(--line);
border-radius: var(--radius-pill);
color: var(--ink-dim);
font: 10px/1.4 var(--font-mono);
text-align: right;
overflow-wrap: anywhere;
}
.detail-card-summary {
margin: 9px 0 0;
color: var(--ink-dim);
font-size: 12px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.detail-card-fields {
display: grid;
gap: 9px;
margin: 14px 0 0;
}
.detail-card-field {
display: grid;
grid-template-columns: minmax(90px, 0.45fr) minmax(0, 1fr);
align-items: start;
gap: 8px 14px;
padding-top: 8px;
border-top: 1px solid var(--line-soft);
}
.detail-card-field dt {
color: var(--ink-faint);
font: 10px/1.45 var(--font-mono);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.detail-card-field dd {
min-width: 0;
margin: 0;
color: var(--paper);
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.detail-card-action { margin-top: 14px; }
.detail-boundary-note {
margin: 12px 0;
padding: 11px 13px;
border-left: 2px solid var(--status-info);
background: var(--surface-2);
color: var(--ink-dim);
font-size: 12px;
line-height: 1.55;
}
.mobile-record-field { display: contents; }
@media (max-width: 640px) {
.detail-mobile-section {
display: grid;
gap: 7px;
margin: 18px 0;
color: var(--ink-dim);
font-size: 12px;
}
.detail-mobile-section span {
color: var(--ink-faint);
font: 10px/1.4 var(--font-mono);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.detail-mobile-section select {
width: 100%;
min-height: 44px;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface-2);
color: var(--paper-bright);
font: inherit;
}
.detail-desktop-tabs { display: none; }
.detail-card-grid { grid-template-columns: 1fr; gap: 10px; margin: 12px 0; }
.detail-card { padding: 14px; }
}
+63
View File
@@ -0,0 +1,63 @@
/* Shared operator surfaces; component-specific shell/workflows follow. */
.dashboard, .dashboard > *, .dashboard-columns > * { min-width: 0; }
.inbox-expand { margin-top: 12px; }
.hero-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }
.hero-meta strong { margin: 0; }
.freshness { display: inline-flex; align-items: center; gap: 6px; font: 11px/1.5 var(--font-mono); color: var(--ink-dim); }
.freshness::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--status-success); }
.freshness-stale::before, .freshness-disconnected::before { background: var(--status-danger); }
.freshness-refreshing::before, .freshness-paused::before { background: var(--ink-faint); }
.signal-next-step { display: inline-flex; gap: 6px; align-items: center; margin-top: 8px; color: var(--accent); font-size: 12px; font-weight: 600; }
.exception-item > div { min-width: 0; overflow-wrap: anywhere; }
.exception-item .signal-time { font-size: 10px; }
.operations-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.operations-overview .panel-heading { gap: 18px; }
.recovery-record { display: grid; grid-template-columns: minmax(130px, .4fr) 1fr; gap: 10px 16px; font-size: 12px; }
.recovery-record dt { color: var(--ink-faint); }
.recovery-record dd { margin: 0; overflow-wrap: anywhere; }
[id^="operations-"] { scroll-margin-top: 20px; }
.alert-list .signal-next-step { grid-column: 1 / -1; }
.trend-chart { display: grid; grid-template-columns: repeat(24, minmax(0, 1fr)); gap: 3px; overflow: visible; }
.trend-column { border: 0; padding: 0; min-width: 0; background: transparent; color: var(--paper); cursor: pointer; border-radius: 3px; }
.trend-column[aria-pressed="true"] { background: var(--surface-2); outline: 1px solid var(--accent); }
.trend-column small { font-size: 8px; }
.trend-detail { min-height: 36px; margin-top: 12px !important; overflow-wrap: anywhere; }
.mobile-trend, .mobile-client-actions { display: none; }
.desktop-client-actions { display: inline-flex; flex-wrap: wrap; gap: 6px; }
.client-actions { align-items: flex-start; }
.metric-card strong, .metric-card p { overflow-wrap: anywhere; }
@media (max-width: 640px) {
.table-controls { display: none; }
.desktop-client-actions, .desktop-trend { display: none; }
.mobile-client-actions, .mobile-trend { display: block; }
.mobile-client-actions { flex: 1; }
.mobile-client-actions summary { display: flex; align-items: center; justify-content: center; min-height: 44px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 4px; color: var(--paper); font-size: 12px; cursor: pointer; }
.mobile-client-actions summary::after { content: "+"; margin-left: 8px; }
.mobile-client-actions[open] summary::after { content: "−"; }
.mobile-client-actions > div { display: grid; gap: 8px; padding-top: 8px; }
.mobile-trend .trend-chart { grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 2px; padding-inline: 0; }
.mobile-trend .trend-column { min-height: 44px; }
.mobile-trend .trend-column small { font-size: 9px; }
.recovery-record { grid-template-columns: 1fr; gap: 4px; }
.recovery-record dd { margin-bottom: 10px; }
.operations-actions { justify-content: flex-start; width: 100%; }
.operations-actions .button { flex: 1; }
.button, .filter-clear, .tab, .theme-toggle button, .panel-heading a { min-height: 44px; }
.button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
.panel-heading a { display: inline-flex; align-items: center; }
.filter-clear { min-width: 44px; }
.filter-input-wrap input { padding-right: 48px; }
input, select, textarea { font-size: 16px; }
.exception-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; }
.exception-action .status { font-size: 10px; padding-inline: 7px; }
}
/* Content, rather than a device label, sets the metric wrapping threshold. */
@media (max-width: 480px) {
.metric-grid, .secondary-grid { grid-template-columns: 1fr; }
.metric-card { min-height: 102px; }
}
.client-created-notice { margin: 0 0 1rem; flex-wrap: wrap; overflow-wrap: anywhere; }
.client-created-notice a, .client-created-notice button { display: inline-flex; align-items: center; min-height: 44px; padding: .5rem .75rem; }
+190
View File
@@ -0,0 +1,190 @@
/* Focus and compact-screen behavior for the application shell and dialogs. */
.nav-trigger,
.nav-close,
.modal-close {
min-width: 44px;
min-height: 44px;
}
.nav-link,
.command-result {
min-height: 44px;
}
.command-result-active {
border-color: var(--line);
color: var(--paper);
background: var(--surface-2);
}
.command-group { display: grid; align-content: start; gap: 3px; }
.command-group + .command-group { margin-top: 8px; }
.command-group h3 {
margin: 8px 10px 2px;
color: var(--ink-faint);
font: 600 10px/1.4 var(--font-mono);
letter-spacing: .08em;
text-transform: uppercase;
}
.command-search-error {
margin: 10px 2px;
padding: 11px 12px;
border: 1px solid color-mix(in srgb, var(--status-danger) 45%, var(--line));
border-radius: var(--radius-md);
color: var(--status-danger);
background: var(--status-danger-dim);
font-size: 12px;
}
.session-signout { min-height: 44px; white-space: nowrap; }
.session-error { max-width: min(80vw, 320px); overflow-wrap: anywhere; }
@media (max-width: 840px) {
.app-shell { min-height: 100dvh; }
.sidebar {
top: 0;
bottom: 0;
height: 100dvh;
max-height: 100dvh;
min-height: 0;
padding-top: calc(20px + env(safe-area-inset-top));
padding-left: calc(16px + env(safe-area-inset-left));
padding-right: calc(16px + env(safe-area-inset-right));
padding-bottom: calc(20px + env(safe-area-inset-bottom));
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.sidebar .nav-link { min-height: 44px; }
.nav-backdrop { touch-action: none; }
.main-content {
min-height: 100dvh;
padding-top: calc(19px + env(safe-area-inset-top));
padding-left: calc(14px + env(safe-area-inset-left));
padding-right: calc(14px + env(safe-area-inset-right));
padding-bottom: calc(44px + env(safe-area-inset-bottom));
}
}
@media (max-width: 640px) {
.topbar { gap: 8px; }
.topbar-title { flex: 1 1 auto; }
.topbar-actions { flex: 0 0 auto; gap: 6px; }
.nav-trigger,
.command-trigger { min-width: 44px; min-height: 44px; }
.session-menu { position: relative; flex: 0 0 auto; }
.session-menu .session-identity {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.session-menu .session-signout {
min-width: 72px;
min-height: 44px;
padding: 0 9px;
font-size: 11px;
}
.session-menu .session-signout::after { content: none; }
.session-error {
position: absolute;
z-index: 5;
top: calc(100% + 5px);
right: 0;
width: min(80vw, 320px);
padding: 8px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-1);
}
.modal-overlay {
padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left));
place-items: center;
}
.modal-overlay:has(.command-modal),
.modal-overlay:has(.readiness-form) { padding: 0; place-items: stretch; }
.modal.command-modal {
width: 100%;
height: 100dvh;
max-height: 100dvh;
max-width: none;
margin: 0;
padding: 0;
padding-top: env(safe-area-inset-top);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
padding-bottom: env(safe-area-inset-bottom);
border: 0;
border-radius: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.command-modal .modal-heading {
flex: 0 0 auto;
padding: 10px 12px 9px;
}
.command-modal .modal-body {
display: flex;
flex: 1 1 auto;
min-height: 0;
flex-direction: column;
overflow: hidden;
padding: 10px 12px 8px;
}
.command-results {
align-content: start;
flex: 1 1 auto;
min-height: 0;
max-height: none;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.command-hint { flex: 0 0 auto; padding-bottom: 2px; }
.modal:has(.readiness-form) {
width: 100%;
height: 100dvh;
max-height: 100dvh;
max-width: none;
margin: 0;
padding: 0;
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
border: 0;
border-radius: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal:has(.readiness-form) .modal-heading {
flex: 0 0 auto;
margin: 0;
padding: calc(8px + env(safe-area-inset-top)) 12px 8px;
border-bottom: 1px solid var(--line);
}
.modal:has(.readiness-form) .modal-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding: 14px 14px calc(18px + env(safe-area-inset-bottom));
}
/* Short phones keep confirmations compact while the dialog itself stays
within the dynamic viewport and can scroll if its copy needs more room. */
.modal:not(.command-modal):not(:has(.readiness-form)) {
place-self: center;
width: min(100%, 760px);
height: auto;
max-height: calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 24px);
overflow-y: auto;
}
}
+99
View File
@@ -0,0 +1,99 @@
.onboarding-steps li.complete > span { border-color: var(--status-success); color: var(--status-success); }
.onboarding-steps li.complete strong { color: var(--status-success); }
.onboarding-identity-bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 17px 0; padding: 13px 15px; border: 1px solid var(--line); background: var(--surface-2); }
.onboarding-identity-bar > div:first-child { display: grid; gap: 4px; min-width: 0; }
.onboarding-identity-bar strong { color: var(--paper-bright); font: 11px/1.5 var(--font-mono); overflow-wrap: anywhere; }
.onboarding-identity-bar small { color: var(--ink-faint); font-size: 11px; }
.onboarding-identity-actions { display: flex; flex-wrap: wrap; gap: 7px; }
.onboarding-created-title { margin: 5px 0 0; color: var(--paper); font-size: 13px; }
.onboarding-config { margin-top: 15px; padding: 15px; border: 1px solid var(--line); border-left: 3px solid var(--accent); background: var(--surface-2); }
.onboarding-config .panel-heading { align-items: center; }
.onboarding-config h4, .onboarding-stage-card h4 { margin: 0; color: var(--paper-bright); font-size: 15px; }
.onboarding-config dl { display: grid; gap: 8px; margin: 14px 0; }
.onboarding-config dl > div { display: grid; grid-template-columns: minmax(105px, .34fr) minmax(0, 1fr); gap: 12px; padding-top: 8px; border-top: 1px solid var(--line); }
.onboarding-config dt, .onboarding-checks dt { color: var(--ink-faint); font: 10px/1.5 var(--font-mono); text-transform: uppercase; letter-spacing: .04em; }
.onboarding-config dd { min-width: 0; margin: 0; color: var(--ink-dim); overflow-wrap: anywhere; }
.onboarding-config-auth { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 16px; margin: 14px 0; padding: 12px; border: 1px solid var(--line); background: var(--surface-1); }
.onboarding-config-auth p { min-width: 0; margin: 0; color: var(--ink-dim); font-size: 11px; overflow-wrap: anywhere; }
.onboarding-config-auth strong { color: var(--paper); }
.onboarding-handoff-steps { display: grid; gap: 7px; margin: 12px 0 0; padding-left: 19px; }
.onboarding-handoff-steps li { color: var(--ink-dim); font-size: 11px; line-height: 1.55; }
.onboarding-stage-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 15px; }
.onboarding-stage-card { min-width: 0; padding: 15px; border: 1px solid var(--line); background: var(--surface-1); }
.onboarding-stage-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.onboarding-stage-heading .eyebrow { margin: 0 0 4px; }
.stage-number { display: grid; place-items: center; flex: 0 0 auto; width: 30px; height: 30px; border: 1px solid var(--accent-dim); border-radius: 50%; color: var(--accent); background: var(--accent-dim); font: 11px var(--font-mono); }
.onboarding-checks { display: grid; gap: 8px; margin: 14px 0; }
.onboarding-check { display: grid; grid-template-columns: minmax(82px, .35fr) minmax(0, 1fr); align-items: start; gap: 10px; padding-top: 9px; border-top: 1px solid var(--line); }
.onboarding-check dd { display: grid; grid-template-columns: max-content minmax(0, 1fr); align-items: start; gap: 7px 9px; min-width: 0; margin: 0; color: var(--ink-dim); font-size: 11px; }
.onboarding-check .status { grid-row: span 2; }
.onboarding-check-detail { min-width: 0; overflow-wrap: anywhere; }
.onboarding-check dd > small { grid-column: 2; color: var(--ink-faint); font: 9px/1.4 var(--font-mono); }
.external-boundary-note, .manual-proof-note { display: grid; gap: 4px; margin-top: 13px; padding: 11px; border: 1px solid var(--line); background: var(--surface-2); }
.external-boundary-note strong, .manual-proof-note strong { color: var(--paper); font-size: 11px; }
.external-boundary-note span, .manual-proof-note span { color: var(--ink-dim); font-size: 10.5px; line-height: 1.55; }
.onboarding-verified, .onboarding-action-success { margin: 12px 0; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--status-success) 35%, var(--line)); color: var(--status-success); background: var(--status-success-dim); font-size: 11px; line-height: 1.5; }
.onboarding-action-info { margin: 12px 0; padding: 10px 12px; border: 1px solid var(--line); color: var(--ink-dim); background: var(--surface-2); font-size: 11px; line-height: 1.5; }
.onboarding-check-recorded { margin: 12px 0; padding: 10px 12px; border: 1px solid var(--line); color: var(--ink-dim); background: var(--surface-2); font-size: 11px; line-height: 1.5; }
.onboarding-verification-detail { margin: 10px 0 0; font-size: 10.5px; overflow-wrap: anywhere; }
.onboarding-loading { display: flex; align-items: center; gap: 8px; min-height: 44px; color: var(--ink-faint); font-size: 11px; }
.schema-inputs { margin-top: 14px; }
.schema-input-field { display: grid; align-content: start; gap: 6px; min-width: 0; }
.schema-input-label { color: var(--paper); font-size: 11px; font-weight: 600; }
.required-mark { color: var(--accent); }
.schema-input-field input, .schema-input-field select, .schema-input-field textarea { min-width: 0; }
.schema-input-field textarea { resize: vertical; font-family: var(--font-mono); font-size: 11px; line-height: 1.5; }
.schema-input-field > small { color: var(--ink-faint); font-size: 10px; font-weight: 400; line-height: 1.45; }
.schema-input-field .schema-field-error { color: var(--status-danger); font-weight: 600; }
.schema-null-choice { width: max-content !important; max-width: 100%; }
.schema-null-value { padding: 10px 11px; border: 1px dashed var(--line); color: var(--ink-faint); font-size: 11px; }
.schema-input-empty, .tool-input-summary { margin: 12px 0; color: var(--ink-faint); font-size: 11px; }
.tool-console-actions { flex-wrap: wrap; }
.tool-result { border-left-color: var(--accent); }
.tool-result pre { max-height: min(54dvh, 560px); max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; tab-size: 2; }
.tool-result .copy-status { overflow-wrap: anywhere; }
.recent-tool-runs { max-width: none; }
.recent-run-list { display: grid; gap: 9px; margin: 14px 0 0; padding: 0; list-style: none; }
.recent-run { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-width: 0; padding: 12px; border: 1px solid var(--line); background: var(--surface-1); }
.recent-run-main { min-width: 0; flex: 1; }
.recent-run-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.recent-run-tool { color: var(--paper-bright); font: 11px/1.5 var(--font-mono); overflow-wrap: anywhere; }
.recent-run-outcome { display: inline-flex; align-items: center; min-height: 22px; padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px; font: 9px/1.2 var(--font-mono); }
.recent-run-success { color: var(--status-success); border-color: color-mix(in srgb, var(--status-success) 38%, var(--line)); }
.recent-run-error { color: var(--status-danger); border-color: color-mix(in srgb, var(--status-danger) 38%, var(--line)); }
.recent-run dl { display: flex; flex-wrap: wrap; gap: 5px 14px; margin: 8px 0 0; }
.recent-run dl > div { display: flex; flex-wrap: wrap; gap: 4px; min-width: 0; }
.recent-run dt { color: var(--ink-faint); font: 9px/1.5 var(--font-mono); text-transform: uppercase; }
.recent-run dd { min-width: 0; margin: 0; color: var(--ink-dim); font-size: 10px; overflow-wrap: anywhere; }
.recent-run dd code { color: var(--ink-faint); font: 9px/1.5 var(--font-mono); overflow-wrap: anywhere; }
.recent-run-open { flex: 0 0 auto; }
@media (max-width: 760px) {
.onboarding-steps { gap: 6px; }
.onboarding-steps li { gap: 5px; }
.onboarding-steps li > div { display: grid; min-width: 0; }
.onboarding-steps strong { font-size: 10px; }
.onboarding-steps small { font-size: 9px; line-height: 1.35; }
.onboarding-stage-grid { grid-template-columns: 1fr; }
.recent-run { align-items: stretch; flex-direction: column; }
.recent-run-open { width: 100%; min-height: 44px; }
}
@media (max-width: 520px) {
.onboarding-identity-bar { align-items: stretch; flex-direction: column; }
.onboarding-identity-actions { display: grid; grid-template-columns: 1fr; }
.onboarding-identity-actions .button { min-height: 44px; }
.onboarding-config .panel-heading { align-items: stretch; flex-direction: column; }
.onboarding-config .panel-heading > .button { min-height: 44px; }
.onboarding-config dl > div { grid-template-columns: 1fr; gap: 3px; }
.onboarding-config-auth { grid-template-columns: 1fr; }
.onboarding-check { grid-template-columns: 1fr; gap: 5px; }
.onboarding-check dd { grid-template-columns: max-content minmax(0, 1fr); }
.onboarding-check dd > small { grid-column: 2; }
.tool-console-actions { display: grid; grid-template-columns: 1fr; }
.tool-console-actions .button { min-height: 44px; }
.tool-result .panel-heading { align-items: stretch; flex-direction: column; }
.tool-result .panel-heading > .button { align-self: flex-start; min-height: 40px; }
}
+82
View File
@@ -0,0 +1,82 @@
/** Browser-local history contains only the identifiers needed to reopen a
* tool's project scope. Invocation arguments, output and error text stay out
* of storage. */
export const TOOL_HISTORY_KEY = "itworx-tool-history-v1";
export const TOOL_HISTORY_EVENT = "itworx:tool-history-updated";
export const MAX_TOOL_RUNS = 20;
export interface ToolRunMetadata {
tool: string;
project: string;
environment: string;
ok: boolean;
traceId: string;
at: string;
}
type HistoryStorage = Pick<Storage, "getItem" | "setItem">;
function browserSessionStorage(): HistoryStorage | null {
try {
return typeof window === "undefined" ? null : window.sessionStorage;
} catch {
return null;
}
}
function boundedString(value: unknown, maxLength: number, pattern: RegExp): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim();
return normalized.length > 0 && normalized.length <= maxLength && pattern.test(normalized)
? normalized
: null;
}
function normalizeToolRun(value: unknown): ToolRunMetadata | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
const candidate = value as Record<string, unknown>;
const tool = boundedString(candidate.tool, 192, /^[A-Za-z0-9][A-Za-z0-9._:-]*$/);
const project = boundedString(candidate.project, 63, /^[a-z0-9][a-z0-9-]*$/);
const environment = boundedString(candidate.environment, 32, /^[a-z0-9][a-z0-9-]*$/);
const traceId = boundedString(candidate.traceId, 256, /^[A-Za-z0-9][A-Za-z0-9._:-]*$/);
const timestamp = typeof candidate.at === "string" ? Date.parse(candidate.at) : Number.NaN;
if (!tool || !project || !environment || !traceId || typeof candidate.ok !== "boolean" || !Number.isFinite(timestamp)) return null;
return {
tool,
project,
environment,
ok: candidate.ok,
traceId,
at: new Date(timestamp).toISOString(),
};
}
export function readToolHistory(storage = browserSessionStorage()): ToolRunMetadata[] {
if (!storage) return [];
try {
const raw = storage.getItem(TOOL_HISTORY_KEY);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.slice(0, MAX_TOOL_RUNS).flatMap((entry) => {
const normalized = normalizeToolRun(entry);
return normalized ? [normalized] : [];
});
} catch {
return [];
}
}
export function recordToolRun(value: ToolRunMetadata, storage = browserSessionStorage()): void {
if (!storage) return;
const run = normalizeToolRun(value);
if (!run) return;
try {
const history = readToolHistory(storage).filter((entry) => entry.traceId !== run.traceId);
storage.setItem(TOOL_HISTORY_KEY, JSON.stringify([run, ...history].slice(0, MAX_TOOL_RUNS)));
if (typeof window !== "undefined") window.dispatchEvent(new Event(TOOL_HISTORY_EVENT));
} catch {
// Browser history is optional; never turn a completed invocation into a failure.
}
}
+24 -4
View File
@@ -10,12 +10,32 @@ describe("management API safety contracts", () => {
await createApiClient().resource("tools", { filter: "risk:R1", limit: 25 });
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 () => {
+4
View File
@@ -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"); });
+4 -2
View File
@@ -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 }) => {
+2 -1
View File
@@ -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 }) => {
+194
View File
@@ -0,0 +1,194 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, loginAs, test } from "./fixtures";
const darkReview = process.env.WP400_DARK_REVIEW === "1";
test.use({ colorScheme: darkReview ? "dark" : "light" });
const viewports = [
{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 390, height: 844 },
{ width: 430, height: 932 }, { width: 768, height: 1024 }, { width: 1280, height: 800 },
{ width: 1440, height: 900 }, { width: 844, height: 390 },
];
for (const viewport of viewports) {
test(`operator layout and accessible surfaces at ${viewport.width}x${viewport.height}`, async ({ page }, info) => {
test.setTimeout(180_000);
await page.setViewportSize(viewport);
await loginAs(page, "hub-admin");
for (const path of ["/", "/projects", "/servers", "/tools", "/connectors", "/clients", "/policies", "/audit", "/operations", "/settings", "/projects/ludarium", "/connectors/knowledge-default"]) {
await page.goto(path);
await expect(page.locator(".topbar")).toBeVisible();
if (viewport.width <= 840) {
const target = await page.locator(".nav-trigger").boundingBox();
expect(target?.width).toBeGreaterThanOrEqual(44);
expect(target?.height).toBeGreaterThanOrEqual(44);
}
await expect(page.locator(".loading-state")).toHaveCount(0, { timeout: 15_000 });
const dimensions = await page.evaluate(() => ({ document: document.documentElement.scrollWidth, viewport: innerWidth }));
expect(dimensions.document, path).toBeLessThanOrEqual(dimensions.viewport + 1);
if (path === "/" && viewport.width <= 430) {
await expect(page.getByLabel("Project status records")).toBeVisible();
await expect(page.getByLabel("Project status table")).toBeHidden();
const grid = await page.locator(".primary-metrics").evaluate((element) => getComputedStyle(element).gridTemplateColumns);
expect(grid.split(" ")).toHaveLength(1);
}
if (path === "/tools" && viewport.width <= 430) await expect(page.getByRole("group", { name: "Table density" })).toBeHidden();
if (viewport.width === 390 || viewport.width === 1440) {
const result = await new AxeBuilder({ page }).analyze();
expect(result.violations.filter((violation) => ["serious", "critical"].includes(violation.impact ?? "")), path).toEqual([]);
}
if (process.env.SOL_VISUAL_REVIEW && ["/", "/tools", "/clients", "/operations", "/projects/ludarium", "/connectors/knowledge-default"].includes(path)) {
const themeSuffix = darkReview ? "-dark" : "";
await page.screenshot({ path: info.outputPath(`${path.replaceAll("/", "_") || "dashboard"}-${viewport.width}${themeSuffix}.png`), fullPage: true });
await page.screenshot({ path: info.outputPath(`${path.replaceAll("/", "_") || "dashboard"}-${viewport.width}${themeSuffix}-viewport.png`) });
}
}
});
}
test("mobile client actions require an explicit disclosure and confirmation", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await loginAs(page, "hub-admin");
await page.goto("/clients");
const name = `mobile-safety-${Date.now()}`;
await page.getByLabel("Name", { exact: true }).fill(name);
await page.getByRole("button", { name: "Create pending client" }).click();
const card = page.locator(".mobile-record").filter({ hasText: name }).first();
await expect(card.getByRole("button", { name: "Config", exact: true })).toBeVisible();
await expect(card.getByRole("button", { name: "Revoke", exact: true })).toBeHidden();
await card.locator("summary").click();
await card.getByRole("button", { name: "Revoke", exact: true }).click();
await expect(page.getByRole("dialog", { name: /Revoke/ })).toBeVisible();
await page.getByRole("button", { name: "Cancel", exact: true }).click();
});
test("desktop density persists while mobile cards have no inert density control", async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/tools");
await page.getByRole("button", { name: "Compact", exact: true }).click();
await page.reload();
await expect(page.getByRole("button", { name: "Compact", exact: true })).toHaveAttribute("aria-pressed", "true");
await page.setViewportSize({ width: 430, height: 932 });
await expect(page.getByRole("group", { name: "Table density" })).toBeHidden();
});
test("phone trend keeps outcome details and operations errors never imply no alerts", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await loginAs(page, "hub-viewer");
await page.route("**/api/operations/summary", async (route) => {
const response = await route.fetch();
const summary = await response.json();
summary.trend = Array.from({ length: 24 }, (_, hour) => ({ hour: new Date(Date.UTC(2026, 9, 1, hour)).toISOString(), success: hour, denied: 1, error: 0, rate_limited: 0 }));
await route.fulfill({ json: summary });
});
await page.goto("/operations");
const mobile = page.getByRole("group", { name: "Four-hour call buckets" });
await expect(mobile).toBeVisible();
await expect(mobile.getByRole("button")).toHaveCount(6);
await mobile.getByRole("button").first().click();
await expect(page.locator(".mobile-trend .trend-detail")).toContainText("success");
await page.unroute("**/api/operations/summary");
await page.route("**/api/operations/summary", (route) => route.fulfill({ status: 503, body: "unavailable" }));
await page.reload();
await expect(page.getByText("Alerts are unknown while the summary is unavailable.")).toBeVisible();
await expect(page.getByText("No alerts in the latest summary")).toHaveCount(0);
});
test("status refresh backs off, reports disconnection and does not invoke probes", async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.clock.install();
await page.goto("/");
await expect(page.getByText("Current · auto refresh")).toBeVisible();
let reads = 0;
let writes = 0;
page.on("request", (request) => { if (request.method() === "POST" && request.url().includes("/api/")) writes += 1; });
await page.route("**/api/dashboard", (route) => { reads += 1; return route.fulfill({ status: 503, body: "unavailable" }); });
await page.clock.fastForward(31_000);
await expect(page.getByText("Disconnected · retry available")).toBeVisible();
expect(reads).toBe(1);
await page.clock.fastForward(30_000);
expect(reads).toBe(1);
await page.clock.fastForward(31_000);
await expect.poll(() => reads).toBe(2);
expect(writes).toBe(0);
});
test("a hidden dashboard pauses scheduled refresh and never sends an automatic write", async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.clock.install();
await page.addInitScript(() => {
Object.defineProperty(document, "hidden", {
configurable: true,
get: () => sessionStorage.getItem("e2e-document-hidden") !== "false",
});
});
let dashboardReads = 0;
let apiWrites = 0;
page.on("request", (request) => {
const url = new URL(request.url());
if (url.pathname === "/api/dashboard" && request.method() === "GET") dashboardReads += 1;
if (url.pathname.startsWith("/api/") && request.method() === "POST") apiWrites += 1;
});
await page.goto("/");
await expect(page.getByText("Auto refresh paused")).toBeVisible();
expect(dashboardReads).toBe(1); // The one initial snapshot; no timer-driven read.
await page.clock.fastForward(120_000);
expect(dashboardReads).toBe(1);
expect(apiWrites).toBe(0);
});
test("the dashboard action inbox starts with four signals and expands the rest on demand", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await loginAs(page, "hub-viewer");
await page.route("**/api/dashboard", async (route) => {
const response = await route.fetch();
const snapshot = await response.json();
snapshot.generatedAt = new Date().toISOString();
snapshot.staleAfterSeconds = 3600;
snapshot.health = { hub: "healthy", gateway: "healthy", detail: "Fixture is current." };
snapshot.connectors = Array.from({ length: 6 }, (_, index) => ({
id: `inbox-fixture-${index + 1}`,
name: `Inbox fixture ${index + 1}`,
project: "ludarium",
state: "degraded",
detail: "A controlled connector needs review.",
registrationState: "registered",
readiness: { state: "unknown", detail: "No live probe was requested.", observedAt: null, source: "not-probed" },
}));
snapshot.drift = { count: 0, projects: [], known: true, detail: "No drift." };
snapshot.backup = { state: "healthy", lastSuccessfulAt: null, detail: "Fixture backup state." };
snapshot.denials = { count: 0, window: "24h" };
await route.fulfill({ json: snapshot });
});
await page.goto("/");
const inbox = page.getByRole("region", { name: "6 actionable signals" });
await expect(inbox.locator(".exception-item")).toHaveCount(4);
await expect(inbox.getByRole("link", { name: /Inbox fixture 1/ })).toBeVisible();
await expect(inbox.getByRole("link", { name: /Inbox fixture 4/ })).toBeVisible();
await expect(inbox.getByRole("link", { name: /Inbox fixture 5/ })).toHaveCount(0);
await inbox.getByRole("button", { name: "Show 2 more signals" }).click();
await expect(inbox.locator(".exception-item")).toHaveCount(6);
await expect(inbox.getByRole("link", { name: /Inbox fixture 6/ })).toBeVisible();
await inbox.getByRole("button", { name: "Show fewer signals" }).click();
await expect(inbox.locator(".exception-item")).toHaveCount(4);
});
test("project and connector detail links resolve to registered records", async ({ page }) => {
await loginAs(page, "hub-admin");
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/connectors/knowledge-default");
await expect(page.getByRole("heading", { name: "Knowledge Default", exact: true })).toBeVisible();
await page.goto("/projects/ludarium?tab=MCP%20Servers");
await expect(page.getByLabel("MCP servers", { exact: true }).getByRole("heading", { name: "ludarium-prod-readonly", exact: true })).toBeVisible();
await page.goto("/projects/ludarium?tab=Knowledge%20Sources");
await expect(page.getByLabel("Knowledge sources", { exact: true }).getByRole("heading", { name: "discovery://repository/ludarium", exact: true })).toBeVisible();
});
+118
View File
@@ -0,0 +1,118 @@
import { expect, loginAs, test } from "./fixtures";
test.describe("operator review regressions", () => {
test.beforeEach(async ({ page }) => { await loginAs(page, "hub-admin"); });
test("filter changes retain the URL project and environment at the API boundary", async ({ page }) => {
await page.goto("/connectors?project=ludarium&environment=prod");
await expect(page.getByLabel("Filter records")).toBeVisible();
const response = page.waitForResponse((value) => {
const url = new URL(value.url());
return url.pathname === "/api/connectors" && url.searchParams.get("q") === "knowledge-default";
});
await page.getByLabel("Filter records").fill("knowledge-default");
const actual = new URL((await response).url());
expect(actual.searchParams.get("project")).toBe("ludarium");
expect(actual.searchParams.get("environment")).toBe("prod");
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("knowledge-default");
expect(new URL(page.url()).searchParams.get("project")).toBe("ludarium");
expect(new URL(page.url()).searchParams.get("environment")).toBe("prod");
await expect(page.getByRole("link", { name: "Knowledge Default", exact: true })).toBeVisible();
});
test("catalog scope stays locked until an explicit apply finishes", async ({ page }) => {
let release!: () => void;
const pending = new Promise<void>((resolve) => { release = resolve; });
await page.route("**/api/catalog/preview", (route) => route.fulfill({ json: { valid: true, changes: ["Review fixture change"], errors: [] } }));
await page.route("**/api/catalog/apply", async (route) => { await pending; await route.fulfill({ json: { accepted: true, message: "Reviewed catalog apply completed" } }); });
await page.goto("/tools?project=ludarium");
const workflow = page.locator(".workflow-panel").filter({ has: page.getByRole("heading", { name: "Catalog validation and preview" }) });
await workflow.getByRole("button", { name: "Validate and preview" }).click();
await workflow.getByRole("button", { name: "Apply approved catalog" }).click();
await page.getByRole("button", { name: "Confirm apply" }).click();
await expect(workflow.getByRole("combobox", { name: "Project", exact: true })).toBeDisabled();
await expect(workflow.getByRole("button", { name: "Applying…" })).toBeDisabled();
release();
await expect(workflow.getByText("Reviewed catalog apply completed")).toBeVisible();
await expect(workflow.getByRole("combobox", { name: "Project", exact: true })).toBeEnabled();
});
test("a pending client created after SPA navigation retains a visible resume path", async ({ page }) => {
let release!: () => void;
const pending = new Promise<void>((resolve) => { release = resolve; });
let createdId = "";
await page.route("**/api/client-management", async (route) => {
if (route.request().method() !== "POST") { await route.continue(); return; }
const response = await route.fetch();
createdId = ((await response.json()) as { client: { id: string } }).client.id;
await pending;
await route.fulfill({ response });
});
await page.goto("/clients");
const name = `creation-navigation-${Date.now()}`;
await page.getByLabel("Name", { exact: true }).fill(name);
await page.getByRole("button", { name: "Create pending client" }).click();
await expect.poll(() => createdId).not.toBe("");
await page.getByRole("navigation", { name: "Primary navigation" }).getByRole("link", { name: "Operations", exact: true }).click();
await expect(page.getByRole("heading", { name: "Operations", level: 1 })).toBeVisible();
release();
const notice = page.locator(".client-created-notice");
await expect(notice).toContainText(name);
expect(new URL(page.url()).pathname).toBe("/operations");
await expect(notice.getByRole("link", { name: "Resume onboarding" })).toHaveAttribute("href", `/clients?onboard=${createdId}`);
await notice.getByRole("link", { name: "Resume onboarding" }).click();
await expect(page.getByText(/Resumable Hub identity/)).toBeVisible();
expect(new URL(page.url()).searchParams.get("onboard")).toBe(createdId);
});
test("operation-check results link to registered connector identities", async ({ page }) => {
await page.route("**/api/operations/check", (route) => route.fulfill({ json: {
generatedAt: new Date().toISOString(), healthy: true,
connectors: { "itworx-knowledge": { reachable: true, status: "healthy", detail: "Bounded contract fixture", connectorRefs: [{ id: "knowledge-default", name: "Knowledge Default" }], connectorRefsTruncated: false } },
} }));
await page.goto("/operations");
await page.getByRole("button", { name: "Run live checks" }).click();
const registration = page.locator(".connector-checks").getByRole("link", { name: "Knowledge Default", exact: true });
await expect(registration).toHaveAttribute("href", "/connectors/knowledge-default");
await registration.click();
await expect(page.getByRole("heading", { name: "Knowledge Default", exact: true })).toBeVisible();
});
test("Quick Jump sends an ordinary client to its filtered records instead of an unavailable onboarding workflow", async ({ page }) => {
await page.route("**/api/clients?*", (route) => route.fulfill({ json: {
kind: "clients", generatedAt: new Date().toISOString(),
rows: [{ id: "ordinary-fixture-client", name: "Ordinary fixture client", status: "healthy", summary: "Existing client", project: "ludarium", environment: "prod" }],
page: { total: 1, nextCursor: null },
} }));
await page.getByRole("button", { name: "Open quick jump" }).click();
const dialog = page.getByRole("dialog", { name: "Quick jump" });
await dialog.getByRole("combobox").fill("ordinary-fixture-client");
await dialog.getByRole("group", { name: "Clients", exact: true }).getByRole("option", { name: /Ordinary fixture client/ }).click();
await expect(page).toHaveURL(/\/clients\?q=ordinary-fixture-client$/);
await expect(page.locator(".desktop-data-table").getByText("Existing client", { exact: true })).toBeVisible();
const desktopRecord = page.locator(".desktop-data-table").getByRole("link", { name: "Ordinary fixture client", exact: true });
await expect(desktopRecord).toHaveAttribute("href", "/clients?q=ordinary-fixture-client");
await desktopRecord.click();
await expect(page).toHaveURL(/\/clients\?q=ordinary-fixture-client$/);
await page.setViewportSize({ width: 390, height: 844 });
const mobileRecord = page.locator(".mobile-record-list").getByRole("link", { name: "Ordinary fixture client", exact: true });
await expect(mobileRecord).toHaveAttribute("href", "/clients?q=ordinary-fixture-client");
await mobileRecord.click();
await expect(page).toHaveURL(/\/clients\?q=ordinary-fixture-client$/);
await expect(page.getByText(/Resumable Hub identity/)).toHaveCount(0);
});
test("Quick Jump retains the selected audit trace as its destination filter", async ({ page }) => {
await page.route("**/api/audit?*", (route) => route.fulfill({ json: {
kind: "audit", generatedAt: new Date().toISOString(),
rows: [{ id: "audit-fixture", name: "tool.invocation", status: "healthy", summary: "Fixture event", traceId: "review-fixture-trace", project: "ludarium" }],
page: { total: 1, nextCursor: null },
} }));
await page.getByRole("button", { name: "Open quick jump" }).click();
const dialog = page.getByRole("dialog", { name: "Quick jump" });
await dialog.getByRole("combobox").fill("review-fixture-trace");
await dialog.getByRole("group", { name: "Audit", exact: true }).getByRole("option", { name: /tool.invocation/ }).click();
await expect(page).toHaveURL(/\/audit\?q=review-fixture-trace$/);
await expect(page.getByLabel("Filter records")).toHaveValue("review-fixture-trace");
});
});
+144
View File
@@ -0,0 +1,144 @@
import { expect, loginAs, test } from "./fixtures";
test.use({ viewport: { width: 390, height: 844 } });
test.describe("mobile operator shell", () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.goto("/");
});
test("navigation isolates the page, traps focus, locks scroll, and restores focus on Escape", async ({ page }, testInfo) => {
const trigger = page.getByRole("button", { name: "Open navigation" });
const main = page.locator("main");
await trigger.focus();
await trigger.press("Enter");
const drawer = page.getByRole("dialog", { name: "Application navigation" });
const close = drawer.getByRole("button", { name: "Close navigation" });
await expect(drawer).toBeVisible();
await expect(drawer).toHaveAttribute("aria-modal", "true");
await expect(close).toBeFocused();
if (process.env.SOL_VISUAL_REVIEW === "1") await page.screenshot({ path: testInfo.outputPath("mobile-drawer.png"), animations: "disabled" });
await expect.poll(() => main.evaluate((element) => (element as HTMLElement).inert)).toBe(true);
await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe("hidden");
await drawer.getByRole("link", { name: "ITWorx MCP Hub" }).focus();
await page.keyboard.press("Shift+Tab");
await expect(drawer.getByRole("link", { name: "Platform" })).toBeFocused();
await page.keyboard.press("Tab");
await expect(drawer.getByRole("link", { name: "ITWorx MCP Hub" })).toBeFocused();
await page.keyboard.press("Escape");
await expect(drawer).toHaveCount(0);
await expect(trigger).toBeFocused();
await expect.poll(() => main.evaluate((element) => (element as HTMLElement).inert)).toBe(false);
await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe("");
});
test("short phones can scroll the full drawer while the page stays locked", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 520 });
await page.getByRole("button", { name: "Open navigation" }).click();
const drawer = page.getByRole("dialog", { name: "Application navigation" });
await expect(drawer).toBeVisible();
const metrics = await drawer.evaluate((element) => ({
height: element.getBoundingClientRect().height,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
overflowY: getComputedStyle(element).overflowY,
}));
expect(metrics.height).toBeLessThanOrEqual(521);
expect(metrics.overflowY).toBe("auto");
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight);
await drawer.evaluate((element) => { element.scrollTop = element.scrollHeight; });
await expect(drawer.getByRole("link", { name: "Platform" })).toBeInViewport();
});
test("Quick Jump searches grouped entities with keyboard control and never requests audit for a viewer", async ({ page }, testInfo) => {
const auditRequests: string[] = [];
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/audit") auditRequests.push(request.url());
});
await page.getByRole("button", { name: "Open quick jump" }).click();
const dialog = page.getByRole("dialog", { name: "Quick jump" });
const input = dialog.getByRole("combobox", { name: "Find a workspace or resource" });
await expect(input).toBeFocused();
await expect(dialog).toHaveAttribute("aria-modal", "true");
await expect.poll(() => page.locator("main").evaluate((element) => Boolean(element.closest("[inert]")))).toBe(true);
await input.fill("Ludarium");
const project = dialog.getByRole("group", { name: "Projects", exact: true }).getByRole("option", { name: /Ludarium/i });
await expect(project).toBeVisible();
expect((await project.boundingBox())!.height).toBeLessThanOrEqual(120);
if (process.env.SOL_VISUAL_REVIEW === "1") await page.screenshot({ path: testInfo.outputPath("mobile-quick-jump.png"), animations: "disabled" });
await expect(dialog.getByRole("group", { name: "Projects" })).toBeVisible();
await input.press("ArrowDown");
await expect(input).toHaveAttribute("aria-activedescendant", /quick-jump-option-/);
await input.press("Enter");
await expect(page).toHaveURL(/\/projects\/ludarium$/);
expect(auditRequests).toEqual([]);
});
test("Quick Jump keeps recent navigation in memory and shows search failures", async ({ page }) => {
await page.getByRole("button", { name: "Open quick jump" }).click();
let dialog = page.getByRole("dialog", { name: "Quick jump" });
await dialog.getByRole("option", { name: "Catalog" }).click();
await expect(page).toHaveURL(/\/tools$/);
await page.getByRole("button", { name: "Open quick jump" }).click();
dialog = page.getByRole("dialog", { name: "Quick jump" });
await expect(dialog.getByRole("group", { name: "Recent destinations" }).getByRole("option", { name: "Catalog" })).toBeVisible();
await page.route("**/api/projects?*", (route) => route.fulfill({ status: 503, body: "unavailable" }));
const input = dialog.getByRole("combobox", { name: "Find a workspace or resource" });
await input.fill("Ludarium");
await expect(dialog.getByRole("alert")).toHaveText("One resource type could not be searched. Results may be incomplete.");
});
test("sign-out remains explicit and reports its busy state on a narrow screen", async ({ page }) => {
await page.route("**/auth/logout", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 300));
await route.fulfill({ status: 503, body: "unavailable" });
});
const signOut = page.getByRole("button", { name: "Sign out" });
await expect(signOut).toBeVisible();
await expect(signOut).toHaveText("Sign out");
await signOut.click();
const busyButton = page.getByRole("button", { name: "Signing out…" });
await expect(busyButton).toBeDisabled();
await expect(page.getByRole("alert")).toContainText("control API is unavailable");
await expect(page.getByRole("button", { name: "Sign out" })).toBeEnabled();
});
});
test.describe("mobile workflow dialog", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("large tool workflow fills the dynamic viewport and scrolls inside the dialog", async ({ page }, testInfo) => {
await loginAs(page, "hub-operator");
await page.goto("/tools");
const trigger = page.locator(".mobile-record-list").getByRole("button", { name: "Readiness" }).first();
await trigger.click();
const dialog = page.getByRole("dialog", { name: /Tool console/ });
await expect(dialog).toBeVisible();
await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe("hidden");
await expect.poll(() => page.locator("main").evaluate((element) => Boolean(element.closest("[inert]")))).toBe(true);
const layout = await dialog.evaluate((element) => ({
height: element.getBoundingClientRect().height,
overflow: getComputedStyle(element).overflowY,
bodyOverflow: getComputedStyle(element.querySelector(".modal-body")!).overflowY,
}));
expect(layout.height).toBeGreaterThan(800);
expect(layout.overflow).toBe("hidden");
expect(layout.bodyOverflow).toBe("auto");
if (process.env.SOL_VISUAL_REVIEW === "1") {
await expect(dialog.getByRole("button", { name: "Run readiness check" })).toBeEnabled();
await page.screenshot({ path: testInfo.outputPath("mobile-tool-workflow.png"), animations: "disabled" });
}
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(trigger).toBeFocused();
});
});
@@ -0,0 +1,329 @@
import { expect, loginAs, test } from "./fixtures";
function projectDetailFixture(slug: string) {
return {
slug,
name: `Project ${slug.toUpperCase()}`,
description: `Delayed route fixture for ${slug}.`,
environments: [{ name: "prod", classification: "production" }],
capabilityPacks: [],
connectorBindings: [],
mcpServers: [],
knowledgeSources: [],
virtualServerCount: 0,
clientCount: 0,
activeClientCount: 0,
};
}
function connectorDetailFixture(id: string) {
return {
id,
name: `Connector ${id.toUpperCase()}`,
connectorType: "fixture",
description: `Delayed route fixture for ${id}.`,
registration: { state: "registered" as const, detail: "Fixture registration." },
readiness: { reachable: null, status: "unknown", detail: "No probe was requested.", observedAt: null, source: "unavailable" as const },
bindings: [],
relatedTools: [],
};
}
test.describe("operator workflow details", () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, "hub-admin");
});
test("client onboarding resumes from its durable identity and never treats a Hub check as MCP proof", async ({ page }) => {
await page.goto("/clients");
const clientName = `onboarding-resume-${Date.now()}`;
await page.getByLabel("Name").fill(clientName);
await page.getByLabel("Client profile").selectOption("native-2026");
const createResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/client-management";
});
await page.getByRole("button", { name: "Create pending client" }).click();
const created = await (await createResponsePromise).json() as {
client: { id: string };
bootstrap: { reference: string };
config: { kind: string; protocolVersion: string; transport: string; credentialReference: string };
provisioning: { oauthUsername: string; issuer: string; tokenEndpoint: string; oauthClientId: string };
};
await expect(page.getByText(`Client created: ${clientName}`)).toBeVisible();
await expect.poll(() => page.url()).toMatch(/\/clients\?onboard=[0-9a-f-]+$/i);
const savedUrl = page.url();
expect(new URL(savedUrl).searchParams.get("onboard")).toBe(created.client.id);
await page.reload();
await expect(page.getByText(/Resumable Hub identity/)).toBeVisible();
const handoff = page.locator(".onboarding-config-auth");
const configPanel = page.locator(".onboarding-config");
const configValue = (label: string) => configPanel.locator("dl > div").filter({ hasText: label }).locator("dd");
await expect(configValue("Client profile")).toHaveText(created.config.kind);
await expect(configValue("Protocol version")).toHaveText(created.config.protocolVersion);
await expect(configValue("Transport")).toHaveText(created.config.transport);
await expect(configPanel.getByText("Credential environment variable", { exact: true })).toHaveCount(0);
await expect(configPanel.getByText("Bridge command", { exact: true })).toHaveCount(0);
await expect(handoff).toContainText(`External account: ${created.provisioning.oauthUsername}`);
await expect(handoff).toContainText(`Issuer: ${created.provisioning.issuer}`);
await expect(handoff).toContainText(`Token endpoint: ${created.provisioning.tokenEndpoint}`);
await expect(handoff).toContainText(`OAuth client ID: ${created.provisioning.oauthClientId}`);
const resumedText = await page.locator("body").innerText();
expect(resumedText).not.toContain(created.bootstrap.reference);
expect(resumedText).not.toContain(created.config.credentialReference);
await expect(page.getByText("OAuth scopes", { exact: true })).toBeVisible();
await expect(page.getByText(/External verification still required/)).toBeVisible();
await expect(page.getByRole("button", { name: "Record Hub checks" })).toBeEnabled();
await page.getByRole("button", { name: "Record Hub checks" }).click();
await expect(page.locator(".onboarding-check-recorded")).toContainText("Hub checks were recorded");
await expect(page.getByText(/does not mark external OAuth grants or MCP discovery as complete/)).toBeVisible();
await expect(page.locator(".onboarding-identity-bar small")).toContainText("· pending");
expect(page.url()).toBe(savedUrl);
});
test("a delayed verify response cannot overwrite the onboarding identity after the route changes", async ({ page }) => {
const onboardingStatus = (clientId: string, subjectDetail: string, verified = false) => ({
clientId,
project: "ludarium",
environment: "prod",
clientStatus: "pending",
provisioning: {
status: "pending-authentik-service-account",
subject: null,
issuer: "https://identity.example.invalid",
oauthUsername: `svc-mcp-codex-ludarium-${clientId}`,
tokenEndpoint: "https://identity.example.invalid/application/o/token/",
oauthClientId: "hub-fixture-client",
steps: ["Complete external account setup with the administrator."],
},
checks: {
externalSubject: { state: "pending", detail: subjectDetail },
externalScope: { state: "unavailable", detail: "External OAuth grants are not visible to the Hub." },
hubBinding: { state: "pending", detail: "The client remains disabled." },
catalog: { state: "unknown", detail: "Catalog check has not passed." },
connector: { state: "unknown", detail: "Connector state is unknown." , observedAt: null },
mcpProof: { state: "unavailable", detail: "MCP discovery and invocation must be confirmed externally." },
},
verification: { checkedAt: verified ? "2026-10-01T10:00:00.000Z" : null, checkedByManagement: verified, verifiedByManagement: verified, hubChecksPassed: verified, detail: verified ? "Client A checks passed." : "No verification record." },
});
let releaseVerify!: () => void;
const verifyGate = new Promise<void>((resolve) => { releaseVerify = resolve; });
await page.route("**/api/client-management/a/onboarding-status", (route) => route.fulfill({ json: onboardingStatus("a", "Client A is awaiting its external subject.") }));
await page.route("**/api/client-management/a/config", (route) => route.fulfill({ json: { clientId: "a", endpoint: "https://hub.example.invalid/mcp", scopes: ["project:ludarium:read"] } }));
await page.route("**/api/client-management/b/onboarding-status", (route) => route.fulfill({ json: onboardingStatus("b", "Client B is awaiting its external subject.") }));
await page.route("**/api/client-management/b/config", (route) => route.fulfill({ json: { clientId: "b", endpoint: "https://hub.example.invalid/mcp", scopes: ["project:ludarium:read"] } }));
await page.route("**/api/client-management/a/verify", async (route) => {
await verifyGate;
await route.fulfill({ json: onboardingStatus("a", "Client A is verified.", true) });
});
await page.goto("/clients?onboard=a");
await expect(page.getByText("Client A is awaiting its external subject.")).toBeVisible();
const verifyRequest = page.waitForRequest((request) => request.url().endsWith("/api/client-management/a/verify"));
await page.getByRole("button", { name: "Record Hub checks" }).click();
await verifyRequest;
await page.evaluate(() => {
window.history.pushState({}, "", "/clients?onboard=b");
window.dispatchEvent(new PopStateEvent("popstate"));
});
await expect(page.getByText("Client B is awaiting its external subject.")).toBeVisible();
releaseVerify();
await expect(page.getByText("Client B is awaiting its external subject.")).toBeVisible();
await expect(page.getByText("Client A is verified.")).toHaveCount(0);
await expect(page.getByText(/Hub checks passed/)).toHaveCount(0);
await expect(page).toHaveURL(/\/clients\?onboard=b$/);
});
test("a delayed catalog preview for the previous project cannot be shown or applied", async ({ page }) => {
await page.route((url) => url.pathname === "/api/projects" && url.searchParams.has("limit"), (route) => route.fulfill({ json: {
kind: "projects",
generatedAt: "2026-10-01T10:00:00.000Z",
rows: [
{ id: "ludarium", name: "Ludarium", status: "healthy", summary: "Fixture project A" },
{ id: "forgeflow", name: "ForgeFlow", status: "healthy", summary: "Fixture project B" },
],
page: { total: 2 },
} }));
let releasePreview!: () => void;
const previewGate = new Promise<void>((resolve) => { releasePreview = resolve; });
const previewProjects: string[] = [];
await page.route("**/api/catalog/preview", async (route) => {
const body = route.request().postDataJSON() as { project: string };
previewProjects.push(body.project);
if (body.project === "ludarium") {
await previewGate;
await route.fulfill({ json: { valid: true, changes: ["Project A private preview"], errors: [] } });
return;
}
await route.fulfill({ json: { valid: true, changes: ["Project B preview"], errors: [] } });
});
await page.goto("/tools");
const workflow = page.locator(".workflow-panel").filter({ has: page.getByRole("heading", { name: "Catalog validation and preview" }) });
const project = workflow.getByLabel("Project");
await expect(project.locator("option")).toHaveCount(2);
await project.selectOption("ludarium");
const previewRequest = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/catalog/preview");
await workflow.getByRole("button", { name: "Validate and preview" }).click();
await previewRequest;
try {
await project.selectOption("forgeflow");
await expect(workflow.locator(".diff-box")).toHaveCount(0);
releasePreview();
await expect(workflow.getByText("Project A private preview")).toHaveCount(0);
await expect(workflow.getByRole("button", { name: "Apply approved catalog" })).toHaveCount(0);
expect(previewProjects).toEqual(["ludarium"]);
} finally {
releasePreview();
}
});
test("a delayed project detail response is ignored after navigating to another project", async ({ page }) => {
let releaseProjectA!: () => void;
const projectGate = new Promise<void>((resolve) => { releaseProjectA = resolve; });
await page.route("**/api/projects/a", async (route) => {
await projectGate;
await route.fulfill({ json: projectDetailFixture("a") });
});
await page.route("**/api/projects/b", (route) => route.fulfill({ json: projectDetailFixture("b") }));
const projectARequest = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/projects/a");
await page.goto("/projects/a");
await projectARequest;
await page.evaluate(() => {
window.history.pushState({}, "", "/projects/b");
window.dispatchEvent(new PopStateEvent("popstate"));
});
try {
await expect(page.getByRole("heading", { name: "Project B", exact: true })).toBeVisible();
releaseProjectA();
await expect(page.getByRole("heading", { name: "Project A", exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/projects\/b$/);
} finally {
releaseProjectA();
}
});
test("a delayed connector detail response is ignored after navigating to another connector", async ({ page }) => {
let releaseConnectorA!: () => void;
const connectorGate = new Promise<void>((resolve) => { releaseConnectorA = resolve; });
await page.route("**/api/connectors/a", async (route) => {
await connectorGate;
await route.fulfill({ json: connectorDetailFixture("a") });
});
await page.route("**/api/connectors/b", (route) => route.fulfill({ json: connectorDetailFixture("b") }));
const connectorARequest = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/connectors/a");
await page.goto("/connectors/a");
await connectorARequest;
await page.evaluate(() => {
window.history.pushState({}, "", "/connectors/b");
window.dispatchEvent(new PopStateEvent("popstate"));
});
try {
await expect(page.getByRole("heading", { name: "Connector B", exact: true })).toBeVisible();
releaseConnectorA();
await expect(page.getByRole("heading", { name: "Connector A", exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/connectors\/b$/);
} finally {
releaseConnectorA();
}
});
test("typed tool arguments reach the readiness API and recent runs retain metadata only", async ({ page }) => {
const capturedRequests: Array<Record<string, unknown>> = [];
await page.route(/\/api\/tools(?:\?.*)?$/, (route) => route.fulfill({ json: {
kind: "tools",
generatedAt: "2026-10-01T10:00:00.000Z",
rows: [{ id: "typed-tool", name: "Typed Tool", status: "healthy", summary: "Typed schema fixture", project: "ludarium", environment: "prod", risk: "R1", canonicalName: "test.typed" }],
page: { total: 1 },
} }));
await page.route("**/api/tool-definitions/test.typed", (route) => route.fulfill({ json: {
canonicalName: "test.typed",
description: "Typed schema fixture",
readOnly: true,
riskClass: "R1",
inputSchema: { type: "object", required: ["count", "enabled", "mode", "tags", "filters"], properties: {
count: { type: "integer", title: "Count", minimum: 1, maximum: 10 },
enabled: { type: "boolean", title: "Enabled" },
mode: { type: "string", title: "Mode", enum: ["safe", "full"] },
tags: { type: "array", title: "Tags" },
filters: { type: "object", title: "Filters" },
} },
limits: { timeoutMs: 1000, maxOutputBytes: 2048, maxItems: 10 },
} }));
await page.route("**/api/tools/test", async (route) => {
capturedRequests.push(route.request().postDataJSON() as Record<string, unknown>);
await route.fulfill({ json: {
ready: true,
tool: "test.typed",
project: "ludarium",
environment: "prod",
note: "Readiness only; no MCP tool invocation was performed.",
checks: {
catalog: { valid: true, readOnly: true, connectorType: "fixture" },
input: { valid: true, errors: [] },
binding: { valid: true, detail: "Fixture binding" },
policy: { allowed: true, decision: "allow", reasonCode: "FIXTURE_ALLOWED", traceId: "ready-trace" },
connector: { reachable: true, status: "healthy", detail: "Fixture route" },
},
} });
});
await page.route("**/api/tools/invoke", (route) => route.fulfill({ json: {
ok: false,
traceId: "trace-typed-001",
tool: "test.typed",
content: [{ type: "text", text: "controlled fixture result" }],
error: { code: "controlled", message: "The fixture returned a controlled error." },
} }));
await page.goto("/tools");
await page.getByRole("button", { name: "Readiness" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("button", { name: "Run readiness check" })).toBeDisabled();
await dialog.getByLabel("Project").selectOption("ludarium");
await dialog.getByLabel("Environment").selectOption("prod");
await dialog.getByLabel("Count").fill("4");
await dialog.getByLabel("Enabled").selectOption("true");
await dialog.getByLabel("Mode").selectOption({ label: "full" });
await dialog.getByLabel("Tags").fill('["never-store-tags"]');
await dialog.getByLabel("Filters").fill('{"author":"never-store-filter"}');
await dialog.getByRole("button", { name: "Run readiness check" }).click();
await expect(dialog.getByText("Ready", { exact: true })).toBeVisible();
expect(capturedRequests[0]).toMatchObject({
project: "ludarium",
environment: "prod",
tool: "test.typed",
arguments: { count: 4, enabled: true, mode: "full", tags: ["never-store-tags"], filters: { author: "never-store-filter" } },
});
await dialog.getByLabel("Count").fill("5");
await expect(dialog.getByText("Ready", { exact: true })).toHaveCount(0);
await expect(dialog.getByRole("button", { name: "Run tool" })).toBeDisabled();
await dialog.getByRole("button", { name: "Run readiness check" }).click();
await expect(dialog.getByText("Ready", { exact: true })).toBeVisible();
await dialog.getByRole("button", { name: "Run tool" }).click();
await expect(dialog.getByRole("heading", { name: "Controlled error" })).toBeVisible();
const storedHistory = await page.evaluate(() => sessionStorage.getItem("itworx-tool-history-v1"));
expect(storedHistory).toContain("trace-typed-001");
expect(storedHistory).not.toContain("never-store-tags");
expect(storedHistory).not.toContain("never-store-filter");
expect(storedHistory).not.toContain("controlled fixture result");
await dialog.getByRole("button", { name: "Close dialog" }).click();
const recentRuns = page.getByRole("region", { name: "Recent Tool Runs" });
await expect(recentRuns.getByText("Controlled error")).toBeVisible();
await expect(recentRuns.getByText("trace-typed-001")).toBeVisible();
await recentRuns.getByRole("button", { name: "Open tool context" }).click();
await expect(page).toHaveURL(/\/tools\?reopen=test\.typed&project=ludarium&environment=prod/);
await expect(page.getByRole("dialog", { name: /Tool console/ })).toBeVisible();
await expect(page.getByRole("dialog").getByLabel("Project")).toHaveValue("ludarium");
await expect(page.getByRole("dialog").getByLabel("Environment")).toHaveValue("prod");
});
});
+5 -5
View File
@@ -33,12 +33,12 @@ test.describe("product-quality interaction and composition", () => {
const dialog = page.getByRole("dialog", { name: "Quick jump" });
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 });
+8 -6
View File
@@ -29,13 +29,13 @@ test.describe("management workflows, driven end to end from the browser", () =>
}) => {
await page.goto("/projects/ludarium?tab=Environments");
await 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/ });
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import type { DashboardSnapshot } from "../src/contracts";
import { actionInbox, aggregateTrend, alertDestination } from "../src/operator-data";
const snapshot: DashboardSnapshot = {
generatedAt: "2026-10-01T10:00:00Z", staleAfterSeconds: 90,
health: { hub: "healthy", gateway: "healthy", detail: "Available" },
projects: [], connectors: [{ id: "gitea-alpha", name: "gitea", project: "alpha", state: "degraded", registrationState: "registered", readiness: { state: "unknown", detail: "Not probed", observedAt: null, source: "not-probed" }, detail: "Readiness failed" }],
denials: { count: 2, window: "24h", latestTool: "gitea.repository.get" },
drift: { count: 1, projects: ["alpha"], known: true, detail: "Catalog differs" },
activeClients: 1, backup: { state: "degraded", lastSuccessfulAt: null, detail: "No backup observed" },
version: { application: "1.0.0", schema: "1" },
};
describe("operator signals", () => {
it("routes a connector signal to its exact project binding context", () => {
const item = actionInbox(snapshot, false, false).find((signal) => signal.project === "alpha" && signal.subsystem === "gitea");
expect(item?.href).toBe("/connectors/gitea-alpha");
expect(item?.detail).toBe("Readiness failed");
expect(item?.observedAt).toBe("2026-10-01T10:00:00Z");
});
it("exposes audit navigation only to an authorized auditor", () => {
expect(actionInbox(snapshot, false, false).find((signal) => signal.subsystem === "Authorization")?.href).toBe("/operations?section=alerts");
expect(actionInbox(snapshot, false, true).find((signal) => signal.subsystem === "Authorization")?.href).toBe("/audit?q=gitea.repository.get");
});
it("links drift and backup to their bounded resolution workspace", () => {
const signals = actionInbox(snapshot, false, true);
expect(signals.find((signal) => signal.subsystem === "Catalog")?.href).toBe("/operations?section=checks");
expect(signals.find((signal) => signal.subsystem === "Recovery")?.href).toBe("/operations?section=recovery");
expect(alertDestination("BACKUP_STALE", false)).toBe("/operations?section=recovery");
});
it("keeps unknown and stale state explicit without claiming a diagnosis", () => {
const signals = actionInbox({ ...snapshot, drift: { ...snapshot.drift, known: false } }, true, false);
expect(signals[0]?.subsystem).toBe("Freshness");
expect(signals.find((signal) => signal.subsystem === "Catalog")?.status).toBe("unknown");
});
});
describe("mobile call buckets", () => {
it("aggregates each four-hour interval without dropping outcomes", () => {
const trend = Array.from({ length: 24 }, (_, hour) => ({ hour: `2026-10-01T${String(hour).padStart(2, "0")}:00:00Z`, success: 1, denied: 2, error: 3, rate_limited: 4 }));
const buckets = aggregateTrend(trend, 4);
expect(buckets).toHaveLength(6);
expect(buckets[0]).toEqual({ hour: "2026-10-01T00:00:00Z", endHour: "2026-10-01T03:00:00Z", success: 4, denied: 8, error: 12, rate_limited: 16 });
expect(buckets[5]?.success).toBe(4);
});
it("retains partial buckets and handles empty observations", () => {
expect(aggregateTrend([], 4)).toEqual([]);
expect(aggregateTrend([{ hour: "2026-10-01T00:00:00Z", success: 2, denied: 0, error: 1, rate_limited: 0 }], 4)[0]?.error).toBe(1);
});
});
+16
View File
@@ -0,0 +1,16 @@
import { afterEach, expect, it, vi } from "vitest";
import { readPreference, writePreference } from "../src/preferences";
afterEach(() => vi.unstubAllGlobals());
it("accepts only known presentation preferences", () => {
const values = new Map<string, string>([["hub-density", "arbitrary"]]);
vi.stubGlobal("localStorage", { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value) });
expect(readPreference("hub-density", ["compact", "comfortable"], "comfortable")).toBe("comfortable");
writePreference("hub-density", "compact");
expect(readPreference("hub-density", ["compact", "comfortable"], "comfortable")).toBe("compact");
});
it("continues with safe defaults when storage is denied", () => {
vi.stubGlobal("localStorage", { getItem: () => { throw new Error("storage denied"); }, setItem: () => { throw new Error("storage denied"); } });
expect(readPreference("hub-theme", ["dark", "light"], "dark")).toBe("dark");
expect(() => writePreference("hub-theme", "light")).not.toThrow();
});
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { NULL_INPUT_VALUE, parseSchemaArguments } from "../src/schema-input";
import type { ToolDefinition } from "../src/contracts";
const schema = (properties: NonNullable<ToolDefinition["inputSchema"]["properties"]>, required: string[] = []): ToolDefinition["inputSchema"] => ({
type: "object",
properties,
required,
});
describe("schema-driven tool arguments", () => {
it("parses strings, integers, decimals, booleans, enums, arrays and objects by declared type", () => {
const inputSchema = schema({
query: { type: "string" },
limit: { type: "integer", minimum: 1, maximum: 20 },
score: { type: "number" },
includeClosed: { type: "boolean" },
format: { type: "string", enum: ["compact", "full"] },
labels: { type: "array" },
filters: { type: "object" },
}, ["query", "limit", "includeClosed", "format"]);
expect(parseSchemaArguments({
query: "pull request",
limit: "4",
score: "3.5",
includeClosed: "false",
format: JSON.stringify("full"),
labels: "[\"bug\",\"urgent\"]",
filters: "{\"author\":\"jens\"}",
}, inputSchema)).toEqual({
arguments: {
query: "pull request",
limit: 4,
score: 3.5,
includeClosed: false,
format: "full",
labels: ["bug", "urgent"],
filters: { author: "jens" },
},
errors: {},
valid: true,
});
});
it("reports missing required values, malformed JSON and type mismatches clearly", () => {
const inputSchema = schema({
count: { type: "integer" },
enabled: { type: "boolean" },
tags: { type: "array" },
options: { type: "object" },
}, ["count", "enabled", "tags", "options"]);
const parsed = parseSchemaArguments({ count: "2.3", enabled: "sometimes", tags: "{oops", options: "[]" }, inputSchema);
expect(parsed.valid).toBe(false);
expect(parsed.arguments).toEqual({});
expect(parsed.errors).toEqual({
count: "Enter a whole number.",
enabled: "Choose yes or no.",
tags: "Enter valid JSON for this array.",
options: "Enter a JSON object.",
});
});
it("applies string and numeric bounds and rejects enum values outside the schema", () => {
const inputSchema = schema({
query: { type: "string", minLength: 2, maxLength: 5 },
limit: { type: "integer", minimum: 1, maximum: 8 },
mode: { type: "string", enum: ["safe", "full"] },
});
const parsed = parseSchemaArguments({ query: "x", limit: "9", mode: JSON.stringify("unsafe") }, inputSchema);
expect(parsed.valid).toBe(false);
expect(parsed.errors.query).toBe("Enter at least 2 characters.");
expect(parsed.errors.limit).toBe("Enter a number no greater than 8.");
expect(parsed.errors.mode).toBe("Choose a value listed by this tool's schema.");
});
it("omits empty optional fields and accepts explicit null only for nullable fields", () => {
const inputSchema = schema({ optional: { type: "string" }, nullable: { type: ["string", "null"] } });
expect(parseSchemaArguments({ optional: "", nullable: NULL_INPUT_VALUE }, inputSchema)).toEqual({
arguments: { nullable: null },
errors: {},
valid: true,
});
expect(parseSchemaArguments({ nullable: NULL_INPUT_VALUE }, schema({ nullable: { type: "string" } })).errors.nullable).toBe("This field does not accept null.");
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { MAX_TOOL_RUNS, recordToolRun, readToolHistory, type ToolRunMetadata } from "../src/tool-history";
function createStorage(seed: string | null = null) {
let value = seed;
return {
getItem: () => value,
setItem: (_key: string, next: string) => { value = next; },
value: () => value,
};
}
const run = (overrides: Partial<ToolRunMetadata> = {}): ToolRunMetadata => ({
tool: "gitea.pullrequest.status",
project: "ludarium",
environment: "prod",
ok: true,
traceId: "trace-123",
at: "2026-09-30T10:00:00.000Z",
...overrides,
});
describe("bounded recent tool history", () => {
it("stores only safe invocation metadata and never serializes arguments or results", () => {
const storage = createStorage();
recordToolRun({ ...run(), arguments: { token: "fixture-do-not-store" }, result: "private output" } as ToolRunMetadata, storage);
expect(JSON.parse(storage.value() ?? "null")).toEqual([run()]);
expect(storage.value()).not.toContain("fixture-do-not-store");
expect(storage.value()).not.toContain("private output");
});
it("bounds history to the newest twenty valid entries", () => {
const storage = createStorage();
for (let index = 0; index < MAX_TOOL_RUNS + 4; index += 1) {
recordToolRun(run({ traceId: `trace-${index}`, at: `2026-09-30T10:${String(index).padStart(2, "0")}:00.000Z` }), storage);
}
const history = readToolHistory(storage);
expect(history).toHaveLength(MAX_TOOL_RUNS);
expect(history[0]?.traceId).toBe(`trace-${MAX_TOOL_RUNS + 3}`);
expect(history.at(-1)?.traceId).toBe("trace-4");
});
it("drops malformed and oversized stored entries without exposing extra fields", () => {
const storage = createStorage(JSON.stringify([
{ ...run(), arguments: { apiKey: "secret" }, content: "result" },
{ ...run(), traceId: "trace\nsecret" },
{ ...run(), at: "yesterday" },
null,
]));
expect(readToolHistory(storage)).toEqual([run()]);
});
it("treats corrupt or unavailable browser storage as an empty history", () => {
expect(readToolHistory(createStorage("not-json"))).toEqual([]);
expect(() => readToolHistory({ getItem: () => { throw new Error("blocked"); }, setItem: () => undefined })).not.toThrow();
expect(() => recordToolRun(run(), { getItem: () => null, setItem: () => { throw new Error("blocked"); } })).not.toThrow();
});
});
+1 -1
View File
@@ -6,7 +6,7 @@ export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
coverage: { reporter: ["text", "json-summary"] },
coverage: { include: ["src/**/*.{ts,tsx}"], reporter: ["text", "json-summary"] },
},
});
+4 -2
View File
@@ -118,7 +118,7 @@ class FakeGiteaServer:
"html_url": "https://gitea.example/acme/hub/issues/1",
}
]
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
View File
@@ -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(
+9
View File
@@ -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"
}
}
}
+13 -14
View File
@@ -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",
),
)
+4 -2
View File
@@ -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,
+5 -1
View File
@@ -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,
)
)
+263 -579
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"pyyaml==6.0.2",
"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]
+2
View File
@@ -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",
+6
View File
@@ -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",
+2
View File
@@ -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}),
+4 -1
View File
@@ -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),
+44 -3
View File
@@ -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,
+55 -6
View File
@@ -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)
+4 -4
View File
@@ -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."""
File diff suppressed because it is too large. Load diff
+29 -4
View File
@@ -11,8 +11,10 @@ authorization, and the real policy engine -- not a mocked frontend:
-> fake ContextForge (in-process MockTransport; nothing the
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__":
+7 -14
View File
@@ -33,14 +33,8 @@ def test_appops_tool_catalog_is_bijective_and_server_namespaced() -> None:
PUBLIC_TO_UPSTREAM["server.appops.self_upgrade.preflight"]
== "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",
+17
View File
@@ -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",
+54
View File
@@ -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)
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from scripts import validate_pack
ROOT = Path(__file__).resolve().parents[1]
@pytest.mark.parametrize(
("canonical_name", "risk", "accepted"),
[
("server.appops.self_upgrade.apply", "R3", True),
("server.appops.self_upgrade.apply", "R2", False),
("server.appops.arbitrary.apply", "R3", False),
],
)
def test_catalog_accepts_only_the_approved_exact_risk_write(
monkeypatch: pytest.MonkeyPatch, canonical_name: str, risk: str, accepted: bool
) -> None:
tool_path = ROOT / "catalog/tools/server-appops-self-upgrade-apply.yaml"
policy_path = next((ROOT / "catalog/policies").glob("*global-deny*"))
tool = validate_pack.load_json(tool_path)
tool["spec"]["canonicalName"] = canonical_name
tool["spec"]["riskClass"] = risk
policy = validate_pack.load_json(policy_path)
def read_documents(_errors: list[str]) -> list[tuple[Path, Any]]:
return [(tool_path, tool), (policy_path, policy)]
monkeypatch.setattr(validate_pack, "read_catalog_documents", read_documents)
errors: list[str] = []
validate_pack.validate_catalog(errors, [])
if accepted:
assert errors == []
else:
assert any("invalid risk class" in error for error in errors)
if canonical_name.endswith("arbitrary.apply"):
assert any("not an approved controlled write tool" in error for error in errors)
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
import pytest
from sqlalchemy import event, select
from packages.hub_clients import ClientKind, ClientStatus
from services.control_api.auth.models import AuthenticatedPrincipal, PrincipalKind
from services.control_api.persistence.models import AuditEventRow, ClientBindingRow, ClientRow
from services.runtime.client_service import PersistentClientService
pytest_plugins = ("tests.test_machine_client_provisioning",)
def _actor() -> AuthenticatedPrincipal:
now = datetime.now(UTC)
return AuthenticatedPrincipal(
subject="operator-audit-fixture",
issuer="https://identity.fixture.invalid",
audience=("hub-fixture",),
kind=PrincipalKind.MANAGEMENT_USER,
issued_at=now,
expires_at=now + timedelta(minutes=5),
)
def _active_client(database: Any) -> tuple[PersistentClientService, Any]:
service = PersistentClientService(
database, issuer="https://identity.fixture.invalid", public_host="mcp.fixture.invalid"
)
record, _, _ = service.create(
name="Audit fixture", project="ludarium", environment="prod", kind=ClientKind.STDIO_BRIDGE
)
with database() as session, session.begin():
row = session.get(ClientRow, record.id)
assert row is not None
row.status = "active"
binding = session.scalar(
select(ClientBindingRow).where(ClientBindingRow.client_id == record.id)
)
assert binding is not None
binding.enabled = True
return service, record.id
def test_client_disable_and_revoke_are_scoped_and_append_actor_audits(database: Any) -> None:
service, client_id = _active_client(database)
actor = _actor()
disabled = service.transition(client_id, ClientStatus.DISABLED, actor)
assert disabled.status is ClientStatus.DISABLED
revoked = service.transition(client_id, ClientStatus.REVOKED, actor)
assert revoked.status is ClientStatus.REVOKED
with database() as session:
binding = session.scalar(
select(ClientBindingRow).where(ClientBindingRow.client_id == client_id)
)
assert binding is not None and binding.enabled is False
audits = session.scalars(
select(AuditEventRow)
.where(AuditEventRow.client_id == client_id)
.order_by(AuditEventRow.occurred_at)
).all()
assert [row.event_type for row in audits] == ["client.disable", "client.revoke"]
for row in audits:
assert row.principal_subject == actor.subject
assert row.principal_issuer == actor.issuer
assert row.project_id == binding.project_id
assert row.virtual_server_id == binding.virtual_server_id
assert row.environment_id is not None
assert row.policy_decision == "allow" and row.outcome == "success"
assert row.sanitized_arguments == {"clientId": str(client_id), "action": row.event_type}
assert row.arguments_hash is not None and len(row.arguments_hash) == 64
assert audits[0].trace_id != audits[1].trace_id
with pytest.raises(ValueError, match="revoked"):
service.transition(client_id, ClientStatus.DISABLED, actor)
with database() as session:
row = session.get(ClientRow, client_id)
assert row is not None and row.status == "revoked"
assert len(session.scalars(select(AuditEventRow)).all()) == 2
@pytest.mark.parametrize("status", [ClientStatus.DISABLED, ClientStatus.REVOKED])
def test_audit_failure_rolls_back_client_and_binding_transition(
database: Any, status: ClientStatus
) -> None:
service, client_id = _active_client(database)
def reject_audit(*_: Any) -> None:
raise RuntimeError("fixture audit insert failed")
event.listen(AuditEventRow, "before_insert", reject_audit)
try:
with pytest.raises(RuntimeError, match="fixture audit insert failed"):
service.transition(client_id, status, _actor())
finally:
event.remove(AuditEventRow, "before_insert", reject_audit)
with database() as session:
row = session.get(ClientRow, client_id)
binding = session.scalar(
select(ClientBindingRow).where(ClientBindingRow.client_id == client_id)
)
assert row is not None and row.status == "active"
assert row.revoked_at is None
assert row.record_metadata["operatorDisabled"] is False
assert binding is not None and binding.enabled is True
assert session.scalars(select(AuditEventRow)).all() == []
+37
View File
@@ -0,0 +1,37 @@
"""The browser harness supplies every real web readiness dependency offline."""
from unittest.mock import patch
from fastapi.testclient import TestClient
from tests.e2e.harness import run_web, worker_readiness_fixture
def test_worker_fixture_is_explicit_and_read_only() -> None:
with TestClient(worker_readiness_fixture()) as client:
response = client.get("/health/ready")
assert response.status_code == 200
assert response.json() == {"status": "ready", "component": "e2e-worker-fixture"}
assert client.post("/health/ready").status_code == 405
def test_web_harness_configures_both_upstream_dependencies(tmp_path) -> None:
(tmp_path / "index.html").write_text("<html></html>", encoding="utf-8")
with (
patch("tests.e2e.harness.os.environ", {}),
patch("tests.e2e.harness.web_runtime.build_app") as build,
patch("tests.e2e.harness.generate_local_tls_material") as tls,
patch("tests.e2e.harness.uvicorn.run") as serve,
):
import os
def inspect_dependencies():
assert os.environ["HUB_CONTROL_URL"] == "http://127.0.0.1:8781"
assert os.environ["HUB_WORKER_URL"] == "http://127.0.0.1:8783"
return "configured-app"
build.side_effect = inspect_dependencies
tls.return_value.cert_path = tmp_path / "cert.pem"
tls.return_value.key_path = tmp_path / "key.pem"
run_web(8780, 8781, 8783, tmp_path)
assert serve.call_args.args == ("configured-app",)
+11 -19
View File
@@ -267,8 +267,7 @@ class GiteaConnectorTests(unittest.TestCase):
"Jens/projectbrain",
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:
+67 -6
View File
@@ -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"
+194
View File
@@ -0,0 +1,194 @@
from __future__ import annotations
from pathlib import Path
from uuid import uuid4
from services.control_api.persistence import create_engine_from_url, session_factory
from services.control_api.persistence.models import (
AuditEventRow,
EnvironmentRow,
ProjectRow,
WorkspaceRow,
)
from tests.test_wp230_rbac import ManagementRole, RoleSession
pytest_plugins = ("tests.test_wp230_rbac",)
def test_resource_search_matches_connector_slug(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
response = role_sessions[ManagementRole.VIEWER].get(
"/api/connectors", params={"q": "knowledge-default"}
)
assert response.status_code == 200
body = response.json()
assert [row["id"] for row in body["rows"]] == ["knowledge-default"]
def test_project_scoped_connector_search_uses_bindings_and_environment(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
viewer = role_sessions[ManagementRole.VIEWER]
response = viewer.get(
"/api/connectors",
params={
"project": "ludarium",
"environment": "prod",
"q": "knowledge-default",
},
)
assert response.status_code == 200
rows = response.json()["rows"]
assert len(rows) == 1
assert rows[0]["id"] == "knowledge-default"
assert rows[0]["project"] == "ludarium"
assert rows[0]["environment"] == "prod"
assert not any("url" in key.casefold() for key in rows[0])
unbound_project = viewer.get(
"/api/connectors",
params={
"project": "mobilityops",
"environment": "prod",
"q": "knowledge-default",
},
)
assert unbound_project.status_code == 200
assert unbound_project.json()["rows"] == []
def test_resource_search_matches_tool_canonical_name(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
response = role_sessions[ManagementRole.VIEWER].get(
"/api/tools", params={"q": "deployment.project.status"}
)
assert response.status_code == 200
body = response.json()
assert [row["canonicalName"] for row in body["rows"]] == ["deployment.project.status"]
def test_project_scoped_tool_search_uses_bindings_and_capability_packs(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
viewer = role_sessions[ManagementRole.VIEWER]
ludarium_tools = viewer.get(
"/api/tools",
params={"project": "ludarium", "limit": 100},
)
assert ludarium_tools.status_code == 200
ludarium_body = ludarium_tools.json()
ludarium_rows = ludarium_body["rows"]
assert ludarium_body["page"]["total"] == len(ludarium_rows)
ludarium_names = {row["canonicalName"] for row in ludarium_rows}
assert "deployment.project.status" in ludarium_names
assert "gitea.branch.delete" not in ludarium_names
assert all(row["project"] == "ludarium" for row in ludarium_rows)
assert all(row["environment"] == "prod" for row in ludarium_rows)
unbound_connector = viewer.get(
"/api/tools",
params={
"project": "mobilityops",
"environment": "prod",
"q": "deployment.project.status",
},
)
assert unbound_connector.status_code == 200
assert unbound_connector.json()["rows"] == []
environment_without_server = viewer.get(
"/api/tools",
params={
"project": "forgeflow",
"environment": "dev",
"q": "deployment.project.status",
},
)
assert environment_without_server.status_code == 200
assert environment_without_server.json()["rows"] == []
def test_audit_search_matches_trace_id_without_exposing_search_to_viewers(
role_sessions: dict[ManagementRole, RoleSession], db_path: Path
) -> None:
trace_id = f"operator-search-{uuid4()}"
engine = create_engine_from_url(f"sqlite:///{db_path}")
sessions = session_factory(engine)
with sessions() as session, session.begin():
workspace = session.query(WorkspaceRow).filter_by(slug="itworx").one()
project = session.query(ProjectRow).filter_by(slug="ludarium").one()
environment = (
session.query(EnvironmentRow).filter_by(project_id=project.id, name="prod").one()
)
session.add(
AuditEventRow(
trace_id=trace_id,
workspace_id=workspace.id,
project_id=project.id,
environment_id=environment.id,
event_type="operator.search.fixture",
tool_name="operator.search.fixture",
policy_decision="must-not-be-a-search-field",
outcome="success",
)
)
engine.dispose()
auditor = role_sessions[ManagementRole.AUDITOR]
matching = auditor.get("/api/audit", params={"q": trace_id})
assert matching.status_code == 200
assert [row["traceId"] for row in matching.json()["rows"]] == [trace_id]
excluded_field = auditor.get("/api/audit", params={"q": "must-not-be-a-search-field"})
assert excluded_field.status_code == 200
assert excluded_field.json()["rows"] == []
denied = role_sessions[ManagementRole.VIEWER].get("/api/audit", params={"q": trace_id})
assert denied.status_code == 403
def test_servers_list_virtual_server_identities_and_apply_project_environment_scope(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
viewer = role_sessions[ManagementRole.VIEWER]
scoped = viewer.get(
"/api/servers",
params={"project": "forgeflow", "environment": "prod", "limit": 1},
)
assert scoped.status_code == 200
scoped_body = scoped.json()
assert scoped_body["page"]["total"] == 1
assert scoped_body["rows"] == [
{
"id": "forgeflow-prod-readonly",
"name": "forgeflow-prod-readonly",
"status": "unknown",
"summary": "Catalog registered; live runtime health has not been observed.",
"project": "forgeflow",
"environment": "prod",
"risk": None,
"canonicalName": None,
}
]
wrong_environment = viewer.get(
"/api/servers", params={"project": "forgeflow", "environment": "dev"}
)
assert wrong_environment.status_code == 200
assert wrong_environment.json()["page"]["total"] == 0
assert wrong_environment.json()["rows"] == []
wrong_project = viewer.get(
"/api/servers",
params={"project": "mobilityops", "environment": "prod", "q": "forgeflow-prod-readonly"},
)
assert wrong_project.status_code == 200
assert wrong_project.json()["page"]["total"] == 0
assert wrong_project.json()["rows"] == []
+519
View File
@@ -0,0 +1,519 @@
from __future__ import annotations
import os
from collections.abc import Iterator
from contextlib import ExitStack
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from http.cookies import SimpleCookie
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
from uuid import UUID, uuid4
import pytest
from fastapi.routing import APIRoute
from fastapi.testclient import TestClient
from services.control_api.auth.config import OIDCSettings
from services.control_api.auth.fake_provider import FakeOIDCProvider
from services.control_api.auth.models import (
AuthenticatedPrincipal,
ManagementRole,
PrincipalKind,
)
from services.control_api.contextforge.fake import FakeContextForge
from services.control_api.contextforge.models import ContextForgeSettings
from services.control_api.persistence import Base, create_engine_from_url, session_factory
from services.control_api.persistence.machine_client import provision_machine_client
from services.control_api.persistence.models import (
AuditEventRow,
ClientBindingRow,
ClientRow,
ConnectorRow,
EnvironmentRow,
ProjectRow,
VirtualServerRow,
WorkspaceRow,
)
from services.runtime import control_api
from services.runtime.control_api import build_app
from services.runtime.state_sync import sync_runtime_state
ROOT = Path(__file__).resolve().parents[1]
ISSUER = "https://fake.operator-workspaces.test"
ROLE_GROUPS = {
ManagementRole.VIEWER: "hub-viewer",
ManagementRole.OPERATOR: "hub-operator",
ManagementRole.ADMIN: "hub-admin",
}
@dataclass
class RoleSession:
app: Any
client: TestClient
csrf: str
def get(self, path: str) -> Any:
return self.client.get(path)
def post(self, path: str, **kwargs: Any) -> Any:
headers = dict(kwargs.pop("headers", {}) or {})
headers["X-CSRF-Token"] = self.csrf
return self.client.post(path, headers=headers, **kwargs)
@pytest.fixture(scope="module", autouse=True)
def client_bindings_env() -> Iterator[None]:
previous_bindings = os.environ.get("MCP_CLIENT_BINDINGS_JSON")
previous_token = os.environ.get("OPERATOR_WORKSPACES_FAKE_CONTEXTFORGE_TOKEN")
os.environ["MCP_CLIENT_BINDINGS_JSON"] = "[]"
os.environ["OPERATOR_WORKSPACES_FAKE_CONTEXTFORGE_TOKEN"] = "operator-workspaces-fake-token" # noqa: S105
yield
if previous_bindings is None:
os.environ.pop("MCP_CLIENT_BINDINGS_JSON", None)
else:
os.environ["MCP_CLIENT_BINDINGS_JSON"] = previous_bindings
if previous_token is None:
os.environ.pop("OPERATOR_WORKSPACES_FAKE_CONTEXTFORGE_TOKEN", None)
else:
os.environ["OPERATOR_WORKSPACES_FAKE_CONTEXTFORGE_TOKEN"] = previous_token
@pytest.fixture(scope="module")
def db_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
path = tmp_path_factory.mktemp("operator-workspaces") / "hub.db"
engine = create_engine_from_url(f"sqlite:///{path}")
Base.metadata.create_all(engine)
sessions = session_factory(engine)
with sessions() as session, session.begin():
sync_runtime_state(session, ROOT / "catalog", ISSUER)
engine.dispose()
return path
def _login(client: TestClient, provider: FakeOIDCProvider) -> str:
login = client.get("/auth/login", follow_redirects=False)
query = parse_qs(urlparse(login.headers["location"]).query)
code = provider.authorize(login.headers["location"])
callback = client.get(
"/auth/callback",
params={"code": code, "state": query["state"][0]},
follow_redirects=False,
)
cookie = SimpleCookie()
cookie.load(callback.headers["set-cookie"])
cookie_name = next(iter(cookie))
client.cookies.set(cookie_name, cookie[cookie_name].value)
return str(client.get("/auth/session").json()["csrf_token"])
@pytest.fixture(scope="module")
def role_sessions(db_path: Path) -> Iterator[dict[ManagementRole, RoleSession]]:
with ExitStack() as stack:
result: dict[ManagementRole, RoleSession] = {}
for role, group in ROLE_GROUPS.items():
provider = FakeOIDCProvider(issuer=ISSUER, user_groups=(group,))
oidc = OIDCSettings(
issuer=ISSUER,
audience=provider.audience,
client_id="operator-workspaces-hub",
redirect_uri="https://hub.test/auth/callback",
secure_cookie=True,
role_group_map={value: key for key, value in ROLE_GROUPS.items()},
)
engine = create_engine_from_url(f"sqlite:///{db_path}")
app = build_app(
oidc=oidc,
provider_transport=provider.transport(),
contextforge_settings=ContextForgeSettings(
base_url="https://contextforge.test",
auth_token_env="OPERATOR_WORKSPACES_FAKE_CONTEXTFORGE_TOKEN", # noqa: S106
),
contextforge_transport=FakeContextForge().transport(),
engine=engine,
catalog_root=ROOT / "catalog",
)
client = stack.enter_context(TestClient(app))
csrf = _login(client, provider)
result[role] = RoleSession(app=app, client=client, csrf=csrf)
yield result
def _ready_probe(_: str) -> dict[str, Any]:
return {"reachable": True, "status": "ready", "detail": "Direct connector readiness verified."}
def test_project_workspace_returns_registered_server_and_source_metadata(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
response = role_sessions[ManagementRole.VIEWER].get("/api/projects/ludarium")
assert response.status_code == 200, response.text
body = response.json()
assert body["mcpServers"]
assert body["mcpServers"][0]["endpointAlias"] == "/mcp/ludarium/prod"
assert body["mcpServers"][0]["stateSource"] in {"persisted", "catalog-only"}
assert body["knowledgeSources"]
source = body["knowledgeSources"][0]
assert source["uri"] == "discovery://repository/ludarium"
assert source["indexesSecrets"] is False
assert "scoped Knowledge connector" in source["accessBoundary"]
assert "README*" in source["include"]
assert "credentialReferences" not in response.text
def test_connector_workspace_is_scoped_and_secret_free(
role_sessions: dict[ManagementRole, RoleSession], monkeypatch: pytest.MonkeyPatch
) -> None:
probe_calls: list[str] = []
def record_probe(slug: str) -> dict[str, Any]:
probe_calls.append(slug)
return _ready_probe(slug)
monkeypatch.setattr(control_api, "_direct_connector_readiness", record_probe)
viewer = role_sessions[ManagementRole.VIEWER]
response = viewer.get("/api/connectors/knowledge-default?project=ludarium&environment=prod")
assert response.status_code == 200, response.text
body = response.json()
assert body["id"] == "knowledge-default"
assert body["connectorType"] == "knowledge"
assert body["registration"]["state"] == "registered"
assert body["readiness"]["source"] in {"persisted", "unavailable"}
if body["readiness"]["source"] == "unavailable":
assert body["readiness"]["observedAt"] is None
assert probe_calls == []
assert body["bindings"] == [
{"project": "ludarium", "environment": "prod", "verification": "verified"}
]
assert body["relatedTools"]
for sensitive_key in ("credentialReferences", "credentialReference", "endpoint", "token"):
assert sensitive_key not in body
assert "credentialReferences" not in response.text
assert "service_token" not in response.text
assert (
viewer.get("/api/connectors/knowledge-default?project=missing&environment=prod").status_code
== 404
)
assert (
viewer.get(
"/api/connectors/knowledge-default?project=ludarium&environment=staging"
).status_code
== 404
)
assert viewer.get("/api/connectors/knowledge-default?project=ludarium").status_code == 422
def test_connector_workspace_marks_old_persisted_health_unknown_without_probing(
role_sessions: dict[ManagementRole, RoleSession],
db_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
probe_calls: list[str] = []
def record_probe(slug: str) -> dict[str, Any]:
probe_calls.append(slug)
return _ready_probe(slug)
monkeypatch.setattr(control_api, "_direct_connector_readiness", record_probe)
engine = create_engine_from_url(f"sqlite:///{db_path}")
sessions = session_factory(engine)
observed_at = datetime.now(UTC) - timedelta(minutes=5)
with sessions() as session, session.begin():
workspace = session.query(WorkspaceRow).filter_by(slug="itworx").one()
project = (
session.query(ProjectRow).filter_by(workspace_id=workspace.id, slug="ludarium").one()
)
environment = (
session.query(EnvironmentRow)
.filter_by(workspace_id=workspace.id, project_id=project.id, name="prod")
.one()
)
connector = (
session.query(ConnectorRow)
.filter_by(
workspace_id=workspace.id,
project_id=project.id,
environment_scope="prod",
type="knowledge",
slug="knowledge-default",
)
.one_or_none()
)
if connector is None:
connector = ConnectorRow(
id=uuid4(),
workspace_id=workspace.id,
project_id=project.id,
environment_id=environment.id,
environment_scope="prod",
type="knowledge",
slug="knowledge-default",
)
session.add(connector)
connector.health_state = "healthy"
connector.last_check_at = observed_at
connector.endpoint = "https://knowledge.example.invalid/health"
connector.credential_reference = "vault://private-reference"
engine.dispose()
response = role_sessions[ManagementRole.VIEWER].get(
"/api/connectors/knowledge-default?project=ludarium&environment=prod"
)
assert response.status_code == 200, response.text
readiness = response.json()["readiness"]
assert readiness["source"] == "persisted"
assert readiness["stale"] is True
assert readiness["reachable"] is None
assert readiness["status"] == "unknown"
assert readiness["observedAt"]
assert "Stale observation" in readiness["detail"]
assert "knowledge.example.invalid" not in response.text
assert "vault://private-reference" not in response.text
assert probe_calls == []
def test_connector_and_onboarding_routes_deny_machine_principals_even_with_admin_role(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
app = role_sessions[ManagementRole.VIEWER].app
route = next(
route
for route in app.routes
if isinstance(route, APIRoute) and route.path == "/api/connectors/{slug}"
)
dependency_calls: list[Any] = []
def visit(dependant: Any) -> None:
if dependant.call is not None:
dependency_calls.append(dependant.call)
for child in dependant.dependencies:
visit(child)
visit(route.dependant)
authenticate = next(
call for call in dependency_calls if getattr(call, "__name__", "") == "authenticate"
)
now = datetime.now(UTC)
machine = AuthenticatedPrincipal(
subject="machine-connector-reader",
issuer=ISSUER,
audience=("operator-workspaces-hub",),
kind=PrincipalKind.MACHINE_CLIENT,
client_id=uuid4(),
roles=frozenset({ManagementRole.ADMIN}),
expires_at=now + timedelta(minutes=5),
issued_at=now,
)
app.dependency_overrides[authenticate] = lambda: machine
try:
client = role_sessions[ManagementRole.VIEWER].client
connector = client.get("/api/connectors/knowledge-default")
onboarding = client.get(f"/api/client-management/{uuid4()}/onboarding-status")
finally:
app.dependency_overrides.pop(authenticate, None)
assert connector.status_code == 403
assert onboarding.status_code == 403
def test_onboarding_verification_records_server_checks_without_enabling_client_or_binding(
role_sessions: dict[ManagementRole, RoleSession], db_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
probe_calls: list[str] = []
def record_probe(slug: str) -> dict[str, Any]:
probe_calls.append(slug)
return _ready_probe(slug)
monkeypatch.setattr(control_api, "_direct_connector_readiness", record_probe)
admin = role_sessions[ManagementRole.ADMIN]
operator = role_sessions[ManagementRole.OPERATOR]
viewer = role_sessions[ManagementRole.VIEWER]
created = admin.post(
"/api/client-management",
json={
"name": "resume-me",
"project": "gitea-loop",
"environment": "prod",
"kind": "native-2026",
},
)
assert created.status_code == 201, created.text
client_id = created.json()["client"]["id"]
operator_status = operator.get(f"/api/client-management/{client_id}/onboarding-status")
assert operator_status.status_code == 200, operator_status.text
assert operator_status.json()["checks"]["connector"]["state"] == "unknown"
assert probe_calls == []
status = viewer.get(f"/api/client-management/{client_id}/onboarding-status")
assert status.status_code == 200, status.text
before = status.json()
assert before["clientStatus"] == "pending"
assert before["checks"]["externalSubject"]["state"] == "pending"
assert before["checks"]["externalScope"]["state"] == "unavailable"
assert before["checks"]["catalog"]["state"] == "ready"
assert before["checks"]["hubBinding"]["state"] == "pending"
assert before["checks"]["connector"]["state"] == "unknown"
assert before["checks"]["connector"]["observedAt"] is None
assert before["checks"]["mcpProof"]["state"] == "unavailable"
handoff = before["provisioning"]
assert handoff == {
"status": "pending-authentik-service-account",
"subject": None,
"issuer": ISSUER,
"oauthUsername": f"svc-mcp-codex-gitea-loop-{client_id.replace('-', '')[:8]}",
"tokenEndpoint": f"{ISSUER}/application/o/token/",
"oauthClientId": "operator-workspaces-hub",
"steps": [
"Provision the named Authentik service account and app password.",
"Activate this pending Hub client with the exact Authentik user UUID.",
"Install the generated Codex stdio profile using a user-only credential file.",
"Run MCP discovery, one read-only invocation and one cross-project denial.",
],
}
assert not {"credentialReference", "bootstrapReference", "credential", "secret"} & set(handoff)
assert "credentialReference" not in status.text
assert "bootstrapReference" not in status.text
assert "authentik-bootstrap://" not in status.text
assert "authentik://service-account/" not in status.text
assert probe_calls == []
denied_verify = viewer.post(f"/api/client-management/{client_id}/verify")
assert denied_verify.status_code == 403
assert probe_calls == []
checked_pending = admin.post(f"/api/client-management/{client_id}/verify")
assert checked_pending.status_code == 200, checked_pending.text
assert probe_calls
pending_verification_probe_count = len(probe_calls)
assert checked_pending.json()["verification"]["checkedByManagement"] is True
assert checked_pending.json()["verification"]["verifiedByManagement"] is False
assert checked_pending.json()["clientStatus"] == "pending"
engine = create_engine_from_url(f"sqlite:///{db_path}")
sessions = session_factory(engine)
with sessions() as session, session.begin():
server = session.query(VirtualServerRow).filter_by(slug="gitea-loop-prod").one()
server.gateway_external_id = "fixture-contextforge-server"
with sessions() as session, session.begin():
provision_machine_client(
session,
issuer=ISSUER,
subject="authentik-user-uuid-1234",
oauth_username="svc-mcp-codex-gitea-loop-test1234",
name="resume-me",
project_slug="gitea-loop",
environment_name="prod",
public_host="hub.test",
client_id=UUID(client_id),
)
resumed = viewer.get(f"/api/client-management/{client_id}/onboarding-status")
assert resumed.status_code == 200, resumed.text
resumed_body = resumed.json()
assert resumed_body["clientStatus"] == "enabled"
assert resumed_body["checks"]["externalSubject"]["state"] == "ready"
assert resumed_body["checks"]["hubBinding"]["state"] == "ready"
assert resumed_body["provisioning"]["status"] == "authentik-service-account-provisioned"
assert resumed_body["provisioning"]["subject"] == "authentik-user-uuid-1234"
assert resumed_body["provisioning"]["oauthUsername"] == "svc-mcp-codex-gitea-loop-test1234"
assert resumed_body["provisioning"]["issuer"] == ISSUER
assert resumed_body["provisioning"]["tokenEndpoint"] == f"{ISSUER}/application/o/token/"
assert resumed_body["provisioning"]["oauthClientId"] == "operator-workspaces-hub"
assert len(probe_calls) == pending_verification_probe_count
assert resumed_body["checks"]["externalScope"]["state"] == "unavailable"
assert resumed_body["checks"]["mcpProof"]["state"] == "unavailable"
with sessions() as session, session.begin():
client_row = session.get(ClientRow, UUID(client_id))
assert client_row is not None
client_row.issuer = "https://foreign-identity.example.invalid"
probes_before_foreign_issuer_read = len(probe_calls)
foreign_issuer_status = viewer.get(f"/api/client-management/{client_id}/onboarding-status")
assert foreign_issuer_status.status_code == 200, foreign_issuer_status.text
foreign_issuer_body = foreign_issuer_status.json()
assert foreign_issuer_body["checks"]["externalSubject"]["state"] == "pending"
assert foreign_issuer_body["provisioning"]["subject"] is None
assert foreign_issuer_body["verification"]["verifiedByManagement"] is False
assert len(probe_calls) == probes_before_foreign_issuer_read
foreign_issuer_verification = admin.post(f"/api/client-management/{client_id}/verify")
assert foreign_issuer_verification.status_code == 200, foreign_issuer_verification.text
assert foreign_issuer_verification.json()["checks"]["externalSubject"]["state"] == "pending"
assert foreign_issuer_verification.json()["verification"]["verifiedByManagement"] is False
assert len(probe_calls) > probes_before_foreign_issuer_read
with sessions() as session, session.begin():
client_row = session.get(ClientRow, UUID(client_id))
assert client_row is not None
client_row.issuer = ISSUER
verified = admin.post(f"/api/client-management/{client_id}/verify")
assert verified.status_code == 200, verified.text
assert len(probe_calls) > pending_verification_probe_count
after = verified.json()
assert after["verification"]["checkedByManagement"] is True
assert after["verification"]["verifiedByManagement"] is True
assert after["verification"]["hubChecksPassed"] is True
assert after["checks"]["externalScope"]["state"] == "unavailable"
assert after["checks"]["mcpProof"]["state"] == "unavailable"
verified_probe_count = len(probe_calls)
persisted_status = viewer.get(f"/api/client-management/{client_id}/onboarding-status")
assert persisted_status.status_code == 200, persisted_status.text
assert persisted_status.json()["checks"]["connector"]["state"] == "healthy"
assert persisted_status.json()["verification"]["verifiedByManagement"] is True
assert len(probe_calls) == verified_probe_count
for stale_checked_at in (
datetime.now(UTC) - timedelta(minutes=5),
datetime.now(UTC) + timedelta(minutes=5),
):
stale_timestamp = stale_checked_at.isoformat()
with sessions() as session, session.begin():
client_row = session.get(ClientRow, UUID(client_id))
assert client_row is not None
metadata = dict(client_row.record_metadata or {})
verification = dict(metadata["onboardingVerification"])
verification["checkedAt"] = stale_timestamp
metadata["onboardingVerification"] = verification
client_row.record_metadata = metadata
stale_status = viewer.get(f"/api/client-management/{client_id}/onboarding-status")
assert stale_status.status_code == 200, stale_status.text
stale_body = stale_status.json()
assert stale_body["checks"]["connector"]["state"] == "unknown"
assert stale_body["checks"]["connector"]["observedAt"] is None
assert stale_body["verification"]["checkedAt"] == stale_timestamp
assert stale_body["verification"]["checkedByManagement"] is True
assert stale_body["verification"]["verifiedByManagement"] is False
assert stale_body["verification"]["hubChecksPassed"] is False
assert len(probe_calls) == verified_probe_count
with sessions() as session:
client_row = session.get(ClientRow, UUID(client_id))
workspace = session.query(WorkspaceRow).filter_by(slug="itworx").one()
binding = session.query(ClientBindingRow).filter_by(client_id=UUID(client_id)).one()
audit_event = (
session.query(AuditEventRow)
.filter_by(client_id=UUID(client_id), event_type="client.onboarding.verify")
.order_by(AuditEventRow.occurred_at.desc())
.first()
)
engine.dispose()
assert client_row is not None and client_row.status == "active"
assert binding.workspace_id == workspace.id
assert binding.enabled is True
assert client_row.record_metadata["onboardingVerification"]["verifiedByManagement"] is True
assert "credential" not in str(client_row.record_metadata["onboardingVerification"]).lower()
assert audit_event is not None
assert audit_event.principal_subject == "user-management-1"
assert audit_event.sanitized_arguments["hubChecksPassed"] is True
def test_unauthed_client_is_not_exposed_by_connector_management_route(
role_sessions: dict[ManagementRole, RoleSession],
) -> None:
unauthenticated = TestClient(role_sessions[ManagementRole.VIEWER].app)
unauthenticated.cookies.clear()
response = unauthenticated.get("/api/connectors/knowledge-default")
assert response.status_code == 401
+18 -8
View File
@@ -47,19 +47,29 @@ def invocation(
class WP110GovernanceTests(unittest.TestCase):
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(
+2
View File
@@ -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",
],
+9 -4
View File
@@ -465,10 +465,15 @@ def test_only_reviewed_gitea_r3_lifecycle_set_is_allowed_in_production() -> None
assert loop_trace.matched_rule_ids == ("036-gitea-loop-reviewed-lifecycle-v1",)
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,
)
)
+9 -3
View File
@@ -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:
+6 -10
View File
@@ -237,24 +237,20 @@ def test_dashboard_drift_is_measured_against_managed_connector_gateways(
assert "connector gateway drift" in drift["detail"].lower()
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
Generated
+8 -7
View File
@@ -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]]