mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Merge pull request #14899 from NousResearch/feat/dashboard-layout
Feat/dashboard layout
This commit is contained in:
commit
1143f234e3
43 changed files with 2534 additions and 1394 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==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
|
|
@ -318,31 +319,6 @@
|
||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
|
|
@ -1509,6 +1485,7 @@
|
||||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.19.0"
|
"undici-types": "~7.19.0"
|
||||||
}
|
}
|
||||||
|
|
@ -1519,6 +1496,7 @@
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
|
|
@ -1529,6 +1507,7 @@
|
||||||
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
|
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
"@typescript-eslint/scope-manager": "8.58.1",
|
"@typescript-eslint/scope-manager": "8.58.1",
|
||||||
|
|
@ -1558,6 +1537,7 @@
|
||||||
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
|
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.58.1",
|
"@typescript-eslint/scope-manager": "8.58.1",
|
||||||
"@typescript-eslint/types": "8.58.1",
|
"@typescript-eslint/types": "8.58.1",
|
||||||
|
|
@ -1875,6 +1855,7 @@
|
||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -2210,6 +2191,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
|
|
@ -2895,6 +2877,7 @@
|
||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@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",
|
"resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz",
|
||||||
"integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==",
|
"integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"type-fest": "^4.18.2"
|
"type-fest": "^4.18.2"
|
||||||
|
|
@ -5146,6 +5130,7 @@
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -5245,6 +5230,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -6017,6 +6003,7 @@
|
||||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.27.0",
|
"esbuild": "~0.27.0",
|
||||||
"get-tsconfig": "^4.7.5"
|
"get-tsconfig": "^4.7.5"
|
||||||
|
|
@ -6143,6 +6130,7 @@
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -6252,6 +6240,7 @@
|
||||||
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
|
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
|
|
@ -6660,6 +6649,7 @@
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
web/package-lock.json
generated
34
web/package-lock.json
generated
|
|
@ -8,7 +8,7 @@
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nous-research/ui": "^0.3.0",
|
"@nous-research/ui": "^0.4.0",
|
||||||
"@observablehq/plot": "^0.6.17",
|
"@observablehq/plot": "^0.6.17",
|
||||||
"@react-three/fiber": "^9.6.0",
|
"@react-three/fiber": "^9.6.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
|
@ -70,6 +70,7 @@
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
|
|
@ -1058,9 +1059,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nous-research/ui": {
|
"node_modules/@nous-research/ui": {
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.4.0.tgz",
|
||||||
"integrity": "sha512-konGgtV9lkzqYkWuoUGnROqavq1svTnGbERLKItvEXmsRz4xRtbAMHI8rK6sjGpHDpwvOUN3olcOhRLTGuVfcA==",
|
"integrity": "sha512-wA9YImWLFjx3yWsb3TsquwG9VKZunupdovkOjnRboFjNAb3Jcf57o67xWafEPEm3VX6k6RP/+Y9zHWX0PUtZ4w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nanostores/react": "^1.0.0",
|
"@nanostores/react": "^1.0.0",
|
||||||
|
|
@ -1103,6 +1104,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz",
|
"resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz",
|
||||||
"integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==",
|
"integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"interval-tree-1d": "^1.0.0",
|
"interval-tree-1d": "^1.0.0",
|
||||||
|
|
@ -1755,6 +1757,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.0.tgz",
|
||||||
"integrity": "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA==",
|
"integrity": "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.17.8",
|
"@babel/runtime": "^7.17.8",
|
||||||
"@types/webxr": "*",
|
"@types/webxr": "*",
|
||||||
|
|
@ -2489,6 +2492,7 @@
|
||||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
|
|
@ -2498,6 +2502,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
|
|
@ -2508,6 +2513,7 @@
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
|
|
@ -2572,6 +2578,7 @@
|
||||||
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
|
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.57.0",
|
"@typescript-eslint/scope-manager": "8.57.0",
|
||||||
"@typescript-eslint/types": "8.57.0",
|
"@typescript-eslint/types": "8.57.0",
|
||||||
|
|
@ -2867,6 +2874,7 @@
|
||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -3019,6 +3027,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
|
|
@ -3526,6 +3535,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
|
|
@ -3839,6 +3849,7 @@
|
||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|
@ -4217,7 +4228,8 @@
|
||||||
"version": "3.15.0",
|
"version": "3.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
|
||||||
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
|
"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": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
|
|
@ -4532,6 +4544,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz",
|
||||||
"integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==",
|
"integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-portal": "^1.1.4",
|
"@radix-ui/react-portal": "^1.1.4",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
|
@ -4953,6 +4966,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.0.0 || >=22.0.0"
|
"node": "^20.0.0 || >=22.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -5080,6 +5094,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -5151,6 +5166,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -5170,6 +5186,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
|
|
@ -5532,7 +5549,8 @@
|
||||||
"version": "0.180.0",
|
"version": "0.180.0",
|
||||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
|
|
@ -5597,6 +5615,7 @@
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -5682,6 +5701,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"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",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|
@ -5818,6 +5839,7 @@
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nous-research/ui": "^0.3.0",
|
"@nous-research/ui": "^0.4.0",
|
||||||
"@observablehq/plot": "^0.6.17",
|
"@observablehq/plot": "^0.6.17",
|
||||||
"@react-three/fiber": "^9.6.0",
|
"@react-three/fiber": "^9.6.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
|
|
||||||
676
web/src/App.tsx
676
web/src/App.tsx
|
|
@ -1,32 +1,57 @@
|
||||||
import { useMemo } from "react";
|
import {
|
||||||
import { Routes, Route, NavLink, Navigate } from "react-router-dom";
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ComponentType,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Routes,
|
||||||
|
Route,
|
||||||
|
NavLink,
|
||||||
|
Navigate,
|
||||||
|
useLocation,
|
||||||
|
useNavigate,
|
||||||
|
} from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
BookOpen,
|
||||||
Clock,
|
Clock,
|
||||||
|
Code,
|
||||||
|
Database,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
|
Globe,
|
||||||
|
Heart,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
Loader2,
|
||||||
|
Menu,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Package,
|
Package,
|
||||||
Settings,
|
|
||||||
Puzzle,
|
Puzzle,
|
||||||
Sparkles,
|
RotateCw,
|
||||||
Terminal,
|
Settings,
|
||||||
Globe,
|
|
||||||
Database,
|
|
||||||
Shield,
|
Shield,
|
||||||
Wrench,
|
Sparkles,
|
||||||
Zap,
|
|
||||||
Heart,
|
|
||||||
Star,
|
Star,
|
||||||
Code,
|
Terminal,
|
||||||
Eye,
|
Wrench,
|
||||||
|
X,
|
||||||
|
Zap,
|
||||||
} from "lucide-react";
|
} 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 { cn } from "@/lib/utils";
|
||||||
import { Backdrop } from "@/components/Backdrop";
|
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 ConfigPage from "@/pages/ConfigPage";
|
||||||
|
import DocsPage from "@/pages/DocsPage";
|
||||||
import EnvPage from "@/pages/EnvPage";
|
import EnvPage from "@/pages/EnvPage";
|
||||||
import SessionsPage from "@/pages/SessionsPage";
|
import SessionsPage from "@/pages/SessionsPage";
|
||||||
import LogsPage from "@/pages/LogsPage";
|
import LogsPage from "@/pages/LogsPage";
|
||||||
|
|
@ -36,15 +61,17 @@ import SkillsPage from "@/pages/SkillsPage";
|
||||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||||
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
import { PluginSlot, usePlugins } from "@/plugins";
|
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
|
||||||
import type { RegisteredPlugin } from "@/plugins";
|
import type { PluginManifest } from "@/plugins";
|
||||||
import { useTheme } from "@/themes";
|
import { useTheme } from "@/themes";
|
||||||
|
|
||||||
/** Built-in route → default page component. Used both for standard routing
|
function RootRedirect() {
|
||||||
* and for resolving plugin `tab.override` values. Keys must match the
|
return <Navigate to="/sessions" replace />;
|
||||||
* `path` in `BUILTIN_NAV` so `/path` lookups stay consistent. */
|
}
|
||||||
const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
|
|
||||||
"/": StatusPage,
|
/** 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,
|
"/sessions": SessionsPage,
|
||||||
"/analytics": AnalyticsPage,
|
"/analytics": AnalyticsPage,
|
||||||
"/logs": LogsPage,
|
"/logs": LogsPage,
|
||||||
|
|
@ -52,10 +79,10 @@ const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
|
||||||
"/skills": SkillsPage,
|
"/skills": SkillsPage,
|
||||||
"/config": ConfigPage,
|
"/config": ConfigPage,
|
||||||
"/env": EnvPage,
|
"/env": EnvPage,
|
||||||
|
"/docs": DocsPage,
|
||||||
};
|
};
|
||||||
|
|
||||||
const BUILTIN_NAV: NavItem[] = [
|
const BUILTIN_NAV: NavItem[] = [
|
||||||
{ path: "/", labelKey: "status", label: "Status", icon: Activity },
|
|
||||||
{
|
{
|
||||||
path: "/sessions",
|
path: "/sessions",
|
||||||
labelKey: "sessions",
|
labelKey: "sessions",
|
||||||
|
|
@ -73,11 +100,15 @@ const BUILTIN_NAV: NavItem[] = [
|
||||||
{ path: "/skills", labelKey: "skills", label: "Skills", icon: Package },
|
{ path: "/skills", labelKey: "skills", label: "Skills", icon: Package },
|
||||||
{ path: "/config", labelKey: "config", label: "Config", icon: Settings },
|
{ path: "/config", labelKey: "config", label: "Config", icon: Settings },
|
||||||
{ path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound },
|
{ path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound },
|
||||||
|
{
|
||||||
|
path: "/docs",
|
||||||
|
labelKey: "documentation",
|
||||||
|
label: "Documentation",
|
||||||
|
icon: BookOpen,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Plugins can reference any of these by name in their manifest — keeps bundle
|
const ICON_MAP: Record<string, ComponentType<{ className?: string }>> = {
|
||||||
// size sane vs. importing the full lucide-react set.
|
|
||||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
|
||||||
Activity,
|
Activity,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Clock,
|
Clock,
|
||||||
|
|
@ -100,24 +131,15 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
Eye,
|
Eye,
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveIcon(
|
function resolveIcon(name: string): ComponentType<{ className?: string }> {
|
||||||
name: string,
|
|
||||||
): React.ComponentType<{ className?: string }> {
|
|
||||||
return ICON_MAP[name] ?? Puzzle;
|
return ICON_MAP[name] ?? Puzzle;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNavItems(
|
function buildNavItems(builtIn: NavItem[], manifests: PluginManifest[]): NavItem[] {
|
||||||
builtIn: NavItem[],
|
|
||||||
plugins: RegisteredPlugin[],
|
|
||||||
): NavItem[] {
|
|
||||||
const items = [...builtIn];
|
const items = [...builtIn];
|
||||||
|
|
||||||
for (const { manifest } of plugins) {
|
for (const manifest of manifests) {
|
||||||
// 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 `/`.
|
|
||||||
if (manifest.tab.override) continue;
|
if (manifest.tab.override) continue;
|
||||||
// Hidden plugins register their component + slots but skip the nav.
|
|
||||||
if (manifest.tab.hidden) continue;
|
if (manifest.tab.hidden) continue;
|
||||||
|
|
||||||
const pluginItem: NavItem = {
|
const pluginItem: NavItem = {
|
||||||
|
|
@ -145,54 +167,58 @@ function buildNavItems(
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the final route table, letting plugins override built-in pages.
|
function buildRoutes(manifests: PluginManifest[]): Array<{
|
||||||
*
|
key: string;
|
||||||
* Returns (path, Component, key) tuples. Plugins with `tab.override`
|
path: string;
|
||||||
* win over both built-ins and other plugins (last registration wins if
|
element: ReactNode;
|
||||||
* two plugins claim the same override, but we warn in dev). Plugins with
|
}> {
|
||||||
* a regular `tab.path` register alongside built-ins as standalone
|
const byOverride = new Map<string, PluginManifest>();
|
||||||
* routes. */
|
const addons: PluginManifest[] = [];
|
||||||
function buildRoutes(
|
|
||||||
plugins: RegisteredPlugin[],
|
|
||||||
): Array<{ key: string; path: string; Component: React.ComponentType }> {
|
|
||||||
const overrides = new Map<string, RegisteredPlugin>();
|
|
||||||
const addons: RegisteredPlugin[] = [];
|
|
||||||
|
|
||||||
for (const p of plugins) {
|
for (const m of manifests) {
|
||||||
if (p.manifest.tab.override) {
|
if (m.tab.override) {
|
||||||
overrides.set(p.manifest.tab.override, p);
|
byOverride.set(m.tab.override, m);
|
||||||
} else {
|
} else {
|
||||||
addons.push(p);
|
addons.push(m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const routes: Array<{
|
const routes: Array<{
|
||||||
key: string;
|
key: string;
|
||||||
path: string;
|
path: string;
|
||||||
Component: React.ComponentType;
|
element: ReactNode;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (const [path, Component] of Object.entries(BUILTIN_ROUTES)) {
|
for (const [path, Component] of Object.entries(BUILTIN_ROUTES)) {
|
||||||
const override = overrides.get(path);
|
const om = byOverride.get(path);
|
||||||
if (override) {
|
if (om) {
|
||||||
routes.push({
|
routes.push({
|
||||||
key: `override:${override.manifest.name}`,
|
key: `override:${om.name}`,
|
||||||
path,
|
path,
|
||||||
Component: override.component,
|
element: <PluginPage name={om.name} />,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
routes.push({ key: `builtin:${path}`, path, Component });
|
routes.push({ key: `builtin:${path}`, path, element: <Component /> });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const addon of addons) {
|
for (const m of addons) {
|
||||||
// Don't double-register a plugin that shadows a built-in path via
|
if (m.tab.hidden) continue;
|
||||||
// `tab.path` — `override` is the supported mechanism for that.
|
if (BUILTIN_ROUTES[m.tab.path]) continue;
|
||||||
if (BUILTIN_ROUTES[addon.manifest.tab.path]) continue;
|
|
||||||
routes.push({
|
routes.push({
|
||||||
key: `plugin:${addon.manifest.name}`,
|
key: `plugin:${m.name}`,
|
||||||
path: addon.manifest.tab.path,
|
path: m.tab.path,
|
||||||
Component: addon.component,
|
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 +227,125 @@ function buildRoutes(
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { plugins } = usePlugins();
|
const { pathname } = useLocation();
|
||||||
|
const { manifests } = usePlugins();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
||||||
|
const isDocsRoute = pathname === "/docs" || pathname === "/docs/";
|
||||||
|
|
||||||
const navItems = useMemo(
|
const navItems = useMemo(
|
||||||
() => buildNavItems(BUILTIN_NAV, plugins),
|
() => buildNavItems(BUILTIN_NAV, manifests),
|
||||||
[plugins],
|
[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 layoutVariant = theme.layoutVariant ?? "standard";
|
||||||
const showSidebar = layoutVariant === "cockpit";
|
|
||||||
// Tiled layout drops the 1600px clamp so pages can use the full viewport;
|
useEffect(() => {
|
||||||
// standard + cockpit keep the centered reading width.
|
if (!mobileOpen) return;
|
||||||
const mainMaxWidth = layoutVariant === "tiled" ? "max-w-none" : "max-w-[1600px]";
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
data-layout-variant={layoutVariant}
|
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 />
|
<SelectionSwitcher />
|
||||||
<Backdrop />
|
<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" />
|
<PluginSlot name="backdrop" />
|
||||||
|
|
||||||
<header
|
<header
|
||||||
className={cn(
|
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",
|
"border-b border-current/20",
|
||||||
"bg-background-base/90 backdrop-blur-sm",
|
"bg-background-base/90 backdrop-blur-sm",
|
||||||
)}
|
)}
|
||||||
style={{
|
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)",
|
background: "var(--component-header-background)",
|
||||||
borderImage: "var(--component-header-border-image)",
|
borderImage: "var(--component-header-border-image)",
|
||||||
clipPath: "var(--component-header-clip-path)",
|
clipPath: "var(--component-header-clip-path)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={cn("mx-auto flex h-12", mainMaxWidth)}>
|
<button
|
||||||
<PluginSlot name="header-left" />
|
type="button"
|
||||||
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-none">
|
onClick={() => setMobileOpen(true)}
|
||||||
<Grid
|
aria-label={t.app.openNavigation}
|
||||||
className="h-full !border-t-0 !border-b-0"
|
aria-expanded={mobileOpen}
|
||||||
style={{
|
aria-controls="app-sidebar"
|
||||||
gridTemplateColumns: `auto repeat(${navItems.length}, auto)`,
|
className={cn(
|
||||||
}}
|
"inline-flex h-8 w-8 items-center justify-center",
|
||||||
>
|
"text-midground/70 hover:text-midground transition-colors cursor-pointer",
|
||||||
<Cell className="flex items-center !p-0 !px-3 sm:!px-5">
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||||
<Typography
|
)}
|
||||||
className="font-bold text-[1.0625rem] sm:text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
|
>
|
||||||
style={{ mixBlendMode: "plus-lighter" }}
|
<Menu className="h-4 w-4" />
|
||||||
>
|
</button>
|
||||||
Hermes
|
|
||||||
<br />
|
|
||||||
Agent
|
|
||||||
</Typography>
|
|
||||||
</Cell>
|
|
||||||
|
|
||||||
{navItems.map(({ path, label, labelKey, icon: Icon }) => (
|
<Typography
|
||||||
<Cell key={path} className="relative !p-0">
|
className="font-bold text-[0.95rem] leading-[0.95] tracking-[0.05em] text-midground"
|
||||||
<NavLink
|
style={{ mixBlendMode: "plus-lighter" }}
|
||||||
to={path}
|
>
|
||||||
end={path === "/"}
|
{t.app.brand}
|
||||||
className={({ isActive }) =>
|
</Typography>
|
||||||
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>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Full-width banner slot under the nav, outside the main clamp —
|
{mobileOpen && (
|
||||||
useful for marquee/alert/status strips themes want to show
|
<button
|
||||||
above page content. */}
|
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" />
|
<PluginSlot name="header-banner" />
|
||||||
|
|
||||||
<div
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-12 lg:pt-0">
|
||||||
className={cn(
|
<div className="flex min-h-0 min-w-0 flex-1">
|
||||||
"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 && (
|
|
||||||
<aside
|
<aside
|
||||||
|
id="app-sidebar"
|
||||||
|
aria-label={t.app.navigation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-[260px] shrink-0 border-r border-current/20 pr-3 sm:pr-4",
|
"fixed top-0 left-0 z-50 flex h-dvh max-h-dvh w-64 min-h-0 flex-col",
|
||||||
"hidden lg:block",
|
"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={{
|
style={{
|
||||||
background: "var(--component-sidebar-background)",
|
background: "var(--component-sidebar-background)",
|
||||||
|
|
@ -356,75 +353,274 @@ export default function App() {
|
||||||
borderImage: "var(--component-sidebar-border-image)",
|
borderImage: "var(--component-sidebar-border-image)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PluginSlot
|
<div
|
||||||
name="sidebar"
|
className={cn(
|
||||||
fallback={
|
"flex h-14 shrink-0 items-center justify-between gap-2 px-5",
|
||||||
<div className="p-4 text-xs opacity-60 font-mondwest tracking-wide">
|
"border-b border-current/20",
|
||||||
{/* Cockpit layout with no sidebar plugin — rare but valid;
|
)}
|
||||||
the space still exists so the grid doesn't shift when
|
>
|
||||||
a plugin loads asynchronously. */}
|
<Typography
|
||||||
sidebar slot empty
|
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
|
||||||
</div>
|
style={{ mixBlendMode: "plus-lighter" }}
|
||||||
}
|
>
|
||||||
/>
|
Hermes
|
||||||
</aside>
|
<br />
|
||||||
)}
|
Agent
|
||||||
|
</Typography>
|
||||||
|
|
||||||
<main className="min-w-0 flex-1">
|
<button
|
||||||
<PluginSlot name="pre-main" />
|
type="button"
|
||||||
<Routes>
|
onClick={closeMobile}
|
||||||
{routes.map(({ key, path, Component }) => (
|
aria-label={t.app.closeNavigation}
|
||||||
<Route key={key} path={path} element={<Component />} />
|
className={cn(
|
||||||
))}
|
"lg:hidden inline-flex h-7 w-7 items-center justify-center",
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
"text-midground/70 hover:text-midground transition-colors cursor-pointer",
|
||||||
</Routes>
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||||
<PluginSlot name="post-main" />
|
)}
|
||||||
</main>
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PluginSlot name="header-left" />
|
||||||
|
|
||||||
|
<nav
|
||||||
|
className="min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden border-t border-current/10 py-2"
|
||||||
|
aria-label={t.app.navigation}
|
||||||
|
>
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{navItems.map(({ path, label, labelKey, icon: Icon }) => {
|
||||||
|
const navLabel = labelKey
|
||||||
|
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
|
||||||
|
: label;
|
||||||
|
return (
|
||||||
|
<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">{navLabel}</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>
|
||||||
|
|
||||||
|
<SidebarFooter />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<PageHeaderProvider pluginTabs={pluginTabMeta}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative z-2 flex min-w-0 min-h-0 flex-1 flex-col",
|
||||||
|
"px-3 sm:px-6",
|
||||||
|
"pt-2 sm:pt-4 lg:pt-6",
|
||||||
|
"pb-4 sm:pb-8",
|
||||||
|
isDocsRoute && "min-h-0 flex-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PluginSlot name="pre-main" />
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full min-w-0",
|
||||||
|
isDocsRoute && "min-h-0 flex flex-1 flex-col",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
</PageHeaderProvider>
|
||||||
|
</div>
|
||||||
</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" />
|
<PluginSlot name="overlay" />
|
||||||
</div>
|
</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 {
|
interface NavItem {
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: ComponentType<{ className?: string }>;
|
||||||
label: string;
|
label: string;
|
||||||
labelKey?: string;
|
labelKey?: string;
|
||||||
path: 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
|
* glow) so users can preview the palette before committing. User-defined
|
||||||
* themes from `~/.hermes/dashboard-themes/*.yaml` that aren't in
|
* themes from `~/.hermes/dashboard-themes/*.yaml` that aren't in
|
||||||
* `BUILTIN_THEMES` render without swatches and apply the default palette.
|
* `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 { themeName, availableThemes, setTheme } = useTheme();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
@ -73,7 +77,8 @@ export function ThemeSwitcher() {
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label={t.theme?.title ?? "Theme"}
|
aria-label={t.theme?.title ?? "Theme"}
|
||||||
className={cn(
|
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",
|
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
|
||||||
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
|
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
|
||||||
)}
|
)}
|
||||||
|
|
@ -166,3 +171,7 @@ function PlaceholderSwatch() {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ThemeSwitcherProps {
|
||||||
|
dropUp?: boolean;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
export const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-mondwest text-xs tracking-[0.1em] uppercase transition-colors cursor-pointer"
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-mondwest text-xs tracking-[0.1em] uppercase transition-colors cursor-pointer"
|
||||||
+ " disabled:pointer-events-none disabled:opacity-50",
|
+ " disabled:pointer-events-none disabled:opacity-50",
|
||||||
{
|
{
|
||||||
|
|
|
||||||
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,
|
onCheckedChange,
|
||||||
className,
|
className,
|
||||||
disabled,
|
disabled,
|
||||||
|
id,
|
||||||
}: {
|
}: {
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onCheckedChange: (v: boolean) => void;
|
onCheckedChange: (v: boolean) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
id?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
id={id}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={checked}
|
aria-checked={checked}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
|
||||||
95
web/src/contexts/PageHeaderProvider.tsx
Normal file
95
web/src/contexts/PageHeaderProvider.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
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 w-full shrink-0",
|
||||||
|
"box-border h-14 min-h-14",
|
||||||
|
"border-b border-current/20",
|
||||||
|
"bg-background-base/40 backdrop-blur-sm",
|
||||||
|
"overflow-hidden",
|
||||||
|
"sm:min-h-0",
|
||||||
|
)}
|
||||||
|
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:flex-row sm:items-center sm:gap-3 sm:px-6 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={cn(
|
||||||
|
"min-h-0 w-full min-w-0 flex-1 flex flex-col",
|
||||||
|
"overflow-y-auto overflow-x-hidden [scrollbar-gutter:stable]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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...",
|
saving: "Saving...",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
close: "Close",
|
close: "Close",
|
||||||
|
confirm: "Confirm",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
refresh: "Refresh",
|
refresh: "Refresh",
|
||||||
retry: "Retry",
|
retry: "Retry",
|
||||||
|
|
@ -42,26 +43,45 @@ export const en: Translations = {
|
||||||
expand: "Expand",
|
expand: "Expand",
|
||||||
general: "General",
|
general: "General",
|
||||||
messaging: "Messaging",
|
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: {
|
app: {
|
||||||
brand: "Hermes Agent",
|
brand: "Hermes Agent",
|
||||||
brandShort: "HA",
|
brandShort: "HA",
|
||||||
webUi: "Web UI",
|
closeNavigation: "Close navigation",
|
||||||
footer: {
|
footer: {
|
||||||
name: "Hermes Agent",
|
|
||||||
org: "Nous Research",
|
org: "Nous Research",
|
||||||
},
|
},
|
||||||
nav: {
|
activeSessionsLabel: "Active Sessions:",
|
||||||
status: "Status",
|
gatewayStatusLabel: "Gateway Status:",
|
||||||
sessions: "Sessions",
|
gatewayStrip: {
|
||||||
analytics: "Analytics",
|
failed: "Start failed",
|
||||||
logs: "Logs",
|
off: "Off",
|
||||||
cron: "Cron",
|
running: "Running",
|
||||||
skills: "Skills",
|
starting: "Starting",
|
||||||
config: "Config",
|
stopped: "Stopped",
|
||||||
keys: "Keys",
|
|
||||||
},
|
},
|
||||||
|
nav: {
|
||||||
|
analytics: "Analytics",
|
||||||
|
config: "Config",
|
||||||
|
cron: "Cron",
|
||||||
|
documentation: "Documentation",
|
||||||
|
keys: "Keys",
|
||||||
|
logs: "Logs",
|
||||||
|
sessions: "Sessions",
|
||||||
|
skills: "Skills",
|
||||||
|
},
|
||||||
|
navigation: "Navigation",
|
||||||
|
openDocumentation: "Open documentation in a new tab",
|
||||||
|
openNavigation: "Open navigation",
|
||||||
|
sessionsActiveCount: "{count} active",
|
||||||
|
statusOverview: "Status overview",
|
||||||
|
system: "System",
|
||||||
|
webUi: "Web UI",
|
||||||
},
|
},
|
||||||
|
|
||||||
status: {
|
status: {
|
||||||
|
|
@ -106,6 +126,11 @@ export const en: Translations = {
|
||||||
noMessages: "No messages",
|
noMessages: "No messages",
|
||||||
untitledSession: "Untitled session",
|
untitledSession: "Untitled session",
|
||||||
deleteSession: "Delete 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",
|
previousPage: "Previous page",
|
||||||
nextPage: "Next page",
|
nextPage: "Next page",
|
||||||
roles: {
|
roles: {
|
||||||
|
|
@ -153,6 +178,9 @@ export const en: Translations = {
|
||||||
},
|
},
|
||||||
|
|
||||||
cron: {
|
cron: {
|
||||||
|
confirmDeleteMessage:
|
||||||
|
"This removes the job from the schedule. This cannot be undone.",
|
||||||
|
confirmDeleteTitle: "Delete scheduled job?",
|
||||||
newJob: "New Cron Job",
|
newJob: "New Cron Job",
|
||||||
nameOptional: "Name (optional)",
|
nameOptional: "Name (optional)",
|
||||||
namePlaceholder: "e.g. Daily summary",
|
namePlaceholder: "e.g. Daily summary",
|
||||||
|
|
@ -182,6 +210,8 @@ export const en: Translations = {
|
||||||
searchPlaceholder: "Search skills and toolsets...",
|
searchPlaceholder: "Search skills and toolsets...",
|
||||||
enabledOf: "{enabled}/{total} enabled",
|
enabledOf: "{enabled}/{total} enabled",
|
||||||
all: "All",
|
all: "All",
|
||||||
|
categories: "Categories",
|
||||||
|
filters: "Filters",
|
||||||
noSkills: "No skills found. Skills are loaded from ~/.hermes/skills/",
|
noSkills: "No skills found. Skills are loaded from ~/.hermes/skills/",
|
||||||
noSkillsMatch: "No skills match your search or filter.",
|
noSkillsMatch: "No skills match your search or filter.",
|
||||||
skillCount: "{count} skill{s}",
|
skillCount: "{count} skill{s}",
|
||||||
|
|
@ -197,6 +227,8 @@ export const en: Translations = {
|
||||||
|
|
||||||
config: {
|
config: {
|
||||||
configPath: "~/.hermes/config.yaml",
|
configPath: "~/.hermes/config.yaml",
|
||||||
|
filters: "Filters",
|
||||||
|
sections: "Sections",
|
||||||
exportConfig: "Export config as JSON",
|
exportConfig: "Export config as JSON",
|
||||||
importConfig: "Import config from JSON",
|
importConfig: "Import config from JSON",
|
||||||
resetDefaults: "Reset to defaults",
|
resetDefaults: "Reset to defaults",
|
||||||
|
|
@ -231,8 +263,11 @@ export const en: Translations = {
|
||||||
},
|
},
|
||||||
|
|
||||||
env: {
|
env: {
|
||||||
description: "Manage API keys and secrets stored in",
|
|
||||||
changesNote: "Changes are saved to disk immediately. Active sessions pick up new keys automatically.",
|
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",
|
hideAdvanced: "Hide Advanced",
|
||||||
showAdvanced: "Show Advanced",
|
showAdvanced: "Show Advanced",
|
||||||
llmProviders: "LLM Providers",
|
llmProviders: "LLM Providers",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export interface Translations {
|
||||||
saving: string;
|
saving: string;
|
||||||
cancel: string;
|
cancel: string;
|
||||||
close: string;
|
close: string;
|
||||||
|
confirm: string;
|
||||||
delete: string;
|
delete: string;
|
||||||
refresh: string;
|
refresh: string;
|
||||||
retry: string;
|
retry: string;
|
||||||
|
|
@ -43,27 +44,44 @@ export interface Translations {
|
||||||
expand: string;
|
expand: string;
|
||||||
general: string;
|
general: string;
|
||||||
messaging: string;
|
messaging: string;
|
||||||
|
pluginLoadFailed: string;
|
||||||
|
pluginNotRegistered: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── App shell ──
|
// ── App shell ──
|
||||||
app: {
|
app: {
|
||||||
brand: string;
|
brand: string;
|
||||||
brandShort: string;
|
brandShort: string;
|
||||||
webUi: string;
|
closeNavigation: string;
|
||||||
footer: {
|
footer: {
|
||||||
name: string;
|
|
||||||
org: string;
|
org: string;
|
||||||
};
|
};
|
||||||
nav: {
|
activeSessionsLabel: string;
|
||||||
status: string;
|
gatewayStatusLabel: string;
|
||||||
sessions: string;
|
gatewayStrip: {
|
||||||
analytics: string;
|
failed: string;
|
||||||
logs: string;
|
off: string;
|
||||||
cron: string;
|
running: string;
|
||||||
skills: string;
|
starting: string;
|
||||||
config: string;
|
stopped: string;
|
||||||
keys: string;
|
|
||||||
};
|
};
|
||||||
|
nav: {
|
||||||
|
analytics: string;
|
||||||
|
config: string;
|
||||||
|
cron: string;
|
||||||
|
documentation: string;
|
||||||
|
keys: string;
|
||||||
|
logs: string;
|
||||||
|
sessions: string;
|
||||||
|
skills: string;
|
||||||
|
};
|
||||||
|
navigation: string;
|
||||||
|
openDocumentation: string;
|
||||||
|
openNavigation: string;
|
||||||
|
sessionsActiveCount: string;
|
||||||
|
statusOverview: string;
|
||||||
|
system: string;
|
||||||
|
webUi: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Status page ──
|
// ── Status page ──
|
||||||
|
|
@ -110,6 +128,10 @@ export interface Translations {
|
||||||
noMessages: string;
|
noMessages: string;
|
||||||
untitledSession: string;
|
untitledSession: string;
|
||||||
deleteSession: string;
|
deleteSession: string;
|
||||||
|
confirmDeleteTitle: string;
|
||||||
|
confirmDeleteMessage: string;
|
||||||
|
sessionDeleted: string;
|
||||||
|
failedToDelete: string;
|
||||||
previousPage: string;
|
previousPage: string;
|
||||||
nextPage: string;
|
nextPage: string;
|
||||||
roles: {
|
roles: {
|
||||||
|
|
@ -160,6 +182,8 @@ export interface Translations {
|
||||||
|
|
||||||
// ── Cron page ──
|
// ── Cron page ──
|
||||||
cron: {
|
cron: {
|
||||||
|
confirmDeleteMessage: string;
|
||||||
|
confirmDeleteTitle: string;
|
||||||
newJob: string;
|
newJob: string;
|
||||||
nameOptional: string;
|
nameOptional: string;
|
||||||
namePlaceholder: string;
|
namePlaceholder: string;
|
||||||
|
|
@ -190,6 +214,8 @@ export interface Translations {
|
||||||
searchPlaceholder: string;
|
searchPlaceholder: string;
|
||||||
enabledOf: string;
|
enabledOf: string;
|
||||||
all: string;
|
all: string;
|
||||||
|
categories: string;
|
||||||
|
filters: string;
|
||||||
noSkills: string;
|
noSkills: string;
|
||||||
noSkillsMatch: string;
|
noSkillsMatch: string;
|
||||||
skillCount: string;
|
skillCount: string;
|
||||||
|
|
@ -206,6 +232,8 @@ export interface Translations {
|
||||||
// ── Config page ──
|
// ── Config page ──
|
||||||
config: {
|
config: {
|
||||||
configPath: string;
|
configPath: string;
|
||||||
|
filters: string;
|
||||||
|
sections: string;
|
||||||
exportConfig: string;
|
exportConfig: string;
|
||||||
importConfig: string;
|
importConfig: string;
|
||||||
resetDefaults: string;
|
resetDefaults: string;
|
||||||
|
|
@ -241,20 +269,22 @@ export interface Translations {
|
||||||
|
|
||||||
// ── Env / Keys page ──
|
// ── Env / Keys page ──
|
||||||
env: {
|
env: {
|
||||||
description: string;
|
|
||||||
changesNote: string;
|
changesNote: string;
|
||||||
hideAdvanced: string;
|
confirmClearMessage: string;
|
||||||
showAdvanced: string;
|
confirmClearTitle: string;
|
||||||
llmProviders: string;
|
description: string;
|
||||||
providersConfigured: string;
|
enterValue: string;
|
||||||
getKey: string;
|
getKey: string;
|
||||||
|
hideAdvanced: string;
|
||||||
|
hideValue: string;
|
||||||
|
keysCount: string;
|
||||||
|
llmProviders: string;
|
||||||
notConfigured: string;
|
notConfigured: string;
|
||||||
notSet: string;
|
notSet: string;
|
||||||
keysCount: string;
|
providersConfigured: string;
|
||||||
enterValue: string;
|
|
||||||
replaceCurrentValue: string;
|
replaceCurrentValue: string;
|
||||||
|
showAdvanced: string;
|
||||||
showValue: string;
|
showValue: string;
|
||||||
hideValue: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── OAuth ──
|
// ── OAuth ──
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export const zh: Translations = {
|
||||||
saving: "保存中...",
|
saving: "保存中...",
|
||||||
cancel: "取消",
|
cancel: "取消",
|
||||||
close: "关闭",
|
close: "关闭",
|
||||||
|
confirm: "确认",
|
||||||
delete: "删除",
|
delete: "删除",
|
||||||
refresh: "刷新",
|
refresh: "刷新",
|
||||||
retry: "重试",
|
retry: "重试",
|
||||||
|
|
@ -42,26 +43,44 @@ export const zh: Translations = {
|
||||||
expand: "展开",
|
expand: "展开",
|
||||||
general: "通用",
|
general: "通用",
|
||||||
messaging: "消息平台",
|
messaging: "消息平台",
|
||||||
|
pluginLoadFailed:
|
||||||
|
"无法加载此插件的脚本。请检查网络请求(dashboard-plugins/…)以及服务器上的插件路径。",
|
||||||
|
pluginNotRegistered: "插件脚本未调用 register(),或执行出错。请打开浏览器控制台查看详情。",
|
||||||
},
|
},
|
||||||
|
|
||||||
app: {
|
app: {
|
||||||
brand: "Hermes Agent",
|
brand: "Hermes Agent",
|
||||||
brandShort: "HA",
|
brandShort: "HA",
|
||||||
webUi: "管理面板",
|
closeNavigation: "关闭导航",
|
||||||
footer: {
|
footer: {
|
||||||
name: "Hermes Agent",
|
|
||||||
org: "Nous Research",
|
org: "Nous Research",
|
||||||
},
|
},
|
||||||
nav: {
|
activeSessionsLabel: "活跃会话:",
|
||||||
status: "状态",
|
gatewayStatusLabel: "网关状态:",
|
||||||
sessions: "会话",
|
gatewayStrip: {
|
||||||
analytics: "分析",
|
failed: "启动失败",
|
||||||
logs: "日志",
|
off: "关闭",
|
||||||
cron: "定时任务",
|
running: "运行中",
|
||||||
skills: "技能",
|
starting: "启动中",
|
||||||
config: "配置",
|
stopped: "已停止",
|
||||||
keys: "密钥",
|
|
||||||
},
|
},
|
||||||
|
nav: {
|
||||||
|
analytics: "分析",
|
||||||
|
config: "配置",
|
||||||
|
cron: "定时任务",
|
||||||
|
documentation: "文档",
|
||||||
|
keys: "密钥",
|
||||||
|
logs: "日志",
|
||||||
|
sessions: "会话",
|
||||||
|
skills: "技能",
|
||||||
|
},
|
||||||
|
navigation: "导航",
|
||||||
|
openDocumentation: "在新标签页中打开文档",
|
||||||
|
openNavigation: "打开导航",
|
||||||
|
sessionsActiveCount: "{count} 个活跃",
|
||||||
|
statusOverview: "状态概览",
|
||||||
|
system: "系统",
|
||||||
|
webUi: "管理面板",
|
||||||
},
|
},
|
||||||
|
|
||||||
status: {
|
status: {
|
||||||
|
|
@ -106,6 +125,10 @@ export const zh: Translations = {
|
||||||
noMessages: "暂无消息",
|
noMessages: "暂无消息",
|
||||||
untitledSession: "无标题会话",
|
untitledSession: "无标题会话",
|
||||||
deleteSession: "删除会话",
|
deleteSession: "删除会话",
|
||||||
|
confirmDeleteTitle: "删除会话?",
|
||||||
|
confirmDeleteMessage: "此操作将永久删除对话及其所有消息,无法恢复。",
|
||||||
|
sessionDeleted: "会话已删除",
|
||||||
|
failedToDelete: "删除会话失败",
|
||||||
previousPage: "上一页",
|
previousPage: "上一页",
|
||||||
nextPage: "下一页",
|
nextPage: "下一页",
|
||||||
roles: {
|
roles: {
|
||||||
|
|
@ -153,6 +176,8 @@ export const zh: Translations = {
|
||||||
},
|
},
|
||||||
|
|
||||||
cron: {
|
cron: {
|
||||||
|
confirmDeleteMessage: "将从此计划移除该任务,此操作无法撤销。",
|
||||||
|
confirmDeleteTitle: "删除定时任务?",
|
||||||
newJob: "新建定时任务",
|
newJob: "新建定时任务",
|
||||||
nameOptional: "名称(可选)",
|
nameOptional: "名称(可选)",
|
||||||
namePlaceholder: "例如:每日总结",
|
namePlaceholder: "例如:每日总结",
|
||||||
|
|
@ -182,6 +207,8 @@ export const zh: Translations = {
|
||||||
searchPlaceholder: "搜索技能和工具集...",
|
searchPlaceholder: "搜索技能和工具集...",
|
||||||
enabledOf: "已启用 {enabled}/{total}",
|
enabledOf: "已启用 {enabled}/{total}",
|
||||||
all: "全部",
|
all: "全部",
|
||||||
|
categories: "分类",
|
||||||
|
filters: "筛选",
|
||||||
noSkills: "未找到技能。技能从 ~/.hermes/skills/ 加载",
|
noSkills: "未找到技能。技能从 ~/.hermes/skills/ 加载",
|
||||||
noSkillsMatch: "没有匹配的技能。",
|
noSkillsMatch: "没有匹配的技能。",
|
||||||
skillCount: "{count} 个技能",
|
skillCount: "{count} 个技能",
|
||||||
|
|
@ -197,6 +224,8 @@ export const zh: Translations = {
|
||||||
|
|
||||||
config: {
|
config: {
|
||||||
configPath: "~/.hermes/config.yaml",
|
configPath: "~/.hermes/config.yaml",
|
||||||
|
filters: "筛选",
|
||||||
|
sections: "分类",
|
||||||
exportConfig: "导出配置为 JSON",
|
exportConfig: "导出配置为 JSON",
|
||||||
importConfig: "从 JSON 导入配置",
|
importConfig: "从 JSON 导入配置",
|
||||||
resetDefaults: "恢复默认值",
|
resetDefaults: "恢复默认值",
|
||||||
|
|
@ -231,8 +260,10 @@ export const zh: Translations = {
|
||||||
},
|
},
|
||||||
|
|
||||||
env: {
|
env: {
|
||||||
description: "管理存储在以下位置的 API 密钥和凭据",
|
|
||||||
changesNote: "更改会立即保存到磁盘。活跃会话将自动获取新密钥。",
|
changesNote: "更改会立即保存到磁盘。活跃会话将自动获取新密钥。",
|
||||||
|
confirmClearMessage: "该变量的已存值将从 .env 文件中删除。无法在此界面撤销。",
|
||||||
|
confirmClearTitle: "清除此密钥?",
|
||||||
|
description: "管理存储在以下位置的 API 密钥和凭据",
|
||||||
hideAdvanced: "隐藏高级选项",
|
hideAdvanced: "隐藏高级选项",
|
||||||
showAdvanced: "显示高级选项",
|
showAdvanced: "显示高级选项",
|
||||||
llmProviders: "LLM 提供商",
|
llmProviders: "LLM 提供商",
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,17 @@ html {
|
||||||
font-size: var(--theme-base-size);
|
font-size: var(--theme-base-size);
|
||||||
line-height: var(--theme-line-height);
|
line-height: var(--theme-line-height);
|
||||||
letter-spacing: var(--theme-letter-spacing);
|
letter-spacing: var(--theme-letter-spacing);
|
||||||
|
height: 100dvh;
|
||||||
|
max-height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--theme-font-sans);
|
font-family: var(--theme-font-sans);
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
code, kbd, pre, samp, .font-mono, .font-mono-ui {
|
code, kbd, pre, samp, .font-mono, .font-mono-ui {
|
||||||
|
|
@ -73,6 +80,13 @@ code, kbd, pre, samp, .font-mono, .font-mono-ui {
|
||||||
--spacing: calc(0.25rem * var(--theme-spacing-mul, 1));
|
--spacing: calc(0.25rem * var(--theme-spacing-mul, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* Nousnet's hermes-agent layout bumps `small` and `code` to readable
|
/* Nousnet's hermes-agent layout bumps `small` and `code` to readable
|
||||||
dashboard sizes. Keep in sync. */
|
dashboard sizes. Keep in sync. */
|
||||||
small { font-size: 1.0625rem; }
|
small { font-size: 1.0625rem; }
|
||||||
|
|
@ -125,6 +139,16 @@ code { font-size: 0.875rem; }
|
||||||
to { opacity: 0; transform: translateX(16px); }
|
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. */
|
/* Hide scrollbar utility — used by the header's overflow-x nav row. */
|
||||||
.scrollbar-none {
|
.scrollbar-none {
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
|
|
|
||||||
|
|
@ -513,7 +513,12 @@ export interface PluginManifestResponse {
|
||||||
description: string;
|
description: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
version: string;
|
version: string;
|
||||||
tab: { path: string; position: string };
|
tab: {
|
||||||
|
path: string;
|
||||||
|
position?: string;
|
||||||
|
override?: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
};
|
||||||
entry: string;
|
entry: string;
|
||||||
css?: string | null;
|
css?: string | null;
|
||||||
has_api: boolean;
|
has_api: boolean;
|
||||||
|
|
|
||||||
32
web/src/lib/resolve-page-title.ts
Normal file
32
web/src/lib/resolve-page-title.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
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",
|
||||||
|
"/docs": "documentation",
|
||||||
|
};
|
||||||
|
|
||||||
|
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 { BrowserRouter } from "react-router-dom";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { SystemActionsProvider } from "./contexts/SystemActions";
|
||||||
import { I18nProvider } from "./i18n";
|
import { I18nProvider } from "./i18n";
|
||||||
import { exposePluginSDK } from "./plugins";
|
import { exposePluginSDK } from "./plugins";
|
||||||
import { ThemeProvider } from "./themes";
|
import { ThemeProvider } from "./themes";
|
||||||
|
|
@ -14,7 +15,9 @@ createRoot(document.getElementById("root")!).render(
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<App />
|
<SystemActionsProvider>
|
||||||
|
<App />
|
||||||
|
</SystemActionsProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</BrowserRouter>,
|
</BrowserRouter>,
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Brain,
|
Brain,
|
||||||
Cpu,
|
Cpu,
|
||||||
Hash,
|
Hash,
|
||||||
|
RefreshCw,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry, AnalyticsSkillEntry } from "@/lib/api";
|
import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry, AnalyticsSkillEntry } from "@/lib/api";
|
||||||
import { timeAgo } from "@/lib/utils";
|
import { timeAgo } from "@/lib/utils";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
|
|
||||||
const PERIODS = [
|
const PERIODS = [
|
||||||
|
|
@ -281,6 +284,7 @@ export default function AnalyticsPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { setAfterTitle, setEnd } = usePageHeader();
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -292,28 +296,60 @@ export default function AnalyticsPage() {
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [days]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<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 && (
|
{loading && !data && (
|
||||||
<div className="flex items-center justify-center py-24">
|
<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 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 {
|
import {
|
||||||
Code,
|
Code,
|
||||||
Download,
|
Download,
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
Search,
|
Search,
|
||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
ChevronRight,
|
|
||||||
Settings2,
|
Settings2,
|
||||||
FileText,
|
FileText,
|
||||||
Settings,
|
Settings,
|
||||||
|
|
@ -27,6 +26,7 @@ import {
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Wrench,
|
Wrench,
|
||||||
FileQuestion,
|
FileQuestion,
|
||||||
|
Filter,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { getNestedValue, setNestedValue } from "@/lib/nested";
|
import { getNestedValue, setNestedValue } from "@/lib/nested";
|
||||||
|
|
@ -38,6 +38,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
|
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Helpers */
|
/* Helpers */
|
||||||
|
|
@ -85,6 +86,35 @@ export default function ConfigPage() {
|
||||||
const { toast, showToast } = useToast();
|
const { toast, showToast } = useToast();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { t } = useI18n();
|
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 {
|
function prettyCategoryName(cat: string): string {
|
||||||
const key = cat as keyof typeof t.config.categories;
|
const key = cat as keyof typeof t.config.categories;
|
||||||
|
|
@ -366,62 +396,66 @@ export default function ConfigPage() {
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
/* ═══════════════ Form Mode ═══════════════ */
|
/* ═══════════════ Form Mode ═══════════════ */
|
||||||
<div className="flex flex-col sm:flex-row gap-4" style={{ minHeight: "calc(100vh - 180px)" }}>
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
{/* ---- Sidebar — horizontal scroll on mobile, fixed column on sm+ ---- */}
|
{/* ---- Filter panel ---- */}
|
||||||
<div className="sm:w-52 sm:shrink-0">
|
<aside aria-label={t.config.filters} className="sm:w-56 sm:shrink-0">
|
||||||
<div className="sm:sticky sm:top-[72px] flex flex-col gap-1">
|
<div className="sm:sticky sm:top-4">
|
||||||
{/* Search */}
|
<div className="flex flex-col border border-border bg-muted/20">
|
||||||
<div className="relative mb-2 hidden sm:block">
|
{/* Panel heading */}
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||||
<Input
|
<Filter className="h-3 w-3 text-muted-foreground" />
|
||||||
className="pl-8 h-8 text-xs"
|
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
|
||||||
placeholder={t.common.search}
|
{t.config.filters}
|
||||||
value={searchQuery}
|
</span>
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
</div>
|
||||||
/>
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* Category nav — horizontal scroll on mobile */}
|
{/* Sections heading (hidden on mobile since it becomes a horizontal scroll) */}
|
||||||
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0">
|
<div className="hidden sm:block px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
|
||||||
{categories.map((cat) => {
|
{t.config.sections}
|
||||||
const isActive = !isSearching && activeCategory === cat;
|
</div>
|
||||||
return (
|
|
||||||
<button
|
{/* Category nav — horizontal scroll on mobile, pill list on sm+ */}
|
||||||
key={cat}
|
<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">
|
||||||
type="button"
|
{categories.map((cat) => {
|
||||||
onClick={() => {
|
const isActive = !isSearching && activeCategory === cat;
|
||||||
setSearchQuery("");
|
|
||||||
setActiveCategory(cat);
|
return (
|
||||||
}}
|
<button
|
||||||
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
|
key={cat}
|
||||||
isActive
|
type="button"
|
||||||
? "bg-primary/10 text-primary font-medium"
|
onClick={() => {
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
setSearchQuery("");
|
||||||
}`}
|
setActiveCategory(cat);
|
||||||
>
|
}}
|
||||||
<CategoryIcon category={cat} className="h-3.5 w-3.5 shrink-0" />
|
className={`
|
||||||
<span className="flex-1 truncate">{prettyCategoryName(cat)}</span>
|
group flex items-center gap-2 px-2 py-1
|
||||||
<span className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}>
|
rounded-sm text-left text-[11px] cursor-pointer whitespace-nowrap
|
||||||
{categoryCounts[cat] || 0}
|
transition-colors
|
||||||
</span>
|
${
|
||||||
{isActive && (
|
isActive
|
||||||
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
|
? "bg-foreground/10 text-foreground"
|
||||||
)}
|
: "text-muted-foreground hover:text-foreground hover:bg-foreground/5"
|
||||||
</button>
|
}
|
||||||
);
|
`}
|
||||||
})}
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
|
|
||||||
{/* ---- Content ---- */}
|
{/* ---- Content ---- */}
|
||||||
<div className="flex-1 min-w-0">
|
<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 { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
|
||||||
import { H2 } from "@nous-research/ui";
|
import { H2 } from "@nous-research/ui";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { CronJob } from "@/lib/api";
|
import type { CronJob } from "@/lib/api";
|
||||||
|
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||||
import { useToast } from "@/hooks/useToast";
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
||||||
import { Toast } from "@/components/Toast";
|
import { Toast } from "@/components/Toast";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -40,17 +42,17 @@ export default function CronPage() {
|
||||||
const [deliver, setDeliver] = useState("local");
|
const [deliver, setDeliver] = useState("local");
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
const loadJobs = () => {
|
const loadJobs = useCallback(() => {
|
||||||
api
|
api
|
||||||
.getCronJobs()
|
.getCronJobs()
|
||||||
.then(setJobs)
|
.then(setJobs)
|
||||||
.catch(() => showToast(t.common.loading, "error"))
|
.catch(() => showToast(t.common.loading, "error"))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
}, [showToast, t.common.loading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadJobs();
|
loadJobs();
|
||||||
}, []);
|
}, [loadJobs]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!prompt.trim() || !schedule.trim()) {
|
if (!prompt.trim() || !schedule.trim()) {
|
||||||
|
|
@ -113,18 +115,25 @@ export default function CronPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (job: CronJob) => {
|
const jobDelete = useConfirmDelete({
|
||||||
try {
|
onDelete: useCallback(
|
||||||
await api.deleteCronJob(job.id);
|
async (id: string) => {
|
||||||
showToast(
|
const job = jobs.find((j) => j.id === id);
|
||||||
`${t.common.delete}: "${job.name || job.prompt.slice(0, 30)}"`,
|
try {
|
||||||
"success",
|
await api.deleteCronJob(id);
|
||||||
);
|
showToast(
|
||||||
loadJobs();
|
`${t.common.delete}: "${job?.name || (job?.prompt ?? "").slice(0, 30) || id}"`,
|
||||||
} catch (e) {
|
"success",
|
||||||
showToast(`${t.status.error}: ${e}`, "error");
|
);
|
||||||
}
|
loadJobs();
|
||||||
};
|
} catch (e) {
|
||||||
|
showToast(`${t.status.error}: ${e}`, "error");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[jobs, loadJobs, showToast, t.common.delete, t.status.error],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -134,10 +143,27 @@ export default function CronPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pendingJob = jobDelete.pendingId
|
||||||
|
? jobs.find((j) => j.id === jobDelete.pendingId)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<Toast toast={toast} />
|
<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 */}
|
{/* Create new job form */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -311,7 +337,7 @@ export default function CronPage() {
|
||||||
size="icon"
|
size="icon"
|
||||||
title={t.common.delete}
|
title={t.common.delete}
|
||||||
aria-label={t.common.delete}
|
aria-label={t.common.delete}
|
||||||
onClick={() => handleDelete(job)}
|
onClick={() => jobDelete.requestDelete(job.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
54
web/src/pages/DocsPage.tsx
Normal file
54
web/src/pages/DocsPage.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { useLayoutEffect } from "react";
|
||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import { useI18n } from "@/i18n";
|
||||||
|
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const HERMES_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/";
|
||||||
|
|
||||||
|
export default function DocsPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { setEnd } = usePageHeader();
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setEnd(
|
||||||
|
<a
|
||||||
|
href={HERMES_DOCS_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline", size: "sm" }),
|
||||||
|
"h-7 text-xs",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ExternalLink className="mr-1.5 h-3 w-3" />
|
||||||
|
{t.app.openDocumentation}
|
||||||
|
</a>,
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
setEnd(null);
|
||||||
|
};
|
||||||
|
}, [setEnd, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 w-full min-w-0 flex-1 flex-col",
|
||||||
|
"-mx-3 sm:-mx-6",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
title={t.app.nav.documentation}
|
||||||
|
src={HERMES_DOCS_URL}
|
||||||
|
className={cn(
|
||||||
|
"min-h-0 w-full min-w-0 flex-1",
|
||||||
|
"rounded-sm border border-current/20",
|
||||||
|
"bg-background",
|
||||||
|
)}
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
|
||||||
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
|
@ -16,8 +16,10 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { EnvVarInfo } 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 { Toast } from "@/components/Toast";
|
||||||
|
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
||||||
|
import { useToast } from "@/hooks/useToast";
|
||||||
import { OAuthProvidersCard } from "@/components/OAuthProvidersCard";
|
import { OAuthProvidersCard } from "@/components/OAuthProvidersCard";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -95,6 +97,7 @@ function EnvVarRow({
|
||||||
onClear,
|
onClear,
|
||||||
onReveal,
|
onReveal,
|
||||||
onCancelEdit,
|
onCancelEdit,
|
||||||
|
clearDialogOpen = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
varKey: string;
|
varKey: string;
|
||||||
|
|
@ -107,6 +110,7 @@ function EnvVarRow({
|
||||||
onClear: (key: string) => void;
|
onClear: (key: string) => void;
|
||||||
onReveal: (key: string) => void;
|
onReveal: (key: string) => void;
|
||||||
onCancelEdit: (key: string) => void;
|
onCancelEdit: (key: string) => void;
|
||||||
|
clearDialogOpen?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
@ -219,7 +223,7 @@ function EnvVarRow({
|
||||||
{info.is_set && (
|
{info.is_set && (
|
||||||
<Button size="sm" variant="ghost"
|
<Button size="sm" variant="ghost"
|
||||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
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" />
|
<Trash2 className="h-3 w-3" />
|
||||||
{saving === varKey ? "..." : t.common.clear}
|
{saving === varKey ? "..." : t.common.clear}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -261,6 +265,7 @@ function ProviderGroupCard({
|
||||||
onClear,
|
onClear,
|
||||||
onReveal,
|
onReveal,
|
||||||
onCancelEdit,
|
onCancelEdit,
|
||||||
|
clearDialogOpen = false,
|
||||||
}: {
|
}: {
|
||||||
group: ProviderGroup;
|
group: ProviderGroup;
|
||||||
edits: Record<string, string>;
|
edits: Record<string, string>;
|
||||||
|
|
@ -271,6 +276,7 @@ function ProviderGroupCard({
|
||||||
onClear: (key: string) => void;
|
onClear: (key: string) => void;
|
||||||
onReveal: (key: string) => void;
|
onReveal: (key: string) => void;
|
||||||
onCancelEdit: (key: string) => void;
|
onCancelEdit: (key: string) => void;
|
||||||
|
clearDialogOpen?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
@ -325,6 +331,7 @@ function ProviderGroupCard({
|
||||||
key={key} varKey={key} info={info} compact
|
key={key} varKey={key} info={info} compact
|
||||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||||
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||||
|
clearDialogOpen={clearDialogOpen}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{/* Base URLs (secondary) */}
|
{/* Base URLs (secondary) */}
|
||||||
|
|
@ -333,6 +340,7 @@ function ProviderGroupCard({
|
||||||
key={key} varKey={key} info={info} compact
|
key={key} varKey={key} info={info} compact
|
||||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||||
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||||
|
clearDialogOpen={clearDialogOpen}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{/* Anything else */}
|
{/* Anything else */}
|
||||||
|
|
@ -341,6 +349,7 @@ function ProviderGroupCard({
|
||||||
key={key} varKey={key} info={info} compact
|
key={key} varKey={key} info={info} compact
|
||||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||||
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||||
|
clearDialogOpen={clearDialogOpen}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -390,24 +399,30 @@ export default function EnvPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClear = async (key: string) => {
|
const keyClear = useConfirmDelete({
|
||||||
setSaving(key);
|
onDelete: useCallback(
|
||||||
try {
|
async (key: string) => {
|
||||||
await api.deleteEnvVar(key);
|
setSaving(key);
|
||||||
setVars((prev) =>
|
try {
|
||||||
prev
|
await api.deleteEnvVar(key);
|
||||||
? { ...prev, [key]: { ...prev[key], is_set: false, redacted_value: null } }
|
setVars((prev) =>
|
||||||
: prev,
|
prev
|
||||||
);
|
? { ...prev, [key]: { ...prev[key], is_set: false, redacted_value: null } }
|
||||||
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
: prev,
|
||||||
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
);
|
||||||
showToast(`${key} ${t.common.removed}`, "success");
|
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||||
} catch (e) {
|
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||||
showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error");
|
showToast(`${key} ${t.common.removed}`, "success");
|
||||||
} finally {
|
} catch (e) {
|
||||||
setSaving(null);
|
showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error");
|
||||||
}
|
throw e;
|
||||||
};
|
} finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showToast, t.common.removed, t.common.failedToRemove],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
const handleReveal = async (key: string) => {
|
const handleReveal = async (key: string) => {
|
||||||
if (revealed[key]) {
|
if (revealed[key]) {
|
||||||
|
|
@ -488,10 +503,29 @@ export default function EnvPage() {
|
||||||
const totalProviders = providerGroups.length;
|
const totalProviders = providerGroups.length;
|
||||||
const configuredProviders = providerGroups.filter((g) => g.hasAnySet).length;
|
const configuredProviders = providerGroups.filter((g) => g.hasAnySet).length;
|
||||||
|
|
||||||
|
const pendingClearKey = keyClear.pendingId;
|
||||||
|
const pendingKeyDescription =
|
||||||
|
pendingClearKey && vars
|
||||||
|
? vars[pendingClearKey]?.description
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<Toast toast={toast} />
|
<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 items-center justify-between">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|
@ -514,7 +548,7 @@ export default function EnvPage() {
|
||||||
|
|
||||||
{/* ═══════════════ LLM Providers (grouped) ═══════════════ */}
|
{/* ═══════════════ LLM Providers (grouped) ═══════════════ */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="sticky top-14 z-10 bg-card border-b border-border">
|
<CardHeader className="border-b border-border bg-card">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Zap className="h-5 w-5 text-muted-foreground" />
|
<Zap className="h-5 w-5 text-muted-foreground" />
|
||||||
<CardTitle className="text-base">{t.env.llmProviders}</CardTitle>
|
<CardTitle className="text-base">{t.env.llmProviders}</CardTitle>
|
||||||
|
|
@ -530,7 +564,8 @@ export default function EnvPage() {
|
||||||
key={group.name}
|
key={group.name}
|
||||||
group={group}
|
group={group}
|
||||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
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>
|
</CardContent>
|
||||||
|
|
@ -542,7 +577,7 @@ export default function EnvPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={category}>
|
<Card key={category}>
|
||||||
<CardHeader className="sticky top-14 z-10 bg-card border-b border-border">
|
<CardHeader className="border-b border-border bg-card">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||||
<CardTitle className="text-base">{label}</CardTitle>
|
<CardTitle className="text-base">{label}</CardTitle>
|
||||||
|
|
@ -557,7 +592,8 @@ export default function EnvPage() {
|
||||||
<EnvVarRow
|
<EnvVarRow
|
||||||
key={key} varKey={key} info={info}
|
key={key} varKey={key} info={info}
|
||||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
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}
|
category={category}
|
||||||
unsetEntries={unsetEntries}
|
unsetEntries={unsetEntries}
|
||||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
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>
|
</CardContent>
|
||||||
|
|
@ -592,6 +629,7 @@ function CollapsibleUnset({
|
||||||
onClear,
|
onClear,
|
||||||
onReveal,
|
onReveal,
|
||||||
onCancelEdit,
|
onCancelEdit,
|
||||||
|
clearDialogOpen = false,
|
||||||
}: {
|
}: {
|
||||||
category: string;
|
category: string;
|
||||||
unsetEntries: [string, EnvVarInfo][];
|
unsetEntries: [string, EnvVarInfo][];
|
||||||
|
|
@ -603,6 +641,7 @@ function CollapsibleUnset({
|
||||||
onClear: (key: string) => void;
|
onClear: (key: string) => void;
|
||||||
onReveal: (key: string) => void;
|
onReveal: (key: string) => void;
|
||||||
onCancelEdit: (key: string) => void;
|
onCancelEdit: (key: string) => void;
|
||||||
|
clearDialogOpen?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [collapsed, setCollapsed] = useState(true);
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
@ -625,6 +664,7 @@ function CollapsibleUnset({
|
||||||
key={key} varKey={key} info={info}
|
key={key} varKey={key} info={info}
|
||||||
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||||
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||||
|
clearDialogOpen={clearDialogOpen}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useLayoutEffect, useState, useCallback, useRef } from "react";
|
||||||
import { FileText, RefreshCw, ChevronRight } from "lucide-react";
|
import { FileText, RefreshCw } from "lucide-react";
|
||||||
import { H2 } from "@nous-research/ui";
|
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { FilterGroup, Segmented } from "@/components/ui/segmented";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
|
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||||
|
|
||||||
const FILES = ["agent", "errors", "gateway"] as const;
|
const FILES = ["agent", "errors", "gateway"] as const;
|
||||||
const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] 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",
|
debug: "text-muted-foreground/60",
|
||||||
};
|
};
|
||||||
|
|
||||||
function SidebarHeading({ children }: { children: React.ReactNode }) {
|
const toOptions = <T extends string>(values: readonly T[]) =>
|
||||||
return (
|
values.map((v) => ({ value: v, label: v }));
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LogsPage() {
|
export default function LogsPage() {
|
||||||
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
|
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
|
||||||
|
|
@ -79,6 +50,7 @@ export default function LogsPage() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { setAfterTitle, setEnd } = usePageHeader();
|
||||||
|
|
||||||
const fetchLogs = useCallback(() => {
|
const fetchLogs = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -97,6 +69,66 @@ export default function LogsPage() {
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [file, lineCount, level, component]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
fetchLogs();
|
fetchLogs();
|
||||||
}, [fetchLogs]);
|
}, [fetchLogs]);
|
||||||
|
|
@ -109,145 +141,80 @@ export default function LogsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* ═══════════════ Header ═══════════════ */}
|
{/* ═══════════════ Filter toolbar ═══════════════ */}
|
||||||
<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 ═══════════════ */}
|
|
||||||
<div
|
<div
|
||||||
className="flex flex-col sm:flex-row gap-4"
|
role="toolbar"
|
||||||
style={{ minHeight: "calc(100vh - 180px)" }}
|
aria-label={t.logs.title}
|
||||||
|
className="flex flex-wrap items-center gap-x-6 gap-y-2"
|
||||||
>
|
>
|
||||||
{/* ---- Sidebar ---- */}
|
<FilterGroup label={t.logs.file}>
|
||||||
<div className="sm:w-44 sm:shrink-0">
|
<Segmented value={file} onChange={setFile} options={toOptions(FILES)} />
|
||||||
<div className="sm:sticky sm:top-[72px] flex flex-col gap-0.5">
|
</FilterGroup>
|
||||||
<SidebarHeading>{t.logs.file}</SidebarHeading>
|
|
||||||
{FILES.map((f) => (
|
|
||||||
<SidebarItem
|
|
||||||
key={f}
|
|
||||||
label={f}
|
|
||||||
value={f}
|
|
||||||
current={file}
|
|
||||||
onChange={setFile}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<SidebarHeading>{t.logs.level}</SidebarHeading>
|
<FilterGroup label={t.logs.level}>
|
||||||
{LEVELS.map((l) => (
|
<Segmented value={level} onChange={setLevel} options={toOptions(LEVELS)} />
|
||||||
<SidebarItem
|
</FilterGroup>
|
||||||
key={l}
|
|
||||||
label={l}
|
|
||||||
value={l}
|
|
||||||
current={level}
|
|
||||||
onChange={setLevel}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<SidebarHeading>{t.logs.component}</SidebarHeading>
|
<FilterGroup label={t.logs.component}>
|
||||||
{COMPONENTS.map((c) => (
|
<Segmented
|
||||||
<SidebarItem
|
value={component}
|
||||||
key={c}
|
onChange={setComponent}
|
||||||
label={c}
|
options={toOptions(COMPONENTS)}
|
||||||
value={c}
|
/>
|
||||||
current={component}
|
</FilterGroup>
|
||||||
onChange={setComponent}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<SidebarHeading>{t.logs.lines}</SidebarHeading>
|
<FilterGroup label={t.logs.lines}>
|
||||||
{LINE_COUNTS.map((n) => (
|
<Segmented
|
||||||
<SidebarItem
|
value={String(lineCount)}
|
||||||
key={n}
|
onChange={(v) =>
|
||||||
label={String(n)}
|
setLineCount(Number(v) as (typeof LINE_COUNTS)[number])
|
||||||
value={String(n)}
|
}
|
||||||
current={String(lineCount)}
|
options={LINE_COUNTS.map((n) => ({
|
||||||
onChange={(v) =>
|
value: String(n),
|
||||||
setLineCount(Number(v) as (typeof LINE_COUNTS)[number])
|
label: String(n),
|
||||||
}
|
}))}
|
||||||
/>
|
/>
|
||||||
))}
|
</FilterGroup>
|
||||||
</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>
|
|
||||||
</div>
|
</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>
|
</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 {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Database,
|
||||||
|
Loader2,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
|
@ -13,19 +17,27 @@ import {
|
||||||
Hash,
|
Hash,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { H2 } from "@nous-research/ui";
|
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type {
|
import type {
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
SessionMessage,
|
SessionMessage,
|
||||||
SessionSearchResult,
|
SessionSearchResult,
|
||||||
|
StatusResponse,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { timeAgo } from "@/lib/utils";
|
import { timeAgo } from "@/lib/utils";
|
||||||
import { Markdown } from "@/components/Markdown";
|
import { Markdown } from "@/components/Markdown";
|
||||||
|
import { PlatformsCard } from "@/components/PlatformsCard";
|
||||||
|
import { Toast } from "@/components/Toast";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { Input } from "@/components/ui/input";
|
||||||
|
import { useSystemActions } from "@/contexts/useSystemActions";
|
||||||
|
import { useToast } from "@/hooks/useToast";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
|
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||||
|
|
||||||
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> =
|
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> =
|
||||||
{
|
{
|
||||||
|
|
@ -381,7 +393,62 @@ export default function SessionsPage() {
|
||||||
>(null);
|
>(null);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
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 { 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) => {
|
const loadSessions = useCallback((p: number) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -399,6 +466,24 @@ export default function SessionsPage() {
|
||||||
loadSessions(page);
|
loadSessions(page);
|
||||||
}, [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
|
// Debounced FTS search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
|
@ -423,16 +508,27 @@ export default function SessionsPage() {
|
||||||
};
|
};
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const sessionDelete = useConfirmDelete({
|
||||||
try {
|
onDelete: useCallback(
|
||||||
await api.deleteSession(id);
|
async (id: string) => {
|
||||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
try {
|
||||||
setTotal((prev) => prev - 1);
|
await api.deleteSession(id);
|
||||||
if (expandedId === id) setExpandedId(null);
|
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||||
} catch {
|
setTotal((prev) => prev - 1);
|
||||||
// ignore
|
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)
|
// Build snippet map from search results (session_id → snippet)
|
||||||
const snippetMap = new Map<string, string>();
|
const snippetMap = new Map<string, string>();
|
||||||
|
|
@ -448,6 +544,36 @@ export default function SessionsPage() {
|
||||||
? sessions.filter((s) => snippetMap.has(s.id))
|
? sessions.filter((s) => snippetMap.has(s.id))
|
||||||
: sessions;
|
: 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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-24">
|
<div className="flex items-center justify-center py-24">
|
||||||
|
|
@ -458,38 +584,159 @@ export default function SessionsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Header outside card for lighter feel */}
|
<Toast toast={toast} />
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
<DeleteConfirmDialog
|
||||||
<MessageSquare className="h-5 w-5 text-muted-foreground" />
|
open={sessionDelete.isOpen}
|
||||||
<H2 variant="sm">{t.sessions.title}</H2>
|
onCancel={sessionDelete.cancel}
|
||||||
<Badge variant="secondary" className="text-xs">
|
onConfirm={sessionDelete.confirm}
|
||||||
{total}
|
title={t.sessions.confirmDeleteTitle}
|
||||||
</Badge>
|
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>
|
||||||
<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" />
|
{activeAction && (
|
||||||
) : (
|
<div className="border border-border bg-background-base/50">
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
<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">
|
||||||
<Input
|
{actionStatus?.running ? (
|
||||||
placeholder={t.sessions.searchPlaceholder}
|
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-warning" />
|
||||||
value={search}
|
) : actionStatus?.exit_code === 0 ? (
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
|
||||||
className="pl-8 pr-7 h-8 text-xs"
|
) : actionStatus !== null ? (
|
||||||
/>
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" />
|
||||||
{search && (
|
) : (
|
||||||
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
onClick={dismissLog}
|
||||||
onClick={() => setSearch("")}
|
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>
|
</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>
|
||||||
</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 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||||
|
|
@ -516,7 +763,7 @@ export default function SessionsPage() {
|
||||||
onToggle={() =>
|
onToggle={() =>
|
||||||
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
||||||
}
|
}
|
||||||
onDelete={() => handleDelete(s.id)}
|
onDelete={() => sessionDelete.requestDelete(s.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useLayoutEffect, useState, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
Search,
|
Search,
|
||||||
Wrench,
|
Wrench,
|
||||||
ChevronRight,
|
|
||||||
X,
|
X,
|
||||||
Cpu,
|
Cpu,
|
||||||
Globe,
|
Globe,
|
||||||
|
|
@ -14,8 +13,8 @@ import {
|
||||||
Blocks,
|
Blocks,
|
||||||
Code,
|
Code,
|
||||||
Zap,
|
Zap,
|
||||||
|
Filter,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { H2 } from "@nous-research/ui";
|
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
|
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
|
||||||
import { useToast } from "@/hooks/useToast";
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
|
@ -25,6 +24,7 @@ import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
|
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Types & helpers */
|
/* Types & helpers */
|
||||||
|
|
@ -98,6 +98,7 @@ export default function SkillsPage() {
|
||||||
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
|
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
|
||||||
const { toast, showToast } = useToast();
|
const { toast, showToast } = useToast();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { setAfterTitle, setEnd } = usePageHeader();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([api.getSkills(), api.getToolsets()])
|
Promise.all([api.getSkills(), api.getToolsets()])
|
||||||
|
|
@ -182,6 +183,53 @@ export default function SkillsPage() {
|
||||||
|
|
||||||
const enabledCount = skills.filter((s) => s.enabled).length;
|
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(() => {
|
const filteredToolsets = useMemo(() => {
|
||||||
return toolsets.filter(
|
return toolsets.filter(
|
||||||
(ts) =>
|
(ts) =>
|
||||||
|
|
@ -205,122 +253,98 @@ export default function SkillsPage() {
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Toast toast={toast} />
|
<Toast toast={toast} />
|
||||||
|
|
||||||
{/* ═══════════════ Header ═══════════════ */}
|
{/* ═══════════════ Filter panel + Content ═══════════════ */}
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||||
<div className="flex items-center gap-3">
|
{/* ---- Filter panel ---- */}
|
||||||
<Package className="h-5 w-5 text-muted-foreground" />
|
<aside
|
||||||
<H2 variant="sm">{t.skills.title}</H2>
|
aria-label={t.skills.title}
|
||||||
<span className="text-xs text-muted-foreground">
|
className="sm:w-56 sm:shrink-0"
|
||||||
{t.skills.enabledOf
|
>
|
||||||
.replace("{enabled}", String(enabledCount))
|
<div className="sm:sticky sm:top-0">
|
||||||
.replace("{total}", String(skills.length))}
|
<div
|
||||||
</span>
|
className={`
|
||||||
</div>
|
flex flex-col
|
||||||
</div>
|
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 ═══════════════ */}
|
{/* View switch (Skills / Toolsets) */}
|
||||||
<div
|
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none p-2">
|
||||||
className="flex flex-col sm:flex-row gap-4"
|
<PanelItem
|
||||||
style={{ minHeight: "calc(100vh - 180px)" }}
|
icon={Package}
|
||||||
>
|
label={`${t.skills.all} (${skills.length})`}
|
||||||
{/* ---- Sidebar ---- */}
|
active={view === "skills" && !isSearching}
|
||||||
<div className="sm:w-52 sm:shrink-0">
|
onClick={() => {
|
||||||
<div className="sm:sticky sm:top-[72px] flex flex-col gap-1">
|
setView("skills");
|
||||||
{/* Search */}
|
setActiveCategory(null);
|
||||||
<div className="relative mb-2 hidden sm:block">
|
setSearch("");
|
||||||
<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"
|
<PanelItem
|
||||||
placeholder={t.common.search}
|
icon={Wrench}
|
||||||
value={search}
|
label={`${t.skills.toolsets} (${toolsets.length})`}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
active={view === "toolsets"}
|
||||||
/>
|
onClick={() => {
|
||||||
{search && (
|
setView("toolsets");
|
||||||
<button
|
setSearch("");
|
||||||
type="button"
|
}}
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
/>
|
||||||
onClick={() => setSearch("")}
|
</div>
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
{/* Category sub-filters (only for Skills view) */}
|
||||||
</button>
|
{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>
|
</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>
|
||||||
</div>
|
</aside>
|
||||||
|
|
||||||
{/* ---- Content ---- */}
|
{/* ---- Content ---- */}
|
||||||
<div className="flex-1 min-w-0">
|
<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 {
|
interface SkillRowProps {
|
||||||
|
noDescriptionLabel: string;
|
||||||
|
onToggle: () => void;
|
||||||
skill: SkillInfo;
|
skill: SkillInfo;
|
||||||
toggling: boolean;
|
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 { exposePluginSDK, getPluginComponent, onPluginRegistered, getRegisteredCount } from "./registry";
|
||||||
|
export { PluginPage } from "./PluginPage";
|
||||||
export { usePlugins } from "./usePlugins";
|
export { usePlugins } from "./usePlugins";
|
||||||
export { PluginSlot, KNOWN_SLOT_NAMES, registerSlot, getSlotEntries, onSlotRegistered, unregisterPluginSlots } from "./slots";
|
export { PluginSlot, KNOWN_SLOT_NAMES, registerSlot, getSlotEntries, onSlotRegistered, unregisterPluginSlots } from "./slots";
|
||||||
export type { KnownSlotName } from "./slots";
|
export type { KnownSlotName } from "./slots";
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import { registerSlot, PluginSlot } from "./slots";
|
||||||
type RegistryListener = () => void;
|
type RegistryListener = () => void;
|
||||||
|
|
||||||
const _registered: Map<string, React.ComponentType> = new Map();
|
const _registered: Map<string, React.ComponentType> = new Map();
|
||||||
|
const _loadErrors: Map<string, string> = new Map();
|
||||||
const _listeners: Set<RegistryListener> = new Set();
|
const _listeners: Set<RegistryListener> = new Set();
|
||||||
|
|
||||||
function _notify() {
|
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. */
|
/** Register a plugin component. Called by plugin JS bundles. */
|
||||||
function registerPlugin(name: string, component: React.ComponentType) {
|
function registerPlugin(name: string, component: React.ComponentType) {
|
||||||
|
_loadErrors.delete(name);
|
||||||
_registered.set(name, component);
|
_registered.set(name, component);
|
||||||
_notify();
|
_notify();
|
||||||
}
|
}
|
||||||
|
|
@ -56,6 +63,15 @@ export function getPluginComponent(name: string): React.ComponentType | undefine
|
||||||
return _registered.get(name);
|
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). */
|
/** Subscribe to registry changes (returns unsubscribe fn). */
|
||||||
export function onPluginRegistered(fn: RegistryListener): () => void {
|
export function onPluginRegistered(fn: RegistryListener): () => void {
|
||||||
_listeners.add(fn);
|
_listeners.add(fn);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
/** Types for the dashboard plugin system. */
|
/** Types for the dashboard plugin system. */
|
||||||
|
|
||||||
|
import type { ComponentType } from "react";
|
||||||
|
|
||||||
export interface PluginManifest {
|
export interface PluginManifest {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -8,21 +10,14 @@ export interface PluginManifest {
|
||||||
version: string;
|
version: string;
|
||||||
tab: {
|
tab: {
|
||||||
path: string;
|
path: string;
|
||||||
position: string; // "end", "after:<tab>", "before:<tab>"
|
/** "end", "after:<pathSegment>", "before:<pathSegment>" (e.g. "after:skills" → after `/skills`) */
|
||||||
/** When set to a built-in route path (e.g. `"/"`, `"/sessions"`), this
|
position?: string;
|
||||||
* plugin's component replaces the built-in page at that route rather
|
/** When set to a built-in route path, this plugin replaces that page instead of adding a new tab. */
|
||||||
* than adding a new tab. Useful for themes that want a custom home
|
|
||||||
* page without losing the rest of the dashboard. */
|
|
||||||
override?: string;
|
override?: string;
|
||||||
/** When true, the plugin registers its component and slot contributors
|
/** When true, the plugin may register without a sidebar tab (slot-only, etc.). */
|
||||||
* without adding a tab to the nav. Used by slot-only plugins (e.g. a
|
|
||||||
* plugin that just injects a header crest). */
|
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
};
|
};
|
||||||
/** Named shell slots this plugin populates. Mirrored by the backend's
|
/** Declared for discovery; actual slots use registerSlot in the plugin bundle. */
|
||||||
* 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)`. */
|
|
||||||
slots?: string[];
|
slots?: string[];
|
||||||
entry: string;
|
entry: string;
|
||||||
css?: string | null;
|
css?: string | null;
|
||||||
|
|
@ -32,5 +27,5 @@ export interface PluginManifest {
|
||||||
|
|
||||||
export interface RegisteredPlugin {
|
export interface RegisteredPlugin {
|
||||||
manifest: PluginManifest;
|
manifest: PluginManifest;
|
||||||
component: React.ComponentType;
|
component: ComponentType;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,12 @@
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { PluginManifest, RegisteredPlugin } from "./types";
|
import type { PluginManifest, RegisteredPlugin } from "./types";
|
||||||
import { getPluginComponent, onPluginRegistered } from "./registry";
|
import {
|
||||||
|
getPluginComponent,
|
||||||
|
onPluginRegistered,
|
||||||
|
notifyPluginRegistry,
|
||||||
|
setPluginLoadError,
|
||||||
|
} from "./registry";
|
||||||
|
|
||||||
export function usePlugins() {
|
export function usePlugins() {
|
||||||
const [manifests, setManifests] = useState<PluginManifest[]>([]);
|
const [manifests, setManifests] = useState<PluginManifest[]>([]);
|
||||||
|
|
@ -33,6 +38,8 @@ export function usePlugins() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (manifests.length === 0) return;
|
if (manifests.length === 0) return;
|
||||||
|
|
||||||
|
const injectedScripts: HTMLScriptElement[] = [];
|
||||||
|
|
||||||
for (const manifest of manifests) {
|
for (const manifest of manifests) {
|
||||||
// Inject CSS if specified.
|
// Inject CSS if specified.
|
||||||
if (manifest.css) {
|
if (manifest.css) {
|
||||||
|
|
@ -45,23 +52,49 @@ export function usePlugins() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load JS bundle.
|
// Load JS bundle. In dev, cache-bust so Vite HMR can clear the
|
||||||
const jsUrl = `/dashboard-plugins/${manifest.name}/${manifest.entry}`;
|
// in-memory registry while the browser would otherwise never
|
||||||
if (loadedScripts.current.has(jsUrl)) continue;
|
// re-execute a previously cached <script> URL.
|
||||||
loadedScripts.current.add(jsUrl);
|
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");
|
const script = document.createElement("script");
|
||||||
script.src = jsUrl;
|
script.setAttribute("data-hermes-plugin", manifest.name);
|
||||||
|
script.src = scriptSrc;
|
||||||
script.async = true;
|
script.async = true;
|
||||||
script.onerror = () => {
|
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);
|
document.body.appendChild(script);
|
||||||
|
injectedScripts.push(script);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give plugins a moment to load and register, then stop loading state.
|
// Give plugins a moment to load and register, then stop loading state.
|
||||||
const timeout = setTimeout(() => setLoading(false), 2000);
|
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]);
|
}, [manifests]);
|
||||||
|
|
||||||
// Listen for plugin registrations and resolve them against manifests.
|
// Listen for plugin registrations and resolve them against manifests.
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,10 @@ export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": BACKEND,
|
"/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