diff --git a/framework/configstore/store.go b/framework/configstore/store.go index cf42373d3a..133d07b3fa 100644 --- a/framework/configstore/store.go +++ b/framework/configstore/store.go @@ -538,6 +538,7 @@ func NewConfigStore(ctx context.Context, config *Config, logger schemas.Logger) if !config.Enabled { return nil, nil } + logger.Info("connecting to %s database", config.Type) switch config.Type { case ConfigStoreTypeSQLite: if sqliteConfig, ok := config.Config.(*SQLiteConfig); ok { diff --git a/ui/app/workspace/governance/views/customerDialog.tsx b/ui/app/workspace/governance/views/customerDialog.tsx deleted file mode 100644 index 2578ce504a..0000000000 --- a/ui/app/workspace/governance/views/customerDialog.tsx +++ /dev/null @@ -1,374 +0,0 @@ -import FormFooter from "@/components/formFooter"; -import { Badge } from "@/components/ui/badge"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import NumberAndSelect from "@/components/ui/numberAndSelect"; -import { resetDurationOptions } from "@/lib/constants/governance"; -import { getErrorMessage, useCreateCustomerMutation, useUpdateCustomerMutation } from "@/lib/store"; -import { CreateCustomerRequest, Customer, UpdateCustomerRequest } from "@/lib/types/governance"; -import { formatCurrency } from "@/lib/utils/governance"; -import { Validator } from "@/lib/utils/validation"; -import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; -import { formatDistanceToNow } from "date-fns"; -import isEqual from "lodash.isequal"; -import { useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; - -interface CustomerDialogProps { - customer?: Customer | null; - onSave: () => void; - onCancel: () => void; -} - -interface CustomerFormData { - name: string; - // Budget - budgetMaxLimit: number | undefined; - budgetResetDuration: string; - // Rate Limit - tokenMaxLimit: number | undefined; - tokenResetDuration: string; - requestMaxLimit: number | undefined; - requestResetDuration: string; - isDirty: boolean; -} - -// Helper function to create initial state -const createInitialState = (customer?: Customer | null): Omit => { - return { - name: customer?.name || "", - // Budget - budgetMaxLimit: customer?.budget?.max_limit ?? undefined, - budgetResetDuration: customer?.budget?.reset_duration || "1M", - // Rate Limit - tokenMaxLimit: customer?.rate_limit?.token_max_limit ?? undefined, - tokenResetDuration: customer?.rate_limit?.token_reset_duration || "1h", - requestMaxLimit: customer?.rate_limit?.request_max_limit ?? undefined, - requestResetDuration: customer?.rate_limit?.request_reset_duration || "1h", - }; -}; - -export default function CustomerDialog({ customer, onSave, onCancel }: CustomerDialogProps) { - const isEditing = !!customer; - const [initialState] = useState>(createInitialState(customer)); - const [formData, setFormData] = useState({ - ...initialState, - isDirty: false, - }); - - const hasCreateAccess = useRbac(RbacResource.Customers, RbacOperation.Create); - const hasUpdateAccess = useRbac(RbacResource.Customers, RbacOperation.Update); - const hasPermission = isEditing ? hasUpdateAccess : hasCreateAccess; - - // RTK Query hooks - const [createCustomer, { isLoading: isCreating }] = useCreateCustomerMutation(); - const [updateCustomer, { isLoading: isUpdating }] = useUpdateCustomerMutation(); - const loading = isCreating || isUpdating; - - // Track isDirty state - useEffect(() => { - const currentData = { - name: formData.name, - budgetMaxLimit: formData.budgetMaxLimit, - budgetResetDuration: formData.budgetResetDuration, - tokenMaxLimit: formData.tokenMaxLimit, - tokenResetDuration: formData.tokenResetDuration, - requestMaxLimit: formData.requestMaxLimit, - requestResetDuration: formData.requestResetDuration, - }; - setFormData((prev) => ({ - ...prev, - isDirty: !isEqual(initialState, currentData), - })); - }, [ - formData.name, - formData.budgetMaxLimit, - formData.budgetResetDuration, - formData.tokenMaxLimit, - formData.tokenResetDuration, - formData.requestMaxLimit, - formData.requestResetDuration, - initialState, - ]); - - // Values for validation and submission (already numbers) - const budgetMaxLimitNum = formData.budgetMaxLimit; - const tokenMaxLimitNum = formData.tokenMaxLimit; - const requestMaxLimitNum = formData.requestMaxLimit; - - // Validation - const validator = useMemo( - () => - new Validator([ - // Basic validation - Validator.required(formData.name.trim(), "Customer name is required"), - - // Check if anything is dirty - Validator.custom(formData.isDirty, "No changes to save"), - - // Budget validation - ...(formData.budgetMaxLimit !== undefined && formData.budgetMaxLimit !== null - ? [ - Validator.minValue(budgetMaxLimitNum ?? 0, 0.01, "Budget max limit must be greater than $0.01"), - Validator.required(formData.budgetResetDuration, "Budget reset duration is required"), - ] - : []), - - // Rate limit validation - token limits - ...(formData.tokenMaxLimit !== undefined && formData.tokenMaxLimit !== null - ? [ - Validator.minValue(tokenMaxLimitNum ?? 0, 1, "Token max limit must be at least 1"), - Validator.required(formData.tokenResetDuration, "Token reset duration is required"), - ] - : []), - - // Rate limit validation - request limits - ...(formData.requestMaxLimit !== undefined && formData.requestMaxLimit !== null - ? [ - Validator.minValue(requestMaxLimitNum ?? 0, 1, "Request max limit must be at least 1"), - Validator.required(formData.requestResetDuration, "Request reset duration is required"), - ] - : []), - ]), - [formData, budgetMaxLimitNum, tokenMaxLimitNum, requestMaxLimitNum], - ); - - const updateField = (field: K, value: CustomerFormData[K]) => { - setFormData((prev) => ({ ...prev, [field]: value })); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!validator.isValid()) { - toast.error(validator.getFirstError()); - return; - } - - try { - if (isEditing && customer) { - // Update existing customer - const updateData: UpdateCustomerRequest = { - name: formData.name, - }; - - // Detect budget changes using had/has pattern - const hadBudget = !!customer.budget; - const hasBudget = budgetMaxLimitNum !== undefined && budgetMaxLimitNum !== null; - if (hasBudget) { - updateData.budget = { - max_limit: budgetMaxLimitNum, - reset_duration: formData.budgetResetDuration, - }; - } else if (hadBudget) { - updateData.budget = {} as UpdateCustomerRequest["budget"]; - } - - // Detect rate limit changes using had/has pattern - const hadRateLimit = !!customer.rate_limit; - const hasRateLimit = - (tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null) || - (requestMaxLimitNum !== undefined && requestMaxLimitNum !== null); - if (hasRateLimit) { - updateData.rate_limit = { - token_max_limit: tokenMaxLimitNum, - token_reset_duration: tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null ? formData.tokenResetDuration : undefined, - request_max_limit: requestMaxLimitNum, - request_reset_duration: - requestMaxLimitNum !== undefined && requestMaxLimitNum !== null ? formData.requestResetDuration : undefined, - }; - } else if (hadRateLimit) { - updateData.rate_limit = {} as UpdateCustomerRequest["rate_limit"]; - } - - await updateCustomer({ customerId: customer.id, data: updateData }).unwrap(); - toast.success("Customer updated successfully"); - } else { - // Create new customer - const createData: CreateCustomerRequest = { - name: formData.name, - }; - - // Add budget if enabled - if (budgetMaxLimitNum !== undefined && budgetMaxLimitNum !== null) { - createData.budget = { - max_limit: budgetMaxLimitNum, - reset_duration: formData.budgetResetDuration, - }; - } - - // Add rate limit if enabled (token or request limits) - if ( - (tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null) || - (requestMaxLimitNum !== undefined && requestMaxLimitNum !== null) - ) { - createData.rate_limit = { - token_max_limit: tokenMaxLimitNum, - token_reset_duration: tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null ? formData.tokenResetDuration : undefined, - request_max_limit: requestMaxLimitNum, - request_reset_duration: - requestMaxLimitNum !== undefined && requestMaxLimitNum !== null ? formData.requestResetDuration : undefined, - }; - } - - await createCustomer(createData).unwrap(); - toast.success("Customer created successfully"); - } - - onSave(); - } catch (error) { - toast.error(getErrorMessage(error)); - } - }; - - return ( - - - - {isEditing ? "Edit Customer" : "Create Customer"} - - {isEditing - ? "Update the customer information and settings." - : "Create a new customer account to organize teams and manage resources."} - - - -
-
- {/* Basic Information */} -
-
- - updateField("name", e.target.value)} - /> -

This name will be used to identify the customer account.

-
-
- - {/* Budget Configuration */} - updateField("budgetMaxLimit", value)} - onChangeSelect={(value) => updateField("budgetResetDuration", value)} - options={resetDurationOptions} - dataTestId="budget-max-limit-input" - /> - - {/* Rate Limit Configuration - Token Limits */} - updateField("tokenMaxLimit", value)} - onChangeSelect={(value) => updateField("tokenResetDuration", value)} - options={resetDurationOptions} - /> - - {/* Rate Limit Configuration - Request Limits */} - updateField("requestMaxLimit", value)} - onChangeSelect={(value) => updateField("requestResetDuration", value)} - options={resetDurationOptions} - /> - - {/* Current Usage Section (only shown when editing with existing limits) */} - {isEditing && (customer?.budget || customer?.rate_limit) && ( -
-

Current Usage

-
- {customer?.budget && ( -
-

Budget

-
- - {formatCurrency(customer.budget.current_usage)} / {formatCurrency(customer.budget.max_limit)} - - = customer.budget.max_limit ? "destructive" : "default"} - className="text-xs" - > - {Math.round((customer.budget.current_usage / customer.budget.max_limit) * 100)}% - -
-

- Last Reset: {formatDistanceToNow(new Date(customer.budget.last_reset), { addSuffix: true })} -

-
- )} - {customer?.rate_limit?.token_max_limit && ( -
-

Tokens

-
- - {customer.rate_limit.token_current_usage.toLocaleString()} /{" "} - {customer.rate_limit.token_max_limit.toLocaleString()} - - = customer.rate_limit.token_max_limit ? "destructive" : "default" - } - className="text-xs" - > - {Math.round((customer.rate_limit.token_current_usage / customer.rate_limit.token_max_limit) * 100)}% - -
-

- Last Reset: {formatDistanceToNow(new Date(customer.rate_limit.token_last_reset), { addSuffix: true })} -

-
- )} - {customer?.rate_limit?.request_max_limit && ( -
-

Requests

-
- - {customer.rate_limit.request_current_usage.toLocaleString()} /{" "} - {customer.rate_limit.request_max_limit.toLocaleString()} - - = customer.rate_limit.request_max_limit ? "destructive" : "default" - } - className="text-xs" - > - {Math.round((customer.rate_limit.request_current_usage / customer.rate_limit.request_max_limit) * 100)}% - -
-

- Last Reset: {formatDistanceToNow(new Date(customer.rate_limit.request_last_reset), { addSuffix: true })} -

-
- )} -
-
- )} -
- - - -
-
- ); -} \ No newline at end of file diff --git a/ui/app/workspace/governance/views/customerSheet.tsx b/ui/app/workspace/governance/views/customerSheet.tsx new file mode 100644 index 0000000000..d9880fdb59 --- /dev/null +++ b/ui/app/workspace/governance/views/customerSheet.tsx @@ -0,0 +1,381 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import NumberAndSelect from "@/components/ui/numberAndSelect"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { resetDurationOptions } from "@/lib/constants/governance"; +import { getErrorMessage, useCreateCustomerMutation, useUpdateCustomerMutation } from "@/lib/store"; +import { CreateCustomerRequest, Customer, UpdateCustomerRequest } from "@/lib/types/governance"; +import { formatCurrency } from "@/lib/utils/governance"; +import { Validator } from "@/lib/utils/validation"; +import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; +import { formatDistanceToNow } from "date-fns"; +import isEqual from "lodash.isequal"; +import { Save } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +interface CustomerSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + customer?: Customer | null; + onSuccess?: () => void; +} + +interface CustomerFormData { + name: string; + budgetMaxLimit: number | undefined; + budgetResetDuration: string; + tokenMaxLimit: number | undefined; + tokenResetDuration: string; + requestMaxLimit: number | undefined; + requestResetDuration: string; + isDirty: boolean; +} + +const createInitialState = (customer?: Customer | null): Omit => { + return { + name: customer?.name || "", + budgetMaxLimit: customer?.budget?.max_limit ?? undefined, + budgetResetDuration: customer?.budget?.reset_duration || "1M", + tokenMaxLimit: customer?.rate_limit?.token_max_limit ?? undefined, + tokenResetDuration: customer?.rate_limit?.token_reset_duration || "1h", + requestMaxLimit: customer?.rate_limit?.request_max_limit ?? undefined, + requestResetDuration: customer?.rate_limit?.request_reset_duration || "1h", + }; +}; + +export default function CustomerSheet({ open, onOpenChange, customer, onSuccess }: CustomerSheetProps) { + const isEditing = !!customer; + const [initialState, setInitialState] = useState>(createInitialState(customer)); + const [formData, setFormData] = useState({ + ...createInitialState(customer), + isDirty: false, + }); + + const hasCreateAccess = useRbac(RbacResource.Customers, RbacOperation.Create); + const hasUpdateAccess = useRbac(RbacResource.Customers, RbacOperation.Update); + const hasPermission = isEditing ? hasUpdateAccess : hasCreateAccess; + + const [createCustomer, { isLoading: isCreating }] = useCreateCustomerMutation(); + const [updateCustomer, { isLoading: isUpdating }] = useUpdateCustomerMutation(); + const loading = isCreating || isUpdating; + + useEffect(() => { + if (open) { + const init = createInitialState(customer); + setInitialState(init); + setFormData({ ...init, isDirty: false }); + } + }, [open, customer]); + + useEffect(() => { + const currentData = { + name: formData.name, + budgetMaxLimit: formData.budgetMaxLimit, + budgetResetDuration: formData.budgetResetDuration, + tokenMaxLimit: formData.tokenMaxLimit, + tokenResetDuration: formData.tokenResetDuration, + requestMaxLimit: formData.requestMaxLimit, + requestResetDuration: formData.requestResetDuration, + }; + setFormData((prev) => ({ + ...prev, + isDirty: !isEqual(initialState, currentData), + })); + }, [ + formData.name, + formData.budgetMaxLimit, + formData.budgetResetDuration, + formData.tokenMaxLimit, + formData.tokenResetDuration, + formData.requestMaxLimit, + formData.requestResetDuration, + initialState, + ]); + + const budgetMaxLimitNum = formData.budgetMaxLimit; + const tokenMaxLimitNum = formData.tokenMaxLimit; + const requestMaxLimitNum = formData.requestMaxLimit; + + const validator = useMemo( + () => + new Validator([ + Validator.required(formData.name.trim(), "Customer name is required"), + Validator.custom(formData.isDirty, "No changes to save"), + ...(formData.budgetMaxLimit !== undefined && formData.budgetMaxLimit !== null + ? [ + Validator.minValue(budgetMaxLimitNum ?? 0, 0.01, "Budget max limit must be greater than $0.01"), + Validator.required(formData.budgetResetDuration, "Budget reset duration is required"), + ] + : []), + ...(formData.tokenMaxLimit !== undefined && formData.tokenMaxLimit !== null + ? [ + Validator.minValue(tokenMaxLimitNum ?? 0, 1, "Token max limit must be at least 1"), + Validator.required(formData.tokenResetDuration, "Token reset duration is required"), + ] + : []), + ...(formData.requestMaxLimit !== undefined && formData.requestMaxLimit !== null + ? [ + Validator.minValue(requestMaxLimitNum ?? 0, 1, "Request max limit must be at least 1"), + Validator.required(formData.requestResetDuration, "Request reset duration is required"), + ] + : []), + ]), + [formData, budgetMaxLimitNum, tokenMaxLimitNum, requestMaxLimitNum], + ); + + const updateField = (field: K, value: CustomerFormData[K]) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validator.isValid()) { + toast.error(validator.getFirstError()); + return; + } + + try { + if (isEditing && customer) { + const updateData: UpdateCustomerRequest = { + name: formData.name, + }; + + const hadBudget = !!customer.budget; + const hasBudget = budgetMaxLimitNum !== undefined && budgetMaxLimitNum !== null; + if (hasBudget) { + updateData.budget = { + max_limit: budgetMaxLimitNum, + reset_duration: formData.budgetResetDuration, + }; + } else if (hadBudget) { + updateData.budget = {} as UpdateCustomerRequest["budget"]; + } + + const hadRateLimit = !!customer.rate_limit; + const hasRateLimit = + (tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null) || + (requestMaxLimitNum !== undefined && requestMaxLimitNum !== null); + if (hasRateLimit) { + updateData.rate_limit = { + token_max_limit: tokenMaxLimitNum, + token_reset_duration: tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null ? formData.tokenResetDuration : undefined, + request_max_limit: requestMaxLimitNum, + request_reset_duration: + requestMaxLimitNum !== undefined && requestMaxLimitNum !== null ? formData.requestResetDuration : undefined, + }; + } else if (hadRateLimit) { + updateData.rate_limit = {} as UpdateCustomerRequest["rate_limit"]; + } + + await updateCustomer({ customerId: customer.id, data: updateData }).unwrap(); + toast.success("Customer updated successfully"); + } else { + const createData: CreateCustomerRequest = { + name: formData.name, + }; + + if (budgetMaxLimitNum !== undefined && budgetMaxLimitNum !== null) { + createData.budget = { + max_limit: budgetMaxLimitNum, + reset_duration: formData.budgetResetDuration, + }; + } + + if ( + (tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null) || + (requestMaxLimitNum !== undefined && requestMaxLimitNum !== null) + ) { + createData.rate_limit = { + token_max_limit: tokenMaxLimitNum, + token_reset_duration: tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null ? formData.tokenResetDuration : undefined, + request_max_limit: requestMaxLimitNum, + request_reset_duration: + requestMaxLimitNum !== undefined && requestMaxLimitNum !== null ? formData.requestResetDuration : undefined, + }; + } + + await createCustomer(createData).unwrap(); + toast.success("Customer created successfully"); + } + + onOpenChange(false); + onSuccess?.(); + } catch (error) { + toast.error(getErrorMessage(error)); + } + }; + + const isSubmitDisabled = loading || !validator.isValid() || !hasPermission; + + const getTooltipMessage = () => { + if (!hasPermission) return "You don't have permission to perform this action"; + if (loading) return "Saving..."; + return validator.getFirstError() || "Please fix validation errors"; + }; + + return ( + + + + {isEditing ? "Edit Customer" : "Create Customer"} + + {isEditing + ? "Update the customer information and settings." + : "Create a new customer account to organize teams and manage resources."} + + + +
+
+
+
+
+ + updateField("name", e.target.value)} + /> +

This name will be used to identify the customer account.

+
+
+ + updateField("budgetMaxLimit", value)} + onChangeSelect={(value) => updateField("budgetResetDuration", value)} + options={resetDurationOptions} + dataTestId="budget-max-limit-input" + /> + + updateField("tokenMaxLimit", value)} + onChangeSelect={(value) => updateField("tokenResetDuration", value)} + options={resetDurationOptions} + /> + + updateField("requestMaxLimit", value)} + onChangeSelect={(value) => updateField("requestResetDuration", value)} + options={resetDurationOptions} + /> + + {isEditing && (customer?.budget || customer?.rate_limit) && ( +
+

Current Usage

+
+ {customer?.budget && ( +
+

Budget

+
+ + {formatCurrency(customer.budget.current_usage)} / {formatCurrency(customer.budget.max_limit)} + + = customer.budget.max_limit ? "destructive" : "default"} + className="text-xs" + > + {Math.round((customer.budget.current_usage / customer.budget.max_limit) * 100)}% + +
+

+ Last Reset: {formatDistanceToNow(new Date(customer.budget.last_reset), { addSuffix: true })} +

+
+ )} + {customer?.rate_limit?.token_max_limit && ( +
+

Tokens

+
+ + {customer.rate_limit.token_current_usage.toLocaleString()} /{" "} + {customer.rate_limit.token_max_limit.toLocaleString()} + + = customer.rate_limit.token_max_limit ? "destructive" : "default" + } + className="text-xs" + > + {Math.round((customer.rate_limit.token_current_usage / customer.rate_limit.token_max_limit) * 100)}% + +
+

+ Last Reset: {formatDistanceToNow(new Date(customer.rate_limit.token_last_reset), { addSuffix: true })} +

+
+ )} + {customer?.rate_limit?.request_max_limit && ( +
+

Requests

+
+ + {customer.rate_limit.request_current_usage.toLocaleString()} /{" "} + {customer.rate_limit.request_max_limit.toLocaleString()} + + = customer.rate_limit.request_max_limit ? "destructive" : "default" + } + className="text-xs" + > + {Math.round((customer.rate_limit.request_current_usage / customer.rate_limit.request_max_limit) * 100)}% + +
+

+ Last Reset: {formatDistanceToNow(new Date(customer.rate_limit.request_last_reset), { addSuffix: true })} +

+
+ )} +
+
+ )} +
+
+ + + + + + + + + + + {isSubmitDisabled && ( + +

{getTooltipMessage()}

+
+ )} +
+
+
+
+
+
+ ); +} diff --git a/ui/app/workspace/governance/views/customerTable.tsx b/ui/app/workspace/governance/views/customerTable.tsx index a5415a5587..9f8e02f615 100644 --- a/ui/app/workspace/governance/views/customerTable.tsx +++ b/ui/app/workspace/governance/views/customerTable.tsx @@ -25,7 +25,7 @@ import { Input } from "@/components/ui/input"; import { ChevronLeft, ChevronRight, Edit, MoreHorizontal, Plus, Search, Trash2 } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; -import CustomerDialog from "./customerDialog"; +import CustomerSheet from "./customerSheet"; import { CustomersEmptyState } from "./customersEmptyState"; // Helper to format reset duration for display @@ -117,7 +117,7 @@ export default function CustomersTable({ limit, onOffsetChange, }: CustomersTableProps) { - const [showCustomerDialog, setShowCustomerDialog] = useState(false); + const [showCustomerSheet, setShowCustomerSheet] = useState(false); const [editingCustomer, setEditingCustomer] = useState(null); const [confirmDeleteCustomer, setConfirmDeleteCustomer] = useState(null); @@ -140,16 +140,16 @@ export default function CustomersTable({ const handleAddCustomer = () => { setEditingCustomer(null); - setShowCustomerDialog(true); + setShowCustomerSheet(true); }; const handleEditCustomer = (customer: Customer) => { setEditingCustomer(customer); - setShowCustomerDialog(true); + setShowCustomerSheet(true); }; const handleCustomerSaved = () => { - setShowCustomerDialog(false); + setShowCustomerSheet(false); setEditingCustomer(null); }; @@ -168,9 +168,15 @@ export default function CustomersTable({ return ( <> - {showCustomerDialog && ( - setShowCustomerDialog(false)} /> - )} + { + setShowCustomerSheet(open); + if (!open) setEditingCustomer(null); + }} + customer={editingCustomer} + onSuccess={handleCustomerSaved} + /> @@ -180,9 +186,15 @@ export default function CustomersTable({ return ( <> - {showCustomerDialog && ( - setShowCustomerDialog(false)} /> - )} + { + setShowCustomerSheet(open); + if (!open) setEditingCustomer(null); + }} + customer={editingCustomer} + onSuccess={handleCustomerSaved} + />
diff --git a/ui/app/workspace/governance/virtual-keys/page.tsx b/ui/app/workspace/governance/virtual-keys/page.tsx index 3e9b1d8cd6..c514a457f1 100644 --- a/ui/app/workspace/governance/virtual-keys/page.tsx +++ b/ui/app/workspace/governance/virtual-keys/page.tsx @@ -35,6 +35,7 @@ export default function GovernanceVirtualKeysPage() { offset: parseAsInteger.withDefault(0), sort_by: parseAsString.withDefault(""), order: parseAsString.withDefault(""), + selected_vk: parseAsString.withDefault(""), }, { history: "push" }, ); @@ -147,6 +148,19 @@ export default function GovernanceVirtualKeysPage() { }); }; + const handleSelectedVkChange = ( + id: string, + options?: { offset?: number }, + ) => { + const update: Record = { + selected_vk: id || null, + }; + if (options?.offset !== undefined) { + update.offset = options.offset; + } + setUrlState(update); + }; + return (
); diff --git a/ui/app/workspace/logs/sheets/logDetailsSheet.tsx b/ui/app/workspace/logs/sheets/logDetailsSheet.tsx index 3d9894a87c..2f5bba3256 100644 --- a/ui/app/workspace/logs/sheets/logDetailsSheet.tsx +++ b/ui/app/workspace/logs/sheets/logDetailsSheet.tsx @@ -1,11 +1,12 @@ +import { SheetNavigationButtons } from "@/components/sheetNavigationButtons"; import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { useGetLogByIdQuery } from "@/lib/store/apis/logsApi"; import { useGetPromptQuery } from "@/lib/store/apis/promptsApi"; import type { LogEntry } from "@/lib/types/logs"; -import { ChevronDown, ChevronUp, Loader2 } from "lucide-react"; +import { useSheetNavigation } from "@/hooks/useSheetNavigation"; +import { Loader2 } from "lucide-react"; import { useEffect, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { LogDetailView } from "./logDetailView"; interface LogDetailSheetProps { @@ -61,13 +62,11 @@ export function LogDetailSheet({ }, [shouldPoll]); // Keyboard navigation: arrow up/down to navigate between logs - useHotkeys("up", () => onNavigate?.("prev"), { - enabled: open && hasPrev, - preventDefault: true, - }); - useHotkeys("down", () => onNavigate?.("next"), { - enabled: open && hasNext, - preventDefault: true, + const { prev: prevKeys, next: nextKeys } = useSheetNavigation({ + enabled: open, + hasPrev, + hasNext, + onNavigate: (direction) => onNavigate?.(direction), }); if (!log) return null; @@ -109,30 +108,14 @@ export function LogDetailSheet({ View Session ) : null} -
- - -
+ onNavigate?.(dir)} + prevKeys={prevKeys} + nextKeys={nextKeys} + entityLabel="log" + /> } /> diff --git a/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx b/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx index 3ef51efa1a..e6659377b2 100644 --- a/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx +++ b/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx @@ -1,3 +1,13 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alertDialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Fragment } from "react"; @@ -23,6 +33,8 @@ import { MCPClient, MCPVKConfig } from "@/lib/types/mcp"; import { mcpClientUpdateSchema, type MCPClientUpdateSchema } from "@/lib/types/schemas"; import { parseArrayFromText } from "@/lib/utils/array"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; +import { SheetNavigationButtons } from "@/components/sheetNavigationButtons"; +import { useSheetNavigation } from "@/hooks/useSheetNavigation"; import { zodResolver } from "@hookform/resolvers/zod"; import { ChevronDown, ChevronRight, Info, Plus, Trash2 } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; @@ -33,6 +45,9 @@ interface MCPClientSheetProps { mcpClient: MCPClient; onClose: () => void; onSubmitSuccess: () => void; + onNavigate?: (direction: "prev" | "next") => void; + hasPrev?: boolean; + hasNext?: boolean; } /** API sends tool_sync_interval as nanoseconds (Go time.Duration). Normalize to minutes for form/store. */ @@ -44,9 +59,12 @@ function toolSyncIntervalToMinutes(v: number | undefined | null): number { return n; } -export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess }: MCPClientSheetProps) { +export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess, onNavigate, hasPrev = false, hasNext = false }: MCPClientSheetProps) { const hasUpdateMCPClientAccess = useRbac(RbacResource.MCPGateway, RbacOperation.Update); const [updateMCPClient, { isLoading: isUpdating }] = useUpdateMCPClientMutation(); + + const [pendingNavDirection, setPendingNavDirection] = useState<"prev" | "next" | null>(null); + const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true }); const globalToolSyncInterval = bifrostConfig?.client_config?.mcp_tool_sync_interval ?? 10; const { toast } = useToast(); @@ -194,6 +212,30 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess }: }); }, [form, mcpClient]); + const handleNavigate = (direction: "prev" | "next") => { + if (form.formState.isDirty || vkConfigsDirty) { + setPendingNavDirection(direction); + } else { + onNavigate?.(direction); + } + }; + + const confirmNavigation = () => { + if (pendingNavDirection) { + onNavigate?.(pendingNavDirection); + setPendingNavDirection(null); + } + }; + + const cancelNavigation = () => setPendingNavDirection(null); + + const { prev: prevKeys, next: nextKeys } = useSheetNavigation({ + enabled: !!onNavigate, + hasPrev, + hasNext, + onNavigate: handleNavigate, + }); + const onSubmit = async (data: MCPClientUpdateSchema) => { try { if (mcpClient.config.auth_type === "per_user_headers" && (!data.per_user_header_keys || data.per_user_header_keys.length === 0)) { @@ -375,6 +417,14 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess }: MCP server configuration and available tools
+
@@ -1260,6 +1310,20 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess }: isPerUserOauth={oauthFlow.isPerUserOauth} /> )} + !open && cancelNavigation()}> + + + Unsaved Changes + + You have unsaved changes. Navigating away will discard them. + + + + Stay + Discard & Navigate + + + ); } \ No newline at end of file diff --git a/ui/app/workspace/mcp-registry/views/mcpClientsTable.tsx b/ui/app/workspace/mcp-registry/views/mcpClientsTable.tsx index 693857eddd..b23fc3bd24 100644 --- a/ui/app/workspace/mcp-registry/views/mcpClientsTable.tsx +++ b/ui/app/workspace/mcp-registry/views/mcpClientsTable.tsx @@ -1,4 +1,5 @@ import ClientForm from "@/app/workspace/mcp-registry/views/mcpClientForm"; +import { PIN_SHADOW_RIGHT } from "@/components/table/columnPinning"; import { AlertDialog, AlertDialogAction, @@ -12,7 +13,6 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdownMenu"; -import { PIN_SHADOW_RIGHT } from "@/components/table/columnPinning"; import { Input } from "@/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { useToast } from "@/hooks/use-toast"; @@ -21,9 +21,9 @@ import { getErrorMessage, useDeleteMCPClientMutation, useReconnectMCPClientMutat import { MCPClient } from "@/lib/types/mcp"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; import { ChevronLeft, ChevronRight, Loader2, MoreHorizontal, PencilIcon, Plus, RefreshCcw, Search, Trash2 } from "lucide-react"; -import { useState } from "react"; -import { MCPServersEmptyState } from "./mcpServersEmptyState"; +import { useEffect, useMemo, useState } from "react"; import MCPClientSheet from "./mcpClientSheet"; +import { MCPServersEmptyState } from "./mcpServersEmptyState"; function MCPClientActionsMenu({ client, @@ -245,6 +245,34 @@ export default function MCPClientsTable({ setSelectedMCPClient(null); }; + const selectedMCPClientIndex = useMemo( + () => (selectedMCPClient ? mcpClients.findIndex((c) => c.config.client_id === selectedMCPClient.config.client_id) : -1), + [selectedMCPClient, mcpClients], + ); + + const [pendingEdgeNav, setPendingEdgeNav] = useState<"first" | "last" | null>(null); + + useEffect(() => { + if (pendingEdgeNav && mcpClients.length > 0) { + const target = pendingEdgeNav === "first" ? mcpClients[0] : mcpClients[mcpClients.length - 1]; + setSelectedMCPClient(target); + setPendingEdgeNav(null); + } + }, [pendingEdgeNav, mcpClients]); + + const handleDetailNavigate = (direction: "prev" | "next") => { + const newIndex = direction === "prev" ? selectedMCPClientIndex - 1 : selectedMCPClientIndex + 1; + if (newIndex >= 0 && newIndex < mcpClients.length) { + setSelectedMCPClient(mcpClients[newIndex]); + } else if (direction === "next" && offset + limit < totalCount) { + onOffsetChange(offset + limit); + setPendingEdgeNav("first"); + } else if (direction === "prev" && offset > 0) { + onOffsetChange(Math.max(0, offset - limit)); + setPendingEdgeNav("last"); + } + }; + const handleEditTools = async () => { setShowDetailSheet(false); setSelectedMCPClient(null); @@ -268,7 +296,14 @@ export default function MCPClientsTable({ return (
{showDetailSheet && selectedMCPClient && ( - + 0 || offset > 0} + hasNext={(selectedMCPClientIndex >= 0 && selectedMCPClientIndex < mcpClients.length - 1) || offset + limit < totalCount} + /> )} !open && setClientToDelete(null)}> diff --git a/ui/app/workspace/routing-rules/views/routingRuleInfoSheet.tsx b/ui/app/workspace/routing-rules/views/routingRuleInfoSheet.tsx index 93f48606c6..5e6a6d91e3 100644 --- a/ui/app/workspace/routing-rules/views/routingRuleInfoSheet.tsx +++ b/ui/app/workspace/routing-rules/views/routingRuleInfoSheet.tsx @@ -1,3 +1,4 @@ +import { SheetNavigationButtons } from "@/components/sheetNavigationButtons"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DottedSeparator } from "@/components/ui/separator"; @@ -10,6 +11,7 @@ import { getProviderLabel } from "@/lib/constants/logs"; import { useGetCustomersQuery, useGetTeamsQuery, useGetVirtualKeysQuery } from "@/lib/store/apis/governanceApi"; import { RoutingRule } from "@/lib/types/routingRules"; import { getScopeLabel } from "@/lib/utils/routingRules"; +import { useSheetNavigation } from "@/hooks/useSheetNavigation"; import { formatDistanceToNow } from "date-fns"; import { Check, Copy, GitMerge, Key } from "lucide-react"; import { useMemo, useState } from "react"; @@ -20,6 +22,9 @@ interface Props { rule: RoutingRule | null; open: boolean; onOpenChange: (open: boolean) => void; + onNavigate?: (direction: "prev" | "next") => void; + hasPrev?: boolean; + hasNext?: boolean; } // ─── helpers ──────────────────────────────────────────────────────────────── @@ -234,36 +239,53 @@ function FallbackChain({ fallbacks }: { fallbacks: string[] }) { // ─── main sheet ────────────────────────────────────────────────────────────── -export function RoutingRuleInfoSheet({ rule, open, onOpenChange }: Props) { +export function RoutingRuleInfoSheet({ rule, open, onOpenChange, onNavigate, hasPrev = false, hasNext = false }: Props) { const targets = rule?.targets ?? []; const fallbacks = rule?.fallbacks ?? []; const hasQuery = rule?.query && (rule.query.rules?.length ?? 0) > 0; const scopeName = useScopeName(rule?.scope ?? "global", rule?.scope_id); + const { prev: prevKeys, next: nextKeys } = useSheetNavigation({ + enabled: open, + hasPrev, + hasNext, + onNavigate: (direction) => onNavigate?.(direction), + }); + return ( {rule && ( <> - -
- {rule.name} - {rule.enabled ? "Enabled" : "Disabled"} - {rule.chain_rule && ( - - - - - Chain Rule - - - - After this rule matches, routing rules are re-evaluated using the resolved provider/model as the new context. - - - )} + +
+
+ {rule.name} + {rule.enabled ? "Enabled" : "Disabled"} + {rule.chain_rule && ( + + + + + Chain Rule + + + + After this rule matches, routing rules are re-evaluated using the resolved provider/model as the new context. + + + )} +
+ {rule.description && {rule.description}}
- {rule.description && {rule.description}} + onNavigate?.(dir)} + prevKeys={prevKeys} + nextKeys={nextKeys} + entityLabel="rule" + />
diff --git a/ui/app/workspace/routing-rules/views/routingRulesView.tsx b/ui/app/workspace/routing-rules/views/routingRulesView.tsx index 90e78aea8e..c76980a19d 100644 --- a/ui/app/workspace/routing-rules/views/routingRulesView.tsx +++ b/ui/app/workspace/routing-rules/views/routingRulesView.tsx @@ -10,7 +10,7 @@ import { useGetRoutingRulesQuery } from "@/lib/store/apis/routingRulesApi"; import { RoutingRule } from "@/lib/types/routingRules"; import { GitBranch, Plus } from "lucide-react"; import { Link } from "@tanstack/react-router"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { RoutingRuleInfoSheet } from "./routingRuleInfoSheet"; import { RoutingRuleSheet } from "./routingRuleSheet"; import { RoutingRulesEmptyState } from "./routingRulesEmptyState"; @@ -76,6 +76,23 @@ export function RoutingRulesView() { setInfoSheetOpen(true); }; + const sortedRules = useMemo(() => [...rules].sort((a, b) => a.priority - b.priority), [rules]); + + const selectedRuleIndex = useMemo( + () => (selectedRule ? sortedRules.findIndex((r) => r.id === selectedRule.id) : -1), + [selectedRule, sortedRules], + ); + + const handleRuleNavigate = useCallback( + (direction: "prev" | "next") => { + const newIndex = direction === "prev" ? selectedRuleIndex - 1 : selectedRuleIndex + 1; + if (newIndex >= 0 && newIndex < sortedRules.length) { + setSelectedRule(sortedRules[newIndex]); + } + }, + [selectedRuleIndex, sortedRules], + ); + const handleDialogOpenChange = (open: boolean) => { setDialogOpen(open); if (!open) { @@ -135,7 +152,14 @@ export function RoutingRulesView() { /> - + 0} + hasNext={selectedRuleIndex >= 0 && selectedRuleIndex < sortedRules.length - 1} + />
); } \ No newline at end of file diff --git a/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx b/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx index 796a6fe3cb..88df95d0a4 100644 --- a/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx +++ b/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx @@ -1,3 +1,4 @@ +import { SheetNavigationButtons } from "@/components/sheetNavigationButtons"; import { Badge } from "@/components/ui/badge"; import { Label } from "@/components/ui/label"; import { Progress } from "@/components/ui/progress"; @@ -17,11 +18,12 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { useSheetNavigation } from "@/hooks/useSheetNavigation"; +import { supportsCalendarAlignment } from "@/lib/constants/governance"; import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons"; import { ProviderLabels, ProviderName } from "@/lib/constants/logs"; import { VirtualKey } from "@/lib/types/governance"; import { cn } from "@/lib/utils"; -import { supportsCalendarAlignment } from "@/lib/constants/governance"; import { calculateUsagePercentage, formatCurrency, @@ -83,11 +85,17 @@ function UsageLine({ interface VirtualKeyDetailSheetProps { virtualKey: VirtualKey; onClose: () => void; + onNavigate?: (direction: "prev" | "next") => void; + hasPrev?: boolean; + hasNext?: boolean; } export default function VirtualKeyDetailSheet({ virtualKey, onClose, + onNavigate, + hasPrev = false, + hasNext = false, }: VirtualKeyDetailSheetProps) { const { assignedUsers, @@ -98,6 +106,13 @@ export default function VirtualKeyDetailSheet({ displayRateLimit, } = useVirtualKeyUsage(virtualKey); + const { prev: prevKeys, next: nextKeys } = useSheetNavigation({ + enabled: true, + hasPrev, + hasNext, + onNavigate: (direction) => onNavigate?.(direction), + }); + const getEntityInfo = () => { if (virtualKey.team) { return { type: "Team", name: virtualKey.team.name }; @@ -117,21 +132,30 @@ export default function VirtualKeyDetailSheet({ (displayRateLimit?.token_current_usage && displayRateLimit?.token_max_limit && displayRateLimit.token_current_usage >= - displayRateLimit.token_max_limit) || + displayRateLimit.token_max_limit) || (displayRateLimit?.request_current_usage && displayRateLimit?.request_max_limit && displayRateLimit.request_current_usage >= - displayRateLimit.request_max_limit); + displayRateLimit.request_max_limit); return ( - - {virtualKey.name} - - {virtualKey.description || - "Virtual key details and usage information"} - + +
+ {virtualKey.name} + + {virtualKey.description || "Virtual key details and usage information"} + +
+ onNavigate?.(dir)} + prevKeys={prevKeys} + nextKeys={nextKeys} + entityLabel="virtual key" + />
@@ -223,7 +247,7 @@ export default function VirtualKeyDetailSheet({
{!virtualKey.provider_configs || - virtualKey.provider_configs.length === 0 ? ( + virtualKey.provider_configs.length === 0 ? ( No providers configured (deny-by-default) @@ -503,7 +527,7 @@ export default function VirtualKeyDetailSheet({
{!virtualKey.mcp_configs || - virtualKey.mcp_configs.length === 0 ? ( + virtualKey.mcp_configs.length === 0 ? ( No MCP clients configured (deny-by-default) diff --git a/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx b/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx index da82fb3f37..d315c079cc 100644 --- a/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx +++ b/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx @@ -79,7 +79,7 @@ function virtualKeysToCSV(vks: VirtualKey[], accessProfileNames: Record formatCurrency(b.max_limit)).join("; ") : ""; const budgetSpent = vk.budgets?.length ? vk.budgets.map((b) => formatCurrency(b.current_usage)).join("; ") : ""; const budgetReset = vk.budgets?.length ? vk.budgets.map((b) => formatResetDuration(b.reset_duration)).join("; ") : ""; @@ -259,6 +259,8 @@ interface VirtualKeysTableProps { sortBy?: string; order?: string; onSortChange: (sortBy: string, order: string) => void; + selectedVkId: string; + onSelectedVkChange: (id: string, options?: { offset?: number }) => void; } export default function VirtualKeysTable({ @@ -279,12 +281,12 @@ export default function VirtualKeysTable({ sortBy, order, onSortChange, + selectedVkId, + onSelectedVkChange, }: VirtualKeysTableProps) { const [showVirtualKeySheet, setShowVirtualKeySheet] = useState(false); const [editingVirtualKeyId, setEditingVirtualKeyId] = useState(null); const [revealedKeys, setRevealedKeys] = useState>(new Set()); - const [selectedVirtualKeyId, setSelectedVirtualKeyId] = useState(null); - const [showDetailSheet, setShowDetailSheet] = useState(false); const [showExportDialog, setShowExportDialog] = useState(false); const [exportScope, setExportScope] = useState("current_page"); const [exportMaxLimit, setExportMaxLimit] = useState(""); @@ -298,8 +300,8 @@ export default function VirtualKeysTable({ [editingVirtualKeyId, virtualKeys], ); const selectedVirtualKey = useMemo( - () => (selectedVirtualKeyId ? (virtualKeys.find((vk) => vk.id === selectedVirtualKeyId) ?? null) : null), - [selectedVirtualKeyId, virtualKeys], + () => (selectedVkId ? (virtualKeys.find((vk) => vk.id === selectedVkId) ?? null) : null), + [selectedVkId, virtualKeys], ); const hasCreateAccess = useRbac(RbacResource.VirtualKeys, RbacOperation.Create); @@ -422,13 +424,67 @@ export default function VirtualKeysTable({ }; const handleRowClick = (vk: VirtualKey) => { - setSelectedVirtualKeyId(vk.id); - setShowDetailSheet(true); + onSelectedVkChange(vk.id); }; const handleDetailSheetClose = () => { - setShowDetailSheet(false); - setSelectedVirtualKeyId(null); + onSelectedVkChange(""); + }; + + const selectedVirtualKeyIndex = useMemo( + () => (selectedVkId ? virtualKeys.findIndex((vk) => vk.id === selectedVkId) : -1), + [selectedVkId, virtualKeys], + ); + + const handleDetailNavigate = (direction: "prev" | "next") => { + const currentVkId = selectedVkId; + if (direction === "prev") { + if (selectedVirtualKeyIndex > 0) { + onSelectedVkChange(virtualKeys[selectedVirtualKeyIndex - 1].id); + } else if (offset > 0) { + const newOffset = Math.max(0, offset - limit); + onSelectedVkChange("", { offset: newOffset }); + fetchVirtualKeys({ + limit, + offset: newOffset, + search: debouncedSearch || undefined, + customer_id: customerFilter || undefined, + team_id: teamFilter || undefined, + sort_by: (sortBy as "name" | "budget_spent" | "created_at" | "status") || undefined, + order: (order as "asc" | "desc") || undefined, + }).then((result) => { + if (result.data?.virtual_keys?.length) { + const lastVk = result.data.virtual_keys[result.data.virtual_keys.length - 1]; + onSelectedVkChange(lastVk.id); + } else if (result.error) { + onSelectedVkChange(currentVkId, { offset }); + } + }); + } + } else { + if (selectedVirtualKeyIndex >= 0 && selectedVirtualKeyIndex < virtualKeys.length - 1) { + onSelectedVkChange(virtualKeys[selectedVirtualKeyIndex + 1].id); + } else if (offset + limit < totalCount) { + const newOffset = offset + limit; + onSelectedVkChange("", { offset: newOffset }); + fetchVirtualKeys({ + limit, + offset: newOffset, + search: debouncedSearch || undefined, + customer_id: customerFilter || undefined, + team_id: teamFilter || undefined, + sort_by: (sortBy as "name" | "budget_spent" | "created_at" | "status") || undefined, + order: (order as "asc" | "desc") || undefined, + }).then((result) => { + if (result.data?.virtual_keys?.length) { + const firstVk = result.data.virtual_keys[0]; + onSelectedVkChange(firstVk.id); + } else if (result.error) { + onSelectedVkChange(currentVkId, { offset }); + } + }); + } + } }; const toggleKeyVisibility = (vkId: string) => { @@ -542,7 +598,15 @@ export default function VirtualKeysTable({ /> )} - {showDetailSheet && selectedVirtualKey && } + {!!selectedVkId && selectedVirtualKey && ( + 0 || (selectedVirtualKeyIndex !== -1 && offset > 0)} + hasNext={selectedVirtualKeyIndex !== -1 && (selectedVirtualKeyIndex < virtualKeys.length - 1 || offset + limit < totalCount)} + /> + )} {/* Export Dialog */} @@ -659,8 +723,8 @@ export default function VirtualKeysTable({ -
-
+
+

Virtual Keys

Manage virtual keys, their permissions, budgets, and rate limits.

@@ -689,7 +753,7 @@ export default function VirtualKeysTable({
{/* Toolbar: Search + Filters */} -
+
-
+
@@ -846,30 +910,38 @@ export default function VirtualKeysTable({ {/* Pagination */} {totalCount > 0 && ( -
-

- Showing {offset + 1}-{Math.min(offset + limit, totalCount)} of {totalCount} -

-
+
+
+ {(offset + 1).toLocaleString()}-{Math.min(offset + limit, totalCount).toLocaleString()} of {totalCount.toLocaleString()} entries +
+ +
+ +
+ Page + {Math.floor(offset / limit) + 1} + of {Math.ceil(totalCount / limit)} +
+
diff --git a/ui/components/sheetNavigationButtons.tsx b/ui/components/sheetNavigationButtons.tsx new file mode 100644 index 0000000000..c9213e82e0 --- /dev/null +++ b/ui/components/sheetNavigationButtons.tsx @@ -0,0 +1,80 @@ +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import type { ShortcutKey } from "@/hooks/useSheetNavigation"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import React from "react"; + +const kbdClass = + "inline-flex items-center justify-center size-4 rounded border border-border/60 bg-muted/80 text-[10px] leading-none text-muted-foreground shadow-[0_1px_0_0.5px] shadow-border/40"; + +interface SheetNavigationButtonsProps { + hasPrev: boolean; + hasNext: boolean; + onNavigate: (direction: "prev" | "next") => void; + prevKeys?: ShortcutKey[]; + nextKeys?: ShortcutKey[]; + entityLabel?: string; +} + +function ShortcutKeys({ keys }: { keys: ShortcutKey[] }) { + return ( + + {keys.map((k, i) => ( + + {i > 0 && "or"} + + {k.icon ? : k.label} + + + ))} + + ); +} + +export function SheetNavigationButtons({ + hasPrev, + hasNext, + onNavigate, + prevKeys, + nextKeys, + entityLabel = "item", +}: SheetNavigationButtonsProps) { + return ( +
+ + + + + + Prev {prevKeys && } + + + + + + + + Next {nextKeys && } + + +
+ ); +} diff --git a/ui/components/ui/sheet.tsx b/ui/components/ui/sheet.tsx index e93d101f63..9301dd98c0 100644 --- a/ui/components/ui/sheet.tsx +++ b/ui/components/ui/sheet.tsx @@ -56,6 +56,7 @@ function SheetContent({ expandable = false, onPointerDownOutside, onInteractOutside, + onOpenAutoFocus, ...props }: React.ComponentProps & { side?: "top" | "right" | "bottom" | "left"; @@ -102,6 +103,10 @@ function SheetContent({ data-slot="sheet-content" onPointerDownOutside={handlePointerDownOutside} onInteractOutside={handleInteractOutside} + onOpenAutoFocus={(e) => { + e.preventDefault(); + onOpenAutoFocus?.(e); + }} className={cn( "bg-card data-[state=open]:animate-in data-[state=closed]:animate-out custom-scrollbar fixed z-50 flex flex-col shadow-lg transition-all ease-in-out overscroll-none data-[state=closed]:duration-100 data-[state=open]:duration-100", side === "right" && diff --git a/ui/hooks/useSheetNavigation.ts b/ui/hooks/useSheetNavigation.ts new file mode 100644 index 0000000000..b715af28c6 --- /dev/null +++ b/ui/hooks/useSheetNavigation.ts @@ -0,0 +1,40 @@ +import { type LucideIcon, ArrowUp, ArrowDown } from "lucide-react"; +import { useHotkeys } from "react-hotkeys-hook"; + +interface UseSheetNavigationOptions { + enabled: boolean; + hasPrev: boolean; + hasNext: boolean; + onNavigate: (direction: "prev" | "next") => void; +} + +export interface ShortcutKey { + icon?: LucideIcon; + label?: string; +} + +export interface SheetNavigationShortcuts { + prev: ShortcutKey[]; + next: ShortcutKey[]; +} + +export function useSheetNavigation({ + enabled, + hasPrev, + hasNext, + onNavigate, +}: UseSheetNavigationOptions): SheetNavigationShortcuts { + useHotkeys("up,k", () => onNavigate("prev"), { + enabled: enabled && hasPrev, + preventDefault: true, + }); + useHotkeys("down,j", () => onNavigate("next"), { + enabled: enabled && hasNext, + preventDefault: true, + }); + + return { + prev: [{ icon: ArrowUp }, { label: "K" }], + next: [{ icon: ArrowDown }, { label: "J" }], + }; +}