diff --git a/webui/package-lock.json b/webui/package-lock.json index 0d657f7..cd6b0ea 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -12,10 +12,12 @@ "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-slot": "^1.2.3", "@tailwindcss/vite": "^4.1.11", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.555.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -160,7 +162,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -510,7 +511,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -554,7 +554,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "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": { "version": "1.1.2", "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": { "version": "1.1.10", "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": { "version": "1.2.3", "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", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2447,7 +2587,6 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2458,7 +2597,6 @@ "integrity": "sha512-tBFxBp9Nfyy5rsmefN+WXc1JeW/j2BpBHFdLZbEVfs9wn3E3NRFxwV0pJg8M1qQAexFpvz73hJXFofV0ZAu92A==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2469,7 +2607,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2520,7 +2657,6 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -2869,7 +3005,6 @@ "integrity": "sha512-F9jI5rSstNknPlTlPN2gcc4gpbaagowuRzw/OJzl368dvPun668Q182S8Q8P9PITgGCl5LAKXpzuue106eM4wA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.8", "fflate": "^0.8.2", @@ -2906,7 +3041,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2957,6 +3091,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -3228,7 +3363,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -3540,6 +3674,16 @@ "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": { "version": "4.4.3", "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", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -3955,7 +4100,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5181,7 +5325,6 @@ "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.23", "@asamuzakjp/dom-selector": "^6.7.4", @@ -5592,6 +5735,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -5977,7 +6121,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6039,6 +6182,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6054,6 +6198,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6095,7 +6240,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6105,7 +6249,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6118,7 +6261,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -7072,7 +7216,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7220,7 +7363,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -7296,7 +7438,6 @@ "integrity": "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.8", "@vitest/mocker": "4.0.8", @@ -7625,7 +7766,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/webui/package.json b/webui/package.json index e998fba..a66e024 100644 --- a/webui/package.json +++ b/webui/package.json @@ -21,10 +21,12 @@ "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-slot": "^1.2.3", "@tailwindcss/vite": "^4.1.11", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.555.0", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 04c8c01..fc62225 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -4,6 +4,7 @@ import InstanceList from "@/components/InstanceList"; import InstanceDialog from "@/components/InstanceDialog"; import LoginDialog from "@/components/LoginDialog"; import SystemInfoDialog from "./components/SystemInfoDialog"; +import SettingsDialog from "./components/settings/SettingsDialog"; import { type CreateInstanceOptions, type Instance } from "@/types/instance"; import { useInstances } from "@/contexts/InstancesContext"; import { useAuth } from "@/contexts/AuthContext"; @@ -14,6 +15,7 @@ function App() { const { isAuthenticated, isLoading: authLoading } = useAuth(); const [isInstanceModalOpen, setIsInstanceModalOpen] = useState(false); const [isSystemInfoModalOpen, setIsSystemInfoModalOpen] = useState(false); + const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); const [editingInstance, setEditingInstance] = useState( undefined ); @@ -41,6 +43,10 @@ function App() { setIsSystemInfoModalOpen(true); }; + const handleShowSettings = () => { + setIsSettingsModalOpen(true); + }; + // Show loading spinner while checking auth if (authLoading) { return ( @@ -70,7 +76,11 @@ function App() { return (
-
+
@@ -86,7 +96,12 @@ function App() { open={isSystemInfoModalOpen} onOpenChange={setIsSystemInfoModalOpen} /> - + + +
diff --git a/webui/src/__tests__/App.test.tsx b/webui/src/__tests__/App.test.tsx index eb212a4..2aa39f6 100644 --- a/webui/src/__tests__/App.test.tsx +++ b/webui/src/__tests__/App.test.tsx @@ -75,8 +75,8 @@ function renderApp() { describe('App Component - Critical Business Logic Only', () => { const mockInstances: Instance[] = [ - { 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: 1, name: 'test-instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } }, + { id: 2, name: 'test-instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.gguf' } } } ] beforeEach(() => { @@ -109,6 +109,7 @@ describe('App Component - Critical Business Logic Only', () => { it('creates new instance with correct API call and updates UI', async () => { const user = userEvent.setup() const newInstance: Instance = { + id: 3, name: 'new-test-instance', status: 'stopped', 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 () => { const user = userEvent.setup() const updatedInstance: Instance = { + id: 1, name: 'test-instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'updated-model.gguf' } } diff --git a/webui/src/components/Header.tsx b/webui/src/components/Header.tsx index 3c7e0e1..d2af49f 100644 --- a/webui/src/components/Header.tsx +++ b/webui/src/components/Header.tsx @@ -1,14 +1,15 @@ 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 { useTheme } from "@/contexts/ThemeContext"; interface HeaderProps { onCreateInstance: () => void; onShowSystemInfo: () => void; + onShowSettings: () => void; } -function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) { +function Header({ onCreateInstance, onShowSystemInfo, onShowSettings }: HeaderProps) { const { logout } = useAuth(); const { theme, toggleTheme } = useTheme(); @@ -41,6 +42,16 @@ function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) { {theme === 'light' ? : } + + + + + + + + ); +} + +export default CreateApiKeyDialog; diff --git a/webui/src/components/settings/ApiKeysSection.tsx b/webui/src/components/settings/ApiKeysSection.tsx new file mode 100644 index 0000000..26fe928 --- /dev/null +++ b/webui/src/components/settings/ApiKeysSection.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [expandedRowId, setExpandedRowId] = useState(null); + const [newKeyPlainText, setNewKeyPlainText] = useState(null); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [copiedKey, setCopiedKey] = useState(false); + const [permissions, setPermissions] = useState>({}); + const [loadingPermissions, setLoadingPermissions] = useState>({}); + + 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 ( +
+
+

API Keys

+ +
+ + {newKeyPlainText && ( + + +
+
+

API key created successfully

+

+ Make sure to copy this key now. You won't be able to see it again! +

+
+ +
+
+ + {newKeyPlainText} + + +
+
+
+ )} + + {error && ( + + {error} + + )} + + {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : keys.length === 0 ? ( +
+ No API keys yet. Create your first key to get started. +
+ ) : ( +
+ + + + + + + + + + + + + {keys.map((key) => ( + <> + handleRowClick(key)} + > + + + + + + + + {expandedRowId === key.id && ( + + + + )} + + ))} + +
NamePermissionsCreatedExpiresLast AccessedActions
+
+ {expandedRowId === key.id ? ( + + ) : ( + + )} + {key.name} +
+
+ {key.permission_mode === PermissionMode.AllowAll ? ( + Full Access + ) : ( + Limited Access + )} + {formatDate(key.created_at)} + {key.expires_at ? ( + isExpired(key.expires_at) ? ( + Expired + ) : ( + {formatDate(key.expires_at)} + ) + ) : ( + Never + )} + {formatLastUsed(key.last_used_at)} + +
+ {key.permission_mode === PermissionMode.AllowAll ? ( +

+ This key has full access to all instances +

+ ) : loadingPermissions[key.id] ? ( +

Loading permissions...

+ ) : permissions[key.id] ? ( +
+

Instance Permissions:

+ + + + + + + + + {permissions[key.id].map((perm) => ( + + + + + ))} + +
Instance NameCan Infer
{perm.instance_name} + {perm.can_infer ? ( + + ) : ( + + )} +
+
+ ) : ( +

No permissions data

+ )} +
+
+ )} + + +
+ ); +} + +export default ApiKeysSection; diff --git a/webui/src/components/settings/SettingsDialog.tsx b/webui/src/components/settings/SettingsDialog.tsx new file mode 100644 index 0000000..9bd2e18 --- /dev/null +++ b/webui/src/components/settings/SettingsDialog.tsx @@ -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 ( + + + + Settings + + + + + ); +} + +export default SettingsDialog; diff --git a/webui/src/components/ui/alert.tsx b/webui/src/components/ui/alert.tsx new file mode 100644 index 0000000..fd81ebc --- /dev/null +++ b/webui/src/components/ui/alert.tsx @@ -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 & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/webui/src/components/ui/radio-group.tsx b/webui/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..43b43b4 --- /dev/null +++ b/webui/src/components/ui/radio-group.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ) +}) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ) +}) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + +export { RadioGroup, RadioGroupItem } diff --git a/webui/src/contexts/__tests__/InstancesContext.test.tsx b/webui/src/contexts/__tests__/InstancesContext.test.tsx index 1920d6a..cad603d 100644 --- a/webui/src/contexts/__tests__/InstancesContext.test.tsx +++ b/webui/src/contexts/__tests__/InstancesContext.test.tsx @@ -123,8 +123,8 @@ function renderWithProvider(children: ReactNode) { describe("InstancesContext", () => { const mockInstances: Instance[] = [ - { 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: 1, name: "instance1", status: "running", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model1.gguf" } } }, + { id: 2, name: "instance2", status: "stopped", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model2.gguf" } } }, ]; beforeEach(() => { @@ -181,6 +181,7 @@ describe("InstancesContext", () => { describe("Create Instance", () => { it("creates instance and adds it to state", async () => { const newInstance: Instance = { + id: 3, name: "new-instance", status: "stopped", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "test.gguf" } }, @@ -238,6 +239,7 @@ describe("InstancesContext", () => { describe("Update Instance", () => { it("updates instance and maintains it in state", async () => { const updatedInstance: Instance = { + id: 1, name: "instance1", status: "running", 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 () => { // Test that operations don't interfere with each other const newInstance: Instance = { + id: 3, name: "new-instance", status: "stopped", options: {}, diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 2ac679c..ddcf384 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -1,5 +1,6 @@ import type { CreateInstanceOptions, Instance } from "@/types/instance"; import type { AppConfig } from "@/types/config"; +import type { ApiKey, CreateKeyRequest, CreateKeyResponse, KeyPermissionResponse } from "@/types/apiKey"; import { handleApiError } from "./errorUtils"; // Adding baseURI as a prefix to support being served behind a subpath @@ -178,3 +179,29 @@ export const instancesApi = { // GET /instances/{name}/proxy/health getHealth: (name: string) => apiCall>(`/instances/${encodeURIComponent(name)}/proxy/health`), }; + +// API Keys API functions +export const apiKeysApi = { + // GET /auth/keys + list: () => apiCall("/auth/keys"), + + // GET /auth/keys/{id} + get: (id: number) => apiCall(`/auth/keys/${id}`), + + // POST /auth/keys + create: (request: CreateKeyRequest) => + apiCall("/auth/keys", { + method: "POST", + body: JSON.stringify(request), + }), + + // DELETE /auth/keys/{id} + delete: (id: number) => + apiCall(`/auth/keys/${id}`, { + method: "DELETE", + }), + + // GET /auth/keys/{id}/permissions + getPermissions: (id: number) => + apiCall(`/auth/keys/${id}/permissions`), +}; diff --git a/webui/src/types/apiKey.ts b/webui/src/types/apiKey.ts new file mode 100644 index 0000000..21a758d --- /dev/null +++ b/webui/src/types/apiKey.ts @@ -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 +} diff --git a/webui/src/types/instance.ts b/webui/src/types/instance.ts index 0977233..27e54a5 100644 --- a/webui/src/types/instance.ts +++ b/webui/src/types/instance.ts @@ -24,6 +24,7 @@ export interface HealthStatus { } export interface Instance { + id: number; name: string; status: InstanceStatus; options?: CreateInstanceOptions;