@@ -0,0 +1,71 @@
|
|||||||
|
import { Button, useConfirmModal } from '@affine/component';
|
||||||
|
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
|
||||||
|
import { ArrowRightBigIcon, Logo1Icon } from '@blocksuite/icons';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './index.css';
|
||||||
|
import { formatValue } from './utils';
|
||||||
|
|
||||||
|
export type ModifiedValues = {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
expiredValue: any;
|
||||||
|
newValue: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminPanelHeader = ({
|
||||||
|
modifiedValues,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
modifiedValues: ModifiedValues[];
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) => {
|
||||||
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
const { jumpToIndex } = useNavigateHelper();
|
||||||
|
|
||||||
|
const handleJumpToIndex = useCallback(() => jumpToIndex(), [jumpToIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Logo1Icon className={styles.logo} onClick={handleJumpToIndex} />
|
||||||
|
<div className={styles.title}>
|
||||||
|
<span>After editing, please click the Save button on the right.</span>
|
||||||
|
<ArrowRightBigIcon />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
disabled={modifiedValues.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
openConfirmModal({
|
||||||
|
title: 'Save Runtime Configurations ?',
|
||||||
|
description:
|
||||||
|
'Are you sure you want to save the following changes?',
|
||||||
|
confirmButtonOptions: {
|
||||||
|
children: 'Save',
|
||||||
|
type: 'primary',
|
||||||
|
},
|
||||||
|
onConfirm: onConfirm,
|
||||||
|
children: (
|
||||||
|
<div className={styles.changedValues}>
|
||||||
|
{modifiedValues.length > 0
|
||||||
|
? modifiedValues.map(
|
||||||
|
({ id, key, expiredValue, newValue }) => (
|
||||||
|
<div key={id}>
|
||||||
|
{key}: {formatValue(expiredValue)} =>{' '}
|
||||||
|
{formatValue(newValue)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: 'There is no change.'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { ArrowDownSmallIcon } from '@blocksuite/icons';
|
||||||
|
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './index.css';
|
||||||
|
|
||||||
|
export const CollapsibleItem = ({
|
||||||
|
items,
|
||||||
|
initialOpen = false,
|
||||||
|
title,
|
||||||
|
currentModule,
|
||||||
|
changeModule,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
items: string[];
|
||||||
|
initialOpen?: boolean;
|
||||||
|
currentModule?: string;
|
||||||
|
changeModule?: (module: string) => void;
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = useState(initialOpen);
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(id: string, event?: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||||
|
event?.preventDefault();
|
||||||
|
const targetElement = document.getElementById(id);
|
||||||
|
if (targetElement) {
|
||||||
|
targetElement.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
changeModule?.(title);
|
||||||
|
},
|
||||||
|
[changeModule, title]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible.Root
|
||||||
|
className={styles.outLine}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.outLineHeader}
|
||||||
|
data-active={title === currentModule}
|
||||||
|
>
|
||||||
|
<Collapsible.Trigger className={styles.arrowIcon} data-open={open}>
|
||||||
|
<ArrowDownSmallIcon />
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
<a
|
||||||
|
className={styles.navText}
|
||||||
|
href={`#${title}`}
|
||||||
|
onClick={e => handleClick(title, e)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.collapsibleContainer}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Collapsible.Content
|
||||||
|
className={styles.outLineContent}
|
||||||
|
key={index}
|
||||||
|
onClick={() => handleClick(item)}
|
||||||
|
>
|
||||||
|
<a className={styles.navText} href={`#${item}`}>
|
||||||
|
{item}
|
||||||
|
</a>
|
||||||
|
</Collapsible.Content>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Collapsible.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
157
packages/frontend/core/src/components/admin-panel/index.css.ts
Normal file
157
packages/frontend/core/src/components/admin-panel/index.css.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const header = style({
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: `1px solid ${cssVar('borderColor')}`,
|
||||||
|
gap: 8,
|
||||||
|
position: 'sticky',
|
||||||
|
backgroundColor: cssVar('backgroundPrimaryColor'),
|
||||||
|
zIndex: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const logo = style({
|
||||||
|
fontSize: 32,
|
||||||
|
cursor: 'pointer',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const title = style({
|
||||||
|
fontSize: cssVar('fontH3'),
|
||||||
|
fontWeight: 'bold',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
textAlign: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const outLine = style({
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const outLineHeader = style({
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
cursor: 'pointer',
|
||||||
|
alignItems: 'center',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
fontSize: cssVar('fontBase'),
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: cssVar('hoverColor'),
|
||||||
|
},
|
||||||
|
selectors: {
|
||||||
|
'&[data-active=true]': {
|
||||||
|
background: cssVar('hoverColor'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: 4,
|
||||||
|
marginBottom: 4,
|
||||||
|
':visited': {
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const arrowIcon = style({
|
||||||
|
transform: 'rotate(-90deg)',
|
||||||
|
selectors: {
|
||||||
|
'&[data-open=true]': {
|
||||||
|
transform: 'rotate(0deg) translateY(3px) translateX(-3px)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transition: 'transform 0.2s',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collapsibleContainer = style({
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
flexDirection: 'column',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const outLineContent = style({
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '4px 18px',
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: cssVar('hoverColor'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const navText = style({
|
||||||
|
width: '100%',
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
padding: '4px 0px',
|
||||||
|
':visited': {
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const settingItem = style({
|
||||||
|
maxWidth: '800px',
|
||||||
|
minHeight: '100px',
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
padding: '16px 0',
|
||||||
|
borderBottom: `0.5px solid ${cssVar('borderColor')}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const LeftItem = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
width: '60%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RightItem = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
width: '40%',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const settingItemTitle = style({
|
||||||
|
fontSize: cssVar('fontBase'),
|
||||||
|
fontWeight: 'bold',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
marginBottom: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const settingItemDescription = style({
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
color: cssVar('textSecondaryColor'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const changedValues = style({
|
||||||
|
background: cssVar('backgroundSecondaryColor'),
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
minHeight: '64px',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '12px 16px 16px 12px',
|
||||||
|
marginTop: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './admin-panel-header';
|
||||||
|
export * from './collapsible-item';
|
||||||
|
export * from './runtime-setting-row';
|
||||||
|
export * from './use-get-server-runtime-config';
|
||||||
|
export * from './utils';
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './index.css';
|
||||||
|
|
||||||
|
export const RuntimeSettingRow = ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
lastUpdatedTime,
|
||||||
|
operation,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
lastUpdatedTime: string;
|
||||||
|
operation: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) => {
|
||||||
|
const formatTime = new Date(lastUpdatedTime).toLocaleString();
|
||||||
|
return (
|
||||||
|
<div id={id} className={styles.settingItem}>
|
||||||
|
<div className={styles.LeftItem}>
|
||||||
|
<div className={styles.settingItemTitle}>{title}</div>
|
||||||
|
<div className={styles.settingItemDescription}>{description}</div>
|
||||||
|
<div className={styles.settingItemDescription}>
|
||||||
|
last updated at: {formatTime}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.RightItem}>
|
||||||
|
{operation}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useQuery } from '@affine/core/hooks/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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { notify } from '@affine/component';
|
||||||
|
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
|
import {
|
||||||
|
useMutateQueryResource,
|
||||||
|
useMutation,
|
||||||
|
} from '@affine/core/hooks/use-mutation';
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
61
packages/frontend/core/src/components/admin-panel/utils.tsx
Normal file
61
packages/frontend/core/src/components/admin-panel/utils.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Input, Switch } from '@affine/component';
|
||||||
|
import type { RuntimeConfigType } from '@affine/graphql';
|
||||||
|
|
||||||
|
export const renderInput = (
|
||||||
|
type: RuntimeConfigType,
|
||||||
|
value: any,
|
||||||
|
onChange: (value?: any) => void
|
||||||
|
) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'Boolean':
|
||||||
|
return <Switch checked={value} onChange={onChange} />;
|
||||||
|
case 'String':
|
||||||
|
return (
|
||||||
|
<Input type="text" minLength={1} value={value} onChange={onChange} />
|
||||||
|
);
|
||||||
|
case 'Number':
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<Input type="number" value={value} onChange={onChange} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
//TODO: 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;
|
||||||
|
};
|
||||||
59
packages/frontend/core/src/pages/admin-panel.css.ts
Normal file
59
packages/frontend/core/src/pages/admin-panel.css.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
export const root = style({
|
||||||
|
height: '100vh',
|
||||||
|
width: '100vw',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
fontSize: cssVar('fontBase'),
|
||||||
|
position: 'relative',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const container = style({
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sideBar = style({
|
||||||
|
width: '300px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
borderRight: `1px solid ${cssVar('borderColor')}`,
|
||||||
|
padding: '12px 8px',
|
||||||
|
height: '100%',
|
||||||
|
background: cssVar('backgroundPrimaryColor'),
|
||||||
|
zIndex: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const scrollArea = style({
|
||||||
|
padding: '24px 0 160px',
|
||||||
|
background: cssVar('backgroundPrimaryColor'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const main = style({
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'auto',
|
||||||
|
alignItems: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const moduleContainer = style({
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: '16px',
|
||||||
|
maxWidth: '800px',
|
||||||
|
margin: 'auto',
|
||||||
|
gap: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const module = style({
|
||||||
|
fontSize: cssVar('fontH5'),
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 8,
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
padding: '16px 0',
|
||||||
|
borderBottom: `0.5px solid ${cssVar('borderColor')}`,
|
||||||
|
});
|
||||||
165
packages/frontend/core/src/pages/admin-panel.tsx
Normal file
165
packages/frontend/core/src/pages/admin-panel.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { Scrollable } from '@affine/component';
|
||||||
|
import type { RuntimeConfigType } from '@affine/graphql';
|
||||||
|
import { FeatureType, getUserFeaturesQuery } from '@affine/graphql';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AdminPanelHeader,
|
||||||
|
CollapsibleItem,
|
||||||
|
formatValue,
|
||||||
|
formatValueForInput,
|
||||||
|
isEqual,
|
||||||
|
type ModifiedValues,
|
||||||
|
renderInput,
|
||||||
|
RuntimeSettingRow,
|
||||||
|
useGetServerRuntimeConfig,
|
||||||
|
} from '../components/admin-panel';
|
||||||
|
import { useUpdateServerRuntimeConfigs } from '../components/admin-panel/use-update-server-runtime-config';
|
||||||
|
import { useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||||
|
import { useQuery } from '../hooks/use-query';
|
||||||
|
import * as styles from './admin-panel.css';
|
||||||
|
|
||||||
|
export const AdminPanel = () => {
|
||||||
|
const { serverRuntimeConfig, moduleList, configGroup } =
|
||||||
|
useGetServerRuntimeConfig();
|
||||||
|
|
||||||
|
const [currentModule, setCurrentModule] = useState<string>(
|
||||||
|
moduleList[0].moduleName
|
||||||
|
);
|
||||||
|
|
||||||
|
const { trigger } = useUpdateServerRuntimeConfigs();
|
||||||
|
|
||||||
|
const [configValues, setConfigValues] = useState(
|
||||||
|
serverRuntimeConfig.reduce(
|
||||||
|
(acc, config) => {
|
||||||
|
acc[config.id] = config.value;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, any>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInputChange = useCallback(
|
||||||
|
(key: string, value: any, type: RuntimeConfigType) => {
|
||||||
|
const newValue = formatValueForInput(value, type);
|
||||||
|
setConfigValues(prevValues => ({
|
||||||
|
...prevValues,
|
||||||
|
[key]: newValue,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root}>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.sideBar}>
|
||||||
|
{moduleList.map(module => (
|
||||||
|
<CollapsibleItem
|
||||||
|
key={module.moduleName}
|
||||||
|
items={module.keys}
|
||||||
|
title={module.moduleName}
|
||||||
|
currentModule={currentModule}
|
||||||
|
changeModule={setCurrentModule}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.main}>
|
||||||
|
<AdminPanelHeader
|
||||||
|
modifiedValues={modifiedValues}
|
||||||
|
onConfirm={handleSave}
|
||||||
|
/>
|
||||||
|
<Scrollable.Root>
|
||||||
|
<Scrollable.Viewport>
|
||||||
|
<div className={styles.scrollArea}>
|
||||||
|
{configGroup
|
||||||
|
.filter(group => group.moduleName === currentModule)
|
||||||
|
.map(group => {
|
||||||
|
const { moduleName, configs } = group;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={moduleName}
|
||||||
|
className={styles.moduleContainer}
|
||||||
|
key={moduleName}
|
||||||
|
>
|
||||||
|
<div className={styles.module}>{moduleName}</div>
|
||||||
|
{configs?.map(config => {
|
||||||
|
const { id, key, type, description, updatedAt } =
|
||||||
|
config;
|
||||||
|
const title = `${key} (${id})`;
|
||||||
|
const isValueEqual = isEqual(
|
||||||
|
config.value,
|
||||||
|
configValues[id]
|
||||||
|
);
|
||||||
|
const formatServerValue = formatValue(config.value);
|
||||||
|
const formatCurrentValue = formatValue(
|
||||||
|
configValues[id]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<RuntimeSettingRow
|
||||||
|
key={id}
|
||||||
|
id={key}
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
lastUpdatedTime={updatedAt}
|
||||||
|
operation={renderInput(
|
||||||
|
type,
|
||||||
|
configValues[id],
|
||||||
|
value => handleInputChange(id, value, type)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div style={{ opacity: isValueEqual ? 0 : 1 }}>
|
||||||
|
{formatServerValue} => {formatCurrentValue}
|
||||||
|
</div>
|
||||||
|
</RuntimeSettingRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Scrollable.Viewport>
|
||||||
|
<Scrollable.Scrollbar />
|
||||||
|
</Scrollable.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Component = () => {
|
||||||
|
const { data } = useQuery({
|
||||||
|
query: getUserFeaturesQuery,
|
||||||
|
});
|
||||||
|
const { jumpTo404 } = useNavigateHelper();
|
||||||
|
const userFeatures = data?.currentUser?.features;
|
||||||
|
if (!userFeatures || !userFeatures.includes(FeatureType.Admin)) {
|
||||||
|
jumpTo404();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AdminPanel />;
|
||||||
|
};
|
||||||
@@ -48,6 +48,10 @@ export const topLevelRoutes = [
|
|||||||
path: '/404',
|
path: '/404',
|
||||||
lazy: () => import('./pages/404'),
|
lazy: () => import('./pages/404'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin-panel',
|
||||||
|
lazy: () => import('./pages/admin-panel'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/auth/:authType',
|
path: '/auth/:authType',
|
||||||
lazy: () => import('./pages/auth'),
|
lazy: () => import('./pages/auth'),
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
query getServerRuntimeConfig {
|
||||||
|
serverRuntimeConfig {
|
||||||
|
id
|
||||||
|
module
|
||||||
|
key
|
||||||
|
description
|
||||||
|
value
|
||||||
|
type
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -385,6 +385,25 @@ query oauthProviders {
|
|||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getServerRuntimeConfigQuery = {
|
||||||
|
id: 'getServerRuntimeConfigQuery' as const,
|
||||||
|
operationName: 'getServerRuntimeConfig',
|
||||||
|
definitionName: 'serverRuntimeConfig',
|
||||||
|
containsFile: false,
|
||||||
|
query: `
|
||||||
|
query getServerRuntimeConfig {
|
||||||
|
serverRuntimeConfig {
|
||||||
|
id
|
||||||
|
module
|
||||||
|
key
|
||||||
|
description
|
||||||
|
value
|
||||||
|
type
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
export const getUserFeaturesQuery = {
|
export const getUserFeaturesQuery = {
|
||||||
id: 'getUserFeaturesQuery' as const,
|
id: 'getUserFeaturesQuery' as const,
|
||||||
operationName: 'getUserFeatures',
|
operationName: 'getUserFeatures',
|
||||||
@@ -818,6 +837,20 @@ query subscription {
|
|||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateServerRuntimeConfigsMutation = {
|
||||||
|
id: 'updateServerRuntimeConfigsMutation' as const,
|
||||||
|
operationName: 'updateServerRuntimeConfigs',
|
||||||
|
definitionName: 'updateRuntimeConfigs',
|
||||||
|
containsFile: false,
|
||||||
|
query: `
|
||||||
|
mutation updateServerRuntimeConfigs($updates: JSONObject!) {
|
||||||
|
updateRuntimeConfigs(updates: $updates) {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
export const updateSubscriptionMutation = {
|
export const updateSubscriptionMutation = {
|
||||||
id: 'updateSubscriptionMutation' as const,
|
id: 'updateSubscriptionMutation' as const,
|
||||||
operationName: 'updateSubscription',
|
operationName: 'updateSubscription',
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
mutation updateServerRuntimeConfigs($updates: JSONObject!) {
|
||||||
|
updateRuntimeConfigs(updates: $updates) {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -533,6 +533,24 @@ export type OauthProvidersQuery = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GetServerRuntimeConfigQueryVariables = Exact<{
|
||||||
|
[key: string]: never;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type GetServerRuntimeConfigQuery = {
|
||||||
|
__typename?: 'Query';
|
||||||
|
serverRuntimeConfig: Array<{
|
||||||
|
__typename?: 'ServerRuntimeConfigType';
|
||||||
|
id: string;
|
||||||
|
module: string;
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
value: Record<string, string>;
|
||||||
|
type: RuntimeConfigType;
|
||||||
|
updatedAt: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export type GetUserFeaturesQueryVariables = Exact<{ [key: string]: never }>;
|
export type GetUserFeaturesQueryVariables = Exact<{ [key: string]: never }>;
|
||||||
|
|
||||||
export type GetUserFeaturesQuery = {
|
export type GetUserFeaturesQuery = {
|
||||||
@@ -916,6 +934,19 @@ export type SubscriptionQuery = {
|
|||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpdateServerRuntimeConfigsMutationVariables = Exact<{
|
||||||
|
updates: Scalars['JSONObject']['input'];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type UpdateServerRuntimeConfigsMutation = {
|
||||||
|
__typename?: 'Mutation';
|
||||||
|
updateRuntimeConfigs: Array<{
|
||||||
|
__typename?: 'ServerRuntimeConfigType';
|
||||||
|
key: string;
|
||||||
|
value: Record<string, string>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export type UpdateSubscriptionMutationVariables = Exact<{
|
export type UpdateSubscriptionMutationVariables = Exact<{
|
||||||
idempotencyKey: Scalars['String']['input'];
|
idempotencyKey: Scalars['String']['input'];
|
||||||
plan?: InputMaybe<SubscriptionPlan>;
|
plan?: InputMaybe<SubscriptionPlan>;
|
||||||
@@ -1139,6 +1170,11 @@ export type Queries =
|
|||||||
variables: OauthProvidersQueryVariables;
|
variables: OauthProvidersQueryVariables;
|
||||||
response: OauthProvidersQuery;
|
response: OauthProvidersQuery;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
name: 'getServerRuntimeConfigQuery';
|
||||||
|
variables: GetServerRuntimeConfigQueryVariables;
|
||||||
|
response: GetServerRuntimeConfigQuery;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
name: 'getUserFeaturesQuery';
|
name: 'getUserFeaturesQuery';
|
||||||
variables: GetUserFeaturesQueryVariables;
|
variables: GetUserFeaturesQueryVariables;
|
||||||
@@ -1371,6 +1407,11 @@ export type Mutations =
|
|||||||
variables: SetWorkspacePublicByIdMutationVariables;
|
variables: SetWorkspacePublicByIdMutationVariables;
|
||||||
response: SetWorkspacePublicByIdMutation;
|
response: SetWorkspacePublicByIdMutation;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
name: 'updateServerRuntimeConfigsMutation';
|
||||||
|
variables: UpdateServerRuntimeConfigsMutationVariables;
|
||||||
|
response: UpdateServerRuntimeConfigsMutation;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
name: 'updateSubscriptionMutation';
|
name: 'updateSubscriptionMutation';
|
||||||
variables: UpdateSubscriptionMutationVariables;
|
variables: UpdateSubscriptionMutationVariables;
|
||||||
|
|||||||
Reference in New Issue
Block a user