refactor(server): config system (#11081)

This commit is contained in:
forehalo
2025-03-27 12:32:28 +00:00
parent 7091111f85
commit 0ea38680fa
274 changed files with 7583 additions and 5841 deletions
@@ -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>{' '}
=&gt;{' '}
<span
style={{
color: 'rgba(20, 147, 67, 1)',
backgroundColor: 'rgba(225, 250, 177, 1)',
}}
>
{JSON.stringify(value)}
</span>
</div>
</div>
);
};
@@ -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>{' '}
=&gt;{' '}
<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;
};