From 33f49ef0ce9ab8a6549d2f1fa4c0b7878f5b4d5e Mon Sep 17 00:00:00 2001 From: kamtschatka Date: Sun, 7 Jul 2024 20:16:00 +0200 Subject: [PATCH] Admin settings config #282 created the basic structure --- apps/web/app/dashboard/admin/page.tsx | 137 +++++++++++++++++++++-- apps/web/components/ui/switch.tsx | 28 +++++ apps/web/package.json | 1 + packages/db/config/config.ts | 151 ++++++++++++++++++++++++++ packages/db/config/configUtils.ts | 91 ++++++++++++++++ packages/db/config/configValue.ts | 66 +++++++++++ 6 files changed, 467 insertions(+), 7 deletions(-) create mode 100644 apps/web/components/ui/switch.tsx create mode 100644 packages/db/config/config.ts create mode 100644 packages/db/config/configUtils.ts create mode 100644 packages/db/config/configValue.ts diff --git a/apps/web/app/dashboard/admin/page.tsx b/apps/web/app/dashboard/admin/page.tsx index 18efc889..fad74ca5 100644 --- a/apps/web/app/dashboard/admin/page.tsx +++ b/apps/web/app/dashboard/admin/page.tsx @@ -2,7 +2,117 @@ import { redirect } from "next/navigation"; import AdminActions from "@/components/dashboard/admin/AdminActions"; import ServerStats from "@/components/dashboard/admin/ServerStats"; import UserList from "@/components/dashboard/admin/UserList"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getServerAuthSession } from "@/server/auth"; +import { Save, Undo } from "lucide-react"; + +import { + ConfigSubSection, + SectionSymbol, + serverConfig, +} from "@hoarder/db/config/config"; +import { + ConfigType, + ConfigValue, + InferenceProviderEnum, +} from "@hoarder/db/config/configValue"; + +function createTabsTrigger(sectionName: string, section: ConfigSubSection) { + return ( + {section[SectionSymbol].name} + ); +} + +function createTab(sectionName: string, section: ConfigSubSection) { + return ( + +
+ + + {Object.values(section).map((value) => createConfigValueUI(value))} + + + + + Reset + + Save + + + +
+
+
+ ); +} + +const LOOKUP_TABLE = { + [ConfigType.BOOLEAN]: createBooleanRow, + [ConfigType.STRING]: createStringRow, + [ConfigType.NUMBER]: createNumberRow, + [ConfigType.INFERENCE_PROVIDER_ENUM]: createInferenceProviderRow, +}; + +function createConfigValueUI(configValue: ConfigValue) { + return ( + + {configValue.name} + {LOOKUP_TABLE[configValue.type](configValue)} + + ); +} + +function createBooleanRow(configValue: ConfigValue) { + return ; +} +function createNumberRow(configValue: ConfigValue) { + return ( + + ); +} + +function createStringRow(configValue: ConfigValue) { + return ( + + ); +} + +function createInferenceProviderRow(configValue: ConfigValue) { + return ( + <> + + + ); +} export default async function AdminPage() { const session = await getServerAuthSession(); @@ -11,13 +121,26 @@ export default async function AdminPage() { } return ( <> -
- - -
-
- -
+ + + Information + {Object.entries(serverConfig).map((entry) => + createTabsTrigger(entry[0], entry[1]), + )} + + +
+ + +
+
+ +
+
+ {Object.entries(serverConfig).map((entry) => + createTab(entry[0], entry[1]), + )} +
); } diff --git a/apps/web/components/ui/switch.tsx b/apps/web/components/ui/switch.tsx new file mode 100644 index 00000000..9da44730 --- /dev/null +++ b/apps/web/components/ui/switch.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; +import * as SwitchPrimitives from "@radix-ui/react-switch"; + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/apps/web/package.json b/apps/web/package.json index a153ef3f..1f177a91 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,7 @@ "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toggle": "^1.0.3", diff --git a/packages/db/config/config.ts b/packages/db/config/config.ts new file mode 100644 index 00000000..84f440ec --- /dev/null +++ b/packages/db/config/config.ts @@ -0,0 +1,151 @@ +import { z } from "zod"; + +import { + ConfigType, + ConfigValue, + InferenceProviderEnum, + InferenceProviderEnumValidator, +} from "./configValue"; + +export const SectionSymbol = Symbol("section"); + +export interface SectionInformation { + name: string; +} + +export interface ConfigSubSection { + [SectionSymbol]: SectionInformation; + [configValue: string]: ConfigValue; +} + +export type ServerConfig = Record; + +export const serverConfig: ServerConfig = { + generalConfig: { + [SectionSymbol]: { + name: "General", + }, + disableSignups: new ConfigValue({ + key: "DISABLE_SIGNUPS", + name: "Disable Signups", + type: ConfigType.BOOLEAN, + defaultValue: false, + validator: z.boolean(), + }), + maxAssetSize: new ConfigValue({ + key: "MAX_ASSET_SIZE_MB", + name: "Maximum Asset size(MB)", + type: ConfigType.NUMBER, + defaultValue: 4, + validator: z.number().positive(), + }), + disableNewReleaseCheck: new ConfigValue({ + key: "DISABLE_NEW_RELEASE_CHECK", + name: "Disable new release check", + type: ConfigType.BOOLEAN, + defaultValue: false, + validator: z.boolean(), + }), + }, + crawlerConfig: { + [SectionSymbol]: { + name: "Crawler", + }, + downloadBannerImage: new ConfigValue({ + key: "CRAWLER_DOWNLOAD_BANNER_IMAGE", + name: "Download Banner Image", + type: ConfigType.BOOLEAN, + defaultValue: true, + validator: z.boolean(), + }), + storeScreenshot: new ConfigValue({ + key: "CRAWLER_STORE_SCREENSHOT", + name: "Disable screenshots", + type: ConfigType.BOOLEAN, + defaultValue: true, + validator: z.boolean(), + }), + storeFullPageScreenshot: new ConfigValue({ + key: "CRAWLER_FULL_PAGE_SCREENSHOT", + name: "Store full page screenshots", + type: ConfigType.BOOLEAN, + defaultValue: false, + validator: z.boolean(), + }), + fullPageArchive: new ConfigValue({ + key: "CRAWLER_FULL_PAGE_ARCHIVE", + name: "Store full page archive", + type: ConfigType.BOOLEAN, + defaultValue: false, + validator: z.boolean(), + }), + jobTimeout: new ConfigValue({ + key: "CRAWLER_JOB_TIMEOUT_SEC", + name: "Job Timeout (sec)", + type: ConfigType.NUMBER, + defaultValue: 60, + validator: z.number().positive(), + }), + navigateTimeout: new ConfigValue({ + key: "CRAWLER_NAVIGATE_TIMEOUT_SEC", + name: "Navigate Timeout (sec)", + type: ConfigType.NUMBER, + defaultValue: 30, + validator: z.number().positive(), + }), + }, + inferenceConfig: { + [SectionSymbol]: { + name: "Inference Config", + }, + inferenceProvider: new ConfigValue({ + key: "INFERENCE_PROVIDER", + name: "Inference Provider", + type: ConfigType.INFERENCE_PROVIDER_ENUM, + defaultValue: InferenceProviderEnum.DISABLED, + validator: InferenceProviderEnumValidator, + }), + openApiKey: new ConfigValue({ + key: "OPENAI_API_KEY", + name: "OpenAPI Key", + type: ConfigType.STRING, + defaultValue: "", + validator: z.string(), + }), + openAiBaseUrl: new ConfigValue({ + key: "OPENAI_BASE_URL", + name: "OpenAPI base URL", + type: ConfigType.STRING, + defaultValue: "", + validator: z.string(), + }), + ollamaBaseUrl: new ConfigValue({ + key: "OLLAMA_BASE_URL", + name: "Ollama base URL", + type: ConfigType.STRING, + defaultValue: "", + validator: z.string(), + }), + inferenceTextModel: new ConfigValue({ + key: "INFERENCE_TEXT_MODEL", + name: "Inference text model", + type: ConfigType.STRING, + defaultValue: "gpt-3.5-turbo-0125", + validator: z.string(), + }), + inferenceImageModel: new ConfigValue({ + key: "INFERENCE_IMAGE_MODEL", + name: "Inference image model", + type: ConfigType.STRING, + defaultValue: "gpt-4o-2024-05-13", + validator: z.string(), + }), + inferenceLanguage: new ConfigValue({ + key: "INFERENCE_LANG", + name: "Inference language", + type: ConfigType.STRING, + defaultValue: "english", + validator: z.string(), + }), + }, +}; diff --git a/packages/db/config/configUtils.ts b/packages/db/config/configUtils.ts new file mode 100644 index 00000000..6c3609a4 --- /dev/null +++ b/packages/db/config/configUtils.ts @@ -0,0 +1,91 @@ +import { eq } from "drizzle-orm"; + +import { db } from "../index"; +import { config } from "../schema"; +import { ConfigType, ConfigTypeMap, ConfigValue } from "./configValue"; + +/** + * @returns the value only from the database without taking the value from the environment into consideration. Undefined if it does not exist + */ +async function getConfigValueFromDB( + configValue: ConfigValue, +): Promise { + try { + const rows = await db + .select() + .from(config) + .where(eq(config.key, configValue.key)); + if (rows.length === 0) { + return void 0; + } + return parseValue(configValue, rows[0].value); + } catch (e) { + return void 0; + } +} + +/** + * @returns the value from the environment variable. Undefined if it does not exist. + */ +export function getConfigValueFromEnv( + configValue: ConfigValue, +): ConfigTypeMap[T] | undefined { + const environmentValue = process.env[configValue.key]; + if (!environmentValue) { + return void 0; + } + return parseValue(configValue, environmentValue); +} + +/** + * @returns the value of the config, considering the database, the environment variable and the default value. Will always return a value + */ +export async function getConfigValue( + configValue: ConfigValue, +): Promise { + const dbValue = await getConfigValueFromDB(configValue); + if (dbValue) { + return dbValue; + } + const envValue = getConfigValueFromEnv(configValue); + if (envValue) { + return envValue; + } + return configValue.defaultValue; +} + +export async function storeValue( + configValue: ConfigValue, +): Promise { + const value = configValue.value?.toString() ?? ""; + await db.transaction(async () => { + const rows = db + .select() + .from(config) + .where(eq(config.key, configValue.key)) + .all(); + + if (rows.length > 0) { + // If the record exists, update it + await db + .update(config) + .set({ value }) + .where(eq(config.key, configValue.key)) + .execute(); + } else { + // If the record doesn't exist, insert it + await db.insert(config).values({ key: configValue.key, value }).execute(); + } + }); +} + +function parseValue( + configValue: ConfigValue, + value: string, +): ConfigTypeMap[T] | undefined { + const parsed = configValue.validator.safeParse(value); + if (parsed.success) { + return parsed.data as ConfigTypeMap[T]; + } + return undefined; +} diff --git a/packages/db/config/configValue.ts b/packages/db/config/configValue.ts new file mode 100644 index 00000000..7d105f64 --- /dev/null +++ b/packages/db/config/configValue.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; + +export enum ConfigType { + BOOLEAN, + STRING, + NUMBER, + INFERENCE_PROVIDER_ENUM, +} + +export enum InferenceProviderEnum { + DISABLED = "disabled", + OPEN_AI = "openai", + OLLAMA = "ollama", +} + +export const InferenceProviderEnumValidator = z.enum([ + InferenceProviderEnum.DISABLED, + InferenceProviderEnum.OPEN_AI, + InferenceProviderEnum.OLLAMA, +]); + +export type InferenceProviderEnumZodType = + typeof InferenceProviderEnumValidator; + +export interface ConfigTypeMap { + [ConfigType.BOOLEAN]: boolean; + [ConfigType.STRING]: string; + [ConfigType.NUMBER]: number; + [ConfigType.INFERENCE_PROVIDER_ENUM]: string; +} + +export interface ConfigValidatorMap { + [ConfigType.BOOLEAN]: z.ZodBoolean; + [ConfigType.STRING]: z.ZodString; + [ConfigType.NUMBER]: z.ZodNumber; + [ConfigType.INFERENCE_PROVIDER_ENUM]: InferenceProviderEnumZodType; +} + +export interface ConfigProperties { + key: string; + name: string; + type: T; + defaultValue: ConfigTypeMap[T]; + validator: ConfigValidatorMap[T]; +} + +export class ConfigValue { + key: string; + name: string; + type: T; + defaultValue: ConfigTypeMap[T]; + validator: ConfigValidatorMap[T]; + value: ConfigTypeMap[T] | undefined; + + constructor(props: ConfigProperties) { + this.key = props.key; + this.name = props.name; + this.type = props.type; + this.defaultValue = props.defaultValue; + this.validator = props.validator; + } + + setValue(value: ConfigTypeMap[T]): void { + this.value = value; + } +}