mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-09-28 12:58:41 +02:00
Adding initial admin pages (#25)
* Adding sidebar to admin page + adding scaffolding for Web connector + a little styling * Rename APIs * Restyling
This commit is contained in:
@@ -13,6 +13,7 @@ from danswer.db.models import IndexingStatus
|
|||||||
from danswer.dynamic_configs.interface import ConfigNotFoundError
|
from danswer.dynamic_configs.interface import ConfigNotFoundError
|
||||||
from danswer.utils.logging import setup_logger
|
from danswer.utils.logging import setup_logger
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
from fastapi import HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin")
|
router = APIRouter(prefix="/admin")
|
||||||
@@ -20,7 +21,7 @@ router = APIRouter(prefix="/admin")
|
|||||||
logger = setup_logger()
|
logger = setup_logger()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/slack_connector_config", response_model=SlackConfig)
|
@router.get("/connectors/slack/config", response_model=SlackConfig)
|
||||||
def fetch_slack_config():
|
def fetch_slack_config():
|
||||||
try:
|
try:
|
||||||
return get_slack_config()
|
return get_slack_config()
|
||||||
@@ -28,7 +29,12 @@ def fetch_slack_config():
|
|||||||
return SlackConfig(slack_bot_token="", workspace_id="")
|
return SlackConfig(slack_bot_token="", workspace_id="")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/slack_connector_config")
|
@router.post("/connectors/slack/config")
|
||||||
|
def modify_slack_config(slack_config: SlackConfig):
|
||||||
|
update_slack_config(slack_config)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connectors/slack/auth")
|
||||||
def modify_slack_config(slack_config: SlackConfig):
|
def modify_slack_config(slack_config: SlackConfig):
|
||||||
update_slack_config(slack_config)
|
update_slack_config(slack_config)
|
||||||
|
|
||||||
@@ -37,7 +43,7 @@ class WebIndexAttemptRequest(BaseModel):
|
|||||||
url: str
|
url: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/website_index", status_code=201)
|
@router.post("/connectors/web/index-attempt", status_code=201)
|
||||||
async def index_website(web_index_attempt_request: WebIndexAttemptRequest):
|
async def index_website(web_index_attempt_request: WebIndexAttemptRequest):
|
||||||
index_request = IndexAttempt(
|
index_request = IndexAttempt(
|
||||||
source=DocumentSource.WEB,
|
source=DocumentSource.WEB,
|
||||||
@@ -52,13 +58,15 @@ class IndexAttemptSnapshot(BaseModel):
|
|||||||
url: str
|
url: str
|
||||||
status: IndexingStatus
|
status: IndexingStatus
|
||||||
time_created: datetime
|
time_created: datetime
|
||||||
|
time_updated: datetime
|
||||||
|
docs_indexed: int
|
||||||
|
|
||||||
|
|
||||||
class ListWebsiteIndexAttemptsResponse(BaseModel):
|
class ListWebsiteIndexAttemptsResponse(BaseModel):
|
||||||
index_attempts: list[IndexAttemptSnapshot]
|
index_attempts: list[IndexAttemptSnapshot]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/website_index")
|
@router.get("/connectors/web/index-attempt")
|
||||||
async def list_website_index_attempts() -> ListWebsiteIndexAttemptsResponse:
|
async def list_website_index_attempts() -> ListWebsiteIndexAttemptsResponse:
|
||||||
index_attempts = await fetch_index_attempts(sources=[DocumentSource.WEB])
|
index_attempts = await fetch_index_attempts(sources=[DocumentSource.WEB])
|
||||||
return ListWebsiteIndexAttemptsResponse(
|
return ListWebsiteIndexAttemptsResponse(
|
||||||
@@ -67,6 +75,10 @@ async def list_website_index_attempts() -> ListWebsiteIndexAttemptsResponse:
|
|||||||
url=index_attempt.connector_specific_config["url"],
|
url=index_attempt.connector_specific_config["url"],
|
||||||
status=index_attempt.status,
|
status=index_attempt.status,
|
||||||
time_created=index_attempt.time_created,
|
time_created=index_attempt.time_created,
|
||||||
|
time_updated=index_attempt.time_updated,
|
||||||
|
docs_indexed=0
|
||||||
|
if not index_attempt.document_ids
|
||||||
|
else len(index_attempt.document_ids),
|
||||||
)
|
)
|
||||||
for index_attempt in index_attempts
|
for index_attempt in index_attempts
|
||||||
]
|
]
|
||||||
|
20
web/package-lock.json
generated
20
web/package-lock.json
generated
@@ -20,6 +20,7 @@
|
|||||||
"postcss": "^8.4.23",
|
"postcss": "^8.4.23",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
"swr": "^2.1.5",
|
||||||
"tailwindcss": "^3.3.1",
|
"tailwindcss": "^3.3.1",
|
||||||
"typescript": "5.0.3",
|
"typescript": "5.0.3",
|
||||||
"yup": "^1.1.1"
|
"yup": "^1.1.1"
|
||||||
@@ -3688,6 +3689,17 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/swr": {
|
||||||
|
"version": "2.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/swr/-/swr-2.1.5.tgz",
|
||||||
|
"integrity": "sha512-/OhfZMcEpuz77KavXST5q6XE9nrOBOVcBLWjMT+oAE/kQHyE3PASrevXCtQDZ8aamntOfFkbVJp7Il9tNBQWrw==",
|
||||||
|
"dependencies": {
|
||||||
|
"use-sync-external-store": "^1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.11.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/synckit": {
|
"node_modules/synckit": {
|
||||||
"version": "0.8.5",
|
"version": "0.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.5.tgz",
|
||||||
@@ -3977,6 +3989,14 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
@@ -21,6 +21,7 @@
|
|||||||
"postcss": "^8.4.23",
|
"postcss": "^8.4.23",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
"swr": "^2.1.5",
|
||||||
"tailwindcss": "^3.3.1",
|
"tailwindcss": "^3.3.1",
|
||||||
"typescript": "5.0.3",
|
"typescript": "5.0.3",
|
||||||
"yup": "^1.1.1"
|
"yup": "^1.1.1"
|
||||||
|
@@ -1,22 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Inter } from "next/font/google";
|
|
||||||
import { Header } from "@/components/Header";
|
|
||||||
import { SlackForm } from "@/components/admin/connectors/SlackForm";
|
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header />
|
|
||||||
<div className="p-24 min-h-screen bg-gray-900 text-gray-100">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold mb-4">Slack</h1>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-3xl font-bold mb-4 ml-auto mr-auto">Config</h2>
|
|
||||||
<SlackForm onSubmit={(success) => console.log(success)} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
95
web/src/app/admin/connectors/slack/SlackForm.tsx
Normal file
95
web/src/app/admin/connectors/slack/SlackForm.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Formik, Form, FormikHelpers } from "formik";
|
||||||
|
import * as Yup from "yup";
|
||||||
|
import { Popup } from "../../../../components/admin/connectors/Popup";
|
||||||
|
import { TextFormField } from "../../../../components/admin/connectors/Field";
|
||||||
|
import { SlackConfig } from "./interfaces";
|
||||||
|
|
||||||
|
const validationSchema = Yup.object().shape({
|
||||||
|
slack_bot_token: Yup.string().required("Please enter your Slack Bot Token"),
|
||||||
|
workspace_id: Yup.string().required("Please enter your Workspace ID"),
|
||||||
|
pull_frequency: Yup.number().required(
|
||||||
|
"Please enter a pull frequency (in minutes). 0 => no pulling from slack"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (
|
||||||
|
values: SlackConfig,
|
||||||
|
{ setSubmitting }: FormikHelpers<SlackConfig>,
|
||||||
|
setPopup: (
|
||||||
|
popup: { message: string; type: "success" | "error" } | null
|
||||||
|
) => void
|
||||||
|
) => {
|
||||||
|
let isSuccess = false;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/admin/connectors/slack/config", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
isSuccess = true;
|
||||||
|
setPopup({ message: "Success!", type: "success" });
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
setPopup({ message: `Error: ${errorData.detail}`, type: "error" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setPopup({ message: `Error: ${error}`, type: "error" });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
setPopup(null);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
return isSuccess;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SlackFormProps {
|
||||||
|
existingSlackConfig: SlackConfig;
|
||||||
|
onSubmit: (isSuccess: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SlackForm: React.FC<SlackFormProps> = ({
|
||||||
|
existingSlackConfig,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
const [popup, setPopup] = useState<{
|
||||||
|
message: string;
|
||||||
|
type: "success" | "error";
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{popup && <Popup message={popup.message} type={popup.type} />}
|
||||||
|
<Formik
|
||||||
|
initialValues={existingSlackConfig}
|
||||||
|
validationSchema={validationSchema}
|
||||||
|
onSubmit={(values, formikHelpers) =>
|
||||||
|
handleSubmit(values, formikHelpers, setPopup).then((isSuccess) =>
|
||||||
|
onSubmit(isSuccess)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isSubmitting }) => (
|
||||||
|
<Form>
|
||||||
|
<TextFormField name="slack_bot_token" label="Slack Bot Token:" />
|
||||||
|
<TextFormField name="workspace_id" label="Workspace ID:" />
|
||||||
|
<TextFormField name="pull_frequency" label="Pull Frequency:" />
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
11
web/src/app/admin/connectors/slack/interfaces.ts
Normal file
11
web/src/app/admin/connectors/slack/interfaces.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export interface SlackConfig {
|
||||||
|
slack_bot_token: string;
|
||||||
|
workspace_id: string;
|
||||||
|
pull_frequency: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// interface SlackIndexAttempt {}
|
||||||
|
|
||||||
|
// interface ListSlackIndexingResponse {
|
||||||
|
// index_attempts: SlackIndexAttempt[];
|
||||||
|
// }
|
76
web/src/app/admin/connectors/slack/page.tsx
Normal file
76
web/src/app/admin/connectors/slack/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ConnectorStatus,
|
||||||
|
ReccuringConnectorStatus,
|
||||||
|
} from "@/components/admin/connectors/RecurringConnectorStatus";
|
||||||
|
import { SlackForm } from "@/app/admin/connectors/slack/SlackForm";
|
||||||
|
import { SlackIcon } from "@/components/icons/icons";
|
||||||
|
import { fetcher } from "@/lib/fetcher";
|
||||||
|
import useSWR, { useSWRConfig } from "swr";
|
||||||
|
import { SlackConfig } from "./interfaces";
|
||||||
|
import { ThinkingAnimation } from "@/components/Thinking";
|
||||||
|
|
||||||
|
const MainSection = () => {
|
||||||
|
// TODO: add back in once this is ready
|
||||||
|
// const { data, isLoading, error } = useSWR<ListSlackIndexingResponse>(
|
||||||
|
// "/api/admin/connectors/web/index-attempt",
|
||||||
|
// fetcher
|
||||||
|
// );
|
||||||
|
const { mutate } = useSWRConfig();
|
||||||
|
const { data, isLoading, error } = useSWR<SlackConfig>(
|
||||||
|
"/api/admin/connectors/slack/config",
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="mt-16">
|
||||||
|
<ThinkingAnimation text="Loading" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="mt-16">{`Error loading Slack config - ${error}`}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold pl-2 mb-2 mt-6 ml-auto mr-auto">
|
||||||
|
Status
|
||||||
|
</h2>
|
||||||
|
{
|
||||||
|
<ReccuringConnectorStatus
|
||||||
|
status={
|
||||||
|
data.pull_frequency !== 0
|
||||||
|
? ConnectorStatus.Running
|
||||||
|
: ConnectorStatus.NotSetup
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h2 className="text-xl font-bold pl-2 mb-2 mt-6 ml-auto mr-auto">
|
||||||
|
Config
|
||||||
|
</h2>
|
||||||
|
<div className="border-solid border-gray-600 border rounded-md p-6">
|
||||||
|
<SlackForm
|
||||||
|
existingSlackConfig={data}
|
||||||
|
onSubmit={() => mutate("/api/admin/connectors/slack/config")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto">
|
||||||
|
<div className="border-solid border-gray-600 border-b mb-4 pb-2 flex">
|
||||||
|
<SlackIcon size="32" />
|
||||||
|
<h1 className="text-3xl font-bold pl-2">Slack</h1>
|
||||||
|
</div>
|
||||||
|
<MainSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
94
web/src/app/admin/connectors/web/WebIndexForm.tsx
Normal file
94
web/src/app/admin/connectors/web/WebIndexForm.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Formik, Form, FormikHelpers } from "formik";
|
||||||
|
import * as Yup from "yup";
|
||||||
|
import { Popup } from "../../../../components/admin/connectors/Popup";
|
||||||
|
import { TextFormField } from "../../../../components/admin/connectors/Field";
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationSchema = Yup.object().shape({
|
||||||
|
url: Yup.string().required(
|
||||||
|
"Please enter the website URL to scrape e.g. https://docs.github.com/en/actions"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (
|
||||||
|
values: FormData,
|
||||||
|
{ setSubmitting }: FormikHelpers<FormData>,
|
||||||
|
setPopup: (
|
||||||
|
popup: { message: string; type: "success" | "error" } | null
|
||||||
|
) => void
|
||||||
|
): Promise<boolean> => {
|
||||||
|
setSubmitting(true);
|
||||||
|
let isSuccess = false;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/admin/connectors/web/index-attempt", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
isSuccess = true;
|
||||||
|
setPopup({ message: "Success!", type: "success" });
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
setPopup({ message: `Error: ${errorData.detail}`, type: "error" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setPopup({ message: `Error: ${error}`, type: "error" });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
setPopup(null);
|
||||||
|
}, 3000);
|
||||||
|
return isSuccess;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SlackFormProps {
|
||||||
|
onSubmit: (isSuccess: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WebIndexForm: React.FC<SlackFormProps> = ({ onSubmit }) => {
|
||||||
|
const [popup, setPopup] = useState<{
|
||||||
|
message: string;
|
||||||
|
type: "success" | "error";
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{popup && <Popup message={popup.message} type={popup.type} />}
|
||||||
|
<Formik
|
||||||
|
initialValues={{ url: "" }}
|
||||||
|
validationSchema={validationSchema}
|
||||||
|
onSubmit={(values, formikHelpers) =>
|
||||||
|
handleSubmit(values, formikHelpers, setPopup).then((isSuccess) =>
|
||||||
|
onSubmit(isSuccess)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isSubmitting }) => (
|
||||||
|
<Form>
|
||||||
|
<TextFormField name="url" label="URL to Index:" />
|
||||||
|
<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-xs"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Index
|
||||||
|
</button>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
113
web/src/app/admin/connectors/web/page.tsx
Normal file
113
web/src/app/admin/connectors/web/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import useSWR, { useSWRConfig } from "swr";
|
||||||
|
|
||||||
|
import { BasicTable } from "@/components/admin/connectors/BasicTable";
|
||||||
|
import { WebIndexForm } from "@/app/admin/connectors/web/WebIndexForm";
|
||||||
|
import { ThinkingAnimation } from "@/components/Thinking";
|
||||||
|
import { timeAgo } from "@/lib/time";
|
||||||
|
import { GlobeIcon } from "@/components/icons/icons";
|
||||||
|
import { fetcher } from "@/lib/fetcher";
|
||||||
|
|
||||||
|
interface WebsiteIndexAttempt {
|
||||||
|
url: string;
|
||||||
|
status: "success" | "failure" | "in_progress" | "not_started";
|
||||||
|
time_created: string;
|
||||||
|
time_updated: string;
|
||||||
|
docs_indexed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListWebIndexingResponse {
|
||||||
|
index_attempts: WebsiteIndexAttempt[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS = [
|
||||||
|
{ header: "Base URL", key: "url" },
|
||||||
|
{ header: "Last Indexed", key: "indexed_at" },
|
||||||
|
{ header: "Docs Indexed", key: "docs_indexed" },
|
||||||
|
{ header: "Status", key: "status" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Web() {
|
||||||
|
const { mutate } = useSWRConfig();
|
||||||
|
const { data, isLoading, error } = useSWR<ListWebIndexingResponse>(
|
||||||
|
"/api/admin/connectors/web/index-attempt",
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const urlToLatestIndexAttempt = new Map<string, WebsiteIndexAttempt>();
|
||||||
|
const urlToLatestIndexSuccess = new Map<string, string>();
|
||||||
|
data?.index_attempts.forEach((indexAttempt) => {
|
||||||
|
const latestIndexAttempt = urlToLatestIndexAttempt.get(indexAttempt.url);
|
||||||
|
if (
|
||||||
|
!latestIndexAttempt ||
|
||||||
|
indexAttempt.time_created > latestIndexAttempt.time_created
|
||||||
|
) {
|
||||||
|
urlToLatestIndexAttempt.set(indexAttempt.url, indexAttempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestIndexSuccess = urlToLatestIndexSuccess.get(indexAttempt.url);
|
||||||
|
if (
|
||||||
|
indexAttempt.status === "success" &&
|
||||||
|
(!latestIndexSuccess || indexAttempt.time_updated > latestIndexSuccess)
|
||||||
|
) {
|
||||||
|
urlToLatestIndexSuccess.set(indexAttempt.url, indexAttempt.time_updated);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto">
|
||||||
|
<div className="border-solid border-gray-600 border-b pb-2 mb-4 flex">
|
||||||
|
<GlobeIcon size="32" />
|
||||||
|
<h1 className="text-3xl font-bold pl-2">Web</h1>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold pl-2 mb-2 mt-6 ml-auto mr-auto">
|
||||||
|
Request Indexing
|
||||||
|
</h2>
|
||||||
|
<div className="border-solid border-gray-600 border rounded-md p-6">
|
||||||
|
<WebIndexForm
|
||||||
|
onSubmit={(success) => {
|
||||||
|
if (success) {
|
||||||
|
mutate("/api/admin/connectors/web/index-attempt");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-bold pl-2 mb-2 mt-6 ml-auto mr-auto">
|
||||||
|
Indexing History
|
||||||
|
</h2>
|
||||||
|
{isLoading ? (
|
||||||
|
<ThinkingAnimation text="Loading" />
|
||||||
|
) : error ? (
|
||||||
|
<div>Error loading indexing history</div>
|
||||||
|
) : (
|
||||||
|
<BasicTable
|
||||||
|
columns={COLUMNS}
|
||||||
|
data={
|
||||||
|
urlToLatestIndexAttempt.size > 0
|
||||||
|
? Array.from(urlToLatestIndexAttempt.values()).map(
|
||||||
|
(indexAttempt) => ({
|
||||||
|
...indexAttempt,
|
||||||
|
indexed_at:
|
||||||
|
timeAgo(urlToLatestIndexSuccess.get(indexAttempt.url)) ||
|
||||||
|
"-",
|
||||||
|
docs_indexed: indexAttempt.docs_indexed || "-",
|
||||||
|
url: (
|
||||||
|
<a
|
||||||
|
className="text-blue-500"
|
||||||
|
target="_blank"
|
||||||
|
href={indexAttempt.url}
|
||||||
|
>
|
||||||
|
{indexAttempt.url}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
48
web/src/app/admin/layout.tsx
Normal file
48
web/src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Header } from "@/components/Header";
|
||||||
|
import { Sidebar } from "@/components/admin/connectors/Sidebar";
|
||||||
|
import { GlobeIcon, SlackIcon } from "@/components/icons/icons";
|
||||||
|
|
||||||
|
export default function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header />
|
||||||
|
<div className="bg-gray-900 pt-8 flex">
|
||||||
|
<Sidebar
|
||||||
|
title="Connectors"
|
||||||
|
collections={[
|
||||||
|
{
|
||||||
|
name: "Connectors",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: (
|
||||||
|
<div className="flex">
|
||||||
|
<SlackIcon size="16" />
|
||||||
|
<div className="ml-1">Slack</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
link: "/admin/connectors/slack",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: (
|
||||||
|
<div className="flex">
|
||||||
|
<GlobeIcon size="16" />
|
||||||
|
<div className="ml-1">Web</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
link: "/admin/connectors/web",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="px-12 min-h-screen bg-gray-900 text-gray-100 w-full">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,5 +1,12 @@
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-inter",
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Danswer",
|
title: "Danswer",
|
||||||
description: "Question answering for your documents",
|
description: "Question answering for your documents",
|
||||||
@@ -12,7 +19,7 @@ export default function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>{children}</body>
|
<body className={`${inter.variable} font-sans`}>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -61,10 +61,6 @@ const SearchBar: React.FC<SearchBarProps> = ({ onSearch }) => {
|
|||||||
target.style.height = `${newHeight}px`;
|
target.style.height = `${newHeight}px`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// const handleSubmit = (event: KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
// onSearch(searchTerm);
|
|
||||||
// };
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
onSearch(searchTerm);
|
onSearch(searchTerm);
|
||||||
|
@@ -1,7 +1,13 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import "./thinking.css";
|
import "./thinking.css";
|
||||||
|
|
||||||
export const ThinkingAnimation: React.FC = () => {
|
interface ThinkingAnimationProps {
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThinkingAnimation: React.FC<ThinkingAnimationProps> = ({
|
||||||
|
text,
|
||||||
|
}) => {
|
||||||
const [dots, setDots] = useState("...");
|
const [dots, setDots] = useState("...");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -26,7 +32,8 @@ export const ThinkingAnimation: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="thinking-animation flex">
|
<div className="thinking-animation flex">
|
||||||
<div className="mx-auto">
|
<div className="mx-auto">
|
||||||
Thinking<span className="dots">{dots}</span>
|
{text || "Thinking"}
|
||||||
|
<span className="dots">{dots}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
67
web/src/components/admin/connectors/BasicTable.tsx
Normal file
67
web/src/components/admin/connectors/BasicTable.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React, { FC } from "react";
|
||||||
|
|
||||||
|
type Column = {
|
||||||
|
header: string;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TableData = {
|
||||||
|
[key: string]: string | number | JSX.Element;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BasicTableProps {
|
||||||
|
columns: Column[];
|
||||||
|
data: TableData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BasicTable: FC<BasicTableProps> = ({ columns, data }) => {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full table-auto">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left bg-gray-700">
|
||||||
|
{columns.map((column, index) => (
|
||||||
|
<th
|
||||||
|
key={index}
|
||||||
|
className={
|
||||||
|
"px-4 py-2 font-bold" +
|
||||||
|
(index === 0 ? " rounded-tl-md" : "") +
|
||||||
|
(index === columns.length - 1 ? " rounded-tr-md" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{column.header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((row, rowIndex) => (
|
||||||
|
<tr key={rowIndex} className="text-sm">
|
||||||
|
{columns.map((column, colIndex) => {
|
||||||
|
let entryClassName = "px-4 py-2 border-b border-gray-700";
|
||||||
|
const isFinalRow = rowIndex === data.length - 1;
|
||||||
|
if (colIndex === 0) {
|
||||||
|
entryClassName += " border-l";
|
||||||
|
if (isFinalRow) {
|
||||||
|
entryClassName += " rounded-bl-md";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (colIndex === columns.length - 1) {
|
||||||
|
entryClassName += " border-r";
|
||||||
|
if (isFinalRow) {
|
||||||
|
entryClassName += " rounded-br-md";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<td key={colIndex} className={entryClassName}>
|
||||||
|
{row[column.key]}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
27
web/src/components/admin/connectors/Field.tsx
Normal file
27
web/src/components/admin/connectors/Field.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { ErrorMessage, Field } from "formik";
|
||||||
|
|
||||||
|
interface TextFormFieldProps {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextFormField = ({ name, label }: TextFormFieldProps) => {
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<label htmlFor={name} className="block mb-1">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<Field
|
||||||
|
type="text"
|
||||||
|
name={name}
|
||||||
|
id={name}
|
||||||
|
className="border bg-slate-700 text-gray-200 border-gray-300 rounded w-full py-2 px-3"
|
||||||
|
/>
|
||||||
|
<ErrorMessage
|
||||||
|
name={name}
|
||||||
|
component="div"
|
||||||
|
className="text-red-500 text-sm mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@@ -0,0 +1,29 @@
|
|||||||
|
import { CheckCircle, MinusCircle } from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
export enum ConnectorStatus {
|
||||||
|
Running = "Running",
|
||||||
|
NotSetup = "Not Setup",
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReccuringConnectorStatusProps {
|
||||||
|
status: ConnectorStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReccuringConnectorStatus = ({
|
||||||
|
status,
|
||||||
|
}: ReccuringConnectorStatusProps) => {
|
||||||
|
if (status === ConnectorStatus.Running) {
|
||||||
|
return (
|
||||||
|
<div className="text-emerald-600 flex align-middle text-center">
|
||||||
|
<CheckCircle size={20} className="my-auto" />
|
||||||
|
<p className="my-auto ml-1">{status}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="text-gray-400 flex align-middle text-center">
|
||||||
|
<MinusCircle size={20} className="my-auto" />
|
||||||
|
<p className="my-auto ml-1">{status}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
43
web/src/components/admin/connectors/Sidebar.tsx
Normal file
43
web/src/components/admin/connectors/Sidebar.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Sidebar.tsx
|
||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
name: string | JSX.Element;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Collection {
|
||||||
|
name: string | JSX.Element;
|
||||||
|
items: Item[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
title: string;
|
||||||
|
collections: Collection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Sidebar: React.FC<SidebarProps> = ({ collections }) => {
|
||||||
|
return (
|
||||||
|
<aside className="w-64 bg-gray-900 text-gray-100 pl-4">
|
||||||
|
<nav className="space-y-2 pl-4">
|
||||||
|
{collections.map((collection, collectionInd) => (
|
||||||
|
<div key={collectionInd}>
|
||||||
|
<h2 className="text-md font-bold pb-2 ">
|
||||||
|
<div>{collection.name}</div>
|
||||||
|
</h2>
|
||||||
|
{collection.items.map((item) => (
|
||||||
|
<Link key={item.link} href={item.link}>
|
||||||
|
<button className="text-sm block w-full py-2 pl-2 text-left border-l border-gray-800">
|
||||||
|
<div className="text-gray-400 hover:text-gray-300">
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
@@ -1,160 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { Formik, Form, Field, ErrorMessage, FormikHelpers } from "formik";
|
|
||||||
import * as Yup from "yup";
|
|
||||||
import { Popup } from "./Popup";
|
|
||||||
|
|
||||||
interface FormData {
|
|
||||||
slack_bot_token: string;
|
|
||||||
workspace_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validationSchema = Yup.object().shape({
|
|
||||||
slack_bot_token: Yup.string().required("Please enter your Slack Bot Token"),
|
|
||||||
workspace_id: Yup.string().required("Please enter your Workspace ID"),
|
|
||||||
pull_frequency: Yup.number().required(
|
|
||||||
"Please enter a pull frequency (in minutes). 0 => no pulling from slack"
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const getConfig = async (): Promise<FormData> => {
|
|
||||||
const response = await fetch("/api/admin/slack_connector_config");
|
|
||||||
return response.json();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (
|
|
||||||
values: FormData,
|
|
||||||
{ setSubmitting }: FormikHelpers<FormData>,
|
|
||||||
setPopup: (
|
|
||||||
popup: { message: string; type: "success" | "error" } | null
|
|
||||||
) => void
|
|
||||||
) => {
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/slack_connector_config", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(values),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setPopup({ message: "Success!", type: "success" });
|
|
||||||
} else {
|
|
||||||
const errorData = await response.json();
|
|
||||||
setPopup({ message: `Error: ${errorData.detail}`, type: "error" });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setPopup({ message: `Error: ${error}`, type: "error" });
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
setTimeout(() => {
|
|
||||||
setPopup(null);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SlackFormProps {
|
|
||||||
onSubmit: (isSuccess: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SlackForm: React.FC<SlackFormProps> = ({ onSubmit }) => {
|
|
||||||
const [initialValues, setInitialValues] = React.useState<FormData>();
|
|
||||||
const [popup, setPopup] = useState<{
|
|
||||||
message: string;
|
|
||||||
type: "success" | "error";
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getConfig().then((response) => {
|
|
||||||
setInitialValues(response);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!initialValues) {
|
|
||||||
// TODO (chris): improve
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{popup && <Popup message={popup.message} type={popup.type} />}
|
|
||||||
<Formik
|
|
||||||
initialValues={initialValues}
|
|
||||||
validationSchema={validationSchema}
|
|
||||||
onSubmit={(values, formikHelpers) =>
|
|
||||||
handleSubmit(values, formikHelpers, setPopup)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ isSubmitting }) => (
|
|
||||||
<Form className="bg-white p-6 rounded shadow-md w-full max-w-md mx-auto">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label
|
|
||||||
htmlFor="slack_bot_token"
|
|
||||||
className="block text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
Slack Bot Token:
|
|
||||||
</label>
|
|
||||||
<Field
|
|
||||||
type="text"
|
|
||||||
name="slack_bot_token"
|
|
||||||
id="slack_bot_token"
|
|
||||||
className="border border-gray-300 rounded w-full py-2 px-3 text-gray-700"
|
|
||||||
/>
|
|
||||||
<ErrorMessage
|
|
||||||
name="slack_bot_token"
|
|
||||||
component="div"
|
|
||||||
className="text-red-500 text-sm mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label
|
|
||||||
htmlFor="workspace_id"
|
|
||||||
className="block text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
Workspace ID:
|
|
||||||
</label>
|
|
||||||
<Field
|
|
||||||
type="text"
|
|
||||||
name="workspace_id"
|
|
||||||
id="workspace_id"
|
|
||||||
className="border border-gray-300 rounded w-full py-2 px-3 text-gray-700"
|
|
||||||
/>
|
|
||||||
<ErrorMessage
|
|
||||||
name="workspace_id"
|
|
||||||
component="div"
|
|
||||||
className="text-red-500 text-sm mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label
|
|
||||||
htmlFor="workspace_id"
|
|
||||||
className="block text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
Pull Frequency:
|
|
||||||
</label>
|
|
||||||
<Field
|
|
||||||
type="text"
|
|
||||||
name="pull_frequency"
|
|
||||||
id="pull_frequency"
|
|
||||||
className="border border-gray-300 rounded w-full py-2 px-3 text-gray-700"
|
|
||||||
/>
|
|
||||||
<ErrorMessage
|
|
||||||
name="pull_frequency"
|
|
||||||
component="div"
|
|
||||||
className="text-red-500 text-sm mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline w-full"
|
|
||||||
>
|
|
||||||
Submit
|
|
||||||
</button>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Formik>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
24
web/src/components/icons/icons.tsx
Normal file
24
web/src/components/icons/icons.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Globe, SlackLogo } from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
interface IconProps {
|
||||||
|
size?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTailwindCSS = "text-blue-400 my-auto flex flex-shrink-0";
|
||||||
|
|
||||||
|
export const GlobeIcon = ({
|
||||||
|
size = "16",
|
||||||
|
className = defaultTailwindCSS,
|
||||||
|
}: IconProps) => {
|
||||||
|
return <Globe size={size} className={className} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SlackIcon = ({
|
||||||
|
size = "16",
|
||||||
|
className = defaultTailwindCSS,
|
||||||
|
}: IconProps) => {
|
||||||
|
return <SlackLogo size={size} className={className} />;
|
||||||
|
};
|
1
web/src/lib/fetcher.ts
Normal file
1
web/src/lib/fetcher.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
36
web/src/lib/time.ts
Normal file
36
web/src/lib/time.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export const timeAgo = (dateString: string | undefined): string | null => {
|
||||||
|
if (!dateString) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const secondsDiff = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||||
|
|
||||||
|
if (secondsDiff < 60) {
|
||||||
|
return `${secondsDiff} seconds ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutesDiff = Math.floor(secondsDiff / 60);
|
||||||
|
if (minutesDiff < 60) {
|
||||||
|
return `${minutesDiff} minutes ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoursDiff = Math.floor(minutesDiff / 60);
|
||||||
|
if (hoursDiff < 24) {
|
||||||
|
return `${hoursDiff} hours ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysDiff = Math.floor(hoursDiff / 24);
|
||||||
|
if (daysDiff < 30) {
|
||||||
|
return `${daysDiff} days ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthsDiff = Math.floor(daysDiff / 30);
|
||||||
|
if (monthsDiff < 12) {
|
||||||
|
return `${monthsDiff} months ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearsDiff = Math.floor(monthsDiff / 12);
|
||||||
|
return `${yearsDiff} years ago`;
|
||||||
|
};
|
@@ -9,7 +9,11 @@ module.exports = {
|
|||||||
"./src/**/*.{js,ts,jsx,tsx,mdx}",
|
"./src/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["var(--font-inter)"],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user