diff --git a/src/App.test.tsx b/src/App.test.tsx index 4a6affe3..4f1c8079 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -8,13 +8,17 @@ describe("App", () => { it("should render header", async () => { render(<App />); expect(screen.getByText(/toggle sidebar/i)).toBeVisible(); - expect(screen.getByText("Certificates")).toBeVisible(); + expect(screen.getByText("Settings")).toBeVisible(); expect(screen.getByText("Help")).toBeVisible(); expect(screen.getByRole("banner", { name: "App header" })).toBeVisible(); expect(screen.getByRole("heading", { name: /codeGate/i })).toBeVisible(); - await userEvent.click(screen.getByText("Certificates")); - + await userEvent.click(screen.getByText("Settings")); + expect( + screen.getByRole("menuitem", { + name: /providers/i, + }), + ).toBeVisible(); expect( screen.getByRole("menuitem", { name: /certificate security/i, @@ -26,7 +30,7 @@ describe("App", () => { }), ).toBeVisible(); - await userEvent.click(screen.getByText("Certificates")); + await userEvent.click(screen.getByText("Settings")); await userEvent.click(screen.getByText("Help")); expect( diff --git a/src/Page.tsx b/src/Page.tsx index 1abcfcde..65d5c1ce 100644 --- a/src/Page.tsx +++ b/src/Page.tsx @@ -8,6 +8,9 @@ import { RouteDashboard } from "./routes/route-dashboard"; import { RouteCertificateSecurity } from "./routes/route-certificate-security"; import { RouteWorkspaceCreation } from "./routes/route-workspace-creation"; import { RouteNotFound } from "./routes/route-not-found"; +import { RouteProvider } from "./routes/route-providers"; +import { RouteProviderCreate } from "./routes/route-provider-create"; +import { RouteProviderUpdate } from "./routes/route-provider-update"; export default function Page() { return ( @@ -22,6 +25,15 @@ export default function Page() { path="/certificates/security" element={<RouteCertificateSecurity />} /> + + <Route path="providers"> + <Route index element={<RouteProvider />} /> + <Route element={<RouteProvider />}> + <Route path=":id" element={<RouteProviderUpdate />} /> + <Route path="new" element={<RouteProviderCreate />} /> + </Route> + </Route> + <Route path="*" element={<RouteNotFound />} /> </Routes> ); diff --git a/src/api/generated/types.gen.ts b/src/api/generated/types.gen.ts index ec403510..2a202061 100644 --- a/src/api/generated/types.gen.ts +++ b/src/api/generated/types.gen.ts @@ -10,6 +10,19 @@ export type ActiveWorkspace = { last_updated: unknown; }; +/** + * Represents a request to add a provider endpoint. + */ +export type AddProviderEndpointRequest = { + id?: string | null; + name: string; + description?: string; + provider_type: ProviderType; + endpoint?: string; + auth_type?: ProviderAuthType | null; + api_key?: string | null; +}; + /** * Represents an alert with it's respective conversation. */ @@ -100,7 +113,6 @@ export type ModelByProvider = { * Represents the different types of matchers we support. */ export enum MuxMatcherType { - FILE_REGEX = "file_regex", CATCH_ALL = "catch_all", } @@ -133,7 +145,7 @@ export type ProviderEndpoint = { name: string; description?: string; provider_type: ProviderType; - endpoint: string; + endpoint?: string; auth_type?: ProviderAuthType | null; }; @@ -219,7 +231,7 @@ export type V1ListProviderEndpointsResponse = Array<ProviderEndpoint>; export type V1ListProviderEndpointsError = HTTPValidationError; export type V1AddProviderEndpointData = { - body: ProviderEndpoint; + body: AddProviderEndpointRequest; }; export type V1AddProviderEndpointResponse = ProviderEndpoint; diff --git a/src/api/openapi.json b/src/api/openapi.json index 67d67ecb..6536af0f 100644 --- a/src/api/openapi.json +++ b/src/api/openapi.json @@ -92,7 +92,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderEndpoint" + "$ref": "#/components/schemas/AddProviderEndpointRequest" } } } @@ -1113,6 +1113,68 @@ ], "title": "ActiveWorkspace" }, + "AddProviderEndpointRequest": { + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Id", + "default": "" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "type": "string", + "title": "Description", + "default": "" + }, + "provider_type": { + "$ref": "#/components/schemas/ProviderType" + }, + "endpoint": { + "type": "string", + "title": "Endpoint", + "default": "" + }, + "auth_type": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthType" + }, + { + "type": "null" + } + ], + "default": "none" + }, + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Key" + } + }, + "type": "object", + "required": [ + "name", + "provider_type" + ], + "title": "AddProviderEndpointRequest", + "description": "Represents a request to add a provider endpoint." + }, "AlertConversation": { "properties": { "conversation": { @@ -1437,7 +1499,6 @@ "MuxMatcherType": { "type": "string", "enum": [ - "file_regex", "catch_all" ], "title": "MuxMatcherType", @@ -1515,7 +1576,8 @@ }, "endpoint": { "type": "string", - "title": "Endpoint" + "title": "Endpoint", + "default": "" }, "auth_type": { "anyOf": [ @@ -1532,8 +1594,7 @@ "type": "object", "required": [ "name", - "provider_type", - "endpoint" + "provider_type" ], "title": "ProviderEndpoint", "description": "Represents a provider's endpoint configuration. This\nallows us to persist the configuration for each provider,\nso we can use this for muxing messages." diff --git a/src/features/header/components/header.tsx b/src/features/header/components/header.tsx index ce7f7c2e..19eaf836 100644 --- a/src/features/header/components/header.tsx +++ b/src/features/header/components/header.tsx @@ -3,9 +3,9 @@ import { SidebarTrigger } from "../../../components/ui/sidebar"; import { DropdownMenu } from "../../../components/HoverPopover"; import { Separator, ButtonDarkMode } from "@stacklok/ui-kit"; import { WorkspacesSelection } from "@/features/workspace/components/workspaces-selection"; -import { CERTIFICATE_MENU_ITEMS } from "../constants/certificate-menu-items"; import { HELP_MENU_ITEMS } from "../constants/help-menu-items"; import { HeaderStatusMenu } from "./header-status-menu"; +import { SETTINGS_MENU_ITEMS } from "../constants/settings-menu-items"; function HomeLink() { return ( @@ -39,8 +39,8 @@ export function Header({ hasError }: { hasError?: boolean }) { </div> <div className="flex items-center gap-1 mr-2"> <HeaderStatusMenu /> - <DropdownMenu title="Certificates" items={CERTIFICATE_MENU_ITEMS} /> <DropdownMenu title="Help" items={HELP_MENU_ITEMS} /> + <DropdownMenu title="Settings" items={SETTINGS_MENU_ITEMS} /> <ButtonDarkMode /> </div> diff --git a/src/features/header/constants/certificate-menu-items.tsx b/src/features/header/constants/certificate-menu-items.tsx deleted file mode 100644 index 64ce0b51..00000000 --- a/src/features/header/constants/certificate-menu-items.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { OptionsSchema } from "@stacklok/ui-kit"; -import { Download01, ShieldTick } from "@untitled-ui/icons-react"; - -export const CERTIFICATE_MENU_ITEMS = [ - { - icon: <ShieldTick />, - id: "about-certificate-security", - href: "/certificates/security", - textValue: "About certificate security", - }, - { - icon: <Download01 />, - id: "download-certificates", - href: "/certificates", - textValue: "Download certificates", - }, -] as const satisfies OptionsSchema<"menu">[]; diff --git a/src/features/header/constants/settings-menu-items.tsx b/src/features/header/constants/settings-menu-items.tsx new file mode 100644 index 00000000..f85a70f2 --- /dev/null +++ b/src/features/header/constants/settings-menu-items.tsx @@ -0,0 +1,39 @@ +import { OptionsSchema } from "@stacklok/ui-kit"; +import { + Download01, + LayersThree01, + ShieldTick, +} from "@untitled-ui/icons-react"; + +export const SETTINGS_MENU_ITEMS = [ + { + textValue: "Providers", + id: "providers", + items: [ + { + icon: <LayersThree01 />, + id: "providers", + href: "/providers", + textValue: "Providers", + }, + ], + }, + { + textValue: "Certificates", + id: "certificates", + items: [ + { + icon: <ShieldTick />, + id: "about-certificate-security", + href: "/certificates/security", + textValue: "About certificate security", + }, + { + icon: <Download01 />, + id: "download-certificates", + href: "/certificates", + textValue: "Download certificates", + }, + ], + }, +] as const satisfies OptionsSchema<"menu">[]; diff --git a/src/features/providers/components/provider-dialog-footer.tsx b/src/features/providers/components/provider-dialog-footer.tsx new file mode 100644 index 00000000..3297c601 --- /dev/null +++ b/src/features/providers/components/provider-dialog-footer.tsx @@ -0,0 +1,17 @@ +import { Button, DialogFooter } from "@stacklok/ui-kit"; +import { useNavigate } from "react-router-dom"; + +export function ProviderDialogFooter() { + const navigate = useNavigate(); + + return ( + <DialogFooter className="justify-end"> + <Button variant="secondary" onPress={() => navigate("/providers")}> + Discard + </Button> + <Button type="submit" variant="primary"> + Save + </Button> + </DialogFooter> + ); +} diff --git a/src/features/providers/components/provider-dialog.tsx b/src/features/providers/components/provider-dialog.tsx new file mode 100644 index 00000000..44ae7de9 --- /dev/null +++ b/src/features/providers/components/provider-dialog.tsx @@ -0,0 +1,39 @@ +import { + DialogModalOverlay, + DialogModal, + Dialog, + DialogHeader, + DialogTitle, + DialogCloseButton, +} from "@stacklok/ui-kit"; +import { useNavigate } from "react-router-dom"; + +export function ProviderDialog({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + const navigate = useNavigate(); + + return ( + <DialogModalOverlay + isDismissable={false} + isOpen + onOpenChange={() => { + navigate("/providers"); + }} + > + <DialogModal> + <Dialog width="md" className=""> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + <DialogCloseButton slot="close" /> + </DialogHeader> + {children} + </Dialog> + </DialogModal> + </DialogModalOverlay> + ); +} diff --git a/src/features/providers/components/provider-form.tsx b/src/features/providers/components/provider-form.tsx new file mode 100644 index 00000000..0614af40 --- /dev/null +++ b/src/features/providers/components/provider-form.tsx @@ -0,0 +1,128 @@ +import { AddProviderEndpointRequest, ProviderAuthType } from "@/api/generated"; +import { + Label, + Select, + SelectButton, + Input, + TextField, +} from "@stacklok/ui-kit"; +import { + getAuthTypeOptions, + getProviderType, + isProviderAuthType, + isProviderType, +} from "../lib/utils"; + +interface Props { + provider: AddProviderEndpointRequest; + setProvider: (provider: AddProviderEndpointRequest) => void; +} + +export function ProviderForm({ provider, setProvider }: Props) { + return ( + <div className="w-full"> + <div className=""> + <TextField + aria-label="Provider name" + name="name" + validationBehavior="aria" + isRequired + onChange={(name) => setProvider({ ...provider, name })} + > + <Label>Name</Label> + <Input value={provider.name} placeholder="Provider name" /> + </TextField> + </div> + <div className="py-3"> + <Label id="provider-type">Provider</Label> + <Select + aria-labelledby="provider type" + selectedKey={provider.provider_type} + name="provider_type" + isRequired + className="w-full" + placeholder="Select the provider type" + items={getProviderType()} + onSelectionChange={(provider_type) => { + if (isProviderType(provider_type)) { + setProvider({ + ...provider, + provider_type, + }); + } + }} + > + <SelectButton /> + </Select> + </div> + <div className="py-3"> + <TextField + aria-label="Provider description" + name="description" + validationBehavior="aria" + onChange={(description) => setProvider({ ...provider, description })} + > + <Label>Description (Optional)</Label> + <Input + placeholder="Provider description" + value={provider.description} + /> + </TextField> + </div> + <div className="py-3"> + <TextField + aria-label="Provider endpoint" + name="endpoint" + validationBehavior="aria" + isRequired + onChange={(endpoint) => setProvider({ ...provider, endpoint })} + > + <Label>Endpoint</Label> + <Input placeholder="Provider endpoint" value={provider.endpoint} /> + </TextField> + </div> + <div className="py-3"> + <Label id="provider-authentication">Authentication</Label> + <Select + aria-labelledby="provider auth type" + name="auth_type" + selectedKey={provider.auth_type} + isRequired + className="w-full" + placeholder="Select the authentication type" + items={getAuthTypeOptions()} + onSelectionChange={(auth_type) => { + if (isProviderAuthType(auth_type)) { + setProvider({ ...provider, auth_type }); + } + }} + > + <SelectButton /> + </Select> + </div> + + {provider.auth_type === ProviderAuthType.API_KEY && ( + <div className="pt-4"> + <TextField + aria-label="Provider API key" + name="api_key" + type="password" + validationBehavior="aria" + isRequired + onChange={(api_key) => setProvider({ ...provider, api_key })} + > + <Label>Api key</Label> + <Input + placeholder={ + provider.api_key === undefined + ? "Update the provider API key" + : "Specify the provider API key" + } + value={provider.api_key ?? ""} + /> + </TextField> + </div> + )} + </div> + ); +} diff --git a/src/features/providers/components/table-actions.tsx b/src/features/providers/components/table-actions.tsx new file mode 100644 index 00000000..68c28fcc --- /dev/null +++ b/src/features/providers/components/table-actions.tsx @@ -0,0 +1,53 @@ +import { ProviderEndpoint } from "@/api/generated"; +import { + MenuTrigger, + Button, + Popover, + Menu, + OptionsSchema, +} from "@stacklok/ui-kit"; +import { DotsVertical, Settings04, Trash01 } from "@untitled-ui/icons-react"; +import { useMutationDeleteProvider } from "../hooks/use-mutation-delete-provider"; +import { useConfirmDeleteProvider } from "../hooks/use-confirm-delete-provider"; + +const getProviderActions = ({ + provider, + deleteProvider, +}: { + provider: ProviderEndpoint; + deleteProvider: ReturnType<typeof useMutationDeleteProvider>["mutateAsync"]; +}): OptionsSchema<"menu">[] => [ + { + textValue: "Edit", + icon: <Settings04 />, + id: "edit", + href: `/providers/${provider.id}`, + }, + { + textValue: "Delete", + icon: <Trash01 />, + id: "delete", + onAction: () => + deleteProvider({ path: { provider_id: provider.id as string } }), + }, +]; + +export function TableActions({ provider }: { provider: ProviderEndpoint }) { + const deleteProvider = useConfirmDeleteProvider(); + + return ( + <MenuTrigger> + <Button aria-label="Actions" isIcon variant="tertiary"> + <DotsVertical /> + </Button> + <Popover placement="bottom end"> + <Menu + items={getProviderActions({ + provider, + deleteProvider, + })} + /> + </Popover> + </MenuTrigger> + ); +} diff --git a/src/features/providers/components/table-providers.tsx b/src/features/providers/components/table-providers.tsx new file mode 100644 index 00000000..197f5c37 --- /dev/null +++ b/src/features/providers/components/table-providers.tsx @@ -0,0 +1,114 @@ +import { + Badge, + Cell, + Column, + Row, + Table, + TableBody, + LinkButton, + TableHeader, + ResizableTableContainer, +} from "@stacklok/ui-kit"; +import { Globe02, Tool01 } from "@untitled-ui/icons-react"; +import { PROVIDER_AUTH_TYPE_MAP } from "../lib/utils"; +import { TableActions } from "./table-actions"; +import { useProviders } from "../hooks/use-providers"; +import { match } from "ts-pattern"; +import { ComponentProps } from "react"; +import { ProviderEndpoint } from "@/api/generated"; + +const COLUMN_MAP = { + provider: "provider", + type: "type", + endpoint: "endpoint", + auth: "auth", + configuration: "configuration", +} as const; + +type ColumnId = keyof typeof COLUMN_MAP; +type Column = { id: ColumnId } & Omit<ComponentProps<typeof Column>, "id">; +const COLUMNS: Column[] = [ + { + id: "provider", + isRowHeader: true, + children: "Name & Description", + width: "40%", + }, + { id: "type", children: "Provider", width: "10%", className: "capitalize" }, + { id: "endpoint", children: "Endpoint", width: "20%" }, + { id: "auth", children: "Authentication", width: "20%" }, + { id: "configuration", alignment: "end", width: "10%", children: "" }, +]; + +function CellRenderer({ + column, + row, +}: { + column: Column; + row: ProviderEndpoint; +}) { + return match(column.id) + .with(COLUMN_MAP.provider, () => ( + <> + <div className="text-primary">{row.name}</div> + <div className="text-tertiary">{row.description}</div> + </> + )) + .with(COLUMN_MAP.type, () => row.provider_type) + .with(COLUMN_MAP.endpoint, () => ( + <div className="flex items-center gap-2"> + <Globe02 className="size-4" /> + <span>{row.endpoint}</span> + </div> + )) + .with(COLUMN_MAP.auth, () => ( + <div className="flex items-center justify-between gap-2"> + {row.auth_type ? ( + <Badge size="sm" className="text-tertiary"> + {PROVIDER_AUTH_TYPE_MAP[row.auth_type]} + </Badge> + ) : ( + "N/A" + )} + <LinkButton + variant="tertiary" + className="flex gap-2 items-center" + href={`/providers/${row.id}`} + > + <Tool01 className="size-4" /> Manage + </LinkButton> + </div> + )) + .with(COLUMN_MAP.configuration, () => <TableActions provider={row} />) + .exhaustive(); +} + +export function TableProviders() { + const { data: providers = [] } = useProviders(); + + return ( + <ResizableTableContainer> + <Table aria-label="List of workspaces"> + <TableHeader columns={COLUMNS}> + {(column) => <Column {...column} id={column.id} />} + </TableHeader> + + <TableBody items={providers}> + {(row) => ( + <Row columns={COLUMNS} id={`${row.id}`}> + {(column) => ( + <Cell + className="h-6 group-last/row:border-b-0" + id={column.id} + alignment={column.alignment} + > + <CellRenderer column={column} row={row} /> + </Cell> + )} + </Row> + )} + </TableBody> + </Table> + </ResizableTableContainer> + ); +} diff --git a/src/features/providers/hooks/use-confirm-delete-provider.tsx b/src/features/providers/hooks/use-confirm-delete-provider.tsx new file mode 100644 index 00000000..ac6ad6b6 --- /dev/null +++ b/src/features/providers/hooks/use-confirm-delete-provider.tsx @@ -0,0 +1,33 @@ +import { useConfirm } from "@/hooks/use-confirm"; +import { useCallback } from "react"; +import { useMutationDeleteProvider } from "./use-mutation-delete-provider"; + +export function useConfirmDeleteProvider() { + const { mutateAsync: deleteProvider } = useMutationDeleteProvider(); + + const { confirm } = useConfirm(); + + return useCallback( + async (...params: Parameters<typeof deleteProvider>) => { + const answer = await confirm( + <> + <p className="mb-1"> + Are you sure you want to permanently delete this provider? + </p> + </>, + { + buttons: { + yes: "Delete", + no: "Cancel", + }, + title: "Permanently delete provider", + isDestructive: true, + } + ); + if (answer) { + return deleteProvider(...params); + } + }, + [confirm, deleteProvider] + ); +} diff --git a/src/features/providers/hooks/use-invalidate-providers-queries.ts b/src/features/providers/hooks/use-invalidate-providers-queries.ts new file mode 100644 index 00000000..36aee59c --- /dev/null +++ b/src/features/providers/hooks/use-invalidate-providers-queries.ts @@ -0,0 +1,16 @@ +import { v1ListProviderEndpointsQueryKey } from "@/api/generated/@tanstack/react-query.gen"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; + +export function useInvalidateProvidersQueries() { + const queryClient = useQueryClient(); + + const invalidate = useCallback(async () => { + await queryClient.invalidateQueries({ + queryKey: v1ListProviderEndpointsQueryKey(), + refetchType: "all", + }); + }, [queryClient]); + + return invalidate; +} diff --git a/src/features/providers/hooks/use-mutation-create-provider.ts b/src/features/providers/hooks/use-mutation-create-provider.ts new file mode 100644 index 00000000..b420d940 --- /dev/null +++ b/src/features/providers/hooks/use-mutation-create-provider.ts @@ -0,0 +1,16 @@ +import { v1AddProviderEndpointMutation } from "@/api/generated/@tanstack/react-query.gen"; +import { useToastMutation } from "@/hooks/use-toast-mutation"; +import { useInvalidateProvidersQueries } from "./use-invalidate-providers-queries"; +import { useNavigate } from "react-router-dom"; + +export function useMutationCreateProvider() { + const navigate = useNavigate(); + const invalidate = useInvalidateProvidersQueries(); + return useToastMutation({ + ...v1AddProviderEndpointMutation(), + onSuccess: async () => { + await invalidate(); + navigate("/providers"); + }, + }); +} diff --git a/src/features/providers/hooks/use-mutation-delete-provider.ts b/src/features/providers/hooks/use-mutation-delete-provider.ts new file mode 100644 index 00000000..6dacf200 --- /dev/null +++ b/src/features/providers/hooks/use-mutation-delete-provider.ts @@ -0,0 +1,13 @@ +import { useToastMutation } from "@/hooks/use-toast-mutation"; +import { useInvalidateProvidersQueries } from "./use-invalidate-providers-queries"; +import { v1DeleteProviderEndpointMutation } from "@/api/generated/@tanstack/react-query.gen"; + +export const useMutationDeleteProvider = () => { + const invalidate = useInvalidateProvidersQueries(); + + return useToastMutation({ + ...v1DeleteProviderEndpointMutation(), + onSuccess: () => invalidate(), + successMsg: () => "Successfully deleted provider", + }); +}; diff --git a/src/features/providers/hooks/use-mutation-update-provider.ts b/src/features/providers/hooks/use-mutation-update-provider.ts new file mode 100644 index 00000000..b8c987bf --- /dev/null +++ b/src/features/providers/hooks/use-mutation-update-provider.ts @@ -0,0 +1,43 @@ +import { useToastMutation } from "@/hooks/use-toast-mutation"; +import { useNavigate } from "react-router-dom"; +import { useInvalidateProvidersQueries } from "./use-invalidate-providers-queries"; +import { + AddProviderEndpointRequest, + ProviderAuthType, + v1ConfigureAuthMaterial, + v1UpdateProviderEndpoint, +} from "@/api/generated"; + +export function useMutationUpdateProvider() { + const navigate = useNavigate(); + const invalidate = useInvalidateProvidersQueries(); + + const mutationFn = ({ api_key, ...rest }: AddProviderEndpointRequest) => { + const provider_id = rest.id; + if (!provider_id) throw new Error("Provider is missing"); + return Promise.all([ + v1ConfigureAuthMaterial({ + path: { provider_id }, + body: { + api_key: api_key, + auth_type: rest.auth_type as ProviderAuthType, + }, + throwOnError: true, + }), + + v1UpdateProviderEndpoint({ + path: { provider_id }, + body: rest, + }), + ]); + }; + + return useToastMutation({ + mutationFn, + successMsg: "Successfully updated provider", + onSuccess: async () => { + await invalidate(); + navigate("/providers"); + }, + }); +} diff --git a/src/features/providers/hooks/use-provider.ts b/src/features/providers/hooks/use-provider.ts new file mode 100644 index 00000000..87e5044c --- /dev/null +++ b/src/features/providers/hooks/use-provider.ts @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query"; +import { v1GetProviderEndpointOptions } from "@/api/generated/@tanstack/react-query.gen"; +import { AddProviderEndpointRequest, ProviderType } from "@/api/generated"; +import { useEffect, useState } from "react"; + +export function useProvider(providerId: string) { + const [provider, setProvider] = useState<AddProviderEndpointRequest>({ + name: "", + description: "", + auth_type: null, + provider_type: ProviderType.OPENAI, + endpoint: "", + api_key: "", + }); + + const { data, isPending, isError } = useQuery({ + ...v1GetProviderEndpointOptions({ path: { provider_id: providerId } }), + }); + + useEffect(() => { + if (data) { + setProvider(data); + } + }, [data]); + + return { isPending, isError, provider, setProvider }; +} diff --git a/src/features/providers/hooks/use-providers.ts b/src/features/providers/hooks/use-providers.ts new file mode 100644 index 00000000..cf112f09 --- /dev/null +++ b/src/features/providers/hooks/use-providers.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { v1ListProviderEndpointsOptions } from "@/api/generated/@tanstack/react-query.gen"; + +export function useProviders() { + return useQuery({ + ...v1ListProviderEndpointsOptions(), + refetchOnMount: true, + refetchOnReconnect: true, + refetchOnWindowFocus: true, + }); +} diff --git a/src/features/providers/lib/utils.ts b/src/features/providers/lib/utils.ts new file mode 100644 index 00000000..80a2e7df --- /dev/null +++ b/src/features/providers/lib/utils.ts @@ -0,0 +1,29 @@ +import { ProviderAuthType, ProviderType } from "@/api/generated"; + +export const PROVIDER_AUTH_TYPE_MAP = { + [ProviderAuthType.NONE]: "None", + [ProviderAuthType.PASSTHROUGH]: "Passthrough", + [ProviderAuthType.API_KEY]: "API Key", +}; + +export function getAuthTypeOptions() { + return Object.entries(PROVIDER_AUTH_TYPE_MAP).map(([id, textValue]) => ({ + id, + textValue, + })); +} + +export function getProviderType() { + return Object.values(ProviderType).map((textValue) => ({ + id: textValue, + textValue, + })); +} + +export function isProviderType(value: unknown): value is ProviderType { + return Object.values(ProviderType).includes(value as ProviderType); +} + +export function isProviderAuthType(value: unknown): value is ProviderAuthType { + return Object.values(ProviderAuthType).includes(value as ProviderAuthType); +} diff --git a/src/features/workspace/components/workspace-preferred-model.tsx b/src/features/workspace/components/workspace-preferred-model.tsx index f3a1c75b..47747732 100644 --- a/src/features/workspace/components/workspace-preferred-model.tsx +++ b/src/features/workspace/components/workspace-preferred-model.tsx @@ -12,7 +12,7 @@ import { MuxMatcherType } from "@/api/generated"; import { FormEvent } from "react"; import { usePreferredModelWorkspace } from "../hooks/use-preferred-preferred-model"; import { Select, SelectButton } from "@stacklok/ui-kit"; -import { useModelsData } from "@/hooks/useModelsData"; +import { useModelsData } from "@/hooks/use-models-data"; export function WorkspacePreferredModel({ className, @@ -23,7 +23,8 @@ export function WorkspacePreferredModel({ workspaceName: string; isArchived: boolean | undefined; }) { - const { preferredModel, setPreferredModel } = usePreferredModelWorkspace(); + const { preferredModel, setPreferredModel } = + usePreferredModelWorkspace(workspaceName); const { mutateAsync } = useMutationPreferredModelWorkspace(); const { data: providerModels = [] } = useModelsData(); const { model, provider_id } = preferredModel; diff --git a/src/features/workspace/hooks/use-preferred-preferred-model.ts b/src/features/workspace/hooks/use-preferred-preferred-model.ts index 555a2a23..834a2ded 100644 --- a/src/features/workspace/hooks/use-preferred-preferred-model.ts +++ b/src/features/workspace/hooks/use-preferred-preferred-model.ts @@ -1,4 +1,7 @@ -import { MuxRule } from "@/api/generated"; +import { MuxRule, V1GetWorkspaceMuxesData } from "@/api/generated"; +import { v1GetWorkspaceMuxesOptions } from "@/api/generated/@tanstack/react-query.gen"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo } from "react"; import { create } from "zustand"; export type ModelRule = Omit<MuxRule, "matcher_type" | "matcher"> & {}; @@ -8,7 +11,7 @@ type State = { preferredModel: ModelRule; }; -export const usePreferredModelWorkspace = create<State>((set) => ({ +const useModelValue = create<State>((set) => ({ preferredModel: { provider_id: "", model: "", @@ -17,3 +20,33 @@ export const usePreferredModelWorkspace = create<State>((set) => ({ set({ preferredModel: { provider_id, model } }); }, })); + +const usePreferredModel = (options: { + path: { + workspace_name: string; + }; +}) => { + return useQuery({ + ...v1GetWorkspaceMuxesOptions(options), + }); +}; +export const usePreferredModelWorkspace = (workspaceName: string) => { + const options: V1GetWorkspaceMuxesData & + Omit<V1GetWorkspaceMuxesData, "body"> = useMemo( + () => ({ + path: { workspace_name: workspaceName }, + }), + [workspaceName], + ); + const { data } = usePreferredModel(options); + const { preferredModel, setPreferredModel } = useModelValue(); + + useEffect(() => { + const providerModel = data?.[0]; + if (providerModel) { + setPreferredModel(providerModel); + } + }, [data, setPreferredModel]); + + return { preferredModel, setPreferredModel }; +}; diff --git a/src/hooks/use-models-data.ts b/src/hooks/use-models-data.ts new file mode 100644 index 00000000..c423147c --- /dev/null +++ b/src/hooks/use-models-data.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { v1ListAllModelsForAllProvidersOptions } from "@/api/generated/@tanstack/react-query.gen"; + +export const useModelsData = () => { + return useQuery({ + ...v1ListAllModelsForAllProvidersOptions(), + refetchOnMount: true, + refetchOnReconnect: true, + refetchOnWindowFocus: true, + }); +}; diff --git a/src/hooks/use-toast-mutation.ts b/src/hooks/use-toast-mutation.ts index 29b8686a..e9b935ce 100644 --- a/src/hooks/use-toast-mutation.ts +++ b/src/hooks/use-toast-mutation.ts @@ -1,3 +1,4 @@ +import { HTTPValidationError } from "@/api/generated"; import { toast } from "@stacklok/ui-kit"; import { DefaultError, @@ -31,7 +32,7 @@ export function useToastMutation< } = useMutation(options); const mutateAsync = useCallback( - async <TError extends { detail: string | undefined }>( + async <TError extends { detail: string | undefined | HTTPValidationError }>( variables: Parameters<typeof originalMutateAsync>[0], options: Parameters<typeof originalMutateAsync>[1] = {}, ) => { @@ -41,8 +42,24 @@ export function useToastMutation< success: typeof successMsg === "function" ? successMsg(variables) : successMsg, loading: loadingMsg ?? "Loading...", - error: (e: TError) => - errorMsg ?? (e.detail ? e.detail : "An error occurred"), + error: (e: TError) => { + if (errorMsg) return errorMsg; + + if (typeof e.detail == "string") { + return e.detail ?? "An error occurred"; + } + + if (Array.isArray(e.detail)) { + const err = e.detail + ?.map((item) => `${item.msg} - ${JSON.stringify(item.loc)}`) + .filter(Boolean) + .join(", "); + + return err ?? "An error occurred"; + } + + return "An error occurred"; + }, }); }, [errorMsg, loadingMsg, originalMutateAsync, successMsg], diff --git a/src/hooks/useModelsData.ts b/src/hooks/useModelsData.ts deleted file mode 100644 index b58fda4e..00000000 --- a/src/hooks/useModelsData.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { v1ListAllModelsForAllProvidersOptions } from "@/api/generated/@tanstack/react-query.gen"; -import { V1ListAllModelsForAllProvidersResponse } from "@/api/generated"; - -export const useModelsData = () => { - return useQuery({ - ...v1ListAllModelsForAllProvidersOptions(), - queryFn: async () => { - const response: V1ListAllModelsForAllProvidersResponse = [ - { - name: "claude-3.5", - provider_name: "anthropic", - provider_id: "anthropic", - }, - { - name: "claude-3.6", - provider_name: "anthropic", - provider_id: "anthropic", - }, - { - name: "claude-3.7", - provider_name: "anthropic", - provider_id: "anthropic", - }, - { name: "chatgpt-4o", provider_name: "openai", provider_id: "openai" }, - { name: "chatgpt-4p", provider_name: "openai", provider_id: "openai" }, - ]; - - return response; - }, - }); -}; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 07a499a6..976388a4 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -73,7 +73,7 @@ export function groupPromptsByRelativeDate(prompts: Conversation[]) { const promptsSorted = prompts.sort( (a, b) => new Date(b.conversation_timestamp).getTime() - - new Date(a.conversation_timestamp).getTime(), + new Date(a.conversation_timestamp).getTime() ); const grouped = promptsSorted.reduce( @@ -90,7 +90,7 @@ export function groupPromptsByRelativeDate(prompts: Conversation[]) { (groups[group] ?? []).push(prompt); return groups; }, - {} as Record<string, Conversation[]>, + {} as Record<string, Conversation[]> ); return grouped; @@ -125,10 +125,15 @@ export function sanitizeQuestionPrompt({ } export function getIssueDetectedType( - alert: AlertConversation, + alert: AlertConversation ): "malicious_package" | "leaked_secret" | null { if (isAlertMalicious(alert)) return "malicious_package"; if (isAlertSecret(alert)) return "leaked_secret"; return null; } + +export function capitalize(text: string) { + const [first, ...rest] = text; + return first ? first.toUpperCase() + rest.join("") : text; +} diff --git a/src/main.tsx b/src/main.tsx index 4a874faf..7398fed0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -27,6 +27,7 @@ createRoot(document.getElementById("root")!).render( <SidebarProvider> <QueryClientProvider> <ErrorBoundary fallback={<Error />}> + <ReactQueryDevtools /> <ConfirmProvider> <Toaster /> <App /> diff --git a/src/mocks/msw/fixtures/GET_PROVIDERS_MODELS.json b/src/mocks/msw/fixtures/GET_PROVIDERS_MODELS.json new file mode 100644 index 00000000..1a6ff4dd --- /dev/null +++ b/src/mocks/msw/fixtures/GET_PROVIDERS_MODELS.json @@ -0,0 +1,27 @@ +[ + { + "name": "claude-3.5", + "provider_id": "id_1", + "provider_name": "anthropic" + }, + { + "name": "claude-3.6", + "provider_id": "id_2", + "provider_name": "anthropic" + }, + { + "name": "claude-3.7", + "provider_id": "id_3", + "provider_name": "anthropic" + }, + { + "name": "chatgpt-4o", + "provider_id": "id_4", + "provider_name": "openai" + }, + { + "name": "chatgpt-4p", + "provider_id": "id_5", + "provider_name": "openai" + } +] \ No newline at end of file diff --git a/src/mocks/msw/handlers.ts b/src/mocks/msw/handlers.ts index be2bd963..2a4974cb 100644 --- a/src/mocks/msw/handlers.ts +++ b/src/mocks/msw/handlers.ts @@ -3,6 +3,7 @@ import mockedPrompts from "@/mocks/msw/fixtures/GET_MESSAGES.json"; import mockedAlerts from "@/mocks/msw/fixtures/GET_ALERTS.json"; import mockedWorkspaces from "@/mocks/msw/fixtures/GET_WORKSPACES.json"; import mockedProviders from "@/mocks/msw/fixtures/GET_PROVIDERS.json"; +import mockedProvidersModels from "@/mocks/msw/fixtures/GET_PROVIDERS_MODELS.json"; import { ProviderType } from "@/api/generated"; export const handlers = [ @@ -112,12 +113,18 @@ export const handlers = [ "*/api/v1/workspaces/:workspace_name/muxes", () => new HttpResponse(null, { status: 204 }), ), - http.get("*/api/v1/provider-endpoints", () => - HttpResponse.json(mockedProviders), + http.get("*/api/v1/provider-endpoints/:provider_name/models", () => + HttpResponse.json(mockedProvidersModels), + ), + http.get("*/api/v1/provider-endpoints/models", () => + HttpResponse.json(mockedProvidersModels), ), http.get("*/api/v1/provider-endpoints/:provider_id", () => HttpResponse.json(mockedProviders[0]), ), + http.get("*/api/v1/provider-endpoints", () => + HttpResponse.json(mockedProviders), + ), http.post( "*/api/v1/provider-endpoints", () => new HttpResponse(null, { status: 204 }), @@ -130,22 +137,4 @@ export const handlers = [ "*/api/v1/provider-endpoints", () => new HttpResponse(null, { status: 204 }), ), - http.get("*/api/v1/provider-endpoints/:provider_name/models", () => - HttpResponse.json([ - { name: "claude-3.5", provider: "anthropic" }, - { name: "claude-3.6", provider: "anthropic" }, - { name: "claude-3.7", provider: "anthropic" }, - { name: "chatgpt-4o", provider: "openai" }, - { name: "chatgpt-4p", provider: "openai" }, - ]), - ), - http.get("*/api/v1/provider-endpoints/models", () => - HttpResponse.json([ - { name: "claude-3.5", provider: "anthropic" }, - { name: "claude-3.6", provider: "anthropic" }, - { name: "claude-3.7", provider: "anthropic" }, - { name: "chatgpt-4o", provider: "openai" }, - { name: "chatgpt-4p", provider: "openai" }, - ]), - ), ]; diff --git a/src/routes/route-provider-create.tsx b/src/routes/route-provider-create.tsx new file mode 100644 index 00000000..197e5401 --- /dev/null +++ b/src/routes/route-provider-create.tsx @@ -0,0 +1,41 @@ +import { AddProviderEndpointRequest, ProviderType } from "@/api/generated"; +import { ProviderDialog } from "@/features/providers/components/provider-dialog"; +import { ProviderDialogFooter } from "@/features/providers/components/provider-dialog-footer"; +import { ProviderForm } from "@/features/providers/components/provider-form"; +import { useMutationCreateProvider } from "@/features/providers/hooks/use-mutation-create-provider"; +import { DialogContent, Form } from "@stacklok/ui-kit"; +import { useState } from "react"; + +const DEFAULT_PROVIDER_STATE = { + name: "", + description: "", + auth_type: null, + provider_type: ProviderType.OPENAI, + endpoint: "", + api_key: "", +}; + +export function RouteProviderCreate() { + const [provider, setProvider] = useState<AddProviderEndpointRequest>( + DEFAULT_PROVIDER_STATE, + ); + const { mutateAsync } = useMutationCreateProvider(); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + mutateAsync({ + body: provider, + }); + }; + + return ( + <Form onSubmit={handleSubmit} validationBehavior="aria"> + <ProviderDialog title="Create Provider"> + <DialogContent className="p-8"> + <ProviderForm provider={provider} setProvider={setProvider} /> + </DialogContent> + <ProviderDialogFooter /> + </ProviderDialog> + </Form> + ); +} diff --git a/src/routes/route-provider-update.tsx b/src/routes/route-provider-update.tsx new file mode 100644 index 00000000..209ac483 --- /dev/null +++ b/src/routes/route-provider-update.tsx @@ -0,0 +1,35 @@ +import { ProviderDialog } from "@/features/providers/components/provider-dialog"; +import { ProviderDialogFooter } from "@/features/providers/components/provider-dialog-footer"; +import { ProviderForm } from "@/features/providers/components/provider-form"; +import { useMutationUpdateProvider } from "@/features/providers/hooks/use-mutation-update-provider"; +import { useProvider } from "@/features/providers/hooks/use-provider"; +import { DialogContent, Form } from "@stacklok/ui-kit"; +import { useParams } from "react-router-dom"; + +export function RouteProviderUpdate() { + const { id } = useParams(); + if (id === undefined) { + throw new Error("Provider id is required"); + } + const { setProvider, provider } = useProvider(id); + const { mutateAsync } = useMutationUpdateProvider(); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + mutateAsync(provider); + }; + + // TODO add empty state and loading in a next step + if (provider === undefined) return; + + return ( + <ProviderDialog title="Manage Provider"> + <Form onSubmit={handleSubmit} validationBehavior="aria"> + <DialogContent className="p-8"> + <ProviderForm provider={provider} setProvider={setProvider} /> + </DialogContent> + <ProviderDialogFooter /> + </Form> + </ProviderDialog> + ); +} diff --git a/src/routes/route-providers.tsx b/src/routes/route-providers.tsx new file mode 100644 index 00000000..7ea1aeea --- /dev/null +++ b/src/routes/route-providers.tsx @@ -0,0 +1,37 @@ +import { BreadcrumbHome } from "@/components/BreadcrumbHome"; +import { + Breadcrumbs, + Breadcrumb, + Heading, + Card, + LinkButton, + CardBody, +} from "@stacklok/ui-kit"; +import { twMerge } from "tailwind-merge"; +import { PlusSquare } from "@untitled-ui/icons-react"; +import { TableProviders } from "@/features/providers/components/table-providers"; +import { Outlet } from "react-router-dom"; + +export function RouteProvider({ className }: { className?: string }) { + return ( + <> + <Breadcrumbs> + <BreadcrumbHome /> + <Breadcrumb>Providers</Breadcrumb> + </Breadcrumbs> + <Heading level={4} className="mb-4 flex items-center justify-between"> + Providers + <LinkButton className="w-fit" href="/providers/new"> + <PlusSquare /> Add Provider + </LinkButton> + </Heading> + <Card className={twMerge(className, "shrink-0")}> + <CardBody> + <TableProviders /> + </CardBody> + </Card> + + <Outlet /> + </> + ); +} diff --git a/src/routes/route-workspace.tsx b/src/routes/route-workspace.tsx index ba265320..b42ddd24 100644 --- a/src/routes/route-workspace.tsx +++ b/src/routes/route-workspace.tsx @@ -54,7 +54,7 @@ export function RouteWorkspace() { workspaceName={name} /> <WorkspacePreferredModel - className="mb-4 hidden" + className="mb-4" isArchived={isArchived} workspaceName={name} />