File connector (#93)

* Initial backend changes for file connector

* Add another background job to clean up files

* UI + tweaks for backend
This commit is contained in:
Chris Weaver
2023-06-09 21:28:50 -07:00
committed by GitHub
parent f10ece4411
commit f20563c9bc
32 changed files with 774 additions and 38 deletions

59
web/package-lock.json generated
View File

@@ -22,6 +22,7 @@
"postcss": "^8.4.23",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-icons": "^4.8.0",
"swr": "^2.1.5",
"tailwindcss": "^3.3.1",
@@ -713,6 +714,14 @@
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz",
"integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag=="
},
"node_modules/attr-accept": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz",
"integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==",
"engines": {
"node": ">=4"
}
},
"node_modules/autoprefixer": {
"version": "10.4.14",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz",
@@ -1744,6 +1753,17 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/file-selector": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz",
"integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==",
"dependencies": {
"tslib": "^2.4.0"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -3267,6 +3287,22 @@
"react": "^18.2.0"
}
},
"node_modules/react-dropzone": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz",
"integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==",
"dependencies": {
"attr-accept": "^2.2.2",
"file-selector": "^0.6.0",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">= 10.13"
},
"peerDependencies": {
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-fast-compare": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
@@ -4562,6 +4598,11 @@
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz",
"integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag=="
},
"attr-accept": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz",
"integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg=="
},
"autoprefixer": {
"version": "10.4.14",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz",
@@ -5311,6 +5352,14 @@
"flat-cache": "^3.0.4"
}
},
"file-selector": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz",
"integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==",
"requires": {
"tslib": "^2.4.0"
}
},
"fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -6315,6 +6364,16 @@
"scheduler": "^0.23.0"
}
},
"react-dropzone": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz",
"integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==",
"requires": {
"attr-accept": "^2.2.2",
"file-selector": "^0.6.0",
"prop-types": "^15.8.1"
}
},
"react-fast-compare": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",

View File

@@ -23,6 +23,7 @@
"postcss": "^8.4.23",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-icons": "^4.8.0",
"swr": "^2.1.5",
"tailwindcss": "^3.3.1",

View File

@@ -0,0 +1,58 @@
// components/FileUpload.tsx
import { ChangeEvent, FC, useState } from "react";
import React from "react";
import Dropzone from "react-dropzone";
interface FileUploadProps {
selectedFiles: File[];
setSelectedFiles: (files: File[]) => void;
}
export const FileUpload: FC<FileUploadProps> = ({
selectedFiles,
setSelectedFiles,
}) => {
const [dragActive, setDragActive] = useState(false);
return (
<div>
<Dropzone
onDrop={(acceptedFiles) => {
setSelectedFiles(acceptedFiles);
setDragActive(false);
}}
onDragLeave={() => setDragActive(false)}
onDragEnter={() => setDragActive(true)}
>
{({ getRootProps, getInputProps }) => (
<section>
<div
{...getRootProps()}
className={
"flex flex-col items-center w-full px-4 py-12 rounded " +
"shadow-lg tracking-wide border border-gray-700 cursor-pointer" +
(dragActive ? " border-blue-500" : "")
}
>
<input {...getInputProps()} />
<b>Drag and drop some files here, or click to select files</b>
</div>
</section>
)}
</Dropzone>
{selectedFiles.length > 0 && (
<div className="mt-4">
<h2 className="font-bold">Selected Files</h2>
<ul>
{selectedFiles.map((file) => (
<div key={file.name} className="flex">
<p className="text-sm mr-2">{file.name}</p>
</div>
))}
</ul>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,262 @@
"use client";
import useSWR, { useSWRConfig } from "swr";
import { FileIcon } from "@/components/icons/icons";
import { fetcher } from "@/lib/fetcher";
import { HealthCheckBanner } from "@/components/health/healthcheck";
import { ConnectorIndexingStatus, FileConfig } from "@/lib/types";
import { linkCredential } from "@/lib/credential";
import { FileUpload } from "./FileUpload";
import { useState } from "react";
import { Button } from "@/components/Button";
import { Popup, PopupSpec } from "@/components/admin/connectors/Popup";
import { createConnector, runConnector } from "@/lib/connector";
import { BasicTable } from "@/components/admin/connectors/BasicTable";
import { CheckCircle, XCircle } from "@phosphor-icons/react";
import { Spinner } from "@/components/Spinner";
const COLUMNS = [
{ header: "File names", key: "fileNames" },
{ header: "Status", key: "status" },
];
const getNameFromPath = (path: string) => {
const pathParts = path.split("/");
return pathParts[pathParts.length - 1];
};
export default function File() {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [filesAreUploading, setFilesAreUploading] = useState<boolean>(false);
const [popup, setPopup] = useState<{
message: string;
type: "success" | "error";
} | null>(null);
const setPopupWithExpiration = (popupSpec: PopupSpec | null) => {
setPopup(popupSpec);
setTimeout(() => {
setPopup(null);
}, 4000);
};
const { mutate } = useSWRConfig();
const { data: connectorIndexingStatuses } = useSWR<
ConnectorIndexingStatus<any>[]
>("/api/manage/admin/connector/indexing-status", fetcher);
const fileIndexingStatuses: ConnectorIndexingStatus<FileConfig>[] =
connectorIndexingStatuses?.filter(
(connectorIndexingStatus) =>
connectorIndexingStatus.connector.source === "file"
) ?? [];
const inProgressFileIndexingStatuses =
fileIndexingStatuses.filter(
(connectorIndexingStatus) =>
connectorIndexingStatus.last_status === "in_progress" ||
connectorIndexingStatus.last_status === "not_started"
) ?? [];
const successfulFileIndexingStatuses = fileIndexingStatuses.filter(
(connectorIndexingStatus) =>
connectorIndexingStatus.last_status === "success"
);
const failedFileIndexingStatuses = fileIndexingStatuses.filter(
(connectorIndexingStatus) =>
connectorIndexingStatus.last_status === "failed"
);
return (
<div className="mx-auto container">
<div className="mb-4">
<HealthCheckBanner />
</div>
<div className="border-solid border-gray-600 border-b pb-2 mb-4 flex">
<FileIcon size="32" />
<h1 className="text-3xl font-bold pl-2">File</h1>
</div>
{popup && <Popup message={popup.message} type={popup.type} />}
{filesAreUploading && <Spinner />}
<h2 className="font-bold mb-2 mt-6 ml-auto mr-auto">Upload Files</h2>
<p className="text-sm mb-2">
Specify files below, click the <b>Upload</b> button, and the contents of
these files will be searchable via Danswer!
</p>
<div className="flex">
<div className="mx-auto max-w-3xl w-full">
<FileUpload
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
/>
<Button
className="mt-4 w-48"
fullWidth
disabled={selectedFiles.length === 0}
onClick={async () => {
const uploadCreateAndTriggerConnector = async () => {
const formData = new FormData();
selectedFiles.forEach((file) => {
formData.append("files", file);
});
const response = await fetch(
"/api/manage/admin/connector/file/upload",
{ method: "POST", body: formData }
);
const responseJson = await response.json();
if (!response.ok) {
setPopupWithExpiration({
message: `Unable to upload files - ${responseJson.detail}`,
type: "error",
});
return;
}
const filePaths = responseJson.file_paths as string[];
const [connectorErrorMsg, connector] =
await createConnector<FileConfig>({
name: "FileConnector-" + Date.now(),
source: "file",
input_type: "load_state",
connector_specific_config: {
file_locations: filePaths,
},
refresh_freq: null,
disabled: false,
});
if (connectorErrorMsg || !connector) {
setPopupWithExpiration({
message: `Unable to create connector - ${connectorErrorMsg}`,
type: "error",
});
return;
}
const credentialResponse = await linkCredential(
connector.id,
0
);
if (credentialResponse.detail) {
setPopupWithExpiration({
message: `Unable to link connector to credential - ${credentialResponse.detail}`,
type: "error",
});
return;
}
const runConnectorErrorMsg = await runConnector(connector.id, [
0,
]);
if (runConnectorErrorMsg) {
setPopupWithExpiration({
message: `Unable to run connector - ${runConnectorErrorMsg}`,
type: "error",
});
return;
}
mutate("/api/manage/admin/connector/indexing-status");
setSelectedFiles([]);
setPopupWithExpiration({
type: "success",
message: "Successfully uploaded files!",
});
};
setFilesAreUploading(true);
try {
await uploadCreateAndTriggerConnector();
} catch (e) {
console.log("Failed to index filels: ", e);
}
setFilesAreUploading(false);
}}
>
Upload!
</Button>
</div>
</div>
{inProgressFileIndexingStatuses.length > 0 && (
<>
<h2 className="font-bold mb-2 mt-6 ml-auto mr-auto">
In Progress File Indexing
</h2>
<BasicTable
columns={COLUMNS}
data={inProgressFileIndexingStatuses.map(
(connectorIndexingStatus) => {
return {
fileNames:
connectorIndexingStatus.connector.connector_specific_config.file_locations
.map(getNameFromPath)
.join(", "),
status: "In Progress",
};
}
)}
/>
</>
)}
{successfulFileIndexingStatuses.length > 0 && (
<>
<h2 className="font-bold mb-2 mt-6 ml-auto mr-auto">
Successful File Indexing
</h2>
<BasicTable
columns={COLUMNS}
data={successfulFileIndexingStatuses.map(
(connectorIndexingStatus) => {
return {
fileNames:
connectorIndexingStatus.connector.connector_specific_config.file_locations
.map(getNameFromPath)
.join(", "),
status: (
<div className="text-emerald-600 flex">
<CheckCircle className="my-auto mr-1" size="18" /> Success
</div>
),
};
}
)}
/>
</>
)}
{failedFileIndexingStatuses.length > 0 && (
<>
<h2 className="font-bold mb-2 mt-6 ml-auto mr-auto">
Failed File Indexing
</h2>
<p className="text-sm mb-3">
The following files failed to be indexed. Please contact an
administrator to resolve this issue.
</p>
<BasicTable
columns={COLUMNS}
data={failedFileIndexingStatuses.map((connectorIndexingStatus) => {
return {
fileNames:
connectorIndexingStatus.connector.connector_specific_config.file_locations
.map(getNameFromPath)
.join(", "),
status: (
<div className="text-red-600 flex">
<XCircle className="my-auto mr-1" size="18" /> Failed
</div>
),
};
})}
/>
</>
)}
</div>
);
}

View File

@@ -8,6 +8,7 @@ import {
SlackIcon,
KeyIcon,
ConfluenceIcon,
FileIcon,
} from "@/components/icons/icons";
import { DISABLE_AUTH } from "@/lib/constants";
import { getCurrentUserSS } from "@/lib/userSS";
@@ -62,15 +63,6 @@ export default async function AdminLayout({
),
link: "/admin/connectors/slack",
},
{
name: (
<div className="flex">
<GlobeIcon size="16" />
<div className="ml-1">Web</div>
</div>
),
link: "/admin/connectors/web",
},
{
name: (
<div className="flex">
@@ -98,6 +90,24 @@ export default async function AdminLayout({
),
link: "/admin/connectors/confluence",
},
{
name: (
<div className="flex">
<GlobeIcon size="16" />
<div className="ml-1">Web</div>
</div>
),
link: "/admin/connectors/web",
},
{
name: (
<div className="flex">
<FileIcon size="16" />
<div className="ml-1">File</div>
</div>
),
link: "/admin/connectors/file",
},
],
},
{

View File

@@ -3,6 +3,7 @@ interface Props {
children: JSX.Element | string;
disabled?: boolean;
fullWidth?: boolean;
className?: string;
}
export const Button = ({
@@ -10,6 +11,7 @@ export const Button = ({
children,
disabled = false,
fullWidth = false,
className = "",
}: Props) => {
return (
<button
@@ -17,9 +19,11 @@ export const Button = ({
"group relative " +
(fullWidth ? "w-full " : "") +
"py-1 px-2 border border-transparent text-sm " +
"font-medium rounded-md text-white bg-red-800 " +
"hover:bg-red-900 focus:outline-none focus:ring-2 " +
"focus:ring-offset-2 focus:ring-red-500 mx-auto"
"font-medium rounded-md text-white " +
"focus:outline-none focus:ring-2 " +
"focus:ring-offset-2 focus:ring-red-500 mx-auto " +
(disabled ? "bg-gray-700 " : "bg-red-800 hover:bg-red-900 ") +
className
}
onClick={onClick}
disabled={disabled}

View File

@@ -0,0 +1,9 @@
import "./spinner.css";
export const Spinner = () => {
return (
<div className="fixed top-0 left-0 z-50 w-screen h-screen bg-black bg-opacity-50 flex items-center justify-center">
<div className="loader ease-linear rounded-full border-8 border-t-8 border-gray-200 h-8 w-8"></div>
</div>
);
};

View File

@@ -11,7 +11,7 @@ import {
Plug,
} from "@phosphor-icons/react";
import { SiConfluence, SiGithub, SiGoogledrive, SiSlack } from "react-icons/si";
import { FaGlobe } from "react-icons/fa";
import { FaFile, FaGlobe } from "react-icons/fa";
interface IconProps {
size?: string;
@@ -76,6 +76,13 @@ export const GlobeIcon = ({
return <FaGlobe size={size} className={className} />;
};
export const FileIcon = ({
size = "16",
className = defaultTailwindCSS,
}: IconProps) => {
return <FaFile size={size} className={className} />;
};
export const SlackIcon = ({
size = "16",
className = defaultTailwindCSS,

View File

@@ -10,6 +10,7 @@ const sources: Source[] = [
{ displayName: "Confluence", internalName: "confluence" },
{ displayName: "Github PRs", internalName: "github" },
{ displayName: "Web", internalName: "web" },
{ displayName: "File", internalName: "file" },
];
interface SourceSelectorProps {

View File

@@ -131,7 +131,10 @@ export const SearchResultsDisplay: React.FC<SearchResultsDisplayProps> = ({
className="text-sm border-b border-gray-800 mb-3"
>
<a
className="rounded-lg flex font-bold"
className={
"rounded-lg flex font-bold " +
(doc.link ? "" : "pointer-events-none")
}
href={doc.link}
target="_blank"
rel="noopener noreferrer"

View File

@@ -1,6 +1,7 @@
import { ValidSources } from "@/lib/types";
import {
ConfluenceIcon,
FileIcon,
GithubIcon,
GlobeIcon,
GoogleDriveIcon,
@@ -21,6 +22,12 @@ export const getSourceMetadata = (sourceType: ValidSources): SourceMetadata => {
displayName: "Web",
adminPageLink: "/admin/connectors/web",
};
case "file":
return {
icon: FileIcon,
displayName: "File",
adminPageLink: "/admin/connectors/file",
};
case "slack":
return {
icon: SlackIcon,

View File

@@ -0,0 +1,23 @@
.loader {
border-top-color: #2876aa;
-webkit-animation: spinner 1.5s linear infinite;
animation: spinner 1.5s linear infinite;
}
@-webkit-keyframes spinner {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}
@keyframes spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -1,8 +1,18 @@
import { Connector, ConnectorBase } from "./types";
async function handleResponse(
response: Response
): Promise<[string | null, any]> {
const responseJson = await response.json();
if (response.ok) {
return [null, responseJson];
}
return [responseJson.detail, null];
}
export async function createConnector<T>(
connector: ConnectorBase<T>
): Promise<Connector<T>> {
): Promise<[string | null, Connector<T> | null]> {
const response = await fetch(`/api/manage/admin/connector`, {
method: "POST",
headers: {
@@ -10,7 +20,7 @@ export async function createConnector<T>(
},
body: JSON.stringify(connector),
});
return response.json();
return handleResponse(response);
}
export async function updateConnector<T>(
@@ -23,7 +33,7 @@ export async function updateConnector<T>(
},
body: JSON.stringify(connector),
});
return response.json();
return await response.json();
}
export async function deleteConnector<T>(
@@ -35,5 +45,20 @@ export async function deleteConnector<T>(
"Content-Type": "application/json",
},
});
return response.json();
return await response.json();
}
export async function runConnector(
connectorId: number,
credentialIds: number[] | null = null
): Promise<string | null> {
const response = await fetch("/api/manage/admin/connector/run-once", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ connector_id: connectorId, credentialIds }),
});
if (!response.ok) {
return (await response.json()).detail;
}
return null;
}

View File

@@ -8,7 +8,7 @@ export async function deleteCredential<T>(credentialId: number) {
return response.json();
}
export async function linkCredential<T>(
export async function linkCredential(
connectorId: number,
credentialId: number
) {

View File

@@ -12,7 +12,8 @@ export type ValidSources =
| "github"
| "slack"
| "google_drive"
| "confluence";
| "confluence"
| "file";
export type ValidInputTypes = "load_state" | "poll" | "event";
// CONNECTORS
@@ -21,7 +22,7 @@ export interface ConnectorBase<T> {
input_type: ValidInputTypes;
source: ValidSources;
connector_specific_config: T;
refresh_freq: number;
refresh_freq: number | null;
disabled: boolean;
}
@@ -49,6 +50,10 @@ export interface SlackConfig {
workspace: string;
}
export interface FileConfig {
file_locations: string[];
}
export interface ConnectorIndexingStatus<T> {
connector: Connector<T>;
public_doc: boolean;