feat(core): add admin panel page (#7115)

The path is `/admin-panel`
This commit is contained in:
JimmFly 2024-05-31 09:42:22 +00:00
parent 004390f40c
commit b13151b480
No known key found for this signature in database
GPG Key ID: 14A6F56854E1BED7
15 changed files with 821 additions and 0 deletions

View File

@ -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)} =&gt;{' '}
{formatValue(newValue)}
</div>
)
)
: 'There is no change.'}
</div>
),
});
}}
>
Save
</Button>
</div>
</div>
);
};

View File

@ -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>
);
};

View 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'),
});

View File

@ -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';

View File

@ -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>
);
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View 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;
};

View 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')}`,
});

View 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} =&gt; {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 />;
};

View File

@ -48,6 +48,10 @@ export const topLevelRoutes = [
path: '/404',
lazy: () => import('./pages/404'),
},
{
path: '/admin-panel',
lazy: () => import('./pages/admin-panel'),
},
{
path: '/auth/:authType',
lazy: () => import('./pages/auth'),

View File

@ -0,0 +1,11 @@
query getServerRuntimeConfig {
serverRuntimeConfig {
id
module
key
description
value
type
updatedAt
}
}

View File

@ -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 = {
id: 'getUserFeaturesQuery' as const,
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 = {
id: 'updateSubscriptionMutation' as const,
operationName: 'updateSubscription',

View File

@ -0,0 +1,6 @@
mutation updateServerRuntimeConfigs($updates: JSONObject!) {
updateRuntimeConfigs(updates: $updates) {
key
value
}
}

View File

@ -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 GetUserFeaturesQuery = {
@ -916,6 +934,19 @@ export type SubscriptionQuery = {
} | 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<{
idempotencyKey: Scalars['String']['input'];
plan?: InputMaybe<SubscriptionPlan>;
@ -1139,6 +1170,11 @@ export type Queries =
variables: OauthProvidersQueryVariables;
response: OauthProvidersQuery;
}
| {
name: 'getServerRuntimeConfigQuery';
variables: GetServerRuntimeConfigQueryVariables;
response: GetServerRuntimeConfigQuery;
}
| {
name: 'getUserFeaturesQuery';
variables: GetUserFeaturesQueryVariables;
@ -1371,6 +1407,11 @@ export type Mutations =
variables: SetWorkspacePublicByIdMutationVariables;
response: SetWorkspacePublicByIdMutation;
}
| {
name: 'updateServerRuntimeConfigsMutation';
variables: UpdateServerRuntimeConfigsMutationVariables;
response: UpdateServerRuntimeConfigsMutation;
}
| {
name: 'updateSubscriptionMutation';
variables: UpdateSubscriptionMutationVariables;