mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 12:28:42 +00:00
refactor(server): config system (#11081)
This commit is contained in:
350
packages/frontend/admin/src/config.json
Normal file
350
packages/frontend/admin/src/config.json
Normal file
@@ -0,0 +1,350 @@
|
||||
{
|
||||
"redis": {
|
||||
"db": {
|
||||
"type": "Number",
|
||||
"desc": "The database index of redis server to be used(Must be less than 10).",
|
||||
"env": "REDIS_DATABASE"
|
||||
},
|
||||
"host": {
|
||||
"type": "String",
|
||||
"desc": "The host of the redis server.",
|
||||
"env": "REDIS_HOST"
|
||||
},
|
||||
"port": {
|
||||
"type": "Number",
|
||||
"desc": "The port of the redis server.",
|
||||
"env": "REDIS_PORT"
|
||||
},
|
||||
"username": {
|
||||
"type": "String",
|
||||
"desc": "The username of the redis server.",
|
||||
"env": "REDIS_USERNAME"
|
||||
},
|
||||
"password": {
|
||||
"type": "String",
|
||||
"desc": "The password of the redis server.",
|
||||
"env": "REDIS_PASSWORD"
|
||||
},
|
||||
"ioredis": {
|
||||
"type": "Object",
|
||||
"desc": "The config for the ioredis client.",
|
||||
"link": "https://github.com/luin/ioredis"
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"enabled": {
|
||||
"type": "Boolean",
|
||||
"desc": "Enable metric and tracing collection"
|
||||
}
|
||||
},
|
||||
"graphql": {
|
||||
"apolloDriverConfig": {
|
||||
"type": "Object",
|
||||
"desc": "The config for underlying nestjs GraphQL and apollo driver engine.",
|
||||
"link": "https://docs.nestjs.com/graphql/quick-start"
|
||||
}
|
||||
},
|
||||
"crypto": {
|
||||
"privateKey": {
|
||||
"type": "String",
|
||||
"desc": "The private key for used by the crypto module to create signed tokens or encrypt data.",
|
||||
"env": "AFFINE_PRIVATE_KEY"
|
||||
}
|
||||
},
|
||||
"job": {
|
||||
"queue": {
|
||||
"type": "Object",
|
||||
"desc": "The config for job queues",
|
||||
"link": "https://api.docs.bullmq.io/interfaces/v5.QueueOptions.html"
|
||||
},
|
||||
"worker": {
|
||||
"type": "Object",
|
||||
"desc": "The config for job workers",
|
||||
"link": "https://api.docs.bullmq.io/interfaces/v5.WorkerOptions.html"
|
||||
},
|
||||
"queues.copilot": {
|
||||
"type": "Object",
|
||||
"desc": "The config for copilot job queue"
|
||||
},
|
||||
"queues.doc": {
|
||||
"type": "Object",
|
||||
"desc": "The config for doc job queue"
|
||||
},
|
||||
"queues.notification": {
|
||||
"type": "Object",
|
||||
"desc": "The config for notification job queue"
|
||||
},
|
||||
"queues.nightly": {
|
||||
"type": "Object",
|
||||
"desc": "The config for nightly job queue"
|
||||
}
|
||||
},
|
||||
"throttle": {
|
||||
"enabled": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether the throttler is enabled."
|
||||
},
|
||||
"throttlers.default": {
|
||||
"type": "Object",
|
||||
"desc": "The config for the default throttler."
|
||||
},
|
||||
"throttlers.strict": {
|
||||
"type": "Object",
|
||||
"desc": "The config for the strict throttler."
|
||||
}
|
||||
},
|
||||
"websocket": {
|
||||
"transports": {
|
||||
"type": "Array",
|
||||
"desc": "The enabled transports for accepting websocket traffics.",
|
||||
"link": "https://docs.nestjs.com/websockets/gateways#transports"
|
||||
},
|
||||
"maxHttpBufferSize": {
|
||||
"type": "Number",
|
||||
"desc": "How many bytes or characters a message can be, before closing the session (to avoid DoS)."
|
||||
}
|
||||
},
|
||||
"db": {
|
||||
"datasourceUrl": {
|
||||
"type": "String",
|
||||
"desc": "The datasource url for the prisma client.",
|
||||
"env": "DATABASE_URL"
|
||||
},
|
||||
"prisma": {
|
||||
"type": "Object",
|
||||
"desc": "The config for the prisma client.",
|
||||
"link": "https://www.prisma.io/docs/reference/api-reference/prisma-client-reference"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"allowSignup": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether allow new registrations."
|
||||
},
|
||||
"requireEmailDomainVerification": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether require email domain record verification before accessing restricted resources."
|
||||
},
|
||||
"requireEmailVerification": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether require email verification before accessing restricted resources(not implemented)."
|
||||
},
|
||||
"passwordRequirements": {
|
||||
"type": "Object",
|
||||
"desc": "The password strength requirements when set new password."
|
||||
},
|
||||
"session.ttl": {
|
||||
"type": "Number",
|
||||
"desc": "Application auth expiration time in seconds."
|
||||
},
|
||||
"session.ttr": {
|
||||
"type": "Number",
|
||||
"desc": "Application auth time to refresh in seconds."
|
||||
}
|
||||
},
|
||||
"mailer": {
|
||||
"enabled": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether enabled mail service."
|
||||
},
|
||||
"SMTP.host": {
|
||||
"type": "String",
|
||||
"desc": "Host of the email server (e.g. smtp.gmail.com)",
|
||||
"env": "MAILER_HOST"
|
||||
},
|
||||
"SMTP.port": {
|
||||
"type": "Number",
|
||||
"desc": "Port of the email server (they commonly are 25, 465 or 587)",
|
||||
"env": "MAILER_PORT"
|
||||
},
|
||||
"SMTP.username": {
|
||||
"type": "String",
|
||||
"desc": "Username used to authenticate the email server",
|
||||
"env": "MAILER_USER"
|
||||
},
|
||||
"SMTP.password": {
|
||||
"type": "String",
|
||||
"desc": "Password used to authenticate the email server",
|
||||
"env": "MAILER_PASSWORD"
|
||||
},
|
||||
"SMTP.sender": {
|
||||
"type": "String",
|
||||
"desc": "Sender of all the emails (e.g. \"AFFiNE Team <noreply@affine.pro>\")",
|
||||
"env": "MAILER_SENDER"
|
||||
},
|
||||
"SMTP.ignoreTLS": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether ignore email server's TSL certification verification. Enable it for self-signed certificates.",
|
||||
"env": "MAILER_IGNORE_TLS"
|
||||
}
|
||||
},
|
||||
"doc": {
|
||||
"experimental.yocto": {
|
||||
"type": "Boolean",
|
||||
"desc": "Use `y-octo` to merge updates at the same time when merging using Yjs."
|
||||
},
|
||||
"history.interval": {
|
||||
"type": "Number",
|
||||
"desc": "The minimum time interval in milliseconds of creating a new history snapshot when doc get updated."
|
||||
}
|
||||
},
|
||||
"storages": {
|
||||
"avatar.publicPath": {
|
||||
"type": "String",
|
||||
"desc": "The public accessible path prefix for user avatars."
|
||||
},
|
||||
"avatar.storage": {
|
||||
"type": "Object",
|
||||
"desc": "The config of storage for user avatars."
|
||||
},
|
||||
"blob.storage": {
|
||||
"type": "Object",
|
||||
"desc": "The config of storage for all uploaded blobs(images, videos, etc.)."
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"name": {
|
||||
"type": "String",
|
||||
"desc": "A recognizable name for the server. Will be shown when connected with AFFiNE Desktop."
|
||||
},
|
||||
"externalUrl": {
|
||||
"type": "String",
|
||||
"desc": "Base url of AFFiNE server, used for generating external urls.\nDefault to be `[server.protocol]://[server.host][:server.port]` if not specified.\n ",
|
||||
"env": "AFFINE_SERVER_EXTERNAL_URL"
|
||||
},
|
||||
"https": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether the server is hosted on a ssl enabled domain (https://).",
|
||||
"env": "AFFINE_SERVER_HTTPS"
|
||||
},
|
||||
"host": {
|
||||
"type": "String",
|
||||
"desc": "Where the server get deployed(FQDN).",
|
||||
"env": "AFFINE_SERVER_HOST"
|
||||
},
|
||||
"port": {
|
||||
"type": "Number",
|
||||
"desc": "Which port the server will listen on.",
|
||||
"env": "AFFINE_SERVER_PORT"
|
||||
},
|
||||
"path": {
|
||||
"type": "String",
|
||||
"desc": "Subpath where the server get deployed if there is.",
|
||||
"env": "AFFINE_SERVER_SUB_PATH"
|
||||
}
|
||||
},
|
||||
"flags": {
|
||||
"earlyAccessControl": {
|
||||
"type": "Boolean",
|
||||
"desc": "Only allow users with early access features to access the app"
|
||||
}
|
||||
},
|
||||
"client": {
|
||||
"versionControl.enabled": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether check version of client before accessing the server."
|
||||
},
|
||||
"versionControl.requiredVersion": {
|
||||
"type": "String",
|
||||
"desc": "Allowed version range of the app that allowed to access the server. Requires 'client/versionControl.enabled' to be true to take effect."
|
||||
}
|
||||
},
|
||||
"captcha": {
|
||||
"enabled": {
|
||||
"type": "Boolean",
|
||||
"desc": "Check captcha challenge when user authenticating the app."
|
||||
},
|
||||
"config": {
|
||||
"type": "Object",
|
||||
"desc": "The config for the captcha plugin."
|
||||
}
|
||||
},
|
||||
"copilot": {
|
||||
"enabled": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether to enable the copilot plugin."
|
||||
},
|
||||
"providers.openai": {
|
||||
"type": "Object",
|
||||
"desc": "The config for the openai provider.",
|
||||
"link": "https://github.com/openai/openai-node"
|
||||
},
|
||||
"providers.fal": {
|
||||
"type": "Object",
|
||||
"desc": "The config for the fal provider."
|
||||
},
|
||||
"providers.gemini": {
|
||||
"type": "Object",
|
||||
"desc": "The config for the gemini provider."
|
||||
},
|
||||
"providers.perplexity": {
|
||||
"type": "Object",
|
||||
"desc": "The config for the perplexity provider."
|
||||
},
|
||||
"unsplash": {
|
||||
"type": "Object",
|
||||
"desc": "The config for the unsplash key."
|
||||
},
|
||||
"storage": {
|
||||
"type": "Object",
|
||||
"desc": "The config for the storage provider."
|
||||
}
|
||||
},
|
||||
"customerIo": {
|
||||
"enabled": {
|
||||
"type": "Boolean",
|
||||
"desc": "Enable customer.io integration"
|
||||
},
|
||||
"token": {
|
||||
"type": "String",
|
||||
"desc": "Customer.io token"
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"providers.google": {
|
||||
"type": "Object",
|
||||
"desc": "Google OAuth provider config",
|
||||
"link": "https://developers.google.com/identity/protocols/oauth2/web-server"
|
||||
},
|
||||
"providers.github": {
|
||||
"type": "Object",
|
||||
"desc": "GitHub OAuth provider config",
|
||||
"link": "https://docs.github.com/en/apps/oauth-apps"
|
||||
},
|
||||
"providers.oidc": {
|
||||
"type": "Object",
|
||||
"desc": "OIDC OAuth provider config"
|
||||
}
|
||||
},
|
||||
"payment": {
|
||||
"enabled": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether enable payment plugin"
|
||||
},
|
||||
"showLifetimePrice": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether enable lifetime price and allow user to pay for it."
|
||||
},
|
||||
"apiKey": {
|
||||
"type": "String",
|
||||
"desc": "Stripe API key to enable payment service.",
|
||||
"env": "STRIPE_API_KEY"
|
||||
},
|
||||
"webhookKey": {
|
||||
"type": "String",
|
||||
"desc": "Stripe webhook key to enable payment service.",
|
||||
"env": "STRIPE_WEBHOOK_KEY"
|
||||
},
|
||||
"stripe": {
|
||||
"type": "Object",
|
||||
"desc": "Stripe API keys",
|
||||
"link": "https://docs.stripe.com/api"
|
||||
}
|
||||
},
|
||||
"worker": {
|
||||
"allowedOrigin": {
|
||||
"type": "Array",
|
||||
"desc": "Allowed origin"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,177 +1,17 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@affine/admin/components/ui/card';
|
||||
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
|
||||
|
||||
import { Header } from '../header';
|
||||
import { AboutAFFiNE } from './about';
|
||||
import type {
|
||||
DatabaseConfig,
|
||||
MailerConfig,
|
||||
ServerConfig,
|
||||
} from './use-server-service-configs';
|
||||
import { useServerServiceConfigs } from './use-server-service-configs';
|
||||
|
||||
export function ConfigPage() {
|
||||
return (
|
||||
<div className=" h-screen flex-1 space-y-1 flex-col flex">
|
||||
<Header title="Server" />
|
||||
<ScrollArea>
|
||||
<ServerServiceConfig />
|
||||
<AboutAFFiNE />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ServerCard = ({ serverConfig }: { serverConfig?: ServerConfig }) => {
|
||||
if (!serverConfig) return null;
|
||||
return (
|
||||
<Card className="px-5 py-4">
|
||||
<CardHeader className="p-0">
|
||||
<CardTitle className="text-base font-semibold mb-3">Server</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 p-0">
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">Domain</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{serverConfig.host}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">Port</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{serverConfig.port}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">HTTPS Prefix</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{serverConfig.https.toString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">External Url</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{serverConfig.externalUrl}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
const DatabaseCard = ({
|
||||
databaseConfig,
|
||||
}: {
|
||||
databaseConfig?: DatabaseConfig;
|
||||
}) => {
|
||||
if (!databaseConfig) return null;
|
||||
return (
|
||||
<Card className="px-5 py-4">
|
||||
<CardHeader className="p-0">
|
||||
<CardTitle className="text-base font-semibold mb-3">Database</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 p-0">
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">Domain</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{databaseConfig.host}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">Port</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{databaseConfig.port}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">User</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{databaseConfig.user}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">Database</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{databaseConfig.database}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
const MailerCard = ({ mailerConfig }: { mailerConfig?: MailerConfig }) => {
|
||||
if (!mailerConfig) return null;
|
||||
return (
|
||||
<Card className="px-5 py-4">
|
||||
<CardHeader className="p-0">
|
||||
<CardTitle className="text-base font-semibold mb-3">Email</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 p-0">
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">Provider Domain</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{mailerConfig.host}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">Port</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{mailerConfig.port}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">Sender</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{mailerConfig.sender}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export function ServerServiceConfig() {
|
||||
const { serverConfig, mailerConfig, databaseConfig } =
|
||||
useServerServiceConfigs();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col py-5 px-6">
|
||||
<div className="flex items-center mb-5">
|
||||
<span className="text-2xl font-semibold">Server Config</span>
|
||||
</div>
|
||||
<div className=" items-start justify-center gap-6 rounded-lg grid grid-cols-2">
|
||||
<div className="col-span-2 grid items-start gap-6 lg:col-span-1">
|
||||
<ServerCard serverConfig={serverConfig} />
|
||||
<MailerCard mailerConfig={mailerConfig} />
|
||||
</div>
|
||||
<div className="col-span-2 grid items-start gap-6 lg:col-span-1">
|
||||
<DatabaseCard databaseConfig={databaseConfig} />
|
||||
<div className="px-5 py-4 border rounded text-sm text-zinc-500 font-normal">
|
||||
<span className="mr-1">
|
||||
These settings are controlled by Docker environment variables.
|
||||
Refer to the
|
||||
</span>
|
||||
<a
|
||||
href="https://docs.affine.pro/docs/self-host-affine"
|
||||
className="text-black underline"
|
||||
>
|
||||
Selfhost documentation.
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ConfigPage as Component };
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useQueryImmutable } from '@affine/admin/use-query';
|
||||
import { getServerServiceConfigsQuery } from '@affine/graphql';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export type ServerConfig = {
|
||||
externalUrl: string;
|
||||
https: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type MailerConfig = {
|
||||
host: string;
|
||||
port: number;
|
||||
sender: string;
|
||||
};
|
||||
|
||||
export type DatabaseConfig = {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
database: string;
|
||||
};
|
||||
|
||||
export type ServerServiceConfig = {
|
||||
name: string;
|
||||
config: ServerConfig | MailerConfig | DatabaseConfig;
|
||||
};
|
||||
|
||||
export const useServerServiceConfigs = () => {
|
||||
const { data } = useQueryImmutable({
|
||||
query: getServerServiceConfigsQuery,
|
||||
});
|
||||
const server = useMemo(
|
||||
() =>
|
||||
data.serverServiceConfigs.find(
|
||||
(service: ServerServiceConfig) => service.name === 'server'
|
||||
),
|
||||
[data.serverServiceConfigs]
|
||||
);
|
||||
const mailer = useMemo(
|
||||
() =>
|
||||
data.serverServiceConfigs.find(
|
||||
(service: ServerServiceConfig) => service.name === 'mailer'
|
||||
),
|
||||
[data.serverServiceConfigs]
|
||||
);
|
||||
const database = useMemo(
|
||||
() =>
|
||||
data.serverServiceConfigs.find(
|
||||
(service: ServerServiceConfig) => service.name === 'database'
|
||||
),
|
||||
[data.serverServiceConfigs]
|
||||
);
|
||||
|
||||
const serverConfig = server?.config as ServerConfig | undefined;
|
||||
const mailerConfig = mailer?.config as MailerConfig | undefined;
|
||||
const databaseConfig = database?.config as DatabaseConfig | undefined;
|
||||
|
||||
return { serverConfig, mailerConfig, databaseConfig };
|
||||
};
|
||||
@@ -5,20 +5,15 @@ import {
|
||||
AccordionTrigger,
|
||||
} from '@affine/admin/components/ui/accordion';
|
||||
import { useCallback } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
export const CollapsibleItem = ({
|
||||
items,
|
||||
title,
|
||||
changeModule,
|
||||
}: {
|
||||
title: string;
|
||||
items: string[];
|
||||
changeModule?: (module: string) => void;
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const activeSubTab = location.hash.slice(1);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(id: string) => {
|
||||
const targetElement = document.getElementById(id);
|
||||
@@ -50,26 +45,6 @@ export const CollapsibleItem = ({
|
||||
{title}
|
||||
</AccordionTrigger>
|
||||
</NavLink>
|
||||
<AccordionContent className="flex flex-col gap-2 py-1">
|
||||
{items.map(item => (
|
||||
<NavLink
|
||||
key={item}
|
||||
to={`/admin/settings/${title}#${item}`}
|
||||
className={({ isActive }) => {
|
||||
return isActive && activeSubTab === item
|
||||
? `transition-all overflow-hidden w-full bg-zinc-100 inline-flex items-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50`
|
||||
: '';
|
||||
}}
|
||||
>
|
||||
<AccordionContent
|
||||
onClick={() => handleClick(item)}
|
||||
className={`py-1 px-2 rounded text-ellipsis whitespace-nowrap overflow-hidden`}
|
||||
>
|
||||
{item}
|
||||
</AccordionContent>
|
||||
</NavLink>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
@@ -79,10 +54,7 @@ export const OtherModules = ({
|
||||
moduleList,
|
||||
changeModule,
|
||||
}: {
|
||||
moduleList: {
|
||||
moduleName: string;
|
||||
keys: string[];
|
||||
}[];
|
||||
moduleList: string[];
|
||||
changeModule?: (module: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
@@ -94,9 +66,8 @@ export const OtherModules = ({
|
||||
<AccordionContent className="flex flex-col gap-2 py-1">
|
||||
{moduleList.map(module => (
|
||||
<CollapsibleItem
|
||||
key={module.moduleName}
|
||||
items={module.keys}
|
||||
title={module.moduleName}
|
||||
key={module}
|
||||
title={module}
|
||||
changeModule={changeModule}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -84,12 +84,6 @@ export function Nav({ isCollapsed = false }: NavProps) {
|
||||
isCollapsed && 'items-center px-0 gap-1 overflow-visible'
|
||||
)}
|
||||
>
|
||||
<NavItem
|
||||
to="/admin/config"
|
||||
icon={<SelfhostIcon fontSize={20} />}
|
||||
label="Server"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<NavItem
|
||||
to="/admin/accounts"
|
||||
icon={<AccountIcon fontSize={20} />}
|
||||
@@ -102,8 +96,13 @@ export function Nav({ isCollapsed = false }: NavProps) {
|
||||
label="AI"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
|
||||
<SettingsItem isCollapsed={isCollapsed} />
|
||||
<NavItem
|
||||
to="/admin/config"
|
||||
icon={<SelfhostIcon fontSize={20} />}
|
||||
label="Server"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</nav>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -10,7 +10,9 @@ export const ServerVersion = () => {
|
||||
const version = serverConfig?.version;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
window.open(availableUpgrade.url, '_blank');
|
||||
if (availableUpgrade) {
|
||||
window.open(availableUpgrade.url, '_blank');
|
||||
}
|
||||
}, [availableUpgrade]);
|
||||
|
||||
if (availableUpgrade) {
|
||||
|
||||
@@ -10,23 +10,19 @@ import { SettingsIcon } from '@blocksuite/icons/rc';
|
||||
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { useMemo } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { useGetServerRuntimeConfig } from '../settings/use-get-server-runtime-config';
|
||||
import { ALL_CONFIGURABLE_MODULES } from '../settings/config';
|
||||
import { CollapsibleItem, OtherModules } from './collapsible-item';
|
||||
import { useNav } from './context';
|
||||
export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
|
||||
const { moduleList } = useGetServerRuntimeConfig();
|
||||
const { setCurrentModule } = useNav();
|
||||
|
||||
const { authModule, otherModules } = useMemo(() => {
|
||||
const authModule = moduleList.find(module => module.moduleName === 'auth');
|
||||
const otherModules = moduleList.filter(
|
||||
module => module.moduleName !== 'auth'
|
||||
);
|
||||
return { authModule, otherModules };
|
||||
}, [moduleList]);
|
||||
const authModule = ALL_CONFIGURABLE_MODULES.find(module => module === 'auth');
|
||||
const otherModules = ALL_CONFIGURABLE_MODULES.filter(
|
||||
module => module !== 'auth'
|
||||
);
|
||||
|
||||
export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
|
||||
const { setCurrentModule } = useNav();
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
@@ -63,10 +59,10 @@ export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
|
||||
borderColor: cssVarV2('layer/insideBorder/blackBorder'),
|
||||
}}
|
||||
>
|
||||
{moduleList.map(module => (
|
||||
<li key={module.moduleName} className="flex">
|
||||
{ALL_CONFIGURABLE_MODULES.map(module => (
|
||||
<li key={module} className="flex">
|
||||
<NavLink
|
||||
to={`/admin/settings/${module.moduleName}`}
|
||||
to={`/admin/settings/${module}`}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'ghost',
|
||||
@@ -79,9 +75,9 @@ export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
|
||||
? cssVarV2('selfhost/button/sidebarButton/bg/select')
|
||||
: undefined,
|
||||
})}
|
||||
onClick={() => setCurrentModule?.(module.moduleName)}
|
||||
onClick={() => setCurrentModule?.(module)}
|
||||
>
|
||||
{module.moduleName}
|
||||
{module}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
@@ -93,6 +89,7 @@ export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
|
||||
</NavigationMenuPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" className="w-full h-full overflow-hidden">
|
||||
<AccordionItem
|
||||
@@ -134,8 +131,7 @@ export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block">
|
||||
{authModule && (
|
||||
<CollapsibleItem
|
||||
items={authModule.keys}
|
||||
title={authModule.moduleName}
|
||||
title={authModule}
|
||||
changeModule={setCurrentModule}
|
||||
/>
|
||||
)}
|
||||
|
||||
125
packages/frontend/admin/src/modules/settings/config-input.tsx
Normal file
125
packages/frontend/admin/src/modules/settings/config-input.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { Switch } from '@affine/admin/components/ui/switch';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { isEqual } from './utils';
|
||||
|
||||
interface ConfigInputProps {
|
||||
module: string;
|
||||
field: string;
|
||||
type: string;
|
||||
defaultValue: any;
|
||||
onChange: (module: string, field: string, value: any) => void;
|
||||
}
|
||||
|
||||
const Inputs: Record<
|
||||
string,
|
||||
React.ComponentType<{
|
||||
defaultValue: any;
|
||||
onChange: (value?: any) => void;
|
||||
}>
|
||||
> = {
|
||||
Boolean: function SwitchInput({ defaultValue, onChange }) {
|
||||
const handleSwitchChange = (checked: boolean) => {
|
||||
onChange(checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch
|
||||
defaultChecked={defaultValue}
|
||||
onCheckedChange={handleSwitchChange}
|
||||
/>
|
||||
);
|
||||
},
|
||||
String: function StringInput({ defaultValue, onChange }) {
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
minLength={1}
|
||||
defaultValue={defaultValue}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
);
|
||||
},
|
||||
Number: function NumberInput({ defaultValue, onChange }) {
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(parseInt(e.target.value));
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
defaultValue={defaultValue}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
);
|
||||
},
|
||||
JSON: function ObjectInput({ defaultValue, onChange }) {
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
try {
|
||||
const value = JSON.parse(e.target.value);
|
||||
onChange(value);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
defaultValue={JSON.stringify(defaultValue)}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ConfigInput = ({
|
||||
module,
|
||||
field,
|
||||
type,
|
||||
defaultValue,
|
||||
onChange,
|
||||
}: ConfigInputProps) => {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(value?: any) => {
|
||||
onChange(module, field, value);
|
||||
setValue(value);
|
||||
},
|
||||
[module, field, onChange]
|
||||
);
|
||||
|
||||
const Input = Inputs[type] ?? Inputs.JSON;
|
||||
|
||||
const isValueEqual = isEqual(value, defaultValue);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input defaultValue={defaultValue} onChange={onValueChange} />
|
||||
<div style={{ opacity: isValueEqual ? 0 : 1 }}>
|
||||
<span
|
||||
className="line-through"
|
||||
style={{
|
||||
color: 'rgba(198, 34, 34, 1)',
|
||||
backgroundColor: 'rgba(254, 213, 213, 1)',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(defaultValue)}
|
||||
</span>{' '}
|
||||
=>{' '}
|
||||
<span
|
||||
style={{
|
||||
color: 'rgba(20, 147, 67, 1)',
|
||||
backgroundColor: 'rgba(225, 250, 177, 1)',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(value)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
31
packages/frontend/admin/src/modules/settings/config.ts
Normal file
31
packages/frontend/admin/src/modules/settings/config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import CONFIG from '../../config.json';
|
||||
|
||||
export type ConfigDescriptor = {
|
||||
desc: string;
|
||||
type: 'String' | 'Number' | 'Boolean' | 'Array' | 'Object';
|
||||
env?: string;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
export type AppConfig = typeof CONFIG;
|
||||
export type AvailableConfig = {
|
||||
[K in keyof AppConfig]: {
|
||||
module: K;
|
||||
fields: Array<keyof AppConfig[K]>;
|
||||
};
|
||||
}[keyof AppConfig];
|
||||
|
||||
const IGNORED_MODULES: (keyof AppConfig)[] = [
|
||||
'db',
|
||||
'redis',
|
||||
'copilot', // not ready
|
||||
];
|
||||
|
||||
if (!environment.isSelfHosted) {
|
||||
IGNORED_MODULES.push('payment');
|
||||
}
|
||||
|
||||
export { CONFIG as ALL_CONFIG };
|
||||
export const ALL_CONFIGURABLE_MODULES = Object.keys(CONFIG).filter(
|
||||
key => !IGNORED_MODULES.includes(key as keyof AppConfig)
|
||||
) as (keyof AppConfig)[];
|
||||
@@ -7,22 +7,27 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
|
||||
import type { ModifiedValues } from './index';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export const ConfirmChanges = ({
|
||||
updates,
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
modifiedValues,
|
||||
onConfirm,
|
||||
}: {
|
||||
updates: Record<string, { from: any; to: any }>;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
modifiedValues: ModifiedValues[];
|
||||
onConfirm: () => void;
|
||||
}) => {
|
||||
const onClose = useCallback(() => {
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
|
||||
const modifiedKeys = Object.keys(updates).filter(
|
||||
key => updates[key].from !== updates[key].to
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:w-[460px]">
|
||||
@@ -34,12 +39,12 @@ export const ConfirmChanges = ({
|
||||
Are you sure you want to save the following changes?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{modifiedValues.length > 0 ? (
|
||||
{modifiedKeys.length > 0 ? (
|
||||
<pre className="flex flex-col text-sm bg-zinc-100 gap-1 min-h-[64px] rounded-md p-[12px_16px_16px_12px] mt-2 overflow-hidden">
|
||||
<p>{'{'}</p>
|
||||
{modifiedValues.map(({ id, expiredValue, newValue }) => (
|
||||
<p key={id}>
|
||||
{' '} {id}:{' '}
|
||||
{modifiedKeys.map(key => (
|
||||
<p key={key}>
|
||||
{' '} {key}:{' '}
|
||||
<span
|
||||
className="mr-2 line-through "
|
||||
style={{
|
||||
@@ -47,7 +52,7 @@ export const ConfirmChanges = ({
|
||||
backgroundColor: 'rgba(254, 213, 213, 1)',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(expiredValue)}
|
||||
{JSON.stringify(updates[key].from)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
@@ -55,7 +60,7 @@ export const ConfirmChanges = ({
|
||||
backgroundColor: 'rgba(225, 250, 177, 1)',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(newValue)}
|
||||
{JSON.stringify(updates[key].to)}
|
||||
</span>
|
||||
,
|
||||
</p>
|
||||
@@ -70,7 +75,11 @@ export const ConfirmChanges = ({
|
||||
<Button type="button" onClick={onClose} variant="outline">
|
||||
<span>Cancel</span>
|
||||
</Button>
|
||||
<Button type="button" onClick={onConfirm}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={modifiedKeys.length === 0}
|
||||
>
|
||||
<span>Save</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,75 +1,47 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import type { RuntimeConfigType } from '@affine/graphql';
|
||||
import { get } from 'lodash-es';
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { Header } from '../header';
|
||||
import { useNav } from '../nav/context';
|
||||
import {
|
||||
ALL_CONFIG,
|
||||
ALL_CONFIGURABLE_MODULES,
|
||||
type ConfigDescriptor,
|
||||
} from './config';
|
||||
import { ConfigInput } from './config-input';
|
||||
import { ConfirmChanges } from './confirm-changes';
|
||||
import { RuntimeSettingRow } from './runtime-setting-row';
|
||||
import { useGetServerRuntimeConfig } from './use-get-server-runtime-config';
|
||||
import { useUpdateServerRuntimeConfigs } from './use-update-server-runtime-config';
|
||||
import {
|
||||
formatValue,
|
||||
formatValueForInput,
|
||||
isEqual,
|
||||
renderInput,
|
||||
} from './utils';
|
||||
|
||||
export type ModifiedValues = {
|
||||
id: string;
|
||||
expiredValue: any;
|
||||
newValue: any;
|
||||
};
|
||||
import { useAppConfig } from './use-app-config';
|
||||
|
||||
export function SettingsPage() {
|
||||
const { trigger } = useUpdateServerRuntimeConfigs();
|
||||
const { serverRuntimeConfig } = useGetServerRuntimeConfig();
|
||||
const { appConfig, update, save, updates } = useAppConfig();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [configValues, setConfigValues] = useState(
|
||||
serverRuntimeConfig.reduce(
|
||||
(acc, config) => {
|
||||
acc[config.id] = config.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
)
|
||||
);
|
||||
const modifiedValues: ModifiedValues[] = useMemo(() => {
|
||||
return serverRuntimeConfig
|
||||
.filter(config => !isEqual(config.value, configValues[config.id]))
|
||||
.map(config => ({
|
||||
id: config.id,
|
||||
key: config.key,
|
||||
expiredValue: config.value,
|
||||
newValue: configValues[config.id],
|
||||
}));
|
||||
}, [configValues, serverRuntimeConfig]);
|
||||
const handleSave = useCallback(() => {
|
||||
// post value example: { "key1": "newValue1","key2": "newValue2"}
|
||||
const updates: Record<string, any> = {};
|
||||
|
||||
modifiedValues.forEach(item => {
|
||||
if (item.id && item.newValue !== undefined) {
|
||||
updates[item.id] = item.newValue;
|
||||
}
|
||||
});
|
||||
trigger({ updates });
|
||||
}, [modifiedValues, trigger]);
|
||||
|
||||
const disableSave = modifiedValues.length === 0;
|
||||
const onOpen = useCallback(() => setOpen(true), [setOpen]);
|
||||
const onClose = useCallback(() => setOpen(false), [setOpen]);
|
||||
const onConfirm = useCallback(() => {
|
||||
|
||||
const disableSave = Object.keys(updates).length === 0;
|
||||
|
||||
const saveChanges = useCallback(() => {
|
||||
if (disableSave) {
|
||||
return;
|
||||
}
|
||||
handleSave();
|
||||
onClose();
|
||||
}, [disableSave, handleSave, onClose]);
|
||||
save(
|
||||
Object.entries(updates).map(([key, { to }]) => {
|
||||
const splitAt = key.indexOf('.');
|
||||
const [module, field] = [key.slice(0, splitAt), key.slice(splitAt + 1)];
|
||||
return {
|
||||
module,
|
||||
key: field,
|
||||
value: to,
|
||||
};
|
||||
})
|
||||
);
|
||||
setOpen(false);
|
||||
}, [save, disableSave, updates]);
|
||||
|
||||
return (
|
||||
<div className=" h-screen flex-1 flex-col flex">
|
||||
<Header
|
||||
@@ -87,101 +59,68 @@ export function SettingsPage() {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<AdminPanel
|
||||
configValues={configValues}
|
||||
setConfigValues={setConfigValues}
|
||||
/>
|
||||
<AdminPanel onUpdate={update} appConfig={appConfig} />
|
||||
<ConfirmChanges
|
||||
modifiedValues={modifiedValues}
|
||||
updates={updates}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
onConfirm={saveChanges}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const AdminPanel = ({
|
||||
setConfigValues,
|
||||
configValues,
|
||||
appConfig,
|
||||
onUpdate,
|
||||
}: {
|
||||
setConfigValues: Dispatch<SetStateAction<Record<string, any>>>;
|
||||
configValues: Record<string, any>;
|
||||
appConfig: Record<string, any>;
|
||||
onUpdate: (module: string, field: string, value: any) => void;
|
||||
}) => {
|
||||
const { configGroup } = useGetServerRuntimeConfig();
|
||||
|
||||
const { currentModule } = useNav();
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(key: string, value: any, type: RuntimeConfigType) => {
|
||||
const newValue = formatValueForInput(value, type);
|
||||
setConfigValues(prevValues => ({
|
||||
...prevValues,
|
||||
[key]: newValue,
|
||||
}));
|
||||
},
|
||||
[setConfigValues]
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea>
|
||||
<div className="flex flex-col h-full gap-3 py-5 px-6 w-full max-w-[800px] mx-auto">
|
||||
{configGroup
|
||||
.filter(group => group.moduleName === currentModule)
|
||||
.map(group => {
|
||||
const { moduleName, configs } = group;
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-5"
|
||||
id={moduleName}
|
||||
key={moduleName}
|
||||
>
|
||||
<div className="text-xl font-semibold">{moduleName}</div>
|
||||
{configs?.map((config, index) => {
|
||||
const { id, type, description, updatedAt } = config;
|
||||
const isValueEqual = isEqual(config.value, configValues[id]);
|
||||
const formatServerValue = formatValue(config.value);
|
||||
const formatCurrentValue = formatValue(configValues[id]);
|
||||
return (
|
||||
<div key={id} className="flex flex-col gap-10">
|
||||
{index !== 0 && <Separator />}
|
||||
<RuntimeSettingRow
|
||||
key={id}
|
||||
id={id}
|
||||
description={description}
|
||||
lastUpdatedTime={updatedAt}
|
||||
operation={renderInput(type, configValues[id], value =>
|
||||
handleInputChange(id, value, type)
|
||||
)}
|
||||
>
|
||||
<div style={{ opacity: isValueEqual ? 0 : 1 }}>
|
||||
<span
|
||||
className="line-through"
|
||||
style={{
|
||||
color: 'rgba(198, 34, 34, 1)',
|
||||
backgroundColor: 'rgba(254, 213, 213, 1)',
|
||||
}}
|
||||
>
|
||||
{formatServerValue}
|
||||
</span>{' '}
|
||||
=>{' '}
|
||||
<span
|
||||
style={{
|
||||
color: 'rgba(20, 147, 67, 1)',
|
||||
backgroundColor: 'rgba(225, 250, 177, 1)',
|
||||
}}
|
||||
>
|
||||
{formatCurrentValue}
|
||||
</span>
|
||||
</div>
|
||||
</RuntimeSettingRow>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{ALL_CONFIGURABLE_MODULES.filter(
|
||||
module => module === currentModule
|
||||
).map(module => {
|
||||
const fields = Object.keys(ALL_CONFIG[module]);
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-5"
|
||||
id={`config-module-${module}`}
|
||||
key={module}
|
||||
>
|
||||
<div className="text-xl font-semibold">{module}</div>
|
||||
{fields.map((field, index) => {
|
||||
// @ts-expect-error allow
|
||||
const { desc, type } = ALL_CONFIG[module][
|
||||
field
|
||||
] as ConfigDescriptor;
|
||||
|
||||
return (
|
||||
<div key={field} className="flex flex-col gap-10">
|
||||
{index !== 0 && <Separator />}
|
||||
<RuntimeSettingRow
|
||||
key={field}
|
||||
id={field}
|
||||
description={desc}
|
||||
>
|
||||
<ConfigInput
|
||||
module={module}
|
||||
field={field}
|
||||
type={type}
|
||||
defaultValue={get(appConfig[module], field)}
|
||||
onChange={onUpdate}
|
||||
/>
|
||||
</RuntimeSettingRow>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -3,20 +3,15 @@ import { type ReactNode } from 'react';
|
||||
export const RuntimeSettingRow = ({
|
||||
id,
|
||||
description,
|
||||
lastUpdatedTime,
|
||||
operation,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
description: string;
|
||||
lastUpdatedTime: string;
|
||||
operation: ReactNode;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const formatTime = new Date(lastUpdatedTime).toLocaleString();
|
||||
return (
|
||||
<div
|
||||
className="flex justify-between flex-grow overflow-y-auto space-y-[10px] gap-5"
|
||||
className="flex justify-between flex-grow overflow-y-auto space-y-[10px] gap-5 "
|
||||
id={id}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -26,14 +21,8 @@ export const RuntimeSettingRow = ({
|
||||
{id}
|
||||
</code>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
last updated at: {formatTime}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 mr-1">
|
||||
{operation}
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 mr-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMutation } from '@affine/admin/use-mutation';
|
||||
import { useQuery } from '@affine/admin/use-query';
|
||||
import { notify } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import {
|
||||
appConfigQuery,
|
||||
type UpdateAppConfigInput,
|
||||
updateAppConfigMutation,
|
||||
} from '@affine/graphql';
|
||||
import { get, merge } from 'lodash-es';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export { type UpdateAppConfigInput };
|
||||
|
||||
export const useAppConfig = () => {
|
||||
const {
|
||||
data: { appConfig },
|
||||
mutate,
|
||||
} = useQuery({
|
||||
query: appConfigQuery,
|
||||
});
|
||||
|
||||
const { trigger } = useMutation({
|
||||
mutation: updateAppConfigMutation,
|
||||
});
|
||||
|
||||
const [updates, setUpdates] = useState<
|
||||
Record<string, { from: any; to: any }>
|
||||
>({});
|
||||
|
||||
const save = useAsyncCallback(
|
||||
async (updates: UpdateAppConfigInput[]) => {
|
||||
try {
|
||||
const savedUpdates = await trigger({
|
||||
updates,
|
||||
});
|
||||
await mutate({ appConfig: merge({}, appConfig, savedUpdates) });
|
||||
setUpdates({});
|
||||
notify.success({
|
||||
title: 'Saved successfully',
|
||||
message: 'Runtime configurations have been saved successfully.',
|
||||
});
|
||||
} catch (e) {
|
||||
const error = UserFriendlyError.fromAny(e);
|
||||
notify.error({
|
||||
title: 'Failed to save',
|
||||
message: error.message,
|
||||
});
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
[appConfig, mutate, trigger]
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
(module: string, field: string, value: any) => {
|
||||
setUpdates(prev => ({
|
||||
...prev,
|
||||
[`${module}.${field}`]: {
|
||||
from: get(appConfig, `${module}.${field}`),
|
||||
to: value,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[appConfig]
|
||||
);
|
||||
|
||||
return {
|
||||
appConfig,
|
||||
update,
|
||||
save,
|
||||
updates,
|
||||
};
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useQuery } from '@affine/admin/use-query';
|
||||
import { getServerRuntimeConfigQuery } from '@affine/graphql';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const useGetServerRuntimeConfig = () => {
|
||||
const { data } = useQuery({
|
||||
query: getServerRuntimeConfigQuery,
|
||||
});
|
||||
|
||||
const serverRuntimeConfig = useMemo(
|
||||
() =>
|
||||
data?.serverRuntimeConfig.sort((a, b) => a.id.localeCompare(b.id)) ?? [],
|
||||
[data]
|
||||
);
|
||||
|
||||
// collect all the modules and config keys in each module
|
||||
const moduleList = useMemo(() => {
|
||||
const moduleMap: { [key: string]: string[] } = {};
|
||||
|
||||
serverRuntimeConfig.forEach(config => {
|
||||
if (!moduleMap[config.module]) {
|
||||
moduleMap[config.module] = [];
|
||||
}
|
||||
moduleMap[config.module].push(config.key);
|
||||
});
|
||||
|
||||
return Object.keys(moduleMap)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map(moduleName => ({
|
||||
moduleName,
|
||||
keys: moduleMap[moduleName].sort((a, b) => a.localeCompare(b)),
|
||||
}));
|
||||
}, [serverRuntimeConfig]);
|
||||
|
||||
// group config by module name
|
||||
const configGroup = useMemo(() => {
|
||||
const configMap = new Map<string, typeof serverRuntimeConfig>();
|
||||
|
||||
serverRuntimeConfig.forEach(config => {
|
||||
if (!configMap.has(config.module)) {
|
||||
configMap.set(config.module, []);
|
||||
}
|
||||
configMap.get(config.module)?.push(config);
|
||||
});
|
||||
|
||||
return Array.from(configMap.entries()).map(([moduleName, configs]) => ({
|
||||
moduleName,
|
||||
configs,
|
||||
}));
|
||||
}, [serverRuntimeConfig]);
|
||||
|
||||
return {
|
||||
serverRuntimeConfig,
|
||||
moduleList,
|
||||
configGroup,
|
||||
};
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import {
|
||||
useMutateQueryResource,
|
||||
useMutation,
|
||||
} from '@affine/admin/use-mutation';
|
||||
import { notify } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import {
|
||||
getServerRuntimeConfigQuery,
|
||||
updateServerRuntimeConfigsMutation,
|
||||
} from '@affine/graphql';
|
||||
|
||||
export const useUpdateServerRuntimeConfigs = () => {
|
||||
const { trigger, isMutating } = useMutation({
|
||||
mutation: updateServerRuntimeConfigsMutation,
|
||||
});
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
return {
|
||||
trigger: useAsyncCallback(
|
||||
async (values: any) => {
|
||||
try {
|
||||
await trigger(values);
|
||||
await revalidate(getServerRuntimeConfigQuery);
|
||||
notify.success({
|
||||
title: 'Saved successfully',
|
||||
message: 'Runtime configurations have been saved successfully.',
|
||||
});
|
||||
} catch (e) {
|
||||
notify.error({
|
||||
title: 'Failed to save',
|
||||
message:
|
||||
'Failed to save runtime configurations, please try again later.',
|
||||
});
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
[revalidate, trigger]
|
||||
),
|
||||
isMutating,
|
||||
};
|
||||
};
|
||||
@@ -1,73 +1,5 @@
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { Switch } from '@affine/admin/components/ui/switch';
|
||||
import type { RuntimeConfigType } from '@affine/graphql';
|
||||
|
||||
export const renderInput = (
|
||||
type: RuntimeConfigType,
|
||||
value: any,
|
||||
onChange: (value?: any) => void
|
||||
) => {
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
const handleSwitchChange = (checked: boolean) => {
|
||||
onChange(checked);
|
||||
};
|
||||
switch (type) {
|
||||
case 'Boolean':
|
||||
return <Switch checked={value} onCheckedChange={handleSwitchChange} />;
|
||||
case 'String':
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
minLength={1}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
);
|
||||
case 'Number':
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<Input type="number" value={value} onChange={handleInputChange} />
|
||||
</div>
|
||||
);
|
||||
// TODO(@JimmFly): add more types
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const isEqual = (a: any, b: any) => {
|
||||
if (typeof a !== typeof b) return false;
|
||||
if (typeof a === 'object') return JSON.stringify(a) === JSON.stringify(b);
|
||||
return a === b;
|
||||
};
|
||||
|
||||
export const formatValue = (value: any) => {
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
export const formatValueForInput = (value: any, type: RuntimeConfigType) => {
|
||||
let newValue = null;
|
||||
switch (type) {
|
||||
case 'Boolean':
|
||||
newValue = !!value;
|
||||
break;
|
||||
case 'String':
|
||||
newValue = value;
|
||||
break;
|
||||
case 'Number':
|
||||
newValue = Number(value);
|
||||
break;
|
||||
case 'Array':
|
||||
newValue = value.split(',');
|
||||
break;
|
||||
case 'Object':
|
||||
newValue = JSON.parse(value);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return newValue;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user