diff --git a/frontend/src/components/Header/Notifications/index.tsx b/frontend/src/components/Header/Notifications/index.tsx deleted file mode 100644 index 9c10c21..0000000 --- a/frontend/src/components/Header/Notifications/index.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { ReactNode, useEffect, useState } from 'react'; -import { AlertOctagon, AlertTriangle, Bell, Info } from 'react-feather'; -import { useParams } from 'react-router-dom'; -import Organization from '../../../models/organization'; -import { databaseTimestampFromNow } from '../../../utils/data'; -import ChromaLogo from '../../../images/vectordbs/chroma.png'; -import PineconeLogo from '../../../images/vectordbs/pinecone.png'; -import qDrantLogo from '../../../images/vectordbs/qdrant.png'; -import WeaviateLogo from '../../../images/vectordbs/weaviate.png'; - -const POLLING_INTERVAL = 30_000; - -export type INotification = { - id: number; - organization_id: number; - seen: boolean; - textContent: string; - symbol?: - | 'info' - | 'warning' - | 'error' - | 'chroma' - | 'pinecone' - | 'weaviate' - | 'qdrant'; - link?: string; - target?: '_blank' | 'self'; - createdAt: string; - lastUpdatedAt: string; -}; - -export default function Notifications() { - const { slug } = useParams(); - const [loading, setLoading] = useState(true); - const [notifications, setNotifications] = useState([]); - const [hasUnseen, setHasUnseen] = useState(false); - const [showNotifs, setShowNotifs] = useState(false); - - async function handleClick() { - if (!showNotifs) { - !!slug && Organization.markNotificationsSeen(slug); - setShowNotifs(true); - setHasUnseen(false); - } else { - setShowNotifs(false); - } - } - - async function fetchNotifications() { - if (!slug) { - setLoading(false); - return; - } - - const { notifications: _notifications } = await Organization.notifications( - slug - ); - setNotifications(_notifications); - setHasUnseen(_notifications.some((notif) => notif.seen === false)); - setLoading(false); - } - - useEffect(() => { - if (!slug) return; - fetchNotifications(); - setInterval(() => { - fetchNotifications(); - }, POLLING_INTERVAL); - }, [slug]); - - if (loading) return null; - - return ( -
- - - - ); -} - -function NotificationImage({ notification }: { notification: INotification }) { - switch (notification.symbol) { - case 'info': - return ; - case 'warning': - return ; - case 'error': - return ; - case 'chroma': - return ; - case 'pinecone': - return ; - case 'qdrant': - return ; - case 'weaviate': - return ; - default: - return ; - } -} - -function NotificationWrapper({ - notification, - children, -}: { - notification: INotification; - children: ReactNode; -}) { - if (!!notification.link) { - return ( - - {children} - - ); - } - return ( -
- {children} -
- ); -} - -function Notification({ notification }: { notification: INotification }) { - return ( - -
- -
-
-
- {notification.textContent} -
-
- {databaseTimestampFromNow(notification.createdAt)} -
-
-
- ); -} diff --git a/frontend/src/components/Notifications/index.tsx b/frontend/src/components/Notifications/index.tsx new file mode 100644 index 0000000..342627a --- /dev/null +++ b/frontend/src/components/Notifications/index.tsx @@ -0,0 +1,391 @@ +import { ReactNode, useEffect, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import Organization from '../../models/organization'; +import { databaseTimestampFromNow } from '../../utils/data'; +import ChromaLogo from '../../images/vectordbs/chroma.png'; +import PineconeLogo from '../../images/vectordbs/pinecone-inverted.png'; +import qDrantLogo from '../../images/vectordbs/qdrant.png'; +import WeaviateLogo from '../../images/vectordbs/weaviate.png'; +import { Bell, Info, Warning, WarningOctagon } from '@phosphor-icons/react'; + +const POLLING_INTERVAL = 30_000; + +export type INotification = { + id: number; + organization_id: number; + seen: boolean; + textContent: string; + symbol?: + | 'info' + | 'warning' + | 'error' + | 'chroma' + | 'pinecone' + | 'weaviate' + | 'qdrant'; + link?: string; + target?: '_blank' | 'self'; + createdAt: string; + lastUpdatedAt: string; +}; + +export default function Notifications() { + const { slug } = useParams(); + const [loading, setLoading] = useState(true); + const [notifications, setNotifications] = useState([]); + const [hasUnseen, setHasUnseen] = useState(false); + const [showNotifs, setShowNotifs] = useState(false); + const notificationRef = useRef(null); + const bellButtonRef = useRef(null); + + async function handleClick() { + if (!showNotifs) { + !!slug && Organization.markNotificationsSeen(slug); + setShowNotifs(true); + setHasUnseen(false); + } else { + setShowNotifs(false); + } + } + useEffect(() => { + function handleClickOutside(event: any) { + if ( + bellButtonRef.current && + bellButtonRef.current.contains(event.target) + ) { + return; + } + if ( + notificationRef.current && + !notificationRef.current.contains(event.target) + ) { + setShowNotifs(false); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + async function fetchNotifications() { + if (!slug) { + setLoading(false); + return; + } + + const { notifications: _notifications } = await Organization.notifications( + slug + ); + setNotifications(_notifications); + setHasUnseen(_notifications.some((notif) => notif.seen === false)); + setLoading(false); + } + + useEffect(() => { + if (!slug) return; + fetchNotifications(); + setInterval(() => { + fetchNotifications(); + }, POLLING_INTERVAL); + }, [slug]); + + if (loading) return null; + + return ( +
+ + + +
+ ); +} + +function NotificationImage({ notification }: { notification: INotification }) { + switch (notification.symbol) { + case 'info': + return ( +
+ +
+ ); + case 'warning': + return ( +
+ +
+ ); + case 'error': + return ( +
+ +
+ ); + case 'chroma': + return ( +
+ Chroma Logo +
+ ); + case 'pinecone': + return ( +
+ Pinecone Logo +
+ ); + case 'qdrant': + return ( +
+ qDrant Logo +
+ ); + case 'weaviate': + return ( +
+ Weaviate Logo +
+ ); + default: + return ( +
+ +
+ ); + } +} + +function NotificationWrapper({ + notification, + children, +}: { + notification: INotification; + children: ReactNode; +}) { + if (!!notification.link) { + return ( + + {children} + + ); + } + return ( +
+ {children} +
+ ); +} + +function Notification({ notification }: { notification: INotification }) { + return ( + +
+ +
+
+
+ {notification.textContent} +
+
+ {databaseTimestampFromNow(notification.createdAt)} +
+
+
+ ); +} + +// +// +// +// +// +// +// {' '} +// {' '} +// diff --git a/frontend/src/components/UserMenu/index.tsx b/frontend/src/components/UserMenu/index.tsx new file mode 100644 index 0000000..a42a01e --- /dev/null +++ b/frontend/src/components/UserMenu/index.tsx @@ -0,0 +1,60 @@ +import { useState, useRef, useEffect } from 'react'; +import useUser from '../../hooks/useUser'; +import paths from '../../utils/paths'; +import { STORE_TOKEN, STORE_USER } from '../../utils/constants'; + +export default function UserMenu() { + const { user } = useUser(); + const [showMenu, setShowMenu] = useState(false); + const menuRef = useRef(null); + const buttonRef = useRef(); + const handleClose = (event: any) => { + if ( + menuRef.current && + !menuRef.current.contains(event.target) && + !buttonRef.current.contains(event.target) + ) { + setShowMenu(false); + } + }; + + useEffect(() => { + if (showMenu) { + document.addEventListener('mousedown', handleClose); + } + return () => document.removeEventListener('mousedown', handleClose); + }, [showMenu]); + + return ( +
+ + {showMenu && ( +
+
+ +
+
+ )} +
+ ); +} diff --git a/frontend/src/layout/AppLayout.tsx b/frontend/src/layout/AppLayout.tsx index 2a29122..e76f24c 100644 --- a/frontend/src/layout/AppLayout.tsx +++ b/frontend/src/layout/AppLayout.tsx @@ -1,6 +1,8 @@ import { ReactNode, useState } from 'react'; import Header from '../components/Header'; import Sidebar from '../components/Sidebar'; +import Notifications from '../components/Notifications'; +import UserMenu from '../components/UserMenu'; interface DefaultLayoutProps { headerEntity: any; @@ -58,6 +60,11 @@ const AppLayout = ({ />
)} + +
+ + +
{children} diff --git a/frontend/src/models/organization.ts b/frontend/src/models/organization.ts index 0b57745..7348694 100644 --- a/frontend/src/models/organization.ts +++ b/frontend/src/models/organization.ts @@ -1,4 +1,4 @@ -import { INotification } from '../components/Header/Notifications'; +import { INotification } from '../components/Notifications'; import { API_BASE } from '../utils/constants'; import { baseHeaders, getAPIUrlString } from '../utils/request'; diff --git a/frontend/src/pages/OrganizationSettings/Settings/index.tsx b/frontend/src/pages/OrganizationSettings/Settings/index.tsx index 816a4b7..4ffa4c6 100644 --- a/frontend/src/pages/OrganizationSettings/Settings/index.tsx +++ b/frontend/src/pages/OrganizationSettings/Settings/index.tsx @@ -2,18 +2,16 @@ import { useState } from 'react'; import Organization from '../../../models/organization'; import { Loader } from 'react-feather'; import paths from '../../../utils/paths'; +import { CaretDown } from '@phosphor-icons/react'; +import showToast from '../../../utils/toast'; export default function OrgSettings({ organization }: { organization: any }) { const [hasOrgChanges, setHasOrgChanges] = useState(false); const [deleting, setDeleting] = useState(false); - const [result, setResult] = useState<{ - show: boolean; - success: boolean; - error: null | string; - }>({ show: false, success: false, error: null }); + const handleOrgUpdate = async (e: any) => { e.preventDefault(); - setResult({ show: false, success: false, error: null }); + const form = new FormData(e.target); const newOrgName = (form.get('organization_name') as string) || organization?.name; @@ -23,11 +21,20 @@ export default function OrgSettings({ organization }: { organization: any }) { organization.slug, data ); - setResult({ show: true, success, error }); + + if (success) { + showToast('Organization updated successfully, reloading...', 'success'); + setTimeout(() => { + window.location.reload(); + }, 2000); + } else { + showToast(error, 'error'); + } success && setHasOrgChanges(false); }; - const handleDelete = async () => { + const handleDelete = async (e: any) => { + e.preventDefault(); if ( !confirm( 'Are you sure you want to do this. All associated information will be deleted.\n\nThis operation will ONLY remove information from Vector Admin and will not remove any data from your connected vector database.' @@ -46,72 +53,56 @@ export default function OrgSettings({ organization }: { organization: any }) { }; return ( -
-
-
-

+
+
+
+ +
Organization Settings -

+
-
- -
- {result.show && ( - <> - {result.success ? ( -

- Settings updated successfully. -

- ) : ( -

- {result.error} -

- )} - - )} -
setHasOrgChanges(true)} onSubmit={handleOrgUpdate} - className="border-b border-gray-200 pb-4" > -
- -

+

+
+ Organization Name +
+
This will only change the display name of the organization. -

-
+
+
+
- -
- -
); diff --git a/frontend/src/pages/OrganizationSettings/index.tsx b/frontend/src/pages/OrganizationSettings/index.tsx index a1b050f..658840f 100644 --- a/frontend/src/pages/OrganizationSettings/index.tsx +++ b/frontend/src/pages/OrganizationSettings/index.tsx @@ -1,4 +1,4 @@ -import { FullScreenLoader } from '../../components/Preloader'; +import PreLoader, { FullScreenLoader } from '../../components/Preloader'; import useUser from '../../hooks/useUser'; import { useState, useEffect } from 'react'; import DefaultLayout from '../../layout/DefaultLayout'; @@ -8,12 +8,24 @@ import AppLayout from '../../layout/AppLayout'; import OrgSettings from './Settings'; import { useParams } from 'react-router-dom'; +import ChromaLogo from '../../images/vectordbs/chroma.png'; +import PineconeLogoInverted from '../../images/vectordbs/pinecone-inverted.png'; +import PineconeLogo from '../../images/vectordbs/pinecone.png'; +import qDrantLogo from '../../images/vectordbs/qdrant.png'; +import WeaviateLogo from '../../images/vectordbs/weaviate.png'; +import { GearSix, Prohibit } from '@phosphor-icons/react'; +import Organization from '../../models/organization'; +import { APP_NAME } from '../../utils/constants'; +import { titleCase } from 'title-case'; +import truncate from 'truncate'; + export default function OrganizationSettingsView() { const { user } = useUser(); const { slug } = useParams(); const [loading, setLoading] = useState(true); const [organizations, setOrganizations] = useState([]); const [organization, setOrganization] = useState(null); + const [connector, setConnector] = useState(false); useEffect(() => { async function fetchInfo() { @@ -23,9 +35,11 @@ export default function OrganizationSettingsView() { return false; } const focusedOrg = orgs?.find((org) => org.slug === slug) || orgs?.[0]; + const _connector = await Organization.connector(focusedOrg.slug); setOrganizations(orgs); setOrganization(focusedOrg); setLoading(false); + setConnector(_connector); } fetchInfo(); }, [user.uid]); @@ -45,12 +59,1039 @@ export default function OrganizationSettingsView() { organizations={organizations} organization={organization} workspaces={[]} + headerExtendedItems={ + + } >
+ setConnector(newConnector)} + /> + setConnector(newConnector)} + />
); } + +function OrganizationHeader({ organization, connector }: any) { + let logo; + switch (connector?.type) { + case 'chroma': + logo = ChromaLogo; + break; + case 'qdrant': + logo = qDrantLogo; + break; + case 'weaviate': + logo = WeaviateLogo; + break; + case 'pinecone': + logo = PineconeLogoInverted; + break; + } + + return ( + <> +
+
+ + {truncate(organization?.name, 20)} + +
+
+
+ + + + + +
+ + ); +} + +const NewConnectorModal = ({ + organization, + onNew, +}: { + organization: any; + onNew: (newConnector: any) => void; +}) => { + const [loading, setLoading] = useState(false); + const [type, setType] = useState('chroma'); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const handleSubmit = async (e: any) => { + e.preventDefault(); + setError(null); + setLoading(true); + const data = { type }; + const form = new FormData(e.target); + for (var [_k, value] of form.entries()) { + if (_k.includes('::')) { + const [_key, __key] = _k.split('::'); + if (!data.hasOwnProperty(_key)) data[_key] = {}; + data[_key][__key] = value; + } else { + data[_k] = value; + } + } + + const { connector, error } = await Organization.addConnector( + organization.slug, + data + ); + + if (!connector) { + setLoading(false); + setError(error); + return false; + } + + setLoading(false); + setSuccess(true); + setTimeout(() => { + onNew(connector); + setSuccess(false); + }, 1500); + }; + + return ( + + event.target == event.currentTarget && event.currentTarget?.close() + } + > +
+
+

+ Connect to Vector Database +

+

+ {APP_NAME} is a tool to help you manage vectors in a vector + database, but without access to a valid vector database you will be + limited to read-only actions and limited functionality - you should + connect to a vector database provider to unlock full functionality. +

+
+ + + + +
+
+ ); +}; + +const UpdateConnectorModal = ({ + organization, + connector, + onUpdate, +}: { + organization: any; + connector: any; + onUpdate: (newConnector: any) => void; +}) => { + const [loading, setLoading] = useState(false); + const [type, setType] = useState(connector?.type); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const settings = JSON.parse(connector?.settings); + + const handleSubmit = async (e: any) => { + e.preventDefault(); + setError(null); + setLoading(true); + const data = { type }; + const form = new FormData(e.target); + for (var [_k, value] of form.entries()) { + if (_k.includes('::')) { + const [_key, __key] = _k.split('::'); + if (!data.hasOwnProperty(_key)) data[_key] = {}; + data[_key][__key] = value; + } else { + data[_k] = value; + } + } + + const { connector, error } = await Organization.updateConnector( + organization.slug, + data + ); + if (!connector) { + setLoading(false); + setError(error); + return false; + } + + setLoading(false); + setSuccess(true); + setTimeout(() => { + onUpdate(connector); + setSuccess(false); + }, 1500); + }; + + return ( + + event.target == event.currentTarget && event.currentTarget?.close() + } + > +
+
+

+ Update Vector Database Connection +

+

+ Currently connected to a {connector.type} vector database instance. + You can update your configuration settings here if they have + changed. +

+
+ {loading ? ( +
+
+ +
+
+ ) : ( +
+
    +
  • setType('chroma')} className="w-[250px]"> + + +
  • +
  • setType('pinecone')} className="w-[250px]"> + + +
  • +
  • setType('qdrant')} className="w-[250px]"> + + +
  • +
  • setType('weaviate')} className="w-[250px]"> + + +
  • +
+ + {type === 'chroma' && ( +
+
+
+ +

+ This is the URL your chroma instance is reachable at. +

+
+ +
+
+
+ +

+ If your hosted Chroma instance is protected by an API key + - enter the header and api key here. +

+
+
+ + +
+
+
+ )} + + {type === 'pinecone' && ( +
+
+
+ +

+ You can find this on your Pinecone index. +

+
+ +
+ +
+
+ +

+ You can find this on your Pinecone index. +

+
+ +
+ +
+
+ +

+ If your hosted Chroma instance is protected by an API key + - enter it here. +

+
+ +
+
+ )} + + {type === 'qdrant' && ( +
+
+
+ +

+ You can find this in your cloud hosted qDrant cluster or + just using the URL to your local docker container. +

+
+ +
+ +
+
+ +

+ (optional) If you are using qDrant cloud you will need an + API key. +

+
+ +
+
+ )} + + {type === 'weaviate' && ( +
+
+
+ +

+ You can find this in your cloud hosted Weaviate cluster or + just using the URL to your local docker container. +

+
+ +
+ +
+
+ +

+ (optional) If you are using Weaviate cloud you will need + an API key. +

+
+ +
+
+ )} + +
+ {error && ( +

+ {error} +

+ )} + {success && ( +

+ Connector changes saved +

+ )} + +
+
+ )} +
+
+ ); +}; + +const SyncConnectorModal = ({ + organization, + connector, +}: { + organization: any; + connector: any; +}) => { + const [synced, setSynced] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const sync = async () => { + setError(null); + setLoading(true); + const { job, error } = await Organization.syncConnector( + organization.slug, + connector.id + ); + + if (!job) { + setError(error); + setLoading(false); + setSynced(false); + return; + } + + setLoading(false); + setSynced(true); + }; + + return ( + + event.target == event.currentTarget && event.currentTarget?.close() + } + > +
+
+

+ Sync Vector Database Connection +

+

+ Automatically sync existing information in your{' '} + {titleCase(connector.type)}{' '} + {connector.type === 'chroma' ? 'collections' : 'namespaces'} so you + can manage it more easily. This process can take a long time to + complete depending on how much data you have embedded already. +
+
+ Once you start this process you can check on its progress in the{' '} + + job queue. + +

+
+
+ {error && ( +

+ {error} +

+ )} + {synced ? ( + + ) : ( + + )} +
+
+
+ ); +};