Migration UI and job queue

This commit is contained in:
timothycarambat
2023-10-03 11:20:37 -07:00
parent c28abf1a29
commit 82cd93dbdf
11 changed files with 554 additions and 29 deletions
+2
View File
@@ -6,6 +6,7 @@ const { documentProcessorEndpoints } = require("./document-processor");
const { documentEndpoints } = require("./documents");
const { jobEndpoints } = require("./jobs");
const { organizationEndpoints } = require("./organizations");
const { toolEndpoints } = require("./tools");
const { userEndpoints } = require("./users");
const { workspaceEndpoints } = require("./workspaces");
@@ -36,6 +37,7 @@ function v1Endpoints(app) {
documentEndpoints(app);
documentProcessorEndpoints(app);
jobEndpoints(app);
toolEndpoints(app);
}
module.exports = { v1Endpoints };
@@ -596,6 +596,7 @@ function organizationEndpoints(app) {
documents: "countForEntity",
vectors: "calcVectors",
"cache-size": "calcVectorCache",
vectorCounts: "vectorCount",
};
if (!Object.keys(methods).includes(statistic)) {
+82
View File
@@ -0,0 +1,82 @@
const { Organization } = require("../../../models/organization");
const { Queue } = require("../../../models/queue");
const {
userFromSession,
validSessionForUser,
reqBody,
} = require("../../../utils/http");
const {
organizationMigrationJob,
} = require("../../../utils/jobs/organizationMigrationJob");
process.env.NODE_ENV === "development"
? require("dotenv").config({ path: `.env.${process.env.NODE_ENV}` })
: require("dotenv").config();
function toolEndpoints(app) {
if (!app) return;
app.post(
"/v1/tools/org/:orgSlug/migrate",
[validSessionForUser],
async function (request, response) {
try {
const { orgSlug } = request.params;
const { destinationOrgId } = reqBody(request);
const user = await userFromSession(request);
if (!user || user.role !== "admin") {
response.sendStatus(403).end();
return;
}
const organization = await Organization.getWithOwner(user.id, {
slug: orgSlug,
});
if (!organization) {
response
.status(200)
.json({ success: false, message: "No org found." });
return;
}
const destinationOrg = await Organization.getWithOwner(user.id, {
id: Number(destinationOrgId),
});
if (!destinationOrg) {
response
.status(200)
.json({
success: false,
message: "Destination org does not exit.",
});
return;
}
const existingJobForOrg = await Queue.get({
taskName: "workspace/migrate",
status: "pending",
organization_id: organization.id,
});
const existingJobForDestinationOrg = await Queue.get({
taskName: "workspace/migrate",
status: "pending",
organization_id: destinationOrg.id,
});
//TODO - uncomment if (!!existingJobForOrg || !!existingJobForDestinationOrg) {
// response.status(200).json({ success: false, message: "There is an existing migration job already running for these organizations." });
// return;
// }
await organizationMigrationJob(organization, destinationOrg, user);
response
.status(200)
.json({ success: true, message: "Migration job queued." });
} catch (e) {
console.log(e.message, e);
response.sendStatus(500).end();
}
}
);
}
module.exports = { toolEndpoints };
+20
View File
@@ -3,6 +3,7 @@ const path = require("path");
const { v5 } = require("uuid");
const { fetchMetadata } = require("../utils/storage");
const { DocumentVectors } = require("./documentVectors");
const { selectConnector } = require("../utils/vectordatabases/providers");
const WorkspaceDocument = {
vectorFilenameRaw: function (documentName, workspaceId) {
@@ -158,6 +159,25 @@ const WorkspaceDocument = {
return totalBytes;
},
// Will get both the remote and local count of vectors to see if the numbers match.
vectorCount: async function (field = "organization_id", value = null) {
try {
const { OrganizationConnection } = require("./organizationConnection");
const connector = await OrganizationConnection.get({ [field]: value });
if (!connector) return { remoteCount: 0, localCount: 0 };
const vectorDb = selectConnector(connector);
return {
remoteCount: (await vectorDb.totalIndicies())?.result,
localCount: await DocumentVectors.count({
[field]: value,
}),
};
} catch (e) {
console.error(e);
return 0;
}
},
};
module.exports.WorkspaceDocument = WorkspaceDocument;
@@ -0,0 +1,29 @@
const { Queue } = require("../../../models/queue");
async function organizationMigrationJob(
organization,
destinationOrganization,
user
) {
const taskName = `organization/migrate`;
const jobData = { organization, destinationOrganization };
const { job, error } = await Queue.create(
taskName,
jobData,
user.id,
organization.id
);
if (!!error) return { job, error };
await Queue.sendJob({
name: taskName,
data: {
jobId: job.id,
...jobData,
},
});
return { job, error: null };
}
module.exports = {
organizationMigrationJob,
};
+4 -1
View File
@@ -24,6 +24,9 @@ const OnboardingSecuritySetup = lazy(
const OrganizationJobsView = lazy(() => import('./pages/Jobs'));
const OrganizationToolsView = lazy(() => import('./pages/Tools'));
const SystemSettingsView = lazy(() => import('./pages/SystemSettings'));
const MigrateConnectionView = lazy(
() => import('./pages/Tools/MigrateConnection')
);
function App() {
return (
@@ -78,7 +81,7 @@ function App() {
<Route
path="/dashboard/:slug/tools/db-migration"
element={<PrivateRoute Component={OrganizationToolsView} />}
element={<PrivateRoute Component={MigrateConnectionView} />}
/>
<Route path="/auth/sign-up" element={<SignUp />} />
+36 -24
View File
@@ -95,8 +95,9 @@ export default function Sidebar({
<>
<aside
ref={sidebar}
className={`absolute left-0 top-0 z-9999 flex h-screen w-72.5 flex-col overflow-y-hidden bg-slate-900 duration-300 ease-linear dark:bg-boxdark lg:static lg:translate-x-0 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
className={`absolute left-0 top-0 z-9999 flex h-screen w-72.5 flex-col overflow-y-hidden bg-slate-900 duration-300 ease-linear dark:bg-boxdark lg:static lg:translate-x-0 ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
{/* <!-- SIDEBAR HEADER --> */}
<div className="flex items-center justify-between gap-2 px-6 py-5.5 lg:py-6.5">
@@ -161,10 +162,11 @@ export default function Sidebar({
<React.Fragment>
<NavLink
to="#"
className={`group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${(pathname === '/' ||
className={`group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${
(pathname === '/' ||
pathname.includes('dashboard')) &&
'bg-graydark dark:bg-meta-4'
}`}
}`}
onClick={(e) => {
e.preventDefault();
sidebarExpanded
@@ -175,14 +177,16 @@ export default function Sidebar({
<Command className="h-4 w-4" />
Organizations
<ChevronUp
className={`absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 fill-current ${open && 'rotate-180'
}`}
className={`absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 fill-current ${
open && 'rotate-180'
}`}
/>
</NavLink>
{/* <!-- Dropdown Menu Start --> */}
<div
className={`translate transform overflow-hidden ${!open && 'hidden'
}`}
className={`translate transform overflow-hidden ${
!open && 'hidden'
}`}
>
<ul className="mb-5.5 mt-4 flex flex-col gap-2.5 pl-6">
{organizations.map((org: any, i: number) => {
@@ -225,10 +229,11 @@ export default function Sidebar({
<React.Fragment>
<NavLink
to="#"
className={`group relative flex items-center gap-2.5 rounded-t-sm px-4 py-2 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${(pathname === '/' ||
className={`group relative flex items-center gap-2.5 rounded-t-sm px-4 py-2 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${
(pathname === '/' ||
pathname.includes('dashboard')) &&
'bg-graydark dark:bg-meta-4'
}`}
}`}
onClick={(e) => {
e.preventDefault();
sidebarExpanded
@@ -239,14 +244,16 @@ export default function Sidebar({
<Box className="h-4 w-4" />
Workspaces
<ChevronUp
className={`absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 fill-current ${open && 'rotate-180'
}`}
className={`absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 fill-current ${
open && 'rotate-180'
}`}
/>
</NavLink>
{/* <!-- Dropdown Menu Start --> */}
<div
className={`translate transform overflow-hidden ${!open && 'hidden'
}`}
className={`translate transform overflow-hidden ${
!open && 'hidden'
}`}
>
<WorkspaceSearch
RenderComponent={WorkspaceItem}
@@ -306,10 +313,11 @@ export default function Sidebar({
<div className={`translate transform overflow-hidden`}>
<NavLink
to={paths.toolsHome(organization)}
className={`group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${(pathname === '/' ||
className={`group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${
(pathname === '/' ||
pathname.includes('all-tools')) &&
'bg-graydark dark:bg-meta-4'
}`}
}`}
>
<Package className="h-4 w-4" />
Tools & More
@@ -320,10 +328,11 @@ export default function Sidebar({
<div className={`translate transform overflow-hidden`}>
<NavLink
to={paths.users()}
className={`group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${(pathname === '/' ||
className={`group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${
(pathname === '/' ||
pathname.includes('users')) &&
'bg-graydark dark:bg-meta-4'
}`}
}`}
>
<Users className="h-4 w-4" />
User Management
@@ -334,12 +343,13 @@ export default function Sidebar({
<div className={`translate transform overflow-hidden`}>
<NavLink
to={paths.organizationSettings(organization)}
className={`group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${(pathname === '/' ||
className={`group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${
(pathname === '/' ||
pathname.includes(
`${organization?.slug}/settings`
)) &&
'bg-graydark dark:bg-meta-4'
}`}
}`}
>
<Briefcase className="h-4 w-4" />
Organization Settings
@@ -354,10 +364,11 @@ export default function Sidebar({
<div className={`translate transform overflow-hidden`}>
<NavLink
to={paths.settings()}
className={`group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${(pathname === '/' ||
className={`group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${
(pathname === '/' ||
pathname.includes('system-settings')) &&
'bg-graydark dark:bg-meta-4'
}`}
}`}
>
<Tool className="h-4 w-4" />
System Settings
@@ -370,9 +381,10 @@ export default function Sidebar({
<div className={`translate transform overflow-hidden`}>
<NavLink
to={paths.jobs(organization)}
className={`group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${(pathname === '/' || pathname.includes('jobs')) &&
className={`group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${
(pathname === '/' || pathname.includes('jobs')) &&
'bg-graydark dark:bg-meta-4'
}`}
}`}
>
<Radio className="h-4 w-4" />
Background Jobs
+24
View File
@@ -0,0 +1,24 @@
import { API_BASE } from '../utils/constants';
import { baseHeaders } from '../utils/request';
const Tools = {
migrateOrg: async (
slug: string,
config = {}
): Promise<{ success: boolean; message: null | string }> => {
return fetch(`${API_BASE}/v1/tools/org/${slug}/migrate`, {
method: 'POST',
cache: 'no-cache',
body: JSON.stringify({ ...config }),
headers: baseHeaders(),
})
.then((res) => res.json())
.then((res) => res)
.catch((e) => {
console.error(e);
return { success: false, message: e.message };
});
},
};
export default Tools;
@@ -0,0 +1,350 @@
import { FullScreenLoader } from '../../../components/Preloader';
import useUser from '../../../hooks/useUser';
import { useState, useEffect } from 'react';
import DefaultLayout from '../../../layout/DefaultLayout';
import User from '../../../models/user';
import paths from '../../../utils/paths';
import AppLayout from '../../../layout/AppLayout';
import { useParams } from 'react-router-dom';
import { APP_NAME } from '../../../utils/constants';
import Organization from '../../../models/organization';
import { CheckCircle, Info, Loader } from 'react-feather';
import { nFormatter } from '../../../utils/numbers';
import pluralize from 'pluralize';
import Tools from '../../../models/tools';
import showToast from '../../../utils/toast';
export default function MigrateConnectionView() {
const { user } = useUser();
const { slug } = useParams();
const [loading, setLoading] = useState<boolean>(true);
const [organizations, setOrganizations] = useState<object[]>([]);
const [organization, setOrganization] = useState<object | null>(null);
const [totalVectorCount, setTotalVectorCount] = useState<number>(0);
const [destination, setDestination] = useState<object | null>(null);
useEffect(() => {
async function userOrgs() {
if (!slug) return false;
const orgs = await User.organizations();
if (orgs.length === 0) {
window.location.replace(paths.onboarding.orgName());
return false;
}
for (const org of orgs) {
const connector = await Organization.connector(org.slug);
org.connector = connector || {};
}
const focusedOrg =
orgs?.find((org: any) => org.slug === slug) || orgs?.[0];
setOrganizations(orgs);
setOrganization(focusedOrg);
setLoading(false);
}
userOrgs();
}, [user.uid, window.location.pathname]);
if (loading || organizations.length === 0) {
return (
<DefaultLayout>
<FullScreenLoader />
</DefaultLayout>
);
}
if (!organization) return null;
return (
<AppLayout
headerEntity={organization}
headerProp="uuid"
organizations={organizations}
organization={organization}
workspaces={[]}
>
<div className="flex-1 rounded-sm bg-white px-6 pb-6">
<div className="flex items-start justify-between">
<div className="mb-6">
<h4 className="text-3xl font-semibold text-black">
Migrate your vector data
</h4>
<p className="mt-2 text-gray-600">
This tool will enable you to migrate all data in this vector
database to another known {APP_NAME} vector database connection.
<br />
No data will be modified or removed from the current vector
database.
<br />
You should only migrate to empty vector databases.
</p>
</div>
</div>
<div className="flex w-1/2 flex-col gap-y-2">
<div className="flex w-full justify-between ">
<CurrentOrgCard
organization={organization}
onCount={setTotalVectorCount}
/>
<div className="flex">
<p className="my-auto text-xl font-semibold text-gray-700">to</p>
</div>
<DestinationOrgCard
currentOrg={organization}
selection={destination}
organizations={organizations}
onSelect={setDestination}
/>
</div>
{!!destination && totalVectorCount > 0 && (
<SubmitMigrationJob
organization={organization}
destination={destination}
totalVectorCount={totalVectorCount}
/>
)}
</div>
</div>
</AppLayout>
);
}
function CurrentOrgCard({
organization,
onCount,
}: {
organization: any;
onCount: (count: number) => void;
}) {
const [inSync, setInSync] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function OrgInSync() {
const { remoteCount, localCount } = (
await Organization.stats(organization.slug, 'vectorCounts')
).value;
if (remoteCount === 0) {
setInSync(false);
setLoading(false);
return;
}
setInSync(remoteCount === localCount);
onCount(remoteCount);
setLoading(false);
}
OrgInSync();
}, []);
return (
<div>
<p className="mb-1 font-semibold text-gray-700">
Migrating all data from
</p>
<div className="w-[20rem] rounded-lg border border-gray-200 bg-white p-6 shadow">
<h5 className="mb-2 text-2xl font-bold tracking-tight text-gray-900">
{organization?.name}
</h5>
<p className="mb-3 text-sm font-normal text-gray-700">
Connected to a <b>{organization?.connector?.type}</b> vector database.
</p>
<div>
{loading ? (
<div className="flex h-8 w-full animate-pulse items-center justify-center gap-x-1 rounded-lg bg-gray-100 py-1 text-sm" />
) : (
<>
{inSync ? (
<div className="flex w-full items-center justify-center gap-x-1 rounded-lg bg-green-100 py-1 text-sm text-green-600">
<CheckCircle size={18} />
<p>Ready to migrate!</p>
</div>
) : (
<div className="text-grey-600 flex w-full flex-1 items-center justify-center gap-x-1 rounded-lg bg-gray-100 px-1 py-1 text-sm">
<Info size={18} className="shrink-0" />
<p className="text-center">
Some vectors are not synced and will not migrate.
</p>
</div>
)}
</>
)}
</div>
</div>
</div>
);
}
function DestinationOrgCard({
currentOrg,
selection,
organizations,
onSelect,
}: {
currentOrg: any;
selection?: any | null;
organizations: any[];
onSelect: (org: object) => void;
}) {
const availableDestinations = organizations.filter(
(org) => org.slug !== currentOrg.slug && !!org.connector
);
const [destination, setDestination] = useState(selection || null);
const [isEmpty, setIsEmpty] = useState(false);
const [loading, setLoading] = useState(true);
const handleSelection = (e) => {
const selectedDestination = availableDestinations.find(
(org) => org.id === Number(e.target.value)
);
setDestination(selectedDestination);
onSelect(selectedDestination);
};
useEffect(() => {
async function OrgIsEmpty() {
if (!destination) return;
const { remoteCount } = (
await Organization.stats(destination.slug, 'vectorCounts')
).value;
setIsEmpty(remoteCount === 0);
setLoading(false);
}
OrgIsEmpty();
}, [destination]);
if (!destination) {
return (
<div className="flex flex-col">
<p className="mb-1 font-semibold text-gray-700">&nbsp;</p>
<div className="h-full w-[20rem] rounded-lg border border-gray-200 bg-white p-6 shadow">
<select
onChange={handleSelection}
name="destinationOrgId"
className="w-full rounded-lg border border-gray-100 bg-gray-50 px-4 py-2 text-2xl text-gray-800 outline-none"
>
<option>Select organization</option>
{availableDestinations.map((org: any) => {
return <option value={org.id}>{org.name}</option>;
})}
</select>
<p className="mb-3 text-sm font-normal text-gray-700"></p>
</div>
</div>
);
}
return (
<div className="flex flex-col">
<p className="mb-1 font-semibold text-gray-700">&nbsp;</p>
<div className="h-full w-[20rem] rounded-lg border border-gray-200 bg-white p-6 shadow">
<select
onChange={handleSelection}
name="destinationOrgId"
className="w-full rounded-lg border border-gray-100 bg-gray-50 px-4 py-2 text-2xl text-gray-800 outline-none"
>
<option value={destination.id}>{destination.name}</option>
{availableDestinations
.filter((org) => org.id !== destination.id)
.map((org: any) => {
return <option value={org.id}>{org.name}</option>;
})}
</select>
<p className="mb-3 text-sm font-normal text-gray-700">
Connected to a <b>{destination?.connector?.type}</b> vector database.
</p>
<div>
{loading ? (
<div className="flex h-8 w-full animate-pulse items-center justify-center gap-x-1 rounded-lg bg-gray-100 py-1 text-sm" />
) : (
<>
{isEmpty ? (
<div className="flex w-full items-center justify-center gap-x-1 rounded-lg bg-green-100 py-1 text-sm text-green-600">
<CheckCircle size={18} />
<p>Ready to migrate!</p>
</div>
) : (
<div className="text-grey-600 flex w-full flex-1 items-center justify-center gap-x-1 rounded-lg bg-gray-100 px-1 py-1 text-sm">
<Info size={18} className="shrink-0" />
<p className="text-center">
You should only migrate to a empty vector database.
</p>
</div>
)}
</>
)}
</div>
</div>
</div>
);
}
function SubmitMigrationJob({
organization,
destination,
totalVectorCount,
}: {
organization: any;
destination: any;
totalVectorCount: number;
}) {
const [status, setStatus] = useState<
'idle' | 'loading' | 'success' | 'failed'
>('idle');
async function submitMigration() {
setStatus('loading');
const { success, message } = await Tools.migrateOrg(organization.slug, {
destinationOrgId: destination.id,
});
if (success) {
setStatus('success');
showToast('Migration has started as background job.', 'success');
return;
}
setStatus('failed');
showToast(message as string, 'error');
}
if (status === 'success') return null;
if (status === 'loading') {
return (
<div className="mt-2 flex flex-col gap-y-1">
<button
disabled={true}
className="full flex items-center justify-center gap-x-1 rounded-lg bg-blue-600 px-4 py-2 font-semibold text-white hover:bg-blue-700"
>
<Loader size={14} className="animate-spin" />
Migrating {nFormatter(totalVectorCount)}{' '}
{pluralize('vector', totalVectorCount)} to {destination.name}...
</button>
<p className="mx-auto w-4/5 text-center text-xs text-gray-500">
This task will run in the background. You can view its progress in
Background Jobs.
</p>
</div>
);
}
return (
<div className="mt-2 flex flex-col gap-y-1">
<button
onClick={submitMigration}
className="full rounded-lg bg-blue-600 px-4 py-2 font-semibold text-white hover:bg-blue-700"
>
Migrate {nFormatter(totalVectorCount)}{' '}
{pluralize('vector', totalVectorCount)} to {destination.name}
</button>
<p className="mx-auto w-4/5 text-center text-xs text-gray-500">
Depending on the number of vectors this process can take a long time.
Please do not edit or update either vector database while the process is
running.
</p>
</div>
);
}
+5 -3
View File
@@ -61,8 +61,7 @@ const ToolItem = ({
}) => {
const [show, setShow] = useState(false);
return (
<div className="flex w-full items-center justify-between border-b border-gray-200 py-5 text-left text-gray-500 dark:border-gray-700 dark:text-gray-400"
>
<div className="flex w-full items-center justify-between border-b border-gray-200 py-5 text-left text-gray-500 dark:border-gray-700 dark:text-gray-400">
<div className="flex w-full items-center justify-between pr-4">
<div className="flex items-center gap-x-8">
<div className="flex flex-col gap-y-2">
@@ -78,7 +77,10 @@ const ToolItem = ({
<p className="text-sm font-normal">feature under development.</p>
</div>
) : (
<a href={linkTo} className="flex items-center gap-x-1 rounded-md bg-blue-600 hover:bg-blue-700 text-white px-4 py-1.5 text-slate-600 ">
<a
href={linkTo}
className="flex items-center gap-x-1 rounded-md bg-blue-600 px-4 py-1.5 text-slate-600 text-white hover:bg-blue-700 "
>
<p className="text-sm font-normal ">Open tool &rarr;</p>
</a>
)}
+1 -1
View File
@@ -39,7 +39,7 @@ const paths = {
tools: {
migrationTool: function ({ slug }: { slug: string }) {
return `/dashboard/${slug}/tools/db-migration`;
}
},
},
dashboard: function () {
return '/dashboard';