feat(admin): add server runtime config settings (#7618)

This commit is contained in:
JimmFly 2024-08-13 14:51:31 +08:00 committed by GitHub
parent 7f7c0519a0
commit bf6e36de37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 762 additions and 223 deletions

View File

@ -59,6 +59,10 @@ export const router = _createBrowserRouter(
path: '/admin/config',
lazy: () => import('./modules/config'),
},
{
path: '/admin/settings',
lazy: () => import('./modules/settings'),
},
],
},
],

View File

@ -55,7 +55,7 @@ export function CreateUserPanel() {
return (
<div className="flex flex-col h-full gap-1">
<div className="flex justify-between items-center py-[10px] px-6">
<div className="flex-grow-0 flex-shrink-0 h-[56px] flex justify-between items-center py-[10px] px-6">
<Button
type="button"
size="icon"

View File

@ -61,7 +61,7 @@ export function DataTable<TData, TValue>({
}, [data]);
return (
<div className="space-y-4 py-5 px-6 h-full">
<div className="flex flex-col gap-4 py-5 px-6 h-full">
<DataTableToolbar setDataTable={setTableData} data={data} />
<ScrollArea className="rounded-md border max-h-[75vh] h-full">
<Table>

View File

@ -71,7 +71,7 @@ export function EditPanel({
return (
<div className="flex flex-col h-full gap-1">
<div className="flex justify-between items-center py-[10px] px-6">
<div className="flex-grow-0 flex-shrink-0 h-[56px] flex justify-between items-center py-[10px] px-6 ">
<Button
type="button"
size="icon"

View File

@ -29,8 +29,8 @@ export function AccountPage() {
});
return (
<div className=" h-screen flex-1 space-y-1 flex-col flex">
<div className="flex items-center justify-between px-6 py-3 max-md:ml-9">
<div className=" h-screen flex-1 flex-col flex">
<div className="flex items-center justify-between px-6 py-3 max-md:ml-9 max-md:mt-[2px]">
<div className="text-base font-medium">Accounts</div>
</div>
<Separator />

View File

@ -6,20 +6,13 @@ import {
import { Separator } from '@affine/admin/components/ui/separator';
import { TooltipProvider } from '@affine/admin/components/ui/tooltip';
import { cn } from '@affine/admin/utils';
import {
AlignJustifyIcon,
ClipboardList,
Cpu,
Settings,
Users,
} from 'lucide-react';
import { AlignJustifyIcon } from 'lucide-react';
import type { ReactNode, RefObject } from 'react';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@ -35,7 +28,7 @@ import {
SheetTrigger,
} from '../components/ui/sheet';
import { Logo } from './accounts/components/logo';
import type { NavProp } from './nav/nav';
import { NavContext } from './nav/context';
import { Nav } from './nav/nav';
interface LayoutProps {
@ -50,33 +43,11 @@ interface RightPanelContextType {
openPanel: () => void;
closePanel: () => void;
}
const RightPanelContext = createContext<RightPanelContextType | undefined>(
undefined
);
const navLinks: NavProp[] = [
{
title: 'Accounts',
icon: Users,
to: '/admin/accounts',
},
{
title: 'AI',
icon: Cpu,
to: '/admin/ai',
},
{
title: 'Config',
icon: ClipboardList,
to: '/admin/config',
},
{
title: 'Settings',
icon: Settings,
to: '/admin/settings',
},
];
export const useRightPanel = () => {
const context = useContext(RightPanelContext);
@ -110,6 +81,10 @@ export function Layout({ content }: LayoutProps) {
const [open, setOpen] = useState(false);
const rightPanelRef = useRef<ImperativePanelHandle>(null);
const [activeTab, setActiveTab] = useState('Accounts');
const [activeSubTab, setActiveSubTab] = useState('auth');
const [currentModule, setCurrentModule] = useState('auth');
const handleExpand = useCallback(() => {
if (rightPanelRef.current?.getSize() === 0) {
rightPanelRef.current?.resize(30);
@ -139,15 +114,6 @@ export function Layout({ content }: LayoutProps) {
[closePanel, openPanel]
);
const activeTab = useMemo(() => {
const path = window.location.pathname;
return (
navLinks.find(link => path.endsWith(link.title.toLocaleLowerCase()))
?.title || ''
);
}, []);
return (
<RightPanelContext.Provider
value={{
@ -158,10 +124,20 @@ export function Layout({ content }: LayoutProps) {
openPanel,
closePanel,
}}
>
<NavContext.Provider
value={{
activeTab,
activeSubTab,
currentModule,
setActiveTab,
setActiveSubTab,
setCurrentModule,
}}
>
<TooltipProvider delayDuration={0}>
<div className="flex">
<LeftPanel activeTab={activeTab} />
<LeftPanel />
<ResizablePanelGroup direction="horizontal">
<ResizablePanel id="0" order={0} minSize={50}>
{content}
@ -174,22 +150,20 @@ export function Layout({ content }: LayoutProps) {
</ResizablePanelGroup>
</div>
</TooltipProvider>
</NavContext.Provider>
</RightPanelContext.Provider>
);
}
export const LeftPanel = ({ activeTab }: { activeTab: string }) => {
export const LeftPanel = () => {
const isSmallScreen = useMediaQuery('(max-width: 768px)');
if (isSmallScreen) {
return (
<Sheet>
<SheetTrigger asChild>
<Button
variant="ghost"
className="fixed top-[14px] left-6 p-0 h-5 w-5"
>
<AlignJustifyIcon />
<Button variant="ghost" className="fixed top-4 left-6 p-0 h-5 w-5">
<AlignJustifyIcon size={20} />
</Button>
</SheetTrigger>
<SheetHeader className="hidden">
@ -209,7 +183,7 @@ export const LeftPanel = ({ activeTab }: { activeTab: string }) => {
AFFiNE
</div>
<Separator />
<Nav links={navLinks} activeTab={activeTab} />
<Nav />
</div>
</SheetContent>
</Sheet>
@ -227,7 +201,7 @@ export const LeftPanel = ({ activeTab }: { activeTab: string }) => {
AFFiNE
</div>
<Separator />
<Nav links={navLinks} activeTab={activeTab} />
<Nav />
</div>
);
};

View File

@ -0,0 +1,67 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@affine/admin/components/ui/accordion';
import { useCallback } from 'react';
import { Link } from 'react-router-dom';
import { useNav } from './context';
export const CollapsibleItem = ({
items,
title,
changeModule,
}: {
title: string;
items: string[];
changeModule?: (module: string) => void;
}) => {
const { activeSubTab, setActiveSubTab } = useNav();
const handleClick = useCallback(
(id: string) => {
const targetElement = document.getElementById(id);
if (targetElement) {
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
changeModule?.(title);
setActiveSubTab(id);
},
[changeModule, setActiveSubTab, title]
);
return (
<Accordion type="multiple" className="w-full ">
<AccordionItem value="item-1" className="border-b-0">
<Link to={`/admin/settings#${title}`}>
<AccordionTrigger
onClick={() => handleClick(title)}
className={`py-2 px-3 rounded ${activeSubTab === title ? 'bg-zinc-100' : ''}`}
>
{title}
</AccordionTrigger>
</Link>
<AccordionContent className=" flex flex-col gap-2">
{items.map((item, index) => (
<Link
key={index}
to={`/admin/settings#${item}`}
className="px-3 overflow-hidden"
>
<AccordionContent
onClick={() => handleClick(item)}
className={`py-1 px-2 rounded text-ellipsis whitespace-nowrap overflow-hidden ${activeSubTab === item ? 'bg-zinc-100' : ''}`}
>
{item}
</AccordionContent>
</Link>
))}
</AccordionContent>
</AccordionItem>
</Accordion>
);
};

View File

@ -0,0 +1,21 @@
import { createContext, useContext } from 'react';
interface NavContextType {
activeTab: string;
activeSubTab: string;
currentModule: string;
setActiveTab: (tab: string) => void;
setActiveSubTab: (tab: string) => void;
setCurrentModule: (module: string) => void;
}
export const NavContext = createContext<NavContextType | undefined>(undefined);
export const useNav = () => {
const context = useContext(NavContext);
if (!context) {
throw new Error('useNav must be used within a NavProvider');
}
return context;
};

View File

@ -1,113 +0,0 @@
import { Button } from '@affine/admin/components/ui/button';
import {
Sheet,
SheetContent,
SheetTrigger,
} from '@affine/admin/components/ui/sheet';
import { Menu, Package2 } from 'lucide-react';
import type { PropsWithChildren } from 'react';
import { Link } from 'react-router-dom';
import { UserDropdown } from './user-dropdown';
export function Nav({ children }: PropsWithChildren<unknown>) {
return (
<div className="flex min-h-screen w-full flex-col">
<header className="sticky top-0 flex h-16 items-center gap-4 border-b bg-background px-4 md:px-6">
<nav className="hidden flex-col gap-6 text-lg font-medium md:flex md:flex-row md:items-center md:gap-5 md:text-sm lg:gap-6">
<Link
to="/"
className="flex items-center gap-2 text-lg font-semibold md:text-base"
>
<Package2 className="h-6 w-6" />
<span className="sr-only">AFFiNE</span>
</Link>
<Link
to="/"
className="text-foreground transition-colors hover:text-foreground"
>
Dashboard
</Link>
<Link
to="/admin/users"
className="text-muted-foreground transition-colors hover:text-foreground"
>
Users
</Link>
<Link
to="/"
className="text-muted-foreground transition-colors hover:text-foreground"
>
Configs
</Link>
<Link
to="/"
className="text-muted-foreground transition-colors hover:text-foreground"
>
Backups
</Link>
<Link
to="/"
className="text-muted-foreground transition-colors hover:text-foreground"
>
Analytics
</Link>
</nav>
<Sheet>
<SheetTrigger asChild>
<Button
variant="outline"
size="icon"
className="shrink-0 md:hidden"
>
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left">
<nav className="grid gap-6 text-lg font-medium">
<Link
to="/"
className="flex items-center gap-2 text-lg font-semibold"
>
<Package2 className="h-6 w-6" />
<span className="sr-only">Acme Inc</span>
</Link>
<Link to="/" className="hover:text-foreground">
Dashboard
</Link>
<Link
to="/"
className="text-muted-foreground hover:text-foreground"
>
Orders
</Link>
<Link
to="/"
className="text-muted-foreground hover:text-foreground"
>
Products
</Link>
<Link
to="/"
className="text-muted-foreground hover:text-foreground"
>
Customers
</Link>
<Link
to="/"
className="text-muted-foreground hover:text-foreground"
>
Analytics
</Link>
</nav>
</SheetContent>
</Sheet>
<div className="flex w-full items-center justify-end gap-4 md:ml-auto md:gap-2 lg:gap-4">
<UserDropdown />
</div>
</header>
{children}
</div>
);
}

View File

@ -1,56 +1,158 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@affine/admin/components/ui/accordion';
import { buttonVariants } from '@affine/admin/components/ui/button';
import { cn } from '@affine/admin/utils';
import type { LucideIcon } from 'lucide-react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import {
ClipboardListIcon,
CpuIcon,
SettingsIcon,
UsersIcon,
} from 'lucide-react';
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useGetServerRuntimeConfig } from '../settings/use-get-server-runtime-config';
import { CollapsibleItem } from './collapsible-item';
import { useNav } from './context';
import { UserDropdown } from './user-dropdown';
export interface NavProp {
title: string;
to: string;
label?: string;
icon: LucideIcon;
}
const TabsMap: { [key: string]: string } = {
accounts: 'Accounts',
ai: 'AI',
config: 'Config',
settings: 'Settings',
};
const defaultTab = 'Accounts';
export function Nav() {
const { moduleList } = useGetServerRuntimeConfig();
const { activeTab, setActiveTab, setCurrentModule } = useNav();
useEffect(() => {
const path = window.location.pathname;
for (const key in TabsMap) {
if (path.includes(key)) {
setActiveTab(TabsMap[key]);
return;
}
}
setActiveTab(defaultTab);
}, [setActiveTab]);
export function Nav({
links,
activeTab,
}: {
links: NavProp[];
activeTab: string;
}) {
return (
<div className="group flex flex-col gap-4 py-2 justify-between flex-grow">
<nav className="grid gap-1 px-2">
{links.map((link, index) => (
<div className="flex flex-col gap-4 py-2 justify-between flex-grow overflow-hidden">
<nav className="flex flex-col gap-1 px-2 flex-grow overflow-hidden">
<Link
key={index}
to={link.to}
to={'/admin/accounts'}
className={cn(
buttonVariants({
variant: activeTab === link.title ? 'default' : 'ghost',
variant: activeTab === 'Accounts' ? 'default' : 'ghost',
size: 'sm',
}),
activeTab === link.title &&
activeTab === 'Accounts' &&
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
'justify-start'
'justify-start',
'flex-none'
)}
>
<link.icon className="mr-2 h-4 w-4" />
{link.title}
{link.label && (
<span
className={cn(
'ml-auto',
activeTab === link.title && 'text-background dark:text-white'
)}
>
{link.label}
</span>
)}
<UsersIcon className="mr-2 h-4 w-4" />
Accounts
</Link>
<Link
to={'/admin/ai'}
className={cn(
buttonVariants({
variant: activeTab === 'AI' ? 'default' : 'ghost',
size: 'sm',
}),
activeTab === 'AI' &&
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
'justify-start',
'flex-none'
)}
>
<CpuIcon className="mr-2 h-4 w-4" />
AI
</Link>
<Link
to={'/admin/config'}
className={cn(
buttonVariants({
variant: activeTab === 'Config' ? 'default' : 'ghost',
size: 'sm',
}),
activeTab === 'Config' &&
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
'justify-start',
'flex-none'
)}
>
<ClipboardListIcon className="mr-2 h-4 w-4" />
Config
</Link>
<Accordion type="multiple" className="w-full h-full overflow-hidden">
<AccordionItem
value="item-1"
className="border-b-0 h-full flex flex-col gap-1"
>
<Link to={'/admin/settings'}>
<AccordionTrigger
className={cn(
buttonVariants({
variant: activeTab === 'Settings' ? 'default' : 'ghost',
size: 'sm',
}),
activeTab === 'Settings' &&
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
'justify-between',
'hover:no-underline'
)}
>
<div className="flex items-center">
<SettingsIcon className="mr-2 h-4 w-4" />
<span>Settings</span>
</div>
</AccordionTrigger>
</Link>
<AccordionContent className="h-full overflow-hidden w-full">
<ScrollAreaPrimitive.Root
className={cn('relative overflow-hidden w-full h-full')}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block">
{moduleList.map(module => (
<CollapsibleItem
key={module.moduleName}
items={module.keys}
title={module.moduleName}
changeModule={setCurrentModule}
/>
))}
</ScrollAreaPrimitive.Viewport>
<ScrollAreaPrimitive.ScrollAreaScrollbar
className={cn(
'flex touch-none select-none transition-colors',
'h-full w-2.5 border-l border-l-transparent p-[1px]'
)}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
</AccordionContent>
</AccordionItem>
</Accordion>
</nav>
<UserDropdown />
</div>
);

View File

@ -67,7 +67,7 @@ export function UserDropdown() {
}, [currentUser, navigate, serverConfig.initialized]);
return (
<div className="flex items-center justify-between px-4 py-3 flex-nowrap">
<div className="flex flex-none items-center justify-between px-4 py-3 flex-nowrap">
<div className="flex items-center gap-2 font-medium text-ellipsis break-words overflow-hidden">
<Avatar className="w-6 h-6">
<AvatarImage src={currentUser?.avatarUrl ?? undefined} />

View File

@ -0,0 +1,81 @@
import { Button } from '@affine/admin/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
import type { ModifiedValues } from './index';
export const ConfirmChanges = ({
open,
onClose,
onConfirm,
onOpenChange,
modifiedValues,
}: {
open: boolean;
onClose: () => void;
onConfirm: () => void;
onOpenChange: (open: boolean) => void;
modifiedValues: ModifiedValues[];
}) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:w-[460px]">
<DialogHeader>
<DialogTitle className="leading-7">
Save Runtime Configurations ?
</DialogTitle>
<DialogDescription className="leading-6">
Are you sure you want to save the following changes?
</DialogDescription>
</DialogHeader>
{modifiedValues.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}:{' '}
<span
className="mr-2 line-through "
style={{
color: 'rgba(198, 34, 34, 1)',
backgroundColor: 'rgba(254, 213, 213, 1)',
}}
>
{JSON.stringify(expiredValue)}
</span>
<span
style={{
color: 'rgba(20, 147, 67, 1)',
backgroundColor: 'rgba(225, 250, 177, 1)',
}}
>
{JSON.stringify(newValue)}
</span>
,
</p>
))}
<p>{'}'}</p>
</pre>
) : (
'There is no change.'
)}
<DialogFooter>
<div className="flex justify-end items-center w-full space-x-4">
<Button type="button" onClick={onClose} variant="outline">
<span>Cancel</span>
</Button>
<Button type="button" onClick={onConfirm}>
<span>Save</span>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,193 @@
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 { CheckIcon } from 'lucide-react';
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Layout } from '../layout';
import { useNav } from '../nav/context';
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;
};
export function Settings() {
return <Layout content={<SettingsPage />} />;
}
export function SettingsPage() {
const { trigger } = useUpdateServerRuntimeConfigs();
const { serverRuntimeConfig } = useGetServerRuntimeConfig();
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(() => {
if (disableSave) {
return;
}
handleSave();
onClose();
}, [disableSave, handleSave, onClose]);
return (
<div className=" h-screen flex-1 flex-col flex">
<div className="flex items-center justify-between px-6 py-3 max-md:ml-9">
<div className="text-base font-medium">Settings</div>
<Button
type="submit"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={onOpen}
disabled={disableSave}
>
<CheckIcon size={20} />
</Button>
</div>
<Separator />
<AdminPanel
configValues={configValues}
setConfigValues={setConfigValues}
/>
<ConfirmChanges
modifiedValues={modifiedValues}
open={open}
onOpenChange={setOpen}
onClose={onClose}
onConfirm={onConfirm}
/>
</div>
);
}
export const AdminPanel = ({
setConfigValues,
configValues,
}: {
setConfigValues: Dispatch<SetStateAction<Record<string, any>>>;
configValues: Record<string, any>;
}) => {
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">
{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>
);
})}
</div>
</ScrollArea>
);
};
export { Settings as Component };

View File

@ -0,0 +1,39 @@
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"
id={id}
>
<div className="flex flex-col gap-1">
<div className="text-base font-bold">{description}</div>
<div className="">
<code className="text-xs bg-zinc-100 text-gray-500 px-[4px] py-[2px] rounded">
{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>
);
};

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