Add inference api key frontend integration

This commit is contained in:
2025-12-04 23:26:32 +01:00
parent 2d0acc60f2
commit 80d5d44a0b
17 changed files with 921 additions and 33 deletions

View File

@@ -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' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
</Button>
<Button
variant="outline"
size="icon"
onClick={onShowSettings}
data-testid="settings-button"
title="Settings"
>
<Settings className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"

View File

@@ -21,12 +21,14 @@ describe('InstanceCard - Instance Actions and State', () => {
const mockEditInstance = vi.fn()
const stoppedInstance: Instance = {
id: 1,
name: 'test-instance',
status: 'stopped',
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'test-model.gguf' } }
}
const runningInstance: Instance = {
id: 2,
name: 'running-instance',
status: 'running',
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'running-model.gguf' } }
@@ -342,6 +344,7 @@ afterEach(() => {
describe('Error Edge Cases', () => {
it('handles instance with minimal data', () => {
const minimalInstance: Instance = {
id: 3,
name: 'minimal',
status: 'stopped',
options: {}
@@ -364,6 +367,7 @@ afterEach(() => {
it('handles instance with undefined options', () => {
const instanceWithoutOptions: Instance = {
id: 4,
name: 'no-options',
status: 'running',
options: undefined

View File

@@ -59,9 +59,9 @@ describe('InstanceList - State Management and UI Logic', () => {
const mockEditInstance = vi.fn()
const mockInstances: Instance[] = [
{ 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' } } },
{ name: 'instance-3', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model3.gguf' } } }
{ id: 1, name: 'instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } },
{ id: 1, name: 'instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.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'

View File

@@ -153,6 +153,7 @@ afterEach(() => {
describe('Edit Mode', () => {
const mockInstance: Instance = {
id: 1,
name: 'existing-instance',
status: 'stopped',
options: {

View 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;

View 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;

View 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;

View 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 }

View 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 }