mirror of
https://github.com/lordmathis/llamactl.git
synced 2025-12-22 17:14:22 +00:00
Add inference api key frontend integration
This commit is contained in:
184
webui/package-lock.json
generated
184
webui/package-lock.json
generated
@@ -12,10 +12,12 @@
|
|||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -160,7 +162,6 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -510,7 +511,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -554,7 +554,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1286,6 +1285,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-collection": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-compose-refs": {
|
"node_modules/@radix-ui/react-compose-refs": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||||
@@ -1352,6 +1377,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-direction": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-dismissable-layer": {
|
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||||
"version": "1.1.10",
|
"version": "1.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
|
||||||
@@ -1531,6 +1571,105 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-radio-group": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-roving-focus": "1.1.11",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-presence": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-roving-focus": {
|
||||||
|
"version": "1.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
||||||
|
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-collection": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-slot": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
@@ -2352,7 +2491,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
@@ -2447,7 +2587,6 @@
|
|||||||
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
|
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -2458,7 +2597,6 @@
|
|||||||
"integrity": "sha512-tBFxBp9Nfyy5rsmefN+WXc1JeW/j2BpBHFdLZbEVfs9wn3E3NRFxwV0pJg8M1qQAexFpvz73hJXFofV0ZAu92A==",
|
"integrity": "sha512-tBFxBp9Nfyy5rsmefN+WXc1JeW/j2BpBHFdLZbEVfs9wn3E3NRFxwV0pJg8M1qQAexFpvz73hJXFofV0ZAu92A==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@@ -2469,7 +2607,6 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
@@ -2520,7 +2657,6 @@
|
|||||||
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
|
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.48.0",
|
"@typescript-eslint/scope-manager": "8.48.0",
|
||||||
"@typescript-eslint/types": "8.48.0",
|
"@typescript-eslint/types": "8.48.0",
|
||||||
@@ -2869,7 +3005,6 @@
|
|||||||
"integrity": "sha512-F9jI5rSstNknPlTlPN2gcc4gpbaagowuRzw/OJzl368dvPun668Q182S8Q8P9PITgGCl5LAKXpzuue106eM4wA==",
|
"integrity": "sha512-F9jI5rSstNknPlTlPN2gcc4gpbaagowuRzw/OJzl368dvPun668Q182S8Q8P9PITgGCl5LAKXpzuue106eM4wA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/utils": "4.0.8",
|
"@vitest/utils": "4.0.8",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
@@ -2906,7 +3041,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2957,6 +3091,7 @@
|
|||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -3228,7 +3363,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001726",
|
"caniuse-lite": "^1.0.30001726",
|
||||||
"electron-to-chromium": "^1.5.173",
|
"electron-to-chromium": "^1.5.173",
|
||||||
@@ -3540,6 +3674,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -3651,7 +3795,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
@@ -3955,7 +4100,6 @@
|
|||||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||||
"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",
|
||||||
@@ -5181,7 +5325,6 @@
|
|||||||
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
|
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@acemir/cssom": "^0.9.23",
|
"@acemir/cssom": "^0.9.23",
|
||||||
"@asamuzakjp/dom-selector": "^6.7.4",
|
"@asamuzakjp/dom-selector": "^6.7.4",
|
||||||
@@ -5592,6 +5735,7 @@
|
|||||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"lz-string": "bin/bin.js"
|
"lz-string": "bin/bin.js"
|
||||||
}
|
}
|
||||||
@@ -5977,7 +6121,6 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -6039,6 +6182,7 @@
|
|||||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1",
|
"ansi-regex": "^5.0.1",
|
||||||
"ansi-styles": "^5.0.0",
|
"ansi-styles": "^5.0.0",
|
||||||
@@ -6054,6 +6198,7 @@
|
|||||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
@@ -6095,7 +6240,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -6105,7 +6249,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -6118,7 +6261,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
@@ -7072,7 +7216,6 @@
|
|||||||
"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"
|
||||||
@@ -7220,7 +7363,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -7296,7 +7438,6 @@
|
|||||||
"integrity": "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg==",
|
"integrity": "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.0.8",
|
"@vitest/expect": "4.0.8",
|
||||||
"@vitest/mocker": "4.0.8",
|
"@vitest/mocker": "4.0.8",
|
||||||
@@ -7625,7 +7766,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
|
||||||
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
|
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,12 @@
|
|||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import InstanceList from "@/components/InstanceList";
|
|||||||
import InstanceDialog from "@/components/InstanceDialog";
|
import InstanceDialog from "@/components/InstanceDialog";
|
||||||
import LoginDialog from "@/components/LoginDialog";
|
import LoginDialog from "@/components/LoginDialog";
|
||||||
import SystemInfoDialog from "./components/SystemInfoDialog";
|
import SystemInfoDialog from "./components/SystemInfoDialog";
|
||||||
|
import SettingsDialog from "./components/settings/SettingsDialog";
|
||||||
import { type CreateInstanceOptions, type Instance } from "@/types/instance";
|
import { type CreateInstanceOptions, type Instance } from "@/types/instance";
|
||||||
import { useInstances } from "@/contexts/InstancesContext";
|
import { useInstances } from "@/contexts/InstancesContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
@@ -14,6 +15,7 @@ function App() {
|
|||||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||||
const [isInstanceModalOpen, setIsInstanceModalOpen] = useState(false);
|
const [isInstanceModalOpen, setIsInstanceModalOpen] = useState(false);
|
||||||
const [isSystemInfoModalOpen, setIsSystemInfoModalOpen] = useState(false);
|
const [isSystemInfoModalOpen, setIsSystemInfoModalOpen] = useState(false);
|
||||||
|
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
|
||||||
const [editingInstance, setEditingInstance] = useState<Instance | undefined>(
|
const [editingInstance, setEditingInstance] = useState<Instance | undefined>(
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
@@ -41,6 +43,10 @@ function App() {
|
|||||||
setIsSystemInfoModalOpen(true);
|
setIsSystemInfoModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleShowSettings = () => {
|
||||||
|
setIsSettingsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Show loading spinner while checking auth
|
// Show loading spinner while checking auth
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -70,7 +76,11 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header onCreateInstance={handleCreateInstance} onShowSystemInfo={handleShowSystemInfo} />
|
<Header
|
||||||
|
onCreateInstance={handleCreateInstance}
|
||||||
|
onShowSystemInfo={handleShowSystemInfo}
|
||||||
|
onShowSettings={handleShowSettings}
|
||||||
|
/>
|
||||||
<main className="container mx-auto max-w-4xl px-4 py-8">
|
<main className="container mx-auto max-w-4xl px-4 py-8">
|
||||||
<InstanceList editInstance={handleEditInstance} />
|
<InstanceList editInstance={handleEditInstance} />
|
||||||
</main>
|
</main>
|
||||||
@@ -86,7 +96,12 @@ function App() {
|
|||||||
open={isSystemInfoModalOpen}
|
open={isSystemInfoModalOpen}
|
||||||
onOpenChange={setIsSystemInfoModalOpen}
|
onOpenChange={setIsSystemInfoModalOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SettingsDialog
|
||||||
|
open={isSettingsModalOpen}
|
||||||
|
onOpenChange={setIsSettingsModalOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ function renderApp() {
|
|||||||
|
|
||||||
describe('App Component - Critical Business Logic Only', () => {
|
describe('App Component - Critical Business Logic Only', () => {
|
||||||
const mockInstances: Instance[] = [
|
const mockInstances: Instance[] = [
|
||||||
{ name: 'test-instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } },
|
{ id: 1, name: 'test-instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } },
|
||||||
{ name: 'test-instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.gguf' } } }
|
{ id: 2, name: 'test-instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.gguf' } } }
|
||||||
]
|
]
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -109,6 +109,7 @@ describe('App Component - Critical Business Logic Only', () => {
|
|||||||
it('creates new instance with correct API call and updates UI', async () => {
|
it('creates new instance with correct API call and updates UI', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const newInstance: Instance = {
|
const newInstance: Instance = {
|
||||||
|
id: 3,
|
||||||
name: 'new-test-instance',
|
name: 'new-test-instance',
|
||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'new-model.gguf' } }
|
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'new-model.gguf' } }
|
||||||
@@ -151,6 +152,7 @@ describe('App Component - Critical Business Logic Only', () => {
|
|||||||
it('updates existing instance with correct API call', async () => {
|
it('updates existing instance with correct API call', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const updatedInstance: Instance = {
|
const updatedInstance: Instance = {
|
||||||
|
id: 1,
|
||||||
name: 'test-instance-1',
|
name: 'test-instance-1',
|
||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'updated-model.gguf' } }
|
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'updated-model.gguf' } }
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { HelpCircle, LogOut, Moon, Sun } from "lucide-react";
|
import { HelpCircle, LogOut, Moon, Settings, Sun } from "lucide-react";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onCreateInstance: () => void;
|
onCreateInstance: () => void;
|
||||||
onShowSystemInfo: () => void;
|
onShowSystemInfo: () => void;
|
||||||
|
onShowSettings: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
|
function Header({ onCreateInstance, onShowSystemInfo, onShowSettings }: HeaderProps) {
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
@@ -41,6 +42,16 @@ function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
|
|||||||
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={onShowSettings}
|
||||||
|
data-testid="settings-button"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ describe('InstanceCard - Instance Actions and State', () => {
|
|||||||
const mockEditInstance = vi.fn()
|
const mockEditInstance = vi.fn()
|
||||||
|
|
||||||
const stoppedInstance: Instance = {
|
const stoppedInstance: Instance = {
|
||||||
|
id: 1,
|
||||||
name: 'test-instance',
|
name: 'test-instance',
|
||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'test-model.gguf' } }
|
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'test-model.gguf' } }
|
||||||
}
|
}
|
||||||
|
|
||||||
const runningInstance: Instance = {
|
const runningInstance: Instance = {
|
||||||
|
id: 2,
|
||||||
name: 'running-instance',
|
name: 'running-instance',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'running-model.gguf' } }
|
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'running-model.gguf' } }
|
||||||
@@ -342,6 +344,7 @@ afterEach(() => {
|
|||||||
describe('Error Edge Cases', () => {
|
describe('Error Edge Cases', () => {
|
||||||
it('handles instance with minimal data', () => {
|
it('handles instance with minimal data', () => {
|
||||||
const minimalInstance: Instance = {
|
const minimalInstance: Instance = {
|
||||||
|
id: 3,
|
||||||
name: 'minimal',
|
name: 'minimal',
|
||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
options: {}
|
options: {}
|
||||||
@@ -364,6 +367,7 @@ afterEach(() => {
|
|||||||
|
|
||||||
it('handles instance with undefined options', () => {
|
it('handles instance with undefined options', () => {
|
||||||
const instanceWithoutOptions: Instance = {
|
const instanceWithoutOptions: Instance = {
|
||||||
|
id: 4,
|
||||||
name: 'no-options',
|
name: 'no-options',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
options: undefined
|
options: undefined
|
||||||
|
|||||||
@@ -59,9 +59,9 @@ describe('InstanceList - State Management and UI Logic', () => {
|
|||||||
const mockEditInstance = vi.fn()
|
const mockEditInstance = vi.fn()
|
||||||
|
|
||||||
const mockInstances: Instance[] = [
|
const mockInstances: Instance[] = [
|
||||||
{ name: 'instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } },
|
{ id: 1, name: 'instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } },
|
||||||
{ name: 'instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.gguf' } } },
|
{ id: 1, name: 'instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.gguf' } } },
|
||||||
{ name: 'instance-3', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model3.gguf' } } }
|
{ id: 1, name: 'instance-3', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model3.gguf' } } }
|
||||||
]
|
]
|
||||||
|
|
||||||
const DUMMY_API_KEY = 'test-api-key-123'
|
const DUMMY_API_KEY = 'test-api-key-123'
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe('Edit Mode', () => {
|
describe('Edit Mode', () => {
|
||||||
const mockInstance: Instance = {
|
const mockInstance: Instance = {
|
||||||
|
id: 1,
|
||||||
name: 'existing-instance',
|
name: 'existing-instance',
|
||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
options: {
|
options: {
|
||||||
|
|||||||
236
webui/src/components/apikeys/CreateApiKeyDialog.tsx
Normal file
236
webui/src/components/apikeys/CreateApiKeyDialog.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { apiKeysApi } from "@/lib/api";
|
||||||
|
import { CreateKeyRequest, PermissionMode, InstancePermission } from "@/types/apiKey";
|
||||||
|
import { useInstances } from "@/contexts/InstancesContext";
|
||||||
|
import { format, addDays } from "date-fns";
|
||||||
|
|
||||||
|
interface CreateApiKeyDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onKeyCreated: (plainTextKey: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateApiKeyDialogProps) {
|
||||||
|
const { instances } = useInstances();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [permissionMode, setPermissionMode] = useState<PermissionMode>(PermissionMode.AllowAll);
|
||||||
|
const [expiresAt, setExpiresAt] = useState<string>("");
|
||||||
|
const [instancePermissions, setInstancePermissions] = useState<Record<number, boolean>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const formatDisplayDate = (dateString: string) => {
|
||||||
|
if (!dateString) return null;
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return format(date, "d MMMM yyyy");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError("Name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.length > 100) {
|
||||||
|
setError("Name must be 100 characters or less");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissionMode === PermissionMode.PerInstance) {
|
||||||
|
const hasAnyPermission = Object.values(instancePermissions).some(v => v);
|
||||||
|
if (!hasAnyPermission) {
|
||||||
|
setError("At least one instance permission is required for per-instance mode");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build request
|
||||||
|
const permissions: InstancePermission[] = [];
|
||||||
|
if (permissionMode === PermissionMode.PerInstance) {
|
||||||
|
Object.entries(instancePermissions).forEach(([instanceId, canInfer]) => {
|
||||||
|
if (canInfer) {
|
||||||
|
permissions.push({
|
||||||
|
InstanceID: parseInt(instanceId),
|
||||||
|
CanInfer: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: CreateKeyRequest = {
|
||||||
|
Name: name.trim(),
|
||||||
|
PermissionMode: permissionMode,
|
||||||
|
InstancePermissions: permissions,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add expiration if provided
|
||||||
|
if (expiresAt) {
|
||||||
|
const expirationDate = new Date(expiresAt);
|
||||||
|
const now = new Date();
|
||||||
|
if (expirationDate <= now) {
|
||||||
|
setError("Expiration date must be in the future");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request.ExpiresAt = Math.floor(expirationDate.getTime() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiKeysApi.create(request);
|
||||||
|
onKeyCreated(response.key);
|
||||||
|
// Reset form
|
||||||
|
setName("");
|
||||||
|
setPermissionMode(PermissionMode.AllowAll);
|
||||||
|
setExpiresAt("");
|
||||||
|
setInstancePermissions({});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to create API key");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstancePermissionChange = (instanceId: number, checked: boolean) => {
|
||||||
|
setInstancePermissions({
|
||||||
|
...instancePermissions,
|
||||||
|
[instanceId]: checked,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create API Key</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="My API Key"
|
||||||
|
maxLength={100}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Permission Mode</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={permissionMode}
|
||||||
|
onValueChange={(value) => setPermissionMode(value as PermissionMode)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value={PermissionMode.AllowAll} id="allow-all" />
|
||||||
|
<Label htmlFor="allow-all" className="font-normal cursor-pointer">
|
||||||
|
Full Access
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value={PermissionMode.PerInstance} id="per-instance" />
|
||||||
|
<Label htmlFor="per-instance" className="font-normal cursor-pointer">
|
||||||
|
Per-Instance Access
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
{permissionMode === PermissionMode.AllowAll && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This key will have access to all instances
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{permissionMode === PermissionMode.PerInstance && (
|
||||||
|
<div className="space-y-2 border rounded-lg p-4">
|
||||||
|
<Label className="text-sm font-semibold">Instance Permissions</Label>
|
||||||
|
{instances.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No instances available</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{instances.map((instance) => (
|
||||||
|
<div key={instance.id} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`instance-${instance.id}`}
|
||||||
|
checked={instancePermissions[instance.id] || false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleInstancePermissionChange(instance.id, checked as boolean)
|
||||||
|
}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`instance-${instance.id}`}
|
||||||
|
className="font-normal cursor-pointer flex-1"
|
||||||
|
>
|
||||||
|
{instance.name}
|
||||||
|
</Label>
|
||||||
|
<span className="text-sm text-muted-foreground">Can Infer</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="expires-at">Expiration Date (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="expires-at"
|
||||||
|
type="date"
|
||||||
|
value={expiresAt}
|
||||||
|
onChange={(e) => setExpiresAt(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{expiresAt && formatDisplayDate(expiresAt) && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Expires on {formatDisplayDate(expiresAt)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateApiKeyDialog;
|
||||||
285
webui/src/components/settings/ApiKeysSection.tsx
Normal file
285
webui/src/components/settings/ApiKeysSection.tsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Trash2, Copy, Check, X, ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
import { apiKeysApi } from "@/lib/api";
|
||||||
|
import { ApiKey, KeyPermissionResponse, PermissionMode } from "@/types/apiKey";
|
||||||
|
import CreateApiKeyDialog from "@/components/apikeys/CreateApiKeyDialog";
|
||||||
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
|
|
||||||
|
function ApiKeysSection() {
|
||||||
|
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [expandedRowId, setExpandedRowId] = useState<number | null>(null);
|
||||||
|
const [newKeyPlainText, setNewKeyPlainText] = useState<string | null>(null);
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
const [copiedKey, setCopiedKey] = useState(false);
|
||||||
|
const [permissions, setPermissions] = useState<Record<number, KeyPermissionResponse[]>>({});
|
||||||
|
const [loadingPermissions, setLoadingPermissions] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchKeys();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchKeys = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await apiKeysApi.list();
|
||||||
|
setKeys(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load API keys");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPermissions = async (keyId: number) => {
|
||||||
|
if (permissions[keyId]) return;
|
||||||
|
|
||||||
|
setLoadingPermissions({ ...loadingPermissions, [keyId]: true });
|
||||||
|
try {
|
||||||
|
const data = await apiKeysApi.getPermissions(keyId);
|
||||||
|
setPermissions({ ...permissions, [keyId]: data });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load permissions:", err);
|
||||||
|
} finally {
|
||||||
|
setLoadingPermissions({ ...loadingPermissions, [keyId]: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyCreated = (plainTextKey: string) => {
|
||||||
|
setNewKeyPlainText(plainTextKey);
|
||||||
|
fetchKeys();
|
||||||
|
setCreateDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissSuccessBanner = () => {
|
||||||
|
setNewKeyPlainText(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyKey = async () => {
|
||||||
|
if (newKeyPlainText) {
|
||||||
|
await navigator.clipboard.writeText(newKeyPlainText);
|
||||||
|
setCopiedKey(true);
|
||||||
|
setTimeout(() => setCopiedKey(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteKey = async (id: number, name: string) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete the key '${name}'?\n\nThis action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiKeysApi.delete(id);
|
||||||
|
fetchKeys();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : "Failed to delete API key");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowClick = (key: ApiKey) => {
|
||||||
|
if (expandedRowId === key.id) {
|
||||||
|
setExpandedRowId(null);
|
||||||
|
} else {
|
||||||
|
setExpandedRowId(key.id);
|
||||||
|
if (key.permission_mode === PermissionMode.PerInstance) {
|
||||||
|
fetchPermissions(key.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (timestamp: number) => {
|
||||||
|
return format(new Date(timestamp * 1000), "MMM d, yyyy");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLastUsed = (timestamp: number | null) => {
|
||||||
|
if (!timestamp) return "Never";
|
||||||
|
return formatDistanceToNow(new Date(timestamp * 1000), { addSuffix: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpired = (expiresAt: number | null) => {
|
||||||
|
if (!expiresAt) return false;
|
||||||
|
return expiresAt * 1000 < Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">API Keys</h3>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(true)}>Create API Key</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newKeyPlainText && (
|
||||||
|
<Alert className="bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-900">
|
||||||
|
<AlertDescription className="space-y-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-green-900 dark:text-green-100">API key created successfully</p>
|
||||||
|
<p className="text-sm text-green-800 dark:text-green-200 mt-1">
|
||||||
|
Make sure to copy this key now. You won't be able to see it again!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={dismissSuccessBanner}
|
||||||
|
className="h-6 w-6"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 p-3 bg-white dark:bg-gray-900 border border-green-300 dark:border-green-800 rounded font-mono text-sm break-all">
|
||||||
|
{newKeyPlainText}
|
||||||
|
</code>
|
||||||
|
<Button onClick={handleCopyKey} variant="outline" size="sm">
|
||||||
|
{copiedKey ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : keys.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
No API keys yet. Create your first key to get started.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-3 font-semibold text-sm">Name</th>
|
||||||
|
<th className="text-left p-3 font-semibold text-sm">Permissions</th>
|
||||||
|
<th className="text-left p-3 font-semibold text-sm">Created</th>
|
||||||
|
<th className="text-left p-3 font-semibold text-sm">Expires</th>
|
||||||
|
<th className="text-left p-3 font-semibold text-sm">Last Accessed</th>
|
||||||
|
<th className="text-left p-3 font-semibold text-sm">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{keys.map((key) => (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
key={key.id}
|
||||||
|
className="border-t hover:bg-muted/50 cursor-pointer"
|
||||||
|
onClick={() => handleRowClick(key)}
|
||||||
|
>
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedRowId === key.id ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{key.name}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{key.permission_mode === PermissionMode.AllowAll ? (
|
||||||
|
<Badge variant="default">Full Access</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">Limited Access</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-sm text-muted-foreground">{formatDate(key.created_at)}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{key.expires_at ? (
|
||||||
|
isExpired(key.expires_at) ? (
|
||||||
|
<Badge variant="destructive">Expired</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">{formatDate(key.expires_at)}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">Never</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-sm text-muted-foreground">{formatLastUsed(key.last_used_at)}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteKey(key.id, key.name);
|
||||||
|
}}
|
||||||
|
title="Delete key"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{expandedRowId === key.id && (
|
||||||
|
<tr key={`${key.id}-expanded`} className="border-t bg-muted/30">
|
||||||
|
<td colSpan={6} className="p-4">
|
||||||
|
{key.permission_mode === PermissionMode.AllowAll ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This key has full access to all instances
|
||||||
|
</p>
|
||||||
|
) : loadingPermissions[key.id] ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading permissions...</p>
|
||||||
|
) : permissions[key.id] ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-semibold">Instance Permissions:</p>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left py-2">Instance Name</th>
|
||||||
|
<th className="text-left py-2">Can Infer</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{permissions[key.id].map((perm) => (
|
||||||
|
<tr key={perm.instance_id} className="border-b">
|
||||||
|
<td className="py-2">{perm.instance_name}</td>
|
||||||
|
<td className="py-2">
|
||||||
|
{perm.can_infer ? (
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<X className="h-4 w-4 text-red-600" />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No permissions data</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateApiKeyDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onOpenChange={setCreateDialogOpen}
|
||||||
|
onKeyCreated={handleKeyCreated}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApiKeysSection;
|
||||||
22
webui/src/components/settings/SettingsDialog.tsx
Normal file
22
webui/src/components/settings/SettingsDialog.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import ApiKeysSection from "./ApiKeysSection";
|
||||||
|
|
||||||
|
interface SettingsDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Settings</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<ApiKeysSection />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsDialog;
|
||||||
59
webui/src/components/ui/alert.tsx
Normal file
59
webui/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border p-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Alert.displayName = "Alert"
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertTitle.displayName = "AlertTitle"
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDescription.displayName = "AlertDescription"
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
42
webui/src/components/ui/radio-group.tsx
Normal file
42
webui/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
|
import { Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const RadioGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
|
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
@@ -123,8 +123,8 @@ function renderWithProvider(children: ReactNode) {
|
|||||||
|
|
||||||
describe("InstancesContext", () => {
|
describe("InstancesContext", () => {
|
||||||
const mockInstances: Instance[] = [
|
const mockInstances: Instance[] = [
|
||||||
{ name: "instance1", status: "running", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model1.gguf" } } },
|
{ id: 1, name: "instance1", status: "running", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model1.gguf" } } },
|
||||||
{ name: "instance2", status: "stopped", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model2.gguf" } } },
|
{ id: 2, name: "instance2", status: "stopped", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model2.gguf" } } },
|
||||||
];
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -181,6 +181,7 @@ describe("InstancesContext", () => {
|
|||||||
describe("Create Instance", () => {
|
describe("Create Instance", () => {
|
||||||
it("creates instance and adds it to state", async () => {
|
it("creates instance and adds it to state", async () => {
|
||||||
const newInstance: Instance = {
|
const newInstance: Instance = {
|
||||||
|
id: 3,
|
||||||
name: "new-instance",
|
name: "new-instance",
|
||||||
status: "stopped",
|
status: "stopped",
|
||||||
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "test.gguf" } },
|
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "test.gguf" } },
|
||||||
@@ -238,6 +239,7 @@ describe("InstancesContext", () => {
|
|||||||
describe("Update Instance", () => {
|
describe("Update Instance", () => {
|
||||||
it("updates instance and maintains it in state", async () => {
|
it("updates instance and maintains it in state", async () => {
|
||||||
const updatedInstance: Instance = {
|
const updatedInstance: Instance = {
|
||||||
|
id: 1,
|
||||||
name: "instance1",
|
name: "instance1",
|
||||||
status: "running",
|
status: "running",
|
||||||
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "updated.gguf" } },
|
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "updated.gguf" } },
|
||||||
@@ -408,6 +410,7 @@ describe("InstancesContext", () => {
|
|||||||
it("maintains consistent state during multiple operations", async () => {
|
it("maintains consistent state during multiple operations", async () => {
|
||||||
// Test that operations don't interfere with each other
|
// Test that operations don't interfere with each other
|
||||||
const newInstance: Instance = {
|
const newInstance: Instance = {
|
||||||
|
id: 3,
|
||||||
name: "new-instance",
|
name: "new-instance",
|
||||||
status: "stopped",
|
status: "stopped",
|
||||||
options: {},
|
options: {},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { CreateInstanceOptions, Instance } from "@/types/instance";
|
import type { CreateInstanceOptions, Instance } from "@/types/instance";
|
||||||
import type { AppConfig } from "@/types/config";
|
import type { AppConfig } from "@/types/config";
|
||||||
|
import type { ApiKey, CreateKeyRequest, CreateKeyResponse, KeyPermissionResponse } from "@/types/apiKey";
|
||||||
import { handleApiError } from "./errorUtils";
|
import { handleApiError } from "./errorUtils";
|
||||||
|
|
||||||
// Adding baseURI as a prefix to support being served behind a subpath
|
// Adding baseURI as a prefix to support being served behind a subpath
|
||||||
@@ -178,3 +179,29 @@ export const instancesApi = {
|
|||||||
// GET /instances/{name}/proxy/health
|
// GET /instances/{name}/proxy/health
|
||||||
getHealth: (name: string) => apiCall<Record<string, unknown>>(`/instances/${encodeURIComponent(name)}/proxy/health`),
|
getHealth: (name: string) => apiCall<Record<string, unknown>>(`/instances/${encodeURIComponent(name)}/proxy/health`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// API Keys API functions
|
||||||
|
export const apiKeysApi = {
|
||||||
|
// GET /auth/keys
|
||||||
|
list: () => apiCall<ApiKey[]>("/auth/keys"),
|
||||||
|
|
||||||
|
// GET /auth/keys/{id}
|
||||||
|
get: (id: number) => apiCall<ApiKey>(`/auth/keys/${id}`),
|
||||||
|
|
||||||
|
// POST /auth/keys
|
||||||
|
create: (request: CreateKeyRequest) =>
|
||||||
|
apiCall<CreateKeyResponse>("/auth/keys", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// DELETE /auth/keys/{id}
|
||||||
|
delete: (id: number) =>
|
||||||
|
apiCall<void>(`/auth/keys/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
|
||||||
|
// GET /auth/keys/{id}/permissions
|
||||||
|
getPermissions: (id: number) =>
|
||||||
|
apiCall<KeyPermissionResponse[]>(`/auth/keys/${id}/permissions`),
|
||||||
|
};
|
||||||
|
|||||||
38
webui/src/types/apiKey.ts
Normal file
38
webui/src/types/apiKey.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export enum PermissionMode {
|
||||||
|
AllowAll = "allow_all",
|
||||||
|
PerInstance = "per_instance"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKey {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
user_id: string
|
||||||
|
permission_mode: PermissionMode
|
||||||
|
expires_at: number | null
|
||||||
|
enabled: boolean
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
last_used_at: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateKeyRequest {
|
||||||
|
Name: string
|
||||||
|
PermissionMode: PermissionMode
|
||||||
|
ExpiresAt?: number
|
||||||
|
InstancePermissions: InstancePermission[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstancePermission {
|
||||||
|
InstanceID: number
|
||||||
|
CanInfer: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateKeyResponse extends ApiKey {
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyPermissionResponse {
|
||||||
|
instance_id: number
|
||||||
|
instance_name: string
|
||||||
|
can_infer: boolean
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ export interface HealthStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Instance {
|
export interface Instance {
|
||||||
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
status: InstanceStatus;
|
status: InstanceStatus;
|
||||||
options?: CreateInstanceOptions;
|
options?: CreateInstanceOptions;
|
||||||
|
|||||||
Reference in New Issue
Block a user