mirror of
https://github.com/lordmathis/llamactl.git
synced 2025-12-24 10:04:26 +00:00
Add inference api key frontend integration
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -153,6 +153,7 @@ afterEach(() => {
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
const mockInstance: Instance = {
|
||||
id: 1,
|
||||
name: 'existing-instance',
|
||||
status: 'stopped',
|
||||
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 }
|
||||
Reference in New Issue
Block a user