mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: add sidebar
This commit is contained in:
parent
7db2703b33
commit
e5d2815b41
41 changed files with 2469 additions and 1391 deletions
40
ui-tui/package-lock.json
generated
40
ui-tui/package-lock.json
generated
|
|
@ -89,6 +89,7 @@
|
|||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
|
|
@ -318,31 +319,6 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
|
|
@ -1509,6 +1485,7 @@
|
|||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
|
|
@ -1519,6 +1496,7 @@
|
|||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
|
|
@ -1529,6 +1507,7 @@
|
|||
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.58.1",
|
||||
|
|
@ -1558,6 +1537,7 @@
|
|||
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.58.1",
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
|
|
@ -1875,6 +1855,7 @@
|
|||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -2210,6 +2191,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
|
|
@ -2895,6 +2877,7 @@
|
|||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
|
|
@ -3790,6 +3773,7 @@
|
|||
"resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz",
|
||||
"integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"type-fest": "^4.18.2"
|
||||
|
|
@ -5146,6 +5130,7 @@
|
|||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -5245,6 +5230,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -6017,6 +6003,7 @@
|
|||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
|
|
@ -6143,6 +6130,7 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -6252,6 +6240,7 @@
|
|||
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
|
|
@ -6660,6 +6649,7 @@
|
|||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
|
|
|||
34
web/package-lock.json
generated
34
web/package-lock.json
generated
|
|
@ -8,7 +8,7 @@
|
|||
"name": "web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "^0.3.0",
|
||||
"@nous-research/ui": "^0.4.0",
|
||||
"@observablehq/plot": "^0.6.17",
|
||||
"@react-three/fiber": "^9.6.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
|
|
@ -70,6 +70,7 @@
|
|||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
|
|
@ -1058,9 +1059,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@nous-research/ui": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.3.0.tgz",
|
||||
"integrity": "sha512-konGgtV9lkzqYkWuoUGnROqavq1svTnGbERLKItvEXmsRz4xRtbAMHI8rK6sjGpHDpwvOUN3olcOhRLTGuVfcA==",
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.4.0.tgz",
|
||||
"integrity": "sha512-wA9YImWLFjx3yWsb3TsquwG9VKZunupdovkOjnRboFjNAb3Jcf57o67xWafEPEm3VX6k6RP/+Y9zHWX0PUtZ4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nanostores/react": "^1.0.0",
|
||||
|
|
@ -1103,6 +1104,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz",
|
||||
"integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"d3": "^7.9.0",
|
||||
"interval-tree-1d": "^1.0.0",
|
||||
|
|
@ -1755,6 +1757,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.0.tgz",
|
||||
"integrity": "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.17.8",
|
||||
"@types/webxr": "*",
|
||||
|
|
@ -2489,6 +2492,7 @@
|
|||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
|
|
@ -2498,6 +2502,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
|
|
@ -2508,6 +2513,7 @@
|
|||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
|
|
@ -2572,6 +2578,7 @@
|
|||
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.57.0",
|
||||
"@typescript-eslint/types": "8.57.0",
|
||||
|
|
@ -2867,6 +2874,7 @@
|
|||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -3019,6 +3027,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
|
|
@ -3526,6 +3535,7 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -3839,6 +3849,7 @@
|
|||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
|
|
@ -4217,7 +4228,8 @@
|
|||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
|
||||
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
|
||||
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
|
||||
"license": "Standard 'no charge' license: https://gsap.com/standard-license.",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
|
|
@ -4532,6 +4544,7 @@
|
|||
"resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz",
|
||||
"integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@radix-ui/react-portal": "^1.1.4",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
|
|
@ -4953,6 +4966,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.0.0 || >=22.0.0"
|
||||
}
|
||||
|
|
@ -5080,6 +5094,7 @@
|
|||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -5151,6 +5166,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -5170,6 +5186,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
|
|
@ -5532,7 +5549,8 @@
|
|||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
|
|
@ -5597,6 +5615,7 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -5682,6 +5701,7 @@
|
|||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
|
|
@ -5697,6 +5717,7 @@
|
|||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
|
@ -5818,6 +5839,7 @@
|
|||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "^0.3.0",
|
||||
"@nous-research/ui": "^0.4.0",
|
||||
"@observablehq/plot": "^0.6.17",
|
||||
"@react-three/fiber": "^9.6.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
|
|
|
|||
691
web/src/App.tsx
691
web/src/App.tsx
|
|
@ -1,31 +1,47 @@
|
|||
import { useMemo } from "react";
|
||||
import { Routes, Route, NavLink, Navigate } from "react-router-dom";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Routes, Route, NavLink, Navigate, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Clock,
|
||||
Code,
|
||||
Database,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Globe,
|
||||
Heart,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Package,
|
||||
Settings,
|
||||
Puzzle,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Globe,
|
||||
Database,
|
||||
RotateCw,
|
||||
Settings,
|
||||
Shield,
|
||||
Wrench,
|
||||
Zap,
|
||||
Heart,
|
||||
Sparkles,
|
||||
Star,
|
||||
Code,
|
||||
Eye,
|
||||
Terminal,
|
||||
Wrench,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Cell, Grid, SelectionSwitcher, Typography } from "@nous-research/ui";
|
||||
import { SelectionSwitcher, Typography } from "@nous-research/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Backdrop } from "@/components/Backdrop";
|
||||
import StatusPage from "@/pages/StatusPage";
|
||||
import { SidebarFooter } from "@/components/SidebarFooter";
|
||||
import { SidebarStatusStrip } from "@/components/SidebarStatusStrip";
|
||||
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
|
||||
import { useSystemActions } from "@/contexts/useSystemActions";
|
||||
import type { SystemAction } from "@/contexts/system-actions-context";
|
||||
import ConfigPage from "@/pages/ConfigPage";
|
||||
import EnvPage from "@/pages/EnvPage";
|
||||
import SessionsPage from "@/pages/SessionsPage";
|
||||
|
|
@ -36,15 +52,17 @@ import SkillsPage from "@/pages/SkillsPage";
|
|||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { PluginSlot, usePlugins } from "@/plugins";
|
||||
import type { RegisteredPlugin } from "@/plugins";
|
||||
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
|
||||
import type { PluginManifest } from "@/plugins";
|
||||
import { useTheme } from "@/themes";
|
||||
|
||||
/** Built-in route → default page component. Used both for standard routing
|
||||
* and for resolving plugin `tab.override` values. Keys must match the
|
||||
* `path` in `BUILTIN_NAV` so `/path` lookups stay consistent. */
|
||||
const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
|
||||
"/": StatusPage,
|
||||
function RootRedirect() {
|
||||
return <Navigate to="/sessions" replace />;
|
||||
}
|
||||
|
||||
/** Built-in route → page component. Used for routing and for plugin `tab.path` / `tab.override` resolution. */
|
||||
const BUILTIN_ROUTES: Record<string, ComponentType> = {
|
||||
"/": RootRedirect,
|
||||
"/sessions": SessionsPage,
|
||||
"/analytics": AnalyticsPage,
|
||||
"/logs": LogsPage,
|
||||
|
|
@ -55,7 +73,6 @@ const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
|
|||
};
|
||||
|
||||
const BUILTIN_NAV: NavItem[] = [
|
||||
{ path: "/", labelKey: "status", label: "Status", icon: Activity },
|
||||
{
|
||||
path: "/sessions",
|
||||
labelKey: "sessions",
|
||||
|
|
@ -75,9 +92,7 @@ const BUILTIN_NAV: NavItem[] = [
|
|||
{ path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound },
|
||||
];
|
||||
|
||||
// Plugins can reference any of these by name in their manifest — keeps bundle
|
||||
// size sane vs. importing the full lucide-react set.
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
const ICON_MAP: Record<string, ComponentType<{ className?: string }>> = {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Clock,
|
||||
|
|
@ -100,24 +115,15 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
|||
Eye,
|
||||
};
|
||||
|
||||
function resolveIcon(
|
||||
name: string,
|
||||
): React.ComponentType<{ className?: string }> {
|
||||
function resolveIcon(name: string): ComponentType<{ className?: string }> {
|
||||
return ICON_MAP[name] ?? Puzzle;
|
||||
}
|
||||
|
||||
function buildNavItems(
|
||||
builtIn: NavItem[],
|
||||
plugins: RegisteredPlugin[],
|
||||
): NavItem[] {
|
||||
function buildNavItems(builtIn: NavItem[], manifests: PluginManifest[]): NavItem[] {
|
||||
const items = [...builtIn];
|
||||
|
||||
for (const { manifest } of plugins) {
|
||||
// Plugins that replace a built-in route don't add a new tab entry —
|
||||
// they reuse the existing tab. The nav just lights up the original
|
||||
// built-in entry when the user visits `/`.
|
||||
for (const manifest of manifests) {
|
||||
if (manifest.tab.override) continue;
|
||||
// Hidden plugins register their component + slots but skip the nav.
|
||||
if (manifest.tab.hidden) continue;
|
||||
|
||||
const pluginItem: NavItem = {
|
||||
|
|
@ -145,54 +151,58 @@ function buildNavItems(
|
|||
return items;
|
||||
}
|
||||
|
||||
/** Build the final route table, letting plugins override built-in pages.
|
||||
*
|
||||
* Returns (path, Component, key) tuples. Plugins with `tab.override`
|
||||
* win over both built-ins and other plugins (last registration wins if
|
||||
* two plugins claim the same override, but we warn in dev). Plugins with
|
||||
* a regular `tab.path` register alongside built-ins as standalone
|
||||
* routes. */
|
||||
function buildRoutes(
|
||||
plugins: RegisteredPlugin[],
|
||||
): Array<{ key: string; path: string; Component: React.ComponentType }> {
|
||||
const overrides = new Map<string, RegisteredPlugin>();
|
||||
const addons: RegisteredPlugin[] = [];
|
||||
function buildRoutes(manifests: PluginManifest[]): Array<{
|
||||
key: string;
|
||||
path: string;
|
||||
element: ReactNode;
|
||||
}> {
|
||||
const byOverride = new Map<string, PluginManifest>();
|
||||
const addons: PluginManifest[] = [];
|
||||
|
||||
for (const p of plugins) {
|
||||
if (p.manifest.tab.override) {
|
||||
overrides.set(p.manifest.tab.override, p);
|
||||
for (const m of manifests) {
|
||||
if (m.tab.override) {
|
||||
byOverride.set(m.tab.override, m);
|
||||
} else {
|
||||
addons.push(p);
|
||||
addons.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
const routes: Array<{
|
||||
key: string;
|
||||
path: string;
|
||||
Component: React.ComponentType;
|
||||
element: ReactNode;
|
||||
}> = [];
|
||||
|
||||
for (const [path, Component] of Object.entries(BUILTIN_ROUTES)) {
|
||||
const override = overrides.get(path);
|
||||
if (override) {
|
||||
const om = byOverride.get(path);
|
||||
if (om) {
|
||||
routes.push({
|
||||
key: `override:${override.manifest.name}`,
|
||||
key: `override:${om.name}`,
|
||||
path,
|
||||
Component: override.component,
|
||||
element: <PluginPage name={om.name} />,
|
||||
});
|
||||
} else {
|
||||
routes.push({ key: `builtin:${path}`, path, Component });
|
||||
routes.push({ key: `builtin:${path}`, path, element: <Component /> });
|
||||
}
|
||||
}
|
||||
|
||||
for (const addon of addons) {
|
||||
// Don't double-register a plugin that shadows a built-in path via
|
||||
// `tab.path` — `override` is the supported mechanism for that.
|
||||
if (BUILTIN_ROUTES[addon.manifest.tab.path]) continue;
|
||||
for (const m of addons) {
|
||||
if (m.tab.hidden) continue;
|
||||
if (BUILTIN_ROUTES[m.tab.path]) continue;
|
||||
routes.push({
|
||||
key: `plugin:${addon.manifest.name}`,
|
||||
path: addon.manifest.tab.path,
|
||||
Component: addon.component,
|
||||
key: `plugin:${m.name}`,
|
||||
path: m.tab.path,
|
||||
element: <PluginPage name={m.name} />,
|
||||
});
|
||||
}
|
||||
|
||||
for (const m of manifests) {
|
||||
if (!m.tab.hidden) continue;
|
||||
if (BUILTIN_ROUTES[m.tab.path] || m.tab.override) continue;
|
||||
routes.push({
|
||||
key: `plugin:hidden:${m.name}`,
|
||||
path: m.tab.path,
|
||||
element: <PluginPage name={m.name} />,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -201,154 +211,125 @@ function buildRoutes(
|
|||
|
||||
export default function App() {
|
||||
const { t } = useI18n();
|
||||
const { plugins } = usePlugins();
|
||||
const { manifests } = usePlugins();
|
||||
const { theme } = useTheme();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
||||
|
||||
const navItems = useMemo(
|
||||
() => buildNavItems(BUILTIN_NAV, plugins),
|
||||
[plugins],
|
||||
() => buildNavItems(BUILTIN_NAV, manifests),
|
||||
[manifests],
|
||||
);
|
||||
const routes = useMemo(() => buildRoutes(manifests), [manifests]);
|
||||
const pluginTabMeta = useMemo(
|
||||
() =>
|
||||
manifests
|
||||
.filter((m) => !m.tab.hidden)
|
||||
.map((m) => ({
|
||||
path: m.tab.override ?? m.tab.path,
|
||||
label: m.label,
|
||||
})),
|
||||
[manifests],
|
||||
);
|
||||
const routes = useMemo(() => buildRoutes(plugins), [plugins]);
|
||||
|
||||
const layoutVariant = theme.layoutVariant ?? "standard";
|
||||
const showSidebar = layoutVariant === "cockpit";
|
||||
// Tiled layout drops the 1600px clamp so pages can use the full viewport;
|
||||
// standard + cockpit keep the centered reading width.
|
||||
const mainMaxWidth = layoutVariant === "tiled" ? "max-w-none" : "max-w-[1600px]";
|
||||
const mainMaxWidth =
|
||||
layoutVariant === "tiled" ? "max-w-none" : "max-w-[1600px]";
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setMobileOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [mobileOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia("(min-width: 1024px)");
|
||||
const onChange = (e: MediaQueryListEvent) => {
|
||||
if (e.matches) setMobileOpen(false);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-layout-variant={layoutVariant}
|
||||
className="text-midground font-mondwest bg-black min-h-screen flex flex-col uppercase antialiased overflow-x-hidden"
|
||||
className="font-mondwest flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black uppercase text-midground antialiased"
|
||||
>
|
||||
<SelectionSwitcher />
|
||||
<Backdrop />
|
||||
{/* Themes can style backdrop chrome via `componentStyles.backdrop.*`
|
||||
CSS vars read by <Backdrop />. Plugins can also inject full
|
||||
components into the backdrop layer via the `backdrop` slot —
|
||||
useful for scanlines, parallax stars, hero artwork, etc. */}
|
||||
<PluginSlot name="backdrop" />
|
||||
|
||||
<header
|
||||
className={cn(
|
||||
"fixed top-0 left-0 right-0 z-40",
|
||||
"lg:hidden fixed top-0 left-0 right-0 z-40 h-12",
|
||||
"flex items-center gap-2 px-3",
|
||||
"border-b border-current/20",
|
||||
"bg-background-base/90 backdrop-blur-sm",
|
||||
)}
|
||||
style={{
|
||||
// Themes can tweak header chrome (background, border-image,
|
||||
// clip-path) via these CSS vars. Unset vars compute to the
|
||||
// property's initial value, so themes opt in per-property.
|
||||
background: "var(--component-header-background)",
|
||||
borderImage: "var(--component-header-border-image)",
|
||||
clipPath: "var(--component-header-clip-path)",
|
||||
}}
|
||||
>
|
||||
<div className={cn("mx-auto flex h-12", mainMaxWidth)}>
|
||||
<PluginSlot name="header-left" />
|
||||
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-none">
|
||||
<Grid
|
||||
className="h-full !border-t-0 !border-b-0"
|
||||
style={{
|
||||
gridTemplateColumns: `auto repeat(${navItems.length}, auto)`,
|
||||
}}
|
||||
>
|
||||
<Cell className="flex items-center !p-0 !px-3 sm:!px-5">
|
||||
<Typography
|
||||
className="font-bold text-[1.0625rem] sm:text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
Hermes
|
||||
<br />
|
||||
Agent
|
||||
</Typography>
|
||||
</Cell>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label={t.app.openNavigation}
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="app-sidebar"
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 items-center justify-center",
|
||||
"text-midground/70 hover:text-midground transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
)}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{navItems.map(({ path, label, labelKey, icon: Icon }) => (
|
||||
<Cell key={path} className="relative !p-0">
|
||||
<NavLink
|
||||
to={path}
|
||||
end={path === "/"}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex h-full w-full items-center gap-1.5",
|
||||
"px-2.5 sm:px-4 py-2",
|
||||
"font-mondwest text-[0.65rem] sm:text-[0.8rem] tracking-[0.12em]",
|
||||
"whitespace-nowrap transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
isActive
|
||||
? "text-midground"
|
||||
: "opacity-60 hover:opacity-100",
|
||||
)
|
||||
}
|
||||
style={{
|
||||
clipPath: "var(--component-tab-clip-path)",
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="hidden sm:inline">
|
||||
{labelKey
|
||||
? ((t.app.nav as Record<string, string>)[
|
||||
labelKey
|
||||
] ?? label)
|
||||
: label}
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-1 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
|
||||
/>
|
||||
|
||||
{isActive && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute bottom-0 left-0 right-0 h-px bg-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</Cell>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
<Grid className="h-full shrink-0 !border-t-0 !border-b-0">
|
||||
<Cell className="flex items-center gap-2 !p-0 !px-2 sm:!px-4">
|
||||
<PluginSlot name="header-right" />
|
||||
<ThemeSwitcher />
|
||||
<LanguageSwitcher />
|
||||
<Typography
|
||||
mondwest
|
||||
className="hidden sm:inline text-[0.7rem] tracking-[0.15em] opacity-50"
|
||||
>
|
||||
{t.app.webUi}
|
||||
</Typography>
|
||||
</Cell>
|
||||
</Grid>
|
||||
</div>
|
||||
<Typography
|
||||
className="font-bold text-[0.95rem] leading-[0.95] tracking-[0.05em] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{t.app.brand}
|
||||
</Typography>
|
||||
</header>
|
||||
|
||||
{/* Full-width banner slot under the nav, outside the main clamp —
|
||||
useful for marquee/alert/status strips themes want to show
|
||||
above page content. */}
|
||||
{mobileOpen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t.app.closeNavigation}
|
||||
onClick={closeMobile}
|
||||
className={cn(
|
||||
"lg:hidden fixed inset-0 z-40",
|
||||
"bg-black/60 backdrop-blur-sm cursor-pointer",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PluginSlot name="header-banner" />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-2 mx-auto w-full flex-1 px-3 sm:px-6 pt-16 sm:pt-20 pb-4 sm:pb-8",
|
||||
mainMaxWidth,
|
||||
showSidebar && "flex gap-4 sm:gap-6",
|
||||
)}
|
||||
>
|
||||
{showSidebar && (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-12 lg:pt-0">
|
||||
<div className="flex min-h-0 min-w-0 flex-1">
|
||||
<aside
|
||||
id="app-sidebar"
|
||||
aria-label={t.app.navigation}
|
||||
className={cn(
|
||||
"w-[260px] shrink-0 border-r border-current/20 pr-3 sm:pr-4",
|
||||
"hidden lg:block",
|
||||
"fixed top-0 left-0 z-50 flex h-dvh max-h-dvh w-64 min-h-0 flex-col",
|
||||
"border-r border-current/20",
|
||||
"bg-background-base/95 backdrop-blur-sm",
|
||||
"transition-transform duration-200 ease-out",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full",
|
||||
"lg:sticky lg:top-0 lg:translate-x-0 lg:shrink-0",
|
||||
)}
|
||||
style={{
|
||||
background: "var(--component-sidebar-background)",
|
||||
|
|
@ -356,75 +337,305 @@ export default function App() {
|
|||
borderImage: "var(--component-sidebar-border-image)",
|
||||
}}
|
||||
>
|
||||
<PluginSlot
|
||||
name="sidebar"
|
||||
fallback={
|
||||
<div className="p-4 text-xs opacity-60 font-mondwest tracking-wide">
|
||||
{/* Cockpit layout with no sidebar plugin — rare but valid;
|
||||
the space still exists so the grid doesn't shift when
|
||||
a plugin loads asynchronously. */}
|
||||
sidebar slot empty
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-14 shrink-0 items-center justify-between gap-2 px-5",
|
||||
"border-b border-current/20",
|
||||
)}
|
||||
>
|
||||
<Typography
|
||||
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
Hermes
|
||||
<br />
|
||||
Agent
|
||||
</Typography>
|
||||
|
||||
<main className="min-w-0 flex-1">
|
||||
<PluginSlot name="pre-main" />
|
||||
<Routes>
|
||||
{routes.map(({ key, path, Component }) => (
|
||||
<Route key={key} path={path} element={<Component />} />
|
||||
))}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<PluginSlot name="post-main" />
|
||||
</main>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeMobile}
|
||||
aria-label={t.app.closeNavigation}
|
||||
className={cn(
|
||||
"lg:hidden inline-flex h-7 w-7 items-center justify-center",
|
||||
"text-midground/70 hover:text-midground transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-b border-current/10">
|
||||
<PluginSlot name="header-left" />
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden py-2"
|
||||
aria-label={t.app.navigation}
|
||||
>
|
||||
<ul className="flex flex-col">
|
||||
{navItems.map(({ path, label, labelKey, icon: Icon }) => (
|
||||
<li key={path}>
|
||||
<NavLink
|
||||
to={path}
|
||||
end={path === "/sessions"}
|
||||
onClick={closeMobile}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex items-center gap-3",
|
||||
"px-5 py-2.5",
|
||||
"font-mondwest text-[0.8rem] tracking-[0.12em]",
|
||||
"whitespace-nowrap transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
isActive
|
||||
? "text-midground"
|
||||
: "opacity-60 hover:opacity-100",
|
||||
)
|
||||
}
|
||||
style={{
|
||||
clipPath: "var(--component-tab-clip-path)",
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{labelKey
|
||||
? ((t.app.nav as Record<string, string>)[
|
||||
labelKey
|
||||
] ?? label)
|
||||
: label}
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
|
||||
/>
|
||||
|
||||
{isActive && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<SidebarSystemActions onNavigate={closeMobile} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-between gap-2",
|
||||
"px-3 py-2",
|
||||
"border-t border-current/20",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<PluginSlot name="header-right" />
|
||||
<ThemeSwitcher dropUp />
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 flex items-center justify-between gap-2",
|
||||
"border-t border-current/20",
|
||||
"px-5 py-3",
|
||||
)}
|
||||
>
|
||||
<PluginSlot
|
||||
name="footer-left"
|
||||
fallback={
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.7rem] tracking-[0.12em] opacity-60"
|
||||
>
|
||||
{t.app.brand}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<PluginSlot
|
||||
name="footer-right"
|
||||
fallback={
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.65rem] tracking-[0.15em] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{t.app.footer.org}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SidebarFooter />
|
||||
</aside>
|
||||
|
||||
<div className="hidden shrink-0 lg:block lg:w-64" aria-hidden />
|
||||
|
||||
<PageHeaderProvider pluginTabs={pluginTabMeta}>
|
||||
<main
|
||||
className={cn(
|
||||
"relative z-2 min-w-0 min-h-0 flex-1",
|
||||
"overflow-y-auto",
|
||||
"px-3 pb-4 sm:px-6 sm:pb-8",
|
||||
"pt-2 sm:pt-4 lg:pt-6",
|
||||
)}
|
||||
>
|
||||
<PluginSlot name="pre-main" />
|
||||
<div className={cn("mx-auto w-full", mainMaxWidth)}>
|
||||
<Routes>
|
||||
{routes.map(({ key, path, element }) => (
|
||||
<Route key={key} path={path} element={element} />
|
||||
))}
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to="/sessions" replace />}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
<PluginSlot name="post-main" />
|
||||
</main>
|
||||
</PageHeaderProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="relative z-2 border-t border-current/20">
|
||||
<Grid className={cn("mx-auto !border-t-0 !border-b-0", mainMaxWidth)}>
|
||||
<Cell className="flex items-center !px-3 sm:!px-6 !py-3">
|
||||
<PluginSlot
|
||||
name="footer-left"
|
||||
fallback={
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] opacity-60"
|
||||
>
|
||||
{t.app.footer.name}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell className="flex items-center justify-end !px-3 sm:!px-6 !py-3">
|
||||
<PluginSlot
|
||||
name="footer-right"
|
||||
fallback={
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{t.app.footer.org}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</Cell>
|
||||
</Grid>
|
||||
</footer>
|
||||
|
||||
{/* Fixed-position overlay plugins (scanlines, vignettes, etc.) render
|
||||
above everything else. Each plugin is responsible for its own
|
||||
pointer-events and z-index. */}
|
||||
<PluginSlot name="overlay" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const { activeAction, isBusy, isRunning, pendingAction, runAction } =
|
||||
useSystemActions();
|
||||
|
||||
const items: SystemActionItem[] = [
|
||||
{
|
||||
action: "restart",
|
||||
icon: RotateCw,
|
||||
label: t.status.restartGateway,
|
||||
runningLabel: t.status.restartingGateway,
|
||||
spin: true,
|
||||
},
|
||||
{
|
||||
action: "update",
|
||||
icon: Download,
|
||||
label: t.status.updateHermes,
|
||||
runningLabel: t.status.updatingHermes,
|
||||
spin: false,
|
||||
},
|
||||
];
|
||||
|
||||
const handleClick = (action: SystemAction) => {
|
||||
if (isBusy) return;
|
||||
void runAction(action);
|
||||
navigate("/sessions");
|
||||
onNavigate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 flex flex-col",
|
||||
"border-t border-current/10",
|
||||
"py-1",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"px-5 pt-0.5 pb-0.5",
|
||||
"font-mondwest text-[0.6rem] tracking-[0.15em] uppercase opacity-30",
|
||||
)}
|
||||
>
|
||||
{t.app.system}
|
||||
</span>
|
||||
|
||||
<SidebarStatusStrip />
|
||||
|
||||
<ul className="flex flex-col">
|
||||
{items.map(({ action, icon: Icon, label, runningLabel, spin }) => {
|
||||
const isPending = pendingAction === action;
|
||||
const isActionRunning =
|
||||
activeAction === action && isRunning && !isPending;
|
||||
const busy = isPending || isActionRunning;
|
||||
const displayLabel = isActionRunning ? runningLabel : label;
|
||||
const disabled = isBusy && !busy;
|
||||
|
||||
return (
|
||||
<li key={action}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleClick(action)}
|
||||
disabled={disabled}
|
||||
aria-busy={busy}
|
||||
className={cn(
|
||||
"group relative flex w-full items-center gap-3",
|
||||
"px-5 py-1.5",
|
||||
"font-mondwest text-[0.75rem] tracking-[0.1em]",
|
||||
"text-left whitespace-nowrap transition-opacity cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
busy
|
||||
? "text-midground opacity-100"
|
||||
: "opacity-60 hover:opacity-100",
|
||||
"disabled:cursor-not-allowed disabled:opacity-30",
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
isActionRunning && spin && "animate-spin",
|
||||
isActionRunning && !spin && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className="truncate">{displayLabel}</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
|
||||
/>
|
||||
|
||||
{busy && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
labelKey?: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface SystemActionItem {
|
||||
action: SystemAction;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
runningLabel: string;
|
||||
spin: boolean;
|
||||
}
|
||||
|
|
|
|||
40
web/src/components/DeleteConfirmDialog.tsx
Normal file
40
web/src/components/DeleteConfirmDialog.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
export function DeleteConfirmDialog({
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
description,
|
||||
loading,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
open,
|
||||
title,
|
||||
}: DeleteConfirmDialogProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
title={title}
|
||||
description={description}
|
||||
loading={loading}
|
||||
destructive
|
||||
confirmLabel={confirmLabel ?? t.common.delete}
|
||||
cancelLabel={cancelLabel ?? t.common.cancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeleteConfirmDialogProps {
|
||||
cancelLabel?: string;
|
||||
confirmLabel?: string;
|
||||
description?: string;
|
||||
loading: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
open: boolean;
|
||||
title: string;
|
||||
}
|
||||
97
web/src/components/PlatformsCard.tsx
Normal file
97
web/src/components/PlatformsCard.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { AlertTriangle, Radio, Wifi, WifiOff } from "lucide-react";
|
||||
import type { PlatformStatus } from "@/lib/api";
|
||||
import { isoTimeAgo } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
export function PlatformsCard({ platforms }: PlatformsCardProps) {
|
||||
const { t } = useI18n();
|
||||
const platformStateBadge: Record<
|
||||
string,
|
||||
{ variant: "success" | "warning" | "destructive"; label: string }
|
||||
> = {
|
||||
connected: { variant: "success", label: t.status.connected },
|
||||
disconnected: { variant: "warning", label: t.status.disconnected },
|
||||
fatal: { variant: "destructive", label: t.status.error },
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">
|
||||
{t.status.connectedPlatforms}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3">
|
||||
{platforms.map(([name, info]) => {
|
||||
const display = platformStateBadge[info.state] ?? {
|
||||
variant: "outline" as const,
|
||||
label: info.state,
|
||||
};
|
||||
const IconComponent =
|
||||
info.state === "connected"
|
||||
? Wifi
|
||||
: info.state === "fatal"
|
||||
? AlertTriangle
|
||||
: WifiOff;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 w-full">
|
||||
<IconComponent
|
||||
className={`h-4 w-4 shrink-0 ${
|
||||
info.state === "connected"
|
||||
? "text-success"
|
||||
: info.state === "fatal"
|
||||
? "text-destructive"
|
||||
: "text-warning"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-sm font-medium capitalize truncate">
|
||||
{name}
|
||||
</span>
|
||||
|
||||
{info.error_message && (
|
||||
<span className="text-xs text-destructive">
|
||||
{info.error_message}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{info.updated_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t.status.lastUpdate}: {isoTimeAgo(info.updated_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
variant={display.variant}
|
||||
className="shrink-0 self-start sm:self-center"
|
||||
>
|
||||
{display.variant === "success" && (
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
{display.label}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlatformsCardProps {
|
||||
platforms: [string, PlatformStatus][];
|
||||
}
|
||||
40
web/src/components/SidebarFooter.tsx
Normal file
40
web/src/components/SidebarFooter.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Typography } from "@nous-research/ui";
|
||||
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
export function SidebarFooter() {
|
||||
const status = useSidebarStatus();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-between gap-2",
|
||||
"px-5 py-2.5",
|
||||
"border-t border-current/10",
|
||||
)}
|
||||
>
|
||||
<Typography
|
||||
mondwest
|
||||
className="font-mono-ui text-[0.7rem] tabular-nums tracking-[0.1em] text-muted-foreground/70"
|
||||
>
|
||||
{status?.version != null ? `v${status.version}` : "—"}
|
||||
</Typography>
|
||||
|
||||
<a
|
||||
href="https://nousresearch.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"font-mondwest text-[0.65rem] tracking-[0.15em] text-midground",
|
||||
"transition-opacity hover:opacity-90",
|
||||
"focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40",
|
||||
)}
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{t.app.footer.org}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
web/src/components/SidebarStatusStrip.tsx
Normal file
70
web/src/components/SidebarStatusStrip.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import type { StatusResponse } from "@/lib/api";
|
||||
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
/** Gateway + session summary for the System sidebar block (no separate strip chrome). */
|
||||
export function SidebarStatusStrip() {
|
||||
const status = useSidebarStatus();
|
||||
const { t } = useI18n();
|
||||
|
||||
if (status === null) {
|
||||
return (
|
||||
<div className="px-5 py-1.5" aria-hidden>
|
||||
<div className="h-2 w-[80%] max-w-full animate-pulse rounded-sm bg-midground/10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gw = gatewayLine(status, t);
|
||||
const { activeSessionsLabel, gatewayStatusLabel } = t.app;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/sessions"
|
||||
title={t.app.statusOverview}
|
||||
className={cn(
|
||||
"block text-left",
|
||||
"px-5 pb-2 pt-0.5",
|
||||
"text-muted-foreground/70",
|
||||
"transition-colors hover:text-muted-foreground/90",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40",
|
||||
"focus-visible:ring-inset",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1 font-mondwest text-[0.55rem] leading-snug tracking-[0.12em]">
|
||||
<p className="break-words">
|
||||
<span className="text-muted-foreground/50">{gatewayStatusLabel}</span>{" "}
|
||||
<span className={cn("font-medium", gw.tone)}>{gw.label}</span>
|
||||
</p>
|
||||
|
||||
<p className="break-words">
|
||||
<span className="text-muted-foreground/50">{activeSessionsLabel}</span>{" "}
|
||||
<span className="tabular-nums text-muted-foreground/70">
|
||||
{status.active_sessions}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function gatewayLine(
|
||||
status: StatusResponse,
|
||||
t: ReturnType<typeof useI18n>["t"],
|
||||
): { label: string; tone: string } {
|
||||
const g = t.app.gatewayStrip;
|
||||
const byState: Record<string, { label: string; tone: string }> = {
|
||||
running: { label: g.running, tone: "text-success" },
|
||||
starting: { label: g.starting, tone: "text-warning" },
|
||||
startup_failed: { label: g.failed, tone: "text-destructive" },
|
||||
stopped: { label: g.stopped, tone: "text-muted-foreground" },
|
||||
};
|
||||
if (status.gateway_state && byState[status.gateway_state]) {
|
||||
return byState[status.gateway_state];
|
||||
}
|
||||
return status.gateway_running
|
||||
? { label: g.running, tone: "text-success" }
|
||||
: { label: g.off, tone: "text-muted-foreground" };
|
||||
}
|
||||
|
|
@ -11,8 +11,12 @@ import { cn } from "@/lib/utils";
|
|||
* glow) so users can preview the palette before committing. User-defined
|
||||
* themes from `~/.hermes/dashboard-themes/*.yaml` that aren't in
|
||||
* `BUILTIN_THEMES` render without swatches and apply the default palette.
|
||||
*
|
||||
* When placed at the bottom of a container (e.g. the sidebar rail), pass
|
||||
* `dropUp` so the menu opens above the trigger instead of clipping below
|
||||
* the viewport.
|
||||
*/
|
||||
export function ThemeSwitcher() {
|
||||
export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
||||
const { themeName, availableThemes, setTheme } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -73,7 +77,8 @@ export function ThemeSwitcher() {
|
|||
role="listbox"
|
||||
aria-label={t.theme?.title ?? "Theme"}
|
||||
className={cn(
|
||||
"absolute right-0 top-full mt-1 z-50 min-w-[240px]",
|
||||
"absolute z-50 min-w-[240px]",
|
||||
dropUp ? "left-0 bottom-full mb-1" : "right-0 top-full mt-1",
|
||||
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
|
||||
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
|
||||
)}
|
||||
|
|
@ -166,3 +171,7 @@ function PlaceholderSwatch() {
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ThemeSwitcherProps {
|
||||
dropUp?: boolean;
|
||||
}
|
||||
|
|
|
|||
138
web/src/components/ui/confirm-dialog.tsx
Normal file
138
web/src/components/ui/confirm-dialog.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function ConfirmDialog({
|
||||
cancelLabel = "Cancel",
|
||||
confirmLabel = "Confirm",
|
||||
description,
|
||||
destructive = false,
|
||||
loading = false,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
open,
|
||||
title,
|
||||
}: ConfirmDialogProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus the confirm button when opened; trap ESC to cancel.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const prevActive = document.activeElement as HTMLElement | null;
|
||||
dialogRef.current
|
||||
?.querySelector<HTMLButtonElement>("[data-confirm]")
|
||||
?.focus();
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
prevActive?.focus?.();
|
||||
};
|
||||
}, [open, onCancel]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby={description ? "confirm-dialog-desc" : undefined}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onCancel();
|
||||
}}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 flex items-center justify-center",
|
||||
"bg-black/60 backdrop-blur-sm",
|
||||
"animate-[fade-in_150ms_ease-out]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={cn(
|
||||
"relative w-full max-w-md mx-4",
|
||||
"border border-border bg-card shadow-lg",
|
||||
"animate-[dialog-in_180ms_ease-out]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4 border-b border-border">
|
||||
{destructive && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="mt-0.5 shrink-0 text-destructive"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<h2
|
||||
id="confirm-dialog-title"
|
||||
className="font-expanded text-sm font-bold tracking-[0.08em] uppercase blend-lighter"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{description && (
|
||||
<p
|
||||
id="confirm-dialog-desc"
|
||||
className="font-mondwest text-xs text-muted-foreground leading-relaxed"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 p-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
data-confirm
|
||||
type="button"
|
||||
variant={destructive ? "destructive" : "default"}
|
||||
size="sm"
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "…" : confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
cancelLabel?: string;
|
||||
confirmLabel?: string;
|
||||
description?: string;
|
||||
destructive?: boolean;
|
||||
loading?: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
open: boolean;
|
||||
title: string;
|
||||
}
|
||||
80
web/src/components/ui/segmented.tsx
Normal file
80
web/src/components/ui/segmented.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Segmented<T extends string>({
|
||||
className,
|
||||
onChange,
|
||||
options,
|
||||
size = "sm",
|
||||
value,
|
||||
}: SegmentedProps<T>) {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
className={cn(
|
||||
"inline-flex border border-border bg-background/30",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const active = opt.value === value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={cn(
|
||||
"font-mondwest tracking-[0.1em] uppercase",
|
||||
"transition-colors cursor-pointer whitespace-nowrap",
|
||||
"border-r border-border last:border-r-0",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30",
|
||||
size === "sm" && "h-7 px-2.5 text-[0.65rem]",
|
||||
size === "md" && "h-8 px-3 text-xs",
|
||||
active
|
||||
? "bg-foreground/90 text-background"
|
||||
: "text-muted-foreground hover:bg-foreground/10 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterGroup({
|
||||
children,
|
||||
className,
|
||||
label,
|
||||
}: FilterGroupProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground/70">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilterGroupProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SegmentedOption<T extends string> {
|
||||
label: string;
|
||||
value: T;
|
||||
}
|
||||
|
||||
interface SegmentedProps<T extends string> {
|
||||
className?: string;
|
||||
onChange: (value: T) => void;
|
||||
options: SegmentedOption<T>[];
|
||||
size?: "sm" | "md";
|
||||
value: T;
|
||||
}
|
||||
|
|
@ -5,15 +5,18 @@ export function Switch({
|
|||
onCheckedChange,
|
||||
className,
|
||||
disabled,
|
||||
id,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onCheckedChange: (v: boolean) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
id={id}
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
|
|
|
|||
89
web/src/contexts/PageHeaderProvider.tsx
Normal file
89
web/src/contexts/PageHeaderProvider.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { useLayoutEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { PageHeaderContext } from "./page-header-context";
|
||||
import { resolvePageTitle } from "@/lib/resolve-page-title";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
export function PageHeaderProvider({
|
||||
children,
|
||||
pluginTabs,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
pluginTabs: { path: string; label: string }[];
|
||||
}) {
|
||||
const { pathname } = useLocation();
|
||||
const { t } = useI18n();
|
||||
const [titleOverride, setTitleOverride] = useState<string | null>(null);
|
||||
const [afterTitle, setAfterTitle] = useState<ReactNode>(null);
|
||||
const [end, setEnd] = useState<ReactNode>(null);
|
||||
|
||||
// Clear any per-page title / toolbar slots when the path changes. Child routes
|
||||
// re-fill these on mount via usePageHeader.
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useLayoutEffect(() => {
|
||||
setTitleOverride(null);
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
}, [pathname]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
const defaultTitle = useMemo(
|
||||
() => resolvePageTitle(pathname, t, pluginTabs),
|
||||
[pathname, t, pluginTabs],
|
||||
);
|
||||
const displayTitle = titleOverride ?? defaultTitle;
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
setAfterTitle,
|
||||
setEnd,
|
||||
setTitle: setTitleOverride,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageHeaderContext.Provider value={value}>
|
||||
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<header
|
||||
className={cn(
|
||||
"z-1 box-border h-14 shrink-0 border-b border-current/20",
|
||||
"bg-background-base/40 backdrop-blur-sm",
|
||||
"overflow-hidden",
|
||||
)}
|
||||
role="banner"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full min-w-0 flex-1 flex-col justify-center gap-2",
|
||||
"px-3 py-2 sm:px-6",
|
||||
"min-h-14 sm:min-h-0 sm:flex-row sm:items-center sm:gap-3 sm:py-0",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
|
||||
<h1
|
||||
className="font-expanded min-w-0 truncate text-sm font-bold tracking-[0.08em] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{displayTitle}
|
||||
</h1>
|
||||
{afterTitle}
|
||||
</div>
|
||||
|
||||
{end ? (
|
||||
<div className="flex w-full min-w-0 justify-end sm:max-w-md sm:flex-1">
|
||||
{end}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
<main
|
||||
className="min-h-0 w-full min-w-0 flex-1 overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</PageHeaderContext.Provider>
|
||||
);
|
||||
}
|
||||
120
web/src/contexts/SystemActions.tsx
Normal file
120
web/src/contexts/SystemActions.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ActionStatusResponse } from "@/lib/api";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { useI18n } from "@/i18n";
|
||||
import {
|
||||
SystemActionsContext,
|
||||
type SystemAction,
|
||||
} from "./system-actions-context";
|
||||
|
||||
const ACTION_NAMES: Record<SystemAction, string> = {
|
||||
restart: "gateway-restart",
|
||||
update: "hermes-update",
|
||||
};
|
||||
|
||||
export function SystemActionsProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [pendingAction, setPendingAction] = useState<SystemAction | null>(null);
|
||||
const [activeAction, setActiveAction] = useState<SystemAction | null>(null);
|
||||
const [actionStatus, setActionStatus] = useState<ActionStatusResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
const timer = setTimeout(() => setToast(null), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeAction) return;
|
||||
const name = ACTION_NAMES[activeAction];
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const resp = await api.getActionStatus(name);
|
||||
if (cancelled) return;
|
||||
setActionStatus(resp);
|
||||
if (!resp.running) {
|
||||
const ok = resp.exit_code === 0;
|
||||
setToast({
|
||||
type: ok ? "success" : "error",
|
||||
message: ok
|
||||
? t.status.actionFinished
|
||||
: `${t.status.actionFailed} (exit ${resp.exit_code ?? "?"})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// transient fetch error; keep polling
|
||||
}
|
||||
if (!cancelled) setTimeout(poll, 1500);
|
||||
};
|
||||
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeAction, t.status.actionFinished, t.status.actionFailed]);
|
||||
|
||||
const runAction = useCallback(
|
||||
async (action: SystemAction) => {
|
||||
setPendingAction(action);
|
||||
setActionStatus(null);
|
||||
try {
|
||||
if (action === "restart") {
|
||||
await api.restartGateway();
|
||||
} else {
|
||||
await api.updateHermes();
|
||||
}
|
||||
setActiveAction(action);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
setToast({
|
||||
type: "error",
|
||||
message: `${t.status.actionFailed}: ${detail}`,
|
||||
});
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
},
|
||||
[t.status.actionFailed],
|
||||
);
|
||||
|
||||
const dismissLog = useCallback(() => {
|
||||
setActiveAction(null);
|
||||
setActionStatus(null);
|
||||
}, []);
|
||||
|
||||
const isRunning = activeAction !== null && actionStatus?.running !== false;
|
||||
const isBusy = pendingAction !== null || isRunning;
|
||||
|
||||
return (
|
||||
<SystemActionsContext.Provider
|
||||
value={{
|
||||
actionStatus,
|
||||
activeAction,
|
||||
dismissLog,
|
||||
isBusy,
|
||||
isRunning,
|
||||
pendingAction,
|
||||
runAction,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<Toast toast={toast} />
|
||||
</SystemActionsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
message: string;
|
||||
type: "success" | "error";
|
||||
}
|
||||
12
web/src/contexts/page-header-context.ts
Normal file
12
web/src/contexts/page-header-context.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { createContext } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface PageHeaderContextValue {
|
||||
setAfterTitle: (node: ReactNode) => void;
|
||||
setEnd: (node: ReactNode) => void;
|
||||
setTitle: (title: string | null) => void;
|
||||
}
|
||||
|
||||
export const PageHeaderContext = createContext<PageHeaderContextValue | null>(
|
||||
null,
|
||||
);
|
||||
18
web/src/contexts/system-actions-context.ts
Normal file
18
web/src/contexts/system-actions-context.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { createContext } from "react";
|
||||
import type { ActionStatusResponse } from "@/lib/api";
|
||||
|
||||
export const SystemActionsContext = createContext<SystemActionsState | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export type SystemAction = "restart" | "update";
|
||||
|
||||
export interface SystemActionsState {
|
||||
actionStatus: ActionStatusResponse | null;
|
||||
activeAction: SystemAction | null;
|
||||
dismissLog: () => void;
|
||||
isBusy: boolean;
|
||||
isRunning: boolean;
|
||||
pendingAction: SystemAction | null;
|
||||
runAction: (action: SystemAction) => Promise<void>;
|
||||
}
|
||||
10
web/src/contexts/usePageHeader.ts
Normal file
10
web/src/contexts/usePageHeader.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { useContext } from "react";
|
||||
import { PageHeaderContext, type PageHeaderContextValue } from "./page-header-context";
|
||||
|
||||
export function usePageHeader(): PageHeaderContextValue {
|
||||
const ctx = useContext(PageHeaderContext);
|
||||
if (!ctx) {
|
||||
throw new Error("usePageHeader must be used within a PageHeaderProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
15
web/src/contexts/useSystemActions.ts
Normal file
15
web/src/contexts/useSystemActions.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { useContext } from "react";
|
||||
import {
|
||||
SystemActionsContext,
|
||||
type SystemActionsState,
|
||||
} from "./system-actions-context";
|
||||
|
||||
export function useSystemActions(): SystemActionsState {
|
||||
const ctx = useContext(SystemActionsContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useSystemActions must be used within a SystemActionsProvider",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
41
web/src/hooks/useConfirmDelete.ts
Normal file
41
web/src/hooks/useConfirmDelete.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { useCallback, useState } from "react";
|
||||
|
||||
export function useConfirmDelete<TId>({
|
||||
onDelete,
|
||||
}: {
|
||||
onDelete: (id: TId) => Promise<void>;
|
||||
}) {
|
||||
const [pendingId, setPendingId] = useState<TId | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const requestDelete = useCallback((id: TId) => {
|
||||
setPendingId(id);
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (!isDeleting) setPendingId(null);
|
||||
}, [isDeleting]);
|
||||
|
||||
const confirm = useCallback(async () => {
|
||||
if (pendingId === null) return;
|
||||
const id = pendingId;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete(id);
|
||||
setPendingId(null);
|
||||
} catch {
|
||||
// Dialog stays open; caller can surface errors in onDelete before rethrowing
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [pendingId, onDelete]);
|
||||
|
||||
return {
|
||||
cancel,
|
||||
confirm,
|
||||
isDeleting,
|
||||
isOpen: pendingId !== null,
|
||||
pendingId,
|
||||
requestDelete,
|
||||
} as const;
|
||||
}
|
||||
27
web/src/hooks/useSidebarStatus.ts
Normal file
27
web/src/hooks/useSidebarStatus.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { StatusResponse } from "@/lib/api";
|
||||
|
||||
const POLL_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Light-weight status poll for the app shell (sidebar). The Status page uses
|
||||
* its own faster interval; we keep this slower to avoid duplicate load.
|
||||
*/
|
||||
export function useSidebarStatus() {
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
api
|
||||
.getStatus()
|
||||
.then(setStatus)
|
||||
.catch(() => {});
|
||||
};
|
||||
load();
|
||||
const id = setInterval(load, POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ export const en: Translations = {
|
|||
saving: "Saving...",
|
||||
cancel: "Cancel",
|
||||
close: "Close",
|
||||
confirm: "Confirm",
|
||||
delete: "Delete",
|
||||
refresh: "Refresh",
|
||||
retry: "Retry",
|
||||
|
|
@ -42,26 +43,43 @@ export const en: Translations = {
|
|||
expand: "Expand",
|
||||
general: "General",
|
||||
messaging: "Messaging",
|
||||
pluginLoadFailed:
|
||||
"Could not load this plugin’s script. Check the Network tab (dashboard-plugins/…) and the server’s plugin path.",
|
||||
pluginNotRegistered:
|
||||
"The plugin’s script did not call register(), or the script errored. Open the browser console for details.",
|
||||
},
|
||||
|
||||
app: {
|
||||
brand: "Hermes Agent",
|
||||
brandShort: "HA",
|
||||
webUi: "Web UI",
|
||||
closeNavigation: "Close navigation",
|
||||
footer: {
|
||||
name: "Hermes Agent",
|
||||
org: "Nous Research",
|
||||
},
|
||||
nav: {
|
||||
status: "Status",
|
||||
sessions: "Sessions",
|
||||
analytics: "Analytics",
|
||||
logs: "Logs",
|
||||
cron: "Cron",
|
||||
skills: "Skills",
|
||||
config: "Config",
|
||||
keys: "Keys",
|
||||
activeSessionsLabel: "Active Sessions:",
|
||||
gatewayStatusLabel: "Gateway Status:",
|
||||
gatewayStrip: {
|
||||
failed: "Start failed",
|
||||
off: "Off",
|
||||
running: "Running",
|
||||
starting: "Starting",
|
||||
stopped: "Stopped",
|
||||
},
|
||||
nav: {
|
||||
analytics: "Analytics",
|
||||
config: "Config",
|
||||
cron: "Cron",
|
||||
keys: "Keys",
|
||||
logs: "Logs",
|
||||
sessions: "Sessions",
|
||||
skills: "Skills",
|
||||
},
|
||||
navigation: "Navigation",
|
||||
openNavigation: "Open navigation",
|
||||
sessionsActiveCount: "{count} active",
|
||||
statusOverview: "Status overview",
|
||||
system: "System",
|
||||
webUi: "Web UI",
|
||||
},
|
||||
|
||||
status: {
|
||||
|
|
@ -106,6 +124,11 @@ export const en: Translations = {
|
|||
noMessages: "No messages",
|
||||
untitledSession: "Untitled session",
|
||||
deleteSession: "Delete session",
|
||||
confirmDeleteTitle: "Delete session?",
|
||||
confirmDeleteMessage:
|
||||
"This permanently removes the conversation and all of its messages. This cannot be undone.",
|
||||
sessionDeleted: "Session deleted",
|
||||
failedToDelete: "Failed to delete session",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
roles: {
|
||||
|
|
@ -153,6 +176,9 @@ export const en: Translations = {
|
|||
},
|
||||
|
||||
cron: {
|
||||
confirmDeleteMessage:
|
||||
"This removes the job from the schedule. This cannot be undone.",
|
||||
confirmDeleteTitle: "Delete scheduled job?",
|
||||
newJob: "New Cron Job",
|
||||
nameOptional: "Name (optional)",
|
||||
namePlaceholder: "e.g. Daily summary",
|
||||
|
|
@ -182,6 +208,8 @@ export const en: Translations = {
|
|||
searchPlaceholder: "Search skills and toolsets...",
|
||||
enabledOf: "{enabled}/{total} enabled",
|
||||
all: "All",
|
||||
categories: "Categories",
|
||||
filters: "Filters",
|
||||
noSkills: "No skills found. Skills are loaded from ~/.hermes/skills/",
|
||||
noSkillsMatch: "No skills match your search or filter.",
|
||||
skillCount: "{count} skill{s}",
|
||||
|
|
@ -197,6 +225,8 @@ export const en: Translations = {
|
|||
|
||||
config: {
|
||||
configPath: "~/.hermes/config.yaml",
|
||||
filters: "Filters",
|
||||
sections: "Sections",
|
||||
exportConfig: "Export config as JSON",
|
||||
importConfig: "Import config from JSON",
|
||||
resetDefaults: "Reset to defaults",
|
||||
|
|
@ -231,8 +261,11 @@ export const en: Translations = {
|
|||
},
|
||||
|
||||
env: {
|
||||
description: "Manage API keys and secrets stored in",
|
||||
changesNote: "Changes are saved to disk immediately. Active sessions pick up new keys automatically.",
|
||||
confirmClearMessage:
|
||||
"The stored value for this variable will be removed from your .env file. This cannot be undone from the UI.",
|
||||
confirmClearTitle: "Clear this key?",
|
||||
description: "Manage API keys and secrets stored in",
|
||||
hideAdvanced: "Hide Advanced",
|
||||
showAdvanced: "Show Advanced",
|
||||
llmProviders: "LLM Providers",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface Translations {
|
|||
saving: string;
|
||||
cancel: string;
|
||||
close: string;
|
||||
confirm: string;
|
||||
delete: string;
|
||||
refresh: string;
|
||||
retry: string;
|
||||
|
|
@ -43,27 +44,42 @@ export interface Translations {
|
|||
expand: string;
|
||||
general: string;
|
||||
messaging: string;
|
||||
pluginLoadFailed: string;
|
||||
pluginNotRegistered: string;
|
||||
};
|
||||
|
||||
// ── App shell ──
|
||||
app: {
|
||||
brand: string;
|
||||
brandShort: string;
|
||||
webUi: string;
|
||||
closeNavigation: string;
|
||||
footer: {
|
||||
name: string;
|
||||
org: string;
|
||||
};
|
||||
nav: {
|
||||
status: string;
|
||||
sessions: string;
|
||||
analytics: string;
|
||||
logs: string;
|
||||
cron: string;
|
||||
skills: string;
|
||||
config: string;
|
||||
keys: string;
|
||||
activeSessionsLabel: string;
|
||||
gatewayStatusLabel: string;
|
||||
gatewayStrip: {
|
||||
failed: string;
|
||||
off: string;
|
||||
running: string;
|
||||
starting: string;
|
||||
stopped: string;
|
||||
};
|
||||
nav: {
|
||||
analytics: string;
|
||||
config: string;
|
||||
cron: string;
|
||||
keys: string;
|
||||
logs: string;
|
||||
sessions: string;
|
||||
skills: string;
|
||||
};
|
||||
navigation: string;
|
||||
openNavigation: string;
|
||||
sessionsActiveCount: string;
|
||||
statusOverview: string;
|
||||
system: string;
|
||||
webUi: string;
|
||||
};
|
||||
|
||||
// ── Status page ──
|
||||
|
|
@ -110,6 +126,10 @@ export interface Translations {
|
|||
noMessages: string;
|
||||
untitledSession: string;
|
||||
deleteSession: string;
|
||||
confirmDeleteTitle: string;
|
||||
confirmDeleteMessage: string;
|
||||
sessionDeleted: string;
|
||||
failedToDelete: string;
|
||||
previousPage: string;
|
||||
nextPage: string;
|
||||
roles: {
|
||||
|
|
@ -160,6 +180,8 @@ export interface Translations {
|
|||
|
||||
// ── Cron page ──
|
||||
cron: {
|
||||
confirmDeleteMessage: string;
|
||||
confirmDeleteTitle: string;
|
||||
newJob: string;
|
||||
nameOptional: string;
|
||||
namePlaceholder: string;
|
||||
|
|
@ -190,6 +212,8 @@ export interface Translations {
|
|||
searchPlaceholder: string;
|
||||
enabledOf: string;
|
||||
all: string;
|
||||
categories: string;
|
||||
filters: string;
|
||||
noSkills: string;
|
||||
noSkillsMatch: string;
|
||||
skillCount: string;
|
||||
|
|
@ -206,6 +230,8 @@ export interface Translations {
|
|||
// ── Config page ──
|
||||
config: {
|
||||
configPath: string;
|
||||
filters: string;
|
||||
sections: string;
|
||||
exportConfig: string;
|
||||
importConfig: string;
|
||||
resetDefaults: string;
|
||||
|
|
@ -241,20 +267,22 @@ export interface Translations {
|
|||
|
||||
// ── Env / Keys page ──
|
||||
env: {
|
||||
description: string;
|
||||
changesNote: string;
|
||||
hideAdvanced: string;
|
||||
showAdvanced: string;
|
||||
llmProviders: string;
|
||||
providersConfigured: string;
|
||||
confirmClearMessage: string;
|
||||
confirmClearTitle: string;
|
||||
description: string;
|
||||
enterValue: string;
|
||||
getKey: string;
|
||||
hideAdvanced: string;
|
||||
hideValue: string;
|
||||
keysCount: string;
|
||||
llmProviders: string;
|
||||
notConfigured: string;
|
||||
notSet: string;
|
||||
keysCount: string;
|
||||
enterValue: string;
|
||||
providersConfigured: string;
|
||||
replaceCurrentValue: string;
|
||||
showAdvanced: string;
|
||||
showValue: string;
|
||||
hideValue: string;
|
||||
};
|
||||
|
||||
// ── OAuth ──
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export const zh: Translations = {
|
|||
saving: "保存中...",
|
||||
cancel: "取消",
|
||||
close: "关闭",
|
||||
confirm: "确认",
|
||||
delete: "删除",
|
||||
refresh: "刷新",
|
||||
retry: "重试",
|
||||
|
|
@ -42,26 +43,42 @@ export const zh: Translations = {
|
|||
expand: "展开",
|
||||
general: "通用",
|
||||
messaging: "消息平台",
|
||||
pluginLoadFailed:
|
||||
"无法加载此插件的脚本。请检查网络请求(dashboard-plugins/…)以及服务器上的插件路径。",
|
||||
pluginNotRegistered: "插件脚本未调用 register(),或执行出错。请打开浏览器控制台查看详情。",
|
||||
},
|
||||
|
||||
app: {
|
||||
brand: "Hermes Agent",
|
||||
brandShort: "HA",
|
||||
webUi: "管理面板",
|
||||
closeNavigation: "关闭导航",
|
||||
footer: {
|
||||
name: "Hermes Agent",
|
||||
org: "Nous Research",
|
||||
},
|
||||
nav: {
|
||||
status: "状态",
|
||||
sessions: "会话",
|
||||
analytics: "分析",
|
||||
logs: "日志",
|
||||
cron: "定时任务",
|
||||
skills: "技能",
|
||||
config: "配置",
|
||||
keys: "密钥",
|
||||
activeSessionsLabel: "活跃会话:",
|
||||
gatewayStatusLabel: "网关状态:",
|
||||
gatewayStrip: {
|
||||
failed: "启动失败",
|
||||
off: "关闭",
|
||||
running: "运行中",
|
||||
starting: "启动中",
|
||||
stopped: "已停止",
|
||||
},
|
||||
nav: {
|
||||
analytics: "分析",
|
||||
config: "配置",
|
||||
cron: "定时任务",
|
||||
keys: "密钥",
|
||||
logs: "日志",
|
||||
sessions: "会话",
|
||||
skills: "技能",
|
||||
},
|
||||
navigation: "导航",
|
||||
openNavigation: "打开导航",
|
||||
sessionsActiveCount: "{count} 个活跃",
|
||||
statusOverview: "状态概览",
|
||||
system: "系统",
|
||||
webUi: "管理面板",
|
||||
},
|
||||
|
||||
status: {
|
||||
|
|
@ -106,6 +123,10 @@ export const zh: Translations = {
|
|||
noMessages: "暂无消息",
|
||||
untitledSession: "无标题会话",
|
||||
deleteSession: "删除会话",
|
||||
confirmDeleteTitle: "删除会话?",
|
||||
confirmDeleteMessage: "此操作将永久删除对话及其所有消息,无法恢复。",
|
||||
sessionDeleted: "会话已删除",
|
||||
failedToDelete: "删除会话失败",
|
||||
previousPage: "上一页",
|
||||
nextPage: "下一页",
|
||||
roles: {
|
||||
|
|
@ -153,6 +174,8 @@ export const zh: Translations = {
|
|||
},
|
||||
|
||||
cron: {
|
||||
confirmDeleteMessage: "将从此计划移除该任务,此操作无法撤销。",
|
||||
confirmDeleteTitle: "删除定时任务?",
|
||||
newJob: "新建定时任务",
|
||||
nameOptional: "名称(可选)",
|
||||
namePlaceholder: "例如:每日总结",
|
||||
|
|
@ -182,6 +205,8 @@ export const zh: Translations = {
|
|||
searchPlaceholder: "搜索技能和工具集...",
|
||||
enabledOf: "已启用 {enabled}/{total}",
|
||||
all: "全部",
|
||||
categories: "分类",
|
||||
filters: "筛选",
|
||||
noSkills: "未找到技能。技能从 ~/.hermes/skills/ 加载",
|
||||
noSkillsMatch: "没有匹配的技能。",
|
||||
skillCount: "{count} 个技能",
|
||||
|
|
@ -197,6 +222,8 @@ export const zh: Translations = {
|
|||
|
||||
config: {
|
||||
configPath: "~/.hermes/config.yaml",
|
||||
filters: "筛选",
|
||||
sections: "分类",
|
||||
exportConfig: "导出配置为 JSON",
|
||||
importConfig: "从 JSON 导入配置",
|
||||
resetDefaults: "恢复默认值",
|
||||
|
|
@ -231,8 +258,10 @@ export const zh: Translations = {
|
|||
},
|
||||
|
||||
env: {
|
||||
description: "管理存储在以下位置的 API 密钥和凭据",
|
||||
changesNote: "更改会立即保存到磁盘。活跃会话将自动获取新密钥。",
|
||||
confirmClearMessage: "该变量的已存值将从 .env 文件中删除。无法在此界面撤销。",
|
||||
confirmClearTitle: "清除此密钥?",
|
||||
description: "管理存储在以下位置的 API 密钥和凭据",
|
||||
hideAdvanced: "隐藏高级选项",
|
||||
showAdvanced: "显示高级选项",
|
||||
llmProviders: "LLM 提供商",
|
||||
|
|
|
|||
|
|
@ -73,6 +73,10 @@ code, kbd, pre, samp, .font-mono, .font-mono-ui {
|
|||
--spacing: calc(0.25rem * var(--theme-spacing-mul, 1));
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* Nousnet's hermes-agent layout bumps `small` and `code` to readable
|
||||
dashboard sizes. Keep in sync. */
|
||||
small { font-size: 1.0625rem; }
|
||||
|
|
@ -125,6 +129,16 @@ code { font-size: 0.875rem; }
|
|||
to { opacity: 0; transform: translateX(16px); }
|
||||
}
|
||||
|
||||
/* Generic fade + dialog entrance used by popovers and confirm dialogs. */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes dialog-in {
|
||||
from { opacity: 0; transform: translateY(4px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* Hide scrollbar utility — used by the header's overflow-x nav row. */
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
|
|
|
|||
|
|
@ -513,7 +513,12 @@ export interface PluginManifestResponse {
|
|||
description: string;
|
||||
icon: string;
|
||||
version: string;
|
||||
tab: { path: string; position: string };
|
||||
tab: {
|
||||
path: string;
|
||||
position?: string;
|
||||
override?: string;
|
||||
hidden?: boolean;
|
||||
};
|
||||
entry: string;
|
||||
css?: string | null;
|
||||
has_api: boolean;
|
||||
|
|
|
|||
31
web/src/lib/resolve-page-title.ts
Normal file
31
web/src/lib/resolve-page-title.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { Translations } from "@/i18n/types";
|
||||
|
||||
const BUILTIN: Record<string, keyof Translations["app"]["nav"]> = {
|
||||
"/sessions": "sessions",
|
||||
"/analytics": "analytics",
|
||||
"/logs": "logs",
|
||||
"/cron": "cron",
|
||||
"/skills": "skills",
|
||||
"/config": "config",
|
||||
"/env": "keys",
|
||||
};
|
||||
|
||||
export function resolvePageTitle(
|
||||
pathname: string,
|
||||
t: Translations,
|
||||
pluginTabs: { path: string; label: string }[],
|
||||
): string {
|
||||
const normalized = pathname.replace(/\/$/, "") || "/";
|
||||
if (normalized === "/") {
|
||||
return t.app.nav.sessions;
|
||||
}
|
||||
const plugin = pluginTabs.find((p) => p.path === normalized);
|
||||
if (plugin) {
|
||||
return plugin.label;
|
||||
}
|
||||
const key = BUILTIN[normalized];
|
||||
if (key) {
|
||||
return t.app.nav[key];
|
||||
}
|
||||
return t.app.webUi;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { createRoot } from "react-dom/client";
|
|||
import { BrowserRouter } from "react-router-dom";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import { SystemActionsProvider } from "./contexts/SystemActions";
|
||||
import { I18nProvider } from "./i18n";
|
||||
import { exposePluginSDK } from "./plugins";
|
||||
import { ThemeProvider } from "./themes";
|
||||
|
|
@ -14,7 +15,9 @@ createRoot(document.getElementById("root")!).render(
|
|||
<BrowserRouter>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
<SystemActionsProvider>
|
||||
<App />
|
||||
</SystemActionsProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</BrowserRouter>,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Brain,
|
||||
Cpu,
|
||||
Hash,
|
||||
RefreshCw,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry, AnalyticsSkillEntry } from "@/lib/api";
|
||||
import { timeAgo } from "@/lib/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
const PERIODS = [
|
||||
|
|
@ -281,6 +284,7 @@ export default function AnalyticsPage() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { t } = useI18n();
|
||||
const { setAfterTitle, setEnd } = usePageHeader();
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
|
|
@ -292,28 +296,60 @@ export default function AnalyticsPage() {
|
|||
.finally(() => setLoading(false));
|
||||
}, [days]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const periodLabel =
|
||||
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
|
||||
setAfterTitle(
|
||||
<span className="flex items-center gap-2">
|
||||
{loading && (
|
||||
<div className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
)}
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{periodLabel}
|
||||
</Badge>
|
||||
</span>,
|
||||
);
|
||||
setEnd(
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{PERIODS.map((p) => (
|
||||
<Button
|
||||
key={p.label}
|
||||
type="button"
|
||||
variant={days === p.days ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 min-w-0 text-xs"
|
||||
onClick={() => setDays(p.days)}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
{t.common.refresh}
|
||||
</Button>
|
||||
</div>,
|
||||
);
|
||||
return () => {
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
};
|
||||
}, [days, loading, load, setAfterTitle, setEnd, t.common.refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Period selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground font-medium">{t.analytics.period}</span>
|
||||
{PERIODS.map((p) => (
|
||||
<Button
|
||||
key={p.label}
|
||||
variant={days === p.days ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="text-xs h-7"
|
||||
onClick={() => setDays(p.days)}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && !data && (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useMemo } from "react";
|
||||
import {
|
||||
Code,
|
||||
Download,
|
||||
|
|
@ -8,7 +8,6 @@ import {
|
|||
Search,
|
||||
Upload,
|
||||
X,
|
||||
ChevronRight,
|
||||
Settings2,
|
||||
FileText,
|
||||
Settings,
|
||||
|
|
@ -27,6 +26,7 @@ import {
|
|||
MessageCircle,
|
||||
Wrench,
|
||||
FileQuestion,
|
||||
Filter,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { getNestedValue, setNestedValue } from "@/lib/nested";
|
||||
|
|
@ -38,6 +38,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
|
|
@ -85,6 +86,35 @@ export default function ConfigPage() {
|
|||
const { toast, showToast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useI18n();
|
||||
const { setEnd } = usePageHeader();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!config || !schema) {
|
||||
setEnd(null);
|
||||
return;
|
||||
}
|
||||
setEnd(
|
||||
<div className="relative w-full min-w-0 sm:max-w-xs">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 pl-8 pr-7 text-xs"
|
||||
placeholder={t.common.search}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>,
|
||||
);
|
||||
return () => setEnd(null);
|
||||
}, [config, schema, searchQuery, setEnd, t.common.search]);
|
||||
|
||||
function prettyCategoryName(cat: string): string {
|
||||
const key = cat as keyof typeof t.config.categories;
|
||||
|
|
@ -366,62 +396,66 @@ export default function ConfigPage() {
|
|||
</Card>
|
||||
) : (
|
||||
/* ═══════════════ Form Mode ═══════════════ */
|
||||
<div className="flex flex-col sm:flex-row gap-4" style={{ minHeight: "calc(100vh - 180px)" }}>
|
||||
{/* ---- Sidebar — horizontal scroll on mobile, fixed column on sm+ ---- */}
|
||||
<div className="sm:w-52 sm:shrink-0">
|
||||
<div className="sm:sticky sm:top-[72px] flex flex-col gap-1">
|
||||
{/* Search */}
|
||||
<div className="relative mb-2 hidden sm:block">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8 h-8 text-xs"
|
||||
placeholder={t.common.search}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* ---- Filter panel ---- */}
|
||||
<aside aria-label={t.config.filters} className="sm:w-56 sm:shrink-0">
|
||||
<div className="sm:sticky sm:top-4">
|
||||
<div className="flex flex-col border border-border bg-muted/20">
|
||||
{/* Panel heading */}
|
||||
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<Filter className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
|
||||
{t.config.filters}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Category nav — horizontal scroll on mobile */}
|
||||
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0">
|
||||
{categories.map((cat) => {
|
||||
const isActive = !isSearching && activeCategory === cat;
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setActiveCategory(cat);
|
||||
}}
|
||||
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<CategoryIcon category={cat} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">{prettyCategoryName(cat)}</span>
|
||||
<span className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}>
|
||||
{categoryCounts[cat] || 0}
|
||||
</span>
|
||||
{isActive && (
|
||||
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* Sections heading (hidden on mobile since it becomes a horizontal scroll) */}
|
||||
<div className="hidden sm:block px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
|
||||
{t.config.sections}
|
||||
</div>
|
||||
|
||||
{/* Category nav — horizontal scroll on mobile, pill list on sm+ */}
|
||||
<div className="flex sm:flex-col gap-1 sm:gap-px p-2 sm:pt-1 overflow-x-auto sm:overflow-x-visible scrollbar-none sm:max-h-[calc(100vh-260px)] sm:overflow-y-auto">
|
||||
{categories.map((cat) => {
|
||||
const isActive = !isSearching && activeCategory === cat;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setActiveCategory(cat);
|
||||
}}
|
||||
className={`
|
||||
group flex items-center gap-2 px-2 py-1
|
||||
rounded-sm text-left text-[11px] cursor-pointer whitespace-nowrap
|
||||
transition-colors
|
||||
${
|
||||
isActive
|
||||
? "bg-foreground/10 text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-foreground/5"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<CategoryIcon category={cat} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">{prettyCategoryName(cat)}</span>
|
||||
<span
|
||||
className={`text-[10px] tabular-nums ${
|
||||
isActive
|
||||
? "text-foreground/60"
|
||||
: "text-muted-foreground/50"
|
||||
}`}
|
||||
>
|
||||
{categoryCounts[cat] || 0}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ---- Content ---- */}
|
||||
<div className="flex-1 min-w-0">
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
|
||||
import { H2 } from "@nous-research/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import type { CronJob } from "@/lib/api";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -40,17 +42,17 @@ export default function CronPage() {
|
|||
const [deliver, setDeliver] = useState("local");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const loadJobs = () => {
|
||||
const loadJobs = useCallback(() => {
|
||||
api
|
||||
.getCronJobs()
|
||||
.then(setJobs)
|
||||
.catch(() => showToast(t.common.loading, "error"))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
}, [showToast, t.common.loading]);
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
}, []);
|
||||
}, [loadJobs]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!prompt.trim() || !schedule.trim()) {
|
||||
|
|
@ -113,18 +115,25 @@ export default function CronPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (job: CronJob) => {
|
||||
try {
|
||||
await api.deleteCronJob(job.id);
|
||||
showToast(
|
||||
`${t.common.delete}: "${job.name || job.prompt.slice(0, 30)}"`,
|
||||
"success",
|
||||
);
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
showToast(`${t.status.error}: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
const jobDelete = useConfirmDelete({
|
||||
onDelete: useCallback(
|
||||
async (id: string) => {
|
||||
const job = jobs.find((j) => j.id === id);
|
||||
try {
|
||||
await api.deleteCronJob(id);
|
||||
showToast(
|
||||
`${t.common.delete}: "${job?.name || (job?.prompt ?? "").slice(0, 30) || id}"`,
|
||||
"success",
|
||||
);
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
showToast(`${t.status.error}: ${e}`, "error");
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[jobs, loadJobs, showToast, t.common.delete, t.status.error],
|
||||
),
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -134,10 +143,27 @@ export default function CronPage() {
|
|||
);
|
||||
}
|
||||
|
||||
const pendingJob = jobDelete.pendingId
|
||||
? jobs.find((j) => j.id === jobDelete.pendingId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Toast toast={toast} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={jobDelete.isOpen}
|
||||
onCancel={jobDelete.cancel}
|
||||
onConfirm={jobDelete.confirm}
|
||||
title={t.cron.confirmDeleteTitle}
|
||||
description={
|
||||
pendingJob
|
||||
? `"${pendingJob.name || pendingJob.prompt.slice(0, 40)}" — ${t.cron.confirmDeleteMessage}`
|
||||
: t.cron.confirmDeleteMessage
|
||||
}
|
||||
loading={jobDelete.isDeleting}
|
||||
/>
|
||||
|
||||
{/* Create new job form */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -311,7 +337,7 @@ export default function CronPage() {
|
|||
size="icon"
|
||||
title={t.common.delete}
|
||||
aria-label={t.common.delete}
|
||||
onClick={() => handleDelete(job)}
|
||||
onClick={() => jobDelete.requestDelete(job.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
|
|
@ -16,8 +16,10 @@ import {
|
|||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { EnvVarInfo } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { OAuthProvidersCard } from "@/components/OAuthProvidersCard";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -95,6 +97,7 @@ function EnvVarRow({
|
|||
onClear,
|
||||
onReveal,
|
||||
onCancelEdit,
|
||||
clearDialogOpen = false,
|
||||
compact = false,
|
||||
}: {
|
||||
varKey: string;
|
||||
|
|
@ -107,6 +110,7 @@ function EnvVarRow({
|
|||
onClear: (key: string) => void;
|
||||
onReveal: (key: string) => void;
|
||||
onCancelEdit: (key: string) => void;
|
||||
clearDialogOpen?: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
|
@ -219,7 +223,7 @@ function EnvVarRow({
|
|||
{info.is_set && (
|
||||
<Button size="sm" variant="ghost"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => onClear(varKey)} disabled={saving === varKey}>
|
||||
onClick={() => onClear(varKey)} disabled={saving === varKey || clearDialogOpen}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{saving === varKey ? "..." : t.common.clear}
|
||||
</Button>
|
||||
|
|
@ -261,6 +265,7 @@ function ProviderGroupCard({
|
|||
onClear,
|
||||
onReveal,
|
||||
onCancelEdit,
|
||||
clearDialogOpen = false,
|
||||
}: {
|
||||
group: ProviderGroup;
|
||||
edits: Record<string, string>;
|
||||
|
|
@ -271,6 +276,7 @@ function ProviderGroupCard({
|
|||
onClear: (key: string) => void;
|
||||
onReveal: (key: string) => void;
|
||||
onCancelEdit: (key: string) => void;
|
||||
clearDialogOpen?: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
|
@ -325,6 +331,7 @@ function ProviderGroupCard({
|
|||
key={key} varKey={key} info={info} compact
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||
clearDialogOpen={clearDialogOpen}
|
||||
/>
|
||||
))}
|
||||
{/* Base URLs (secondary) */}
|
||||
|
|
@ -333,6 +340,7 @@ function ProviderGroupCard({
|
|||
key={key} varKey={key} info={info} compact
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||
clearDialogOpen={clearDialogOpen}
|
||||
/>
|
||||
))}
|
||||
{/* Anything else */}
|
||||
|
|
@ -341,6 +349,7 @@ function ProviderGroupCard({
|
|||
key={key} varKey={key} info={info} compact
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||
clearDialogOpen={clearDialogOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -390,24 +399,30 @@ export default function EnvPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleClear = async (key: string) => {
|
||||
setSaving(key);
|
||||
try {
|
||||
await api.deleteEnvVar(key);
|
||||
setVars((prev) =>
|
||||
prev
|
||||
? { ...prev, [key]: { ...prev[key], is_set: false, redacted_value: null } }
|
||||
: prev,
|
||||
);
|
||||
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
showToast(`${key} ${t.common.removed}`, "success");
|
||||
} catch (e) {
|
||||
showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error");
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
const keyClear = useConfirmDelete({
|
||||
onDelete: useCallback(
|
||||
async (key: string) => {
|
||||
setSaving(key);
|
||||
try {
|
||||
await api.deleteEnvVar(key);
|
||||
setVars((prev) =>
|
||||
prev
|
||||
? { ...prev, [key]: { ...prev[key], is_set: false, redacted_value: null } }
|
||||
: prev,
|
||||
);
|
||||
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
showToast(`${key} ${t.common.removed}`, "success");
|
||||
} catch (e) {
|
||||
showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error");
|
||||
throw e;
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
},
|
||||
[showToast, t.common.removed, t.common.failedToRemove],
|
||||
),
|
||||
});
|
||||
|
||||
const handleReveal = async (key: string) => {
|
||||
if (revealed[key]) {
|
||||
|
|
@ -488,10 +503,29 @@ export default function EnvPage() {
|
|||
const totalProviders = providerGroups.length;
|
||||
const configuredProviders = providerGroups.filter((g) => g.hasAnySet).length;
|
||||
|
||||
const pendingClearKey = keyClear.pendingId;
|
||||
const pendingKeyDescription =
|
||||
pendingClearKey && vars
|
||||
? vars[pendingClearKey]?.description
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Toast toast={toast} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={keyClear.isOpen}
|
||||
onCancel={keyClear.cancel}
|
||||
onConfirm={keyClear.confirm}
|
||||
title={t.env.confirmClearTitle}
|
||||
description={
|
||||
pendingClearKey
|
||||
? `${pendingClearKey}${pendingKeyDescription ? ` — ${pendingKeyDescription}` : ""}. ${t.env.confirmClearMessage}`
|
||||
: t.env.confirmClearMessage
|
||||
}
|
||||
loading={keyClear.isDeleting}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
|
@ -530,7 +564,8 @@ export default function EnvPage() {
|
|||
key={group.name}
|
||||
group={group}
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit}
|
||||
onSave={handleSave} onClear={keyClear.requestDelete} onReveal={handleReveal} onCancelEdit={cancelEdit}
|
||||
clearDialogOpen={keyClear.isOpen}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
|
|
@ -557,7 +592,8 @@ export default function EnvPage() {
|
|||
<EnvVarRow
|
||||
key={key} varKey={key} info={info}
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit}
|
||||
onSave={handleSave} onClear={keyClear.requestDelete} onReveal={handleReveal} onCancelEdit={cancelEdit}
|
||||
clearDialogOpen={keyClear.isOpen}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
@ -566,7 +602,8 @@ export default function EnvPage() {
|
|||
category={category}
|
||||
unsetEntries={unsetEntries}
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit}
|
||||
onSave={handleSave} onClear={keyClear.requestDelete} onReveal={handleReveal} onCancelEdit={cancelEdit}
|
||||
clearDialogOpen={keyClear.isOpen}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
@ -592,6 +629,7 @@ function CollapsibleUnset({
|
|||
onClear,
|
||||
onReveal,
|
||||
onCancelEdit,
|
||||
clearDialogOpen = false,
|
||||
}: {
|
||||
category: string;
|
||||
unsetEntries: [string, EnvVarInfo][];
|
||||
|
|
@ -603,6 +641,7 @@ function CollapsibleUnset({
|
|||
onClear: (key: string) => void;
|
||||
onReveal: (key: string) => void;
|
||||
onCancelEdit: (key: string) => void;
|
||||
clearDialogOpen?: boolean;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const { t } = useI18n();
|
||||
|
|
@ -625,6 +664,7 @@ function CollapsibleUnset({
|
|||
key={key} varKey={key} info={info}
|
||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||
clearDialogOpen={clearDialogOpen}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { FileText, RefreshCw, ChevronRight } from "lucide-react";
|
||||
import { H2 } from "@nous-research/ui";
|
||||
import { useEffect, useLayoutEffect, useState, useCallback, useRef } from "react";
|
||||
import { FileText, RefreshCw } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { FilterGroup, Segmented } from "@/components/ui/segmented";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
|
||||
const FILES = ["agent", "errors", "gateway"] as const;
|
||||
const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const;
|
||||
|
|
@ -34,38 +35,8 @@ const LINE_COLORS: Record<string, string> = {
|
|||
debug: "text-muted-foreground/60",
|
||||
};
|
||||
|
||||
function SidebarHeading({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60 px-2.5 pt-3 pb-1">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarItem<T extends string>({
|
||||
label,
|
||||
value,
|
||||
current,
|
||||
onChange,
|
||||
}: SidebarItemProps<T>) {
|
||||
const isActive = current === value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(value)}
|
||||
className={`group flex items-center gap-2 px-2.5 py-1 text-left text-xs transition-colors cursor-pointer ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
{isActive && (
|
||||
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const toOptions = <T extends string>(values: readonly T[]) =>
|
||||
values.map((v) => ({ value: v, label: v }));
|
||||
|
||||
export default function LogsPage() {
|
||||
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
|
||||
|
|
@ -79,6 +50,7 @@ export default function LogsPage() {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const { t } = useI18n();
|
||||
const { setAfterTitle, setEnd } = usePageHeader();
|
||||
|
||||
const fetchLogs = useCallback(() => {
|
||||
setLoading(true);
|
||||
|
|
@ -97,6 +69,66 @@ export default function LogsPage() {
|
|||
.finally(() => setLoading(false));
|
||||
}, [file, lineCount, level, component]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setAfterTitle(
|
||||
<span className="flex items-center gap-2">
|
||||
{loading && (
|
||||
<div className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
)}
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{file} · {level} · {component}
|
||||
</Badge>
|
||||
</span>,
|
||||
);
|
||||
setEnd(
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={setAutoRefresh}
|
||||
id="logs-auto-refresh"
|
||||
/>
|
||||
<Label htmlFor="logs-auto-refresh" className="text-xs cursor-pointer">
|
||||
{t.logs.autoRefresh}
|
||||
</Label>
|
||||
{autoRefresh && (
|
||||
<Badge variant="success" className="text-[10px]">
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
{t.common.live}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchLogs}
|
||||
disabled={loading}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
{t.common.refresh}
|
||||
</Button>
|
||||
</div>,
|
||||
);
|
||||
return () => {
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
};
|
||||
}, [
|
||||
autoRefresh,
|
||||
component,
|
||||
file,
|
||||
level,
|
||||
loading,
|
||||
setAfterTitle,
|
||||
setEnd,
|
||||
t.common.live,
|
||||
t.common.refresh,
|
||||
t.logs.autoRefresh,
|
||||
fetchLogs,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
|
@ -109,145 +141,80 @@ export default function LogsPage() {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* ═══════════════ Header ═══════════════ */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<H2 variant="sm">{t.logs.title}</H2>
|
||||
{loading && (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
)}
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{file} · {level} · {component}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
|
||||
<Label className="text-xs">{t.logs.autoRefresh}</Label>
|
||||
{autoRefresh && (
|
||||
<Badge variant="success" className="text-[10px]">
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
{t.common.live}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchLogs}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
{t.common.refresh}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════ Sidebar + Content ═══════════════ */}
|
||||
{/* ═══════════════ Filter toolbar ═══════════════ */}
|
||||
<div
|
||||
className="flex flex-col sm:flex-row gap-4"
|
||||
style={{ minHeight: "calc(100vh - 180px)" }}
|
||||
role="toolbar"
|
||||
aria-label={t.logs.title}
|
||||
className="flex flex-wrap items-center gap-x-6 gap-y-2"
|
||||
>
|
||||
{/* ---- Sidebar ---- */}
|
||||
<div className="sm:w-44 sm:shrink-0">
|
||||
<div className="sm:sticky sm:top-[72px] flex flex-col gap-0.5">
|
||||
<SidebarHeading>{t.logs.file}</SidebarHeading>
|
||||
{FILES.map((f) => (
|
||||
<SidebarItem
|
||||
key={f}
|
||||
label={f}
|
||||
value={f}
|
||||
current={file}
|
||||
onChange={setFile}
|
||||
/>
|
||||
))}
|
||||
<FilterGroup label={t.logs.file}>
|
||||
<Segmented value={file} onChange={setFile} options={toOptions(FILES)} />
|
||||
</FilterGroup>
|
||||
|
||||
<SidebarHeading>{t.logs.level}</SidebarHeading>
|
||||
{LEVELS.map((l) => (
|
||||
<SidebarItem
|
||||
key={l}
|
||||
label={l}
|
||||
value={l}
|
||||
current={level}
|
||||
onChange={setLevel}
|
||||
/>
|
||||
))}
|
||||
<FilterGroup label={t.logs.level}>
|
||||
<Segmented value={level} onChange={setLevel} options={toOptions(LEVELS)} />
|
||||
</FilterGroup>
|
||||
|
||||
<SidebarHeading>{t.logs.component}</SidebarHeading>
|
||||
{COMPONENTS.map((c) => (
|
||||
<SidebarItem
|
||||
key={c}
|
||||
label={c}
|
||||
value={c}
|
||||
current={component}
|
||||
onChange={setComponent}
|
||||
/>
|
||||
))}
|
||||
<FilterGroup label={t.logs.component}>
|
||||
<Segmented
|
||||
value={component}
|
||||
onChange={setComponent}
|
||||
options={toOptions(COMPONENTS)}
|
||||
/>
|
||||
</FilterGroup>
|
||||
|
||||
<SidebarHeading>{t.logs.lines}</SidebarHeading>
|
||||
{LINE_COUNTS.map((n) => (
|
||||
<SidebarItem
|
||||
key={n}
|
||||
label={String(n)}
|
||||
value={String(n)}
|
||||
current={String(lineCount)}
|
||||
onChange={(v) =>
|
||||
setLineCount(Number(v) as (typeof LINE_COUNTS)[number])
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- Content ---- */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Card>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
{file}.log
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border-b border-destructive/20 p-3">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="p-4 font-mono-ui text-xs leading-5 overflow-auto max-h-[600px] min-h-[200px]"
|
||||
>
|
||||
{lines.length === 0 && !loading && (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
{t.logs.noLogLines}
|
||||
</p>
|
||||
)}
|
||||
{lines.map((line, i) => {
|
||||
const cls = classifyLine(line);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<FilterGroup label={t.logs.lines}>
|
||||
<Segmented
|
||||
value={String(lineCount)}
|
||||
onChange={(v) =>
|
||||
setLineCount(Number(v) as (typeof LINE_COUNTS)[number])
|
||||
}
|
||||
options={LINE_COUNTS.map((n) => ({
|
||||
value: String(n),
|
||||
label: String(n),
|
||||
}))}
|
||||
/>
|
||||
</FilterGroup>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════ Log viewer ═══════════════ */}
|
||||
<Card>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
{file}.log
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border-b border-destructive/20 p-3">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="p-4 font-mono-ui text-xs leading-5 overflow-auto min-h-[400px] max-h-[calc(100vh-220px)]"
|
||||
>
|
||||
{lines.length === 0 && !loading && (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
{t.logs.noLogLines}
|
||||
</p>
|
||||
)}
|
||||
{lines.map((line, i) => {
|
||||
const cls = classifyLine(line);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarItemProps<T extends string> {
|
||||
label: string;
|
||||
value: T;
|
||||
current: T;
|
||||
onChange: (v: T) => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useEffect, useLayoutEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Database,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Search,
|
||||
Trash2,
|
||||
|
|
@ -13,19 +17,27 @@ import {
|
|||
Hash,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { H2 } from "@nous-research/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
SessionInfo,
|
||||
SessionMessage,
|
||||
SessionSearchResult,
|
||||
StatusResponse,
|
||||
} from "@/lib/api";
|
||||
import { timeAgo } from "@/lib/utils";
|
||||
import { Markdown } from "@/components/Markdown";
|
||||
import { PlatformsCard } from "@/components/PlatformsCard";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useSystemActions } from "@/contexts/useSystemActions";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
|
||||
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> =
|
||||
{
|
||||
|
|
@ -381,7 +393,62 @@ export default function SessionsPage() {
|
|||
>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const logScrollRef = useRef<HTMLPreElement | null>(null);
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null);
|
||||
const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]);
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { setAfterTitle, setEnd } = usePageHeader();
|
||||
const { activeAction, actionStatus, dismissLog } = useSystemActions();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (loading) {
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
return;
|
||||
}
|
||||
setAfterTitle(
|
||||
<Badge variant="secondary" className="text-xs tabular-nums">
|
||||
{total}
|
||||
</Badge>,
|
||||
);
|
||||
setEnd(
|
||||
<div className="relative w-full min-w-0 sm:max-w-xs">
|
||||
{searching ? (
|
||||
<div className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin rounded-full border-[1.5px] border-primary border-t-transparent" />
|
||||
) : (
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<Input
|
||||
placeholder={t.sessions.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-8 pr-7 pl-8 text-xs"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSearch("")}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>,
|
||||
);
|
||||
return () => {
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
};
|
||||
}, [
|
||||
loading,
|
||||
search,
|
||||
searching,
|
||||
setAfterTitle,
|
||||
setEnd,
|
||||
t.sessions.searchPlaceholder,
|
||||
total,
|
||||
]);
|
||||
|
||||
const loadSessions = useCallback((p: number) => {
|
||||
setLoading(true);
|
||||
|
|
@ -399,6 +466,24 @@ export default function SessionsPage() {
|
|||
loadSessions(page);
|
||||
}, [loadSessions, page]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadOverview = () => {
|
||||
api.getStatus().then(setStatus).catch(() => {});
|
||||
api
|
||||
.getSessions(50)
|
||||
.then((r) => setOverviewSessions(r.sessions))
|
||||
.catch(() => {});
|
||||
};
|
||||
loadOverview();
|
||||
const id = setInterval(loadOverview, 5000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = logScrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [actionStatus?.lines]);
|
||||
|
||||
// Debounced FTS search
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
|
@ -423,16 +508,27 @@ export default function SessionsPage() {
|
|||
};
|
||||
}, [search]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.deleteSession(id);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
setTotal((prev) => prev - 1);
|
||||
if (expandedId === id) setExpandedId(null);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
const sessionDelete = useConfirmDelete({
|
||||
onDelete: useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await api.deleteSession(id);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
setTotal((prev) => prev - 1);
|
||||
if (expandedId === id) setExpandedId(null);
|
||||
showToast(t.sessions.sessionDeleted, "success");
|
||||
} catch {
|
||||
showToast(t.sessions.failedToDelete, "error");
|
||||
throw new Error("delete failed");
|
||||
}
|
||||
},
|
||||
[expandedId, showToast, t.sessions.sessionDeleted, t.sessions.failedToDelete],
|
||||
),
|
||||
});
|
||||
|
||||
const pendingSession = sessionDelete.pendingId
|
||||
? sessions.find((s) => s.id === sessionDelete.pendingId)
|
||||
: null;
|
||||
|
||||
// Build snippet map from search results (session_id → snippet)
|
||||
const snippetMap = new Map<string, string>();
|
||||
|
|
@ -448,6 +544,36 @@ export default function SessionsPage() {
|
|||
? sessions.filter((s) => snippetMap.has(s.id))
|
||||
: sessions;
|
||||
|
||||
const platformEntries = status
|
||||
? Object.entries(status.gateway_platforms ?? {})
|
||||
: [];
|
||||
const recentSessions = overviewSessions
|
||||
.filter((s) => !s.is_active)
|
||||
.slice(0, 5);
|
||||
|
||||
const alerts: { message: string; detail?: string }[] = [];
|
||||
if (status) {
|
||||
if (status.gateway_state === "startup_failed") {
|
||||
alerts.push({
|
||||
message: t.status.gatewayFailedToStart,
|
||||
detail: status.gateway_exit_reason ?? undefined,
|
||||
});
|
||||
}
|
||||
const failedPlatformEntries = platformEntries.filter(
|
||||
([, info]) => info.state === "fatal" || info.state === "disconnected",
|
||||
);
|
||||
for (const [name, info] of failedPlatformEntries) {
|
||||
const stateLabel =
|
||||
info.state === "fatal"
|
||||
? t.status.platformError
|
||||
: t.status.platformDisconnected;
|
||||
alerts.push({
|
||||
message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${stateLabel}`,
|
||||
detail: info.error_message ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
|
|
@ -458,38 +584,159 @@ export default function SessionsPage() {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header outside card for lighter feel */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-muted-foreground" />
|
||||
<H2 variant="sm">{t.sessions.title}</H2>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{total}
|
||||
</Badge>
|
||||
<Toast toast={toast} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={sessionDelete.isOpen}
|
||||
onCancel={sessionDelete.cancel}
|
||||
onConfirm={sessionDelete.confirm}
|
||||
title={t.sessions.confirmDeleteTitle}
|
||||
description={
|
||||
pendingSession?.title && pendingSession.title !== "Untitled"
|
||||
? `"${pendingSession.title}" — ${t.sessions.confirmDeleteMessage}`
|
||||
: t.sessions.confirmDeleteMessage
|
||||
}
|
||||
loading={sessionDelete.isDeleting}
|
||||
/>
|
||||
|
||||
{alerts.length > 0 && (
|
||||
<div className="border border-destructive/30 bg-destructive/[0.06] p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex flex-col gap-2 min-w-0">
|
||||
{alerts.map((alert, i) => (
|
||||
<div key={i}>
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{alert.message}
|
||||
</p>
|
||||
{alert.detail && (
|
||||
<p className="text-xs text-destructive/70 mt-0.5">
|
||||
{alert.detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-full sm:w-64">
|
||||
{searching ? (
|
||||
<div className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin rounded-full border-[1.5px] border-primary border-t-transparent" />
|
||||
) : (
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<Input
|
||||
placeholder={t.sessions.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8 pr-7 h-8 text-xs"
|
||||
/>
|
||||
{search && (
|
||||
)}
|
||||
|
||||
{activeAction && (
|
||||
<div className="border border-border bg-background-base/50">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{actionStatus?.running ? (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-warning" />
|
||||
) : actionStatus?.exit_code === 0 ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
|
||||
) : actionStatus !== null ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
|
||||
<span className="text-xs font-mondwest tracking-[0.12em] truncate">
|
||||
{activeAction === "restart"
|
||||
? t.status.restartGateway
|
||||
: t.status.updateHermes}
|
||||
</span>
|
||||
|
||||
<Badge
|
||||
variant={
|
||||
actionStatus?.running
|
||||
? "warning"
|
||||
: actionStatus?.exit_code === 0
|
||||
? "success"
|
||||
: actionStatus
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
className="text-[10px] shrink-0"
|
||||
>
|
||||
{actionStatus?.running
|
||||
? t.status.running
|
||||
: actionStatus?.exit_code === 0
|
||||
? t.status.actionFinished
|
||||
: actionStatus
|
||||
? `${t.status.actionFailed} (${actionStatus.exit_code ?? "?"})`
|
||||
: t.common.loading}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={() => setSearch("")}
|
||||
onClick={dismissLog}
|
||||
className="shrink-0 opacity-60 hover:opacity-100 cursor-pointer"
|
||||
aria-label={t.common.close}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<pre
|
||||
ref={logScrollRef}
|
||||
className="max-h-72 overflow-auto px-3 py-2 font-mono-ui text-[11px] leading-relaxed whitespace-pre-wrap break-all"
|
||||
>
|
||||
{actionStatus?.lines && actionStatus.lines.length > 0
|
||||
? actionStatus.lines.join("\n")
|
||||
: t.status.waitingForOutput}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{platformEntries.length > 0 && status && (
|
||||
<PlatformsCard platforms={platformEntries} />
|
||||
)}
|
||||
|
||||
{recentSessions.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">
|
||||
{t.status.recentSessions}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3">
|
||||
{recentSessions.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
|
||||
>
|
||||
<div className="flex flex-col gap-1 min-w-0 w-full">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{s.title ?? t.common.untitled}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
<span className="font-mono-ui">
|
||||
{(s.model ?? t.common.unknown).split("/").pop()}
|
||||
</span>{" "}
|
||||
· {s.message_count} {t.common.msgs} ·{" "}
|
||||
{timeAgo(s.last_active)}
|
||||
</span>
|
||||
|
||||
{s.preview && (
|
||||
<span className="text-xs text-muted-foreground/70 truncate">
|
||||
{s.preview}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] shrink-0 self-start sm:self-center"
|
||||
>
|
||||
<Database className="mr-1 h-3 w-3" />
|
||||
{s.source ?? "local"}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
|
|
@ -516,7 +763,7 @@ export default function SessionsPage() {
|
|||
onToggle={() =>
|
||||
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
||||
}
|
||||
onDelete={() => handleDelete(s.id)}
|
||||
onDelete={() => sessionDelete.requestDelete(s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useEffect, useLayoutEffect, useState, useMemo } from "react";
|
||||
import {
|
||||
Package,
|
||||
Search,
|
||||
Wrench,
|
||||
ChevronRight,
|
||||
X,
|
||||
Cpu,
|
||||
Globe,
|
||||
|
|
@ -14,8 +13,8 @@ import {
|
|||
Blocks,
|
||||
Code,
|
||||
Zap,
|
||||
Filter,
|
||||
} from "lucide-react";
|
||||
import { H2 } from "@nous-research/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
|
|
@ -25,6 +24,7 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types & helpers */
|
||||
|
|
@ -98,6 +98,7 @@ export default function SkillsPage() {
|
|||
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { setAfterTitle, setEnd } = usePageHeader();
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.getSkills(), api.getToolsets()])
|
||||
|
|
@ -182,6 +183,53 @@ export default function SkillsPage() {
|
|||
|
||||
const enabledCount = skills.filter((s) => s.enabled).length;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (loading) {
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
return;
|
||||
}
|
||||
setAfterTitle(
|
||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{t.skills.enabledOf
|
||||
.replace("{enabled}", String(enabledCount))
|
||||
.replace("{total}", String(skills.length))}
|
||||
</span>,
|
||||
);
|
||||
setEnd(
|
||||
<div className="relative w-full min-w-0 sm:max-w-xs">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 pl-8 pr-7 text-xs"
|
||||
placeholder={t.common.search}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSearch("")}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>,
|
||||
);
|
||||
return () => {
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
};
|
||||
}, [
|
||||
enabledCount,
|
||||
loading,
|
||||
search,
|
||||
setAfterTitle,
|
||||
setEnd,
|
||||
skills.length,
|
||||
t,
|
||||
]);
|
||||
|
||||
const filteredToolsets = useMemo(() => {
|
||||
return toolsets.filter(
|
||||
(ts) =>
|
||||
|
|
@ -205,122 +253,98 @@ export default function SkillsPage() {
|
|||
<div className="flex flex-col gap-4">
|
||||
<Toast toast={toast} />
|
||||
|
||||
{/* ═══════════════ Header ═══════════════ */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-5 w-5 text-muted-foreground" />
|
||||
<H2 variant="sm">{t.skills.title}</H2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t.skills.enabledOf
|
||||
.replace("{enabled}", String(enabledCount))
|
||||
.replace("{total}", String(skills.length))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* ═══════════════ Filter panel + Content ═══════════════ */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* ---- Filter panel ---- */}
|
||||
<aside
|
||||
aria-label={t.skills.title}
|
||||
className="sm:w-56 sm:shrink-0"
|
||||
>
|
||||
<div className="sm:sticky sm:top-4">
|
||||
<div
|
||||
className={`
|
||||
flex flex-col
|
||||
border border-border bg-muted/20
|
||||
`}
|
||||
>
|
||||
{/* Filter heading */}
|
||||
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<Filter className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
|
||||
{t.skills.filters}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════ Sidebar + Content ═══════════════ */}
|
||||
<div
|
||||
className="flex flex-col sm:flex-row gap-4"
|
||||
style={{ minHeight: "calc(100vh - 180px)" }}
|
||||
>
|
||||
{/* ---- Sidebar ---- */}
|
||||
<div className="sm:w-52 sm:shrink-0">
|
||||
<div className="sm:sticky sm:top-[72px] flex flex-col gap-1">
|
||||
{/* Search */}
|
||||
<div className="relative mb-2 hidden sm:block">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8 h-8 text-xs"
|
||||
placeholder={t.common.search}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSearch("")}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
{/* View switch (Skills / Toolsets) */}
|
||||
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none p-2">
|
||||
<PanelItem
|
||||
icon={Package}
|
||||
label={`${t.skills.all} (${skills.length})`}
|
||||
active={view === "skills" && !isSearching}
|
||||
onClick={() => {
|
||||
setView("skills");
|
||||
setActiveCategory(null);
|
||||
setSearch("");
|
||||
}}
|
||||
/>
|
||||
<PanelItem
|
||||
icon={Wrench}
|
||||
label={`${t.skills.toolsets} (${toolsets.length})`}
|
||||
active={view === "toolsets"}
|
||||
onClick={() => {
|
||||
setView("toolsets");
|
||||
setSearch("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category sub-filters (only for Skills view) */}
|
||||
{view === "skills" && !isSearching && allCategories.length > 0 && (
|
||||
<div className="hidden sm:flex flex-col border-t border-border">
|
||||
<div className="px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
|
||||
{t.skills.categories}
|
||||
</div>
|
||||
<div className="flex flex-col p-2 pt-1 gap-px max-h-[calc(100vh-340px)] overflow-y-auto">
|
||||
{allCategories.map(({ key, name, count }) => {
|
||||
const isActive = activeCategory === key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setActiveCategory(isActive ? null : key)
|
||||
}
|
||||
className={`
|
||||
group flex items-center gap-2 px-2 py-1
|
||||
rounded-sm text-left text-[11px] cursor-pointer
|
||||
transition-colors
|
||||
${
|
||||
isActive
|
||||
? "bg-foreground/10 text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-foreground/5"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="flex-1 truncate">{name}</span>
|
||||
<span
|
||||
className={`text-[10px] tabular-nums ${
|
||||
isActive
|
||||
? "text-foreground/60"
|
||||
: "text-muted-foreground/50"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top-level nav */}
|
||||
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setView("skills");
|
||||
setActiveCategory(null);
|
||||
setSearch("");
|
||||
}}
|
||||
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
|
||||
view === "skills" && !isSearching
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Package className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">
|
||||
{t.skills.all} ({skills.length})
|
||||
</span>
|
||||
{view === "skills" && !isSearching && (
|
||||
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Skill categories (nested under All Skills) */}
|
||||
{view === "skills" &&
|
||||
!isSearching &&
|
||||
allCategories.map(({ key, name, count }) => {
|
||||
const isActive = activeCategory === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setActiveCategory(activeCategory === key ? null : key)
|
||||
}
|
||||
className={`group flex items-center gap-2 px-2.5 py-1 pl-7 text-left text-[11px] transition-colors cursor-pointer ${
|
||||
isActive
|
||||
? "text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1 truncate">{name}</span>
|
||||
<span
|
||||
className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setView("toolsets");
|
||||
setSearch("");
|
||||
}}
|
||||
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
|
||||
view === "toolsets"
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">
|
||||
{t.skills.toolsets} ({toolsets.length})
|
||||
</span>
|
||||
{view === "toolsets" && (
|
||||
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ---- Content ---- */}
|
||||
<div className="flex-1 min-w-0">
|
||||
|
|
@ -522,9 +546,39 @@ function SkillRow({
|
|||
);
|
||||
}
|
||||
|
||||
function PanelItem({ active, icon: Icon, label, onClick }: PanelItemProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`
|
||||
group flex items-center gap-2 px-2.5 py-1.5
|
||||
font-mondwest text-[0.7rem] tracking-[0.08em] uppercase
|
||||
rounded-sm text-left cursor-pointer whitespace-nowrap
|
||||
transition-colors
|
||||
${
|
||||
active
|
||||
? "bg-foreground/90 text-background"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-foreground/10"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface PanelItemProps {
|
||||
active: boolean;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface SkillRowProps {
|
||||
noDescriptionLabel: string;
|
||||
onToggle: () => void;
|
||||
skill: SkillInfo;
|
||||
toggling: boolean;
|
||||
onToggle: () => void;
|
||||
noDescriptionLabel: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,614 +0,0 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Cpu,
|
||||
Database,
|
||||
Download,
|
||||
Loader2,
|
||||
Radio,
|
||||
RotateCw,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Wrench,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Cell, Grid } from "@nous-research/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
ActionStatusResponse,
|
||||
PlatformStatus,
|
||||
SessionInfo,
|
||||
StatusResponse,
|
||||
} from "@/lib/api";
|
||||
import { cn, timeAgo, isoTimeAgo } from "@/lib/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
const ACTION_NAMES: Record<"restart" | "update", string> = {
|
||||
restart: "gateway-restart",
|
||||
update: "hermes-update",
|
||||
};
|
||||
|
||||
export default function StatusPage() {
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [pendingAction, setPendingAction] = useState<
|
||||
"restart" | "update" | null
|
||||
>(null);
|
||||
const [activeAction, setActiveAction] = useState<"restart" | "update" | null>(
|
||||
null,
|
||||
);
|
||||
const [actionStatus, setActionStatus] = useState<ActionStatusResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const logScrollRef = useRef<HTMLPreElement | null>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
api
|
||||
.getStatus()
|
||||
.then(setStatus)
|
||||
.catch(() => {});
|
||||
api
|
||||
.getSessions(50)
|
||||
.then((resp) => setSessions(resp.sessions))
|
||||
.catch(() => {});
|
||||
};
|
||||
load();
|
||||
const interval = setInterval(load, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
const timer = setTimeout(() => setToast(null), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeAction) return;
|
||||
const name = ACTION_NAMES[activeAction];
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const resp = await api.getActionStatus(name);
|
||||
if (cancelled) return;
|
||||
setActionStatus(resp);
|
||||
if (!resp.running) {
|
||||
const ok = resp.exit_code === 0;
|
||||
setToast({
|
||||
type: ok ? "success" : "error",
|
||||
message: ok
|
||||
? t.status.actionFinished
|
||||
: `${t.status.actionFailed} (exit ${resp.exit_code ?? "?"})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// transient fetch error; keep polling
|
||||
}
|
||||
if (!cancelled) setTimeout(poll, 1500);
|
||||
};
|
||||
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeAction, t.status.actionFinished, t.status.actionFailed]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = logScrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [actionStatus?.lines]);
|
||||
|
||||
const runAction = async (action: "restart" | "update") => {
|
||||
setPendingAction(action);
|
||||
setActionStatus(null);
|
||||
try {
|
||||
if (action === "restart") {
|
||||
await api.restartGateway();
|
||||
} else {
|
||||
await api.updateHermes();
|
||||
}
|
||||
setActiveAction(action);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
setToast({
|
||||
type: "error",
|
||||
message: `${t.status.actionFailed}: ${detail}`,
|
||||
});
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const dismissLog = () => {
|
||||
setActiveAction(null);
|
||||
setActionStatus(null);
|
||||
};
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PLATFORM_STATE_BADGE: Record<
|
||||
string,
|
||||
{ variant: "success" | "warning" | "destructive"; label: string }
|
||||
> = {
|
||||
connected: { variant: "success", label: t.status.connected },
|
||||
disconnected: { variant: "warning", label: t.status.disconnected },
|
||||
fatal: { variant: "destructive", label: t.status.error },
|
||||
};
|
||||
|
||||
const GATEWAY_STATE_DISPLAY: Record<
|
||||
string,
|
||||
{ badge: "success" | "warning" | "destructive" | "outline"; label: string }
|
||||
> = {
|
||||
running: { badge: "success", label: t.status.running },
|
||||
starting: { badge: "warning", label: t.status.starting },
|
||||
startup_failed: { badge: "destructive", label: t.status.failed },
|
||||
stopped: { badge: "outline", label: t.status.stopped },
|
||||
};
|
||||
|
||||
function gatewayValue(): string {
|
||||
if (status!.gateway_running && status!.gateway_health_url)
|
||||
return status!.gateway_health_url;
|
||||
if (status!.gateway_running && status!.gateway_pid)
|
||||
return `${t.status.pid} ${status!.gateway_pid}`;
|
||||
if (status!.gateway_running) return t.status.runningRemote;
|
||||
if (status!.gateway_state === "startup_failed") return t.status.startFailed;
|
||||
return t.status.notRunning;
|
||||
}
|
||||
|
||||
function gatewayBadge() {
|
||||
const info = status!.gateway_state
|
||||
? GATEWAY_STATE_DISPLAY[status!.gateway_state]
|
||||
: null;
|
||||
if (info) return info;
|
||||
return status!.gateway_running
|
||||
? { badge: "success" as const, label: t.status.running }
|
||||
: { badge: "outline" as const, label: t.common.off };
|
||||
}
|
||||
|
||||
const gwBadge = gatewayBadge();
|
||||
|
||||
const items = [
|
||||
{
|
||||
icon: Cpu,
|
||||
label: t.status.agent,
|
||||
value: `v${status.version}`,
|
||||
badgeText: t.common.live,
|
||||
badgeVariant: "success" as const,
|
||||
},
|
||||
{
|
||||
icon: Radio,
|
||||
label: t.status.gateway,
|
||||
value: gatewayValue(),
|
||||
badgeText: gwBadge.label,
|
||||
badgeVariant: gwBadge.badge,
|
||||
},
|
||||
{
|
||||
icon: Activity,
|
||||
label: t.status.activeSessions,
|
||||
value:
|
||||
status.active_sessions > 0
|
||||
? `${status.active_sessions} ${t.status.running.toLowerCase()}`
|
||||
: t.status.noneRunning,
|
||||
badgeText: status.active_sessions > 0 ? t.common.live : t.common.off,
|
||||
badgeVariant: (status.active_sessions > 0 ? "success" : "outline") as
|
||||
| "success"
|
||||
| "outline",
|
||||
},
|
||||
];
|
||||
|
||||
const platforms = Object.entries(status.gateway_platforms ?? {});
|
||||
const activeSessions = sessions.filter((s) => s.is_active);
|
||||
const recentSessions = sessions.filter((s) => !s.is_active).slice(0, 5);
|
||||
|
||||
// Collect alerts that need attention
|
||||
const alerts: { message: string; detail?: string }[] = [];
|
||||
if (status.gateway_state === "startup_failed") {
|
||||
alerts.push({
|
||||
message: t.status.gatewayFailedToStart,
|
||||
detail: status.gateway_exit_reason ?? undefined,
|
||||
});
|
||||
}
|
||||
const failedPlatforms = platforms.filter(
|
||||
([, info]) => info.state === "fatal" || info.state === "disconnected",
|
||||
);
|
||||
for (const [name, info] of failedPlatforms) {
|
||||
const stateLabel =
|
||||
info.state === "fatal"
|
||||
? t.status.platformError
|
||||
: t.status.platformDisconnected;
|
||||
alerts.push({
|
||||
message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${stateLabel}`,
|
||||
detail: info.error_message ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Toast toast={toast} />
|
||||
|
||||
{alerts.length > 0 && (
|
||||
<div className="border border-destructive/30 bg-destructive/[0.06] p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex flex-col gap-2 min-w-0">
|
||||
{alerts.map((alert, i) => (
|
||||
<div key={i}>
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{alert.message}
|
||||
</p>
|
||||
{alert.detail && (
|
||||
<p className="text-xs text-destructive/70 mt-0.5">
|
||||
{alert.detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Grid className="border-b md:!grid-cols-2 lg:!grid-cols-4">
|
||||
{items.map(({ icon: Icon, label, value, badgeText, badgeVariant }) => (
|
||||
<Cell
|
||||
key={label}
|
||||
className="flex min-w-0 flex-col gap-2 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium">{label}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="truncate text-2xl font-bold font-mondwest"
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
|
||||
{badgeText && (
|
||||
<Badge variant={badgeVariant} className="self-start">
|
||||
{badgeVariant === "success" && (
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
{badgeText}
|
||||
</Badge>
|
||||
)}
|
||||
</Cell>
|
||||
))}
|
||||
|
||||
<Cell className="flex min-w-0 flex-col gap-2 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t.status.actions}
|
||||
</CardTitle>
|
||||
<Wrench className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => runAction("restart")}
|
||||
disabled={
|
||||
pendingAction !== null ||
|
||||
(activeAction !== null && actionStatus?.running !== false)
|
||||
}
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
<RotateCw
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
(pendingAction === "restart" ||
|
||||
(activeAction === "restart" && actionStatus?.running)) &&
|
||||
"animate-spin",
|
||||
)}
|
||||
/>
|
||||
|
||||
{activeAction === "restart" && actionStatus?.running
|
||||
? t.status.restartingGateway
|
||||
: t.status.restartGateway}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => runAction("update")}
|
||||
disabled={
|
||||
pendingAction !== null ||
|
||||
(activeAction !== null && actionStatus?.running !== false)
|
||||
}
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
<Download
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
(pendingAction === "update" ||
|
||||
(activeAction === "update" && actionStatus?.running)) &&
|
||||
"animate-pulse",
|
||||
)}
|
||||
/>
|
||||
|
||||
{activeAction === "update" && actionStatus?.running
|
||||
? t.status.updatingHermes
|
||||
: t.status.updateHermes}
|
||||
</Button>
|
||||
</div>
|
||||
</Cell>
|
||||
</Grid>
|
||||
|
||||
{activeAction && (
|
||||
<div className="border border-border bg-background-base/50">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{actionStatus?.running ? (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-warning" />
|
||||
) : actionStatus?.exit_code === 0 ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
|
||||
) : actionStatus !== null ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
|
||||
<span className="text-xs font-mondwest tracking-[0.12em] truncate">
|
||||
{activeAction === "restart"
|
||||
? t.status.restartGateway
|
||||
: t.status.updateHermes}
|
||||
</span>
|
||||
|
||||
<Badge
|
||||
variant={
|
||||
actionStatus?.running
|
||||
? "warning"
|
||||
: actionStatus?.exit_code === 0
|
||||
? "success"
|
||||
: actionStatus
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
className="text-[10px] shrink-0"
|
||||
>
|
||||
{actionStatus?.running
|
||||
? t.status.running
|
||||
: actionStatus?.exit_code === 0
|
||||
? t.status.actionFinished
|
||||
: actionStatus
|
||||
? `${t.status.actionFailed} (${actionStatus.exit_code ?? "?"})`
|
||||
: t.common.loading}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismissLog}
|
||||
className="shrink-0 opacity-60 hover:opacity-100 cursor-pointer"
|
||||
aria-label={t.common.close}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<pre
|
||||
ref={logScrollRef}
|
||||
className="max-h-72 overflow-auto px-3 py-2 font-mono-ui text-[11px] leading-relaxed whitespace-pre-wrap break-all"
|
||||
>
|
||||
{actionStatus?.lines && actionStatus.lines.length > 0
|
||||
? actionStatus.lines.join("\n")
|
||||
: t.status.waitingForOutput}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{platforms.length > 0 && (
|
||||
<PlatformsCard
|
||||
platforms={platforms}
|
||||
platformStateBadge={PLATFORM_STATE_BADGE}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSessions.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-success" />
|
||||
<CardTitle className="text-base">
|
||||
{t.status.activeSessions}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3">
|
||||
{activeSessions.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
|
||||
>
|
||||
<div className="flex flex-col gap-1 min-w-0 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{s.title ?? t.common.untitled}
|
||||
</span>
|
||||
|
||||
<Badge variant="success" className="text-[10px] shrink-0">
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
{t.common.live}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
<span className="font-mono-ui">
|
||||
{(s.model ?? t.common.unknown).split("/").pop()}
|
||||
</span>{" "}
|
||||
· {s.message_count} {t.common.msgs} ·{" "}
|
||||
{timeAgo(s.last_active)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{recentSessions.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">
|
||||
{t.status.recentSessions}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3">
|
||||
{recentSessions.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
|
||||
>
|
||||
<div className="flex flex-col gap-1 min-w-0 w-full">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{s.title ?? t.common.untitled}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
<span className="font-mono-ui">
|
||||
{(s.model ?? t.common.unknown).split("/").pop()}
|
||||
</span>{" "}
|
||||
· {s.message_count} {t.common.msgs} ·{" "}
|
||||
{timeAgo(s.last_active)}
|
||||
</span>
|
||||
|
||||
{s.preview && (
|
||||
<span className="text-xs text-muted-foreground/70 truncate">
|
||||
{s.preview}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] shrink-0 self-start sm:self-center"
|
||||
>
|
||||
<Database className="mr-1 h-3 w-3" />
|
||||
{s.source ?? "local"}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">
|
||||
{t.status.connectedPlatforms}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3">
|
||||
{platforms.map(([name, info]) => {
|
||||
const display = platformStateBadge[info.state] ?? {
|
||||
variant: "outline" as const,
|
||||
label: info.state,
|
||||
};
|
||||
const IconComponent =
|
||||
info.state === "connected"
|
||||
? Wifi
|
||||
: info.state === "fatal"
|
||||
? AlertTriangle
|
||||
: WifiOff;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 w-full">
|
||||
<IconComponent
|
||||
className={`h-4 w-4 shrink-0 ${
|
||||
info.state === "connected"
|
||||
? "text-success"
|
||||
: info.state === "fatal"
|
||||
? "text-destructive"
|
||||
: "text-warning"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-sm font-medium capitalize truncate">
|
||||
{name}
|
||||
</span>
|
||||
|
||||
{info.error_message && (
|
||||
<span className="text-xs text-destructive">
|
||||
{info.error_message}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{info.updated_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t.status.lastUpdate}: {isoTimeAgo(info.updated_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
variant={display.variant}
|
||||
className="shrink-0 self-start sm:self-center"
|
||||
>
|
||||
{display.variant === "success" && (
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
{display.label}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
message: string;
|
||||
type: "success" | "error";
|
||||
}
|
||||
|
||||
interface PlatformsCardProps {
|
||||
platforms: [string, PlatformStatus][];
|
||||
platformStateBadge: Record<
|
||||
string,
|
||||
{ variant: "success" | "warning" | "destructive"; label: string }
|
||||
>;
|
||||
}
|
||||
64
web/src/plugins/PluginPage.tsx
Normal file
64
web/src/plugins/PluginPage.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
getPluginComponent,
|
||||
getPluginLoadError,
|
||||
onPluginRegistered,
|
||||
} from "./registry";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Translations } from "@/i18n/types";
|
||||
|
||||
/** Renders a plugin tab once its bundle has called `register()`. */
|
||||
export function PluginPage({ name }: { name: string }) {
|
||||
const { t } = useI18n();
|
||||
// Subscribe in render (via useSyncExternalStore) so we never miss
|
||||
// `register()` if the script loads before a useEffect would run.
|
||||
const Component = useSyncExternalStore(
|
||||
(onChange) => onPluginRegistered(onChange),
|
||||
() => getPluginComponent(name) ?? null,
|
||||
() => null,
|
||||
);
|
||||
const loadError = useSyncExternalStore(
|
||||
(onChange) => onPluginRegistered(onChange),
|
||||
() => getPluginLoadError(name) ?? null,
|
||||
() => null,
|
||||
);
|
||||
|
||||
if (Component) {
|
||||
return <Component />;
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
const message = formatPluginError(loadError, t);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-lg p-4",
|
||||
"font-mondwest text-sm tracking-[0.08em] text-midground/80",
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-4",
|
||||
"font-mondwest text-sm tracking-[0.1em] text-midground/60",
|
||||
)}
|
||||
>
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
|
||||
<span>{t.common.loading}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPluginError(code: string, t: Translations): string {
|
||||
if (code === "LOAD_FAILED") return t.common.pluginLoadFailed;
|
||||
if (code === "NO_REGISTER") return t.common.pluginNotRegistered;
|
||||
return code;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export { exposePluginSDK, getPluginComponent, onPluginRegistered, getRegisteredCount } from "./registry";
|
||||
export { PluginPage } from "./PluginPage";
|
||||
export { usePlugins } from "./usePlugins";
|
||||
export { PluginSlot, KNOWN_SLOT_NAMES, registerSlot, getSlotEntries, onSlotRegistered, unregisterPluginSlots } from "./slots";
|
||||
export type { KnownSlotName } from "./slots";
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { registerSlot, PluginSlot } from "./slots";
|
|||
type RegistryListener = () => void;
|
||||
|
||||
const _registered: Map<string, React.ComponentType> = new Map();
|
||||
const _loadErrors: Map<string, string> = new Map();
|
||||
const _listeners: Set<RegistryListener> = new Set();
|
||||
|
||||
function _notify() {
|
||||
|
|
@ -45,8 +46,14 @@ function _notify() {
|
|||
}
|
||||
}
|
||||
|
||||
/** Re-run registry subscribers (e.g. after a plugin script onload, or dev HMR re-inject). */
|
||||
export function notifyPluginRegistry() {
|
||||
_notify();
|
||||
}
|
||||
|
||||
/** Register a plugin component. Called by plugin JS bundles. */
|
||||
function registerPlugin(name: string, component: React.ComponentType) {
|
||||
_loadErrors.delete(name);
|
||||
_registered.set(name, component);
|
||||
_notify();
|
||||
}
|
||||
|
|
@ -56,6 +63,15 @@ export function getPluginComponent(name: string): React.ComponentType | undefine
|
|||
return _registered.get(name);
|
||||
}
|
||||
|
||||
export function getPluginLoadError(name: string): string | undefined {
|
||||
return _loadErrors.get(name);
|
||||
}
|
||||
|
||||
export function setPluginLoadError(name: string, message: string) {
|
||||
_loadErrors.set(name, message);
|
||||
_notify();
|
||||
}
|
||||
|
||||
/** Subscribe to registry changes (returns unsubscribe fn). */
|
||||
export function onPluginRegistered(fn: RegistryListener): () => void {
|
||||
_listeners.add(fn);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
/** Types for the dashboard plugin system. */
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
export interface PluginManifest {
|
||||
name: string;
|
||||
label: string;
|
||||
|
|
@ -8,21 +10,14 @@ export interface PluginManifest {
|
|||
version: string;
|
||||
tab: {
|
||||
path: string;
|
||||
position: string; // "end", "after:<tab>", "before:<tab>"
|
||||
/** When set to a built-in route path (e.g. `"/"`, `"/sessions"`), this
|
||||
* plugin's component replaces the built-in page at that route rather
|
||||
* than adding a new tab. Useful for themes that want a custom home
|
||||
* page without losing the rest of the dashboard. */
|
||||
/** "end", "after:<pathSegment>", "before:<pathSegment>" (e.g. "after:skills" → after `/skills`) */
|
||||
position?: string;
|
||||
/** When set to a built-in route path, this plugin replaces that page instead of adding a new tab. */
|
||||
override?: string;
|
||||
/** When true, the plugin registers its component and slot contributors
|
||||
* without adding a tab to the nav. Used by slot-only plugins (e.g. a
|
||||
* plugin that just injects a header crest). */
|
||||
/** When true, the plugin may register without a sidebar tab (slot-only, etc.). */
|
||||
hidden?: boolean;
|
||||
};
|
||||
/** Named shell slots this plugin populates. Mirrored by the backend's
|
||||
* manifest discovery; used purely as a documentation/discovery aid —
|
||||
* actual slot registration happens when the plugin's JS bundle calls
|
||||
* `window.__HERMES_PLUGINS__.registerSlot(name, slot, Component)`. */
|
||||
/** Declared for discovery; actual slots use registerSlot in the plugin bundle. */
|
||||
slots?: string[];
|
||||
entry: string;
|
||||
css?: string | null;
|
||||
|
|
@ -32,5 +27,5 @@ export interface PluginManifest {
|
|||
|
||||
export interface RegisteredPlugin {
|
||||
manifest: PluginManifest;
|
||||
component: React.ComponentType;
|
||||
component: ComponentType;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,12 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { PluginManifest, RegisteredPlugin } from "./types";
|
||||
import { getPluginComponent, onPluginRegistered } from "./registry";
|
||||
import {
|
||||
getPluginComponent,
|
||||
onPluginRegistered,
|
||||
notifyPluginRegistry,
|
||||
setPluginLoadError,
|
||||
} from "./registry";
|
||||
|
||||
export function usePlugins() {
|
||||
const [manifests, setManifests] = useState<PluginManifest[]>([]);
|
||||
|
|
@ -33,6 +38,8 @@ export function usePlugins() {
|
|||
useEffect(() => {
|
||||
if (manifests.length === 0) return;
|
||||
|
||||
const injectedScripts: HTMLScriptElement[] = [];
|
||||
|
||||
for (const manifest of manifests) {
|
||||
// Inject CSS if specified.
|
||||
if (manifest.css) {
|
||||
|
|
@ -45,23 +52,49 @@ export function usePlugins() {
|
|||
}
|
||||
}
|
||||
|
||||
// Load JS bundle.
|
||||
const jsUrl = `/dashboard-plugins/${manifest.name}/${manifest.entry}`;
|
||||
if (loadedScripts.current.has(jsUrl)) continue;
|
||||
loadedScripts.current.add(jsUrl);
|
||||
// Load JS bundle. In dev, cache-bust so Vite HMR can clear the
|
||||
// in-memory registry while the browser would otherwise never
|
||||
// re-execute a previously cached <script> URL.
|
||||
const baseUrl = `/dashboard-plugins/${manifest.name}/${manifest.entry}`;
|
||||
const scriptSrc = import.meta.env.DEV
|
||||
? `${baseUrl}?hermes_dv=${Date.now()}`
|
||||
: baseUrl;
|
||||
if (!import.meta.env.DEV) {
|
||||
if (loadedScripts.current.has(baseUrl)) continue;
|
||||
loadedScripts.current.add(baseUrl);
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = jsUrl;
|
||||
script.setAttribute("data-hermes-plugin", manifest.name);
|
||||
script.src = scriptSrc;
|
||||
script.async = true;
|
||||
script.onerror = () => {
|
||||
console.warn(`[plugins] Failed to load ${manifest.name} from ${jsUrl}`);
|
||||
setPluginLoadError(manifest.name, "LOAD_FAILED");
|
||||
console.warn(
|
||||
`[plugins] Failed to load ${manifest.name} from ${scriptSrc} (open Network tab)`,
|
||||
);
|
||||
};
|
||||
script.onload = () => {
|
||||
notifyPluginRegistry();
|
||||
queueMicrotask(() => {
|
||||
if (getPluginComponent(manifest.name)) return;
|
||||
setPluginLoadError(manifest.name, "NO_REGISTER");
|
||||
});
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
injectedScripts.push(script);
|
||||
}
|
||||
|
||||
// Give plugins a moment to load and register, then stop loading state.
|
||||
const timeout = setTimeout(() => setLoading(false), 2000);
|
||||
return () => clearTimeout(timeout);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
if (import.meta.env.DEV) {
|
||||
for (const el of injectedScripts) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [manifests]);
|
||||
|
||||
// Listen for plugin registrations and resolve them against manifests.
|
||||
|
|
|
|||
|
|
@ -65,6 +65,10 @@ export default defineConfig({
|
|||
server: {
|
||||
proxy: {
|
||||
"/api": BACKEND,
|
||||
// Same host as `hermes dashboard` must serve these; Vite has no
|
||||
// dashboard-plugins/* files, so without this, plugin scripts 404
|
||||
// or receive index.html in dev.
|
||||
"/dashboard-plugins": BACKEND,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue