Enable non-admin credentials + add page for google drive (#84)

* Enable non-admin credentials + add page for google drive

* Return one indexing status entry for each connector / credential pair

* Remove some logs

* Small fixes

* Sort index status by source
This commit is contained in:
Chris Weaver
2023-06-04 11:26:50 -07:00
committed by GitHub
parent 8c9b3079aa
commit 7cc64efc3a
33 changed files with 1072 additions and 644 deletions

25
web/package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@phosphor-icons/react": "^2.0.8",
"@types/js-cookie": "^3.0.3",
"@types/node": "18.15.11",
"@types/react": "18.0.32",
"@types/react-dom": "18.0.11",
@@ -16,6 +17,7 @@
"eslint": "8.37.0",
"eslint-config-next": "13.2.4",
"formik": "^2.2.9",
"js-cookie": "^3.0.5",
"next": "^13.2.4",
"postcss": "^8.4.23",
"react": "^18.2.0",
@@ -392,6 +394,11 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/js-cookie": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.3.tgz",
"integrity": "sha512-Xe7IImK09HP1sv2M/aI+48a20VX+TdRJucfq4vfRVy6nWN8PYPOEnlMRSgxJAgYQIXJVL8dZ4/ilAM7dWNaOww=="
},
"node_modules/@types/json5": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@@ -2483,6 +2490,14 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"engines": {
"node": ">=14"
}
},
"node_modules/js-sdsl": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz",
@@ -4324,6 +4339,11 @@
"tslib": "^2.4.0"
}
},
"@types/js-cookie": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.3.tgz",
"integrity": "sha512-Xe7IImK09HP1sv2M/aI+48a20VX+TdRJucfq4vfRVy6nWN8PYPOEnlMRSgxJAgYQIXJVL8dZ4/ilAM7dWNaOww=="
},
"@types/json5": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@@ -5791,6 +5811,11 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.18.2.tgz",
"integrity": "sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg=="
},
"js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="
},
"js-sdsl": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz",

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"@phosphor-icons/react": "^2.0.8",
"@types/js-cookie": "^3.0.3",
"@types/node": "18.15.11",
"@types/react": "18.0.32",
"@types/react-dom": "18.0.11",
@@ -17,6 +18,7 @@
"eslint": "8.37.0",
"eslint-config-next": "13.2.4",
"formik": "^2.2.9",
"js-cookie": "^3.0.5",
"next": "^13.2.4",
"postcss": "^8.4.23",
"react": "^18.2.0",

View File

@@ -25,7 +25,7 @@ const Main = () => {
isLoading: isConnectorIndexingStatusesLoading,
error: isConnectorIndexingStatusesError,
} = useSWR<ConnectorIndexingStatus<any>[]>(
"/api/admin/connector/indexing-status",
"/api/manage/admin/connector/indexing-status",
fetcher
);
const {
@@ -34,7 +34,7 @@ const Main = () => {
isValidating: isCredentialsValidating,
error: isCredentialsError,
} = useSWR<Credential<ConfluenceCredentialJson>[]>(
"/api/admin/credential",
"/api/manage/credential",
fetcher
);
@@ -85,7 +85,7 @@ const Main = () => {
className="ml-1 hover:bg-gray-700 rounded-full p-1"
onClick={async () => {
await deleteCredential(confluenceCredential.id);
mutate("/api/admin/credential");
mutate("/api/manage/credential");
}}
>
<TrashIcon />
@@ -131,7 +131,7 @@ const Main = () => {
}}
onSubmit={(isSuccess) => {
if (isSuccess) {
mutate("/api/admin/credential");
mutate("/api/manage/credential");
}
}}
/>
@@ -181,7 +181,7 @@ const Main = () => {
onCredentialLink={async (connectorId) => {
if (confluenceCredential) {
await linkCredential(connectorId, confluenceCredential.id);
mutate("/api/admin/connector/indexing-status");
mutate("/api/manage/admin/connector/indexing-status");
}
}}
specialColumns={[
@@ -198,7 +198,9 @@ const Main = () => {
),
},
]}
onUpdate={() => mutate("/api/admin/connector/indexing-status")}
onUpdate={() =>
mutate("/api/manage/admin/connector/indexing-status")
}
/>
</div>
</>
@@ -229,7 +231,7 @@ const Main = () => {
onSubmit={async (isSuccess, responseJson) => {
if (isSuccess && responseJson) {
await linkCredential(responseJson.id, confluenceCredential.id);
mutate("/api/admin/connector/indexing-status");
mutate("/api/manage/admin/connector/indexing-status");
}
}}
/>

View File

@@ -25,7 +25,7 @@ const Main = () => {
isLoading: isConnectorIndexingStatusesLoading,
error: isConnectorIndexingStatusesError,
} = useSWR<ConnectorIndexingStatus<any>[]>(
"/api/admin/connector/indexing-status",
"/api/manage/admin/connector/indexing-status",
fetcher
);
@@ -35,7 +35,7 @@ const Main = () => {
isValidating: isCredentialsValidating,
error: isCredentialsError,
} = useSWR<Credential<GithubCredentialJson>[]>(
"/api/admin/credential",
"/api/manage/credential",
fetcher
);
@@ -81,7 +81,7 @@ const Main = () => {
className="ml-1 hover:bg-gray-700 rounded-full p-1"
onClick={async () => {
await deleteCredential(githubCredential.id);
mutate("/api/admin/credential");
mutate("/api/manage/credential");
}}
>
<TrashIcon />
@@ -121,7 +121,7 @@ const Main = () => {
}}
onSubmit={(isSuccess) => {
if (isSuccess) {
mutate("/api/admin/credential");
mutate("/api/manage/credential");
}
}}
/>
@@ -149,7 +149,7 @@ const Main = () => {
onCredentialLink={async (connectorId) => {
if (githubCredential) {
await linkCredential(connectorId, githubCredential.id);
mutate("/api/admin/connector/indexing-status");
mutate("/api/manage/admin/connector/indexing-status");
}
}}
specialColumns={[
@@ -160,7 +160,9 @@ const Main = () => {
`${connector.connector_specific_config.repo_owner}/${connector.connector_specific_config.repo_name}`,
},
]}
onUpdate={() => mutate("/api/admin/connector/indexing-status")}
onUpdate={() =>
mutate("/api/manage/admin/connector/indexing-status")
}
/>
</div>
</>
@@ -196,7 +198,7 @@ const Main = () => {
onSubmit={async (isSuccess, responseJson) => {
if (isSuccess && responseJson) {
await linkCredential(responseJson.id, githubCredential.id);
mutate("/api/admin/connector/indexing-status");
mutate("/api/manage/admin/connector/indexing-status");
}
}}
/>

View File

@@ -2,11 +2,12 @@ import { getDomain } from "@/lib/redirectSS";
import { buildUrl } from "@/lib/utilsSS";
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { GOOGLE_DRIVE_AUTH_IS_ADMIN_COOKIE_NAME } from "@/lib/constants";
export const GET = async (request: NextRequest) => {
// Wrapper around the FastAPI endpoint /connectors/google-drive/callback,
// which adds back a redirect to the Google Drive admin page.
const url = new URL(buildUrl("/admin/connector/google-drive/callback"));
const url = new URL(buildUrl("/manage/connector/google-drive/callback"));
url.search = request.nextUrl.search;
const response = await fetch(url.toString(), {
@@ -26,7 +27,14 @@ export const GET = async (request: NextRequest) => {
return NextResponse.redirect(new URL("/auth/error", getDomain(request)));
}
return NextResponse.redirect(
new URL("/admin/connectors/google-drive", getDomain(request))
);
if (
cookies()
.get(GOOGLE_DRIVE_AUTH_IS_ADMIN_COOKIE_NAME)
?.value?.toLowerCase() === "true"
) {
return NextResponse.redirect(
new URL("/admin/connectors/google-drive", getDomain(request))
);
}
return NextResponse.redirect(new URL("/user/connectors", getDomain(request)));
};

View File

@@ -18,6 +18,10 @@ import {
} from "@/lib/types";
import { deleteConnector } from "@/lib/connector";
import { StatusRow } from "@/components/admin/connectors/table/ConnectorsTable";
import { setupGoogleDriveOAuth } from "@/lib/googleDrive";
import Cookies from "js-cookie";
import { GOOGLE_DRIVE_AUTH_IS_ADMIN_COOKIE_NAME } from "@/lib/constants";
import { deleteCredential, linkCredential } from "@/lib/credential";
const AppCredentialUpload = ({
setPopup,
@@ -61,7 +65,7 @@ const AppCredentialUpload = ({
disabled={!appCredentialJsonStr}
onClick={async () => {
const response = await fetch(
"/api/admin/connector/google-drive/app-credential",
"/api/manage/admin/connector/google-drive/app-credential",
{
method: "PUT",
headers: {
@@ -98,7 +102,7 @@ const Main = () => {
isLoading: isAppCredentialLoading,
error: isAppCredentialError,
} = useSWR<{ client_id: string }>(
"/api/admin/connector/google-drive/app-credential",
"/api/manage/admin/connector/google-drive/app-credential",
fetcher
);
const {
@@ -106,7 +110,7 @@ const Main = () => {
isLoading: isConnectorIndexingStatusesLoading,
error: isConnectorIndexingStatusesError,
} = useSWR<ConnectorIndexingStatus<any>[]>(
"/api/admin/connector/indexing-status",
"/api/manage/admin/connector/indexing-status",
fetcher
);
const {
@@ -114,7 +118,7 @@ const Main = () => {
isLoading: isCredentialsLoading,
error: isCredentialsError,
} = useSWR<Credential<GoogleDriveCredentialJson>[]>(
"/api/admin/credential",
"/api/manage/credential",
fetcher
);
@@ -167,6 +171,10 @@ const Main = () => {
);
}
const googleDrivePublicCredential = credentialsData.find(
(credential) =>
credential.credential_json?.google_drive_tokens && credential.public_doc
);
const googleDriveConnectorIndexingStatuses: ConnectorIndexingStatus<{}>[] =
connectorIndexingStatuses.filter(
(connectorIndexingStatus) =>
@@ -174,9 +182,13 @@ const Main = () => {
);
const googleDriveConnectorIndexingStatus =
googleDriveConnectorIndexingStatuses[0];
const googleDriveCredential = credentialsData.filter(
(credential) => credential.credential_json?.google_drive_tokens
)[0];
const credentialIsLinked =
googleDriveConnectorIndexingStatus !== undefined &&
googleDrivePublicCredential !== undefined &&
googleDriveConnectorIndexingStatus.connector.credential_ids.includes(
googleDrivePublicCredential.id
);
return (
<>
@@ -198,7 +210,9 @@ const Main = () => {
<div className="mt-2">
<AppCredentialUpload
setPopup={(popup) => {
mutate("/api/admin/connector/google-drive/app-credential");
mutate(
"/api/manage/admin/connector/google-drive/app-credential"
);
setPopupWithExpiration(popup);
}}
/>
@@ -221,7 +235,9 @@ const Main = () => {
</p>
<AppCredentialUpload
setPopup={(popup) => {
mutate("/api/admin/connector/google-drive/app-credential");
mutate(
"/api/manage/admin/connector/google-drive/app-credential"
);
setPopupWithExpiration(popup);
}}
/>
@@ -233,175 +249,199 @@ const Main = () => {
Step 2: Authenticate with Danswer
</h2>
<div className="text-sm mb-4">
{googleDriveCredential ? (
<p>
<i>Existing credential already setup!</i> If you want to reset that
credential, click the button below to go through the OAuth flow
again.
</p>
{googleDrivePublicCredential ? (
<>
<p className="mb-2">
<i>Existing credential already setup!</i>
</p>
<Button
onClick={async () => {
await deleteCredential(googleDrivePublicCredential.id);
setPopup({
message: "Successfully revoked access to Google Drive!",
type: "success",
});
mutate("/api/manage/credential");
}}
>
Revoke Access
</Button>
</>
) : (
<>
<p>
<p className="mb-2">
Next, you must provide credentials via OAuth. This gives us read
access to the docs you have access to in your google drive
account.
</p>
<Button
onClick={async () => {
const [authUrl, errorMsg] = await setupGoogleDriveOAuth({
isPublic: true,
});
if (authUrl) {
// cookie used by callback to determine where to finally redirect to
Cookies.set(GOOGLE_DRIVE_AUTH_IS_ADMIN_COOKIE_NAME, "true", {
path: "/",
});
router.push(authUrl);
return;
}
setPopup({
message: errorMsg,
type: "error",
});
}}
>
Authenticate with Google Drive
</Button>
</>
)}
</div>
<Button
onClick={async () => {
const credentialCreationResponse = await fetch(
"/api/admin/credential",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
public_doc: true,
credential_json: {},
}),
}
);
if (!credentialCreationResponse.ok) {
setPopupWithExpiration({
message: `Failed to create credential - ${credentialCreationResponse.status}`,
type: "error",
});
return;
}
const credential =
(await credentialCreationResponse.json()) as Credential<{}>;
const authorizationUrlResponse = await fetch(
`/api/admin/connector/google-drive/authorize/${credential.id}`
);
if (!authorizationUrlResponse.ok) {
setPopupWithExpiration({
message: `Failed to create credential - ${authorizationUrlResponse.status}`,
type: "error",
});
return;
}
const authorizationUrlJson =
(await authorizationUrlResponse.json()) as { auth_url: string };
router.push(authorizationUrlJson.auth_url);
}}
>
Authenticate with Google Drive
</Button>
<h2 className="font-bold mb-2 mt-6 ml-auto mr-auto">
Step 3: Start Indexing!
</h2>
{googleDriveConnectorIndexingStatus ? (
<div>
<div className="text-sm mb-2">
<div className="flex mb-1">
The Google Drive connector is setup!{" "}
<b className="mx-2">Status:</b>{" "}
<StatusRow
connectorIndexingStatus={googleDriveConnectorIndexingStatus}
hasCredentialsIssue={
googleDriveConnectorIndexingStatus.connector.credential_ids
.length === 0
}
setPopup={setPopupWithExpiration}
onUpdate={() => {
mutate("/api/admin/connector/indexing-status");
{googleDrivePublicCredential ? (
googleDriveConnectorIndexingStatus ? (
credentialIsLinked ? (
<div>
<div className="text-sm mb-2">
<div className="flex mb-1">
The Google Drive connector is setup!{" "}
<b className="mx-2">Status:</b>{" "}
<StatusRow
connectorIndexingStatus={googleDriveConnectorIndexingStatus}
hasCredentialsIssue={
googleDriveConnectorIndexingStatus.connector
.credential_ids.length === 0
}
setPopup={setPopupWithExpiration}
onUpdate={() => {
mutate("/api/manage/admin/connector/indexing-status");
}}
/>
</div>
<p>
Checkout the{" "}
<a href="/admin/indexing/status" className="text-blue-500">
status page
</a>{" "}
for the latest indexing status. We fetch the latest documents
from Google Drive every <b>10</b> minutes.
</p>
</div>
<Button
onClick={() => {
deleteConnector(
googleDriveConnectorIndexingStatus.connector.id
).then(() => {
setPopupWithExpiration({
message: "Successfully deleted connector!",
type: "success",
});
mutate("/api/manage/admin/connector/indexing-status");
});
}}
/>
>
Delete Connector
</Button>
</div>
<p>
Checkout the{" "}
<a href="/admin/indexing/status" className="text-blue-500">
status page
</a>{" "}
for the latest indexing status. We fetch the latest documents from
Google Drive every <b>10</b> minutes.
) : (
<>
<p className="text-sm mb-2">
Click the button below to link your credentials! Once this is
done, all public documents in your Google Drive will be
searchable. We will refresh the latest documents every <b>10</b>{" "}
minutes.
</p>
<Button
onClick={async () => {
await linkCredential(
googleDriveConnectorIndexingStatus.connector.id,
googleDrivePublicCredential.id
);
setPopupWithExpiration({
message: "Successfully linked credentials!",
type: "success",
});
mutate("/api/manage/admin/connector/indexing-status");
}}
>
Link Credentials
</Button>
</>
)
) : (
<>
<p className="text-sm mb-2">
Click the button below to create a connector. We will refresh the
latest documents from Google Drive every <b>10</b> minutes.
</p>
</div>
<Button
onClick={() => {
deleteConnector(
googleDriveConnectorIndexingStatus.connector.id
).then(() => {
<Button
onClick={async () => {
const connectorBase: ConnectorBase<{}> = {
name: "GoogleDriveConnector",
input_type: "load_state",
source: "google_drive",
connector_specific_config: {},
refresh_freq: 60 * 10, // 10 minutes
disabled: false,
};
const connectorCreationResponse = await fetch(
`/api/manage/admin/connector`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(connectorBase),
}
);
if (!connectorCreationResponse.ok) {
setPopupWithExpiration({
message: `Failed to create connector - ${connectorCreationResponse.status}`,
type: "error",
});
return;
}
const connector =
(await connectorCreationResponse.json()) as Connector<{}>;
const credentialLinkResponse = await fetch(
`/api/manage/connector/${connector.id}/credential/${googleDrivePublicCredential.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
}
);
if (!credentialLinkResponse.ok) {
setPopupWithExpiration({
message: `Failed to link connector to credential - ${credentialLinkResponse.status}`,
type: "error",
});
return;
}
setPopupWithExpiration({
message: "Successfully deleted connector!",
message: "Successfully created connector!",
type: "success",
});
mutate("/api/admin/connector/indexing-status");
});
}}
>
Delete Connector
</Button>
</div>
mutate("/api/manage/admin/connector/indexing-status");
}}
>
Add
</Button>
</>
)
) : (
<>
<p className="text-sm mb-2">
Click the button below to create a connector. We will refresh the
latest documents from Google Drive every <b>10</b> minutes.
</p>
<Button
onClick={async () => {
const connectorBase: ConnectorBase<{}> = {
name: "GoogleDriveConnector",
input_type: "load_state",
source: "google_drive",
connector_specific_config: {},
refresh_freq: 60 * 10, // 10 minutes
disabled: false,
};
const connectorCreationResponse = await fetch(
`/api/admin/connector`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(connectorBase),
}
);
if (!connectorCreationResponse.ok) {
setPopupWithExpiration({
message: `Failed to create connector - ${connectorCreationResponse.status}`,
type: "error",
});
return;
}
const connector =
(await connectorCreationResponse.json()) as Connector<{}>;
const credentialLinkResponse = await fetch(
`/api/admin/connector/${connector.id}/credential/${googleDriveCredential.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
}
);
if (!credentialLinkResponse.ok) {
setPopupWithExpiration({
message: `Failed to link connector to credential - ${credentialLinkResponse.status}`,
type: "error",
});
return;
}
setPopupWithExpiration({
message: "Successfully created connector!",
type: "success",
});
mutate("/api/admin/connector/indexing-status");
}}
>
Add
</Button>
</>
<p className="text-sm">
Please authenticate with Google Drive as described in Step 2! Once
done with that, you can then move on to enable this connector.
</p>
)}
</>
);

View File

@@ -26,7 +26,7 @@ const MainSection = () => {
isLoading: isConnectorIndexingStatusesLoading,
error: isConnectorIndexingStatusesError,
} = useSWR<ConnectorIndexingStatus<any>[]>(
"/api/admin/connector/indexing-status",
"/api/manage/admin/connector/indexing-status",
fetcher
);
@@ -36,7 +36,7 @@ const MainSection = () => {
isValidating: isCredentialsValidating,
error: isCredentialsError,
} = useSWR<Credential<SlackCredentialJson>[]>(
"/api/admin/credential",
"/api/manage/credential",
fetcher
);
@@ -81,7 +81,7 @@ const MainSection = () => {
className="ml-1 hover:bg-gray-700 rounded-full p-1"
onClick={async () => {
await deleteCredential(slackCredential.id);
mutate("/api/admin/credential");
mutate("/api/manage/credential");
}}
>
<TrashIcon />
@@ -123,7 +123,7 @@ const MainSection = () => {
}}
onSubmit={(isSuccess) => {
if (isSuccess) {
mutate("/api/admin/credential");
mutate("/api/manage/credential");
}
}}
/>
@@ -156,11 +156,13 @@ const MainSection = () => {
connector.connector_specific_config.workspace,
},
]}
onUpdate={() => mutate("/api/admin/connector/indexing-status")}
onUpdate={() =>
mutate("/api/manage/admin/connector/indexing-status")
}
onCredentialLink={async (connectorId) => {
if (slackCredential) {
await linkCredential(connectorId, slackCredential.id);
mutate("/api/admin/connector/indexing-status");
mutate("/api/manage/admin/connector/indexing-status");
}
}}
/>
@@ -191,7 +193,7 @@ const MainSection = () => {
onSubmit={async (isSuccess, responseJson) => {
if (isSuccess && responseJson) {
await linkCredential(responseJson.id, slackCredential.id);
mutate("/api/admin/connector/indexing-status");
mutate("/api/manage/admin/connector/indexing-status");
}
}}
/>

View File

@@ -21,7 +21,7 @@ export default function Web() {
isLoading: isConnectorIndexingStatusesLoading,
error: isConnectorIndexingStatusesError,
} = useSWR<ConnectorIndexingStatus<any>[]>(
"/api/admin/connector/indexing-status",
"/api/manage/admin/connector/indexing-status",
fetcher
);
@@ -69,7 +69,7 @@ export default function Web() {
if (isSuccess && responseJson) {
// assumes there is a dummy credential with id 0
await linkCredential(responseJson.id, 0);
mutate("/api/admin/connector/indexing-status");
mutate("/api/manage/admin/connector/indexing-status");
}
}}
/>
@@ -99,7 +99,7 @@ export default function Web() {
),
},
]}
onUpdate={() => mutate("/api/admin/connector/indexing-status")}
onUpdate={() => mutate("/api/manage/admin/connector/indexing-status")}
/>
) : (
<p className="text-sm">No indexed websites found</p>

View File

@@ -9,12 +9,13 @@ import { NotebookIcon, XSquareIcon } from "@/components/icons/icons";
import { fetcher } from "@/lib/fetcher";
import { getSourceMetadata } from "@/components/source";
import { CheckCircle, XCircle } from "@phosphor-icons/react";
import { useState } from "react";
import { Popup } from "@/components/admin/connectors/Popup";
import { HealthCheckBanner } from "@/components/health/healthcheck";
import { Connector, ConnectorIndexingStatus } from "@/lib/types";
import { ConnectorIndexingStatus } from "@/lib/types";
const getSourceDisplay = (connector: Connector<any>) => {
const getSourceDisplay = (
connectorIndexingStatus: ConnectorIndexingStatus<any>
) => {
const connector = connectorIndexingStatus.connector;
const sourceMetadata = getSourceMetadata(connector.source);
if (connector.source === "web") {
return (
@@ -38,28 +39,140 @@ const getSourceDisplay = (connector: Connector<any>) => {
);
}
if (
connector.source === "google_drive" &&
!connectorIndexingStatus.public_doc
) {
if (connectorIndexingStatus.owner) {
return `${sourceMetadata.displayName} [${connectorIndexingStatus.owner}]`;
}
return `${sourceMetadata.displayName} [private]`;
}
return sourceMetadata.displayName;
};
export default function Status() {
function Main() {
const {
data: indexAttemptData,
isLoading: indexAttemptIsLoading,
error: indexAttemptIsError,
} = useSWR<ConnectorIndexingStatus<any>[]>(
"/api/admin/connector/indexing-status",
"/api/manage/admin/connector/indexing-status",
fetcher,
{ refreshInterval: 30000 } // 30 seconds
);
const [popup, setPopup] = useState<{
message: string;
type: "success" | "error";
} | null>(null);
if (indexAttemptIsLoading) {
return <LoadingAnimation text="" />;
}
if (indexAttemptIsError || !indexAttemptData) {
return <div className="text-red-600">Error loading indexing history.</div>;
}
// sort by source name
indexAttemptData.sort((a, b) => {
if (a.connector.source < b.connector.source) {
return -1;
} else if (a.connector.source > b.connector.source) {
return 1;
} else {
return 0;
}
});
return (
<BasicTable
columns={[
{ header: "Connector", key: "connector" },
{ header: "Status", key: "status" },
{ header: "Last Indexed", key: "indexed_at" },
{ header: "Docs Indexed", key: "docs_indexed" },
// { header: "Re-Index", key: "reindex" },
]}
data={indexAttemptData.map((connectorIndexingStatus) => {
const sourceMetadata = getSourceMetadata(
connectorIndexingStatus.connector.source
);
let statusDisplay = <div className="text-gray-400">In Progress...</div>;
if (connectorIndexingStatus.connector.disabled) {
statusDisplay = (
<div className="text-red-600 flex">
<XSquareIcon className="my-auto mr-1" size="18" />
Disabled
</div>
);
} else if (connectorIndexingStatus.last_status === "success") {
statusDisplay = (
<div className="text-green-600 flex">
<CheckCircle className="my-auto mr-1" size="18" />
Enabled
</div>
);
} else if (connectorIndexingStatus.last_status === "failed") {
statusDisplay = (
<div className="text-red-600 flex">
<XCircle className="my-auto mr-1" size="18" />
Error
</div>
);
}
return {
indexed_at: timeAgo(connectorIndexingStatus?.last_success) || "-",
docs_indexed: connectorIndexingStatus?.docs_indexed
? `${connectorIndexingStatus?.docs_indexed} documents`
: "-",
connector: (
<a
className="text-blue-500 flex"
href={sourceMetadata.adminPageLink}
>
{sourceMetadata.icon({ size: "20" })}
<div className="ml-1">
{getSourceDisplay(connectorIndexingStatus)}
</div>
</a>
),
status: statusDisplay,
// TODO: add the below back in after this is supported in the backend
// reindex: (
// <button
// className={
// "group relative " +
// "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"
// }
// onClick={async () => {
// const { message, isSuccess } = await submitIndexRequest(
// connectorIndexingStatus.connector.source,
// connectorIndexingStatus.connector
// .connector_specific_config
// );
// setPopup({
// message,
// type: isSuccess ? "success" : "error",
// });
// setTimeout(() => {
// setPopup(null);
// }, 4000);
// mutate("/api/manage/admin/connector/index-attempt");
// }}
// >
// Index
// </button>
// ),
};
})}
/>
);
}
export default function Status() {
return (
<div className="mx-auto container">
{popup && <Popup message={popup.message} type={popup.type} />}
<div className="mb-4">
<HealthCheckBanner />
</div>
@@ -67,99 +180,7 @@ export default function Status() {
<NotebookIcon size="32" />
<h1 className="text-3xl font-bold pl-2">Indexing Status</h1>
</div>
{indexAttemptIsLoading ? (
<LoadingAnimation text="Loading" />
) : indexAttemptIsError || !indexAttemptData ? (
<div>Error loading indexing history</div>
) : (
<BasicTable
columns={[
{ header: "Connector", key: "connector" },
{ header: "Status", key: "status" },
{ header: "Last Indexed", key: "indexed_at" },
{ header: "Docs Indexed", key: "docs_indexed" },
// { header: "Re-Index", key: "reindex" },
]}
data={indexAttemptData.map((connectorIndexingStatus) => {
const sourceMetadata = getSourceMetadata(
connectorIndexingStatus.connector.source
);
let statusDisplay = (
<div className="text-gray-400">In Progress...</div>
);
if (connectorIndexingStatus.connector.disabled) {
statusDisplay = (
<div className="text-red-600 flex">
<XSquareIcon className="my-auto mr-1" size="18" />
Disabled
</div>
);
} else if (connectorIndexingStatus.last_status === "success") {
statusDisplay = (
<div className="text-green-600 flex">
<CheckCircle className="my-auto mr-1" size="18" />
Enabled
</div>
);
} else if (connectorIndexingStatus.last_status === "failed") {
statusDisplay = (
<div className="text-red-600 flex">
<XCircle className="my-auto mr-1" size="18" />
Error
</div>
);
}
return {
indexed_at: timeAgo(connectorIndexingStatus?.last_success) || "-",
docs_indexed: connectorIndexingStatus?.docs_indexed
? `${connectorIndexingStatus?.docs_indexed} documents`
: "-",
connector: (
<a
className="text-blue-500 flex"
href={sourceMetadata.adminPageLink}
>
{sourceMetadata.icon({ size: "20" })}
<div className="ml-1">
{getSourceDisplay(connectorIndexingStatus.connector)}
</div>
</a>
),
status: statusDisplay,
// TODO: add the below back in after this is supported in the backend
// reindex: (
// <button
// className={
// "group relative " +
// "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"
// }
// onClick={async () => {
// const { message, isSuccess } = await submitIndexRequest(
// connectorIndexingStatus.connector.source,
// connectorIndexingStatus.connector
// .connector_specific_config
// );
// setPopup({
// message,
// type: isSuccess ? "success" : "error",
// });
// setTimeout(() => {
// setPopup(null);
// }, 4000);
// mutate("/api/admin/connector/index-attempt");
// }}
// >
// Index
// </button>
// ),
};
})}
/>
)}
<Main />
</div>
);
}

View File

@@ -0,0 +1,146 @@
import { Button } from "@/components/Button";
import { GoogleDriveIcon } from "@/components/icons/icons";
import { deleteCredential, linkCredential } from "@/lib/credential";
import { setupGoogleDriveOAuth } from "@/lib/googleDrive";
import { GoogleDriveCredentialJson, Credential } from "@/lib/types";
import { GOOGLE_DRIVE_AUTH_IS_ADMIN_COOKIE_NAME } from "@/lib/constants";
import Cookies from "js-cookie";
import { CardProps } from "./interface";
import { CheckCircle, MinusCircle } from "@phosphor-icons/react";
export const GoogleDriveCard = ({
connector,
userCredentials,
setPopup,
router,
mutate,
}: CardProps) => {
if (!connector) return null;
const existingCredential: Credential<GoogleDriveCredentialJson> | undefined =
userCredentials?.find(
(credential) =>
credential.credential_json?.google_drive_tokens !== undefined &&
!credential.public_doc
);
const credentialIsLinked =
existingCredential !== undefined &&
connector.credential_ids.includes(existingCredential.id);
return (
<div className="border rounded border-gray-700 p-3 w-80">
<div className="flex items-center">
<GoogleDriveIcon size="20" />{" "}
<b className="ml-2 text-xl">Google Drive</b>
</div>
<div>
{existingCredential && credentialIsLinked ? (
<div className="text-green-600 flex text-sm mt-1">
<CheckCircle className="my-auto mr-1" size="16" />
Enabled
</div>
) : (
<div className="text-gray-400 flex text-sm mt-1">
<MinusCircle className="my-auto mr-1" size="16" />
Not Setup
</div>
)}
</div>
<div className="text-sm mt-2">
{existingCredential ? (
credentialIsLinked ? (
<>
<p>
Danswer has access to your Google Drive documents! Don&apos;t
worry, only <b>you</b> will be able to see your private
documents. You can revoke this access by clicking the button
below.
</p>
<div className="mt-2 flex">
<Button
onClick={async () => {
await deleteCredential(existingCredential.id);
setPopup({
message: "Successfully revoked access to Google Drive!",
type: "success",
});
mutate("/api/manage/connector");
mutate("/api/manage/credential");
}}
fullWidth
>
Revoke Access
</Button>
</div>
</>
) : (
<>
<p>
We&apos;ve recieved your credentials from Google! Click the
button below to activate the connector - we will pull the latest
state of your documents every <b>10</b> minutes.
</p>
<div className="mt-2 flex">
<Button
onClick={async () => {
await linkCredential(connector.id, existingCredential.id);
setPopup({
message: "Activated!",
type: "success",
});
mutate("/api/manage/connector");
}}
fullWidth
>
Activate
</Button>
</div>
</>
)
) : (
<>
<p>
If you want to make all your Google Drive documents searchable
through Danswer, click the button below! Don&apos;t worry, only{" "}
<b>you</b> will be able to see your private documents. Currently,
you&apos;ll only be able to search through documents shared with
the whole company.
</p>
<div className="mt-2 flex">
<Button
onClick={async () => {
const [authUrl, errorMsg] = await setupGoogleDriveOAuth({
isPublic: false,
});
if (authUrl) {
// cookie used by callback to determine where to finally redirect to
Cookies.set(
GOOGLE_DRIVE_AUTH_IS_ADMIN_COOKIE_NAME,
"false",
{
path: "/",
}
);
router.push(authUrl);
return;
}
setPopup({
message: errorMsg,
type: "error",
});
}}
fullWidth
>
Authenticate with Google Drive
</Button>
</div>
</>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,12 @@
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { Connector, Credential } from "@/lib/types";
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context";
import { ScopedMutator } from "swr/_internal";
export interface CardProps {
connector: Connector<{}> | null | undefined;
userCredentials: Credential<any>[] | null | undefined;
setPopup: (popup: PopupSpec | null) => void;
router: AppRouterInstance;
mutate: ScopedMutator;
}

View File

@@ -0,0 +1,136 @@
"use client";
import { PlugIcon } from "@/components/icons/icons";
import useSWR, { useSWRConfig } from "swr";
import { fetcher } from "@/lib/fetcher";
import { LoadingAnimation } from "@/components/Loading";
import { useRouter } from "next/navigation";
import { Popup, PopupSpec } from "@/components/admin/connectors/Popup";
import { useState } from "react";
import { HealthCheckBanner } from "@/components/health/healthcheck";
import { Connector, Credential, ValidSources } from "@/lib/types";
import { GoogleDriveCard } from "./GoogleDriveCard";
import { CardProps } from "./interface";
const connectorSourceToConnectorCard = (
source: ValidSources
): React.FC<CardProps> | null => {
switch (source) {
case "google_drive":
return GoogleDriveCard;
default:
return null;
}
};
const Main = () => {
const router = useRouter();
const { mutate } = useSWRConfig();
const {
data: appCredentialData,
isLoading: isAppCredentialLoading,
error: isAppCredentialError,
} = useSWR<{ client_id: string }>(
"/api/manage/admin/connector/google-drive/app-credential",
fetcher
);
const {
data: connectorsData,
isLoading: isConnectorDataLoading,
error: isConnectorDataError,
} = useSWR<Connector<any>[]>("/api/manage/connector", fetcher);
const {
data: credentialsData,
isLoading: isCredentialsLoading,
error: isCredentialsError,
} = useSWR<Credential<any>[]>("/api/manage/credential", fetcher);
const [popup, setPopup] = useState<{
message: string;
type: "success" | "error";
} | null>(null);
const setPopupWithExpiration = (popupSpec: PopupSpec | null) => {
setPopup(popupSpec);
setTimeout(() => {
setPopup(null);
}, 4000);
};
if (
isCredentialsLoading ||
isAppCredentialLoading ||
isConnectorDataLoading
) {
return (
<div className="mx-auto">
<LoadingAnimation text="" />
</div>
);
}
if (isCredentialsError || !credentialsData) {
return (
<div className="mx-auto">
<div className="text-red-500">Failed to load credentials.</div>
</div>
);
}
if (isConnectorDataError || !connectorsData) {
return (
<div className="mx-auto">
<div className="text-red-500">Failed to load connectors.</div>
</div>
);
}
if (isAppCredentialError || !appCredentialData) {
return (
<div className="mx-auto">
<div className="text-red-500">
Error loading Google Drive app credentials. Contact an administrator.
</div>
</div>
);
}
return (
<>
{popup && <Popup message={popup.message} type={popup.type} />}
{connectorsData.map((connector) => {
const connectorCard = connectorSourceToConnectorCard(connector.source);
if (connectorCard) {
return (
<div key={connector.id}>
{connectorCard({
connector,
userCredentials: credentialsData,
setPopup: setPopupWithExpiration,
router,
mutate,
})}
</div>
);
}
})}
</>
);
};
export default function Page() {
return (
<div className="mx-auto container">
<div className="mb-4">
<HealthCheckBanner />
</div>
<div className="border-solid border-gray-600 border-b mb-4 pb-2 flex">
<PlugIcon size="32" />
<h1 className="text-3xl font-bold pl-2">Personal Connectors</h1>
</div>
<Main />
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { Header } from "@/components/Header";
import { Sidebar } from "@/components/admin/connectors/Sidebar";
import {
NotebookIcon,
GithubIcon,
GlobeIcon,
GoogleDriveIcon,
SlackIcon,
KeyIcon,
ConfluenceIcon,
} from "@/components/icons/icons";
import { DISABLE_AUTH } from "@/lib/constants";
import { getCurrentUserSS } from "@/lib/userSS";
import { redirect } from "next/navigation";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
let user = null;
if (!DISABLE_AUTH) {
user = await getCurrentUserSS();
if (!user) {
return redirect("/auth/login");
}
if (user.role !== "admin") {
return redirect("/");
}
}
return (
<div>
<Header user={user} />
<div className="bg-gray-900 pt-8 flex">
<div className="px-12 min-h-screen bg-gray-900 text-gray-100 w-full">
{children}
</div>
</div>
</div>
);
}

View File

@@ -2,13 +2,20 @@ interface Props {
onClick: () => void;
children: JSX.Element | string;
disabled?: boolean;
fullWidth?: boolean;
}
export const Button = ({ onClick, children, disabled = false }: Props) => {
export const Button = ({
onClick,
children,
disabled = false,
fullWidth = false,
}: Props) => {
return (
<button
className={
"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 " +

View File

@@ -71,14 +71,19 @@ export const Header: React.FC<HeaderProps> = ({ user }) => {
<div
className={
"absolute top-10 right-0 mt-2 bg-gray-600 rounded-sm " +
"w-36 overflow-hidden shadow-xl z-10 text-sm text-gray-300"
"w-48 overflow-hidden shadow-xl z-10 text-sm text-gray-300"
}
>
<Link href="/user/connectors">
<div className="flex py-2 px-3 cursor-pointer hover:bg-gray-500 border-b border-gray-500">
Personal Connectors
</div>
</Link>
{/* Show connector option if (1) auth is disabled or (2) user is an admin */}
{(!user || user.role === "admin") && (
<Link href="/admin/indexing/status">
<div className="flex py-2 px-3 cursor-pointer hover:bg-gray-500 border-b border-gray-500">
Connectors
Admin Panel
</div>
</Link>
)}

View File

@@ -14,7 +14,7 @@ export async function submitConnector<T>(
): Promise<{ message: string; isSuccess: boolean; response?: Connector<T> }> {
let isSuccess = false;
try {
const response = await fetch(`/api/admin/connector`, {
const response = await fetch(`/api/manage/admin/connector`, {
method: "POST",
headers: {
"Content-Type": "application/json",

View File

@@ -9,7 +9,7 @@ export async function submitCredential<T>(
): Promise<{ message: string; isSuccess: boolean }> {
let isSuccess = false;
try {
const response = await fetch(`/api/admin/credential`, {
const response = await fetch(`/api/manage/credential`, {
method: "POST",
headers: {
"Content-Type": "application/json",

View File

@@ -1,104 +0,0 @@
import React, { useState } from "react";
import { Formik, Form, FormikHelpers } from "formik";
import * as Yup from "yup";
import { Popup } from "./Popup";
import { ValidInputTypes, ValidSources } from "@/lib/types";
export const submitIndexRequest = async (
source: ValidSources,
values: Yup.AnyObject,
inputType: ValidInputTypes = "load_state"
): Promise<{ message: string; isSuccess: boolean }> => {
let isSuccess = false;
try {
const response = await fetch(
`/api/admin/connector/${source}/index-attempt`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
connector_specific_config: values,
input_type: inputType,
}),
}
);
if (response.ok) {
isSuccess = true;
return { message: "Success!", isSuccess: true };
} else {
const errorData = await response.json();
return { message: `Error: ${errorData.detail}`, isSuccess: false };
}
} catch (error) {
return { message: `Error: ${error}`, isSuccess: false };
}
};
interface IndexFormProps<YupObjectType extends Yup.AnyObject> {
source: ValidSources;
formBody: JSX.Element | null;
validationSchema: Yup.ObjectSchema<YupObjectType>;
initialValues: YupObjectType;
onSubmit: (isSuccess: boolean) => void;
additionalNonFormValues?: Yup.AnyObject;
}
export function IndexForm<YupObjectType extends Yup.AnyObject>({
source,
formBody,
validationSchema,
initialValues,
onSubmit,
additionalNonFormValues = {},
}: IndexFormProps<YupObjectType>): JSX.Element {
const [popup, setPopup] = useState<{
message: string;
type: "success" | "error";
} | null>(null);
return (
<>
{popup && <Popup message={popup.message} type={popup.type} />}
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={(values, formikHelpers) => {
formikHelpers.setSubmitting(true);
submitIndexRequest(source, {
...values,
...additionalNonFormValues,
}).then(({ message, isSuccess }) => {
setPopup({ message, type: isSuccess ? "success" : "error" });
formikHelpers.setSubmitting(false);
setTimeout(() => {
setPopup(null);
}, 4000);
onSubmit(isSuccess);
});
}}
>
{({ isSubmitting }) => (
<Form>
{formBody}
<div className="flex">
<button
type="submit"
disabled={isSubmitting}
className={
"bg-slate-500 hover:bg-slate-700 text-white " +
"font-bold py-2 px-4 rounded focus:outline-none " +
"focus:shadow-outline w-full max-w-sm mx-auto"
}
>
Index
</button>
</div>
</Form>
)}
</Formik>
</>
);
}

View File

@@ -8,6 +8,7 @@ import {
XSquare,
LinkBreak,
Link,
Plug,
} from "@phosphor-icons/react";
import { SiConfluence, SiGithub, SiGoogledrive, SiSlack } from "react-icons/si";
import { FaGlobe } from "react-icons/fa";
@@ -19,6 +20,13 @@ interface IconProps {
const defaultTailwindCSS = "text-blue-400 my-auto flex flex-shrink-0";
export const PlugIcon = ({
size = "16",
className = defaultTailwindCSS,
}: IconProps) => {
return <Plug size={size} className={className} />;
};
export const NotebookIcon = ({
size = "16",
className = defaultTailwindCSS,

View File

@@ -7,7 +7,7 @@ export const ApiKeyModal = () => {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
fetch("/api/admin/openai-api-key/validate", {
fetch("/api/manage/admin/openai-api-key/validate", {
method: "HEAD",
}).then((res) => {
// show popup if either the API key is not set or the API key is invalid

View File

@@ -1 +1 @@
export const OPENAI_API_KEY_URL = "/api/admin/openai-api-key";
export const OPENAI_API_KEY_URL = "/api/manage/admin/openai-api-key";

View File

@@ -3,7 +3,7 @@ import { Connector, ConnectorBase } from "./types";
export async function createConnector<T>(
connector: ConnectorBase<T>
): Promise<Connector<T>> {
const response = await fetch(`/api/admin/connector`, {
const response = await fetch(`/api/manage/admin/connector`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -16,7 +16,7 @@ export async function createConnector<T>(
export async function updateConnector<T>(
connector: Connector<T>
): Promise<Connector<T>> {
const response = await fetch(`/api/admin/connector/${connector.id}`, {
const response = await fetch(`/api/manage/admin/connector/${connector.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
@@ -29,7 +29,7 @@ export async function updateConnector<T>(
export async function deleteConnector<T>(
connectorId: number
): Promise<Connector<T>> {
const response = await fetch(`/api/admin/connector/${connectorId}`, {
const response = await fetch(`/api/manage/admin/connector/${connectorId}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",

View File

@@ -1,2 +1,5 @@
export const DISABLE_AUTH = process.env.DISABLE_AUTH?.toLowerCase() === "true";
export const INTERNAL_URL = process.env.INTERNAL_URL || "http://127.0.0.1:8080";
export const GOOGLE_DRIVE_AUTH_IS_ADMIN_COOKIE_NAME =
"google_drive_auth_is_admin";

View File

@@ -1,5 +1,5 @@
export async function deleteCredential<T>(credentialId: number) {
const response = await fetch(`/api/admin/credential/${credentialId}`, {
const response = await fetch(`/api/manage/credential/${credentialId}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
@@ -13,7 +13,7 @@ export async function linkCredential<T>(
credentialId: number
) {
const response = await fetch(
`/api/admin/connector/${connectorId}/credential/${credentialId}`,
`/api/manage/connector/${connectorId}/credential/${credentialId}`,
{
method: "PUT",
headers: {

View File

@@ -0,0 +1,43 @@
import { Credential } from "@/lib/types";
interface SetupGoogleDriveArgs {
isPublic: boolean;
}
export const setupGoogleDriveOAuth = async ({
isPublic,
}: SetupGoogleDriveArgs): Promise<[string | null, string]> => {
const credentialCreationResponse = await fetch("/api/manage/credential", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
public_doc: isPublic,
credential_json: {},
}),
});
if (!credentialCreationResponse.ok) {
return [
null,
`Failed to create credential - ${credentialCreationResponse.status}`,
];
}
const credential =
(await credentialCreationResponse.json()) as Credential<{}>;
const authorizationUrlResponse = await fetch(
`/api/manage/connector/google-drive/authorize/${credential.id}`
);
if (!authorizationUrlResponse.ok) {
return [
null,
`Failed to create credential - ${authorizationUrlResponse.status}`,
];
}
const authorizationUrlJson = (await authorizationUrlResponse.json()) as {
auth_url: string;
};
return [authorizationUrlJson.auth_url, ""];
};

View File

@@ -51,6 +51,8 @@ export interface SlackConfig {
export interface ConnectorIndexingStatus<T> {
connector: Connector<T>;
public_doc: boolean;
owner: string;
last_status: "success" | "failed" | "in_progress" | "not_started";
last_success: string | null;
docs_indexed: number;